create-stitchkit 0.3.3 → 0.4.1

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 (99) hide show
  1. package/CHANGELOG.md +347 -0
  2. package/README.md +3 -1
  3. package/UPGRADING.md +342 -0
  4. package/dist/cli.js +238 -42
  5. package/examples/repository/_env.example.append +21 -0
  6. package/examples/repository/packages/backend/src/domain/repository/github-cache.ts +2 -2
  7. package/examples/repository/packages/backend/src/surface.ts +1 -1
  8. package/examples/repository/packages/config/src/features.ts +17 -0
  9. package/examples/repository/packages/frontend/src/app/[locale]/page.tsx +4 -4
  10. package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
  11. package/examples/repository/packages/frontend/src/app/api/[...path]/route.ts +66 -0
  12. package/examples/repository/packages/frontend/src/lib/api/client.ts +17 -12
  13. package/examples/repository/packages/frontend/src/lib/api/cross-origin.ts +87 -0
  14. package/examples/repository/packages/frontend/src/lib/api/place.ts +26 -0
  15. package/examples/repository/packages/frontend/src/lib/api/queries.ts +3 -0
  16. package/examples/repository/packages/frontend/src/lib/api/server-client.ts +11 -0
  17. package/examples/repository/packages/frontend/src/lib/realtime/repository.ts +44 -12
  18. package/examples/repository/packages/frontend/src/providers/client-providers.tsx +30 -0
  19. package/examples/repository/packages/frontend/src/providers/index.tsx +17 -12
  20. package/examples/repository/packages/frontend/src/providers/realtime.tsx +6 -4
  21. package/examples/repository/project.json +189 -0
  22. package/examples/repository/scripts/runtime-smoke.ts +38 -6
  23. package/package.json +12 -2
  24. package/template/AGENTS.md +23 -3
  25. package/template/README.md +83 -8
  26. package/template/_env.example +16 -4
  27. package/template/_gitignore +1 -0
  28. package/template/biome.json +6 -2
  29. package/template/bun.lock +115 -98
  30. package/template/e2e/starter.spec.ts +5 -7
  31. package/template/ecosystem.config.cjs +42 -19
  32. package/template/ecosystem.dev.config.cjs +41 -21
  33. package/template/package.json +12 -10
  34. package/template/packages/backend/package.json +2 -2
  35. package/template/packages/backend/src/cleanup.ts +121 -0
  36. package/template/packages/backend/src/cli.ts +6 -2
  37. package/template/packages/backend/src/index.ts +33 -8
  38. package/template/packages/backend/src/surface.ts +6 -1
  39. package/template/packages/backend/src/transport/errors.ts +4 -2
  40. package/template/packages/config/package.json +6 -2
  41. package/template/packages/config/src/app-identity.generated.ts +20 -0
  42. package/template/packages/config/src/declaration.ts +30 -0
  43. package/template/packages/config/src/server.ts +8 -17
  44. package/template/packages/config/src/shutdown.ts +20 -0
  45. package/template/packages/config/src/variables.ts +89 -0
  46. package/template/packages/db/package.json +2 -2
  47. package/template/packages/frontend/next.config.ts +3 -2
  48. package/template/packages/frontend/package.json +13 -13
  49. package/template/packages/frontend/scripts/serve.ts +70 -0
  50. package/template/packages/frontend/src/app/[locale]/layout.tsx +9 -8
  51. package/template/packages/frontend/src/app/[locale]/page.tsx +2 -2
  52. package/template/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
  53. package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +1 -1
  54. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +1 -1
  55. package/template/packages/frontend/src/app/robots.ts +4 -2
  56. package/template/packages/frontend/src/app/sitemap.ts +7 -19
  57. package/template/packages/frontend/src/components/ui/toaster.tsx +4 -1
  58. package/template/packages/frontend/src/env.ts +27 -8
  59. package/template/packages/frontend/src/lib/seo/cache-by-origin.test.ts +68 -0
  60. package/template/packages/frontend/src/lib/seo/cache-by-origin.ts +40 -0
  61. package/template/packages/frontend/src/lib/seo/metadata.ts +68 -11
  62. package/template/packages/frontend/src/lib/seo/pages.ts +1 -1
  63. package/template/packages/frontend/src/lib/seo/request-origin.ts +89 -0
  64. package/template/packages/frontend/src/theme/config.ts +1 -1
  65. package/template/packages/frontend/tsconfig.json +10 -3
  66. package/template/packages/shared/package.json +1 -1
  67. package/template/playwright.config.ts +1 -1
  68. package/template/project.json +169 -0
  69. package/template/scripts/acceptance-database.test.ts +73 -0
  70. package/template/scripts/acceptance-database.ts +92 -0
  71. package/template/scripts/acceptance-local.ts +144 -0
  72. package/template/scripts/build-inputs.test.ts +69 -0
  73. package/template/scripts/build-inputs.ts +58 -0
  74. package/template/scripts/build-stamp.test.ts +151 -0
  75. package/template/scripts/build-stamp.ts +169 -0
  76. package/template/scripts/check-authored.ts +18 -2
  77. package/template/scripts/client-boundary.test.ts +117 -0
  78. package/template/scripts/client-boundary.ts +148 -0
  79. package/template/scripts/declaration.test.ts +206 -0
  80. package/template/scripts/declaration.ts +271 -0
  81. package/template/scripts/deployment-preflight.ts +41 -0
  82. package/template/scripts/dev.ts +43 -20
  83. package/template/scripts/local-env.test.ts +2 -2
  84. package/template/scripts/local-env.ts +9 -3
  85. package/template/scripts/readiness.ts +92 -0
  86. package/template/scripts/release-steps.test.ts +87 -0
  87. package/template/scripts/release-steps.ts +112 -0
  88. package/template/scripts/release.ts +38 -0
  89. package/template/scripts/runtime-smoke.test.ts +178 -0
  90. package/template/scripts/runtime-smoke.ts +21 -5
  91. package/template/scripts/serve-mode.test.ts +36 -0
  92. package/template/scripts/shutdown-budget.fixture.ts +29 -0
  93. package/template/scripts/shutdown-budget.test.ts +164 -0
  94. package/template/scripts/supervision-signal.test.ts +94 -0
  95. package/template/scripts/surface-conformance.ts +8 -1
  96. package/template/scripts/tooling-env.ts +35 -3
  97. package/template/scripts/web-surface-smoke.ts +183 -2
  98. package/template/app.config.json +0 -9
  99. package/template/packages/config/src/identity.ts +0 -18
