Some checks failed
CI / Lint, typecheck, test (push) Failing after 1m39s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m51s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 11s
Token-authenticated machine access at /api/public/v1 — the foundation for the built-in MCP endpoint (#105). Personal access tokens: - api_tokens table (SHA-256 hash, scope read|write, optional pond restriction, expiry, revocation, throttled last-used) + migration; secrets are dt_pat_<random>, shown exactly once - lifecycle endpoints under /users/me/api-tokens (session-only — a leaked token can never mint more tokens) with audit entries api.token_created/api.token_revoked - settings UI section (create with scope/expiry/pond restriction, one-time reveal with copy, list with status + revoke), de+en Activation (404 semantics per #60 on both levels): - instance setting api.enabled (default off, admin settings switch) - pond setting apiEnabled (default off, pond settings toggle; the PondsService settings-merge learned the key — the #92 lesson) Surface (/api/public/v1, excluded from the SPA's global prefix): - me, ponds, pages (list/read as Markdown+HTML, create from Markdown via the shared pipeline, PATCH title/content, DELETE to trash), search (permission-filtered + narrowed to exposed ponds, highlights as **…**), markdown ZIP export, labels (tree, create/rename/recolour/move/delete, assign/unassign), comments (threads, create, resolve/reopen) - content replacement travels the collab-owned document path: the new state lands as a MANUAL version "API update", then the established restore NOTIFY applies it — open editors converge, history stays append-only, no second lineage (VersionsService.replaceContent) - hand-maintained OpenAPI 3.1 document at /openapi.json, pinned to the controller by a route-coverage test in both directions Enforcement: - PublicApiGuard: instance switch → bearer PAT auth (request.user is the token's user) → per-token rate limit (429 + Retry-After) → scope (403 scope_required) → pond opt-in + token restriction - the shared PermissionGuard then applies the unchanged permission model; PageParamSource gained pondSlugParam for the slug+slug routes - no cookies anywhere → no CSRF surface (pinned by a hostile-Origin test) - every write audit-logged as api.write with the token attributed Tests/verification: - 12-test e2e pack: lifecycle, switches, permission matrix (reader/editor/outsider × scopes), restriction, page roundtrip incl. restore-NOTIFY assertion, labels, comments incl. policy, search narrowing, ZIP export, rate limit; full api suite 60/60 green (quota fixture via per-user override — never the instance default) - new collab-pack test proves an open editor converges onto an API content replacement (green against a local seeded stack) - UI smoke against the built SPA: token create/reveal/revoke, pond opt-in persists, admin switch persists (10/10) - docs/self-hosting/public-api.md + README link Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
66 lines
2.7 KiB
TypeScript
66 lines
2.7 KiB
TypeScript
import 'reflect-metadata';
|
|
|
|
import { METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
|
|
import { RequestMethod } from '@nestjs/common';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import { buildOpenApiDocument } from './openapi';
|
|
import { PublicApiController } from './public-api.controller';
|
|
|
|
/**
|
|
* The OpenAPI document is maintained by hand (openapi.ts) — this test walks
|
|
* the controller's real routes and asserts each one is described, so the
|
|
* document cannot silently drift from the implementation (issue #104
|
|
* acceptance criterion), and nothing documented is stale.
|
|
*/
|
|
describe('public api OpenAPI document', () => {
|
|
const verbs: Record<number, string> = {
|
|
[RequestMethod.GET]: 'get',
|
|
[RequestMethod.POST]: 'post',
|
|
[RequestMethod.PUT]: 'put',
|
|
[RequestMethod.PATCH]: 'patch',
|
|
[RequestMethod.DELETE]: 'delete',
|
|
};
|
|
|
|
function controllerRoutes(): { path: string; verb: string }[] {
|
|
const prototype = PublicApiController.prototype as unknown as Record<string, unknown>;
|
|
const routes: { path: string; verb: string }[] = [];
|
|
for (const name of Object.getOwnPropertyNames(prototype)) {
|
|
if (name === 'constructor') continue;
|
|
const handler = prototype[name];
|
|
if (typeof handler !== 'function') continue;
|
|
const method = Reflect.getMetadata(METHOD_METADATA, handler) as number | undefined;
|
|
if (method === undefined) continue;
|
|
const raw = Reflect.getMetadata(PATH_METADATA, handler) as string;
|
|
// Nest `:param` → OpenAPI `{param}`; the controller base is the server url.
|
|
const path = `/${raw}`.replace(/\/+/g, '/').replace(/:([A-Za-z0-9_]+)/g, '{$1}');
|
|
routes.push({ path, verb: verbs[method]! });
|
|
}
|
|
return routes;
|
|
}
|
|
|
|
it('describes every controller route and nothing else', () => {
|
|
const document = buildOpenApiDocument() as {
|
|
paths: Record<string, Record<string, unknown>>;
|
|
};
|
|
const documented = new Set(
|
|
Object.entries(document.paths).flatMap(([path, methods]) =>
|
|
Object.keys(methods).map((verb) => `${verb} ${path}`),
|
|
),
|
|
);
|
|
const implemented = new Set(controllerRoutes().map(({ verb, path }) => `${verb} ${path}`));
|
|
|
|
expect([...implemented].filter((route) => !documented.has(route))).toEqual([]);
|
|
expect([...documented].filter((route) => !implemented.has(route))).toEqual([]);
|
|
});
|
|
|
|
it('declares bearer security and the versioned server url', () => {
|
|
const document = buildOpenApiDocument() as {
|
|
servers: { url: string }[];
|
|
components: { securitySchemes: Record<string, unknown> };
|
|
};
|
|
expect(document.servers[0]!.url).toBe('/api/public/v1');
|
|
expect(document.components.securitySchemes.pat).toBeDefined();
|
|
});
|
|
});
|