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
@@ -1,25 +1,57 @@
1
- import type { Metadata } from 'next';
2
- import { env } from '@/env';
1
+ import type { Metadata, MetadataRoute } from 'next';
3
2
  import type { AppLocale } from '@/i18n/locales';
4
3
  import { locales } from '@/i18n/locales';
4
+ import { cacheByOrigin } from './cache-by-origin';
5
5
  import type { SeoPageId } from './pages';
6
- import { getSeoPage, localizedPagePath, SITE_NAME } from './pages';
6
+ import { getSeoPage, localizedPagePath, publicPageIds, SITE_NAME } from './pages';
7
+ import { requestOrigin } from './request-origin';
7
8
 
8
- export const siteOrigin = new URL(env.NEXT_PUBLIC_WEB_URL).origin;
9
+ /** Absolute URL for `path` on the origin this response is being served from. */
10
+ export async function absoluteSiteUrl(path: string): Promise<string> {
11
+ return siteUrl(await requestOrigin(), path);
12
+ }
13
+
14
+ function siteUrl(origin: string, path: string): string {
15
+ return new URL(path, origin).toString();
16
+ }
9
17
 
10
- export function absoluteSiteUrl(path: string): string {
11
- return new URL(path, siteOrigin).toString();
18
+ interface PageMetadataInput {
19
+ origin: string;
20
+ pageId: SeoPageId;
21
+ locale: AppLocale;
12
22
  }
13
23
 
14
- export function createPageMetadata(pageId: SeoPageId, locale: AppLocale): Metadata {
24
+ /**
25
+ * Page metadata for one origin. Pure in its inputs, so it is built once per
26
+ * (origin, page, locale) instead of once per request — the cost of trading a
27
+ * build-time constant for a request-time value, paid once per address.
28
+ */
29
+ const metadataFor = cacheByOrigin(
30
+ ({ origin, pageId, locale }: PageMetadataInput) => `${origin}|${pageId}|${locale}`,
31
+ ({ origin, pageId, locale }: PageMetadataInput) => buildPageMetadata(origin, pageId, locale),
32
+ );
33
+
34
+ export async function createPageMetadata(
35
+ pageId: SeoPageId,
36
+ locale: AppLocale,
37
+ ): Promise<Metadata> {
38
+ return metadataFor({ origin: await requestOrigin(), pageId, locale });
39
+ }
40
+
41
+ /** `metadataBase` for the origin in hand — relative metadata resolves against it. */
42
+ export async function siteMetadataBase(): Promise<URL> {
43
+ return new URL(await requestOrigin());
44
+ }
45
+
46
+ function buildPageMetadata(origin: string, pageId: SeoPageId, locale: AppLocale): Metadata {
15
47
  const page = getSeoPage(pageId, locale);
16
48
  const title = pageId === 'home' ? SITE_NAME : `${page.title} · ${SITE_NAME}`;
17
49
  const canonicalPath = localizedPagePath(pageId, locale);
18
- const image = absoluteSiteUrl(`/api/og/${locale}/${pageId}`);
50
+ const image = siteUrl(origin, `/api/og/${locale}/${pageId}`);
19
51
  const languageAlternates = Object.fromEntries(
20
52
  locales.map((availableLocale) => [
21
53
  availableLocale,
22
- absoluteSiteUrl(localizedPagePath(pageId, availableLocale)),
54
+ siteUrl(origin, localizedPagePath(pageId, availableLocale)),
23
55
  ]),
24
56
  );
25
57
 
@@ -27,7 +59,7 @@ export function createPageMetadata(pageId: SeoPageId, locale: AppLocale): Metada
27
59
  title,
28
60
  description: page.description,
29
61
  alternates: {
30
- canonical: absoluteSiteUrl(canonicalPath),
62
+ canonical: siteUrl(origin, canonicalPath),
31
63
  languages: languageAlternates,
32
64
  },
33
65
  openGraph: {
@@ -35,7 +67,7 @@ export function createPageMetadata(pageId: SeoPageId, locale: AppLocale): Metada
35
67
  siteName: SITE_NAME,
36
68
  title,
37
69
  description: page.description,
38
- url: absoluteSiteUrl(canonicalPath),
70
+ url: siteUrl(origin, canonicalPath),
39
71
  locale: locale === 'ru' ? 'ru_RU' : 'en_US',
40
72
  alternateLocale: locale === 'ru' ? ['en_US'] : ['ru_RU'],
41
73
  images: [{ url: image, width: 1200, height: 630, alt: `${title} — ${page.eyebrow}` }],
@@ -48,3 +80,28 @@ export function createPageMetadata(pageId: SeoPageId, locale: AppLocale): Metada
48
80
  },
49
81
  };
50
82
  }
83
+
84
+ /**
85
+ * Sitemap entries for one origin — the same value for every request that
86
+ * arrives on that address, so it is built once per address.
87
+ */
88
+ export const sitemapForOrigin = cacheByOrigin(
89
+ (origin: string) => origin,
90
+ (origin: string): MetadataRoute.Sitemap =>
91
+ publicPageIds.flatMap(
92
+ (pageId): MetadataRoute.Sitemap =>
93
+ locales.map((locale) => ({
94
+ url: siteUrl(origin, localizedPagePath(pageId, locale)),
95
+ changeFrequency: pageId === 'home' ? 'weekly' : 'monthly',
96
+ priority: pageId === 'home' ? 1 : 0.7,
97
+ alternates: {
98
+ languages: Object.fromEntries(
99
+ locales.map((availableLocale) => [
100
+ availableLocale,
101
+ siteUrl(origin, localizedPagePath(pageId, availableLocale)),
102
+ ]),
103
+ ),
104
+ },
105
+ })),
106
+ ),
107
+ );
@@ -1,4 +1,4 @@
1
- import { appIdentity } from '@app/config/identity';
1
+ import { appIdentity } from '@app/config/app-identity';
2
2
  import { z } from 'zod';
3
3
  import type { AppLocale } from '@/i18n/locales';
4
4
 
@@ -0,0 +1,89 @@
1
+ import { headers } from 'next/headers';
2
+ import { env } from '@/env';
3
+
4
+ /**
5
+ * The public origin this response is being served on.
6
+ *
7
+ * Derived from the REQUEST, never from the build. An absolute address read at
8
+ * build time is frozen into the artifact — `robots.txt` and `sitemap.xml` were
9
+ * prerendered with one origin inside their bytes, so a single build could not
10
+ * serve a second address. Reading the request instead makes one artifact
11
+ * correct at every address it is ever routed to.
12
+ *
13
+ * **The request is not trusted on its own.** `x-forwarded-host` is set by
14
+ * whoever is in front of this process, and a request that reaches the role
15
+ * directly can set it too. An unchecked value would put an attacker's host into
16
+ * canonical URLs, the sitemap and OG metadata — the artifact would be portable
17
+ * and also forgeable. So a forwarded host is honoured only when the deployment
18
+ * has said which hosts it serves, and `PUBLIC_WEB_ORIGIN` remains the way to
19
+ * state a single one.
20
+ *
21
+ * Both are read at RUNTIME (no `NEXT_PUBLIC_` prefix, so nothing is substituted
22
+ * at build time).
23
+ */
24
+ export async function requestOrigin(): Promise<string> {
25
+ const configured = env.PUBLIC_WEB_ORIGIN;
26
+ if (configured) return new URL(configured).origin;
27
+
28
+ const incoming = await headers();
29
+ const host = firstValue(incoming.get('x-forwarded-host') ?? incoming.get('host'));
30
+ if (!host) {
31
+ throw new Error(
32
+ 'Cannot determine the public origin: the request carried no Host header. Set PUBLIC_WEB_ORIGIN.',
33
+ );
34
+ }
35
+ if (!isAllowedHost(host)) {
36
+ throw new Error(
37
+ `Refusing to answer for host "${host}": it is not in PUBLIC_WEB_HOSTS. Set PUBLIC_WEB_ORIGIN for a single address, or PUBLIC_WEB_HOSTS for several.`,
38
+ );
39
+ }
40
+ return new URL(`${protocolOf(incoming.get('x-forwarded-proto'))}://${host}`).origin;
41
+ }
42
+
43
+ /**
44
+ * Which hosts this deployment serves.
45
+ *
46
+ * Empty means "only what `PUBLIC_WEB_ORIGIN` says", and since that short-circuits
47
+ * above, an empty list with no origin set is a deployment that has not been told
48
+ * where it lives — which is an error, not a licence to believe the caller.
49
+ *
50
+ * There is deliberately no wildcard: serving any host is the same as having no
51
+ * canonical origin, and every answer this module gives is a canonical origin.
52
+ */
53
+ function isAllowedHost(host: string): boolean {
54
+ const allowed = env.PUBLIC_WEB_HOSTS?.split(',')
55
+ .map((entry) => entry.trim().toLowerCase())
56
+ .filter((entry) => entry.length > 0);
57
+ if (!allowed || allowed.length === 0) return false;
58
+ // No wildcard. `*` read as "serve any host" — which is the same as having no
59
+ // canonical origin at all, and therefore no meaningful answer to give for a
60
+ // canonical URL, a sitemap or an OG card. A deployment that genuinely serves
61
+ // many addresses lists them; one that does not know which it serves has a
62
+ // configuration problem, not a licence to believe the caller.
63
+ return allowed.includes(host.toLowerCase());
64
+ }
65
+
66
+ /**
67
+ * The scheme this response is being served over.
68
+ *
69
+ * Only two are possible, and anything else is a misconfigured proxy or a
70
+ * forgery — both of which must be seen, not normalised. Mapping every unknown
71
+ * value to `http` meant `ftp`, `javascript` and a truncated header all produced
72
+ * a plausible-looking origin, and the answer went into canonical URLs.
73
+ *
74
+ * A missing header is not a failure: a deployment reached directly has no
75
+ * forwarding layer, and `http` is then the truth.
76
+ */
77
+ function protocolOf(header: string | null): 'http' | 'https' {
78
+ const forwarded = firstValue(header)?.toLowerCase();
79
+ if (forwarded === undefined) return 'http';
80
+ if (forwarded === 'http' || forwarded === 'https') return forwarded;
81
+ throw new Error(
82
+ `Refusing to answer for forwarded protocol "${forwarded}": x-forwarded-proto must be http or https. Fix the proxy, or set PUBLIC_WEB_ORIGIN to state the public origin outright.`,
83
+ );
84
+ }
85
+
86
+ /** A forwarded header may carry the whole proxy chain — the first hop is ours. */
87
+ function firstValue(header: string | null): string | undefined {
88
+ return header?.split(',')[0]?.trim() || undefined;
89
+ }
@@ -1,4 +1,4 @@
1
- import { appIdentity } from '@app/config/identity';
1
+ import { appIdentity } from '@app/config/app-identity';
2
2
  import type { ThemeProviderProps } from '@wrksz/themes/next';
3
3
 
4
4
  export type AppTheme = 'light' | 'dark';
@@ -5,15 +5,22 @@
5
5
  "jsx": "react-jsx",
6
6
  "incremental": true,
7
7
  "types": ["bun"],
8
- "plugins": [{ "name": "next" }],
9
- "paths": { "@/*": ["./src/*"] }
8
+ "plugins": [
9
+ {
10
+ "name": "next"
11
+ }
12
+ ],
13
+ "paths": {
14
+ "@/*": ["./src/*"]
15
+ }
10
16
  },
