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.
- package/CHANGELOG.md +347 -0
- package/README.md +3 -1
- package/UPGRADING.md +342 -0
- package/dist/cli.js +238 -42
- package/examples/repository/_env.example.append +21 -0
- package/examples/repository/packages/backend/src/domain/repository/github-cache.ts +2 -2
- package/examples/repository/packages/backend/src/surface.ts +1 -1
- package/examples/repository/packages/config/src/features.ts +17 -0
- package/examples/repository/packages/frontend/src/app/[locale]/page.tsx +4 -4
- package/examples/repository/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
- package/examples/repository/packages/frontend/src/app/api/[...path]/route.ts +66 -0
- package/examples/repository/packages/frontend/src/lib/api/client.ts +17 -12
- package/examples/repository/packages/frontend/src/lib/api/cross-origin.ts +87 -0
- package/examples/repository/packages/frontend/src/lib/api/place.ts +26 -0
- package/examples/repository/packages/frontend/src/lib/api/queries.ts +3 -0
- package/examples/repository/packages/frontend/src/lib/api/server-client.ts +11 -0
- package/examples/repository/packages/frontend/src/lib/realtime/repository.ts +44 -12
- package/examples/repository/packages/frontend/src/providers/client-providers.tsx +30 -0
- package/examples/repository/packages/frontend/src/providers/index.tsx +17 -12
- package/examples/repository/packages/frontend/src/providers/realtime.tsx +6 -4
- package/examples/repository/project.json +189 -0
- package/examples/repository/scripts/runtime-smoke.ts +38 -6
- package/package.json +12 -2
- package/template/AGENTS.md +23 -3
- package/template/README.md +83 -8
- package/template/_env.example +16 -4
- package/template/_gitignore +1 -0
- package/template/biome.json +6 -2
- package/template/bun.lock +115 -98
- package/template/e2e/starter.spec.ts +5 -7
- package/template/ecosystem.config.cjs +42 -19
- package/template/ecosystem.dev.config.cjs +41 -21
- package/template/package.json +12 -10
- package/template/packages/backend/package.json +2 -2
- package/template/packages/backend/src/cleanup.ts +121 -0
- package/template/packages/backend/src/cli.ts +6 -2
- package/template/packages/backend/src/index.ts +33 -8
- package/template/packages/backend/src/surface.ts +6 -1
- package/template/packages/backend/src/transport/errors.ts +4 -2
- package/template/packages/config/package.json +6 -2
- package/template/packages/config/src/app-identity.generated.ts +20 -0
- package/template/packages/config/src/declaration.ts +30 -0
- package/template/packages/config/src/server.ts +8 -17
- package/template/packages/config/src/shutdown.ts +20 -0
- package/template/packages/config/src/variables.ts +89 -0
- package/template/packages/db/package.json +2 -2
- package/template/packages/frontend/next.config.ts +3 -2
- package/template/packages/frontend/package.json +13 -13
- package/template/packages/frontend/scripts/serve.ts +70 -0
- package/template/packages/frontend/src/app/[locale]/layout.tsx +9 -8
- package/template/packages/frontend/src/app/[locale]/page.tsx +2 -2
- package/template/packages/frontend/src/app/[locale]/starter-page.tsx +2 -2
- package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +1 -1
- package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +1 -1
- package/template/packages/frontend/src/app/robots.ts +4 -2
- package/template/packages/frontend/src/app/sitemap.ts +7 -19
- package/template/packages/frontend/src/components/ui/toaster.tsx +4 -1
- package/template/packages/frontend/src/env.ts +27 -8
- package/template/packages/frontend/src/lib/seo/cache-by-origin.test.ts +68 -0
- package/template/packages/frontend/src/lib/seo/cache-by-origin.ts +40 -0
- package/template/packages/frontend/src/lib/seo/metadata.ts +68 -11
- package/template/packages/frontend/src/lib/seo/pages.ts +1 -1
- package/template/packages/frontend/src/lib/seo/request-origin.ts +89 -0
- package/template/packages/frontend/src/theme/config.ts +1 -1
- package/template/packages/frontend/tsconfig.json +10 -3
- package/template/packages/shared/package.json +1 -1
- package/template/playwright.config.ts +1 -1
- package/template/project.json +169 -0
- 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 +69 -0
- package/template/scripts/build-inputs.ts +58 -0
- package/template/scripts/build-stamp.test.ts +151 -0
- package/template/scripts/build-stamp.ts +169 -0
- package/template/scripts/check-authored.ts +18 -2
- package/template/scripts/client-boundary.test.ts +117 -0
- package/template/scripts/client-boundary.ts +148 -0
- package/template/scripts/declaration.test.ts +206 -0
- package/template/scripts/declaration.ts +271 -0
- package/template/scripts/deployment-preflight.ts +41 -0
- package/template/scripts/dev.ts +43 -20
- package/template/scripts/local-env.test.ts +2 -2
- package/template/scripts/local-env.ts +9 -3
- package/template/scripts/readiness.ts +92 -0
- package/template/scripts/release-steps.test.ts +87 -0
- package/template/scripts/release-steps.ts +112 -0
- package/template/scripts/release.ts +38 -0
- package/template/scripts/runtime-smoke.test.ts +178 -0
- package/template/scripts/runtime-smoke.ts +21 -5
- package/template/scripts/serve-mode.test.ts +36 -0
- package/template/scripts/shutdown-budget.fixture.ts +29 -0
- package/template/scripts/shutdown-budget.test.ts +164 -0
- package/template/scripts/supervision-signal.test.ts +94 -0
- package/template/scripts/surface-conformance.ts +8 -1
- package/template/scripts/tooling-env.ts +35 -3
- package/template/scripts/web-surface-smoke.ts +183 -2
- package/template/app.config.json +0 -9
- 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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
18
|
+
interface PageMetadataInput {
|
|
19
|
+
origin: string;
|
|
20
|
+
pageId: SeoPageId;
|
|
21
|
+
locale: AppLocale;
|
|
12
22
|
}
|
|
13
23
|
|
|
14
|
-
|
|
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 =
|
|
50
|
+
const image = siteUrl(origin, `/api/og/${locale}/${pageId}`);
|
|
19
51
|
const languageAlternates = Object.fromEntries(
|
|
20
52
|
locales.map((availableLocale) => [
|
|
21
53
|
availableLocale,
|
|
22
|
-
|
|
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:
|
|
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:
|
|
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
|
+
);
|
|
@@ -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
|
+
}
|
|
@@ -5,15 +5,22 @@
|
|
|
5
5
|
"jsx": "react-jsx",
|
|
6
6
|
"incremental": true,
|
|
7
7
|
"types": ["bun"],
|
|
8
|
-
"plugins": [
|
|
9
|
-
|
|
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
|
}
|
|
@@ -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.
|
|
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
|
+
}
|