@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.
Files changed (243) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +144 -94
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/deno.d.ts +52 -0
  5. package/dist/augmentation-generator.d.ts +22 -0
  6. package/dist/augmentation-generator.d.ts.map +1 -1
  7. package/dist/augmentation-generator.js +18 -4
  8. package/dist/augmentation-generator.js.map +1 -1
  9. package/dist/binary-cache.d.ts +14 -0
  10. package/dist/binary-cache.d.ts.map +1 -1
  11. package/dist/binary-cache.js +33 -13
  12. package/dist/binary-cache.js.map +1 -1
  13. package/dist/cache-clean.d.ts +15 -0
  14. package/dist/cache-clean.d.ts.map +1 -0
  15. package/dist/cache-clean.js +76 -0
  16. package/dist/cache-clean.js.map +1 -0
  17. package/dist/cache-pins.d.ts +51 -0
  18. package/dist/cache-pins.d.ts.map +1 -0
  19. package/dist/cache-pins.js +184 -0
  20. package/dist/cache-pins.js.map +1 -0
  21. package/dist/cli-install-method.d.ts +19 -0
  22. package/dist/cli-install-method.d.ts.map +1 -0
  23. package/dist/cli-install-method.js +60 -0
  24. package/dist/cli-install-method.js.map +1 -0
  25. package/dist/cli.d.ts.map +1 -1
  26. package/dist/cli.js +6 -0
  27. package/dist/cli.js.map +1 -1
  28. package/dist/cloud-credentials.d.ts +34 -0
  29. package/dist/cloud-credentials.d.ts.map +1 -0
  30. package/dist/cloud-credentials.js +139 -0
  31. package/dist/cloud-credentials.js.map +1 -0
  32. package/dist/commands/admin.d.ts +11 -0
  33. package/dist/commands/admin.d.ts.map +1 -1
  34. package/dist/commands/admin.js +121 -19
  35. package/dist/commands/admin.js.map +1 -1
  36. package/dist/commands/cache.d.ts.map +1 -1
  37. package/dist/commands/cache.js +67 -71
  38. package/dist/commands/cache.js.map +1 -1
  39. package/dist/commands/cloud.d.ts.map +1 -1
  40. package/dist/commands/cloud.js +32 -8
  41. package/dist/commands/cloud.js.map +1 -1
  42. package/dist/commands/db.d.ts +1 -0
  43. package/dist/commands/db.d.ts.map +1 -1
  44. package/dist/commands/db.js +147 -1
  45. package/dist/commands/db.js.map +1 -1
  46. package/dist/commands/dev.d.ts.map +1 -1
  47. package/dist/commands/dev.js +50 -10
  48. package/dist/commands/dev.js.map +1 -1
  49. package/dist/commands/doctor.d.ts +28 -0
  50. package/dist/commands/doctor.d.ts.map +1 -1
  51. package/dist/commands/doctor.js +60 -3
  52. package/dist/commands/doctor.js.map +1 -1
  53. package/dist/commands/functions.d.ts +7 -0
  54. package/dist/commands/functions.d.ts.map +1 -1
  55. package/dist/commands/functions.js +40 -6
  56. package/dist/commands/functions.js.map +1 -1
  57. package/dist/commands/generate.d.ts.map +1 -1
  58. package/dist/commands/generate.js +5 -1
  59. package/dist/commands/generate.js.map +1 -1
  60. package/dist/commands/hooks.d.ts +12 -0
  61. package/dist/commands/hooks.d.ts.map +1 -0
  62. package/dist/commands/hooks.js +118 -0
  63. package/dist/commands/hooks.js.map +1 -0
  64. package/dist/commands/init.d.ts.map +1 -1
  65. package/dist/commands/init.js +41 -7
  66. package/dist/commands/init.js.map +1 -1
  67. package/dist/commands/internal.d.ts +6 -0
  68. package/dist/commands/internal.d.ts.map +1 -0
  69. package/dist/commands/internal.js +22 -0
  70. package/dist/commands/internal.js.map +1 -0
  71. package/dist/commands/link-helpers.d.ts.map +1 -1
  72. package/dist/commands/link-helpers.js +8 -2
  73. package/dist/commands/link-helpers.js.map +1 -1
  74. package/dist/commands/login.d.ts +7 -0
  75. package/dist/commands/login.d.ts.map +1 -0
  76. package/dist/commands/login.js +110 -0
  77. package/dist/commands/login.js.map +1 -0
  78. package/dist/commands/push.d.ts.map +1 -1
  79. package/dist/commands/push.js +110 -7
  80. package/dist/commands/push.js.map +1 -1
  81. package/dist/commands/self-update.d.ts +7 -0
  82. package/dist/commands/self-update.d.ts.map +1 -1
  83. package/dist/commands/self-update.js +18 -11
  84. package/dist/commands/self-update.js.map +1 -1
  85. package/dist/config.d.ts.map +1 -1
  86. package/dist/config.js +18 -3
  87. package/dist/config.js.map +1 -1
  88. package/dist/db-preflight.d.ts +78 -0
  89. package/dist/db-preflight.d.ts.map +1 -0
  90. package/dist/db-preflight.js +452 -0
  91. package/dist/db-preflight.js.map +1 -0
  92. package/dist/dev-compose.d.ts +1 -1
  93. package/dist/dev-compose.d.ts.map +1 -1
  94. package/dist/dev-compose.js +127 -72
  95. package/dist/dev-compose.js.map +1 -1
  96. package/dist/env-file.d.ts +40 -2
  97. package/dist/env-file.d.ts.map +1 -1
  98. package/dist/env-file.js +132 -11
  99. package/dist/env-file.js.map +1 -1
  100. package/dist/field-masking-tier.d.ts +50 -0
  101. package/dist/field-masking-tier.d.ts.map +1 -0
  102. package/dist/field-masking-tier.js +91 -0
  103. package/dist/field-masking-tier.js.map +1 -0
  104. package/dist/functions-deno-types.d.ts +24 -0
  105. package/dist/functions-deno-types.d.ts.map +1 -0
  106. package/dist/functions-deno-types.js +155 -0
  107. package/dist/functions-deno-types.js.map +1 -0
  108. package/dist/hook-upload.d.ts +30 -0
  109. package/dist/hook-upload.d.ts.map +1 -0
  110. package/dist/hook-upload.js +86 -0
  111. package/dist/hook-upload.js.map +1 -0
  112. package/dist/hooks-generator.d.ts +8 -0
  113. package/dist/hooks-generator.d.ts.map +1 -0
  114. package/dist/hooks-generator.js +332 -0
  115. package/dist/hooks-generator.js.map +1 -0
  116. package/dist/link.d.ts +4 -0
  117. package/dist/link.d.ts.map +1 -1
  118. package/dist/link.js +2 -0
  119. package/dist/link.js.map +1 -1
  120. package/dist/local-secrets.d.ts +71 -0
  121. package/dist/local-secrets.d.ts.map +1 -0
  122. package/dist/local-secrets.js +100 -0
  123. package/dist/local-secrets.js.map +1 -0
  124. package/dist/model-hooks.d.ts +73 -0
  125. package/dist/model-hooks.d.ts.map +1 -0
  126. package/dist/model-hooks.js +211 -0
  127. package/dist/model-hooks.js.map +1 -0
  128. package/dist/postgres-ctl.d.ts +11 -0
  129. package/dist/postgres-ctl.d.ts.map +1 -1
  130. package/dist/postgres-ctl.js +25 -2
  131. package/dist/postgres-ctl.js.map +1 -1
  132. package/dist/project-config.d.ts +121 -1
  133. package/dist/project-config.d.ts.map +1 -1
  134. package/dist/project-config.js +150 -1
  135. package/dist/project-config.js.map +1 -1
  136. package/dist/resolve-target.d.ts +4 -0
  137. package/dist/resolve-target.d.ts.map +1 -1
  138. package/dist/resolve-target.js +69 -73
  139. package/dist/resolve-target.js.map +1 -1
  140. package/dist/schema-ast-v2.d.ts +2 -1
  141. package/dist/schema-ast-v2.d.ts.map +1 -1
  142. package/dist/schema-ast-v2.js +4 -2
  143. package/dist/schema-ast-v2.js.map +1 -1
  144. package/dist/schema-push-lock.d.ts.map +1 -1
  145. package/dist/schema-push-lock.js +43 -16
  146. package/dist/schema-push-lock.js.map +1 -1
  147. package/dist/self-host-compose.d.ts +24 -0
  148. package/dist/self-host-compose.d.ts.map +1 -1
  149. package/dist/self-host-compose.js +301 -69
  150. package/dist/self-host-compose.js.map +1 -1
  151. package/dist/service-role-check.d.ts +21 -0
  152. package/dist/service-role-check.d.ts.map +1 -0
  153. package/dist/service-role-check.js +85 -0
  154. package/dist/service-role-check.js.map +1 -0
  155. package/dist/target-client.d.ts +17 -0
  156. package/dist/target-client.d.ts.map +1 -1
  157. package/dist/target-client.js +69 -17
  158. package/dist/target-client.js.map +1 -1
  159. package/dist/type-extractor.d.ts.map +1 -1
  160. package/dist/type-extractor.js +736 -32
  161. package/dist/type-extractor.js.map +1 -1
  162. package/dist/ui/runtime/command-chrome.js +1 -1
  163. package/dist/ui/runtime/command-chrome.js.map +1 -1
  164. package/dist/ui/runtime/stdin-after-ink.d.ts.map +1 -1
  165. package/dist/ui/runtime/stdin-after-ink.js +3 -1
  166. package/dist/ui/runtime/stdin-after-ink.js.map +1 -1
  167. package/package.json +4 -1
  168. package/scripts/generate-homebrew-formula.mjs +121 -0
  169. package/src/augmentation-generator.ts +18 -5
  170. package/src/binary-cache.ts +47 -15
  171. package/src/cache-clean.ts +81 -0
  172. package/src/cache-pins.ts +243 -0
  173. package/src/cli-install-method.ts +67 -0
  174. package/src/cli.ts +6 -0
  175. package/src/cloud-credentials.ts +183 -0
  176. package/src/commands/admin.ts +140 -25
  177. package/src/commands/cache.ts +90 -75
  178. package/src/commands/cloud.ts +32 -8
  179. package/src/commands/db.ts +162 -1
  180. package/src/commands/dev.ts +57 -8
  181. package/src/commands/doctor.ts +69 -3
  182. package/src/commands/functions.ts +42 -5
  183. package/src/commands/generate.ts +5 -1
  184. package/src/commands/hooks.ts +138 -0
  185. package/src/commands/init.ts +42 -8
  186. package/src/commands/internal.ts +23 -0
  187. package/src/commands/link-helpers.ts +9 -2
  188. package/src/commands/login.ts +127 -0
  189. package/src/commands/push.ts +124 -7
  190. package/src/commands/self-update.ts +85 -13
  191. package/src/config.ts +22 -3
  192. package/src/db-preflight.ts +575 -0
  193. package/src/dev-compose.ts +142 -73
  194. package/src/env-file.ts +157 -11
  195. package/src/field-masking-tier.ts +107 -0
  196. package/src/functions-deno-types.ts +188 -0
  197. package/src/hook-upload.ts +107 -0
  198. package/src/hooks-generator.ts +352 -0
  199. package/src/link.ts +7 -0
  200. package/src/local-secrets.ts +104 -0
  201. package/src/model-hooks.ts +260 -0
  202. package/src/postgres-ctl.ts +29 -2
  203. package/src/project-config.ts +236 -2
  204. package/src/resolve-target.ts +113 -68
  205. package/src/schema-ast-v2.ts +5 -2
  206. package/src/schema-push-lock.ts +56 -17
  207. package/src/self-host-compose.ts +344 -68
  208. package/src/service-role-check.ts +111 -0
  209. package/src/target-client.ts +105 -18
  210. package/src/type-extractor.ts +883 -28
  211. package/src/ui/runtime/command-chrome.ts +1 -1
  212. package/src/ui/runtime/stdin-after-ink.ts +3 -1
  213. package/tests/access-composition.test.ts +667 -0
  214. package/tests/admin-studio-membership.test.ts +121 -0
  215. package/tests/api-schemas.test.ts +83 -0
  216. package/tests/archive-name-alias.test.ts +42 -0
  217. package/tests/augmentation-generator.test.ts +28 -0
  218. package/tests/cache-clean.test.ts +206 -0
  219. package/tests/cli-help.test.ts +3 -0
  220. package/tests/cli-install-method.test.ts +66 -0
  221. package/tests/command-chrome-coverage.test.ts +31 -26
  222. package/tests/command-chrome-wrap.test.ts +1 -0
  223. package/tests/db-preflight.test.ts +72 -0
  224. package/tests/doctor-render.test.ts +62 -0
  225. package/tests/doctor-service-role.test.ts +55 -0
  226. package/tests/env-file.test.ts +121 -0
  227. package/tests/external-database-compose.test.ts +184 -0
  228. package/tests/external-database-config.test.ts +154 -0
  229. package/tests/field-masking-tier.test.ts +184 -0
  230. package/tests/functions-deno-types.test.ts +197 -0
  231. package/tests/homebrew-formula.test.ts +58 -0
  232. package/tests/hook-upload.test.ts +125 -0
  233. package/tests/hooks-generator.test.ts +212 -0
  234. package/tests/init.test.ts +3 -0
  235. package/tests/local-secrets.test.ts +92 -0
  236. package/tests/model-hooks.test.ts +322 -0
  237. package/tests/runtime-contract.test.ts +113 -1
  238. package/tests/service-role-check.test.ts +109 -0
  239. package/tests/service-role-routes.test.ts +51 -0
  240. package/tests/target-client-refresh.test.ts +94 -0
  241. package/tests/type-extractor.test.ts +187 -0
  242. package/tsconfig.tsbuildinfo +1 -1
  243. package/tests/engine-distribution.test.ts +0 -418
