create-agent-rig 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +207 -8
  2. package/README.md +50 -17
  3. package/package.json +2 -1
  4. package/packages/cli/dist/commands/create.js +8 -3
  5. package/packages/cli/dist/commands/init.js +82 -34
  6. package/packages/cli/dist/commands/upgrade.js +112 -30
  7. package/packages/cli/dist/index.js +38 -14
  8. package/packages/cli/dist/lib/copy-tree.js +35 -6
  9. package/packages/cli/dist/lib/init-settings.js +12 -0
  10. package/packages/cli/dist/lib/install-set.js +6 -8
  11. package/packages/cli/dist/lib/manifest.js +21 -9
  12. package/packages/cli/dist/lib/safe-path.js +30 -0
  13. package/templates/agent-os/init/AGENTS.md +191 -0
  14. package/templates/agent-os/init/CLAUDE.md +61 -9
  15. package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +98 -0
  16. package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
  17. package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +34 -3
  18. package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
  19. package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +34 -0
  20. package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
  21. package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +233 -0
  22. package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +676 -0
  23. package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
  24. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
  25. package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
  26. package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +338 -0
  27. package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
  28. package/templates/agent-os/universal/.claude/agents/code-reviewer.md +33 -1
  29. package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +64 -1
  30. package/templates/agent-os/universal/.claude/agents/security-scanner.md +33 -0
  31. package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +274 -24
  32. package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
  33. package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
  34. package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
  35. package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
  36. package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
  37. package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +480 -0
  38. package/templates/agent-os/universal/.claude/rules/autonomy.md +61 -1
  39. package/templates/agent-os/universal/.claude/rules/invariants.md +71 -16
  40. package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
  41. package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1242 -0
  42. package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
  43. package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
  44. package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
  45. package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +433 -0
  46. package/templates/agent-os/universal/.claude/scripts/preflight.mjs +6 -16
  47. package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +89 -0
  48. package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +334 -23
  49. package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
  50. package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +7 -2
  51. package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +413 -8
  52. package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +75 -13
  53. package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +222 -21
  54. package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
  55. package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
  56. package/templates/agent-os/universal/.claude/scripts/run-state.mjs +441 -0
  57. package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -0
  58. package/templates/agent-os/universal/.claude/settings.json +7 -2
  59. package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +115 -7
  60. package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +378 -31
  61. package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +300 -25
  62. package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
  63. package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
  64. package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
  65. package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
  66. package/templates/agent-os/universal/.codex/hooks.json +65 -0
  67. package/templates/agent-os/universal/AGENTS.md +164 -0
  68. package/templates/agent-os/universal/CLAUDE.md +47 -14
  69. package/templates/agent-os/universal/PLAN.md +7 -40
  70. package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
  71. package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
  72. package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
  73. package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
  74. package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
  75. package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
  76. package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
  77. package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +62 -0
  78. package/templates/agent-os/universal/journal/README.md +101 -0
  79. package/templates/agent-os/universal/layers.json +36 -2
  80. package/templates/hash-history.json +2 -1
  81. package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
  82. package/templates/skeleton/aws-serverless/README.md +91 -9
  83. package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
  84. package/templates/skeleton/aws-serverless/gitignore +37 -0
  85. package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
  86. package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
  87. package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
  88. package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
  89. package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
  90. package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
  91. package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
  92. package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
  93. package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
  94. package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
  95. package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
  96. package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
  97. package/templates/skeleton/node-service/README.md +11 -1
  98. package/templates/skeleton/node-service/gitignore +34 -0
  99. package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
  100. package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
  101. package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
  102. package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
  103. package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
  104. package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
  105. package/templates/skeleton/node-service/services/api/test/static-dir.test.ts +28 -0
@@ -87,7 +87,17 @@ diagnose second.
87
87
  layer is mandatory even when it looks like ceremony.
88
88
  - `packages/core` is pure — the `guard-core-purity` hook refuses I/O, clock,
89
89
  randomness, and environment access at the tool layer.
