create-stitchkit 0.4.0 → 0.4.2
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 +141 -0
- package/UPGRADING.md +117 -0
- package/dist/cli.js +5 -1
- package/examples/repository/scripts/runtime-smoke.ts +14 -2
- package/package.json +4 -2
- package/template/AGENTS.md +15 -5
- package/template/README.md +46 -8
- package/template/_env.example +8 -0
- package/template/_gitignore +1 -0
- package/template/biome.json +1 -1
- package/template/bun.lock +115 -98
- package/template/package.json +9 -8
- package/template/packages/backend/package.json +2 -2
- package/template/packages/backend/src/cleanup.ts +121 -0
- package/template/packages/backend/src/index.ts +12 -3
- package/template/packages/config/package.json +3 -1
- package/template/packages/config/src/declaration.ts +1 -1
- package/template/packages/config/src/shutdown.ts +20 -0
- package/template/packages/db/package.json +2 -2
- package/template/packages/frontend/package.json +11 -11
- package/template/packages/frontend/src/components/ui/toaster.tsx +4 -1
- package/template/packages/frontend/src/lib/seo/pages.ts +2 -2
- package/template/packages/shared/package.json +1 -1
- package/template/scripts/acceptance-database.test.ts +73 -0
- package/template/scripts/acceptance-database.ts +92 -0
- package/template/scripts/acceptance-local.ts +144 -0
- package/template/scripts/build-inputs.test.ts +1 -1
- package/template/scripts/build-inputs.ts +4 -3
- package/template/scripts/build-stamp.test.ts +151 -0
- package/template/scripts/build-stamp.ts +169 -0
- package/template/scripts/client-boundary.test.ts +117 -0
- package/template/scripts/client-boundary.ts +148 -0
- package/template/scripts/declaration.ts +10 -7
- package/template/scripts/deployment-preflight.ts +41 -0
- package/template/scripts/dev.ts +8 -6
- package/template/scripts/local-env.ts +9 -3
- package/template/scripts/readiness.ts +92 -0
- package/template/scripts/release-steps.ts +5 -1
- package/template/scripts/release.ts +8 -0
- package/template/scripts/runtime-smoke.test.ts +178 -0
- package/template/scripts/runtime-smoke.ts +15 -2
- package/template/scripts/shutdown-budget.fixture.ts +29 -0
- package/template/scripts/shutdown-budget.test.ts +164 -0
- package/template/scripts/surface-conformance.ts +8 -1
- package/template/scripts/tooling-env.ts +30 -1
- package/template/scripts/web-surface-smoke.ts +125 -14
- package/template/packages/config/src/project-declaration.generated.ts +0 -611
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
type CleanupResult,
|
|
6
|
+
closeWithinBudget,
|
|
7
|
+
concludeShutdown,
|
|
8
|
+
} from '../packages/backend/src/cleanup';
|
|
9
|
+
import { appDeclaration } from '../packages/config/src/declaration';
|
|
10
|
+
import { CLEANUP_BUDGET_MS, FORCE_BUDGET_MS } from '../packages/config/src/shutdown';
|
|
11
|
+
import { terminationBudgetMs } from './declaration';
|
|
12
|
+
|
|
13
|
+
describe('the termination budget is an upper bound, not an estimate', () => {
|
|
14
|
+
test('the budget is exactly the three bounded parts', () => {
|
|
15
|
+
for (const role of appDeclaration.roles) {
|
|
16
|
+
expect(terminationBudgetMs(role)).toBe(
|
|
17
|
+
role.drainFloorMs + FORCE_BUDGET_MS + CLEANUP_BUDGET_MS,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('a cleanup that finishes reports nothing unfinished', async () => {
|
|
23
|
+
const closed: string[] = [];
|
|
24
|
+
const result = await closeWithinBudget([
|
|
25
|
+
{ name: 'MCP', close: async () => closed.push('MCP') },
|
|
26
|
+
{ name: 'database', close: async () => closed.push('database') },
|
|
27
|
+
]);
|
|
28
|
+
expect(closed).toEqual(['MCP', 'database']);
|
|
29
|
+
expect(result.unfinished).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('a close that hangs stops being waited for, and is named', async () => {
|
|
33
|
+
// The defect this exists for: the drain had a deadline, the closes after it
|
|
34
|
+
// had none, so a hung MCP session or database pool ran past the very kill
|
|
35
|
+
// timeout the budget had told the supervisor to allow — turning an orderly
|
|
36
|
+
// shutdown into the SIGKILL that runs no cleanup at all.
|
|
37
|
+
const result = await closeWithinBudget(
|
|
38
|
+
[{ name: 'MCP', close: () => new Promise<void>(() => undefined) }],
|
|
39
|
+
25,
|
|
40
|
+
);
|
|
41
|
+
expect(result.unfinished).toEqual(['MCP']);
|
|
42
|
+
expect(result.durationMs).toBeLessThan(CLEANUP_BUDGET_MS);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('the steps share one budget rather than each getting a full one', async () => {
|
|
46
|
+
const result = await closeWithinBudget(
|
|
47
|
+
[
|
|
48
|
+
{ name: 'MCP', close: () => new Promise<void>(() => undefined) },
|
|
49
|
+
{ name: 'database', close: () => new Promise<void>(() => undefined) },
|
|
50
|
+
],
|
|
51
|
+
25,
|
|
52
|
+
);
|
|
53
|
+
expect(result.unfinished).toEqual(['MCP', 'database']);
|
|
54
|
+
// Two steps, one budget: about one budget of wall clock, not two.
|
|
55
|
+
expect(result.durationMs).toBeLessThan(50);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a close that fails is a failure, and keeps its cause', async () => {
|
|
59
|
+
// It used to be swallowed into `undefined` with the note that the budget is
|
|
60
|
+
// about time. The budget is — but a close that throws is a shutdown that
|
|
61
|
+
// did not happen, and reporting it as a clean exit with the reason gone is
|
|
62
|
+
// how a broken shutdown looks exactly like a working one.
|
|
63
|
+
const result = await closeWithinBudget([
|
|
64
|
+
{ name: 'database', close: () => Promise.reject(new Error('pool already gone')) },
|
|
65
|
+
]);
|
|
66
|
+
expect(result.unfinished).toEqual([]);
|
|
67
|
+
expect(result.failed.map((failure) => failure.name)).toEqual(['database']);
|
|
68
|
+
expect(result.failed[0]?.cause).toBeInstanceOf(Error);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('a cleanup that completed is a zero exit and no forced ending', () => {
|
|
72
|
+
let exited: number | undefined;
|
|
73
|
+
concludeShutdown({ unfinished: [], failed: [], durationMs: 3 }, true, (code) => {
|
|
74
|
+
exited = code;
|
|
75
|
+
});
|
|
76
|
+
expect(process.exitCode).toBe(0);
|
|
77
|
+
expect(exited).toBeUndefined();
|
|
78
|
+
process.exitCode = 0;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('an unfinished step and a failed one both end the process non-zero', () => {
|
|
82
|
+
const endings: Array<[string, CleanupResult]> = [
|
|
83
|
+
['unfinished', { unfinished: ['database'], failed: [], durationMs: 5 }],
|
|
84
|
+
[
|
|
85
|
+
'failed',
|
|
86
|
+
{
|
|
87
|
+
unfinished: [],
|
|
88
|
+
failed: [{ name: 'database', cause: new Error('pool already gone') }],
|
|
89
|
+
durationMs: 5,
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
];
|
|
93
|
+
for (const [label, result] of endings) {
|
|
94
|
+
let exited: number | undefined;
|
|
95
|
+
concludeShutdown(result, true, (code) => {
|
|
96
|
+
exited = code;
|
|
97
|
+
});
|
|
98
|
+
expect(`${label}:${exited}`).toBe(`${label}:1`);
|
|
99
|
+
}
|
|
100
|
+
process.exitCode = 0;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('the role bounds its own cleanup rather than awaiting it bare', () => {
|
|
104
|
+
// One number, two readers — and the reader that matters is the role. An
|
|
105
|
+
// `await mcp.close()` here with nothing around it is the whole defect.
|
|
106
|
+
const source = readFileSync(
|
|
107
|
+
resolve(import.meta.dir, '../packages/backend/src/index.ts'),
|
|
108
|
+
'utf8',
|
|
109
|
+
);
|
|
110
|
+
expect(source).toContain('closeWithinBudget');
|
|
111
|
+
expect(source).not.toMatch(/await mcp\.close\(\);/);
|
|
112
|
+
expect(source).not.toMatch(/await prisma\.\$disconnect\(\);/);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('a spent budget ends the process itself', () => {
|
|
117
|
+
const fixture = resolve(import.meta.dir, 'shutdown-budget.fixture.ts');
|
|
118
|
+
|
|
119
|
+
async function runFixture(mode: string, budgetMs: number, waitMs: number) {
|
|
120
|
+
const child = Bun.spawn(['bun', fixture, String(budgetMs), mode], {
|
|
121
|
+
cwd: resolve(import.meta.dir, '..'),
|
|
122
|
+
stdout: 'pipe',
|
|
123
|
+
stderr: 'pipe',
|
|
124
|
+
});
|
|
125
|
+
const startedAt = Date.now();
|
|
126
|
+
const timeout = Bun.sleep(waitMs).then(() => 'timeout');
|
|
127
|
+
const finished = await Promise.race([child.exited, timeout]);
|
|
128
|
+
const elapsed = Date.now() - startedAt;
|
|
129
|
+
if (finished === 'timeout') {
|
|
130
|
+
child.kill('SIGKILL');
|
|
131
|
+
await child.exited;
|
|
132
|
+
return { exitCode: undefined, elapsed, stdout: '', stderr: '' };
|
|
133
|
+
}
|
|
134
|
+
const [stdout, stderr] = await Promise.all([
|
|
135
|
+
new Response(child.stdout).text(),
|
|
136
|
+
new Response(child.stderr).text(),
|
|
137
|
+
]);
|
|
138
|
+
return { exitCode: finished, elapsed, stdout, stderr };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
test('a role whose close never finishes exits anyway, and says so', async () => {
|
|
142
|
+
const run = await runFixture('hang', 200, 8_000);
|
|
143
|
+
expect(run.exitCode).toBe(1);
|
|
144
|
+
expect(run.stderr).toContain('the cleanup budget is spent, exiting anyway');
|
|
145
|
+
// Well inside a supervisor's kill timeout, which is what the declared
|
|
146
|
+
// termination budget promises this stays inside.
|
|
147
|
+
expect(run.elapsed).toBeLessThan(5_000);
|
|
148
|
+
}, 20_000);
|
|
149
|
+
|
|
150
|
+
test('a role whose close throws exits non-zero, with the cause', async () => {
|
|
151
|
+
const run = await runFixture('throw', 200, 8_000);
|
|
152
|
+
expect(run.exitCode).toBe(1);
|
|
153
|
+
expect(run.stderr).toContain('Shutdown could not close database');
|
|
154
|
+
expect(run.stderr).toContain('pool already gone');
|
|
155
|
+
}, 20_000);
|
|
156
|
+
|
|
157
|
+
test('the held handle really does hold — without it the test proves nothing', async () => {
|
|
158
|
+
// Falsification. If this process ended on its own, the two assertions above
|
|
159
|
+
// would pass with `concludeShutdown` deleted, and the regression would be
|
|
160
|
+
// measuring the absence of work rather than the presence of an ending.
|
|
161
|
+
const run = await runFixture('clean', 200, 1_500);
|
|
162
|
+
expect(run.exitCode).toBeUndefined();
|
|
163
|
+
}, 20_000);
|
|
164
|
+
});
|
|
@@ -46,7 +46,7 @@ interface SurfaceConformanceOptions {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
async function readJson(url: string): Promise<unknown> {
|
|
49
|
-
const response = await fetch(url);
|
|
49
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
|
|
50
50
|
if (!response.ok) throw new Error(`GET ${url} returned ${response.status}`);
|
|
51
51
|
return response.json();
|
|
52
52
|
}
|
|
@@ -94,6 +94,13 @@ export async function discoverMcpTools(
|
|
|
94
94
|
const transport = new StreamableHTTPClientTransport(new URL(`${apiOrigin}/mcp`));
|
|
95
95
|
try {
|
|
96
96
|
await client.connect(transport);
|
|
97
|
+
// Ask the server what it HAS before asking it to list it. A server exposing
|
|
98
|
+
// no MCP tool advertises no `tools` capability, and calling `listTools()`
|
|
99
|
+
// anyway makes the vendor client log a debug line — noise printed on every
|
|
100
|
+
// green run of a gate whose whole output is one line — to return the empty
|
|
101
|
+
// list the capability already stated. The absence is still observed here,
|
|
102
|
+
// and a manifest that claims an MCP tool still fails against it.
|
|
103
|
+
if (!client.getServerCapabilities()?.tools) return [];
|
|
97
104
|
return (await client.listTools()).tools.map((tool) => tool.name);
|
|
98
105
|
} finally {
|
|
99
106
|
await client.close();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import { config as dotenvConfig } from 'dotenv';
|
|
4
|
+
import { config as dotenvConfig, parse as parseDotenv } from 'dotenv';
|
|
4
5
|
import { z } from 'zod';
|
|
5
6
|
import { ensureLocalEnvironment } from './local-env';
|
|
6
7
|
|
|
@@ -12,6 +13,18 @@ const ToolingEnvSchema = z.object({
|
|
|
12
13
|
SMOKE_API_ORIGIN: z.url(),
|
|
13
14
|
SMOKE_WEB_ORIGIN: z.url(),
|
|
14
15
|
PLAYWRIGHT_BASE_URL: z.url().optional(),
|
|
16
|
+
// What the deployment says about the addresses it serves. A check that proves
|
|
17
|
+
// one artifact answers on many can only dial an address the deployment
|
|
18
|
+
// claims — every other one is refused, by design — so it has to read the
|
|
19
|
+
// claim rather than carry two fixture hosts of its own and require the
|
|
20
|
+
// deployment to have been told the same two names.
|
|
21
|
+
PUBLIC_WEB_ORIGIN: z.url().optional(),
|
|
22
|
+
PUBLIC_WEB_HOSTS: z.string().min(1).optional(),
|
|
23
|
+
// The acceptance gate's own database. Optional here because only that gate
|
|
24
|
+
// needs it and `runtime:smoke` must still run against a deployment someone
|
|
25
|
+
// else brought up; `resolveAcceptanceDatabase` is what makes it mandatory
|
|
26
|
+
// where it matters, with a refusal that names the line to add.
|
|
27
|
+
ACCEPTANCE_DATABASE_URL: z.url().optional(),
|
|
15
28
|
});
|
|
16
29
|
|
|
17
30
|
export function loadToolingEnv(root = path.resolve(scriptDirectory, '..')) {
|
|
@@ -23,6 +36,22 @@ export function loadToolingEnv(root = path.resolve(scriptDirectory, '..')) {
|
|
|
23
36
|
return ToolingEnvSchema.parse(process.env);
|
|
24
37
|
}
|
|
25
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The environment this deployment actually runs with: what the place injected,
|
|
41
|
+
* with `.env` filling the gaps.
|
|
42
|
+
*
|
|
43
|
+
* The same precedence the generated supervision file uses — a file in the
|
|
44
|
+
* repository never overrules the place — so a script reading it and a role
|
|
45
|
+
* started from it cannot disagree about which port a role is on.
|
|
46
|
+
*/
|
|
47
|
+
export function deploymentEnvironment(
|
|
48
|
+
root = path.resolve(scriptDirectory, '..'),
|
|
49
|
+
): Record<string, string | undefined> {
|
|
50
|
+
const file = path.resolve(root, '.env');
|
|
51
|
+
const fromFile = existsSync(file) ? parseDotenv(readFileSync(file, 'utf8')) : {};
|
|
52
|
+
return { ...fromFile, ...process.env };
|
|
53
|
+
}
|
|
54
|
+
|
|
26
55
|
/** Process inheritance is isolated to this tooling environment boundary. */
|
|
27
56
|
export function inheritToolingEnvironment(
|
|
28
57
|
overrides: Record<string, string>,
|
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
const OG_IMAGE_PATH = '/api/og/en/themes';
|
|
2
2
|
const SITEMAP_PATH = '/sitemap.xml';
|
|
3
|
+
const DIAL_TIMEOUT_MS = 30_000;
|
|
3
4
|
|
|
4
5
|
function publicUrl(origin: string, path: string): URL {
|
|
5
6
|
return new URL(path, new URL(origin).origin);
|
|
6
7
|
}
|
|
7
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Every dial is bounded.
|
|
11
|
+
*
|
|
12
|
+
* A gate that hangs is worse than one that fails: it reports nothing, ever. An
|
|
13
|
+
* endpoint that accepts the connection and never answers used to stop the whole
|
|
14
|
+
* run with no output and no deadline.
|
|
15
|
+
*/
|
|
16
|
+
function dial(url: URL, init?: RequestInit): Promise<Response> {
|
|
17
|
+
return fetch(url, { ...init, signal: AbortSignal.timeout(DIAL_TIMEOUT_MS) });
|
|
18
|
+
}
|
|
19
|
+
|
|
8
20
|
export async function assertPublicWebSurface(webOrigin: string): Promise<void> {
|
|
9
|
-
const image = await
|
|
21
|
+
const image = await dial(publicUrl(webOrigin, OG_IMAGE_PATH));
|
|
10
22
|
if (image.status !== 200) {
|
|
11
23
|
throw new Error(`GET ${OG_IMAGE_PATH} returned ${image.status}`);
|
|
12
24
|
}
|
|
@@ -18,7 +30,7 @@ export async function assertPublicWebSurface(webOrigin: string): Promise<void> {
|
|
|
18
30
|
throw new Error(`GET ${OG_IMAGE_PATH} returned an empty image`);
|
|
19
31
|
}
|
|
20
32
|
|
|
21
|
-
const sitemap = await
|
|
33
|
+
const sitemap = await dial(publicUrl(webOrigin, SITEMAP_PATH));
|
|
22
34
|
if (sitemap.status !== 200) {
|
|
23
35
|
throw new Error(`GET ${SITEMAP_PATH} returned ${sitemap.status}`);
|
|
24
36
|
}
|
|
@@ -27,6 +39,97 @@ export async function assertPublicWebSurface(webOrigin: string): Promise<void> {
|
|
|
27
39
|
}
|
|
28
40
|
}
|
|
29
41
|
|
|
42
|
+
/** What the deployment has been told about the addresses it serves. */
|
|
43
|
+
export interface PublicHostPolicy {
|
|
44
|
+
/** `PUBLIC_WEB_ORIGIN` — a single public address, stated outright. */
|
|
45
|
+
origin?: string | undefined;
|
|
46
|
+
/** `PUBLIC_WEB_HOSTS` — the comma-separated hosts this deployment claims. */
|
|
47
|
+
hosts?: string | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface ForwardedAddress {
|
|
51
|
+
host: string;
|
|
52
|
+
proto: 'http' | 'https';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type PortabilityPlan =
|
|
56
|
+
| {
|
|
57
|
+
kind: 'check';
|
|
58
|
+
addresses: readonly [ForwardedAddress, ForwardedAddress];
|
|
59
|
+
forgedHost: string;
|
|
60
|
+
}
|
|
61
|
+
| { kind: 'skip'; reason: string }
|
|
62
|
+
| { kind: 'refuse'; reason: string };
|
|
63
|
+
|
|
64
|
+
function claimedHosts(hosts: string | undefined): string[] {
|
|
65
|
+
// Deduplicated, because the count is load-bearing: the check needs two
|
|
66
|
+
// DIFFERENT addresses to ask this deployment on, and one host written twice
|
|
67
|
+
// would pass as two and make it compare an address with itself.
|
|
68
|
+
return [
|
|
69
|
+
...new Set(
|
|
70
|
+
(hosts ?? '')
|
|
71
|
+
.split(',')
|
|
72
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
73
|
+
.filter((entry) => entry.length > 0),
|
|
74
|
+
),
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Which addresses to ask this deployment on — ITS OWN, not the check's.
|
|
80
|
+
*
|
|
81
|
+
* The check can only dial an address the deployment claims: a forwarded host
|
|
82
|
+
* outside `PUBLIC_WEB_HOSTS` is refused on purpose, and rightly so. Naming two
|
|
83
|
+
* fixture hosts here therefore meant the deployment had to have been told about
|
|
84
|
+
* those exact two names by somebody else — which only the packed lane ever was.
|
|
85
|
+
* Everyone running the same command against their own deployment got a bare
|
|
86
|
+
* 500 from a policy working exactly as designed.
|
|
87
|
+
*
|
|
88
|
+
* So the deployment declares and the check reads, instead of two copies of the
|
|
89
|
+
* same fixture that must agree across two repositories.
|
|
90
|
+
*/
|
|
91
|
+
export function planPortabilityCheck(
|
|
92
|
+
webOrigin: string,
|
|
93
|
+
policy: PublicHostPolicy,
|
|
94
|
+
): PortabilityPlan {
|
|
95
|
+
if (policy.origin) {
|
|
96
|
+
return {
|
|
97
|
+
kind: 'skip',
|
|
98
|
+
reason: `PUBLIC_WEB_ORIGIN pins this deployment to ${policy.origin}, so it answers with that one address whatever it is asked on. Portability by request is observable only where PUBLIC_WEB_HOSTS names the addresses instead.`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const claimed = claimedHosts(policy.hosts);
|
|
103
|
+
const dialled = new URL(webOrigin).host.toLowerCase();
|
|
104
|
+
const others = claimed.filter((host) => host !== dialled);
|
|
105
|
+
const [first, second] = others;
|
|
106
|
+
if (!first || !second) {
|
|
107
|
+
const suggestion = [dialled, 'alpha.example', 'beta.example:8443'].join(',');
|
|
108
|
+
return {
|
|
109
|
+
kind: 'refuse',
|
|
110
|
+
reason: `This deployment claims ${claimed.length === 0 ? 'no host at all' : claimed.join(', ')}, which leaves ${others.length} address besides ${dialled} to ask it on. Proving that one artifact serves many needs at least two. Set PUBLIC_WEB_HOSTS=${suggestion} in .env, restart the web role (\`bun run dev\`), then rerun.`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The forged host must be one the deployment never claimed — otherwise the
|
|
115
|
+
// refusal it is supposed to provoke never happens and the check is vacuous.
|
|
116
|
+
let forgedHost = 'attacker.example';
|
|
117
|
+
for (let attempt = 1; claimed.includes(forgedHost); attempt += 1) {
|
|
118
|
+
forgedHost = `attacker-${attempt}.invalid`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
kind: 'check',
|
|
123
|
+
// Both protocols on purpose: the answer has to follow the forwarded scheme,
|
|
124
|
+
// not a default the artifact was built with.
|
|
125
|
+
addresses: [
|
|
126
|
+
{ host: first, proto: 'https' },
|
|
127
|
+
{ host: second, proto: 'http' },
|
|
128
|
+
],
|
|
129
|
+
forgedHost,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
30
133
|
/**
|
|
31
134
|
* One artifact, many external addresses.
|
|
32
135
|
*
|
|
@@ -36,17 +139,24 @@ export async function assertPublicWebSurface(webOrigin: string): Promise<void> {
|
|
|
36
139
|
* be answering with the address it was built with instead of the one it was
|
|
37
140
|
* asked on.
|
|
38
141
|
*/
|
|
39
|
-
export async function assertArtifactIsPlacementFree(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
142
|
+
export async function assertArtifactIsPlacementFree(
|
|
143
|
+
webOrigin: string,
|
|
144
|
+
policy: PublicHostPolicy,
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
const plan = planPortabilityCheck(webOrigin, policy);
|
|
147
|
+
if (plan.kind === 'refuse') throw new Error(plan.reason);
|
|
148
|
+
if (plan.kind === 'skip') {
|
|
149
|
+
// Named, not silent: a gate whose strongest check quietly did not run reads
|
|
150
|
+
// exactly like one that ran and passed.
|
|
151
|
+
console.log(`Placement-free check not run — ${plan.reason}`);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
44
154
|
|
|
45
|
-
for (const { host, proto } of addresses) {
|
|
155
|
+
for (const { host, proto } of plan.addresses) {
|
|
46
156
|
const expected = `${proto}://${host}`;
|
|
47
157
|
const headers = { 'x-forwarded-host': host, 'x-forwarded-proto': proto };
|
|
48
158
|
|
|
49
|
-
const sitemap = await
|
|
159
|
+
const sitemap = await dial(publicUrl(webOrigin, SITEMAP_PATH), { headers });
|
|
50
160
|
if (sitemap.status !== 200) {
|
|
51
161
|
throw new Error(`GET ${SITEMAP_PATH} as ${expected} returned ${sitemap.status}`);
|
|
52
162
|
}
|
|
@@ -60,7 +170,7 @@ export async function assertArtifactIsPlacementFree(webOrigin: string): Promise<
|
|
|
60
170
|
);
|
|
61
171
|
}
|
|
62
172
|
|
|
63
|
-
const robots = await
|
|
173
|
+
const robots = await dial(publicUrl(webOrigin, '/robots.txt'), { headers });
|
|
64
174
|
if (!(await robots.text()).includes(`Sitemap: ${expected}/sitemap.xml`)) {
|
|
65
175
|
throw new Error(`GET /robots.txt as ${expected} did not answer with that address`);
|
|
66
176
|
}
|
|
@@ -70,8 +180,8 @@ export async function assertArtifactIsPlacementFree(webOrigin: string): Promise<
|
|
|
70
180
|
// must be refused, or the same mechanism that lets one artifact serve many
|
|
71
181
|
// addresses lets a caller choose the canonical URL, the sitemap and the OG
|
|
72
182
|
// metadata for everyone else.
|
|
73
|
-
const forged = await
|
|
74
|
-
headers: { 'x-forwarded-host':
|
|
183
|
+
const forged = await dial(publicUrl(webOrigin, SITEMAP_PATH), {
|
|
184
|
+
headers: { 'x-forwarded-host': plan.forgedHost, 'x-forwarded-proto': 'https' },
|
|
75
185
|
});
|
|
76
186
|
if (forged.status < 400) {
|
|
77
187
|
throw new Error(
|
|
@@ -83,9 +193,10 @@ export async function assertArtifactIsPlacementFree(webOrigin: string): Promise<
|
|
|
83
193
|
// misconfigured proxy or a forgery, and both have to be visible: mapping
|
|
84
194
|
// every unknown value to `http` produced a plausible canonical origin from
|
|
85
195
|
// `ftp`, `javascript` and a truncated header alike.
|
|
196
|
+
const [claimedAddress] = plan.addresses;
|
|
86
197
|
for (const proto of ['ftp', 'javascript', 'HTTPS://', '']) {
|
|
87
|
-
const answered = await
|
|
88
|
-
headers: { 'x-forwarded-host':
|
|
198
|
+
const answered = await dial(publicUrl(webOrigin, SITEMAP_PATH), {
|
|
199
|
+
headers: { 'x-forwarded-host': claimedAddress.host, 'x-forwarded-proto': proto },
|
|
89
200
|
});
|
|
90
201
|
// An empty header carries no claim at all and is treated as absent.
|
|
91
202
|
const acceptable = proto === '' ? answered.status === 200 : answered.status >= 400;
|