@@ -1,6 +1,7 @@
1
1
  import type { Command } from "commander"
2
2
  import { readFileSync, writeFileSync, existsSync } from "node:fs"
3
3
  import { resolve } from "node:path"
4
+ import { loadCloudCredentials, persistCloudSession } from "../cloud-credentials.js"
4
5
  import { loadProjectLink, migrateLegacyLinkFiles } from "../link.js"
5
6
  import { targetFetch } from "../target-client.js"
6
7
  import { registerEnvs, registerLinkOptions, runLinkAction } from "./link-helpers.js"
@@ -28,7 +29,7 @@ export function loadCloudConfig(cwd: string): CloudConfig | null {
28
29
  }
29
30
  return {
30
31
  apiUrl: link.cloudApiUrl ?? "https://api.supatype.com",
31
- token: link.token ?? "",
32
+ token: link.token ?? loadCloudCredentials()?.accessToken ?? "",
32
33
  projectSlug: link.projectRef,
33
34
  ...(link.orgId !== undefined ? { orgId: link.orgId } : {}),
34
35
  }
@@ -44,19 +45,39 @@ function saveCloudConfig(cwd: string, config: CloudConfig): void {
44
45
  }
45
46
 
46
47
  async function cloudFetch<T>(config: CloudConfig, method: string, path: string, body?: unknown): Promise<T> {
48
+ const cwd = process.cwd()
49
+ const creds = loadCloudCredentials()
50
+ const refreshToken = creds?.refreshToken ?? loadProjectLink(cwd)?.refreshToken
47
51
  return targetFetch<T>(config.apiUrl, "/api/v1", {
48
52
  method,
49
53
  path,
50
- body,
54
+ ...(body !== undefined ? { body } : {}),
51
55
  token: config.token,
52
- orgId: config.orgId,
56
+ ...(config.orgId !== undefined ? { orgId: config.orgId } : {}),
57
+ ...(refreshToken
58
+ ? {
59
+ authRefresh: {
60
+ cloudApiUrl: config.apiUrl,
61
+ refreshToken,
62
+ onRefreshed: ({ accessToken, refreshToken: nextRefresh }) => {
63
+ persistCloudSession(cwd, {
64
+ apiUrl: config.apiUrl,
65
+ accessToken,
66
+ ...(nextRefresh !== undefined ? { refreshToken: nextRefresh } : {}),
67
+ })
68
+ config.token = accessToken
69
+ },
70
+ },
71
+ }
72
+ : {}),
53
73
  })
54
74
  }