11
17
  "include": [
12
18
  "next-env.d.ts",
13
19
  "next.config.ts",
14
20
  "src/**/*.ts",
15
21
  "src/**/*.tsx",
16
- ".next/types/**/*.ts"
22
+ ".next/types/**/*.ts",
23
+ "scripts/**/*.ts"
17
24
  ],
18
25
  "exclude": ["node_modules"]
19
26
  }
@@ -16,7 +16,7 @@
16
16
  "zod": "^4.4.3"
17
17
  },
18
18
  "devDependencies": {
19
- "@types/bun": "^1.3.14",
19
+ "@types/bun": "^1.4.0",
20
20
  "typescript": "^7.0.2"
21
21
  }
22
22
  }
@@ -2,7 +2,7 @@ import { defineConfig, devices } from '@playwright/test';
2
2
  import { loadToolingEnv } from './scripts/tooling-env';
3
3
 
4
4
  const toolingEnv = loadToolingEnv();
5
- const baseURL = toolingEnv.PLAYWRIGHT_BASE_URL ?? toolingEnv.NEXT_PUBLIC_WEB_URL;
5
+ const baseURL = toolingEnv.PLAYWRIGHT_BASE_URL ?? toolingEnv.SMOKE_WEB_ORIGIN;
6
6
 