90
- - `packages/db` is the only module that touches the stored data.
90
+ - `packages/db` is the only module that touches the stored data. It is a
91
+ **single-instance store**: writes serialise inside one `JsonFileNoteStore`
92
+ object, and that is the whole of it. Two stores over the same file lose
93
+ notes to each other whether they sit in one process or two — share the one
94
+ instance, and reach for a real lock before you share the file.
95
+ A hard kill between the temp write and the rename leaves a `*.tmp` file
96
+ behind; nothing sweeps them.
97
+ - `POST /notes` buffers at most **1 MB** of request body and answers `413` past
98
+ that. The cap lives in that route, not in the shell: a new route that reads a
99
+ body adds its own. Raise it in `services/api/src/server.ts` if your payloads
100
+ are bigger; do not remove it.
91
101
  - A failing queue message is poison: it throws, the spool retries ×3, then the
92
102
  DLQ gets it and the ALARM line fires. Never wrap the worker in a broad catch.
93
103
 
@@ -9,10 +9,44 @@ out/
9
9
  next-env.d.ts
10
10
  dist/
11
11
  .claude/worktrees/
12
+ # per-checkout queue state: the tier the last close recorded. The queue CONFIG
13
+ # is composed and tracked; this is runtime state and must never be committed.
14
+ .claude/queue.state.json
15
+ # gate rounds, one count per branch
16
+ .claude/gate-rounds.json
17
+ # per-run machine trace (.claude/scripts/run-journal.mjs): forensics that churn
18
+ # on every gate and collide across concurrent sessions. Unlike a committed
19
+ # evidence directory, this one is per-run and stays out of the repository.
20
+ .claude/runs/
12
21
 
13
22
  # secrets — never commit these
23
+ *.env
14
24
  .env
15
25
  .env.*
16
26
  !.env.example
27
+ .envrc
28
+ .npmrc
29
+ .netrc
30
+ .pgpass
31
+ id_rsa
32
+ id_ed25519
17
33
  *.pem
18
34
  *.key
35
+ *.p12
36
+ *.pfx
37
+ *.keystore
38
+ *.jks
39
+ *.secret
40
+ *.secrets
41
+ *.token
42
+ *.tokens
43
+ *.password
44
+ *.passwords
45
+ *.credential
46
+ *.credentials
47
+ *.apikey
48
+ *.apikeys
49
+ *.passwd
50
+ *.creds
51
+ *.jwt
52
+ *.bearer
@@ -1,7 +1,8 @@
1
1
  // The model boundary, same contract as any other target — only the medium
2
2
  // differs: one JSON file. This is the ONLY module that knows how notes are
3
3
  // persisted; nothing else composes a path into the data file.
4
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
5
6
  import path from 'node:path';
6
7
  import { NoteSchema, type Note } from '@app/core';
7
8
  import { AppError, NotFoundError } from '@app/shared';
@@ -9,15 +10,28 @@ import { AppError, NotFoundError } from '@app/shared';
9
10
  type NoteTable = Record<string, unknown>;
10
11
 