55
75
 
56
76
  export function isCloudLinked(cwd: string): boolean {
57
77
  migrateLegacyLinkFiles(cwd)
58
78
  const link = loadProjectLink(cwd)
59
- return Boolean(link?.kind === "cloud" && link.projectRef && link.token)
79
+ if (!(link?.kind === "cloud" && link.projectRef)) return false
80
+ return Boolean(link.token || loadCloudCredentials()?.accessToken)
60
81
  }
61
82
 
62
83
  export async function pushSchemaToLinkedProject(
@@ -260,15 +281,18 @@ function getCloudConfigOrExit(): CloudConfig {
260
281
  const cwd = process.cwd()
261
282
  let config = loadCloudConfig(cwd)
262
283
  if (!config) {
284
+ const creds = loadCloudCredentials()
263
285
  const token =
264
286
  process.env["SUPATYPE_ACCESS_TOKEN"] ??
265
- process.env["SUPATYPE_TOKEN"]
266
- const apiUrl = process.env["SUPATYPE_API_URL"] ?? "https://api.supatype.com"
287
+ process.env["SUPATYPE_TOKEN"] ??
288
+ creds?.accessToken
289
+ const apiUrl =
290
+ process.env["SUPATYPE_API_URL"] ?? creds?.apiUrl ?? "https://api.supatype.com"
267
291
  if (!token) {
268
- error("Not connected to Supatype Cloud. Run: supatype link, or set SUPATYPE_ACCESS_TOKEN.")
292
+ error("Not connected to Supatype Cloud. Run: supatype login (then supatype link), or set SUPATYPE_ACCESS_TOKEN.")
269
293
  process.exit(1)
270
294
  }
271
- config = { apiUrl, token }
295
+ config = { apiUrl: apiUrl.replace(/\/$/, ""), token }
272
296
  }
273
297
  return config
274
298
  }
@@ -2,6 +2,7 @@
2
2
  * Database connection commands:
3
3
  * supatype db connection-string — show the connection string for the linked project
4
4
  * supatype db reset-password — reset the database password
5
+ * supatype db check — does this database meet what Supatype needs of it?
5
6
  */
6
7
 
7
8
  import type { Command } from "commander"
@@ -10,7 +11,16 @@ import { connectionString } from "../project-config.js"
10
11
  import { loadProjectLink } from "../link.js"
11
12
  import { resolveTarget } from "../resolve-target.js"
12
13
  import { targetFetch } from "../target-client.js"
13
- import { error, info, plain } from "../ui/messages.js"
14
+ import { error, info, plain, warn } from "../ui/messages.js"
15
+ import {
16
+ applyRemedies,
17
+ needsOperatorPassword,
18
+ operatorRemedies,
19
+ runPreflight,
20
+ transactionalRemedies,
21
+ type CheckResult,
22
+ type Severity,
23
+ } from "../db-preflight.js"
14
24
 
15
25
  export function registerDb(program: Command): void {
16
26
  const db = program
@@ -110,4 +120,155 @@ export function registerDb(program: Command): void {
110
120
  process.exitCode = 1
111
121
  }
112
122
  })
