@supatype/cli 0.3.0 → 0.3.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.
@@ -1,6 +1,7 @@
1
1
  import type { Command } from "commander"
2
- import { readFileSync, existsSync, writeFileSync } from "node:fs"
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs"
3
3
  import { resolve } from "node:path"
4
+ import { readEnvFile, upsertEnvFile } from "../env-file.js"
4
5
  import { signJwt } from "../jwt.js"
5
6
  import { error, plain } from "../ui/messages.js"
6
7
 
@@ -10,7 +11,9 @@ export function registerKeys(program: Command): void {
10
11
  .description("Generate ANON_KEY and SERVICE_ROLE_KEY JWTs from your JWT_SECRET")
11
12
  .option("--secret <secret>", "JWT secret (defaults to JWT_SECRET env var or value in .env)")
12
13
  .option("--exp-years <years>", "Token expiry in years (default: 10)", "10")
13
- .action((opts: { secret?: string; expYears: string }) => {
14
+ .option("--write", "Write the keys into .env in the current directory instead of printing them")
15
+ .option("--force", "With --write, replace every key already in .env rather than filling blanks")
16
+ .action((opts: { secret?: string; expYears: string; write?: boolean; force?: boolean }) => {
14
17
  const secret = opts.secret ?? resolveSecret()
15
18
  if (!secret) {
16
19
  error("JWT_SECRET not found. Set it in .env or pass --secret <value>")
@@ -23,19 +26,116 @@ export function registerKeys(program: Command): void {
23
26
  process.exit(1)
24
27
  }
25
28
 
26
- const now = Math.floor(Date.now() / 1000)
27
- const exp = now + expYears * 365 * 24 * 60 * 60
29
+ const { anonKey, serviceKey } = signKeyPair(secret, expYears)
28
30
 
29
- const anonKey = signJwt({ iss: "supatype", role: "anon", iat: now, exp }, secret)
30
- const serviceKey = signJwt({ iss: "supatype", role: "service_role", iat: now, exp }, secret)
31
+ if (opts.write) {
32
+ const written = writeKeysToEnv(
33
+ process.cwd(),
34
+ { anonKey, serviceKey },
35
+ opts.force === true,
36
+ )
37
+ if (written.path === null) {
38
+ error("No .env here to write to. Create one, or drop --write to print the keys.")
39
+ process.exit(1)
40
+ }
41
+ plain(`\nKeys valid for ${expYears} years, written to ${written.path}:\n`)
42
+ // The names come back from the writer rather than being restated here, which is the whole
43
+ // point of there being one list.
44
+ for (const key of written.written) plain(` ${key}`)
45
+ if (written.kept.length > 0) {
46
+ plain(`\nLeft alone, because they already hold a value:\n`)
47
+ for (const key of written.kept) plain(` ${key}`)
48
+ plain("\nRe-run with --force to replace them.")
49
+ }
50
+ plain("\nDo not commit .env to source control.")
51
+ return
52
+ }
31
53
 
32
54
  plain(`\nGenerated keys (valid for ${expYears} years):\n`)
33
55
  plain("ANON_KEY=" + anonKey)
34
56
  plain("SERVICE_ROLE_KEY=" + serviceKey)
35
- plain("\nAdd these to your .env file. Do not commit .env to source control.")
57
+ plain("\nAdd these to your .env file, or re-run with --write. Do not commit .env to source control.")
36
58
  })
37
59
  }
38
60
 
61
+ /**
62
+ * Every env name a minted key pair is written to, in one place.
63
+ *
64
+ * Shared with `supatype dev`, which writes the same pair when it starts a stack. They used to hold
65
+ * separate lists, so a front end's prefix could be written by one and forgotten by the other, and
66
+ * which names your `.env` ended up with depended on whichever command you happened to run last.
67
+ *
68
+ * The URL names are only included when there is a URL to write, because an empty
69
+ * `VITE_SUPATYPE_URL` is worse than an absent one: the client reads it, finds a blank string, and
70
+ * requests against the page origin without saying why.
71
+ */
72
+ export function anonKeyEnvUpdates(
73
+ anonKey: string,
74
+ serviceKey: string,
75
+ apiUrl?: string | undefined,
76
+ ): Record<string, string> {
77
+ return {
78
+ ANON_KEY: anonKey,
79
+ SERVICE_ROLE_KEY: serviceKey,
80
+ VITE_SUPATYPE_ANON_KEY: anonKey,
81
+ PUBLIC_SUPATYPE_ANON_KEY: anonKey,
82
+ EXPO_PUBLIC_SUPATYPE_ANON_KEY: anonKey,
83
+ ...(apiUrl !== undefined && {
84
+ VITE_SUPATYPE_URL: apiUrl,
85
+ PUBLIC_SUPATYPE_URL: apiUrl,
86
+ EXPO_PUBLIC_SUPATYPE_URL: apiUrl,
87
+ }),
88
+ }
89
+ }
90
+
91
+ export interface WriteKeysResult {
92
+ /** The .env written, or null when there is none here. */
93
+ path: string | null
94
+ written: string[]
95
+ kept: string[]
96
+ }
97
+
98
+ /**
99
+ * Write a minted pair into `dir`'s .env.
100
+ *
101
+ * Blanks only by default. An anon key already in `.env` is held by clients this command cannot
102
+ * reach, and reissuing it locks them out, so filling what is empty is the useful half and
103
+ * replacing what is not is a decision someone has to make out loud. `--force` is how they make it,
104
+ * and it rewrites every name this owns rather than only the empty ones.
105
+ *
106
+ * `--force` does not rewrite the file. `.env` also holds `JWT_SECRET`, which these keys are
107
+ * derived from, along with the database password and whatever else the project keeps there;
108
+ * replacing the file would leave a project whose keys cannot be regenerated and whose stack cannot
109
+ * start. Every line this command does not own is left exactly as it was, comments included.
110
+ */
111
+ export function writeKeysToEnv(
112
+ dir: string,
113
+ keys: { anonKey: string; serviceKey: string },
114
+ force: boolean,
115
+ ): WriteKeysResult {
116
+ const envPath = resolve(dir, ".env")
117
+ if (!existsSync(envPath)) return { path: null, written: [], kept: [] }
118
+
119
+ const existing = readEnvFile(dir)
120
+ const apiUrl = existing["PUBLIC_SUPATYPE_URL"] || existing["API_EXTERNAL_URL"] || undefined
121
+ const candidates = anonKeyEnvUpdates(keys.anonKey, keys.serviceKey, apiUrl)
122
+
123
+ const updates: Record<string, string> = {}
124
+ const kept: string[] = []
125
+ for (const [name, value] of Object.entries(candidates)) {
126
+ // Blank counts as absent: a template ships `ANON_KEY=` and that is exactly the line to fill.
127
+ const current = existing[name]?.trim() ?? ""
128
+ if (!force && current !== "") {
129
+ kept.push(name)
130
+ continue
131
+ }
132
+ updates[name] = value
133
+ }
134
+
135
+ if (Object.keys(updates).length > 0) upsertEnvFile(dir, updates)
136
+ return { path: envPath, written: Object.keys(updates), kept }
137
+ }
138
+
39
139
  // ─── Helpers ─────────────────────────────────────────────────────────────────
40
140
 
41
141
  /** Mint a long-lived anon + service_role JWT pair from a secret. */
@@ -91,6 +191,11 @@ function upsertEnvVar(content: string, key: string, value: string): string {
91
191
  return `${content}${sep}${key}=${value}\n`
92
192
  }
93
193
 
194
+ /** Whether `.env` declares `key` at all, with or without a value. */
195
+ function hasEnvLine(content: string, key: string): boolean {
196
+ return new RegExp(`^${key}=`, "m").test(content)
197
+ }
198
+
94
199
  function readEnvVar(content: string, key: string): string | undefined {
95
200
  const re = new RegExp(`^${key}=(.*)$`, "m")
96
201
  const match = re.exec(content)
@@ -9,6 +9,7 @@ import {
9
9
  validateModelValidators,
10
10
  writeHooksModule,
11
11
  } from "../model-hooks.js"
12
+ import { syncRowCacheEnv } from "../model-cache.js"
12
13
  import { adapterEntry, readHookUpload } from "../hook-upload.js"
13
14
  import { checkServiceRoleRoutes, serviceRoleProblemLines } from "../service-role-check.js"
14
15
  import { fatalError } from "../ui/fatal.js"
@@ -295,6 +296,16 @@ async function generateTypesLocal(ast: unknown, config: SupatypeProjectConfig):
295
296
  if (hooksPath !== null) info(`Hook handler types written to ${hooksPath}`)
296
297
  // The server watches this file, so a changed hook takes effect without a restart.
297
298
  if (syncManifestHooks(cwd, ast)) info("Hook map written to .supatype/manifest.json")
299
+ // The row cache is configured at postmaster start, so this is the one cache setting a push
300
+ // cannot make take effect on its own.
301
+ const rowCache = syncRowCacheEnv(cwd, ast)
302
+ if (rowCache !== null) {
303
+ info(
304
+ `Row cache switched ${rowCache} in .env. Recreate the database container for it to take ` +
305
+ "effect: the image writes pg_keyspace.conf at start, so a running Postgres keeps the " +
306
+ "setting it booted with.",
307
+ )
308
+ }
298
309
 
299
310
  if (!config.output?.types && !config.output?.client) return
300
311
  // The CLI writes these, it does not ask the engine to. Passing types_path and client_path and
@@ -21,6 +21,8 @@ import {
21
21
  type SupatypeProjectConfig,
22
22
  } from "./project-config.js"
23
23
  import { syncManifestHooks, writeHooksModule } from "./model-hooks.js"
24
+ import { syncRowCacheEnv } from "./model-cache.js"
25
+ import { anonKeyEnvUpdates } from "./commands/keys.js"
24
26
  import { signJwt } from "./jwt.js"
25
27
  import { ensureDevDbPort, ensureKongPort } from "./dev-ports.js"
26
28
  import { handleComposeProjectRename } from "./compose-rename.js"
@@ -35,6 +37,7 @@ import {
35
37
  runDockerCompose,
36
38
  schemaEngineImageForPush,
37
39
  writeSelfHostCompose,
40
+ selfHostComposePaths,
38
41
  type SelfHostComposePaths,
39
42
  } from "./self-host-compose.js"
40
43
  import type { DockerBrandOptions } from "./docker-runtime.js"
@@ -288,13 +291,13 @@ export function upsertDevComposeEnv(
288
291
  ...seedMissingLocalSecrets(cwd),
289
292
  // Project configuration, seeded not overwritten, see seedMissingDatabaseIdentity.
290
293
  ...seedMissingDatabaseIdentity(cwd),
291
- ANON_KEY: anonKey,
292
- SERVICE_ROLE_KEY: serviceRoleKey,
293
- PUBLIC_SUPATYPE_ANON_KEY: anonKey,
294
- VITE_SUPATYPE_ANON_KEY: anonKey,
295
- EXPO_PUBLIC_SUPATYPE_ANON_KEY: anonKey,
296
- PUBLIC_SUPATYPE_URL: apiUrl,
297
- EXPO_PUBLIC_SUPATYPE_URL: apiUrl,
294
+ // Shared with `supatype keys --write`, so a front end's prefix cannot be written by one and
295
+ // forgotten by the other. See `anonKeyEnvUpdates`.
296
+ //
297
+ // This list used to live here as well, and the two had already drifted: `dev` wrote
298
+ // PUBLIC_ and EXPO_PUBLIC_ URLs and no VITE_SUPATYPE_URL, so a Vite app whose `.env` had only
299
+ // ever been touched by `supatype dev` built against an empty string.
300
+ ...anonKeyEnvUpdates(anonKey, serviceRoleKey, apiUrl),
298
301
  SUPATYPE_KONG_PORT: String(kongPort),
299
302
  API_EXTERNAL_URL: apiUrl,
300
303
  SITE_URL: apiUrl,
@@ -594,12 +597,42 @@ async function refreshSchemaArtifacts(
594
597
  if (syncManifestHooks(cwd, ast)) {
595
598
  console.log("[supatype] Hook and validator maps written to .supatype/manifest.json")
596
599
  }
600
+ // Written before the stack comes up, so a first run with a row cache declared starts with it on
601
+ // rather than needing a second `supatype dev`.
602
+ const rowCache = syncRowCacheEnv(cwd, ast)
603
+ if (rowCache !== null) {
604
+ console.log(`[supatype] Row cache switched ${rowCache} in .env (pg_keyspace Mode B).`)
605
+ }
597
606
 
598
607
  try {
599
608
  await ensureEngine()
600
609
  } catch (err) {
610
+ // The host engine is a CDN download and this machine could not complete it. The schema is
611
+ // already applied, so fall back to the engine in compose rather than giving up: without
612
+ // admin-config.json Studio reports "No schema has been pushed yet" on a stack whose schema is
613
+ // entirely live, which reads as a failed push rather than a missing binary.
614
+ const paths = selfHostComposePaths(cwd)
615
+ const composeProject = composeProjectName(config.project.name)
616
+ const wrote = await generateViaComposeEngine(
617
+ paths,
618
+ cwd,
619
+ composeProject,
620
+ config,
621
+ ast,
622
+ adminConfigPath,
623
+ ).catch(() => false)
624
+ if (wrote) {
625
+ console.warn(
626
+ `[supatype] Host engine unavailable (${(err as Error).message}); used the in-compose ` +
627
+ "engine instead.",
628
+ )
629
+ return
630
+ }
601
631
  console.warn(
602
- `[supatype] Host engine unavailable, admin/types not refreshed: ${(err as Error).message}`,
632
+ `[supatype] Host engine unavailable, admin/types not refreshed: ${(err as Error).message}
633
+ ` +
634
+ "[supatype] Studio will report no schema until this succeeds, even though the schema is " +
635
+ "applied. Retry with the stack up, or run `supatype push`.",
603
636
  )
604
637
  return
605
638
  }
@@ -1333,3 +1366,95 @@ function grantAuthSchemaAccess(
1333
1366
  console.warn("[supatype] Could not grant service_role access to auth.users, Studio relation preview may fail.")
1334
1367
  }
1335
1368
  }
1369
+
1370
+ /**
1371
+ * Run one read-only generator in the in-compose schema-engine and return its stdout.
1372
+ *
1373
+ * The same image and the same bind mount the push just used, so if the schema could be applied
1374
+ * this can run. It exists because the host engine is a separate binary fetched from a CDN, and a
1375
+ * machine that cannot fetch it is not a machine that cannot generate: the schema is already
1376
+ * applied, the AST is already on disk, and the container that did it is one `docker compose run`
1377
+ * away.
1378
+ */
1379
+ async function runComposeEngineGenerator(
1380
+ paths: SelfHostComposePaths,
1381
+ cwd: string,
1382
+ composeProject: string,
1383
+ config: SupatypeProjectConfig,
1384
+ args: readonly string[],
1385
+ ): Promise<string | null> {
1386
+ const envFile = resolve(cwd, ".env")
1387
+ const composeArgs = ["compose", "--progress", "quiet"]
1388
+ if (composeProject) composeArgs.push("-p", composeProject)
1389
+ composeArgs.push("--project-directory", cwd)
1390
+ composeArgs.push("-f", paths.composePath)
1391
+ if (existsSync(envFile)) composeArgs.push("--env-file", envFile)
1392
+ composeArgs.push(
1393
+ "--profile",
1394
+ "tools",
1395
+ "run",
1396
+ "--rm",
1397
+ "-T",
1398
+ "schema-engine",
1399
+ ...args,
1400
+ "-i",
1401
+ "/project/.supatype/schema.ast.json",
1402
+ )
1403
+
1404
+ const env: NodeJS.ProcessEnv = { ...process.env, COMPOSE_PROGRESS: "quiet" }
1405
+ const engineImage = await schemaEngineImageForPush(config)
1406
+ if (engineImage) env.SUPATYPE_ENGINE_IMAGE = engineImage
1407
+
1408
+ const result = spawnSync("docker", composeArgs, {
1409
+ cwd,
1410
+ encoding: "utf8",
1411
+ maxBuffer: 20 * 1024 * 1024,
1412
+ env,
1413
+ })
1414
+ if ((result.status ?? 1) !== 0) return null
1415
+ return String(result.stdout ?? "")
1416
+ }
1417
+
1418
+ /**
1419
+ * Write `admin-config.json` and the generated types using the in-compose engine.
1420
+ *
1421
+ * The fallback for a host engine that could not be fetched. Without it, a `supatype dev` that
1422
+ * applied the schema perfectly well still left no `admin-config.json`, and Studio reads that file
1423
+ * to decide whether a schema exists: the stack came up, every table was live, the API served them,
1424
+ * and Studio said **"No schema has been pushed yet."** The warning that preceded it named the
1425
+ * download, not the consequence, so nothing connected the two.
1426
+ *
1427
+ * Returns true when the admin config was written, which is the file Studio actually needs.
1428
+ */
1429
+ export async function generateViaComposeEngine(
1430
+ paths: SelfHostComposePaths,
1431
+ cwd: string,
1432
+ composeProject: string,
1433
+ config: SupatypeProjectConfig,
1434
+ ast: unknown,
1435
+ adminConfigPath: string,
1436
+ ): Promise<boolean> {
1437
+ const typesPath = config.output?.types
1438
+ if (typeof typesPath === "string" && typesPath.trim().length > 0) {
1439
+ const out = await runComposeEngineGenerator(paths, cwd, composeProject, config, ["generate"])
1440
+ const marker = out?.indexOf("// Generated by supatype-engine") ?? -1
1441
+ if (out && out.includes("export type")) {
1442
+ const ts = (marker >= 0 ? out.slice(marker) : out).trimStart()
1443
+ const hostPath = join(cwd, typesPath)
1444
+ mkdirSync(dirname(hostPath), { recursive: true })
1445
+ writeFileSync(hostPath, ts)
1446
+ console.log(`[supatype] Types written to ${typesPath} (in-compose engine).`)
1447
+ }
1448
+ }
1449
+
1450
+ const adminOut = await runComposeEngineGenerator(paths, cwd, composeProject, config, ["admin"])
1451
+ if (!adminOut) return false
1452
+ const parsed = parseEngineJsonOutput<unknown>(adminOut)
1453
+ if (parsed === null) return false
1454
+
1455
+ const admin = withAdminRoles(parsed, config)
1456
+ restoreSystemRelationTargets(admin, ast)
1457
+ writeFileSync(adminConfigPath, `${JSON.stringify(admin, null, 2)}\n`)
1458
+ console.log("[supatype] Admin config written to .supatype/admin-config.json (in-compose engine).")
1459
+ return true
1460
+ }
@@ -16,6 +16,7 @@
16
16
  * `hooks` has always taken this route for the same reason. Plan §13.1 said otherwise and said to
17
17
  * verify before relying on it; this is the verified path.
18
18
  */
19
+ import { readEnvValue, upsertEnvFile } from "./env-file.js"
19
20
  import type { ModelCacheAst } from "./schema-ast-v2.js"
20
21
 
21
22
  /** One table's cache declaration, as the manifest carries it. */
@@ -72,3 +73,50 @@ export function manifestCache(ast: unknown): Record<string, ManifestCacheEntry>
72
73
  }
73
74
  return out
74
75
  }
76
+
77
+ // ─── Row cache enablement ────────────────────────────────────────────────────
78
+
79
+ /** The two env names the Postgres image reads to turn Mode B on. */
80
+ export const ROWCACHE_DECODE_ENV = "SUPATYPE_KEYSPACE_ROWCACHE_DECODE"
81
+ export const ROWCACHE_READTHROUGH_ENV = "SUPATYPE_KEYSPACE_ROWCACHE_READTHROUGH"
82
+
83
+ /** Whether any model declares `cache: { rows: true }`. */
84
+ export function declaresRowCache(ast: unknown): boolean {
85
+ return Object.values(manifestCache(ast)).some((entry) => entry.rows === true)
86
+ }
87
+
88
+ /**
89
+ * Keep the row cache's two switches in `.env` matching what the schema declares.
90
+ *
91
+ * `cache: { rows: true }` registers a table with the row cache, and registration alone serves
92
+ * nothing: Mode B needs `pg_keyspace.rowcache_decode` for the invalidation worker and
93
+ * `rowcache_readthrough` to fill on a primary-key miss. Both are written by the image's entrypoint
94
+ * from these variables, before any server starts, so they cannot be switched at runtime and cannot
95
+ * be decided by the compose file alone: only a push knows whether any model declares `rows`.
96
+ *
97
+ * Without this the stack came up with the row-cache segment reserved, the tables registered, both
98
+ * switches off and nothing anywhere saying so. Studio's panel was the only thing that reported it,
99
+ * and it reported it as an operator's missing configuration rather than as a push that had not
100
+ * finished the job.
101
+ *
102
+ * Written to `.env` rather than baked into the compose file for the same reason `SUPATYPE_KONG_PORT`
103
+ * is: `self-host compose render` runs with no AST in hand, so a value the compose file hardcoded
104
+ * would be whatever the last render guessed.
105
+ *
106
+ * Returns the new state when it changed, and null when it did not. A change needs the database
107
+ * container recreated, which the caller is the one that can say.
108
+ */
109
+ export function syncRowCacheEnv(cwd: string, ast: unknown): "on" | "off" | null {
110
+ const want = declaresRowCache(ast)
111
+ const value = want ? "1" : "0"
112
+
113
+ const current = readEnvValue(cwd, ROWCACHE_DECODE_ENV, "")
114
+ const currentThrough = readEnvValue(cwd, ROWCACHE_READTHROUGH_ENV, "")
115
+ if (current === value && currentThrough === value) return null
116
+
117
+ upsertEnvFile(cwd, {
118
+ [ROWCACHE_DECODE_ENV]: value,
119
+ [ROWCACHE_READTHROUGH_ENV]: value,
120
+ })
121
+ return want ? "on" : "off"
122
+ }
@@ -402,18 +402,29 @@ ${studioService}
402
402
  - "127.0.0.1:\${SUPATYPE_DEV_DB_PORT:-54329}:5432"
403
403
  `
404
404
  : ` ports:
405
- - "5432:5432"
405
+ - "\${SUPATYPE_DB_PORT:-5432}:5432"
406
406
  `
407
407
  : ""
408
+ // Host ports, every one of them overridable.
409
+ //
410
+ // Kong's has been `${SUPATYPE_KONG_PORT:-18473}` for as long as `supatype dev` has picked a free
411
+ // one per project and written it back to .env. These three were fixed literals, so the second
412
+ // project on a machine could not start: Docker refuses the bind and compose reports only that
413
+ // the stack would not come up, naming no port. Two projects side by side is the normal case
414
+ // here (this repository ships eight examples), so a literal is the wrong default.
415
+ //
416
+ // Defaults are the previous literals, so a project with none of these set behaves exactly as
417
+ // before. Allocating them per project the way the Kong port is allocated is the follow-up; this
418
+ // makes a second stack possible rather than automatic.
408
419
  const serverPorts = devLocal
409
420
  ? ""
410
421
  : ` ports:
411
- - "9999:9999"
422
+ - "\${SUPATYPE_SERVER_PORT:-9999}:9999"
412
423
  `
413
424
  const seaweedPorts = devLocal
414
425
  ? ""
415
426
  : ` ports:
416
- - "8333:8333"
427
+ - "\${SUPATYPE_SEAWEEDFS_PORT:-8333}:8333"
417
428
  `
418
429
  // One source for the credentials: the server is configured with them and the storage service is
419
430
  // handed them, and a mismatch does not fail at start, it fails at the first upload.
@@ -560,6 +571,29 @@ ${dbDependency}`
560
571
  SUPATYPE_KEYSPACE_KEYS: "200000"
561
572
  SUPATYPE_KEYSPACE_RING_MB: "16"
562
573
  SUPATYPE_KEYSPACE_ROWCACHE_MB: "64"
574
+ # Mode B, the row cache itself. The segment above is reserved either way; these two decide
575
+ # whether anything decodes into it or serves from it, and the image refuses readthrough
576
+ # without decode because that pair serves stale rows forever rather than merely wasting
577
+ # memory.
578
+ #
579
+ # From .env rather than a literal, because only a push can answer this: the switches follow
580
+ # whether any model declares \`cache: { rows: true }\`, and \`self-host compose render\` runs
581
+ # with no schema in hand. Default off, so a project that declares no row cache does not pin
582
+ # WAL behind a replication slot it never reads.
583
+ SUPATYPE_KEYSPACE_ROWCACHE_DECODE: "\${SUPATYPE_KEYSPACE_ROWCACHE_DECODE:-0}"
584
+ SUPATYPE_KEYSPACE_ROWCACHE_READTHROUGH: "\${SUPATYPE_KEYSPACE_ROWCACHE_READTHROUGH:-0}"
585
+ # Both decoders this stack runs, because the image's setting REPLACES the allowlist.
586
+ #
587
+ # Turning the row cache on makes the entrypoint write
588
+ # \`output_plugin_libraries = 'supacache_keys'\`, and PostgreSQL then refuses every other
589
+ # plugin. Realtime decodes with wal2json, so a project that declared \`cache: { rows: true }\`
590
+ # silently lost realtime: the service stayed up, answered /health/ready with 200, and logged
591
+ # \`library "wal2json" may not be used as an output plugin\` once a second while every
592
+ # subscription reported SUBSCRIBED and delivered nothing.
593
+ #
594
+ # Named here rather than left to the image because only this file knows both features are in
595
+ # the same stack.
596
+ SUPATYPE_KEYSPACE_OUTPUT_PLUGIN_LIBRARIES: "supacache_keys, wal2json"
563
597
  `
564
598
  : ""
565
599
 
@@ -705,7 +739,17 @@ ${dbDependency}
705
739
  # stays visible instead of hiding in a crash loop.
706
740
  restart: on-failure:5
707
741
  ${serverPorts} volumes:
742
+ # The project is read-only: the server reads the schema, the manifest and the functions, and
743
+ # has no business editing any of them.
708
744
  - ${projectMount}:/project:ro
745
+ # .supatype is the exception, and only because one file in it is not project source.
746
+ # api-config.json is the operator's runtime state: which tables have caching switched on,
747
+ # the project TTL, max_rows. PATCH /admin/v1/config/rest writes it, which is what Studio's
748
+ # cache panel and the CLI's cache commands call. Under the read-only mount alone that PATCH
749
+ # fails with "read-only file system", so a cache a model declares can be read back as
750
+ # declared and never actually switched on. A narrower bind than making the whole project
751
+ # writable, because the rest of the tree keeps the guarantee.
752
+ - ${projectMount}/.supatype:/project/.supatype
709
753
  working_dir: /project
710
754
  environment:
711
755
  SUPATYPE_MODE: ${devLocal ? "dev" : "standalone"}
@@ -0,0 +1,148 @@
1
+ /**
2
+ * `supatype keys --write` writes the pair it mints.
3
+ *
4
+ * The command printed the keys and wrote nothing, while every README in this repository says it
5
+ * mints them into `.env`. Following the docs therefore produced a front end built with
6
+ * `anonKey: undefined` and a gateway refusing its every request, with nothing in the output saying
7
+ * so.
8
+ *
9
+ * Writing is opt-in rather than the default: a command that reads a secret and edits a file on
10
+ * sight is worse than one that has to be asked. What it writes is blanks only, because an anon key
11
+ * already in `.env` is held by clients this command cannot reach and reissuing it locks them out.
12
+ * `--force` is how someone says they meant to rotate.
13
+ */
14
+ import { describe, it, expect, beforeEach, afterEach } from "vitest"
15
+ import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs"
16
+ import { tmpdir } from "node:os"
17
+ import { join } from "node:path"
18
+ import { anonKeyEnvUpdates, writeKeysToEnv } from "../src/commands/keys.js"
19
+
20
+ const PAIR = { anonKey: "anon.jwt.value", serviceKey: "service.jwt.value" }
21
+
22
+ function envValue(dir: string, key: string): string | undefined {
23
+ const line = readFileSync(join(dir, ".env"), "utf8")
24
+ .split("\n")
25
+ .find((l) => l.startsWith(`${key}=`))
26
+ return line?.slice(key.length + 1).trim()
27
+ }
28
+
29
+ describe("writeKeysToEnv", () => {
30
+ let dir: string
31
+
32
+ beforeEach(() => {
33
+ dir = mkdtempSync(join(tmpdir(), "supatype-keys-"))
34
+ })
35
+ afterEach(() => {
36
+ rmSync(dir, { recursive: true, force: true })
37
+ })
38
+
39
+ it("fills blank keys", () => {
40
+ writeFileSync(join(dir, ".env"), "JWT_SECRET=s\nANON_KEY=\nSERVICE_ROLE_KEY=\n")
41
+ const result = writeKeysToEnv(dir, PAIR, false)
42
+
43
+ expect(result.written).toContain("ANON_KEY")
44
+ expect(result.written).toContain("SERVICE_ROLE_KEY")
45
+ expect(envValue(dir, "ANON_KEY")).toBe(PAIR.anonKey)
46
+ expect(envValue(dir, "SERVICE_ROLE_KEY")).toBe(PAIR.serviceKey)
47
+ })
48
+
49
+ it("adds keys that are absent entirely", () => {
50
+ writeFileSync(join(dir, ".env"), "JWT_SECRET=s\n")
51
+ writeKeysToEnv(dir, PAIR, false)
52
+
53
+ expect(envValue(dir, "ANON_KEY")).toBe(PAIR.anonKey)
54
+ expect(envValue(dir, "VITE_SUPATYPE_ANON_KEY")).toBe(PAIR.anonKey)
55
+ })
56
+
57
+ it("treats a declared-but-blank line as a blank to fill", () => {
58
+ // The templates ship `ANON_KEY=` with nothing after it, which is exactly the line to write.
59
+ writeFileSync(join(dir, ".env"), "JWT_SECRET=s\nVITE_SUPATYPE_ANON_KEY=\n")
60
+ const result = writeKeysToEnv(dir, PAIR, false)
61
+
62
+ expect(result.written).toContain("VITE_SUPATYPE_ANON_KEY")
63
+ expect(envValue(dir, "VITE_SUPATYPE_ANON_KEY")).toBe(PAIR.anonKey)
64
+ })
65
+
66
+ it("leaves a key that already holds a value, and says which", () => {
67
+ writeFileSync(join(dir, ".env"), "JWT_SECRET=s\nANON_KEY=in-use-by-clients\nSERVICE_ROLE_KEY=\n")
68
+ const result = writeKeysToEnv(dir, PAIR, false)
69
+
70
+ expect(result.kept).toContain("ANON_KEY")
71
+ expect(result.written).not.toContain("ANON_KEY")
72
+ expect(envValue(dir, "ANON_KEY")).toBe("in-use-by-clients")
73
+ expect(envValue(dir, "SERVICE_ROLE_KEY")).toBe(PAIR.serviceKey)
74
+ })
75
+
76
+ it("replaces every key it owns under --force, not only the blank ones", () => {
77
+ writeFileSync(
78
+ join(dir, ".env"),
79
+ "JWT_SECRET=s\nANON_KEY=old-anon\nSERVICE_ROLE_KEY=old-service\nVITE_SUPATYPE_ANON_KEY=old-vite\n",
80
+ )
81
+ const result = writeKeysToEnv(dir, PAIR, true)
82
+
83
+ expect(result.kept).toEqual([])
84
+ expect(envValue(dir, "ANON_KEY")).toBe(PAIR.anonKey)
85
+ expect(envValue(dir, "SERVICE_ROLE_KEY")).toBe(PAIR.serviceKey)
86
+ expect(envValue(dir, "VITE_SUPATYPE_ANON_KEY")).toBe(PAIR.anonKey)
87
+ })
88
+
89
+ it("does not rewrite the file, even under --force", () => {
90
+ // `.env` holds JWT_SECRET, which these keys are derived from, plus the database password and
91
+ // whatever else the project keeps there. Replacing the file would leave a project whose keys
92
+ // cannot be regenerated and whose stack cannot start.
93
+ writeFileSync(
94
+ join(dir, ".env"),
95
+ "# a comment someone wrote\nJWT_SECRET=s\nPOSTGRES_PASSWORD=pw\nANON_KEY=old\nCUSTOM_THING=mine\n",
96
+ )
97
+ writeKeysToEnv(dir, PAIR, true)
98
+
99
+ const content = readFileSync(join(dir, ".env"), "utf8")
100
+ expect(content).toContain("# a comment someone wrote")
101
+ expect(envValue(dir, "JWT_SECRET")).toBe("s")
102
+ expect(envValue(dir, "POSTGRES_PASSWORD")).toBe("pw")
103
+ expect(envValue(dir, "CUSTOM_THING")).toBe("mine")
104
+ })
105
+
106
+ it("carries the API URL through when the project records one", () => {
107
+ writeFileSync(join(dir, ".env"), "JWT_SECRET=s\nPUBLIC_SUPATYPE_URL=http://localhost:18475\n")
108
+ writeKeysToEnv(dir, PAIR, true)
109
+
110
+ expect(envValue(dir, "VITE_SUPATYPE_URL")).toBe("http://localhost:18475")
111
+ })
112
+
113
+ it("writes no URL names when the project records no URL", () => {
114
+ // An empty `VITE_SUPATYPE_URL` is worse than an absent one: the client reads it, finds a blank
115
+ // string, and requests against the page origin without saying why.
116
+ writeFileSync(join(dir, ".env"), "JWT_SECRET=s\n")
117
+ writeKeysToEnv(dir, PAIR, false)
118
+
119
+ expect(readFileSync(join(dir, ".env"), "utf8")).not.toContain("VITE_SUPATYPE_URL")
120
+ })
121
+
122
+ it("reports no .env rather than creating one", () => {
123
+ const result = writeKeysToEnv(dir, PAIR, false)
124
+
125
+ expect(result.path).toBeNull()
126
+ expect(result.written).toEqual([])
127
+ })
128
+ })
129
+
130
+ describe("anonKeyEnvUpdates", () => {
131
+ it("is the one list both `keys` and `dev` write from", () => {
132
+ // They held separate lists, so a front end's prefix could be written by one and forgotten by
133
+ // the other, and which names a project's .env ended up with depended on which command ran last.
134
+ const names = Object.keys(anonKeyEnvUpdates("a", "s"))
135
+ expect(names).toEqual([
136
+ "ANON_KEY",
137
+ "SERVICE_ROLE_KEY",
138
+ "VITE_SUPATYPE_ANON_KEY",
139
+ "PUBLIC_SUPATYPE_ANON_KEY",
140
+ "EXPO_PUBLIC_SUPATYPE_ANON_KEY",
141
+ ])
142
+ })
143
+
144
+ it("adds the URL names only when given a URL", () => {
145
+ expect(Object.keys(anonKeyEnvUpdates("a", "s", "http://x"))).toContain("VITE_SUPATYPE_URL")
146
+ expect(Object.keys(anonKeyEnvUpdates("a", "s"))).not.toContain("VITE_SUPATYPE_URL")
147
+ })
148
+ })