@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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +144 -94
- package/.turbo/turbo-typecheck.log +1 -1
- package/deno.d.ts +52 -0
- package/dist/augmentation-generator.d.ts +22 -0
- package/dist/augmentation-generator.d.ts.map +1 -1
- package/dist/augmentation-generator.js +18 -4
- package/dist/augmentation-generator.js.map +1 -1
- package/dist/binary-cache.d.ts +14 -0
- package/dist/binary-cache.d.ts.map +1 -1
- package/dist/binary-cache.js +33 -13
- package/dist/binary-cache.js.map +1 -1
- package/dist/cache-clean.d.ts +15 -0
- package/dist/cache-clean.d.ts.map +1 -0
- package/dist/cache-clean.js +76 -0
- package/dist/cache-clean.js.map +1 -0
- package/dist/cache-pins.d.ts +51 -0
- package/dist/cache-pins.d.ts.map +1 -0
- package/dist/cache-pins.js +184 -0
- package/dist/cache-pins.js.map +1 -0
- package/dist/cli-install-method.d.ts +19 -0
- package/dist/cli-install-method.d.ts.map +1 -0
- package/dist/cli-install-method.js +60 -0
- package/dist/cli-install-method.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -0
- package/dist/cli.js.map +1 -1
- package/dist/cloud-credentials.d.ts +34 -0
- package/dist/cloud-credentials.d.ts.map +1 -0
- package/dist/cloud-credentials.js +139 -0
- package/dist/cloud-credentials.js.map +1 -0
- package/dist/commands/admin.d.ts +11 -0
- package/dist/commands/admin.d.ts.map +1 -1
- package/dist/commands/admin.js +121 -19
- package/dist/commands/admin.js.map +1 -1
- package/dist/commands/cache.d.ts.map +1 -1
- package/dist/commands/cache.js +67 -71
- package/dist/commands/cache.js.map +1 -1
- package/dist/commands/cloud.d.ts.map +1 -1
- package/dist/commands/cloud.js +32 -8
- package/dist/commands/cloud.js.map +1 -1
- package/dist/commands/db.d.ts +1 -0
- package/dist/commands/db.d.ts.map +1 -1
- package/dist/commands/db.js +147 -1
- package/dist/commands/db.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +50 -10
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/doctor.d.ts +28 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +60 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/functions.d.ts +7 -0
- package/dist/commands/functions.d.ts.map +1 -1
- package/dist/commands/functions.js +40 -6
- package/dist/commands/functions.js.map +1 -1
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +5 -1
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/hooks.d.ts +12 -0
- package/dist/commands/hooks.d.ts.map +1 -0
- package/dist/commands/hooks.js +118 -0
- package/dist/commands/hooks.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +41 -7
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/internal.d.ts +6 -0
- package/dist/commands/internal.d.ts.map +1 -0
- package/dist/commands/internal.js +22 -0
- package/dist/commands/internal.js.map +1 -0
- package/dist/commands/link-helpers.d.ts.map +1 -1
- package/dist/commands/link-helpers.js +8 -2
- package/dist/commands/link-helpers.js.map +1 -1
- package/dist/commands/login.d.ts +7 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +110 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +110 -7
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/self-update.d.ts +7 -0
- package/dist/commands/self-update.d.ts.map +1 -1
- package/dist/commands/self-update.js +18 -11
- package/dist/commands/self-update.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -3
- package/dist/config.js.map +1 -1
- package/dist/db-preflight.d.ts +78 -0
- package/dist/db-preflight.d.ts.map +1 -0
- package/dist/db-preflight.js +452 -0
- package/dist/db-preflight.js.map +1 -0
- package/dist/dev-compose.d.ts +1 -1
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +127 -72
- package/dist/dev-compose.js.map +1 -1
- package/dist/env-file.d.ts +40 -2
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +132 -11
- package/dist/env-file.js.map +1 -1
- package/dist/field-masking-tier.d.ts +50 -0
- package/dist/field-masking-tier.d.ts.map +1 -0
- package/dist/field-masking-tier.js +91 -0
- package/dist/field-masking-tier.js.map +1 -0
- package/dist/functions-deno-types.d.ts +24 -0
- package/dist/functions-deno-types.d.ts.map +1 -0
- package/dist/functions-deno-types.js +155 -0
- package/dist/functions-deno-types.js.map +1 -0
- package/dist/hook-upload.d.ts +30 -0
- package/dist/hook-upload.d.ts.map +1 -0
- package/dist/hook-upload.js +86 -0
- package/dist/hook-upload.js.map +1 -0
- package/dist/hooks-generator.d.ts +8 -0
- package/dist/hooks-generator.d.ts.map +1 -0
- package/dist/hooks-generator.js +332 -0
- package/dist/hooks-generator.js.map +1 -0
- package/dist/link.d.ts +4 -0
- package/dist/link.d.ts.map +1 -1
- package/dist/link.js +2 -0
- package/dist/link.js.map +1 -1
- package/dist/local-secrets.d.ts +71 -0
- package/dist/local-secrets.d.ts.map +1 -0
- package/dist/local-secrets.js +100 -0
- package/dist/local-secrets.js.map +1 -0
- package/dist/model-hooks.d.ts +73 -0
- package/dist/model-hooks.d.ts.map +1 -0
- package/dist/model-hooks.js +211 -0
- package/dist/model-hooks.js.map +1 -0
- package/dist/postgres-ctl.d.ts +11 -0
- package/dist/postgres-ctl.d.ts.map +1 -1
- package/dist/postgres-ctl.js +25 -2
- package/dist/postgres-ctl.js.map +1 -1
- package/dist/project-config.d.ts +121 -1
- package/dist/project-config.d.ts.map +1 -1
- package/dist/project-config.js +150 -1
- package/dist/project-config.js.map +1 -1
- package/dist/resolve-target.d.ts +4 -0
- package/dist/resolve-target.d.ts.map +1 -1
- package/dist/resolve-target.js +69 -73
- package/dist/resolve-target.js.map +1 -1
- package/dist/schema-ast-v2.d.ts +2 -1
- package/dist/schema-ast-v2.d.ts.map +1 -1
- package/dist/schema-ast-v2.js +4 -2
- package/dist/schema-ast-v2.js.map +1 -1
- package/dist/schema-push-lock.d.ts.map +1 -1
- package/dist/schema-push-lock.js +43 -16
- package/dist/schema-push-lock.js.map +1 -1
- package/dist/self-host-compose.d.ts +24 -0
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +301 -69
- package/dist/self-host-compose.js.map +1 -1
- package/dist/service-role-check.d.ts +21 -0
- package/dist/service-role-check.d.ts.map +1 -0
- package/dist/service-role-check.js +85 -0
- package/dist/service-role-check.js.map +1 -0
- package/dist/target-client.d.ts +17 -0
- package/dist/target-client.d.ts.map +1 -1
- package/dist/target-client.js +69 -17
- package/dist/target-client.js.map +1 -1
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +736 -32
- package/dist/type-extractor.js.map +1 -1
- package/dist/ui/runtime/command-chrome.js +1 -1
- package/dist/ui/runtime/command-chrome.js.map +1 -1
- package/dist/ui/runtime/stdin-after-ink.d.ts.map +1 -1
- package/dist/ui/runtime/stdin-after-ink.js +3 -1
- package/dist/ui/runtime/stdin-after-ink.js.map +1 -1
- package/package.json +4 -1
- package/scripts/generate-homebrew-formula.mjs +121 -0
- package/src/augmentation-generator.ts +18 -5
- package/src/binary-cache.ts +47 -15
- package/src/cache-clean.ts +81 -0
- package/src/cache-pins.ts +243 -0
- package/src/cli-install-method.ts +67 -0
- package/src/cli.ts +6 -0
- package/src/cloud-credentials.ts +183 -0
- package/src/commands/admin.ts +140 -25
- package/src/commands/cache.ts +90 -75
- package/src/commands/cloud.ts +32 -8
- package/src/commands/db.ts +162 -1
- package/src/commands/dev.ts +57 -8
- package/src/commands/doctor.ts +69 -3
- package/src/commands/functions.ts +42 -5
- package/src/commands/generate.ts +5 -1
- package/src/commands/hooks.ts +138 -0
- package/src/commands/init.ts +42 -8
- package/src/commands/internal.ts +23 -0
- package/src/commands/link-helpers.ts +9 -2
- package/src/commands/login.ts +127 -0
- package/src/commands/push.ts +124 -7
- package/src/commands/self-update.ts +85 -13
- package/src/config.ts +22 -3
- package/src/db-preflight.ts +575 -0
- package/src/dev-compose.ts +142 -73
- package/src/env-file.ts +157 -11
- package/src/field-masking-tier.ts +107 -0
- package/src/functions-deno-types.ts +188 -0
- package/src/hook-upload.ts +107 -0
- package/src/hooks-generator.ts +352 -0
- package/src/link.ts +7 -0
- package/src/local-secrets.ts +104 -0
- package/src/model-hooks.ts +260 -0
- package/src/postgres-ctl.ts +29 -2
- package/src/project-config.ts +236 -2
- package/src/resolve-target.ts +113 -68
- package/src/schema-ast-v2.ts +5 -2
- package/src/schema-push-lock.ts +56 -17
- package/src/self-host-compose.ts +344 -68
- package/src/service-role-check.ts +111 -0
- package/src/target-client.ts +105 -18
- package/src/type-extractor.ts +883 -28
- package/src/ui/runtime/command-chrome.ts +1 -1
- package/src/ui/runtime/stdin-after-ink.ts +3 -1
- package/tests/access-composition.test.ts +667 -0
- package/tests/admin-studio-membership.test.ts +121 -0
- package/tests/api-schemas.test.ts +83 -0
- package/tests/archive-name-alias.test.ts +42 -0
- package/tests/augmentation-generator.test.ts +28 -0
- package/tests/cache-clean.test.ts +206 -0
- package/tests/cli-help.test.ts +3 -0
- package/tests/cli-install-method.test.ts +66 -0
- package/tests/command-chrome-coverage.test.ts +31 -26
- package/tests/command-chrome-wrap.test.ts +1 -0
- package/tests/db-preflight.test.ts +72 -0
- package/tests/doctor-render.test.ts +62 -0
- package/tests/doctor-service-role.test.ts +55 -0
- package/tests/env-file.test.ts +121 -0
- package/tests/external-database-compose.test.ts +184 -0
- package/tests/external-database-config.test.ts +154 -0
- package/tests/field-masking-tier.test.ts +184 -0
- package/tests/functions-deno-types.test.ts +197 -0
- package/tests/homebrew-formula.test.ts +58 -0
- package/tests/hook-upload.test.ts +125 -0
- package/tests/hooks-generator.test.ts +212 -0
- package/tests/init.test.ts +3 -0
- package/tests/local-secrets.test.ts +92 -0
- package/tests/model-hooks.test.ts +322 -0
- package/tests/runtime-contract.test.ts +113 -1
- package/tests/service-role-check.test.ts +109 -0
- package/tests/service-role-routes.test.ts +51 -0
- package/tests/target-client-refresh.test.ts +94 -0
- package/tests/type-extractor.test.ts +187 -0
- package/tsconfig.tsbuildinfo +1 -1
- 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
|
+
}
|