123
+
124
+ db
125
+ .command("check")
126
+ .description("Check whether a database meets Supatype's requirements (for external Postgres)")
127
+ .option("--connection <url>", "Postgres URL to check (else config `connection`, else DATABASE_URL)")
128
+ .option("--schema <name>", "Schema Supatype will manage (else config `schema.pg_schema`)")
129
+ .option("--emit", "Print the remediation SQL instead of applying it")
130
+ .option("--fix", "Apply the remediation in a single transaction")
131
+ .option(
132
+ "--authenticator-password <password>",
133
+ "Password for the `authenticator` role when it has to be created",
134
+ )
135
+ .action(async (opts: {
136
+ connection?: string
137
+ schema?: string
138
+ emit?: boolean
139
+ fix?: boolean
140
+ authenticatorPassword?: string
141
+ }) => {
142
+ if (opts.emit && opts.fix) {
143
+ error("Pass either --emit or --fix, not both: one prints the SQL, the other runs it.")
144
+ process.exitCode = 1
145
+ return
146
+ }
147
+
148
+ const cwd = process.cwd()
149
+ let url = opts.connection?.trim()
150
+ let schema = opts.schema?.trim()
151
+ if (!url || !schema) {
152
+ try {
153
+ const config = loadConfig(cwd)
154
+ url = url ?? connectionString(config)
155
+ schema = schema ?? config.schema?.pg_schema ?? "public"
156
+ } catch {
157
+ // Runnable outside a project, which is the point — an operator should be able to check a
158
+ // database before committing to any config at all.
159
+ url = url ?? process.env["DATABASE_URL"]
160
+ schema = schema ?? "public"
161
+ }
162
+ }
163
+ if (!url) {
164
+ error("No connection. Pass --connection, set DATABASE_URL, or set `connection` in supatype.config.ts.")
165
+ process.exitCode = 1
166
+ return
167
+ }
168
+
169
+ const pgMod = await import("pg")
170
+ const client = new pgMod.default.Client({ connectionString: url })
171
+ try {
172
+ await client.connect()
173
+ } catch (err) {
174
+ error(`Could not connect: ${(err as Error).message}`)
175
+ process.exitCode = 1
176
+ return
177
+ }
178
+
179
+ try {
180
+ const report = await runPreflight(client, {
181
+ schema: schema!,
182
+ ...(opts.authenticatorPassword !== undefined && {
183
+ authenticatorPassword: opts.authenticatorPassword,
184
+ }),
185
+ })
186
+
187
+ const fixable = transactionalRemedies(report)
188
+ const manual = operatorRemedies(report)
189
+
190
+ if (opts.emit) {
191
+ // Machine-consumable: SQL on stdout, commentary on stderr, so it can be piped to psql.
192
+ if (fixable.length === 0) {
193
+ warn("Nothing to emit — no SQL-fixable findings.")
194
+ } else {
195
+ plain("BEGIN;")
196
+ for (const r of fixable) plain(`\n-- ${r.title}\n${r.remedy}`)
197
+ plain("\nCOMMIT;")
198
+ }
199
+ if (manual.length > 0) {
200
+ warn(`\n${manual.length} finding(s) need operator action and are not included above:`)
201
+ for (const r of manual) warn(` ${r.title} — ${r.detail}`)
202
+ }
203
+ return
204
+ }
205
+
206
+ printReport(report.results)
207
+
208
+ if (opts.fix) {
209
+ if (fixable.length === 0) {
210
+ info("Nothing to fix.")
211
+ } else if (needsOperatorPassword(fixable)) {
212
+ // The credential is the operator's by decision. Creating the role with a placeholder
213
+ // password would be worse than refusing, and harder to notice.
214
+ error(
215
+ "`authenticator` has to be created and no password was given. Nothing was changed.\n" +
216
+ " Re-run with --authenticator-password <password>, or use --emit and apply the SQL yourself.\n" +
217
+ " Use the same value for AUTHENTICATOR_PASSWORD in your .env.",
218
+ )
219
+ process.exitCode = 1
220
+ return
221
+ } else {
222
+ info(`\nApplying ${fixable.length} remediation(s) in one transaction...`)
223
+ try {
224
+ await applyRemedies(client, fixable)
225
+ info("Applied. Re-run `supatype db check` to confirm.")
226
+ } catch (err) {
227
+ error(`Rolled back — the database is unchanged: ${(err as Error).message}`)
228
+ process.exitCode = 1
229
+ return
230
+ }
231
+ }
232
+ if (manual.length > 0) {
233
+ warn(`\n${manual.length} finding(s) cannot be fixed in a transaction:`)
234
+ for (const r of manual) {
235
+ warn(` ${r.title} — needs a server setting change and likely a restart`)
236
+ }
237
+ }
238
+ return
239
+ }
240
+
241
+ if (report.worst === "fail") {
242
+ plain("")
243
+ error("This database is not ready. Re-run with --emit to see the SQL, or --fix to apply it.")
244
+ process.exitCode = 1
245
+ } else if (fixable.length > 0) {
246
+ plain("")
247
+ info("Re-run with --emit to see suggested SQL, or --fix to apply it.")
248
+ }
249
+ } catch (err) {
250
+ // An unexpected failure must not read as a clean bill of health.
251
+ error(`Preflight could not complete: ${(err as Error).message}`)
252
+ process.exitCode = 1
253
+ } finally {
254
+ await client.end().catch(() => undefined)
255
+ }
256
+ })
257
+ }
258
+
259
+ const SEVERITY_LABEL: Record<Severity, string> = {
260
+ pass: "ok ",
261
+ warn: "note",
262
+ degrade: "off ",
263
+ fail: "FAIL",
264
+ }
265
+
266
+ function printReport(results: readonly CheckResult[]): void {
267
+ for (const r of results) {
268
+ const line = `[${SEVERITY_LABEL[r.severity]}] ${r.title} — ${r.detail}`
269
+ if (r.severity === "fail") error(line)
270
+ else if (r.severity === "degrade" || r.severity === "warn") warn(line)
271
+ else plain(line)
272
+ if (r.impact) plain(` ${r.impact}`)
273
+ }
113
274
  }
