@supatype/cli 0.1.6 → 0.1.7
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/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +144 -94
- package/.turbo/turbo-typecheck.log +1 -1
- package/deno.d.ts +52 -0
- package/dist/augmentation-generator.d.ts +22 -0
- package/dist/augmentation-generator.d.ts.map +1 -1
- package/dist/augmentation-generator.js +18 -4
- package/dist/augmentation-generator.js.map +1 -1
- package/dist/binary-cache.d.ts +14 -0
- package/dist/binary-cache.d.ts.map +1 -1
- package/dist/binary-cache.js +33 -13
- package/dist/binary-cache.js.map +1 -1
- package/dist/cache-clean.d.ts +15 -0
- package/dist/cache-clean.d.ts.map +1 -0
- package/dist/cache-clean.js +76 -0
- package/dist/cache-clean.js.map +1 -0
- package/dist/cache-pins.d.ts +51 -0
- package/dist/cache-pins.d.ts.map +1 -0
- package/dist/cache-pins.js +184 -0
- package/dist/cache-pins.js.map +1 -0
- package/dist/cli-install-method.d.ts +19 -0
- package/dist/cli-install-method.d.ts.map +1 -0
- package/dist/cli-install-method.js +60 -0
- package/dist/cli-install-method.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -0
- package/dist/cli.js.map +1 -1
- package/dist/cloud-credentials.d.ts +34 -0
- package/dist/cloud-credentials.d.ts.map +1 -0
- package/dist/cloud-credentials.js +139 -0
- package/dist/cloud-credentials.js.map +1 -0
- package/dist/commands/admin.d.ts +11 -0
- package/dist/commands/admin.d.ts.map +1 -1
- package/dist/commands/admin.js +121 -19
- package/dist/commands/admin.js.map +1 -1
- package/dist/commands/cache.d.ts.map +1 -1
- package/dist/commands/cache.js +67 -71
- package/dist/commands/cache.js.map +1 -1
- package/dist/commands/cloud.d.ts.map +1 -1
- package/dist/commands/cloud.js +32 -8
- package/dist/commands/cloud.js.map +1 -1
- package/dist/commands/db.d.ts +1 -0
- package/dist/commands/db.d.ts.map +1 -1
- package/dist/commands/db.js +147 -1
- package/dist/commands/db.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +50 -10
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/doctor.d.ts +28 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +60 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/functions.d.ts +7 -0
- package/dist/commands/functions.d.ts.map +1 -1
- package/dist/commands/functions.js +40 -6
- package/dist/commands/functions.js.map +1 -1
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +5 -1
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/hooks.d.ts +12 -0
- package/dist/commands/hooks.d.ts.map +1 -0
- package/dist/commands/hooks.js +118 -0
- package/dist/commands/hooks.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +41 -7
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/internal.d.ts +6 -0
- package/dist/commands/internal.d.ts.map +1 -0
- package/dist/commands/internal.js +22 -0
- package/dist/commands/internal.js.map +1 -0
- package/dist/commands/link-helpers.d.ts.map +1 -1
- package/dist/commands/link-helpers.js +8 -2
- package/dist/commands/link-helpers.js.map +1 -1
- package/dist/commands/login.d.ts +7 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +110 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +110 -7
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/self-update.d.ts +7 -0
- package/dist/commands/self-update.d.ts.map +1 -1
- package/dist/commands/self-update.js +18 -11
- package/dist/commands/self-update.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -3
- package/dist/config.js.map +1 -1
- package/dist/db-preflight.d.ts +78 -0
- package/dist/db-preflight.d.ts.map +1 -0
- package/dist/db-preflight.js +452 -0
- package/dist/db-preflight.js.map +1 -0
- package/dist/dev-compose.d.ts +1 -1
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +127 -72
- package/dist/dev-compose.js.map +1 -1
- package/dist/env-file.d.ts +40 -2
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +132 -11
- package/dist/env-file.js.map +1 -1
- package/dist/field-masking-tier.d.ts +50 -0
- package/dist/field-masking-tier.d.ts.map +1 -0
- package/dist/field-masking-tier.js +91 -0
- package/dist/field-masking-tier.js.map +1 -0
- package/dist/functions-deno-types.d.ts +24 -0
- package/dist/functions-deno-types.d.ts.map +1 -0
- package/dist/functions-deno-types.js +155 -0
- package/dist/functions-deno-types.js.map +1 -0
- package/dist/hook-upload.d.ts +30 -0
- package/dist/hook-upload.d.ts.map +1 -0
- package/dist/hook-upload.js +86 -0
- package/dist/hook-upload.js.map +1 -0
- package/dist/hooks-generator.d.ts +8 -0
- package/dist/hooks-generator.d.ts.map +1 -0
- package/dist/hooks-generator.js +332 -0
- package/dist/hooks-generator.js.map +1 -0
- package/dist/link.d.ts +4 -0
- package/dist/link.d.ts.map +1 -1
- package/dist/link.js +2 -0
- package/dist/link.js.map +1 -1
- package/dist/local-secrets.d.ts +71 -0
- package/dist/local-secrets.d.ts.map +1 -0
- package/dist/local-secrets.js +100 -0
- package/dist/local-secrets.js.map +1 -0
- package/dist/model-hooks.d.ts +73 -0
- package/dist/model-hooks.d.ts.map +1 -0
- package/dist/model-hooks.js +211 -0
- package/dist/model-hooks.js.map +1 -0
- package/dist/postgres-ctl.d.ts +11 -0
- package/dist/postgres-ctl.d.ts.map +1 -1
- package/dist/postgres-ctl.js +25 -2
- package/dist/postgres-ctl.js.map +1 -1
- package/dist/project-config.d.ts +121 -1
- package/dist/project-config.d.ts.map +1 -1
- package/dist/project-config.js +150 -1
- package/dist/project-config.js.map +1 -1
- package/dist/resolve-target.d.ts +4 -0
- package/dist/resolve-target.d.ts.map +1 -1
- package/dist/resolve-target.js +69 -73
- package/dist/resolve-target.js.map +1 -1
- package/dist/schema-ast-v2.d.ts +2 -1
- package/dist/schema-ast-v2.d.ts.map +1 -1
- package/dist/schema-ast-v2.js +4 -2
- package/dist/schema-ast-v2.js.map +1 -1
- package/dist/schema-push-lock.d.ts.map +1 -1
- package/dist/schema-push-lock.js +43 -16
- package/dist/schema-push-lock.js.map +1 -1
- package/dist/self-host-compose.d.ts +24 -0
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +301 -69
- package/dist/self-host-compose.js.map +1 -1
- package/dist/service-role-check.d.ts +21 -0
- package/dist/service-role-check.d.ts.map +1 -0
- package/dist/service-role-check.js +85 -0
- package/dist/service-role-check.js.map +1 -0
- package/dist/target-client.d.ts +17 -0
- package/dist/target-client.d.ts.map +1 -1
- package/dist/target-client.js +69 -17
- package/dist/target-client.js.map +1 -1
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +736 -32
- package/dist/type-extractor.js.map +1 -1
- package/dist/ui/runtime/command-chrome.js +1 -1
- package/dist/ui/runtime/command-chrome.js.map +1 -1
- package/dist/ui/runtime/stdin-after-ink.d.ts.map +1 -1
- package/dist/ui/runtime/stdin-after-ink.js +3 -1
- package/dist/ui/runtime/stdin-after-ink.js.map +1 -1
- package/package.json +4 -1
- package/scripts/generate-homebrew-formula.mjs +121 -0
- package/src/augmentation-generator.ts +18 -5
- package/src/binary-cache.ts +47 -15
- package/src/cache-clean.ts +81 -0
- package/src/cache-pins.ts +243 -0
- package/src/cli-install-method.ts +67 -0
- package/src/cli.ts +6 -0
- package/src/cloud-credentials.ts +183 -0
- package/src/commands/admin.ts +140 -25
- package/src/commands/cache.ts +90 -75
- package/src/commands/cloud.ts +32 -8
- package/src/commands/db.ts +162 -1
- package/src/commands/dev.ts +57 -8
- package/src/commands/doctor.ts +69 -3
- package/src/commands/functions.ts +42 -5
- package/src/commands/generate.ts +5 -1
- package/src/commands/hooks.ts +138 -0
- package/src/commands/init.ts +42 -8
- package/src/commands/internal.ts +23 -0
- package/src/commands/link-helpers.ts +9 -2
- package/src/commands/login.ts +127 -0
- package/src/commands/push.ts +124 -7
- package/src/commands/self-update.ts +85 -13
- package/src/config.ts +22 -3
- package/src/db-preflight.ts +575 -0
- package/src/dev-compose.ts +142 -73
- package/src/env-file.ts +157 -11
- package/src/field-masking-tier.ts +107 -0
- package/src/functions-deno-types.ts +188 -0
- package/src/hook-upload.ts +107 -0
- package/src/hooks-generator.ts +352 -0
- package/src/link.ts +7 -0
- package/src/local-secrets.ts +104 -0
- package/src/model-hooks.ts +260 -0
- package/src/postgres-ctl.ts +29 -2
- package/src/project-config.ts +236 -2
- package/src/resolve-target.ts +113 -68
- package/src/schema-ast-v2.ts +5 -2
- package/src/schema-push-lock.ts +56 -17
- package/src/self-host-compose.ts +344 -68
- package/src/service-role-check.ts +111 -0
- package/src/target-client.ts +105 -18
- package/src/type-extractor.ts +883 -28
- package/src/ui/runtime/command-chrome.ts +1 -1
- package/src/ui/runtime/stdin-after-ink.ts +3 -1
- package/tests/access-composition.test.ts +667 -0
- package/tests/admin-studio-membership.test.ts +121 -0
- package/tests/api-schemas.test.ts +83 -0
- package/tests/archive-name-alias.test.ts +42 -0
- package/tests/augmentation-generator.test.ts +28 -0
- package/tests/cache-clean.test.ts +206 -0
- package/tests/cli-help.test.ts +3 -0
- package/tests/cli-install-method.test.ts +66 -0
- package/tests/command-chrome-coverage.test.ts +31 -26
- package/tests/command-chrome-wrap.test.ts +1 -0
- package/tests/db-preflight.test.ts +72 -0
- package/tests/doctor-render.test.ts +62 -0
- package/tests/doctor-service-role.test.ts +55 -0
- package/tests/env-file.test.ts +121 -0
- package/tests/external-database-compose.test.ts +184 -0
- package/tests/external-database-config.test.ts +154 -0
- package/tests/field-masking-tier.test.ts +184 -0
- package/tests/functions-deno-types.test.ts +197 -0
- package/tests/homebrew-formula.test.ts +58 -0
- package/tests/hook-upload.test.ts +125 -0
- package/tests/hooks-generator.test.ts +212 -0
- package/tests/init.test.ts +3 -0
- package/tests/local-secrets.test.ts +92 -0
- package/tests/model-hooks.test.ts +322 -0
- package/tests/runtime-contract.test.ts +113 -1
- package/tests/service-role-check.test.ts +109 -0
- package/tests/service-role-routes.test.ts +51 -0
- package/tests/target-client-refresh.test.ts +94 -0
- package/tests/type-extractor.test.ts +187 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/tests/engine-distribution.test.ts +0 -418
package/src/self-host-compose.ts
CHANGED
|
@@ -3,9 +3,21 @@ import { dirname, join, relative, resolve } from "node:path"
|
|
|
3
3
|
import { spawnSync } from "node:child_process"
|
|
4
4
|
import { requireDockerDaemon, type DockerBrandOptions } from "./docker-runtime.js"
|
|
5
5
|
import { fatalError } from "./ui/fatal.js"
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
apiSchemaList,
|
|
8
|
+
externalDatabaseUrl,
|
|
9
|
+
hooksPathFromProject,
|
|
10
|
+
preferredFunctionsPathFromProject,
|
|
11
|
+
realtimeEnabled,
|
|
12
|
+
serviceRoleRoutes,
|
|
13
|
+
selfHostTlsEnabled,
|
|
14
|
+
usesExternalDatabase,
|
|
15
|
+
type SupatypeProjectConfig,
|
|
16
|
+
} from "./project-config.js"
|
|
7
17
|
import { hasEngineOverride, hasStudioOverride, pinnedVersion, fetchLatestVersion, VERSION_PIN_LOCAL } from "./binary-cache.js"
|
|
8
18
|
import { buildKongDeclarative } from "./kong-config.js"
|
|
19
|
+
import { readEnvFile } from "./env-file.js"
|
|
20
|
+
import { fieldMaskingTierFromProject, type FieldMaskingTier } from "./field-masking-tier.js"
|
|
9
21
|
|
|
10
22
|
/** Env keys written when `versions` pins exist in supatype.config.ts. */
|
|
11
23
|
export const COMPOSE_PINNED_IMAGE_ENV_KEYS = [
|
|
@@ -181,6 +193,47 @@ function kongMountPath(_cwd: string): string {
|
|
|
181
193
|
return ".supatype/self-host/kong.yml"
|
|
182
194
|
}
|
|
183
195
|
|
|
196
|
+
/**
|
|
197
|
+
* The DSN every service uses for the schema-owning role.
|
|
198
|
+
*
|
|
199
|
+
* One expression instead of six identical constructions of `@db:5432`. With an external database it
|
|
200
|
+
* becomes `${DATABASE_URL:?…}`: the URL is the operator's, so there is nothing for the generator to
|
|
201
|
+
* build — and interpolating the value into the compose file would write a password into a generated
|
|
202
|
+
* file. Compose resolves the variable from `.env` at up-time, the same place the config's
|
|
203
|
+
* `process.env.DATABASE_URL` read it from.
|
|
204
|
+
*/
|
|
205
|
+
function ownerDatabaseUrl(config: SupatypeProjectConfig, scheme = "postgresql"): string {
|
|
206
|
+
if (usesExternalDatabase(config)) {
|
|
207
|
+
return "${DATABASE_URL:?DATABASE_URL is missing from .env — required by database.external}"
|
|
208
|
+
}
|
|
209
|
+
return (
|
|
210
|
+
`${scheme}://\${POSTGRES_USER:-supatype_admin}:` +
|
|
211
|
+
"\${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is missing from .env}" +
|
|
212
|
+
"@db:5432/\${POSTGRES_DB:-supatype}"
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* PostgREST's DSN, which connects as `authenticator` rather than the owner.
|
|
218
|
+
*
|
|
219
|
+
* For an external database the host, port, database and query string are taken from the operator's
|
|
220
|
+
* URL and only the password stays a variable. The alternative — a second full URL in `.env` — is one
|
|
221
|
+
* more thing to keep in step with the first, and two URLs that disagree about *which database* is a
|
|
222
|
+
* split-brain nobody notices until the API is serving one and migrations are landing in the other.
|
|
223
|
+
* A hostname is not the secret here; the password is, and it stays out of the generated file.
|
|
224
|
+
*/
|
|
225
|
+
function postgrestDatabaseUrl(config: SupatypeProjectConfig): string {
|
|
226
|
+
const password = "${AUTHENTICATOR_PASSWORD:?AUTHENTICATOR_PASSWORD is missing from .env}"
|
|
227
|
+
const externalUrl = externalDatabaseUrl(config)
|
|
228
|
+
if (externalUrl === undefined) {
|
|
229
|
+
return `postgresql://authenticator:${password}@db:5432/\${POSTGRES_DB:-supatype}`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const parsed = new URL(externalUrl)
|
|
233
|
+
const port = parsed.port ? `:${parsed.port}` : ""
|
|
234
|
+
return `postgresql://authenticator:${password}@${parsed.hostname}${port}${parsed.pathname}${parsed.search}`
|
|
235
|
+
}
|
|
236
|
+
|
|
184
237
|
/** Host Vite dev server as seen from Kong inside Docker Compose. */
|
|
185
238
|
export const COMPOSE_STUDIO_HOST_URL = "http://host.docker.internal:3002"
|
|
186
239
|
|
|
@@ -225,6 +278,14 @@ function serverAppEnvForCompose(config: SupatypeProjectConfig, devLocal: boolean
|
|
|
225
278
|
export interface SelfHostComposeOptions {
|
|
226
279
|
/** `supatype dev` with provider docker: internal-only db/server; Kong on host :18473. */
|
|
227
280
|
devLocal?: boolean
|
|
281
|
+
/**
|
|
282
|
+
* Which mechanism enforces per-column rules, when the caller knows.
|
|
283
|
+
*
|
|
284
|
+
* Only the call sites that have loaded the schema can say — see `field-masking-tier.ts`. Left
|
|
285
|
+
* unset, the exposed schema list is today's, which is correct for every project without field
|
|
286
|
+
* rules and for every project on `supatype/postgres`.
|
|
287
|
+
*/
|
|
288
|
+
fieldMaskingTier?: FieldMaskingTier
|
|
228
289
|
}
|
|
229
290
|
|
|
230
291
|
export function renderSelfHostCompose(
|
|
@@ -234,6 +295,14 @@ export function renderSelfHostCompose(
|
|
|
234
295
|
): string {
|
|
235
296
|
const projectMount = projectMountPath(cwd)
|
|
236
297
|
const kongMount = kongMountPath(cwd)
|
|
298
|
+
const external = usesExternalDatabase(config)
|
|
299
|
+
const ownerUrl = ownerDatabaseUrl(config)
|
|
300
|
+
// GoTrue's driver wants the `postgres://` spelling; an external URL is used as given.
|
|
301
|
+
const gotrueUrl = external ? ownerUrl : ownerDatabaseUrl(config, "postgres")
|
|
302
|
+
// An external URL may already carry query parameters (`?sslmode=require` is common on managed
|
|
303
|
+
// providers), and appending a second `?` produces a DSN that fails to parse.
|
|
304
|
+
const gotrueSearchPathSeparator = externalDatabaseUrl(config)?.includes("?") ? "&" : "?"
|
|
305
|
+
const postgrestUrl = postgrestDatabaseUrl(config)
|
|
237
306
|
const devLocal = options?.devLocal === true
|
|
238
307
|
const tlsEnabled = selfHostTlsEnabled(config, devLocal)
|
|
239
308
|
const domain = config.server.domain?.trim() ?? ""
|
|
@@ -308,61 +377,126 @@ ${studioService}
|
|
|
308
377
|
# server: { mode: "standalone", domain: "your.domain", tls: { email: "you@example.com" } }
|
|
309
378
|
# then re-run \`supatype self-host compose up -d\`. Kong publishes :80/:443 and provisions certs automatically.
|
|
310
379
|
`
|
|
380
|
+
// An external database is not ours to declare a volume for.
|
|
311
381
|
const volumesBlock = `volumes:
|
|
312
|
-
db-data:
|
|
313
|
-
minio-data:
|
|
382
|
+
${external ? "" : " db-data:\n"} minio-data:
|
|
314
383
|
valkey-data:
|
|
315
384
|
`
|
|
316
385
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
386
|
+
// `depends_on` for the services that talk to Postgres. With an external database there is no
|
|
387
|
+
// container to wait on, so the clause disappears entirely and each service retries on connect
|
|
388
|
+
// instead — which is also what covers a database that restarts *after* boot, something no
|
|
389
|
+
// healthcheck ever did.
|
|
390
|
+
const dbDependencyClause = external
|
|
391
|
+
? ""
|
|
392
|
+
: ` db:
|
|
393
|
+
condition: service_healthy
|
|
394
|
+
`
|
|
395
|
+
const dbDependency = external ? "" : ` depends_on:\n${dbDependencyClause}`
|
|
396
|
+
|
|
397
|
+
// Realtime, omitted entirely when the project has turned it off.
|
|
398
|
+
//
|
|
399
|
+
// Not started-and-disabled: the service degrades gracefully on its own (it reports the reason on
|
|
400
|
+
// /health/ready rather than crash-looping), so this switch is for the operator who has read
|
|
401
|
+
// `supatype db check`, knows their database cannot do logical decoding, and would rather not run a
|
|
402
|
+
// container that can only report that. The server then has no realtime URL, which is what makes
|
|
403
|
+
// subscription requests fail with a clear route error instead of hanging against a dead upstream.
|
|
404
|
+
const realtime = realtimeEnabled(config)
|
|
405
|
+
const realtimeBlock = realtime
|
|
406
|
+
? ` realtime:
|
|
407
|
+
image: \${SUPATYPE_REALTIME_IMAGE:-supatype/realtime:latest}
|
|
408
|
+
expose:
|
|
409
|
+
- "4000"
|
|
410
|
+
environment:
|
|
411
|
+
PORT: "4000"
|
|
412
|
+
DATABASE_URL: "${ownerUrl}"
|
|
413
|
+
JWT_SECRET: \${JWT_SECRET:?JWT_SECRET is missing from .env}
|
|
414
|
+
SLOT_NAME: supatype_realtime
|
|
415
|
+
# Matches the publication the supatype/postgres image creates. Read by nothing today —
|
|
416
|
+
# wal2json decodes from the slot — and kept for a future pgoutput decoder.
|
|
417
|
+
PUBLICATION_NAME: supatype_realtime
|
|
418
|
+
${dbDependency}`
|
|
419
|
+
: ` # No \`realtime\` service: database.external.realtime is false. Subscriptions are
|
|
420
|
+
# unavailable; REST, storage, auth and functions are unaffected.
|
|
421
|
+
`
|
|
422
|
+
const realtimeDependency = realtime
|
|
423
|
+
? ` realtime:
|
|
424
|
+
condition: service_started
|
|
425
|
+
`
|
|
426
|
+
: ""
|
|
427
|
+
const realtimeServerEnv = realtime ? " SUPATYPE_REALTIME_URL: http://realtime:4000" : ""
|
|
428
|
+
|
|
429
|
+
const dbServiceBlock = external
|
|
430
|
+
? ` # No \`db\` service: database.external points this stack at a Postgres it does not manage.
|
|
431
|
+
# Every service reads \${DATABASE_URL} from .env, and \`supatype db check\` reports what that
|
|
432
|
+
# database still needs (roles, extensions, wal_level for realtime).
|
|
433
|
+
`
|
|
434
|
+
: ` db:
|
|
321
435
|
image: \${SUPATYPE_POSTGRES_IMAGE:-supatype/postgres:latest}
|
|
322
436
|
environment:
|
|
323
437
|
POSTGRES_USER: \${POSTGRES_USER:-supatype_admin}
|
|
324
|
-
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD
|
|
438
|
+
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is missing from .env}
|
|
325
439
|
POSTGRES_DB: \${POSTGRES_DB:-supatype}
|
|
440
|
+
# Read by the image's init to password the \`authenticator\` role PostgREST connects as.
|
|
441
|
+
AUTHENTICATOR_PASSWORD: \${AUTHENTICATOR_PASSWORD:?AUTHENTICATOR_PASSWORD is missing from .env}
|
|
326
442
|
${dbPorts} volumes:
|
|
327
443
|
- db-data:/var/lib/postgresql/data
|
|
328
444
|
healthcheck:
|
|
329
|
-
|
|
445
|
+
# -h 127.0.0.1 forces TCP. Without it \`pg_isready\` uses the Unix socket, which the
|
|
446
|
+
# entrypoint's temporary init server is already listening on while TCP is still refused —
|
|
447
|
+
# so the container reported healthy, \`depends_on: service_healthy\` released, and every
|
|
448
|
+
# service that connects over the network died with ECONNREFUSED on first boot. PostgREST
|
|
449
|
+
# survived only because it retries. Observed on a clean stack: server, storage and
|
|
450
|
+
# realtime all exited 1 while db said "healthy".
|
|
451
|
+
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U \${POSTGRES_USER:-supatype_admin} -d \${POSTGRES_DB:-supatype}"]
|
|
330
452
|
interval: 5s
|
|
331
453
|
timeout: 5s
|
|
454
|
+
# First boot runs initdb plus every bootstrap migration. Failures inside the start period
|
|
455
|
+
# do not count against retries, so a slow init waits rather than being declared unhealthy.
|
|
456
|
+
start_period: 90s
|
|
332
457
|
retries: 20
|
|
333
458
|
|
|
334
|
-
|
|
459
|
+
`
|
|
460
|
+
|
|
461
|
+
return `# Generated by supatype self-host compose
|
|
462
|
+
# Kong → supatype-server (unified gateway) → internal PostgREST / storage / etc.
|
|
463
|
+
services:
|
|
464
|
+
${dbServiceBlock} postgrest:
|
|
335
465
|
image: postgrest/postgrest:v12.2.8
|
|
336
466
|
expose:
|
|
337
467
|
- "3000"
|
|
338
468
|
environment:
|
|
339
|
-
|
|
340
|
-
|
|
469
|
+
# Connects as \`authenticator\`, not \${POSTGRES_USER} — which is a superuser, and a
|
|
470
|
+
# superuser session may SET ROLE to any role in the cluster, so a request whose JWT
|
|
471
|
+
# named one got it. \`authenticator\` is NOINHERIT with membership in only
|
|
472
|
+
# anon/authenticated/service_role, so the same SET ROLE is refused.
|
|
473
|
+
#
|
|
474
|
+
# Its own credential, not POSTGRES_PASSWORD: yours is for direct SQL access and
|
|
475
|
+
# rotating it must not take the REST API down. \`supatype init\` generates this.
|
|
476
|
+
PGRST_DB_URI: ${postgrestUrl}
|
|
477
|
+
# Derived from schema.pg_schema (or schema.api_schemas). Hardcoding this is why choosing a
|
|
478
|
+
# non-public pg_schema used to give a correct push and an API that answered PGRST106 for
|
|
479
|
+
# everything — the engine moved and PostgREST was never told.
|
|
480
|
+
PGRST_DB_SCHEMA: "${apiSchemaList(config, options?.fieldMaskingTier)}"
|
|
341
481
|
PGRST_DB_ANON_ROLE: anon
|
|
342
|
-
PGRST_JWT_SECRET: \${JWT_SECRET
|
|
482
|
+
PGRST_JWT_SECRET: \${JWT_SECRET:?JWT_SECRET is missing from .env}
|
|
343
483
|
PGRST_DB_EXTRA_SEARCH_PATH: public,extensions
|
|
344
484
|
PGRST_DB_POOL: 3
|
|
345
|
-
|
|
346
|
-
db:
|
|
347
|
-
condition: service_healthy
|
|
348
|
-
|
|
485
|
+
${dbDependency}
|
|
349
486
|
storage:
|
|
350
487
|
image: \${SUPATYPE_STORAGE_IMAGE:-supatype/storage:latest}
|
|
351
488
|
expose:
|
|
352
489
|
- "5000"
|
|
353
490
|
environment:
|
|
354
491
|
PORT: 5000
|
|
355
|
-
DATABASE_URL: "
|
|
356
|
-
JWT_SECRET: \${JWT_SECRET
|
|
492
|
+
DATABASE_URL: "${ownerUrl}"
|
|
493
|
+
JWT_SECRET: \${JWT_SECRET:?JWT_SECRET is missing from .env}
|
|
357
494
|
S3_ENDPOINT: http://minio:9000
|
|
358
495
|
S3_REGION: us-east-1
|
|
359
496
|
S3_ACCESS_KEY: supatype
|
|
360
497
|
S3_SECRET_KEY: supatype-secret
|
|
361
498
|
S3_FORCE_PATH_STYLE: "true"
|
|
362
|
-
|
|
363
|
-
db:
|
|
364
|
-
condition: service_healthy
|
|
365
|
-
|
|
499
|
+
${dbDependency}
|
|
366
500
|
functions-worker:
|
|
367
501
|
image: \${SUPATYPE_FUNCTIONS_WORKER_IMAGE:-supatype/functions-worker:latest}
|
|
368
502
|
expose:
|
|
@@ -372,31 +506,26 @@ ${dbPorts} volumes:
|
|
|
372
506
|
environment:
|
|
373
507
|
SUPATYPE_FUNCTIONS_ROOT: /project/functions
|
|
374
508
|
SUPATYPE_DENO_FUNCTIONS_DIR: /project/functions
|
|
509
|
+
# Model hooks, served by this same worker under a hooks/ route the gateway refuses from
|
|
510
|
+
# outside. One worker rather than two: a second container would cost a pod per project on
|
|
511
|
+
# cloud, for isolation the route boundary already provides.
|
|
512
|
+
SUPATYPE_HOOKS_ROOT: /project/hooks
|
|
375
513
|
PORT: "8001"
|
|
376
|
-
|
|
514
|
+
# In-compose loopback to Kong (not API_EXTERNAL_URL / localhost — unreachable from this container).
|
|
515
|
+
SUPATYPE_URL: http://kong:8000
|
|
516
|
+
SUPATYPE_INTERNAL_URL: http://kong:8000
|
|
377
517
|
SUPATYPE_ANON_KEY: \${ANON_KEY:-}
|
|
518
|
+
# Present so the worker can hand it to hooks — which are procedural and unreachable from
|
|
519
|
+
# outside — and to the public functions named below. Withheld from every other handler before
|
|
520
|
+
# any of them is imported: a function is a public endpoint, and an ambient admin credential made
|
|
521
|
+
# each one able to read past every access rule in the schema.
|
|
378
522
|
SUPATYPE_SERVICE_ROLE_KEY: \${SERVICE_ROLE_KEY:-}
|
|
523
|
+
SUPATYPE_SERVICE_ROLE_ROUTES: "${serviceRoleRoutes(config).join(",")}"
|
|
379
524
|
STRIPE_SECRET_KEY: \${STRIPE_SECRET_KEY:-}
|
|
380
525
|
STRIPE_WEBHOOK_SECRET: \${STRIPE_WEBHOOK_SECRET:-}
|
|
381
526
|
SITE_URL: \${SITE_URL:-\${API_EXTERNAL_URL:-${externalUrlFallback}}}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
condition: service_healthy
|
|
385
|
-
|
|
386
|
-
realtime:
|
|
387
|
-
image: \${SUPATYPE_REALTIME_IMAGE:-supatype/realtime:latest}
|
|
388
|
-
expose:
|
|
389
|
-
- "4000"
|
|
390
|
-
environment:
|
|
391
|
-
PORT: "4000"
|
|
392
|
-
DATABASE_URL: "postgresql://\${POSTGRES_USER:-supatype_admin}:\${POSTGRES_PASSWORD:-postgres}@db:5432/\${POSTGRES_DB:-supatype}"
|
|
393
|
-
JWT_SECRET: \${JWT_SECRET:-super-secret-jwt-token-change-in-production}
|
|
394
|
-
SLOT_NAME: supatype_realtime
|
|
395
|
-
PUBLICATION_NAME: supatype_realtime_pub
|
|
396
|
-
depends_on:
|
|
397
|
-
db:
|
|
398
|
-
condition: service_healthy
|
|
399
|
-
|
|
527
|
+
${dbDependency}
|
|
528
|
+
${realtimeBlock}
|
|
400
529
|
control-plane:
|
|
401
530
|
image: \${SUPATYPE_CONTROL_PLANE_IMAGE:-supatype/control-plane:latest}
|
|
402
531
|
expose:
|
|
@@ -408,16 +537,13 @@ ${dbPorts} volumes:
|
|
|
408
537
|
PORT: "8080"
|
|
409
538
|
SUPATYPE_PROJECT_REF: ${JSON.stringify(config.project.name)}
|
|
410
539
|
SUPATYPE_PROJECT_ROOT: /project
|
|
411
|
-
DATABASE_URL: "
|
|
540
|
+
DATABASE_URL: "${ownerUrl}"
|
|
412
541
|
SUPATYPE_FUNCTIONS_ROOT: /project/functions
|
|
413
542
|
SUPATYPE_STATIC_ROOT: /project/${staticDir.replace(/^\.\//, "")}
|
|
414
543
|
SUPATYPE_DEPLOYMENTS_DIR: /project/.supatype/deployments
|
|
415
544
|
COMPOSE_PROJECT_NAME: ${composeProject}
|
|
416
545
|
SUPATYPE_ENGINE_BIN: supatype-engine
|
|
417
|
-
|
|
418
|
-
db:
|
|
419
|
-
condition: service_healthy
|
|
420
|
-
|
|
546
|
+
${dbDependency}
|
|
421
547
|
server:
|
|
422
548
|
image: \${SUPATYPE_SERVER_IMAGE:-\${SUPATYPE_AUTH_IMAGE:-supatype/server:latest}}
|
|
423
549
|
${serverPorts} volumes:
|
|
@@ -434,10 +560,10 @@ ${serverPorts} volumes:
|
|
|
434
560
|
SUPATYPE_URL: \${API_EXTERNAL_URL:-${externalUrlFallback}}
|
|
435
561
|
SUPATYPE_ANON_KEY: \${ANON_KEY:-}
|
|
436
562
|
SUPATYPE_SERVICE_ROLE_KEY: \${SERVICE_ROLE_KEY:-}
|
|
437
|
-
SUPATYPE_SQL_DATABASE_URL: "
|
|
563
|
+
SUPATYPE_SQL_DATABASE_URL: "${ownerUrl}"
|
|
438
564
|
SUPATYPE_DENO_FUNCTIONS_DIR: /project/functions
|
|
439
565
|
SUPATYPE_FUNCTIONS_WORKER_URL: http://functions-worker:8001
|
|
440
|
-
|
|
566
|
+
${realtimeServerEnv}
|
|
441
567
|
SUPATYPE_CONTROL_PLANE_URL: http://control-plane:8080
|
|
442
568
|
SUPATYPE_VALKEY_ADDR: valkey:6379
|
|
443
569
|
${appEnv}
|
|
@@ -446,9 +572,9 @@ ${appEnv}
|
|
|
446
572
|
API_EXTERNAL_URL: \${API_EXTERNAL_URL:-${externalUrlFallback}}
|
|
447
573
|
GOTRUE_API_EXTERNAL_URL: \${API_EXTERNAL_URL:-${externalUrlFallback}}
|
|
448
574
|
GOTRUE_DB_DRIVER: postgres
|
|
449
|
-
GOTRUE_DB_DATABASE_URL: "
|
|
575
|
+
GOTRUE_DB_DATABASE_URL: "${gotrueUrl}${gotrueSearchPathSeparator}search_path=auth"
|
|
450
576
|
GOTRUE_SITE_URL: \${SITE_URL:-${siteUrlFallback}}
|
|
451
|
-
GOTRUE_JWT_SECRET: \${JWT_SECRET
|
|
577
|
+
GOTRUE_JWT_SECRET: \${JWT_SECRET:?JWT_SECRET is missing from .env}
|
|
452
578
|
GOTRUE_JWT_EXP: 3600
|
|
453
579
|
GOTRUE_JWT_AUD: authenticated
|
|
454
580
|
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
|
|
@@ -457,9 +583,7 @@ ${appEnv}
|
|
|
457
583
|
GOTRUE_DISABLE_SIGNUP: \${DISABLE_SIGNUP:-false}
|
|
458
584
|
${devLocal ? " STUDIO_OPEN_DEV: \"1\"\n" : ""}
|
|
459
585
|
depends_on:
|
|
460
|
-
|
|
461
|
-
condition: service_healthy
|
|
462
|
-
valkey:
|
|
586
|
+
${dbDependencyClause} valkey:
|
|
463
587
|
condition: service_started
|
|
464
588
|
postgrest:
|
|
465
589
|
condition: service_started
|
|
@@ -467,9 +591,7 @@ ${devLocal ? " STUDIO_OPEN_DEV: \"1\"\n" : ""}
|
|
|
467
591
|
condition: service_started
|
|
468
592
|
functions-worker:
|
|
469
593
|
condition: service_started
|
|
470
|
-
|
|
471
|
-
condition: service_started
|
|
472
|
-
control-plane:
|
|
594
|
+
${realtimeDependency} control-plane:
|
|
473
595
|
condition: service_started
|
|
474
596
|
|
|
475
597
|
minio:
|
|
@@ -488,10 +610,7 @@ ${minioPorts} volumes:
|
|
|
488
610
|
volumes:
|
|
489
611
|
- ${projectMount}:/project
|
|
490
612
|
working_dir: /project
|
|
491
|
-
|
|
492
|
-
db:
|
|
493
|
-
condition: service_healthy
|
|
494
|
-
${studioBlock}${valkeyBlock}${tlsHintComment} kong:
|
|
613
|
+
${dbDependency}${studioBlock}${valkeyBlock}${tlsHintComment} kong:
|
|
495
614
|
image: kong:3.6
|
|
496
615
|
environment:
|
|
497
616
|
KONG_DATABASE: "off"
|
|
@@ -510,23 +629,173 @@ ${kongDependsOn}${kongValkeyDepends}
|
|
|
510
629
|
${volumesBlock}`
|
|
511
630
|
}
|
|
512
631
|
|
|
513
|
-
|
|
632
|
+
/** In-compose worker address — matches the `functions-worker` service this file always generates. */
|
|
633
|
+
const COMPOSE_FUNCTIONS_WORKER_URL = "http://functions-worker:8001"
|
|
634
|
+
|
|
635
|
+
function ensureComposeManifest(cwd: string, config: SupatypeProjectConfig): void {
|
|
514
636
|
const manifestPath = join(cwd, ".supatype", "manifest.json")
|
|
515
|
-
if (existsSync(manifestPath)) return
|
|
516
637
|
mkdirSync(dirname(manifestPath), { recursive: true })
|
|
638
|
+
|
|
639
|
+
// The manifest is generated, not operator-authored, but `push` writes real values into it (the
|
|
640
|
+
// schema, for one), so an existing file is repaired rather than replaced.
|
|
641
|
+
if (existsSync(manifestPath)) {
|
|
642
|
+
repairComposeFunctionsFlag(manifestPath)
|
|
643
|
+
return
|
|
644
|
+
}
|
|
645
|
+
|
|
517
646
|
const manifest = {
|
|
518
647
|
schema: "public",
|
|
519
648
|
postgrest_url: "http://postgrest:3000",
|
|
520
649
|
storage_url: "http://storage:5000",
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
650
|
+
// Matches the compose file: a manifest advertising a service that was not generated is how
|
|
651
|
+
// the server ends up proxying subscriptions to a host that does not resolve.
|
|
652
|
+
realtime_enabled: realtimeEnabled(config),
|
|
653
|
+
...(realtimeEnabled(config) && { realtime_url: "http://realtime:4000" }),
|
|
654
|
+
// True because this file *always* generates a `functions-worker` service and hands the server
|
|
655
|
+
// `SUPATYPE_FUNCTIONS_WORKER_URL`. It said false, so the server answered 404 for every function
|
|
656
|
+
// in a stack that was running a worker for them — provisioned, then switched off.
|
|
657
|
+
functions_enabled: true,
|
|
658
|
+
functions_worker_url: COMPOSE_FUNCTIONS_WORKER_URL,
|
|
524
659
|
}
|
|
525
660
|
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8")
|
|
526
661
|
}
|
|
527
662
|
|
|
663
|
+
/**
|
|
664
|
+
* Turn functions on in a manifest written by an older CLI, leaving every other key alone.
|
|
665
|
+
*
|
|
666
|
+
* Without this, only new projects get working functions: a stack generated before the flag was
|
|
667
|
+
* corrected keeps `functions_enabled: false` on disk, and regenerating the compose file — the
|
|
668
|
+
* obvious thing to try — does not fix it.
|
|
669
|
+
*/
|
|
670
|
+
function repairComposeFunctionsFlag(manifestPath: string): void {
|
|
671
|
+
let parsed: Record<string, unknown>
|
|
672
|
+
try {
|
|
673
|
+
parsed = JSON.parse(readFileSync(manifestPath, "utf8")) as Record<string, unknown>
|
|
674
|
+
} catch {
|
|
675
|
+
return // Malformed: leave it for the server to complain about rather than overwrite it here.
|
|
676
|
+
}
|
|
677
|
+
if (typeof parsed !== "object" || parsed === null) return
|
|
678
|
+
if (parsed["functions_enabled"] === true && parsed["functions_worker_url"] !== undefined) return
|
|
679
|
+
|
|
680
|
+
parsed["functions_enabled"] = true
|
|
681
|
+
if (parsed["functions_worker_url"] === undefined) {
|
|
682
|
+
parsed["functions_worker_url"] = COMPOSE_FUNCTIONS_WORKER_URL
|
|
683
|
+
}
|
|
684
|
+
writeFileSync(manifestPath, `${JSON.stringify(parsed, null, 2)}\n`, "utf8")
|
|
685
|
+
}
|
|
686
|
+
|
|
528
687
|
function ensureProjectFunctionsDir(cwd: string, config: SupatypeProjectConfig): void {
|
|
529
688
|
mkdirSync(preferredFunctionsPathFromProject(config, cwd), { recursive: true })
|
|
689
|
+
// Both roots must exist before compose mounts the project read-only: a missing directory becomes
|
|
690
|
+
// a bind mount of a file that is not there, and the worker fails to start rather than serving the
|
|
691
|
+
// half it does have.
|
|
692
|
+
mkdirSync(hooksPathFromProject(config, cwd), { recursive: true })
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* The config's external URL and `.env`'s `DATABASE_URL` must be the same string.
|
|
697
|
+
*
|
|
698
|
+
* The CLI resolves the URL from config; Compose substitutes `.env` at up-time. If the two disagree,
|
|
699
|
+
* `push` migrates one database while the services serve another — which reads as data loss and
|
|
700
|
+
* isn't. It also makes the generated GoTrue DSN wrong, since whether to append `search_path` with
|
|
701
|
+
* `?` or `&` is decided from the config URL's query string.
|
|
702
|
+
*/
|
|
703
|
+
function assertExternalUrlMatchesEnv(cwd: string, config: SupatypeProjectConfig): void {
|
|
704
|
+
const configured = externalDatabaseUrl(config)
|
|
705
|
+
if (configured === undefined) return
|
|
706
|
+
|
|
707
|
+
const fromEnv = readEnvFile(cwd)["DATABASE_URL"]
|
|
708
|
+
if (fromEnv === undefined) {
|
|
709
|
+
// Nothing to disagree with. Compose's own `:?` guard reports this at up-time, and the operator
|
|
710
|
+
// may be exporting the variable in their shell rather than keeping a file.
|
|
711
|
+
return
|
|
712
|
+
}
|
|
713
|
+
if (fromEnv.trim() === configured) return
|
|
714
|
+
|
|
715
|
+
fatalError(
|
|
716
|
+
"DATABASE_URL in .env does not match database.external.url",
|
|
717
|
+
[
|
|
718
|
+
`.env: ${fromEnv.trim()}`,
|
|
719
|
+
`config: ${configured}`,
|
|
720
|
+
"",
|
|
721
|
+
"The CLI connects using the config value and the stack connects using .env, so a push would",
|
|
722
|
+
"land in one database while the services serve the other.",
|
|
723
|
+
"Point the config at the same variable to keep them in step:",
|
|
724
|
+
" database: { external: { url: process.env.DATABASE_URL! } }",
|
|
725
|
+
],
|
|
726
|
+
{ brand: { intro: "Self-host compose" } },
|
|
727
|
+
)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* A loopback host in the external URL cannot work from inside a container.
|
|
732
|
+
*
|
|
733
|
+
* `127.0.0.1` means "this container" to every service in the stack, so an external database on the
|
|
734
|
+
* host machine is unreachable — while the *CLI* reaches it fine, because the CLI runs on the host.
|
|
735
|
+
* That asymmetry is the trap: `supatype db check` passes, `push` applies the schema, and then
|
|
736
|
+
* storage, realtime and the server all die with ECONNREFUSED against their own loopback. Found by
|
|
737
|
+
* rehearsing exactly that.
|
|
738
|
+
*
|
|
739
|
+
* Refused rather than rewritten. The compose file interpolates one `${DATABASE_URL}` for every
|
|
740
|
+
* service, so silently substituting a different host would mean the CLI and the stack no longer
|
|
741
|
+
* agree about which database they are talking to — the thing every other check here exists to
|
|
742
|
+
* prevent.
|
|
743
|
+
*/
|
|
744
|
+
export function loopbackExternalHost(config: SupatypeProjectConfig): string | undefined {
|
|
745
|
+
const url = externalDatabaseUrl(config)
|
|
746
|
+
if (url === undefined) return undefined
|
|
747
|
+
|
|
748
|
+
let host: string
|
|
749
|
+
try {
|
|
750
|
+
host = new URL(url).hostname.toLowerCase()
|
|
751
|
+
} catch {
|
|
752
|
+
return undefined // Shape is validated at config load; nothing useful to add here.
|
|
753
|
+
}
|
|
754
|
+
// Node strips the brackets from an IPv6 hostname, and 127.0.0.0/8 is all loopback.
|
|
755
|
+
const loopback =
|
|
756
|
+
host === "localhost" ||
|
|
757
|
+
host === "::1" ||
|
|
758
|
+
host === "[::1]" ||
|
|
759
|
+
/^127\.\d+\.\d+\.\d+$/.test(host)
|
|
760
|
+
return loopback ? host : undefined
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function assertExternalUrlReachableFromContainers(config: SupatypeProjectConfig): void {
|
|
764
|
+
const host = loopbackExternalHost(config)
|
|
765
|
+
if (host === undefined) return
|
|
766
|
+
|
|
767
|
+
fatalError(
|
|
768
|
+
`database.external.url points at ${host}, which no container can reach`,
|
|
769
|
+
[
|
|
770
|
+
"Inside a container, localhost is that container — not the machine running Docker. Every",
|
|
771
|
+
"service in the stack would fail to connect, while the CLI succeeds because it runs on the host.",
|
|
772
|
+
"",
|
|
773
|
+
"Use a host the containers can resolve:",
|
|
774
|
+
" host.docker.internal (Docker Desktop on macOS and Windows)",
|
|
775
|
+
" 172.17.0.1 (the docker0 bridge on Linux, or add an extra_hosts entry)",
|
|
776
|
+
" the database's LAN address or hostname",
|
|
777
|
+
"",
|
|
778
|
+
"The CLI reaches the same address, so one value keeps working for both.",
|
|
779
|
+
],
|
|
780
|
+
{ brand: { intro: "Self-host compose" } },
|
|
781
|
+
)
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* The tier to write into the compose file.
|
|
786
|
+
*
|
|
787
|
+
* Resolved here so every caller gets it, rather than threaded through four call sites that load the
|
|
788
|
+
* schema a moment later anyway. A schema that fails to load falls back to the default list: compose
|
|
789
|
+
* generation is the wrong place to report a syntax error, and `push`/`dev` do it properly seconds
|
|
790
|
+
* later with the file and line.
|
|
791
|
+
*/
|
|
792
|
+
function resolveFieldMaskingTier(
|
|
793
|
+
cwd: string,
|
|
794
|
+
config: SupatypeProjectConfig,
|
|
795
|
+
options?: SelfHostComposeOptions,
|
|
796
|
+
): FieldMaskingTier | undefined {
|
|
797
|
+
if (options?.fieldMaskingTier !== undefined) return options.fieldMaskingTier
|
|
798
|
+
return fieldMaskingTierFromProject(cwd, config)
|
|
530
799
|
}
|
|
531
800
|
|
|
532
801
|
export function writeSelfHostCompose(
|
|
@@ -534,11 +803,18 @@ export function writeSelfHostCompose(
|
|
|
534
803
|
config: SupatypeProjectConfig,
|
|
535
804
|
options?: SelfHostComposeOptions,
|
|
536
805
|
): SelfHostComposePaths {
|
|
806
|
+
assertExternalUrlMatchesEnv(cwd, config)
|
|
807
|
+
const tier = resolveFieldMaskingTier(cwd, config, options)
|
|
808
|
+
const resolved: SelfHostComposeOptions = {
|
|
809
|
+
...options,
|
|
810
|
+
...(tier !== undefined && { fieldMaskingTier: tier }),
|
|
811
|
+
}
|
|
812
|
+
assertExternalUrlReachableFromContainers(config)
|
|
537
813
|
const paths = selfHostComposePaths(cwd)
|
|
538
814
|
mkdirSync(paths.dir, { recursive: true })
|
|
539
815
|
ensureProjectFunctionsDir(cwd, config)
|
|
540
|
-
ensureComposeManifest(cwd)
|
|
541
|
-
writeFileSync(paths.composePath, renderSelfHostCompose(config, cwd,
|
|
816
|
+
ensureComposeManifest(cwd, config)
|
|
817
|
+
writeFileSync(paths.composePath, renderSelfHostCompose(config, cwd, resolved), "utf8")
|
|
542
818
|
const studioHostDev = options?.devLocal === true && hasStudioOverride(config)
|
|
543
819
|
const tlsEnabled = selfHostTlsEnabled(config, options?.devLocal === true)
|
|
544
820
|
const domain = config.server.domain?.trim()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check that every name in `functions.serviceRole` is a function that exists.
|
|
3
|
+
*
|
|
4
|
+
* The grant fails **closed**: a typo, or a function that has since been renamed, means that function
|
|
5
|
+
* quietly does not receive the service-role key. Nothing breaks loudly — the handler simply reads
|
|
6
|
+
* `undefined` and whatever it does with the key stops working, at runtime, in a deploy that reported
|
|
7
|
+
* success. So the name is checked where it is declared, against the directory it must match.
|
|
8
|
+
*
|
|
9
|
+
* `serviceRoleRoutes()` claimed this was already true. It was not: nothing read the list except the two
|
|
10
|
+
* places that turn it into an env var.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readdirSync, statSync } from "node:fs"
|
|
13
|
+
import { join, relative } from "node:path"
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
functionsPathCandidatesFromProject,
|
|
17
|
+
serviceRoleRoutes,
|
|
18
|
+
type SupatypeProjectConfig,
|
|
19
|
+
} from "./project-config.js"
|
|
20
|
+
|
|
21
|
+
export interface ServiceRoleProblems {
|
|
22
|
+
/** Names with no matching function. Each line is ready to print. */
|
|
23
|
+
errors: string[]
|
|
24
|
+
/** The same names, unformatted, for callers that need to mark them rather than print them. */
|
|
25
|
+
missing: string[]
|
|
26
|
+
/** Entries that are harmless but say nothing, e.g. a hook. */
|
|
27
|
+
warnings: string[]
|
|
28
|
+
/** Function names available to be granted, for the "did you mean" line. */
|
|
29
|
+
available: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Function directory names, matching how the worker discovers routes. */
|
|
33
|
+
function availableFunctions(dirs: readonly string[]): string[] {
|
|
34
|
+
const names = new Set<string>()
|
|
35
|
+
for (const dir of dirs) {
|
|
36
|
+
if (!existsSync(dir)) continue
|
|
37
|
+
for (const entry of readdirSync(dir)) {
|
|
38
|
+
if (entry.startsWith("_") || entry.startsWith(".")) continue
|
|
39
|
+
const full = join(dir, entry)
|
|
40
|
+
if (statSync(full).isDirectory()) {
|
|
41
|
+
if (existsSync(join(full, "index.ts"))) names.add(entry)
|
|
42
|
+
} else if (entry.endsWith(".ts") && !entry.endsWith(".d.ts")) {
|
|
43
|
+
names.add(entry.replace(/\.ts$/, ""))
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return [...names].sort()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the declared grants against what is on disk.
|
|
52
|
+
*
|
|
53
|
+
* Returns the problems rather than throwing, so `push` can refuse while `doctor` reports alongside
|
|
54
|
+
* everything else it found.
|
|
55
|
+
*/
|
|
56
|
+
export function checkServiceRoleRoutes(
|
|
57
|
+
cfg: SupatypeProjectConfig,
|
|
58
|
+
cwd: string,
|
|
59
|
+
): ServiceRoleProblems {
|
|
60
|
+
const declared = serviceRoleRoutes(cfg)
|
|
61
|
+
if (declared.length === 0) return { errors: [], missing: [], warnings: [], available: [] }
|
|
62
|
+
|
|
63
|
+
const dirs = functionsPathCandidatesFromProject(cfg, cwd)
|
|
64
|
+
const available = availableFunctions(dirs)
|
|
65
|
+
const known = new Set(available)
|
|
66
|
+
// Forward slashes and the candidates listed separately: joining them into one path produced
|
|
67
|
+
// "no functions or supatype\functions/send-email/index.ts", which reads as neither a path nor a list.
|
|
68
|
+
const searched = dirs.map((dir) => (relative(cwd, dir) || dir).replace(/\\/g, "/"))
|
|
69
|
+
const primary = searched[0] ?? "functions"
|
|
70
|
+
|
|
71
|
+
const errors: string[] = []
|
|
72
|
+
const missing: string[] = []
|
|
73
|
+
const warnings: string[] = []
|
|
74
|
+
|
|
75
|
+
for (const name of declared) {
|
|
76
|
+
if (name.startsWith("hooks/")) {
|
|
77
|
+
// Not an error: it was the documented form once, and the worker grants hooks whatever this says.
|
|
78
|
+
// Still worth saying, because a reader would reasonably assume the line is what does the granting.
|
|
79
|
+
warnings.push(
|
|
80
|
+
` "${name}" is not needed — a hook receives the service-role key because only the API server ` +
|
|
81
|
+
`can reach it`,
|
|
82
|
+
)
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
if (!known.has(name)) {
|
|
86
|
+
const where =
|
|
87
|
+
searched.length > 1 ? ` (searched ${searched.join(", ")})` : ""
|
|
88
|
+
errors.push(` "${name}" — no ${primary}/${name}/index.ts${where}`)
|
|
89
|
+
missing.push(name)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { errors, missing, warnings, available }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The full message for a refusal, including what *is* available to name. */
|
|
97
|
+
export function serviceRoleProblemLines(problems: ServiceRoleProblems): string[] {
|
|
98
|
+
if (problems.errors.length === 0) return []
|
|
99
|
+
const lines = [...problems.errors, ""]
|
|
100
|
+
lines.push(
|
|
101
|
+
problems.available.length > 0
|
|
102
|
+
? `Functions found: ${problems.available.join(", ")}`
|
|
103
|
+
: "No functions found. Create one with: supatype functions new <name>",
|
|
104
|
+
)
|
|
105
|
+
lines.push("")
|
|
106
|
+
lines.push(
|
|
107
|
+
"A name that matches nothing grants nothing, and says so nowhere: the function reads no key at " +
|
|
108
|
+
"runtime, in a deploy that reported success.",
|
|
109
|
+
)
|
|
110
|
+
return lines
|
|
111
|
+
}
|