@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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +133 -133
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/cli-version-embedded.js +1 -1
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +15 -18
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +16 -5
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +15 -10
- package/dist/commands/push.js.map +1 -1
- package/dist/db-preflight.d.ts +23 -0
- package/dist/db-preflight.d.ts.map +1 -1
- package/dist/db-preflight.js +66 -1
- package/dist/db-preflight.js.map +1 -1
- package/dist/type-generation.d.ts +24 -0
- package/dist/type-generation.d.ts.map +1 -0
- package/dist/type-generation.js +44 -0
- package/dist/type-generation.js.map +1 -0
- package/package.json +1 -1
- package/src/cli-version-embedded.ts +1 -1
- package/src/commands/generate.ts +13 -21
- package/src/commands/init.ts +16 -5
- package/src/commands/push.ts +14 -8
- package/src/db-preflight.ts +89 -1
- package/src/type-generation.ts +60 -0
- package/tests/app-command.test.ts +12 -2
- package/tests/db-preflight.test.ts +80 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/commands/init.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/commands/push.ts
CHANGED
|
@@ -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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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> {
|
package/src/db-preflight.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
+
})
|