@@ -18,13 +18,21 @@ import { isAbsolute, join, relative, resolve } from "node:path"
18
18
  import { loadConfig } from "../config.js"
19
19
  import type { ExtractedSchemaAstV2 } from "../schema-ast-v2.js"
20
20
  import {
21
+ apiSchemaList,
21
22
  functionsPathCandidatesFromProject,
22
23
  resolveRuntimeProvider,
23
24
  schemaPathFromProject,
24
25
  type SupatypeProjectConfig,
25
26
  } from "../project-config.js"
26
27
  import { discoverTsFunctionsInDir, writeDevFunctionsRouter } from "../functions-router-gen.js"
28
+ import { fieldMaskingTierFromProject } from "../field-masking-tier.js"
27
29
  import { signJwt } from "../jwt.js"
30
+ import {
31
+ devAuthenticatorPassword,
32
+ devJwtSecret,
33
+ devPostgresPassword,
34
+ secretFingerprint,
35
+ } from "../local-secrets.js"
28
36
  import {
29
37
  normalisePlatformPath,
30
38
  cachePath,
@@ -50,6 +58,7 @@ import { writeAppViteEnv } from "../app-vite-env.js"
50
58
  import { ensureValkeySidecar, stopValkeySidecar } from "../valkey-sidecar.js"
51
59
  import {
52
60
  initdb,
61
+ nativeMaskLibraryPresent,
53
62
  start as pgStart,
54
63
  stop as pgStop,
55
64
  waitReady as pgWaitReady,
@@ -172,9 +181,15 @@ export function registerDev(program: Command): void {
172
181
 
173
182
  // ── 5–7. Start Postgres ───────────────────────────────────────────────
174
183
  let dbURL: string
184
+ /** What PostgREST connects as — deliberately not `dbURL`, which is a superuser. */
185
+ let postgrestDbURL: string
175
186
  let stopPostgres: () => void | Promise<void>
176
187
  const pgPort = NATIVE_PG_PORT
177
- const pgPassword = "postgres"
188
+ const pgPassword = devPostgresPassword(cwd)
189
+ // Distinct from pgPassword even locally, so the split the other paths enforce is the
190
+ // one developers see. Read from .env when present so it matches a stack the user has
191
+ // already provisioned.
192
+ const authenticatorPassword = devAuthenticatorPassword(cwd)
178
193
  // pgBinDir is set on the native path and used to add DLL search path for
179
194
  // PostgREST on Windows (PostgREST links against libpq + SSL from MinGW).
180
195
  let pgBinDir: string | null = null
@@ -192,7 +207,8 @@ export function registerDev(program: Command): void {
192
207
  pgStart(pgOpts)
193
208
  await pgWaitReady(pgOpts, 15_000)
194
209
  console.log("[supatype] Postgres is ready.")
195
- dbURL = `postgres://postgres:postgres@127.0.0.1:${pgPort}/${projectName}?sslmode=disable`
210
+ dbURL = `postgres://postgres:${pgPassword}@127.0.0.1:${pgPort}/${projectName}?sslmode=disable`
211
+ postgrestDbURL = `postgres://authenticator:${authenticatorPassword}@127.0.0.1:${pgPort}/${projectName}?sslmode=disable`
196
212
  stopPostgres = () => pgStop(pgOpts)
197
213
 
198
214
  // Create project database if it doesn't exist (native only).
@@ -219,8 +235,25 @@ export function registerDev(program: Command): void {
219
235
  // anon – unauthenticated requests (RLS enforced)
220
236
  // authenticated – signed-in user requests (RLS enforced)
221
237
  // service_role – developer/admin bypass (BYPASSRLS)
238
+ // authenticator – the role PostgREST *connects* as, and nothing else
239
+ //
240
+ // `authenticator` is NOINHERIT and holds no privileges of its own: it can only
241
+ // SET ROLE to the three above. That containment is the point. Connecting as
242
+ // `postgres` — a superuser — meant a request whose JWT named *any* role in the
243
+ // cluster got it, because a superuser may SET ROLE to anything. Verified: as
244
+ // postgres, `SET ROLE supatype_replication_admin` succeeds; as authenticator the
245
+ // same statement is refused. Mirrors the image, which has had this role all along.
246
+ // Created here rather than during the push, because the engine chooses how to enforce field
247
+ // rules from the extensions present *when it diffs*. Creating it later would leave the tier
248
+ // decided against a database that no longer looks like that.
249
+ //
250
+ // Conditional on the library being bundled: an archive downloaded before it was would fail
251
+ // the CREATE, and there is nothing the developer could do about it from here.
252
+ const maskSql = nativeMaskLibraryPresent(pgBinDir)
253
+ ? "CREATE EXTENSION IF NOT EXISTS supatype_mask;\n"
254
+ : ""
222
255
  const rolesSql = `
223
- CREATE SCHEMA IF NOT EXISTS auth;
256
+ ${maskSql}CREATE SCHEMA IF NOT EXISTS auth;
224
257
  DO $$ BEGIN
225
258
  IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'anon')
226
259
  THEN CREATE ROLE anon NOLOGIN; END IF;
@@ -228,8 +261,11 @@ DO $$ BEGIN
228
261
  THEN CREATE ROLE authenticated NOLOGIN; END IF;
229
262
  IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'service_role')
230
263
  THEN CREATE ROLE service_role NOLOGIN BYPASSRLS; END IF;
264
+ IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticator')
265
+ THEN CREATE ROLE authenticator LOGIN NOINHERIT PASSWORD '${authenticatorPassword}'; END IF;
231
266
  END $$;
232
267
  GRANT anon, authenticated, service_role TO postgres;
268
+ GRANT anon, authenticated, service_role TO authenticator;
233
269
  GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role;
234
270
  -- Table-level privileges (RLS restricts rows; roles still need table access)
235
271
  GRANT SELECT ON ALL TABLES IN SCHEMA public TO anon;
@@ -246,7 +282,7 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticate
246
282
  { stdio: "pipe", encoding: "utf8", env: pgEnv })
247
283
  }
248
284
 
249
- const LOCAL_JWT_SECRET = "super-secret-jwt-token-with-at-least-32-characters-long"
285
+ const LOCAL_JWT_SECRET = devJwtSecret(cwd)
250
286
  const authDbURL = dbURL.includes("?")
251
287
  ? `${dbURL}&search_path=auth`
252
288
  : `${dbURL}?search_path=auth`
@@ -340,7 +376,7 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticate
340
376
  JWT_SECRET: LOCAL_JWT_SECRET,
341
377
  PORT: REALTIME_PORT,
342
378
  SLOT_NAME: "supatype_realtime",
343
- PUBLICATION_NAME: "supatype_realtime_pub",
379
+ PUBLICATION_NAME: "supatype_realtime",
344
380
  },
345
381
  })
346
382
  realtimeProc.start()
@@ -472,8 +508,21 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticate
472
508
  }
473
509
 
474
510
  const postgrestEnv: Record<string, string> = {
475
- PGRST_DB_URI: dbURL,
476
- PGRST_DB_SCHEMA: "public, supatype, graphql_public",
511
+ PGRST_DB_URI: postgrestDbURL,
512
+ // Derived from schema.pg_schema (or schema.api_schemas) — a hardcoded "public" here meant
513
+ // a non-default pg_schema pushed correctly and then answered PGRST106 on every request.
514
+ // Native dev has no masking extension either, so a schema with field rules is served from
515
+ // `api` here too — the case an "is the database external" rule would have got wrong.
516
+ PGRST_DB_SCHEMA: apiSchemaList(
517
+ config,
518
+ // The installed archive decides: one downloaded before the masking library was bundled
519
+ // has no extension, so its field rules are enforced by views instead.
520
+ fieldMaskingTierFromProject(cwd, config, nativeMaskLibraryPresent(pgBinDir)),
521
+ ),
522
+ // Parity with self-host, which has always set this. Unqualified names in column defaults
523
+ // (`uuid_generate_v4()`) resolve here, and with a non-public pg_schema the request schema
524
+ // alone does not reach them.
525
+ PGRST_DB_EXTRA_SEARCH_PATH: "public,extensions",
477
526
  PGRST_DB_ANON_ROLE: "anon",
478
527
  PGRST_SERVER_PORT: postgrestPort,
479
528
  PGRST_SERVER_HOST: "127.0.0.1",
@@ -547,7 +596,7 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticate
547
596
  links,
548
597
  anonKey,
549
598
  serviceRoleKey,
550
- hints: [`Postgres ${dbURL}`, `JWT secret: ${LOCAL_JWT_SECRET}`],
599
+ hints: [`Postgres ${dbURL}`, `JWT secret: #${secretFingerprint(LOCAL_JWT_SECRET)} (in .env)`],
551
600
  })
552
601
 
553
602
  // ── Shutdown handler ──────────────────────────────────────────────────
@@ -1,10 +1,12 @@
1
1
  import type { Command } from "commander"
2
2
  import { loadConfig, loadSchemaAst } from "../config.js"
3
3
  import { info, plain } from "../ui/messages.js"
4
- import { schemaPathFromProject } from "../project-config.js"
4
+ import { hooksPathFromProject, schemaPathFromProject, serviceRoleRoutes } from "../project-config.js"
5
5
  import { resolveTarget, targetSchemaDoctor, schemaPgSchema } from "../resolve-target.js"
6
6
  import { loadProjectLink } from "../link.js"
7
7
  import { resolveHostEngineDatabaseUrl } from "../dev-compose.js"
8
+ import { hooksReport, type HooksReport } from "../model-hooks.js"
9
+ import { checkServiceRoleRoutes, type ServiceRoleProblems } from "../service-role-check.js"
8
10
 
9
11
  interface DoctorItem {
10
12
  kind: string
@@ -20,12 +22,15 @@ interface DoctorReport {
20
22
  unmanagedDrift: DoctorItem[]
21
23
  }
22
24
 
23
- function printSection(title: string, items: DoctorItem[]): void {
25
+ /** Exported for tests: the label form is easy to get subtly wrong per item kind. */
26
+ export function printSection(title: string, items: DoctorItem[]): void {
24
27
  if (items.length === 0) return
25
28
  plain(`\n${title} (${items.length}):\n`)
26
29
  for (const item of items) {
27
30
  const fields = item.fields.length > 0 ? ` (${item.fields.join(", ")})` : ""
28
- plain(` ${item.table}.${item.name}${fields}`)
31
+ // A table's `name` *is* its table, so the usual `table.name` form renders "widget.widget".
32
+ const label = item.table === item.name ? item.name : `${item.table}.${item.name}`
33
+ plain(` • ${label}${fields}`)
29
34
  plain(` ${item.message}`)
30
35
  }
31
36
  }
@@ -82,6 +87,9 @@ export function registerDoctor(program: Command): void {
82
87
  })) as DoctorReport
83
88
  }
84
89
 
90
+ printHooks(hooksReport(cwd, hooksPathFromProject(config, cwd), ast))
91
+ printServiceRoleGrants(checkServiceRoleRoutes(config, cwd), serviceRoleRoutes(config))
92
+
85
93
  printSection("Missing (in AST, not in DB)", report.missing ?? [])
86
94
  printSection("Stale managed (stamped, not in AST)", report.staleManaged ?? [])
87
95
  printSection("Unmanaged drift (manual decision)", report.unmanagedDrift ?? [])
@@ -101,3 +109,61 @@ export function registerDoctor(program: Command): void {
101
109
  }
102
110
  })