@@ -0,0 +1,94 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ /**
7
+ * Why the supervision files run each role from its OWN directory.
8
+ *
9
+ * A supervisor that reaches into a workspace from the root — `bun run --filter
10
+ * @app/backend start` — puts a launcher process between itself and the role.
11
+ * `SIGTERM` sent to that launcher does not arrive at the role: the launcher
12
+ * dies and the role is torn down without ever running its shutdown. Every
13
+ * drain, every `kill_timeout`, every grace period is then decoration.
14
+ *
15
+ * This test spawns both shapes and compares what the role actually receives, so
16
+ * the choice in `scripts/declaration.ts` is pinned by behaviour rather than by
17
+ * a comment that a later refactor can talk itself out of.
18
+ */
19
+ async function roleReceivesSignal(from: 'role directory' | 'workspace root'): Promise<boolean> {
20
+ const root = await mkdtemp(join(tmpdir(), 'starter-signal-'));
21
+ try {
22
+ const rolePath = join(root, 'role');
23
+ await Bun.$`mkdir -p ${rolePath}`.quiet();
24
+ await writeFile(
25
+ join(rolePath, 'role.ts'),
26
+ [
27
+ "process.on('SIGTERM', () => { console.log('DRAINED'); process.exit(0); });",
28
+ "console.log('UP');",
29
+ 'setInterval(() => {}, 1000);',
30
+ ].join('\n'),
31
+ );
32
+ await writeFile(
33
+ join(rolePath, 'package.json'),
34
+ JSON.stringify({ name: '@t/role', scripts: { start: 'bun role.ts' } }),
35
+ );
36
+ await writeFile(
37
+ join(root, 'package.json'),
38
+ JSON.stringify({ name: 'root', private: true, workspaces: ['role'] }),
39
+ );
40
+
41
+ // `detached` makes the child a process-group LEADER, which is what lets the
42
+ // negative-pid signal below reach the role behind the launcher. The
43
+ // NEGATIVE case is the point: there the role never sees the signal, so
44
+ // killing the launcher alone would leave it running for the life of the
45
+ // machine — this test used to leak one role per run.
46
+ const child =
47
+ from === 'role directory'
48
+ ? Bun.spawn(['bun', 'run', 'start'], {
49
+ cwd: rolePath,
50
+ detached: true,
51
+ stdout: 'pipe',
52
+ stderr: 'ignore',
53
+ })
54
+ : Bun.spawn(['bun', 'run', '--filter', '@t/role', 'start'], {
55
+ cwd: root,
56
+ detached: true,
57
+ stdout: 'pipe',
58
+ stderr: 'ignore',
59
+ });
60
+
61
+ const output = new Response(child.stdout).text();
62
+ await Bun.sleep(1500);
63
+ // The role first, exactly as a supervisor would; then the whole group, so
64
+ // nothing that ignored the request outlives the test.
65
+ child.kill('SIGTERM');
66
+ await child.exited;
67
+ const printed = await output;
68
+ try {
69
+ process.kill(-child.pid, 'SIGKILL');
70
+ } catch {
71
+ // The group is already gone, which is the outcome this wants.
72
+ }
73
+ // Without this the negative case passes when the spawn simply failed —
74
+ // proving nothing about signal delivery, which is the only thing it exists
75
+ // to prove.
76
+ if (!printed.includes('UP')) {
77
+ throw new Error(`The role never started when launched from the ${from}: ${printed}`);
78
+ }
79
+ return printed.includes('DRAINED');
80
+ } finally {
81
+ await rm(root, { recursive: true, force: true });
82
+ }
83
+ }
84
+
85
+ test('a role started in its own directory receives the shutdown signal', async () => {
86
+ expect(await roleReceivesSignal('role directory')).toBe(true);
87
+ }, 20_000);
88
+
89
+ test('a role started through a workspace filter never receives it', async () => {
90
+ // The reason `cwd` is per role in the generated supervision files. If this
91
+ // ever starts passing, the generator may be simplified — until then it may
92
+ // not.
93
+ expect(await roleReceivesSignal('workspace root')).toBe(false);
94
+ }, 20_000);
@@ -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,14 +1,30 @@
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
 
