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.
- package/CHANGELOG.md +207 -8
- package/README.md +50 -17
- package/package.json +2 -1
- package/packages/cli/dist/commands/create.js +8 -3
- package/packages/cli/dist/commands/init.js +82 -34
- package/packages/cli/dist/commands/upgrade.js +112 -30
- package/packages/cli/dist/index.js +38 -14
- package/packages/cli/dist/lib/copy-tree.js +35 -6
- package/packages/cli/dist/lib/init-settings.js +12 -0
- package/packages/cli/dist/lib/install-set.js +6 -8
- package/packages/cli/dist/lib/manifest.js +21 -9
- package/packages/cli/dist/lib/safe-path.js +30 -0
- package/templates/agent-os/init/AGENTS.md +191 -0
- package/templates/agent-os/init/CLAUDE.md +61 -9
- package/templates/agent-os/stack/aws-cdk/.agents/skills/post-deploy-verify/SKILL.md +98 -0
- package/templates/agent-os/stack/aws-cdk/.agents/skills/ro-debug/SKILL.md +117 -0
- package/templates/agent-os/stack/aws-cdk/.claude/agents/cdk-diff-reviewer.md +34 -3
- package/templates/agent-os/stack/aws-cdk/.claude/rules/aws-cdk.md +1 -1
- package/templates/agent-os/stack/aws-cdk/.claude/skills/post-deploy-verify/SKILL.md +34 -0
- package/templates/agent-os/stack/aws-cdk/.codex/agents/cdk-diff-reviewer.toml +4 -0
- package/templates/agent-os/universal/.agents/skills/check-premises/SKILL.md +233 -0
- package/templates/agent-os/universal/.agents/skills/loop/SKILL.md +676 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/SKILL.md +102 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.mjs +78 -0
- package/templates/agent-os/universal/.agents/skills/new-invariant/guard-invariant.example.test.mjs +89 -0
- package/templates/agent-os/universal/.agents/skills/pr-ship/SKILL.md +338 -0
- package/templates/agent-os/universal/.agents/skills/worktree-task/SKILL.md +73 -0
- package/templates/agent-os/universal/.claude/agents/code-reviewer.md +33 -1
- package/templates/agent-os/universal/.claude/agents/prose-reviewer.md +64 -1
- package/templates/agent-os/universal/.claude/agents/security-scanner.md +33 -0
- package/templates/agent-os/universal/.claude/hooks/gate-stop-dod.mjs +274 -24
- package/templates/agent-os/universal/.claude/hooks/guard-bash.mjs +43 -5
- package/templates/agent-os/universal/.claude/hooks/guard-core-purity.mjs +25 -11
- package/templates/agent-os/universal/.claude/hooks/guard-secret-file.mjs +180 -0
- package/templates/agent-os/universal/.claude/hooks/guard-web-boundary.mjs +32 -13
- package/templates/agent-os/universal/.claude/hooks/inject-rules.mjs +175 -7
- package/templates/agent-os/universal/.claude/hooks/lib/edit-input.mjs +480 -0
- package/templates/agent-os/universal/.claude/rules/autonomy.md +61 -1
- package/templates/agent-os/universal/.claude/rules/invariants.md +71 -16
- package/templates/agent-os/universal/.claude/rules/workflow.md +55 -5
- package/templates/agent-os/universal/.claude/scripts/decision-router.mjs +1242 -0
- package/templates/agent-os/universal/.claude/scripts/detect-missed-gate.mjs +65 -2
- package/templates/agent-os/universal/.claude/scripts/git-env.mjs +49 -0
- package/templates/agent-os/universal/.claude/scripts/lib/secrets.mjs +490 -0
- package/templates/agent-os/universal/.claude/scripts/lib/verdict.mjs +433 -0
- package/templates/agent-os/universal/.claude/scripts/preflight.mjs +6 -16
- package/templates/agent-os/universal/.claude/scripts/queue/checkout.mjs +89 -0
- package/templates/agent-os/universal/.claude/scripts/queue/core.mjs +334 -23
- package/templates/agent-os/universal/.claude/scripts/queue/gate-rounds.mjs +160 -0
- package/templates/agent-os/universal/.claude/scripts/queue/github-issues.mjs +7 -2
- package/templates/agent-os/universal/.claude/scripts/queue/index.mjs +413 -8
- package/templates/agent-os/universal/.claude/scripts/queue/jira.mjs +75 -13
- package/templates/agent-os/universal/.claude/scripts/queue/plan-md.mjs +222 -21
- package/templates/agent-os/universal/.claude/scripts/queue/state.mjs +197 -0
- package/templates/agent-os/universal/.claude/scripts/run-journal.mjs +435 -0
- package/templates/agent-os/universal/.claude/scripts/run-state.mjs +441 -0
- package/templates/agent-os/universal/.claude/scripts/verdict.mjs +101 -0
- package/templates/agent-os/universal/.claude/settings.json +7 -2
- package/templates/agent-os/universal/.claude/skills/check-premises/SKILL.md +115 -7
- package/templates/agent-os/universal/.claude/skills/loop/SKILL.md +378 -31
- package/templates/agent-os/universal/.claude/skills/pr-ship/SKILL.md +300 -25
- package/templates/agent-os/universal/.codex/agents/code-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/prose-reviewer.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/security-scanner.toml +4 -0
- package/templates/agent-os/universal/.codex/agents/test-writer.toml +4 -0
- package/templates/agent-os/universal/.codex/hooks.json +65 -0
- package/templates/agent-os/universal/AGENTS.md +164 -0
- package/templates/agent-os/universal/CLAUDE.md +47 -14
- package/templates/agent-os/universal/PLAN.md +7 -40
- package/templates/agent-os/universal/docs/decisions/closing-a-task.md +98 -0
- package/templates/agent-os/universal/docs/decisions/codex-adapter.md +108 -0
- package/templates/agent-os/universal/docs/decisions/fail-open-guards.md +43 -0
- package/templates/agent-os/universal/docs/decisions/review-lanes.md +72 -0
- package/templates/agent-os/universal/docs/decisions/run-directory.md +44 -0
- package/templates/agent-os/universal/docs/decisions/spacing-rations-mechanisms.md +125 -0
- package/templates/agent-os/universal/docs/decisions/stop-conditions-in-a-file.md +46 -0
- package/templates/agent-os/universal/docs/decisions/two-empty-endings.md +62 -0
- package/templates/agent-os/universal/journal/README.md +101 -0
- package/templates/agent-os/universal/layers.json +36 -2
- package/templates/hash-history.json +2 -1
- package/templates/skeleton/aws-serverless/.github/workflows/deploy.yml +34 -1
- package/templates/skeleton/aws-serverless/README.md +91 -9
- package/templates/skeleton/aws-serverless/apps/web/src/lib/api.ts +9 -2
- package/templates/skeleton/aws-serverless/gitignore +37 -0
- package/templates/skeleton/aws-serverless/infra/bin/app.ts +73 -13
- package/templates/skeleton/aws-serverless/infra/lib/app-stack.ts +136 -4
- package/templates/skeleton/aws-serverless/infra/lib/web-stack.ts +14 -1
- package/templates/skeleton/aws-serverless/infra/test/allowed-origins.test.ts +301 -0
- package/templates/skeleton/aws-serverless/infra/test/app-composition.test.ts +137 -0
- package/templates/skeleton/aws-serverless/infra/test/app-stack.test.ts +36 -20
- package/templates/skeleton/aws-serverless/infra/test/web-stack.test.ts +15 -12
- package/templates/skeleton/aws-serverless/services/api/src/handlers/create-note.ts +33 -6
- package/templates/skeleton/aws-serverless/services/api/src/handlers/list-notes.ts +18 -5
- package/templates/skeleton/aws-serverless/services/api/src/usecases/create-note.ts +11 -0
- package/templates/skeleton/aws-serverless/services/api/test/create-note.handler.test.ts +120 -1
- package/templates/skeleton/aws-serverless/services/api/test/list-notes.test.ts +72 -1
- package/templates/skeleton/node-service/README.md +11 -1
- package/templates/skeleton/node-service/gitignore +34 -0
- package/templates/skeleton/node-service/packages/db/src/note-store.ts +47 -10
- package/templates/skeleton/node-service/packages/db/test/note-store.test.ts +20 -0
- package/templates/skeleton/node-service/services/api/src/main.ts +2 -9
- package/templates/skeleton/node-service/services/api/src/server.ts +93 -10
- package/templates/skeleton/node-service/services/api/src/static-dir.ts +20 -0
- package/templates/skeleton/node-service/services/api/test/server.test.ts +98 -13
- 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 {
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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 =
|
|
88
|
-
|
|
89
|
-
|
|
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
|
+
});
|