fix: final-review wave (CSRF guard, fail-closed owner bootstrap, streamed zips, e2e visibility suite)

- Critical 1 (CSRF): core wrap refuses every POST/PUT/PATCH/DELETE whose Origin
  (Referer as fallback) is not publicUrl's origin or STC_ALLOWED_ORIGINS, and
  any body that is not application/json (multipart only on declared upload
  routes). Better Auth routes go through the same guard; only declared
  identity-provider callbacks (SAML ACS/SLO, OAuth callbacks) are exempt.
  Better Auth trustedOrigins use the same list. Session cookie stays Lax
  (Strict breaks OAuth/OIDC/SAML returns); documented.
- Important 4 / learner minor 4: the first-owner claim and the role row are
  one transaction guarded by "no owner yet"; only a unique violation means
  learner; any other error fails the sign-up with 500 and removes the
  half-created user. Serialised per DB handle (Bun's SQLite connection).
- Important 2 / learner minor 12: code zips stream entry by entry (large
  files stored in 256 KB chunks with a data descriptor and incremental
  CRC-32), so memory stays at one chunk; the cap is now 1 GiB (ZIP32).
- Important 3 / learner minor 10: the e2e runs the production wiring
  (productFeatures order, AI drafts through the real sink) against a
  restricted course and asserts 404/401 for learner and anonymous on
  course, lesson, media, cover, code, zip, notes, progress, attempts, AI
  chat, library, search and AI status; a real Range request (206, 416);
  forged writes (foreign Origin, text/plain, no Origin) are 403 and the MCP
  test command never runs.
- Minors 7-9: compose passes every documented variable; the health check
  uses STC_PORT; productFeatures checks the studio-before-AI order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 15:42:17 +01:00
co-authored by Claude Opus 5.5
parent 69aaedcb43
commit 8045c80d93
24 changed files with 725 additions and 85 deletions
+12 -3
View File
@@ -1,6 +1,6 @@
import type { FeatureFactory, ServerContext } from "@stc/contracts";
import type { PackageMigrations } from "@stc/db";
import { aiFeature, createFsDraftSink, migrationsDirs as aiMigrations, type DraftSink } from "@stc/feature-ai";
import { aiFeature, createFsDraftSink, migrationsDirs as aiMigrations, type AiFeatureOptions, type DraftSink } from "@stc/feature-ai";
import { importFeature, migrationsDirs as importMigrations } from "@stc/feature-import";
import { learnerFeature, migrationsDirs as learnerMigrations, userIdentity } from "@stc/feature-learner";
import { createDraftWriter, migrationsDirs as studioMigrations, studioFeature, type StudioFeature } from "@stc/feature-studio";
@@ -30,15 +30,24 @@ export function aiDraftSink(ctx: ServerContext, studio: Pick<StudioFeature, "sto
export interface FeatureSetOptions {
/** Studio published-worktree polling (default on). */
studioWatch?: boolean;
/** Test seam: replaces AI provider construction (e.g. a canned provider). Production leaves it unset. */
aiProviderFactory?: AiFeatureOptions["providerFactory"];
}
/** The product: learner (auth, library, reader), importer, studio and AI, in that order. */
/**
* The product: learner (auth, library, reader), importer, studio and AI, in that order.
* Order matters: the AI factory needs the studio feature (its draft sink writes through the studio store), so the
* studio factory must run first. composeServer runs factories in list order; the AI factory checks this.
*/
export function productFeatures(opts: FeatureSetOptions = {}): FeatureFactory[] {
let studio: StudioFeature | null = null;
return [
learnerFeature,
(ctx) => importFeature(ctx),
(ctx) => (studio = studioFeature(ctx, { watch: opts.studioWatch ?? true })),
(ctx) => aiFeature(ctx, { draftSink: aiDraftSink(ctx, studio!) }),
(ctx) => {
if (!studio) throw new Error("productFeatures: the studio factory must run before the AI factory");
return aiFeature(ctx, { draftSink: aiDraftSink(ctx, studio), ...(opts.aiProviderFactory ? { providerFactory: opts.aiProviderFactory } : {}) });
},
];
}
+1
View File
@@ -28,6 +28,7 @@ export async function startServer(config: AppConfig, features: (Feature | Featur
return {
server,
ctx: composed.ctx,
features: composed.features,
async stop() {
await server.stop(true);
await composed.stop();