7
7
  export default defineConfig({
8
8
  testDir: './e2e',
@@ -0,0 +1,169 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "kind": "application",
4
+ "identity": {
5
+ "slug": "stitchkit-starter",
6
+ "name": "Stitchkit Starter",
7
+ "version": "0.1.0",
8
+ "description": {
9
+ "en": "Stitchkit Starter is a production application built with Stitchkit.",
10
+ "ru": "Stitchkit Starter — production-приложение на Stitchkit."
11
+ }
12
+ },
13
+ "roles": [
14
+ {
15
+ "name": "api",
16
+ "workingDirectory": "packages/backend",
17
+ "commands": {
18
+ "development": {
19
+ "executable": "bun",
20
+ "args": [
21
+ "--watch",
22
+ "src/index.ts"
23
+ ]
24
+ },
25
+ "production": {
26
+ "executable": "bun",
27
+ "args": [
28
+ "dist/index.js"
29
+ ]
30
+ }
31
+ },
32
+ "listener": {
33
+ "portVariable": "API_PORT",
34
+ "bindVariable": "BIND_HOST",
35
+ "readinessPath": "/health"
36
+ },
37
+ "drainFloorMs": 15000
38
+ },
39
+ {
40
+ "name": "web",
41
+ "workingDirectory": "packages/frontend",
42
+ "commands": {
43
+ "development": {
44
+ "executable": "bun",
45
+ "args": [
46
+ "scripts/serve.ts",
47
+ "development"
48
+ ]
49
+ },
50
+ "production": {
51
+ "executable": "bun",
52
+ "args": [
53
+ "scripts/serve.ts",
54
+ "production"
55
+ ]
56
+ }
57
+ },
58
+ "listener": {
59
+ "portVariable": "WEB_PORT",
60
+ "bindVariable": "BIND_HOST",
61
+ "readinessPath": "/"
62
+ },
63
+ "drainFloorMs": 5000
64
+ }
65
+ ],
66
+ "build": {
67
+ "command": {
68
+ "executable": "bun",
69
+ "args": [
70
+ "run",
71
+ "build"
72
+ ]
73
+ },
74
+ "artifacts": [
75
+ "packages/backend/dist",
76
+ "packages/frontend/.next",
77
+ "packages/db/src/generated"
78
+ ]
79
+ },
80
+ "requires": [
81
+ {
82
+ "name": "postgres",
83
+ "phases": [
84
+ "release",
85
+ "start"
86
+ ]
87
+ }
88
+ ],
89
+ "release": {
90
+ "migrations": {
91
+ "engine": "prisma",
92
+ "root": "packages/db/migrations",
93
+ "lockfile": "packages/db/migrations/migration_lock.toml"
94
+ }
95
+ },
96
+ "env": {
97
+ "variables": [
98
+ {
99
+ "name": "API_PORT",
100
+ "shape": "integer",
101
+ "required": true
102
+ },
103
+ {
104
+ "name": "BIND_HOST",
105
+ "shape": "string",
106
+ "required": false
107
+ },
108
+ {
109
+ "name": "CORS_ORIGIN",
110
+ "shape": "url",
111
+ "required": false
112
+ },
113
+ {
114
+ "name": "DATABASE_URL",
115
+ "shape": "url",
116
+ "required": true
117
+ },
118
+ {
119
+ "name": "INTERNAL_API_URL",
120
+ "shape": "url",
121
+ "required": false
122
+ },
123
+ {
124
+ "name": "LOG_FORMAT",
125
+ "shape": "enum",
126
+ "required": false,
127
+ "members": [
128
+ "pretty",
129
+ "json"
130
+ ]
131
+ },
132
+ {
133
+ "name": "NODE_ENV",
134
+ "shape": "enum",
135
+ "required": false,
136
+ "members": [
137
+ "development",
138
+ "test",
139
+ "production"
140
+ ]
141
+ },
142
+ {
143
+ "name": "PUBLIC_API_ORIGIN",
144
+ "shape": "url",
145
+ "required": false
146
+ },
147
+ {
148
+ "name": "PUBLIC_REALTIME_ORIGIN",
149
+ "shape": "url",
150
+ "required": false
151
+ },
152
+ {
153
+ "name": "PUBLIC_WEB_HOSTS",
154
+ "shape": "string",
155
+ "required": false
156
+ },
157
+ {
158
+ "name": "PUBLIC_WEB_ORIGIN",
159
+ "shape": "url",
160
+ "required": false
161
+ },
162
+ {
163
+ "name": "WEB_PORT",
164
+ "shape": "integer",
165
+ "required": true
166
+ }
167
+ ]
168
+ }
169
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { resolveAcceptanceDatabase } from './acceptance-database';
3
+
4
+ const deployment = 'postgresql://app:secret@127.0.0.1:5432/starter';
5
+
6
+ describe('the acceptance gate writes only to a database of its own', () => {
7
+ test('a distinct database is accepted', () => {
8
+ expect(
9
+ resolveAcceptanceDatabase({
10
+ DATABASE_URL: deployment,
11
+ ACCEPTANCE_DATABASE_URL: 'postgresql://app:secret@127.0.0.1:5432/starter_acceptance',
12
+ }),
13
+ ).toBe('postgresql://app:secret@127.0.0.1:5432/starter_acceptance');
14
+ });
15
+
16
+ test('an unset variable is refused with the line to paste', () => {
17
+ // The gate used to inherit DATABASE_URL. Defaulting to it is exactly the
18
+ // behaviour this replaces, so absence has to be a refusal, not a fallback.
19
+ expect(() => resolveAcceptanceDatabase({ DATABASE_URL: deployment })).toThrow(
20
+ /ACCEPTANCE_DATABASE_URL=postgresql:\/\/app:secret@127\.0\.0\.1:5432\/starter_acceptance/,
21
+ );
22
+ });
23
+
24
+ test('the same database written differently is still the same database', () => {
25
+ // Credentials and the scheme do not make it another database; host, port
26
+ // and name do. A check on the raw string would pass this and then migrate
27
+ // the deployment.
28
+ expect(() =>
29
+ resolveAcceptanceDatabase({
30
+ DATABASE_URL: deployment,
31
+ ACCEPTANCE_DATABASE_URL: 'postgres://someone:else@127.0.0.1/starter',
32
+ }),
33
+ ).toThrow(/which is the one DATABASE_URL names/);
34
+ });
35
+
36
+ test('a default port on one side and 5432 on the other is one database', () => {
37
+ expect(() =>
38
+ resolveAcceptanceDatabase({
39
+ DATABASE_URL: 'postgresql://app@db.internal/starter',
40
+ ACCEPTANCE_DATABASE_URL: 'postgresql://app@db.internal:5432/starter',
41
+ }),
42
+ ).toThrow(/which is the one DATABASE_URL names/);
43
+ });
44
+
45
+ test('the same name on another hostname is refused, because a hostname proves nothing', () => {
46
+ // `localhost` and `127.0.0.1` are one server; so are two DNS names for the
47
+ // same PostgreSQL. A guard that compared `host:port/name` called these two
48
+ // different databases and let the gate migrate the deployment's own.
49
+ expect(() =>
50
+ resolveAcceptanceDatabase({
51
+ DATABASE_URL: 'postgresql://app@localhost:5432/starter',
52
+ ACCEPTANCE_DATABASE_URL: 'postgresql://app@127.0.0.1:5432/starter',
53
+ }),
54
+ ).toThrow(/A different host is not proof of a different server/);
55
+ });
56
+
57
+ test('a different name on the same host is fine — the name is what decides', () => {
58
+ // The control. Without it the rule "refuse everything" would pass the case
59
+ // above, and no acceptance database would ever be accepted.
60
+ expect(
61
+ resolveAcceptanceDatabase({
62
+ DATABASE_URL: 'postgresql://app@localhost:5432/starter',
63
+ ACCEPTANCE_DATABASE_URL: 'postgresql://app@localhost:5432/starter_acceptance',
64
+ }),
65
+ ).toContain('starter_acceptance');
66
+ });
67
+
68
+ test('a value that is not a URL is named, not silently used', () => {
69
+ expect(() =>
70
+ resolveAcceptanceDatabase({ ACCEPTANCE_DATABASE_URL: 'starter_acceptance' }),
71
+ ).toThrow(/ACCEPTANCE_DATABASE_URL is not a valid URL/);
72
+ });
73
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The database the local acceptance gate is allowed to write to.
3
+ *
4
+ * `acceptance:local` creates and destroys its own deployment — its own PM2 home,
5
+ * its own ephemeral ports, its own host allowlist. The database was the one
6
+ * thing it still borrowed: it inherited `DATABASE_URL`, and the runtime gates
7
+ * WRITE. The repository example's smoke posts `/api/repository/refresh` twice,
8
+ * which upserts. So a gate a developer is told to run before handing work off
9
+ * wrote rows into whatever `.env` happened to name — including a production
10
+ * database, if that is what the machine was pointed at.
11
+ *
12
+ * Fail-closed on purpose. An acceptance database that is merely *probably*
13
+ * separate is the same defect one edit later, so an unset variable and one that
14
+ * names the deployment's own database are both refused before a role starts,
15
+ * and the refusal carries the line to paste.
16
+ */
17
+
18
+ /**
19
+ * The database NAME, which is all a refusal may rely on.
20
+ *
21
+ * Comparing `host:port/name` catches the ordinary mistake and misses the ones
22
+ * that matter: `localhost` and `127.0.0.1` are one server, and so are two DNS
23
+ * names pointing at the same PostgreSQL. A guard that reads those as different
24
+ * databases lets this gate migrate and write into the deployment's own.
25
+ *
26
+ * A hostname is not proof of a different server, so it does not take part in
27
+ * the decision. The cost is a false refusal when two genuinely separate servers
28
+ * host a database of the same name — answered by renaming the throwaway one,
29
+ * which the message asks for. That is the trade a fail-closed gate is supposed
30
+ * to make.
31
+ */
32
+ function databaseName(url: URL): string {
33
+ return decodeURIComponent(url.pathname.replace(/^\//, '').replace(/\/+$/, ''));
34
+ }
35
+
36
+ function parse(name: string, value: string): URL {
37
+ try {
38
+ return new URL(value);
39
+ } catch {
40
+ throw new Error(`${name} is not a valid URL.`);
41
+ }
42
+ }
43
+
44
+ /** `…/app` → `…/app_acceptance`, so the refusal can name a line worth pasting. */
45
+ function suggestionFrom(deploymentUrl: string | undefined): string {
46
+ if (!deploymentUrl) return 'postgresql://USER:PASSWORD@127.0.0.1:5432/acceptance';
47
+ try {
48
+ const url = new URL(deploymentUrl);
49
+ url.pathname = `${url.pathname.replace(/\/$/, '')}_acceptance`;
50
+ return url.toString();
51
+ } catch {
52
+ return 'postgresql://USER:PASSWORD@127.0.0.1:5432/acceptance';
53
+ }
54
+ }
55
+
56
+ /**
57
+ * The acceptance database URL, or a refusal explaining exactly what to add.
58
+ *
59
+ * Takes the environment rather than reading it, so the rule that keeps a gate
60
+ * off the deployment's database is testable without one.
61
+ */
62
+ export function resolveAcceptanceDatabase(
63
+ environment: Record<string, string | undefined>,
64
+ ): string {
65
+ const acceptance = environment.ACCEPTANCE_DATABASE_URL?.trim();
66
+ const deployment = environment.DATABASE_URL?.trim();
67
+
68
+ if (!acceptance) {
69
+ throw new Error(
70
+ 'ACCEPTANCE_DATABASE_URL is not set, and `bun run acceptance:local` will not write to the ' +
71
+ 'database this deployment uses. Add a line naming a throwaway database to `.env`:\n' +
72
+ ` ACCEPTANCE_DATABASE_URL=${suggestionFrom(deployment)}`,
73
+ );
74
+ }
75
+
76
+ const acceptanceUrl = parse('ACCEPTANCE_DATABASE_URL', acceptance);
77
+ if (deployment) {
78
+ const deploymentUrl = parse('DATABASE_URL', deployment);
79
+ if (databaseName(acceptanceUrl) === databaseName(deploymentUrl)) {
80
+ throw new Error(
81
+ `ACCEPTANCE_DATABASE_URL uses the database name "${databaseName(acceptanceUrl)}", which is ` +
82
+ 'the one DATABASE_URL names. A different host is not proof of a different server — ' +
83
+ '`localhost` and `127.0.0.1` are one, and so are two DNS names for the same ' +
84
+ 'PostgreSQL — and this gate applies migrations and writes rows. Give it a name of ' +
85
+ 'its own:\n' +
86
+ ` ACCEPTANCE_DATABASE_URL=${suggestionFrom(deployment)}`,
87
+ );
88
+ }
89
+ }
90
+
91
+ return acceptanceUrl.toString();
92
+ }