103
111
  }
112
+
113
+ /**
114
+ * Whether declared hooks can actually run.
115
+ *
116
+ * Worth its own section because every failure here is silent: a hook whose function is missing, or a
117
+ * stack with functions switched off, produces no error anywhere — the write just succeeds
118
+ * unvalidated. Drift you cannot see is the thing doctor exists for.
119
+ */
120
+ export function printHooks(report: HooksReport): void {
121
+ if (report.declared.length === 0) return
122
+
123
+ plain(`\nHooks (${report.declared.length}):\n`)
124
+ for (const hook of report.declared) {
125
+ const broken = report.missing.some(
126
+ (m) => m.model === hook.model && m.event === hook.event,
127
+ )
128
+ plain(` ${broken ? "✗" : "•"} ${hook.model}.${hook.event} → ${hook.function}`)
129
+ }
130
+
131
+ if (report.missing.length > 0) {
132
+ plain("\n Those marked ✗ name a function that does not exist, so they never fire.")
133
+ plain(" Create it with: supatype hooks new <name>")
134
+ }
135
+ if (report.functionsDisabled) {
136
+ plain("\n functions_enabled is false in .supatype/manifest.json — every hook is inert.")
137
+ plain(" Regenerate the stack config with: supatype self-host compose")
138
+ }
139
+ if (report.mapMissing) {
140
+ plain("\n .supatype/manifest.json carries no hook map, so the server has nothing to call.")
141
+ plain(" Run: supatype push")
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Report which functions may see the service-role key, and which grants do nothing.
147
+ *
148
+ * Worth printing even when everything resolves: this is the list of functions that can read and write
149
+ * past every access rule in the schema, and "which ones are those again?" should be answerable without
150
+ * opening the config.
151
+ */
152
+ export function printServiceRoleGrants(
153
+ problems: ServiceRoleProblems,
154
+ declared: readonly string[],
155
+ ): void {
156
+ if (declared.length === 0) return
157
+
158
+ plain(`\nService-role grants (${declared.length}):\n`)
159
+ const broken = new Set(problems.missing)
160
+ for (const name of declared) {
161
+ plain(` ${broken.has(name) ? "✗" : "•"} ${name}`)
162
+ }
163
+
164
+ if (broken.size > 0) {
165
+ plain("\n Those marked ✗ match no function, so they grant nothing — the function reads no key.")
166
+ }
167
+ for (const warning of problems.warnings) plain(warning)
168
+ plain("\n These functions bypass every access rule in the schema. Anything not listed cannot.")
169
+ }
@@ -16,6 +16,7 @@ import { ensureBinary } from "../ensure-binary.js"
16
16
  import {
17
17
  functionsPathCandidatesFromProject,
18
18
  preferredFunctionsPathFromProject,
19
+ serviceRoleRoutes,
19
20
  } from "../project-config.js"
20
21
  import {
21
22
  discoverTsFunctionsInDir,
@@ -24,6 +25,7 @@ import {
24
25
  import { loadProjectLink } from "../link.js"
25
26
  import { resolveTarget } from "../resolve-target.js"
26
27
  import { targetFetch } from "../target-client.js"
28
+ import { ensureFunctionsDenoTypes } from "../functions-deno-types.js"
27
29
  import { error, info, plain } from "../ui/messages.js"
28
30
  import { nextSteps } from "../ui/next-steps.js"
29
31
 
@@ -140,18 +142,19 @@ function scaffoldFunction(cwd: string, name: string): void {
140
142
  // Docs: https://supatype.com/docs/edge-functions
141
143
 
142
144
  export default async function handler(req: Request): Promise<Response> {
143
- const { method, url } = req
145
+ const { method } = req
146
+ const url = Deno.env.get("SUPATYPE_URL")
144
147
 
145
148
  // Example: read request body for POST requests
146
149
  if (method === "POST") {
147
150
  const body = await req.json()
148
- return new Response(JSON.stringify({ message: "Hello from ${name}!", received: body }), {
151
+ return new Response(JSON.stringify({ message: "Hello from ${name}!", received: body, url }), {
149
152
  status: 200,
150
153
  headers: { "Content-Type": "application/json" },
151
154
  })
152
155
  }
153
156
 
154
- return new Response(JSON.stringify({ message: "Hello from ${name}!" }), {
157
+ return new Response(JSON.stringify({ message: "Hello from ${name}!", url }), {
155
158
  status: 200,
156
159
  headers: { "Content-Type": "application/json" },
157
160
  })
@@ -181,13 +184,27 @@ export default async function handler(req: Request): Promise<Response> {
181
184
  )
182
185
  }
183
186
 
187
+ const denoTypes = ensureFunctionsDenoTypes(cwd, functionsDir)
188
+
184
189
  const functionsDirLabel = relativeFunctionsDir(cwd, functionsDir)
185
190
  info(`Created function: ${functionsDirLabel}/${name}/index.ts`)
186
- nextSteps("Next steps:", [
191
+ if (denoTypes.wroteDenoDts) {
192
+ info(`Added Deno IDE types: ${functionsDirLabel}/deno.d.ts`)
193
+ }
194
+ if (denoTypes.wroteTsconfig) {
195
+ info(`Added functions tsconfig: ${functionsDirLabel}/tsconfig.json`)
196
+ }
197
+ if (denoTypes.rootExclude === "updated") {
198
+ info('Updated root tsconfig.json to exclude "functions"')
199
+ }
200
+
201
+ const steps = [
187
202
  "Local dev: npx supatype functions serve",
188
203
  `Invoke: npx supatype functions invoke ${name}`,
189
204
  "Deploy: npx supatype functions deploy",
190
- ])
205
+ ...denoTypes.hints,
206
+ ]
207
+ nextSteps("Next steps:", steps)
191
208
  }
192
209
 
193
210
  // ─── Discover functions ──────────────────────────────────────────────────────
@@ -422,6 +439,9 @@ async function deployViaTarget(
422
439
  source,
423
440
  entrypoint: `${fn.name}/index.ts`,
424
441
  }],
442
+ // Sent every deploy, and replaced wholesale by the platform: the config file is the whole
443
+ // truth, so a name removed from it must stop being entitled to the service-role key.
444
+ serviceRole: serviceRoleRoutesFor(cwd),
425
445
  },
426
446
  token: target.token!,
427
447
  orgId: target.orgId,
@@ -479,6 +499,9 @@ async function deployCloud(cwd: string, fns: DiscoveredFunction[], env?: string)
479
499
  source,
480
500
  entrypoint: `${fn.name}/index.ts`,
481
501
  }],
502
+ // Same grants as the target path: both reach the same endpoint, and a project deployed one
503
+ // way must not end up with a different credential surface than the other.
504
+ serviceRole: serviceRoleRoutesFor(cwd),
482
505
  }),
483
506
  signal: AbortSignal.timeout(60_000),
484
507
  })