11
12
  export class JsonFileNoteStore {
13
+ /**
14
+ * Writes run one at a time — **per instance, which is the whole extent of
15
+ * it**. A write is load → mutate → save, and that is not atomic: two
16
+ * concurrent `put()`s would both read the same table and the second save
17
+ * would drop the first note. This chain serialises the ones going through
18
+ * *this* object. Two `JsonFileNoteStore`s over the same file lose notes to
19
+ * each other exactly as before — in one process as readily as in two — so
20
+ * share the instance, and reach for a real lock before sharing the file.
21
+ */
22
+ private writes: Promise<unknown> = Promise.resolve();
23
+
12
24
  constructor(private readonly file: string) {}
13
25
 
14
26
  async put(note: Note): Promise<void> {
15
- const table = await this.load();
16
- if (note.id in table) {
17
- throw new AppError(`note ${note.id} already exists`, { code: 'CONFLICT', statusCode: 409 });
18
- }
19
- table[note.id] = note;
20
- await this.save(table);
27
+ return this.serialised(async () => {
28
+ const table = await this.load();
29
+ if (note.id in table) {
30
+ throw new AppError(`note ${note.id} already exists`, { code: 'CONFLICT', statusCode: 409 });
31
+ }
32
+ table[note.id] = note;
33
+ await this.save(table);
34
+ });
21
35
  }
22
36
 
23
37
  async get(id: string): Promise<Note> {
@@ -38,6 +52,20 @@ export class JsonFileNoteStore {
38
52
  .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
39
53
  }
40
54
 
55
+ /** Queues `work` behind the writes already in flight on this instance. */
56
+ private serialised<T>(work: () => Promise<T>): Promise<T> {
57
+ const result = this.writes.then(work);
58
+ // The caller gets the rejection; the chain gets a settled promise. Without
59
+ // this line one failed write (a 409, say) would reject every write queued
60
+ // behind it, and this store object would stay broken for as long as it is
61
+ // held — the chain is per instance, so a fresh store would still work.
62
+ this.writes = result.then(
63
+ () => undefined,
64
+ () => undefined,
65
+ );
66
+ return result;
67
+ }
68
+
41
69
  private async load(): Promise<NoteTable> {
42
70
  let content: string;
43
71
  try {
@@ -56,8 +84,17 @@ export class JsonFileNoteStore {
56
84
  private async save(table: NoteTable): Promise<void> {
57
85
  await mkdir(path.dirname(this.file), { recursive: true });
58
86
  // Write-then-rename keeps readers from ever seeing a half-written file.
59
- const tmp = `${this.file}.tmp`;
60
- await writeFile(tmp, JSON.stringify(table, null, 2));
61
- await rename(tmp, this.file);
87
+ // The temp name is unique per write: a shared one lets two writers scribble
88
+ // over each other's file and lose a rename to ENOENT.
89
+ const tmp = `${this.file}.${process.pid}.${randomUUID()}.tmp`;
90
+ try {
91
+ await writeFile(tmp, JSON.stringify(table, null, 2));
92
+ await rename(tmp, this.file);
93
+ } catch (error) {
94
+ // Best-effort tidy-up: a failure to remove the temp file must not
95
+ // replace the failure that actually matters.
96
+ await rm(tmp, { force: true }).catch(() => undefined);
97
+ throw error;
98
+ }
62
99
  }
63
100
  }
@@ -40,6 +40,17 @@ describe('JsonFileNoteStore', () => {
40
40
  await expect(store.put(note)).rejects.toMatchObject({ statusCode: 409 });
41
41
  });
42
42
 
43
+ it('a rejected write does not block the writes queued behind it', async () => {
44
+ // The 409 above cannot see this: a poisoned write chain rejects the next
45
+ // put() with the very same AppError the duplicate would have raised.
46
+ const store = new JsonFileNoteStore(file);
47
+ await store.put(note);
48
+ await expect(store.put(note)).rejects.toThrow(AppError);
49
+
50
+ await expect(store.put({ ...note, id: 'n2' })).resolves.toBeUndefined();
51
+ expect((await store.list()).map((n) => n.id).sort()).toEqual(['n1', 'n2']);
52
+ });
53
+
43
54
  it('throws NotFoundError on a miss', async () => {
44
55
  await expect(new JsonFileNoteStore(file).get('nope')).rejects.toThrow(NotFoundError);
45
56
  });
@@ -69,6 +80,15 @@ describe('JsonFileNoteStore', () => {
69
80
  expect(listed.map((n) => n.id)).toEqual(['b', 'a']);
70
81
  });
71
82
 
83
+ it('keeps every concurrently written note (no lost update)', async () => {
84
+ const store = new JsonFileNoteStore(file);
85
+ const ids = Array.from({ length: 20 }, (_, i) => `n${i}`);
86
+ await Promise.all(ids.map((id) => store.put({ ...note, id })));
87
+
88
+ expect((await store.list()).map((n) => n.id).sort()).toEqual([...ids].sort());
89
+ expect(Object.keys(JSON.parse(await readFile(file, 'utf8'))).sort()).toEqual([...ids].sort());
90
+ });
91
+
72
92
  it('refuses to list corrupt entries instead of skipping them silently', async () => {
73
93
  const store = new JsonFileNoteStore(file);
74
94
  await store.put(note);
@@ -7,17 +7,10 @@ import { JsonFileNoteStore } from '@app/db';
7
7
  import { createLogger, loadEnv } from '@app/shared';
8
8
  import { SpoolEventPublisher } from './adapters/spool-publisher.js';
9
9
  import { makeServer } from './server.js';
10
+ import { defaultStaticDirFor } from './static-dir.js';
10
11
 
11
12
  // The built web bundle (pnpm build:web) lives here; served when present.
12
- const defaultStaticDir = path.resolve(
13
- path.dirname(new URL(import.meta.url).pathname),
14
- '..',
15
- '..',
16
- '..',
17
- 'apps',
18
- 'web',
19
- 'out',
20
- );
13
+ const defaultStaticDir = defaultStaticDirFor(import.meta.url);
21
14
 
22
15
  const env = loadEnv(
23
16
  z.object({
@@ -2,7 +2,7 @@
2
2
  // configured) serving the built web bundle. No second runtime for the
3
3
  // frontend — the same process serves the static export.
4
4
  import { createReadStream } from 'node:fs';
5
- import { stat } from 'node:fs/promises';
5
+ import { realpath, stat } from 'node:fs/promises';
6
6
  import { createServer, type Server } from 'node:http';
7
7
  import path from 'node:path';
8
8
  import type { CreateNoteDeps } from './usecases/create-note.js';
@@ -18,6 +18,14 @@ export interface ServerOptions {
18
18
  staticDir?: string;
19
19
  }
20
20
 
21
+ /**
22
+ * Largest request body `POST /notes` will buffer — that route and no other:
23
+ * the cap is applied where the body is read, so a second route that reads one
24
+ * brings its own. A note is a title and some tags; anything past this is a
25
+ * mistake or an attack, and either way the process must not hold it in memory.
26
+ */
27
+ const MAX_BODY_BYTES = 1024 * 1024;
28
+
21
29
  const CONTENT_TYPES: Record<string, string> = {
22
30
  '.html': 'text/html; charset=utf-8',
23
31
  '.css': 'text/css',
@@ -39,11 +47,34 @@ export function makeServer(deps: ServerDeps, options: ServerOptions = {}): Serve
39
47
  response.end(result.body);
40
48
  };
41
49
  const notFound = () => respond({ statusCode: 404, body: JSON.stringify({ error: 'not found' }) });
50
+ const badRequest = () =>
51
+ respond({ statusCode: 400, body: JSON.stringify({ error: 'bad request' }) });
42
52
 
43
53
  if (request.method === 'POST' && request.url === '/notes') {
44
54
  const chunks: Buffer[] = [];
45
- request.on('data', (chunk: Buffer) => chunks.push(chunk));
55
+ let size = 0;
56
+ let refused = false;
57
+ // A socket that dies mid-upload emits 'error'; unhandled, it takes the
58
+ // process with it.
59
+ request.on('error', () => {
60
+ refused = true;
61
+ });
62
+ request.on('data', (chunk: Buffer) => {
63
+ if (refused) return;
64
+ size += chunk.length;
65
+ if (size > MAX_BODY_BYTES) {
66
+ refused = true;
67
+ chunks.length = 0;
68
+ respond({ statusCode: 413, body: JSON.stringify({ error: 'payload too large' }) });
69
+ // Keep draining rather than destroying the socket: a client that is
70
+ // still uploading has to stay connected to read the refusal.
71
+ request.resume();
72
+ return;
73
+ }
74
+ chunks.push(chunk);
75
+ });
46
76
  request.on('end', () => {
77
+ if (refused) return;
47
78
  void createNote(Buffer.concat(chunks).toString('utf8')).then(respond);
48
79
  });
49
80
  return;
@@ -55,7 +86,21 @@ export function makeServer(deps: ServerDeps, options: ServerOptions = {}): Serve
55
86
  }
56
87
 
57
88
  if (request.method === 'GET' && options.staticDir) {
58
- void serveStatic(options.staticDir, request.url ?? '/', response, notFound);
89
+ // The rejection handler is the point: without it a throw in here is an
90
+ // unhandled rejection, which ends the process on one malformed request.
91
+ void serveStatic(options.staticDir, request.url ?? '/', response, notFound, badRequest).catch(
92
+ (error: unknown) => {
93
+ // Say nothing to the client beyond the code, but never swallow it —
94
+ // a static path that fails silently is a blind spot in every
95
+ // generated project.
96
+ deps.log.error('static serve failed', { url: request.url, error: String(error) });
97
+ if (response.headersSent) {
98
+ response.destroy();
99
+ return;
100
+ }
101
+ respond({ statusCode: 500, body: JSON.stringify({ error: 'internal error' }) });
102
+ },
103
+ );
59
104
  return;
60
105
  }
61
106
 
@@ -68,22 +113,60 @@ async function serveStatic(
68
113
  rawUrl: string,
69
114
  response: import('node:http').ServerResponse,
70
115
  notFound: () => void,
116
+ badRequest: () => void,
71
117
  ): Promise<void> {
72
- const pathname = decodeURIComponent(new URL(rawUrl, 'http://local').pathname);
118
+ let pathname: string;
119
+ try {
120
+ pathname = decodeURIComponent(new URL(rawUrl, 'http://local').pathname);
121
+ } catch {
122
+ // A malformed escape (`GET /%`) is the client's error, not ours.
123
+ return badRequest();
124
+ }
73
125
  const relative = pathname.endsWith('/') ? `${pathname}index.html` : pathname;
74
- const resolved = path.normalize(path.join(staticDir, relative));
126
+ // Resolve the root first: a configured dir with a trailing separator would
127
+ // otherwise build a `//` prefix that no resolved path can start with, and
128
+ // every request would 404.
129
+ const root = path.resolve(staticDir);
130
+ const resolved = path.resolve(root, `.${path.sep}${relative}`);
75
131
  // Path-traversal guard: whatever the URL said, we never leave staticDir.
76
- if (!resolved.startsWith(path.normalize(staticDir) + path.sep)) {
132
+ // The string test is only half of it — it says where the path points, not
133
+ // what the filesystem will open, and stat/open both follow symlinks.
134
+ if (!within(root, resolved)) {
77
135
  return notFound();
78
136
  }
137
+ let file: string;
79
138
  try {
80
- const stats = await stat(resolved);
139
+ // …so ask the filesystem too, and test the answer the same way. A missing
140
+ // file throws here, which is the 404 we would give it anyway.
141
+ file = await realpath(resolved);
142
+ if (!within(await realpath(root), file)) return notFound();
143
+ const stats = await stat(file);
81
144
  if (!stats.isFile()) return notFound();
82
145
  } catch {
83
146
  return notFound();
84
147
  }
85
- response.writeHead(200, {
86
- 'content-type': CONTENT_TYPES[path.extname(resolved)] ?? 'application/octet-stream',
148
+ // Open before the headers go out: a file that passes stat can still fail to
149
+ // open (EACCES, or it is gone by now), and after writeHead(200) there is no
150
+ // way left to say so — which is one GET away from an unhandled stream error.
151
+ const stream = createReadStream(file);
152
+ await new Promise<void>((resolve, reject) => {
153
+ stream.once('error', reject);
154
+ stream.once('open', () => {
155
+ stream.removeListener('error', reject);
156
+ response.writeHead(200, {
157
+ 'content-type': CONTENT_TYPES[path.extname(file)] ?? 'application/octet-stream',
158
+ });
159
+ // Past the headers the only honest answer to a read failure is to cut
160
+ // the response short, so the client sees a truncated body rather than a
161
+ // complete one.
162
+ stream.once('error', () => response.destroy());
163
+ stream.pipe(response);
164
+ resolve();
165
+ });
87
166
  });
88
- createReadStream(resolved).pipe(response);
167
+ }
168
+
169
+ /** Whether `candidate` is `root` itself or sits underneath it. */
170
+ function within(root: string, candidate: string): boolean {
171
+ return candidate === root || candidate.startsWith(root + path.sep);
89
172
  }
@@ -0,0 +1,20 @@
1
+ // Where the built web bundle lives, resolved relative to the composition root.
2
+ // A pure function rather than an inline expression in main.ts: main.ts is wiring
3
+ // that tests never import, and this resolution has a bug worth pinning —
4
+ // `new URL(url).pathname` leaves percent-escapes in place (a checkout under
5
+ // "/Users/a b/" resolves to "/Users/a%20b/") and prefixes a slash on Windows.
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ /** `apps/web/out`, three levels up from a module in `services/api/src`. */
10
+ export function defaultStaticDirFor(moduleUrl: string): string {
11
+ return path.resolve(
12
+ path.dirname(fileURLToPath(moduleUrl)),
13
+ '..',
14
+ '..',
15
+ '..',
16
+ 'apps',
17
+ 'web',
18
+ 'out',
19
+ );
20
+ }
@@ -1,6 +1,6 @@
1
1
  // Integration: the real HTTP server over a real socket, with real file
2
2
  // storage and a real spool directory — the whole request path at once.
3
- import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
3
+ import { chmod, mkdir, mkdtemp, readdir, rm, symlink, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import type { AddressInfo } from 'node:net';
6
6
  import path from 'node:path';
@@ -75,8 +75,43 @@ describe('the service end to end', () => {
75
75
  const { notes } = (await response.json()) as { notes: Array<{ title: string }> };
76
76
  expect(notes).toHaveLength(2);
77
77
  });
78
+
79
+ it('refuses a request body larger than the cap with 413', async () => {
80
+ // Well-formed JSON on purpose: what is rejected is the size, not the shape.
81
+ // The server must answer before it has buffered the whole upload, and must
82
+ // stay readable long enough for the client to see that answer.
83
+ const body = JSON.stringify({ title: 'x'.repeat(1024 * 1024 + 1024) });
84
+ const response = await fetch(`${baseUrl}/notes`, {
85
+ method: 'POST',
86
+ headers: { 'content-type': 'application/json' },
87
+ body,
88
+ });
89
+ expect(response.status).toBe(413);
90
+ });
78
91
  });
79
92
 
93
+ /** A listening server serving `staticDir`, plus its base URL. */
94
+ async function startStaticServer(staticDir: string): Promise<{ server: Server; base: string }> {
95
+ const server = makeServer(
96
+ {
97
+ notes: new JsonFileNoteStore(path.join(dir, 'data', 'notes.json')),
98
+ events: new SpoolEventPublisher(path.join(dir, 'queue'), () => 'm'),
99
+ newId: () => 'id',
100
+ now: () => '2024-01-01T00:00:00.000Z',
101
+ log: createLogger({}, () => {}),
102
+ },
103
+ { staticDir },
104
+ );
105
+ await new Promise<void>((resolve) => server.listen(0, resolve));
106
+ return { server, base: `http://127.0.0.1:${(server.address() as AddressInfo).port}` };
107
+ }
108
+
109
+ // Windows has no POSIX mode bits, and root reads a 0o000 file regardless — in
110
+ // both cases the "unreadable file" this test needs cannot be built at all.
111
+ const cannotMakeFileUnreadable = process.platform === 'win32' || process.getuid?.() === 0;
112
+ // Creating a symlink on Windows needs a privilege the test runner may not hold.
113
+ const cannotSymlink = process.platform === 'win32';
114
+
80
115
  describe('static serving (the built web bundle)', () => {
81
116
  let staticServer: Server;
82
117
  let staticBase: string;
@@ -84,18 +119,9 @@ describe('static serving (the built web bundle)', () => {
84
119
  beforeEach(async () => {
85
120
  await mkdir(path.join(dir, 'static'), { recursive: true });
86
121
  await writeFile(path.join(dir, 'static', 'index.html'), '<h1>web shell</h1>');
87
- staticServer = makeServer(
88
- {
89
- notes: new JsonFileNoteStore(path.join(dir, 'data', 'notes.json')),
90
- events: new SpoolEventPublisher(path.join(dir, 'queue'), () => 'm'),
91
- newId: () => 'id',
92
- now: () => '2024-01-01T00:00:00.000Z',
93
- log: createLogger({}, () => {}),
94
- },
95
- { staticDir: path.join(dir, 'static') },
96
- );
97
- await new Promise<void>((resolve) => staticServer.listen(0, resolve));
98
- staticBase = `http://127.0.0.1:${(staticServer.address() as AddressInfo).port}`;
122
+ ({ server: staticServer, base: staticBase } = await startStaticServer(
123
+ path.join(dir, 'static'),
124
+ ));
99
125
  });
100
126
 
101
127
  afterEach(async () => {
@@ -120,4 +146,63 @@ describe('static serving (the built web bundle)', () => {
120
146
  expect(response.status).toBe(200);
121
147
  expect(response.headers.get('content-type')).toContain('application/json');
122
148
  });
149
+
150
+ it('answers 400 to an undecodable URL and keeps serving afterwards', async () => {
151
+ // The signal is a leash, not a relaxation: a server that dies on this URL
152
+ // never answers at all, and the run should say so in seconds.
153
+ const response = await fetch(staticBase + '/%', { signal: AbortSignal.timeout(5000) });
154
+ expect(response.status).toBe(400);
155
+
156
+ const afterwards = await fetch(staticBase + '/');
157
+ expect(afterwards.status).toBe(200);
158
+ expect(await afterwards.text()).toContain('web shell');
159
+ });
160
+
161
+ it.skipIf(cannotMakeFileUnreadable)(
162
+ 'answers a file it cannot open and keeps serving afterwards',
163
+ async () => {
164
+ // A file that passes stat() but fails open() (EACCES here; in production
165
+ // the stat/open race gives ENOENT). Before the headers are held back
166
+ // until the stream opens, that error reached no handler at all and the
167
+ // process paid for one unreadable file.
168
+ const locked = path.join(dir, 'static', 'locked.txt');
169
+ await writeFile(locked, 'unreadable');
170
+ await chmod(locked, 0o000);
171
+ try {
172
+ const response = await fetch(staticBase + '/locked.txt', {
173
+ signal: AbortSignal.timeout(5000),
174
+ });
175
+ expect(response.status).toBe(500);
176
+
177
+ const afterwards = await fetch(staticBase + '/');
178
+ expect(afterwards.status).toBe(200);
179
+ expect(await afterwards.text()).toContain('web shell');
180
+ } finally {
181
+ // Restore before afterEach removes the temp dir.
182
+ await chmod(locked, 0o600);
183
+ }
184
+ },
185
+ );
186
+
187
+ it.skipIf(cannotSymlink)('refuses a symlink that escapes the static dir', async () => {
188
+ // A lexical guard inspects only the resolved string, while stat and the
189
+ // read stream follow the link wherever it points — which is why the guard
190
+ // asks realpath as well.
191
+ await writeFile(path.join(dir, 'secret-target.txt'), 'nope');
192
+ await symlink(path.join(dir, 'secret-target.txt'), path.join(dir, 'static', 'escape.txt'));
193
+
194
+ const response = await fetch(staticBase + '/escape.txt');
195
+ expect(response.status).toBe(404);
196
+ });
197
+
198
+ it('serves the bundle when the configured static dir has a trailing separator', async () => {
199
+ const { server, base } = await startStaticServer(path.join(dir, 'static') + path.sep);
200
+ try {
201
+ const response = await fetch(base + '/');
202
+ expect(response.status).toBe(200);
203
+ expect(await response.text()).toContain('web shell');
204
+ } finally {
205
+ await new Promise((resolve) => server.close(resolve));
206
+ }
207
+ });
123
208
  });
@@ -0,0 +1,28 @@
1
+ // The composition root resolves the built web bundle relative to itself; the
2
+ // resolution is a pure function so it can be tested without importing main.ts.
3
+ //
4
+ // What is NOT pinned here: the Windows half of the same defect — the old
5
+ // `new URL(url).pathname` yielded `/C:/…`. Both expressions are identical on a
6
+ // POSIX runner, so observing the difference would mean passing a path flavour
7
+ // into `defaultStaticDirFor`, i.e. a parameter that exists only for the test.
8
+ // The decision was to leave that hook out; percent-decoding below is the same
9
+ // bug's POSIX-visible half, and it fails against the old expression.
10
+ import path from 'node:path';
11
+ import { describe, expect, it } from 'vitest';
12
+ import { defaultStaticDirFor } from '../src/static-dir.js';
13
+
14
+ describe('resolving the default static dir from the module url', () => {
15
+ it('decodes percent-escapes in the path (a directory may contain a space)', () => {
16
+ const resolved = defaultStaticDirFor('file:///Users/a%20b/proj/services/api/src/main.ts');
17
+
18
+ expect(resolved).toContain('a b');
19
+ expect(resolved).not.toContain('%20');
20
+ });
21
+
22
+ it('points three levels up from services/api/src at apps/web/out', () => {
23
+ const resolved = defaultStaticDirFor('file:///Users/a%20b/proj/services/api/src/main.ts');
24
+
25
+ expect(resolved.endsWith(path.join('a b', 'proj', 'apps', 'web', 'out'))).toBe(true);
26
+ expect(path.isAbsolute(resolved)).toBe(true);
27
+ });
28
+ });