7
8
  const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
9
+ // Tooling addresses are LEGITIMATELY bound to a place — they name the deployment a
10
+ // check is dialling. They must not be named `NEXT_PUBLIC_*`, because that
11
+ // prefix makes Next substitute the value into the build output.
8
12
  const ToolingEnvSchema = z.object({
9
- NEXT_PUBLIC_API_URL: z.url(),
10
- NEXT_PUBLIC_WEB_URL: z.url(),
13
+ SMOKE_API_ORIGIN: z.url(),
14
+ SMOKE_WEB_ORIGIN: z.url(),
11
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(),
12
28
  });
13
29
 
14
30
  export function loadToolingEnv(root = path.resolve(scriptDirectory, '..')) {
@@ -20,6 +36,22 @@ export function loadToolingEnv(root = path.resolve(scriptDirectory, '..')) {
20
36
  return ToolingEnvSchema.parse(process.env);
21
37
  }
22
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
+
23
55
  /** Process inheritance is isolated to this tooling environment boundary. */
24
56
  export function inheritToolingEnvironment(
25
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
  }
@@ -26,3 +38,172 @@ export async function assertPublicWebSurface(webOrigin: string): Promise<void> {
26
38
  throw new Error(`GET ${SITEMAP_PATH} omitted the localized theme-system URL`);
27
39
  }
28
40
  }
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
+
133
+ /**
134
+ * One artifact, many external addresses.
135
+ *
136
+ * The single check that catches a regression back into a build-time address: if
137
+ * anything is baked in again — a `NEXT_PUBLIC_` variable, a prerendered
138
+ * `sitemap.xml` — the two answers below stop differing, because the build would
139
+ * be answering with the address it was built with instead of the one it was
140
+ * asked on.
141
+ */
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
+ }
154
+
155
+ for (const { host, proto } of plan.addresses) {
156
+ const expected = `${proto}://${host}`;
157
+ const headers = { 'x-forwarded-host': host, 'x-forwarded-proto': proto };
158
+
159
+ const sitemap = await dial(publicUrl(webOrigin, SITEMAP_PATH), { headers });
160
+ if (sitemap.status !== 200) {
161
+ throw new Error(`GET ${SITEMAP_PATH} as ${expected} returned ${sitemap.status}`);
162
+ }
163
+ const body = await sitemap.text();
164
+ if (!body.includes(`${expected}/en`)) {
165
+ throw new Error(`GET ${SITEMAP_PATH} as ${expected} did not answer with that address`);
166
+ }
167
+ if (body.includes(new URL(webOrigin).host)) {
168
+ throw new Error(
169
+ `GET ${SITEMAP_PATH} as ${expected} leaked the address the build was made on — something is baked into the artifact`,
170
+ );
171
+ }
172
+
173
+ const robots = await dial(publicUrl(webOrigin, '/robots.txt'), { headers });
174
+ if (!(await robots.text()).includes(`Sitemap: ${expected}/sitemap.xml`)) {
175
+ throw new Error(`GET /robots.txt as ${expected} did not answer with that address`);
176
+ }
177
+ }
178
+
179
+ // Portable is not the same as forgeable. A host the deployment never claimed
180
+ // must be refused, or the same mechanism that lets one artifact serve many
181
+ // addresses lets a caller choose the canonical URL, the sitemap and the OG
182
+ // metadata for everyone else.
183
+ const forged = await dial(publicUrl(webOrigin, SITEMAP_PATH), {
184
+ headers: { 'x-forwarded-host': plan.forgedHost, 'x-forwarded-proto': 'https' },
185
+ });
186
+ if (forged.status < 400) {
187
+ throw new Error(
188
+ `GET ${SITEMAP_PATH} answered for an unclaimed host with ${forged.status} — the forwarded host is trusted without a policy`,
189
+ );
190
+ }
191
+
192
+ // Same rule, one layer down. A forwarded protocol outside `http | https` is a
193
+ // misconfigured proxy or a forgery, and both have to be visible: mapping
194
+ // every unknown value to `http` produced a plausible canonical origin from
195
+ // `ftp`, `javascript` and a truncated header alike.
196
+ const [claimedAddress] = plan.addresses;
197
+ for (const proto of ['ftp', 'javascript', 'HTTPS://', '']) {
198
+ const answered = await dial(publicUrl(webOrigin, SITEMAP_PATH), {
199
+ headers: { 'x-forwarded-host': claimedAddress.host, 'x-forwarded-proto': proto },
200
+ });
201
+ // An empty header carries no claim at all and is treated as absent.
202
+ const acceptable = proto === '' ? answered.status === 200 : answered.status >= 400;
203
+ if (!acceptable) {
204
+ throw new Error(
205
+ `GET ${SITEMAP_PATH} with x-forwarded-proto "${proto}" returned ${answered.status} — an unknown protocol is normalised instead of refused`,
206
+ );
207
+ }
208
+ }
209
+ }
@@ -1,9 +0,0 @@
1
- {
2
- "slug": "stitchkit-starter",
3
- "name": "Stitchkit Starter",
4
- "version": "0.1.0",
5
- "description": {
6
- "en": "Stitchkit Starter is a production application built with Stitchkit.",
7
- "ru": "Stitchkit Starter — production-приложение на Stitchkit."
8
- }
9
- }
@@ -1,18 +0,0 @@
1
- import { z } from 'zod';
2
- import source from '../../../app.config.json' with { type: 'json' };
3
-
4
- export const ApplicationIdentitySchema = z.object({
5
- slug: z
6
- .string()
7
- .min(1)
8
- .max(64)
9
- .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
10
- name: z.string().trim().min(1).max(80),
11
- version: z.string().regex(/^\d+\.\d+\.\d+$/),
12
- description: z.object({
13
- en: z.string().trim().min(1),
14
- ru: z.string().trim().min(1),
15
- }),
16
- });
17
-
18
- export const appIdentity = ApplicationIdentitySchema.parse(source);