@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
@@ -0,0 +1,352 @@
1
+ /**
2
+ * Generate `functions/_supatype/hooks.ts` — the typed handler surface for model hooks.
3
+ *
4
+ * Generated into the project rather than shipped as a package, for two reasons: a Deno function
5
+ * resolves a relative `.ts` import with no npm involved, and the types then move in lockstep with the
6
+ * schema they came from instead of with a release.
7
+ *
8
+ * The `hook()` adapter is why the functions worker needs no change at all: it wraps a typed handler
9
+ * into the `(req: Request) => Response` contract the worker already discovers and serves.
10
+ */
11
+ import {
12
+ generateInsertType,
13
+ generateRowType,
14
+ generateUpdateType,
15
+ resolveTableName,
16
+ type AstModel,
17
+ } from "./augmentation-generator.js"
18
+
19
+ interface HookedModel {
20
+ table: string
21
+ fields: Record<string, Record<string, unknown>>
22
+ }
23
+
24
+ /** Tables that declare at least one hook, sorted for a stable file. */
25
+ function hookedModels(ast: unknown): HookedModel[] {
26
+ const models = (ast as { models?: AstModel[] })?.models
27
+ if (!Array.isArray(models)) return []
28
+
29
+ return models
30
+ .filter((model) => {
31
+ const hooks = (model as { annotations?: { platform?: { hooks?: unknown } } }).annotations
32
+ ?.platform?.hooks
33
+ return typeof hooks === "object" && hooks !== null && Object.keys(hooks).length > 0
34
+ })
35
+ .map((model) => ({ table: resolveTableName(model), fields: model.fields }))
36
+ .sort((a, b) => a.table.localeCompare(b.table))
37
+ }
38
+
39
+ /**
40
+ * The module source, or `null` when no model declares a hook.
41
+ *
42
+ * Returning null rather than an empty module keeps a `_supatype/hooks.ts` from appearing in projects
43
+ * that have no hooks — a generated file nobody imports is a file somebody eventually edits.
44
+ */
45
+ export function generateHooksModule(ast: unknown): string | null {
46
+ const models = hookedModels(ast)
47
+ if (models.length === 0) return null
48
+
49
+ const tableEntries = models
50
+ .map(
51
+ (model) => ` ${JSON.stringify(model.table)}: {
52
+ Row: ${indent(denoSafe(generateRowType(model.fields)), 4)}
53
+ Insert: ${indent(denoSafe(generateInsertType(model.fields)), 4)}
54
+ Update: ${indent(denoSafe(generateUpdateType(model.fields)), 4)}
55
+ }`,
56
+ )
57
+ .join("\n")
58
+
59
+ return `// Generated by supatype CLI — do not edit manually.
60
+ //
61
+ // Typed handlers for the models that declare \`hooks\`. Import from a function beside it:
62
+ //
63
+ // import { hook, type BeforeChange } from "../_supatype/hooks.ts"
64
+ //
65
+ // const moderate: BeforeChange<"posts"> = async (ctx) => {
66
+ // if (ctx.operation === "insert") return { rows: ctx.rows }
67
+ // return {}
68
+ // }
69
+ //
70
+ // export default hook(moderate)
71
+ //
72
+ // One function can serve a model's whole lifecycle instead, since the worker takes one default
73
+ // export per directory:
74
+ //
75
+ // export default hooks({
76
+ // beforeChange: moderate,
77
+ // afterChange: reindex,
78
+ // })
79
+
80
+ /**
81
+ * Lexical editor state as JSON, or the plain string accepted for seeds and defaults.
82
+ *
83
+ * Structural rather than imported: the precise \`SerializedEditorState\` lives in
84
+ * \`@supatype/types/lexical\`, which a Deno function has no way to resolve.
85
+ */
86
+ export type RichTextValue = { readonly root: { readonly children: readonly unknown[] } } | string
87
+
88
+ export interface HookTables {
89
+ ${tableEntries}
90
+ }
91
+
92
+ /** Only the tables that declare a hook, so \`BeforeChange<"unhooked">\` is a compile error. */
93
+ export type HookedTable = keyof HookTables & string
94
+
95
+ interface HookBase {
96
+ readonly table: string
97
+ /** Claims from the JWT the server already verified. \`null\` for an anonymous caller. */
98
+ readonly user: { readonly sub: string; readonly role: string; readonly email?: string } | null
99
+ /** Correlates this hook with the request in server logs. */
100
+ readonly requestId: string
101
+ }
102
+
103
+ /**
104
+ * The rows this request is about to overwrite, fetched only if you ask.
105
+ *
106
+ * Resolved by the server from the in-flight request's own filter, as the caller's own role — so it
107
+ * cannot show you rows the caller could not read, and you cannot widen the filter. Capped:
108
+ * \`truncated\` is true when there were more, in which case acting on what you got means acting on a
109
+ * prefix you did not choose.
110
+ */
111
+ export type PreviousRows<T extends HookedTable> = () => Promise<{
112
+ rows: HookTables[T]["Row"][]
113
+ truncated: boolean
114
+ }>
115
+
116
+ export type BeforeChangeContext<T extends HookedTable> =
117
+ | (HookBase & { readonly operation: "insert"; readonly rows: HookTables[T]["Insert"][] })
118
+ | (HookBase & {
119
+ readonly operation: "update"
120
+ readonly patch: HookTables[T]["Update"]
121
+ /** PostgREST filter from the request, e.g. \`status=eq.draft\`. Not rewritable. */
122
+ readonly filter: string
123
+ readonly previous: PreviousRows<T>
124
+ })
125
+
126
+ export type BeforeDeleteContext<T extends HookedTable> = HookBase & {
127
+ readonly operation: "delete"
128
+ readonly filter: string
129
+ readonly previous: PreviousRows<T>
130
+ }
131
+
132
+ export type AfterChangeContext<T extends HookedTable> = HookBase & {
133
+ readonly operation: "insert" | "update"
134
+ /** Written rows — present only when the caller sent \`Prefer: return=representation\`. */
135
+ readonly rows?: HookTables[T]["Row"][]
136
+ }
137
+
138
+ export type AfterDeleteContext<T extends HookedTable> = HookBase & {
139
+ readonly operation: "delete"
140
+ readonly filter: string
141
+ }
142
+
143
+ /** Reject with a message, or with a status of your choosing. */
144
+ export interface HookRejection {
145
+ readonly reject: string | { readonly message: string; readonly status?: number; readonly code?: string }
146
+ }
147
+
148
+ export type BeforeChangeVerdict<T extends HookedTable> =
149
+ | Record<string, never>
150
+ | { readonly rows: HookTables[T]["Insert"][] }
151
+ | { readonly patch: HookTables[T]["Update"] }
152
+ | HookRejection
153
+
154
+ export type BeforeDeleteVerdict = Record<string, never> | HookRejection
155
+
156
+ export type BeforeChange<T extends HookedTable> = (
157
+ ctx: BeforeChangeContext<T>,
158
+ ) => BeforeChangeVerdict<T> | Promise<BeforeChangeVerdict<T>>
159
+
160
+ export type BeforeDelete<T extends HookedTable> = (
161
+ ctx: BeforeDeleteContext<T>,
162
+ ) => BeforeDeleteVerdict | Promise<BeforeDeleteVerdict>
163
+
164
+ export type AfterChange<T extends HookedTable> = (
165
+ ctx: AfterChangeContext<T>,
166
+ ) => void | Promise<void>
167
+
168
+ export type AfterDelete<T extends HookedTable> = (
169
+ ctx: AfterDeleteContext<T>,
170
+ ) => void | Promise<void>
171
+
172
+ /** Every handler a single function may serve, when one function covers a model's whole lifecycle. */
173
+ export interface HookHandlers<T extends HookedTable> {
174
+ readonly beforeChange?: BeforeChange<T>
175
+ readonly beforeDelete?: BeforeDelete<T>
176
+ readonly afterChange?: AfterChange<T>
177
+ readonly afterDelete?: AfterDelete<T>
178
+ }
179
+
180
+ type AnyHandler = (ctx: never) => unknown
181
+
182
+ interface WireContext {
183
+ table?: string
184
+ operation?: string
185
+ user?: unknown
186
+ requestId?: string
187
+ rows?: unknown[]
188
+ patch?: unknown
189
+ filter?: string
190
+ /**
191
+ * Server callback **path** for pre-write rows; absent when there are none to fetch.
192
+ *
193
+ * A path rather than a URL because the server does not know its own in-network address, while this
194
+ * worker is already told how to reach the stack.
195
+ */
196
+ previousPath?: string
197
+ }
198
+
199
+ /**
200
+ * Wrap a typed handler as the request handler the worker expects.
201
+ *
202
+ * The outcome travels as the **HTTP status**: 200 for proceed, 4xx for a rejection. That keeps the
203
+ * contract in the transport rather than in a second envelope inside the body, so a hook written by
204
+ * hand — or in another language — needs to know nothing about this file.
205
+ */
206
+ export function hook(handler: AnyHandler): (req: Request) => Promise<Response> {
207
+ return async (req: Request): Promise<Response> => {
208
+ if (req.method !== "POST") {
209
+ return json({ message: "A hook is invoked with POST" }, 405)
210
+ }
211
+
212
+ let wire: WireContext
213
+ try {
214
+ wire = (await req.json()) as WireContext
215
+ } catch {
216
+ // A 400 rather than a 500: the payload was wrong, the handler never ran.
217
+ return json({ message: "Hook payload was not JSON" }, 400)
218
+ }
219
+
220
+ const signature = req.headers.get("webhook-signature")
221
+ const ctx = {
222
+ table: wire.table ?? "",
223
+ operation: wire.operation ?? "",
224
+ user: wire.user ?? null,
225
+ requestId: wire.requestId ?? "",
226
+ ...(wire.rows !== undefined && { rows: wire.rows }),
227
+ ...(wire.patch !== undefined && { patch: wire.patch }),
228
+ ...(wire.filter !== undefined && { filter: wire.filter }),
229
+ ...(wire.previousPath !== undefined && {
230
+ previous: () => fetchPrevious(wire.previousPath as string, signature),
231
+ }),
232
+ }
233
+
234
+ let verdict: unknown
235
+ try {
236
+ verdict = await handler(ctx as never)
237
+ } catch (err) {
238
+ // An exception is not a rejection: the hook broke, so the server's onUnavailable policy should
239
+ // decide, and that is what a 500 asks for.
240
+ return json({ message: err instanceof Error ? err.message : "Hook threw" }, 500)
241
+ }
242
+
243
+ if (verdict === undefined || verdict === null) return json({}, 200)
244
+
245
+ const rejection = (verdict as HookRejection).reject
246
+ if (rejection !== undefined) {
247
+ const detail = typeof rejection === "string" ? { message: rejection } : rejection
248
+ const status = typeof detail.status === "number" ? detail.status : 422
249
+ return json(
250
+ { message: detail.message, ...(detail.code !== undefined && { code: detail.code }) },
251
+ // A rejection must be a 4xx: a hook that answered 500 would be read as unavailable and
252
+ // handed to onUnavailable, which is a different decision from "no".
253
+ status >= 400 && status < 500 ? status : 422,
254
+ )
255
+ }
256
+
257
+ return json(verdict as Record<string, unknown>, 200)
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Serve several of a model's hooks from one function, dispatched on \`X-Supatype-Hook\`.
263
+ *
264
+ * The worker takes **one default export per function directory**, so \`hook(single)\` is one hook per
265
+ * directory. That is fine for a single moderation rule and tedious for a model whose whole lifecycle
266
+ * you want in one place — hence this. Point every event at the same function name in the schema:
267
+ *
268
+ * hooks: { beforeChange: "post-hooks", afterChange: "post-hooks" }
269
+ *
270
+ * An event arriving with no handler is a 200, not an error: the schema and this file can be out of
271
+ * step for one deploy, and failing a write because the *other* half has not shipped yet would be the
272
+ * wrong way round.
273
+ */
274
+ export function hooks(handlers: Record<string, AnyHandler | undefined>): (req: Request) => Promise<Response> {
275
+ return async (req: Request): Promise<Response> => {
276
+ const event = req.headers.get("x-supatype-hook") ?? ""
277
+ const handler = handlers[event]
278
+ if (handler === undefined) return json({}, 200)
279
+ return hook(handler)(req)
280
+ }
281
+ }
282
+
283
+ /**
284
+ * The stack's address from this worker's point of view.
285
+ *
286
+ * \`SUPATYPE_INTERNAL_URL\` in Compose is the in-network gateway; \`SUPATYPE_URL\` is the same value
287
+ * there. Never a public or host URL — \`localhost\` inside this container is this container.
288
+ */
289
+ function stackBaseUrl(): string {
290
+ const base =
291
+ Deno.env.get("SUPATYPE_INTERNAL_URL") ??
292
+ Deno.env.get("SUPATYPE_URL") ??
293
+ ""
294
+ if (base === "") {
295
+ throw new Error(
296
+ "ctx.previous() needs SUPATYPE_INTERNAL_URL or SUPATYPE_URL to reach the API from this function",
297
+ )
298
+ }
299
+ // No regex: a backslash in a template literal is one escape away from emitting broken code, and a
300
+ // generator's output has to be right the first time.
301
+ return base.endsWith("/") ? base.slice(0, -1) : base
302
+ }
303
+
304
+ async function fetchPrevious(
305
+ path: string,
306
+ signature: string | null,
307
+ ): Promise<{ rows: unknown[]; truncated: boolean }> {
308
+ const url = stackBaseUrl() + path
309
+ const res = await fetch(url, {
310
+ method: "POST",
311
+ headers: signature !== null ? { "webhook-signature": signature } : {},
312
+ })
313
+ if (!res.ok) {
314
+ throw new Error(\`Could not read the rows this write affects (\${res.status})\`)
315
+ }
316
+ const body = (await res.json()) as { rows?: unknown[]; truncated?: boolean }
317
+ return { rows: body.rows ?? [], truncated: body.truncated === true }
318
+ }
319
+
320
+ function json(body: unknown, status: number): Response {
321
+ return new Response(JSON.stringify(body), {
322
+ status,
323
+ headers: { "Content-Type": "application/json" },
324
+ })
325
+ }
326
+ `
327
+ }
328
+
329
+ /**
330
+ * Replace cross-package type imports the client types use but a hook cannot resolve.
331
+ *
332
+ * `RichText` normally generates `import("@supatype/types/lexical").SerializedEditorState`. That works
333
+ * in an app, where the package is installed — but this file lives in `functions/`, is imported by a
334
+ * Deno handler, and has no node_modules to reach into. Left alone, any hooked model with a rich-text
335
+ * column emitted a module that failed to compile in the user's editor. Caught by compiling the
336
+ * generated file in a test rather than by a reader reporting it.
337
+ */
338
+ function denoSafe(source: string): string {
339
+ return source.replaceAll(
340
+ 'import("@supatype/types/lexical").SerializedEditorState',
341
+ "RichTextValue",
342
+ )
343
+ }
344
+
345
+ /** Re-indent a generated type literal so the emitted file reads as if it were written by hand. */
346
+ function indent(source: string, spaces: number): string {
347
+ const pad = " ".repeat(spaces)
348
+ return source
349
+ .split("\n")
350
+ .map((line, i) => (i === 0 ? line : `${pad}${line}`))
351
+ .join("\n")
352
+ }
package/src/link.ts CHANGED
@@ -21,7 +21,10 @@ export interface ProjectLink {
21
21
  kind: ProjectLinkKind
22
22
  projectRef: string
23
23
  defaultEnvironment: string
24
+ /** Access token (cloud user JWT) or self-host SERVICE_ROLE_KEY. */
24
25
  token?: string
26
+ /** Cloud GoTrue refresh token — used to renew short-lived access JWTs. */
27
+ refreshToken?: string
25
28
  orgId?: string | undefined
26
29
  cloudApiUrl?: string
27
30
  linkedAt: string
@@ -209,6 +212,7 @@ export function createCloudLink(params: {
209
212
  projectRef: string
210
213
  cloudApiUrl: string
211
214
  token: string
215
+ refreshToken?: string
212
216
  orgId?: string | undefined
213
217
  environments?: Array<{ name: string; apiUrl: string }>
214
218
  existing?: ProjectLink | null
@@ -227,6 +231,8 @@ export function createCloudLink(params: {
227
231
  }
228
232
  }
229
233
 
234
+ const refreshToken = params.refreshToken ?? params.existing?.refreshToken
235
+
230
236
  return {
231
237
  version: LINK_VERSION,
232
238
  kind: "cloud",
@@ -239,5 +245,6 @@ export function createCloudLink(params: {
239
245
  ? { ...params.existing.environments, ...envMap }
240
246
  : envMap,
241
247
  ...(params.orgId !== undefined ? { orgId: params.orgId } : {}),
248
+ ...(refreshToken !== undefined ? { refreshToken } : {}),
242
249
  }
243
250
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The secrets the local stack runs with.
3
+ *
4
+ * **Read from `.env`, never pinned back into it.** These used to be hardcoded constants that
5
+ * `supatype dev` wrote to `.env` on every run — which meant a project could not hold its own
6
+ * secrets: whatever `supatype init` or the developer put there was replaced by the published
7
+ * defaults on the next `dev`, and the same `.env` then went to a server. Resolving instead of
8
+ * pinning is what lets a generated secret survive to production.
9
+ *
10
+ * The constants remain as *fallbacks* so a project without `.env` still starts, and so the
11
+ * behaviour of an existing project is unchanged.
12
+ */
13
+
14
+ import { hasEnvValue, readEnvValue } from "./env-file.js"
15
+
16
+ /**
17
+ * Fallback JWT signing secret for a project that has none.
18
+ *
19
+ * Published, therefore worthless as a secret — anyone can mint a token for a stack still using
20
+ * it. That is precisely why `init` generates one instead of relying on this.
21
+ */
22
+ export const FALLBACK_JWT_SECRET = "super-secret-jwt-token-with-at-least-32-characters-long"
23
+
24
+ /** Fallback Postgres superuser password for a project that has none. */
25
+ export const FALLBACK_POSTGRES_PASSWORD = "postgres"
26
+
27
+ /** Fallback password for the role PostgREST connects as. */
28
+ export const FALLBACK_AUTHENTICATOR_PASSWORD = "authenticator-local"
29
+
30
+ /** The secret the local stack signs and validates tokens with. */
31
+ export function devJwtSecret(cwd: string): string {
32
+ return readEnvValue(cwd, "JWT_SECRET", FALLBACK_JWT_SECRET)
33
+ }
34
+
35
+ /** The password for the Postgres superuser the engine and server connect as. */
36
+ export function devPostgresPassword(cwd: string): string {
37
+ return readEnvValue(cwd, "POSTGRES_PASSWORD", FALLBACK_POSTGRES_PASSWORD)
38
+ }
39
+
40
+ /**
41
+ * The password for `authenticator`, the role PostgREST connects as.
42
+ *
43
+ * Separate from {@link devPostgresPassword} for the same reason the deployed paths keep them
44
+ * apart: rotating the operator's database password must not take the REST API down.
45
+ */
46
+ export function devAuthenticatorPassword(cwd: string): string {
47
+ return readEnvValue(cwd, "AUTHENTICATOR_PASSWORD", FALLBACK_AUTHENTICATOR_PASSWORD)
48
+ }
49
+
50
+ /**
51
+ * The secrets that are missing from `.env` and must be written so the stack can start.
52
+ *
53
+ * The compose template requires these with `${VAR:?}` rather than defaulting them, because a
54
+ * default is how a published constant ends up signing a real deployment's tokens. That only
55
+ * works if something guarantees presence — this does, for any project that predates
56
+ * `supatype init` writing them.
57
+ *
58
+ * **Fills with the fallback, does not generate.** Preserving the value such a project has been
59
+ * running with is the point: generating a new `JWT_SECRET` would invalidate tokens already
60
+ * issued, and a new `POSTGRES_PASSWORD` would not match the password baked into an existing
61
+ * Postgres volume, which `initdb` only sets once. New projects get generated values from
62
+ * `init`; existing ones keep working and are no worse off than before.
63
+ */
64
+ export function seedMissingLocalSecrets(cwd: string): Record<string, string> {
65
+ const out: Record<string, string> = {}
66
+ if (!hasEnvValue(cwd, "JWT_SECRET")) out.JWT_SECRET = FALLBACK_JWT_SECRET
67
+ if (!hasEnvValue(cwd, "POSTGRES_PASSWORD")) out.POSTGRES_PASSWORD = FALLBACK_POSTGRES_PASSWORD
68
+ if (!hasEnvValue(cwd, "AUTHENTICATOR_PASSWORD")) {
69
+ out.AUTHENTICATOR_PASSWORD = FALLBACK_AUTHENTICATOR_PASSWORD
70
+ }
71
+ return out
72
+ }
73
+
74
+ /**
75
+ * The database identity the local stack connects with — seeded, never overwritten.
76
+ *
77
+ * `POSTGRES_DB` in particular is *project configuration*: `supatype init` sets it to the
78
+ * project name, and the running container's database was created from it. Rewriting it to a
79
+ * default afterwards points every tool at a database that does not exist — observed as
80
+ * `supatype push` failing with `FATAL: database "supatype" does not exist` on a project called
81
+ * something else, because the push path rewrote `.env` after `self-host compose up` had already
82
+ * created the database under its real name.
83
+ *
84
+ * Seeded values match the compose file's own `${VAR:-default}`, so an absent key behaves
85
+ * exactly as it did before.
86
+ */
87
+ export function seedMissingDatabaseIdentity(cwd: string): Record<string, string> {
88
+ const out: Record<string, string> = {}
89
+ if (!hasEnvValue(cwd, "POSTGRES_USER")) out.POSTGRES_USER = "supatype_admin"
90
+ if (!hasEnvValue(cwd, "POSTGRES_DB")) out.POSTGRES_DB = "supatype"
91
+ return out
92
+ }
93
+
94
+ /**
95
+ * A short, non-reversible tag for a secret, safe to print.
96
+ *
97
+ * The ready panel used to echo the JWT secret in full, which was harmless while it was a
98
+ * published constant and is not once it is the project's own.
99
+ */
100
+ export function secretFingerprint(secret: string): string {
101
+ let hash = 5381
102
+ for (let i = 0; i < secret.length; i++) hash = ((hash << 5) + hash + secret.charCodeAt(i)) | 0
103
+ return (hash >>> 0).toString(16).padStart(8, "0")
104
+ }