@supatype/cli 0.1.10 → 0.1.12

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.
@@ -816,7 +816,22 @@ function packageJsonTemplate(opts: ScaffoldOptions, deps: InitDependencyVersions
816
816
  if (opts.helloFunction) {
817
817
  scripts.push(` "functions": "supatype functions serve"`)
818
818
  }
819
- const devDeps = [` "tsx": "^4.19.2"`, ` "typescript": "^5"`]
819
+ // Both are development-time only, so they belong in devDependencies. Validated end to end
820
+ // against a real database: schema/index.ts imports @supatype/types with `import type`, which is
821
+ // erased at build; supatype.config.ts imports defineConfig, which only the CLI evaluates; and
822
+ // seed.ts is run by `tsx seed.ts`. Nothing `supatype push` or `supatype generate` writes refers
823
+ // to either package: the generated types import nothing, and the client augmentation declares a
824
+ // module for @supatype/client, which stays a real dependency.
825
+ //
826
+ // In dependencies they were installed by `npm install --omit=dev`, so every production install
827
+ // of a Supatype app pulled the whole CLI toolchain (ink, react, typescript, ts-morph, pg, tsx)
828
+ // to support three files that never run in production.
829
+ const devDeps = [
830
+ ` "@supatype/cli": "^${deps.cli}"`,
831
+ ` "@supatype/types": "^${deps.types}"`,
832
+ ` "tsx": "^4.19.2"`,
833
+ ` "typescript": "^5"`,
834
+ ]
820
835
  if (opts.app.viteDevUrl) {
821
836
  devDeps.push(` "vite": "^6"`)
822
837
  }
@@ -827,10 +842,6 @@ function packageJsonTemplate(opts: ScaffoldOptions, deps: InitDependencyVersions
827
842
  "scripts": {
828
843
  ${scripts.join(",\n")}
829
844
  },
830
- "dependencies": {
831
- "@supatype/cli": "^${deps.cli}",
832
- "@supatype/types": "^${deps.types}"
833
- },
834
845
  "devDependencies": {
835
846
  ${devDeps.join(",\n")}
836
847
  }
@@ -38,6 +38,7 @@ import {
38
38
  import { confirm, logSkippedConfirm } from "../ui/confirm.js"
39
39
  import { info, plain } from "../ui/messages.js"
40
40
  import { withSpinner } from "../ui/progress.js"
41
+ import { writeGeneratedTypes } from "../type-generation.js"
41
42
  import { isInteractive } from "../ui/interactive.js"
42
43
 
43
44
  const DEV_JWT_SECRET = "super-secret-jwt-token-with-at-least-32-characters-long"
@@ -247,14 +248,19 @@ async function generateTypesLocal(ast: unknown, config: SupatypeProjectConfig):
247
248
  if (syncManifestHooks(cwd, ast)) info("Hook map written to .supatype/manifest.json")
248
249
 
249
250
  if (!config.output?.types && !config.output?.client) return
250
- await withSpinner("Generating types", async () => {
251
- await ensureEngine()
252
- const genBody: Record<string, unknown> = { ast, lang: "typescript" }
253
- if (config.output?.types) genBody["types_path"] = config.output.types
254
- if (config.output?.client) genBody["client_path"] = config.output.client
255
- const genResult = await engineRequest<{ message?: string }>("/generate", genBody)
256
- return genResult.message ?? "Types generated."
257
- }).then((msg) => info(msg))
251
+ // The CLI writes these, it does not ask the engine to. Passing types_path and client_path and
252
+ // reading only `message` meant the generated TypeScript was printed to the terminal and no file
253
+ // was ever created, so `push` reported success and produced nothing.
254
+ const written = await withSpinner("Generating types", async () => {
255
+ const messages = await writeGeneratedTypes({
256
+ cwd,
257
+ ast,
258
+ typesPath: config.output?.types,
259
+ clientPath: config.output?.client,
260
+ })
261
+ return messages
262
+ })
263
+ for (const message of written) info(message)
258
264
  }
259
265
 
260
266
  async function provisionLocalStorage(ast: unknown, config: SupatypeProjectConfig): Promise<void> {
@@ -113,6 +113,16 @@ async function probeLogicalDecoding(q: QueryFn): Promise<CheckResult> {
113
113
 
114
114
  const REQUIRED_ROLES = ["anon", "authenticated", "service_role", "authenticator"] as const
115
115
 
116
+ /** The three roles PostgREST switches to per request. `authenticator` only connects and switches. */
117
+ const API_ROLES = ["anon", "authenticated", "service_role"] as const
118
+
119
+ /** One row per API role that exists, for the schema named in the query. */
120
+ export interface AuthSchemaUsageRow {
121
+ rolname: string | null
122
+ has_usage: boolean | null
123
+ owner: string
124
+ }
125
+
116
126
  /**
117
127
  * Stands in for the `authenticator` password when the operator has not supplied one.
118
128
  *
@@ -309,7 +319,7 @@ export async function runPreflight(
309
319
  WHERE g.rolname = 'authenticator'`,
310
320
  )
311
321
  const held = new Set(memberships.map((m) => m.rolname))
312
- const needed = ["anon", "authenticated", "service_role"].filter((r) => !held.has(r))
322
+ const needed = API_ROLES.filter((r) => !held.has(r))
313
323
  results.push({
314
324
  id: "authenticator-memberships",
315
325
  title: "authenticator can reach the three API roles",
@@ -322,6 +332,19 @@ export async function runPreflight(
322
332
  })
323
333
  }
324
334
 
335
+ // USAGE on the auth schema, asked by OID so a role that does not exist cannot raise
336
+ // `role "x" does not exist` the way the name-taking overload would.
337
+ const authUsage = await q<AuthSchemaUsageRow>(
338
+ `SELECT r.rolname,
339
+ has_schema_privilege(r.oid, n.oid, 'USAGE') AS has_usage,
340
+ pg_get_userbyid(n.nspowner) AS owner
341
+ FROM pg_namespace n
342
+ LEFT JOIN pg_roles r ON r.rolname = ANY($1)
343
+ WHERE n.nspname = 'auth'`,
344
+ [API_ROLES as unknown as string[]],
345
+ )
346
+ results.push(authSchemaUsageCheck(authUsage, privs))
347
+
325
348
  // ── Extensions ─────────────────────────────────────────────────────────────
326
349
  const installed = new Set(
327
350
  (await q<{ extname: string }>("SELECT extname FROM pg_extension")).map((r) => r.extname),
@@ -456,6 +479,71 @@ export async function runPreflight(
456
479
  return { results, worst: worstOf(results) }
457
480
  }
458
481
 
482
+ /**
483
+ * Can the API roles reach the auth helpers when a query calls them?
484
+ *
485
+ * Postgres evaluates a policy's `USING` clause with the table owner's privileges, so row-level
486
+ * security works without this grant and looks like proof the schema is fine. Field masking does
487
+ * not: `supatype_mask` rewrites a masked column into `CASE WHEN can_read_t__c(t) …` in the target
488
+ * list, which runs as the caller and reaches `auth.role()` directly. Studio's per-record
489
+ * `can_<op>_<table>` calls have the same shape.
490
+ *
491
+ * `supatype/postgres` grants this at initdb, so a missing grant means a database Supatype did not
492
+ * bootstrap. Kept a `degrade` rather than a `fail`: two named features stop working and the rest
493
+ * of the stack is unaffected.
494
+ */
495
+ export function authSchemaUsageCheck(
496
+ rows: AuthSchemaUsageRow[],
497
+ privs: { current_user: string; is_super: boolean },
498
+ ): CheckResult {
499
+ const base = { id: "auth-schema-usage", title: "USAGE on schema auth (API roles)" }
500
+
501
+ // No auth schema yet: the first push creates it and grants on it, so there is nothing to fix.
502
+ if (rows.length === 0) {
503
+ return {
504
+ ...base,
505
+ severity: "pass",
506
+ detail: "schema auth does not exist yet; the first push creates it and grants usage",
507
+ }
508
+ }
509
+
510
+ const owner = rows[0]!.owner
511
+ const present = rows.filter((r) => r.rolname !== null)
512
+
513
+ // The roles check above owns this finding; repeating it here as a privilege problem would send
514
+ // the operator after the wrong fix.
515
+ if (present.length === 0) {
516
+ return { ...base, severity: "pass", detail: "no API roles exist on this server yet" }
517
+ }
518
+
519
+ const lacking = present.filter((r) => !r.has_usage).map((r) => r.rolname!)
520
+ if (lacking.length === 0) {
521
+ return {
522
+ ...base,
523
+ severity: "pass",
524
+ detail: `granted to ${present.map((r) => r.rolname).join(", ")} (schema owned by "${owner}")`,
525
+ }
526
+ }
527
+
528
+ // A grant from a role that owns neither the schema nor the server is discarded with a WARNING
529
+ // rather than an error, so `--fix` would report "Applied" having changed nothing. That is the
530
+ // same fault as a privilege generated and never applied, so hand it to the operator instead.
531
+ const canGrant = owner === privs.current_user || privs.is_super
532
+
533
+ return {
534
+ ...base,
535
+ severity: "degrade",
536
+ detail: `not granted to ${lacking.join(", ")} (schema owned by "${owner}")`,
537
+ impact:
538
+ "Field-level access is unavailable: a push declaring `access.fields` refuses rather than " +
539
+ "applying, and a masked column read by one of these roles fails with 42501 instead of " +
540
+ "masking. Studio's per-record permission checks fail the same way. Row-level security is " +
541
+ "unaffected, because Postgres evaluates policies with the table owner's privileges.",
542
+ remedy: `GRANT USAGE ON SCHEMA auth TO ${lacking.map(ident).join(", ")};`,
543
+ ...(!canGrant && { remedyNeedsOperator: true }),
544
+ }
545
+ }
546
+
459
547
  function extensionCheck(
460
548
  name: string,
461
549
  installed: Set<string>,
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Write a project's generated types to disk.
3
+ *
4
+ * `push` used to send `types_path` and `client_path` to the engine and read only `message`,
5
+ * expecting the engine to write the files. It does not write them, so with `output.types`
6
+ * configured the generated TypeScript was printed to the terminal and nothing reached disk:
7
+ * `supatype push` claims to generate types and produced none. `supatype generate` had it right
8
+ * all along, and this is that logic, shared, so the two cannot disagree again.
9
+ *
10
+ * Each path is optional and an absent one is skipped, because the two callers differ on purpose:
11
+ * `generate` falls back to defaults and always writes, while `push` writes only what the project
12
+ * asked for and must not start creating files in projects that never configured any.
13
+ */
14
+
15
+ import { mkdirSync, writeFileSync } from "node:fs"
16
+ import { dirname, resolve } from "node:path"
17
+ import { generateClientAugmentation } from "./augmentation-generator.js"
18
+ import { ensureEngine, engineRequest } from "./engine-client.js"
19
+
20
+ export interface GenerateTypesRequest {
21
+ cwd: string
22
+ ast: unknown
23
+ /** Relative path for the database types, from `output.types`. */
24
+ typesPath?: string | undefined
25
+ /** Relative path for the client augmentation, from `output.client`. */
26
+ clientPath?: string | undefined
27
+ }
28
+
29
+ /** Writes what was asked for and returns one message per file, for the caller to report. */
30
+ export async function writeGeneratedTypes(req: GenerateTypesRequest): Promise<string[]> {
31
+ const written: string[] = []
32
+
33
+ if (req.typesPath !== undefined && req.typesPath !== "") {
34
+ await ensureEngine()
35
+ const result = await engineRequest<{ code?: string; message?: string }>(
36
+ "/generate",
37
+ { ast: req.ast, lang: "typescript" },
38
+ )
39
+ // `code` is the field the engine fills; `message` is the older shape. Reading only `message`
40
+ // and printing it is how the generated file ended up in the terminal.
41
+ const code = result.code ?? result.message
42
+ if (code === undefined) {
43
+ throw new Error("Engine returned no output for type generation.")
44
+ }
45
+ const outPath = resolve(req.cwd, req.typesPath)
46
+ mkdirSync(dirname(outPath), { recursive: true })
47
+ writeFileSync(outPath, code, "utf8")
48
+ written.push(`Types written to ${req.typesPath}`)
49
+ }
50
+
51
+ // Generated locally from the AST, so it needs no engine round trip.
52
+ if (req.clientPath !== undefined && req.clientPath !== "") {
53
+ const outPath = resolve(req.cwd, req.clientPath)
54
+ mkdirSync(dirname(outPath), { recursive: true })
55
+ writeFileSync(outPath, generateClientAugmentation(req.ast), "utf8")
56
+ written.push(`Client augmentation written to ${req.clientPath}`)
57
+ }
58
+
59
+ return written
60
+ }
@@ -12,10 +12,20 @@ const CLI_BIN = resolve(__dirname, "../bin/supatype.js")
12
12
 
13
13
  function runCli(cwd: string, args: string[]): { stdout: string; stderr: string; exitCode: number } {
14
14
  const result = spawnSync(process.execPath, [CLI_BIN, ...args], {
15
- encoding: "utf8",
16
15
  cwd,
17
- timeout: 10_000,
16
+ encoding: "utf8",
17
+ // 60s, not 10s: this spawns the built CLI, which imports Ink, React and commander before it
18
+ // does anything. That is about 1.2s idle, and CI runs `turbo run test` across every package at
19
+ // once, where it exceeded 10s and the subprocess was killed. The assertion then compared
20
+ // against empty output and pointed at the CLI rather than at contention.
21
+ timeout: 60_000,
18
22
  })
23
+ if (result.signal) {
24
+ throw new Error(
25
+ `CLI subprocess killed by ${result.signal} after the spawn timeout. `
26
+ + `Args: ${args.join(" ")}. This is usually machine contention, not a CLI fault.`,
27
+ )
28
+ }
19
29
  return {
20
30
  stdout: String(result.stdout ?? ""),
21
31
  stderr: String(result.stderr ?? ""),
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import {
3
3
  PASSWORD_PLACEHOLDER,
4
+ authSchemaUsageCheck,
5
+ type AuthSchemaUsageRow,
4
6
  needsOperatorPassword,
5
7
  operatorRemedies,
6
8
  transactionalRemedies,
@@ -70,3 +72,81 @@ describe("the authenticator password guard", () => {
70
72
  expect(PASSWORD_PLACEHOLDER).toMatch(/^<.*>$/)
71
73
  })
72
74
  })
75
+
76
+ describe("USAGE on the auth schema", () => {
77
+ const row = (rolname: string, has_usage: boolean, owner = "supatype_admin"): AuthSchemaUsageRow => ({
78
+ rolname,
79
+ has_usage,
80
+ owner,
81
+ })
82
+ const asOwner = { current_user: "supatype_admin", is_super: false }
83
+ const asStranger = { current_user: "app_migrator", is_super: false }
84
+
85
+ // Before the first push there is no auth schema, and the push creates it and grants on it.
86
+ // Reporting that as a problem would send the operator to fix something that fixes itself.
87
+ it("says nothing when the schema does not exist", () => {
88
+ const check = authSchemaUsageCheck([], asOwner)
89
+ expect(check.severity).toBe("pass")
90
+ expect(check.remedy).toBeUndefined()
91
+ })
92
+
93
+ it("passes when all three roles hold usage, and names the owner", () => {
94
+ const check = authSchemaUsageCheck(
95
+ [row("anon", true), row("authenticated", true), row("service_role", true)],
96
+ asOwner,
97
+ )
98
+ expect(check.severity).toBe("pass")
99
+ expect(check.detail).toContain("supatype_admin")
100
+ expect(check.remedy).toBeUndefined()
101
+ })
102
+
103
+ // Degrade, not fail: REST and RLS keep working because policies run with the table owner's
104
+ // privileges. Calling it a failure would exit non-zero on databases using neither feature.
105
+ it("degrades when a role lacks usage, and grants exactly that role", () => {
106
+ const check = authSchemaUsageCheck(
107
+ [row("anon", true), row("authenticated", false), row("service_role", true)],
108
+ asOwner,
109
+ )
110
+ expect(check.severity).toBe("degrade")
111
+ expect(check.remedy).toBe('GRANT USAGE ON SCHEMA auth TO "authenticated";')
112
+ expect(check.impact).toContain("access.fields")
113
+ expect(check.impact).toContain("42501")
114
+ })
115
+
116
+ it("grants every lacking role in one statement", () => {
117
+ const check = authSchemaUsageCheck(
118
+ [row("anon", false), row("authenticated", false), row("service_role", true)],
119
+ asOwner,
120
+ )
121
+ expect(check.remedy).toBe('GRANT USAGE ON SCHEMA auth TO "anon", "authenticated";')
122
+ })
123
+
124
+ // `--fix` applies remedies in a transaction and reports "Applied". Postgres discards a grant
125
+ // from a non-owner with a WARNING rather than an error, so without this flag `--fix` would
126
+ // report success having changed nothing.
127
+ it("hands the remedy to the operator when the caller cannot grant it", () => {
128
+ const check = authSchemaUsageCheck([row("authenticated", false)], asStranger)
129
+ expect(check.remedyNeedsOperator).toBe(true)
130
+ })
131
+
132
+ it("applies it itself when the caller owns the schema", () => {
133
+ const check = authSchemaUsageCheck([row("authenticated", false)], asOwner)
134
+ expect(check.remedyNeedsOperator).toBeUndefined()
135
+ })
136
+
137
+ it("applies it itself when the caller is a superuser on someone else's schema", () => {
138
+ const check = authSchemaUsageCheck(
139
+ [row("authenticated", false, "someone_else")],
140
+ { current_user: "postgres", is_super: true },
141
+ )
142
+ expect(check.remedyNeedsOperator).toBeUndefined()
143
+ })
144
+
145
+ // A LEFT JOIN against a server with none of the API roles yields one all-null row. The roles
146
+ // check owns that finding; reporting it here as a privilege problem points at the wrong fix.
147
+ it("defers to the roles check when no API role exists", () => {
148
+ const check = authSchemaUsageCheck([{ rolname: null, has_usage: null, owner: "postgres" }], asOwner)
149
+ expect(check.severity).toBe("pass")
150
+ expect(check.detail).toContain("no API roles")
151
+ })
152
+ })