Phase 0: repo skeleton, image, compose stacks, doctor, migration runner, frontend shell
New repo for the Packet Station rebuild (Python 3.12, FastAPI, PostgreSQL 16, React, VisualKit, MFilesKit, Docker Compose on docker-1). Nothing is migrated from the M: install. - CLAUDE.md with the safety rules, README, CHANGELOG, docs (BRIEF, PLAN, SECURITY rewritten for the server with the requirement ID table, DEPLOY, stubs for FEED, POLICY, SCHEMA, GO-LIVE, PAPER-FALLBACK). - packetstation CLI: app, worker (heartbeat stub), migrate, doctor, version, healthcheck. Settings from the environment with secret files. Stack guard (test never points at VISUAL production). Security headers and CSP. Trusted proxy resolved by name. JSON logs with redaction. - Migration runner and 0001_base: settings, append-only audit_events with hash chain, ps_notify, grants for ps_app. - Multi-stage Dockerfile (ODBC Driver 18, PESCO libraries from the Gitea index only), compose base, proxy overlay for npm_default, CI overlay, postgres initdb roles, env examples, NPM proxy host notes, Gitea Actions. - Frontend: Vite, React, TypeScript, the PESCO dark theme ported with self-hosted fonts, placeholder sign-in and home. - Verified on docker-1: image builds, CI stack comes up healthy, doctor database checks pass, 77 server tests pass against PostgreSQL. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mockFetch } from "../test/fetchMock";
|
||||
import { ApiError, REQUESTED_WITH, api, request } from "./client";
|
||||
|
||||
describe("api client", () => {
|
||||
it("returns parsed JSON on success", async () => {
|
||||
mockFetch({ "/api/thing": { body: { a: 1 } } });
|
||||
await expect(api.get<{ a: number }>("/api/thing")).resolves.toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it("sends same-origin requests with credentials and no CSRF header on GET", async () => {
|
||||
const fetch = mockFetch({ "/api/thing": { body: {} } });
|
||||
await api.get("/api/thing");
|
||||
const init = fetch.mock.calls[0]?.[1];
|
||||
expect(init?.credentials).toBe("include");
|
||||
expect(init?.mode).toBe("same-origin");
|
||||
expect((init?.headers as Record<string, string>)["X-Requested-With"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends X-Requested-With and a JSON body on state-changing requests", async () => {
|
||||
const fetch = mockFetch({ "/api/thing": { status: 201, body: { id: 7 } } });
|
||||
await expect(api.post("/api/thing", { name: "x" })).resolves.toEqual({ id: 7 });
|
||||
const init = fetch.mock.calls[0]?.[1];
|
||||
const headers = init?.headers as Record<string, string>;
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(headers["X-Requested-With"]).toBe(REQUESTED_WITH);
|
||||
expect(headers["Content-Type"]).toBe("application/json");
|
||||
expect(init?.body).toBe(JSON.stringify({ name: "x" }));
|
||||
});
|
||||
|
||||
it("throws ApiError with status, body and the FastAPI detail as message", async () => {
|
||||
mockFetch({ "/api/locked": { status: 409, body: { detail: "Packet is locked by J. Smith" } } });
|
||||
const err = await request("/api/locked", { method: "DELETE" }).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(ApiError);
|
||||
const apiErr = err as ApiError;
|
||||
expect(apiErr.status).toBe(409);
|
||||
expect(apiErr.body).toEqual({ detail: "Packet is locked by J. Smith" });
|
||||
expect(apiErr.message).toBe("Packet is locked by J. Smith");
|
||||
});
|
||||
|
||||
it("keeps a plain-text error body", async () => {
|
||||
mockFetch({ "/api/boom": { status: 502, body: "Bad gateway" } });
|
||||
await expect(api.get("/api/boom")).rejects.toMatchObject({ status: 502, body: "Bad gateway" });
|
||||
});
|
||||
|
||||
it("handles an empty error body", async () => {
|
||||
mockFetch({ "/api/gone": { status: 500 } });
|
||||
await expect(api.get("/api/gone")).rejects.toMatchObject({ status: 500, body: null, message: "Request failed (500)" });
|
||||
});
|
||||
|
||||
it("turns a network failure into ApiError status 0", async () => {
|
||||
mockFetch({ "/api/down": new TypeError("Failed to fetch") });
|
||||
await expect(api.get("/api/down")).rejects.toMatchObject({ name: "ApiError", status: 0 });
|
||||
});
|
||||
|
||||
it("returns null for 204", async () => {
|
||||
mockFetch({ "/api/thing": { status: 204 } });
|
||||
await expect(api.del("/api/thing")).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("refuses absolute and protocol-relative URLs", async () => {
|
||||
mockFetch({});
|
||||
await expect(api.get("https://evil.example/api")).rejects.toThrow(TypeError);
|
||||
await expect(api.get("//evil.example/api")).rejects.toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
// Small fetch wrapper for the Packet Station API.
|
||||
// Same origin only. Cookies ride along. JSON in and out.
|
||||
|
||||
/** Thrown for any response that is not 2xx. Holds the status and the parsed body. */
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly body: unknown;
|
||||
|
||||
constructor(status: number, body: unknown, message?: string) {
|
||||
super(message ?? ApiError.describe(status, body));
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
/** Prefer the server's own message ({"detail": ...} from FastAPI), else the status. */
|
||||
private static describe(status: number, body: unknown): string {
|
||||
if (body && typeof body === "object" && "detail" in body) {
|
||||
const detail = (body as { detail: unknown }).detail;
|
||||
if (typeof detail === "string") return detail;
|
||||
}
|
||||
if (typeof body === "string" && body.trim() !== "") return body.trim().slice(0, 200);
|
||||
return status === 0 ? "Network error" : `Request failed (${status})`;
|
||||
}
|
||||
}
|
||||
|
||||
export type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
export interface RequestOptions {
|
||||
method?: Method;
|
||||
/** Sent as JSON. FormData and Blob are sent as they are. */
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const SAFE_METHODS: ReadonlySet<Method> = new Set<Method>(["GET"]);
|
||||
|
||||
/** The server checks this header on state-changing requests as a CSRF guard. */
|
||||
export const REQUESTED_WITH = "PacketStation";
|
||||
|
||||
/** Paths must be same-origin and absolute, like "/api/version". */
|
||||
function assertSameOrigin(path: string): void {
|
||||
if (!path.startsWith("/") || path.startsWith("//")) {
|
||||
throw new TypeError(`API path must start with a single "/": ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function readBody(res: Response): Promise<unknown> {
|
||||
if (res.status === 204 || res.status === 205) return null;
|
||||
const text = await res.text();
|
||||
if (text === "") return null;
|
||||
const type = res.headers.get("content-type") ?? "";
|
||||
if (type.includes("json")) {
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
assertSameOrigin(path);
|
||||
const method: Method = opts.method ?? "GET";
|
||||
const headers: Record<string, string> = { Accept: "application/json", ...opts.headers };
|
||||
if (!SAFE_METHODS.has(method)) headers["X-Requested-With"] = REQUESTED_WITH;
|
||||
|
||||
let body: BodyInit | undefined;
|
||||
if (opts.body instanceof FormData || opts.body instanceof Blob) {
|
||||
body = opts.body;
|
||||
} else if (opts.body !== undefined) {
|
||||
body = JSON.stringify(opts.body);
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
const init: RequestInit = { method, headers, credentials: "include", mode: "same-origin" };
|
||||
if (body !== undefined) init.body = body;
|
||||
if (opts.signal) init.signal = opts.signal;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, init);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") throw err;
|
||||
throw new ApiError(0, null, "Network error. Check the connection and try again.");
|
||||
}
|
||||
|
||||
const parsed = await readBody(res);
|
||||
if (!res.ok) throw new ApiError(res.status, parsed);
|
||||
return parsed as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string, signal?: AbortSignal) =>
|
||||
request<T>(path, signal ? { signal } : {}),
|
||||
post: <T>(path: string, body?: unknown) => request<T>(path, { method: "POST", body }),
|
||||
put: <T>(path: string, body?: unknown) => request<T>(path, { method: "PUT", body }),
|
||||
patch: <T>(path: string, body?: unknown) => request<T>(path, { method: "PATCH", body }),
|
||||
del: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||
};
|
||||
Reference in New Issue
Block a user