@@ -500,6 +523,20 @@ async function deployCloud(cwd: string, fns: DiscoveredFunction[], env?: string)
500
523
  info(`Invoke: https://${linked.ref}.supatype.dev/functions/v1/<name>`)
501
524
  }
502
525
 
526
+ /**
527
+ * The project's `functions.serviceRole` list, or undefined when the config cannot be read.
528
+ *
529
+ * Undefined rather than `[]`: the platform replaces the stored list wholesale when the field is present,
530
+ * so sending an empty array because a config failed to load would revoke every grant the project has.
531
+ */
532
+ export function serviceRoleRoutesFor(cwd: string): string[] | undefined {
533
+ try {
534
+ return serviceRoleRoutes(loadConfig(cwd))
535
+ } catch {
536
+ return undefined
537
+ }
538
+ }
539
+
503
540
  function readFunctionSource(fn: DiscoveredFunction): string {
504
541
  const stat = statSync(fn.absPath)
505
542
  if (stat.isFile()) {
@@ -2,9 +2,10 @@ import type { Command } from "commander"
2
2
  import { mkdirSync, writeFileSync } from "node:fs"
3
3
  import { resolve, dirname } from "node:path"
4
4
  import { loadConfig, loadSchemaAst } from "../config.js"
5
- import { schemaPathFromProject } from "../project-config.js"
5
+ import { hooksPathFromProject, schemaPathFromProject } from "../project-config.js"
6
6
  import { ensureEngine, engineRequest } from "../engine-client.js"
7
7
  import { generateClientAugmentation } from "../augmentation-generator.js"
8
+ import { writeHooksModule } from "../model-hooks.js"
8
9
  import { error, info } from "../ui/messages.js"
9
10
 
10
11
  export function registerGenerate(program: Command): void {
@@ -41,5 +42,8 @@ export function registerGenerate(program: Command): void {
41
42
  mkdirSync(dirname(augmentationOutPath), { recursive: true })
42
43
  writeFileSync(augmentationOutPath, augmentationCode, "utf8")
43
44
  info(`Client augmentation written to ${outputClientPath}`)
45
+
46
+ const hooksPath = writeHooksModule(cwd, hooksPathFromProject(config, cwd), ast)
47
+ if (hooksPath !== null) info(`Hook handler types written to ${hooksPath}`)
44
48
  })
45
49
  }