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.
Files changed (47) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/UPGRADING.md +117 -0
  3. package/dist/cli.js +5 -1
  4. package/examples/repository/scripts/runtime-smoke.ts +14 -2
  5. package/package.json +4 -2
  6. package/template/AGENTS.md +15 -5
  7. package/template/README.md +46 -8
  8. package/template/_env.example +8 -0
  9. package/template/_gitignore +1 -0
  10. package/template/biome.json +1 -1
  11. package/template/bun.lock +115 -98
  12. package/template/package.json +9 -8
  13. package/template/packages/backend/package.json +2 -2
  14. package/template/packages/backend/src/cleanup.ts +121 -0
  15. package/template/packages/backend/src/index.ts +12 -3
  16. package/template/packages/config/package.json +3 -1
  17. package/template/packages/config/src/declaration.ts +1 -1
  18. package/template/packages/config/src/shutdown.ts +20 -0
  19. package/template/packages/db/package.json +2 -2
  20. package/template/packages/frontend/package.json +11 -11
  21. package/template/packages/frontend/src/components/ui/toaster.tsx +4 -1
  22. package/template/packages/frontend/src/lib/seo/pages.ts +2 -2
  23. package/template/packages/shared/package.json +1 -1
  24. package/template/scripts/acceptance-database.test.ts +73 -0
  25. package/template/scripts/acceptance-database.ts +92 -0
  26. package/template/scripts/acceptance-local.ts +144 -0
  27. package/template/scripts/build-inputs.test.ts +1 -1
  28. package/template/scripts/build-inputs.ts +4 -3
  29. package/template/scripts/build-stamp.test.ts +151 -0
  30. package/template/scripts/build-stamp.ts +169 -0
  31. package/template/scripts/client-boundary.test.ts +117 -0
  32. package/template/scripts/client-boundary.ts +148 -0
  33. package/template/scripts/declaration.ts +10 -7
  34. package/template/scripts/deployment-preflight.ts +41 -0
  35. package/template/scripts/dev.ts +8 -6
  36. package/template/scripts/local-env.ts +9 -3
  37. package/template/scripts/readiness.ts +92 -0
  38. package/template/scripts/release-steps.ts +5 -1
  39. package/template/scripts/release.ts +8 -0
  40. package/template/scripts/runtime-smoke.test.ts +178 -0
  41. package/template/scripts/runtime-smoke.ts +15 -2
  42. package/template/scripts/shutdown-budget.fixture.ts +29 -0
  43. package/template/scripts/shutdown-budget.test.ts +164 -0
  44. package/template/scripts/surface-conformance.ts +8 -1
  45. package/template/scripts/tooling-env.ts +30 -1
  46. package/template/scripts/web-surface-smoke.ts +125 -14
  47. 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 fetch(publicUrl(webOrigin, OG_IMAGE_PATH));
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 fetch(publicUrl(webOrigin, SITEMAP_PATH));
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(webOrigin: string): Promise<void> {
40
- const addresses = [
41
- { host: 'alpha.example', proto: 'https' },
42
- { host: 'beta.example:8443', proto: 'http' },
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 fetch(publicUrl(webOrigin, SITEMAP_PATH), { headers });
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 fetch(publicUrl(webOrigin, '/robots.txt'), { headers });
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 fetch(publicUrl(webOrigin, SITEMAP_PATH), {
74
- headers: { 'x-forwarded-host': 'attacker.example', 'x-forwarded-proto': 'https' },
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 fetch(publicUrl(webOrigin, SITEMAP_PATH), {
88
- headers: { 'x-forwarded-host': 'alpha.example', 'x-forwarded-proto': proto },
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;