@guard-rail/core 0.2.1 → 0.5.0

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 (68) hide show
  1. package/README.md +132 -31
  2. package/dist/adapters/supabase/auth.js +50 -0
  3. package/dist/adapters/supabase/auth.js.map +1 -1
  4. package/dist/adapters/supabase/db.js +11 -0
  5. package/dist/adapters/supabase/db.js.map +1 -1
  6. package/dist/cli/init-db.d.ts +1 -0
  7. package/dist/cli/init-db.js +121 -0
  8. package/dist/cli/init-db.js.map +1 -0
  9. package/dist/core/adapter-types.d.ts +77 -1
  10. package/dist/core/config.d.ts +7 -1
  11. package/dist/core/config.js +11 -0
  12. package/dist/core/config.js.map +1 -1
  13. package/dist/core/defs.d.ts +43 -2
  14. package/dist/core/defs.js +29 -2
  15. package/dist/core/defs.js.map +1 -1
  16. package/dist/core/scaffold.d.ts +11 -0
  17. package/dist/core/scaffold.js +19 -0
  18. package/dist/core/scaffold.js.map +1 -0
  19. package/dist/core/types.d.ts +21 -1
  20. package/dist/index.client.d.ts +18 -0
  21. package/dist/index.client.js +70 -0
  22. package/dist/index.client.js.map +1 -0
  23. package/dist/index.d.ts +3 -3
  24. package/dist/index.js +5 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/react/Admin.d.ts +2 -2
  27. package/dist/react/Admin.js +84 -8
  28. package/dist/react/Admin.js.map +1 -1
  29. package/dist/react/AuthRedirectSync.d.ts +20 -0
  30. package/dist/react/AuthRedirectSync.js +38 -0
  31. package/dist/react/AuthRedirectSync.js.map +1 -0
  32. package/dist/react/GRail/client.js +5 -1
  33. package/dist/react/GRail/client.js.map +1 -1
  34. package/dist/react/GRail/editor.d.ts +24 -0
  35. package/dist/react/GRail/editor.js +9 -0
  36. package/dist/react/GRail/editor.js.map +1 -0
  37. package/dist/react/GRail/index.js +2 -2
  38. package/dist/react/GRail/index.js.map +1 -1
  39. package/dist/react/GRail/render.d.ts +3 -3
  40. package/dist/react/GRail/universal.d.ts +22 -0
  41. package/dist/react/GRail/universal.js +29 -0
  42. package/dist/react/GRail/universal.js.map +1 -0
  43. package/dist/react/Provider.d.ts +9 -5
  44. package/dist/react/Provider.js +15 -5
  45. package/dist/react/Provider.js.map +1 -1
  46. package/dist/react/Repeater/client.d.ts +3 -3
  47. package/dist/react/Repeater/index.d.ts +3 -3
  48. package/dist/react/Syncer.d.ts +10 -8
  49. package/dist/react/Syncer.js +8 -6
  50. package/dist/react/Syncer.js.map +1 -1
  51. package/dist/react/Team.js +1 -1
  52. package/dist/react/Team.js.map +1 -1
  53. package/dist/react/useAnchorRect.d.ts +17 -1
  54. package/dist/react/useAnchorRect.js +37 -9
  55. package/dist/react/useAnchorRect.js.map +1 -1
  56. package/dist/server/actions.d.ts +31 -3
  57. package/dist/server/actions.js +45 -1
  58. package/dist/server/actions.js.map +1 -1
  59. package/dist/server/data.js +1 -15
  60. package/dist/server/data.js.map +1 -1
  61. package/dist/server/loader.d.ts +9 -2
  62. package/dist/server/loader.js +74 -11
  63. package/dist/server/loader.js.map +1 -1
  64. package/dist/server/wrapper.d.ts +2 -2
  65. package/dist/server/wrapper.js +13 -4
  66. package/dist/server/wrapper.js.map +1 -1
  67. package/package.json +12 -2
  68. package/scripts/postinstall.mjs +4 -4
package/README.md CHANGED
@@ -25,14 +25,20 @@ pnpm add @guard-rail/core @supabase/ssr @supabase/supabase-js
25
25
 
26
26
  Set up the database:
27
27
 
28
- 1. Copy `node_modules/@guard-rail/core/supabase/migrations/*.sql` into your own
29
- project's `supabase/migrations/`, then run `supabase db push`. No Supabase
30
- CLI yet? Paste the file's contents into the SQL editor instead — it runs
31
- the same statements, just without the CLI's version tracking.
32
- 2. **Required:** add `guardrail` under **Settings > API > Exposed schemas** in
33
- the Supabase dashboard. PostgREST only serves schemas on that list; every
34
- table below is otherwise invisible to the client with a "not found in
35
- schema cache" error that looks identical to a missing table.
28
+ 1. Run `npx guardrail-init-db --push`. It copies the package's migration(s)
29
+ into your own project's `supabase/migrations/` (skipping any already
30
+ there — safe to re-run after upgrading), runs `supabase init` if needed,
31
+ adds `guardrail` to `config.toml`'s exposed schemas for local dev, and
32
+ applies the migration with `supabase db push`. Leave off `--push` to copy
33
+ without applying. No Supabase CLI at all? Paste the migration file's
34
+ contents into the SQL editor instead — same statements, just without the
35
+ CLI's version tracking.
36
+ 2. **Required for a hosted project:** also add `guardrail` under
37
+ **Settings > API > Exposed schemas** in the Supabase dashboard —
38
+ `config.toml`'s schemas list only governs local dev. PostgREST only
39
+ serves schemas on that list; every table below is otherwise invisible to
40
+ the client with a "not found in schema cache" error that looks identical
41
+ to a missing table.
36
42
 
37
43
  Guardrail's tables live in their own `guardrail` Postgres schema, not `public`
38
44
  — so a `supabase db reset`, a broad `DROP ... CASCADE`, or a migration tool
@@ -107,8 +113,72 @@ import { GRail } from "@guard-rail/core";
107
113
  `id` is the content key. `className` and every other prop pass straight through
108
114
  to the element — Guardrail never styles your content.
109
115
 
116
+ Links take `as="a"` (so `href` type-checks) and edit both the text and the URL:
117
+
118
+ ```tsx
119
+ <GRail id="nav.cta" as="a" type="link" href="/contact">Get in touch</GRail>
120
+ ```
121
+
110
122
  To edit, visit any page with `?edit=true`, sign in with a Supabase user, and
111
- click a field.
123
+ click a field. `?edit=true` itself only grants a short (~10 minute) window to
124
+ reach the sign-in form — a normal visitor never sees any Guardrail chrome, and
125
+ a stray copy of that window (a shared computer, an old tab) self-expires
126
+ rather than showing a sign-in bar indefinitely. It's a dismissable prompt, not
127
+ a standing invitation: a signed-in editor's toolbar isn't on that timer at
128
+ all, and persists for as long as their real session does, with no need to
129
+ revisit `?edit=true` again after the first sign-in. `?edit=false` explicitly
130
+ hides the toolbar (also for ~10 minutes) if you want to preview the site
131
+ without it while still signed in.
132
+
133
+ ### In Client Components
134
+
135
+ `<GRail>` works the same inside a `"use client"` component — a mobile-nav header,
136
+ a carousel, an accordion — with the same import and the same props:
137
+
138
+ ```tsx
139
+ "use client";
140
+ import { GRail } from "@guard-rail/core";
141
+
142
+ export function Header() {
143
+ const [open, setOpen] = useState(false);
144
+ return (
145
+ <header>
146
+ <GRail id="header.tagline" as="p">Built by hand in Oklahoma</GRail>
147
+ <button onClick={() => setOpen(!open)}>Menu</button>
148
+ </header>
149
+ );
150
+ }
151
+ ```
152
+
153
+ The package resolves `@guard-rail/core` to an async Server Component in server
154
+ code and to a client-safe version in client code (via the `"react-server"` export
155
+ condition), so moving a component between the two needs no changes. Edited
156
+ content is still in the server-rendered HTML — a Client Component is rendered on
157
+ the server for its first paint too — so SEO is unaffected.
158
+
159
+ A Client Component can't await a query, so instead the layout **preloads** the
160
+ site's content once per request and hands it down. That preload is the same
161
+ single query the server-rendered fields read from, so there's no extra cost: one
162
+ query per request, whether a page uses server fields, client fields, or both.
163
+
164
+ By default it preloads up to 500 rows with no configuration. If a site outgrows
165
+ that, a dev-mode warning says so — scope it, or turn it off:
166
+
167
+ ```tsx
168
+ withGuardrail(RootLayout, {
169
+ ...supabase,
170
+ prefetch: { prefix: ["home.", "header."] }, // only preload these; optional
171
+ // prefetch: { limit: 2000 }, // or raise the ceiling
172
+ // prefetch: false, // off: client fields show their JSX
173
+ });
174
+ ```
175
+
176
+ What still needs a Server Component: `<GRepeater>`, `getFieldValue()`, and
177
+ `listRepeaterItems()` throw a clear error if used from client code. Render them in
178
+ a Server Component and pass the result into the client one as `children` or a
179
+ prop. If your tooling doesn't set the `"react-server"` condition (a test runner,
180
+ a plain Node script), import from `@guard-rail/core/server` or
181
+ `@guard-rail/core/client` explicitly.
112
182
 
113
183
  ## Team and roles
114
184
 
@@ -136,26 +206,42 @@ export default withGuardrail(RootLayout, {
136
206
  });
137
207
  ```
138
208
 
139
- **Invite emails (Supabase).** Inviting someone creates their account on first
140
- use and emails them a sign-in link. Three one-time steps in the Supabase
141
- dashboard, plus one route in your app:
209
+ **Invite emails (Supabase).** Inviting someone emails them a sign-in link
210
+ that creates their account on first use — this works out of the box, no
211
+ route or email template changes needed. A small always-mounted component
212
+ notices the link's tokens (in the URL hash, a `code`, or a `token_hash` —
213
+ whichever style your project's templates happen to produce, including the
214
+ unedited default) and completes the session automatically.
215
+
216
+ Someone arriving this way has a valid session but no password yet, so they
217
+ get a **Set Password** prompt in place of the usual toolbar — skippable,
218
+ since a magic link works fine on its own indefinitely if they'd rather not.
219
+ The same prompt appears after a password-recovery link.
220
+
221
+ Two things still worth knowing:
222
+
223
+ - **Auth → URL Configuration**, in the Supabase dashboard: Site URL and
224
+ Redirect URLs must actually match wherever your app runs, including in
225
+ local dev — a mismatch here makes Supabase silently redirect to whatever
226
+ Site URL *is* configured instead of erroring, which reads exactly like the
227
+ link "not working" for no visible reason.
228
+ - **Auth → SMTP**: Supabase's built-in mailer allows only a few emails an
229
+ hour. Configure custom SMTP (Resend, Postmark, SES) before relying on
230
+ invites. Submitting the same address again re-sends a pending invite.
231
+
232
+ Prefer a route that verifies server-side rather than relying on client JS to
233
+ notice the redirect? `supabaseConfirmRoute` still works exactly as before —
234
+ mount it and point a custom email template's link at
235
+ `{{ .SiteURL }}/guardrail/confirm?token_hash={{ .TokenHash }}&type=email`:
142
236
 
143
- 1. **Route** — `app/guardrail/confirm/route.ts`:
144
- ```ts
145
- export const GET = supabaseConfirmRoute({ url, anonKey });
146
- ```
147
- 2. **Auth → URL Configuration** — set Site URL to your app's origin, and add
148
- `<origin>/**` to Redirect URLs.
149
- 3. **Auth → Email Templates** — in both **Confirm signup** and **Magic Link**,
150
- point the link at the confirm route using the token hash:
151
- ```html
152
- <a href="{{ .SiteURL }}/guardrail/confirm?token_hash={{ .TokenHash }}&type=email">Sign in</a>
153
- ```
154
- The default `{{ .ConfirmationURL }}` uses a PKCE code that only works in
155
- the browser that requested it — the admin's, not the invitee's.
156
- 4. **Auth → SMTP** — Supabase's built-in mailer allows only a few emails an
157
- hour. Configure custom SMTP (Resend, Postmark, SES) before relying on
158
- invites. Submitting the same address again re-sends a pending invite.
237
+ ```ts
238
+ // app/guardrail/confirm/route.ts
239
+ export const GET = supabaseConfirmRoute({ url, anonKey });
240
+ ```
241
+
242
+ The default `{{ .ConfirmationURL }}` uses a PKCE code that only works in the
243
+ browser that requested it — the admin's, not the invitee's — so a custom
244
+ template needs the token hash form above either way.
159
245
 
160
246
  **Bootstrapping.** A site with no members has nobody who can add members, and
161
247
  there is deliberately no in-app way to grant yourself a role — that would mean
@@ -378,18 +464,33 @@ const clerkAuth: GRAuthAdapter = {
378
464
  };
379
465
  ```
380
466
 
467
+ `completeAuthRedirect` and `setPassword` are optional too, and go together —
468
+ an adapter with no concept of a redirect-carried session (an OAuth provider
469
+ whose own SDK already handles its callback route, say) omits both, and links
470
+ of that kind pass through untouched. Implement `completeAuthRedirect` to
471
+ support them, returning `needsPassword: true` for a session that has none
472
+ yet; `setPassword` is what the resulting prompt calls.
473
+
381
474
  `getSession()` is the sole authorization check for writes. It must validate the
382
475
  session against the provider — never trust the presence of a cookie.
383
476
 
477
+ `listOverrides(siteId, { prefix, limit })` is optional. It's what lets `<GRail>`
478
+ work inside Client Components (see above): one round trip returning the site's rows
479
+ and whether the list was cut off at `limit`. An adapter without it still works
480
+ fully for Server Components — client-rendered fields just show their JSX fallback.
481
+
384
482
  `getOverrides(pathKeys, siteId)` is batched by design — it receives every key a
385
483
  render pass needs, already deduplicated. Adapters map their own storage naming
386
484
  at this boundary; core speaks `pathKey` / `siteId` only.
387
485
 
388
486
  ## Performance
389
487
 
390
- Fields are resolved through a request-scoped batching loader: every `<GRail>`
391
- rendered in the same pass is collected and fetched in a single query. Set
392
- `GUARDRAIL_DEBUG=1` to log how many keys each batch resolved.
488
+ Fields are resolved through a request-scoped loader. By default it preloads the
489
+ site's content in one query and every `<GRail>` — server or client — reads from
490
+ that, so a request issues a single content query however many fields it renders.
491
+ Anything outside the preload (a `prefetch.prefix` scope, or a site past the row
492
+ ceiling) falls back to a batched lookup by key. Set `GUARDRAIL_DEBUG=1` to log
493
+ what each query resolved.
393
494
 
394
495
  ## Known limitations
395
496
 
@@ -1,4 +1,5 @@
1
1
  import { anonClient, assertCredentials, sessionClient } from "./client";
2
+ const NEEDS_PASSWORD_TYPES = /* @__PURE__ */ new Set(["invite", "recovery", "magiclink"]);
2
3
  function supabaseAuth(creds) {
3
4
  assertCredentials(creds);
4
5
  return {
@@ -48,6 +49,55 @@ function supabaseAuth(creds) {
48
49
  async logout() {
49
50
  const client = await sessionClient(creds);
50
51
  await client.auth.signOut();
52
+ },
53
+ /**
54
+ * Whichever of hash/code/tokenHash is present decides the exchange —
55
+ * these correspond to Supabase's three redirect styles (legacy implicit
56
+ * grant, PKCE, and the token_hash style Guardrail's own confirm route
57
+ * already used), so this handles a link regardless of which one a
58
+ * project's email templates happen to produce, including the
59
+ * unconfigured default (implicit grant, hash-based — what an unedited
60
+ * Supabase project actually sends).
61
+ *
62
+ * Goes through sessionClient(), the same cookie-writing client
63
+ * everything else in this adapter uses — setSession/exchangeCodeForSession/
64
+ * verifyOtp all persist the resulting session through its existing
65
+ * getAll/setAll bridge, so this needs no browser-side Supabase client of
66
+ * its own.
67
+ */
68
+ async completeAuthRedirect({ hash, code, tokenHash, type }) {
69
+ const client = await sessionClient(creds);
70
+ if (hash) {
71
+ const hashParams = new URLSearchParams(hash);
72
+ const access_token = hashParams.get("access_token");
73
+ const refresh_token = hashParams.get("refresh_token");
74
+ const hashType = hashParams.get("type") ?? type;
75
+ if (!access_token || !refresh_token) {
76
+ return { success: false, error: "Redirect link is missing its tokens." };
77
+ }
78
+ const { error } = await client.auth.setSession({ access_token, refresh_token });
79
+ if (error) return { success: false, error: error.message };
80
+ return { success: true, needsPassword: NEEDS_PASSWORD_TYPES.has(hashType ?? "") };
81
+ }
82
+ if (code) {
83
+ const { error } = await client.auth.exchangeCodeForSession(code);
84
+ if (error) return { success: false, error: error.message };
85
+ return { success: true, needsPassword: NEEDS_PASSWORD_TYPES.has(type ?? "") };
86
+ }
87
+ if (tokenHash && type) {
88
+ const { error } = await client.auth.verifyOtp({
89
+ type,
90
+ token_hash: tokenHash
91
+ });
92
+ if (error) return { success: false, error: error.message };
93
+ return { success: true, needsPassword: NEEDS_PASSWORD_TYPES.has(type) };
94
+ }
95
+ return { success: false, error: "No redirect data to complete." };
96
+ },
97
+ async setPassword(password) {
98
+ const client = await sessionClient(creds);
99
+ const { error } = await client.auth.updateUser({ password });
100
+ return error ? { success: false, error: error.message } : { success: true };
51
101
  }
52
102
  };
53
103
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/adapters/supabase/auth.ts"],"sourcesContent":["import { anonClient, assertCredentials, sessionClient } from \"./client\";\nimport type { SupabaseCredentials } from \"./client\";\nimport type { GRAuthAdapter, GRSession } from \"../../core/adapter-types\";\n\nexport function supabaseAuth(creds: SupabaseCredentials): GRAuthAdapter {\n assertCredentials(creds);\n\n return {\n async authenticate(email, password) {\n const client = await sessionClient(creds);\n const { data, error } = await client.auth.signInWithPassword({\n email,\n password,\n });\n\n if (error) return { success: false, error: error.message };\n if (!data.user) return { success: false, error: \"User not found.\" };\n\n return {\n success: true,\n session: { userId: data.user.id, email: data.user.email },\n };\n },\n\n /**\n * Uses getUser(), which revalidates the JWT against the Supabase auth\n * server. getSession() only decodes the cookie, so it would trust a forged\n * token.\n */\n async getSession(): Promise<GRSession | null> {\n const client = await sessionClient(creds);\n const { data, error } = await client.auth.getUser();\n if (error || !data.user) return null;\n return { userId: data.user.id, email: data.user.email };\n },\n\n /**\n * Sends a sign-in link that creates the account on first use.\n *\n * Requested through the cookie-free client on purpose. A session client\n * would use PKCE and drop the code verifier into the *admin's* browser,\n * so the link would fail when the invitee opened it anywhere else. The\n * email template must point at the confirm route with {{ .TokenHash }},\n * which verifies server-side and needs no verifier.\n */\n async inviteUser(email, options) {\n const { error } = await anonClient(creds).auth.signInWithOtp({\n email,\n options: {\n shouldCreateUser: true,\n ...(options?.redirectTo ? { emailRedirectTo: options.redirectTo } : {}),\n },\n });\n return error\n ? { success: false, error: error.message }\n : { success: true };\n },\n\n async logout() {\n const client = await sessionClient(creds);\n await client.auth.signOut();\n },\n };\n}\n"],"mappings":"AAAA,SAAS,YAAY,mBAAmB,qBAAqB;AAItD,SAAS,aAAa,OAA2C;AACtE,oBAAkB,KAAK;AAEvB,SAAO;AAAA,IACL,MAAM,aAAa,OAAO,UAAU;AAClC,YAAM,SAAS,MAAM,cAAc,KAAK;AACxC,YAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK,mBAAmB;AAAA,QAC3D;AAAA,QACA;AAAA,MACF,CAAC;AAED,UAAI,MAAO,QAAO,EAAE,SAAS,OAAO,OAAO,MAAM,QAAQ;AACzD,UAAI,CAAC,KAAK,KAAM,QAAO,EAAE,SAAS,OAAO,OAAO,kBAAkB;AAElE,aAAO;AAAA,QACL,SAAS;AAAA,QACT,SAAS,EAAE,QAAQ,KAAK,KAAK,IAAI,OAAO,KAAK,KAAK,MAAM;AAAA,MAC1D;AAAA,IACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAOA,MAAM,aAAwC;AAC5C,YAAM,SAAS,MAAM,cAAc,KAAK;AACxC,YAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK,QAAQ;AAClD,UAAI,SAAS,CAAC,KAAK,KAAM,QAAO;AAChC,aAAO,EAAE,QAAQ,KAAK,KAAK,IAAI,OAAO,KAAK,KAAK,MAAM;AAAA,IACxD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAWA,MAAM,WAAW,OAAO,SAAS;AAC/B,YAAM,EAAE,MAAM,IAAI,MAAM,WAAW,KAAK,EAAE,KAAK,cAAc;AAAA,QAC3D;AAAA,QACA,SAAS;AAAA,UACP,kBAAkB;AAAA,UAClB,GAAI,SAAS,aAAa,EAAE,iBAAiB,QAAQ,WAAW,IAAI,CAAC;AAAA,QACvE;AAAA,MACF,CAAC;AACD,aAAO,QACH,EAAE,SAAS,OAAO,OAAO,MAAM,QAAQ,IACvC,EAAE,SAAS,KAAK;AAAA,IACtB;AAAA,IAEA,MAAM,SAAS;AACb,YAAM,SAAS,MAAM,cAAc,KAAK;AACxC,YAAM,OAAO,KAAK,QAAQ;AAAA,IAC5B;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../../../src/adapters/supabase/auth.ts"],"sourcesContent":["import type { EmailOtpType } from \"@supabase/supabase-js\";\nimport { anonClient, assertCredentials, sessionClient } from \"./client\";\nimport type { SupabaseCredentials } from \"./client\";\nimport type {\n AuthRedirectParams,\n GRAuthAdapter,\n GRSession,\n} from \"../../core/adapter-types\";\n\n// \"invite\" covers Supabase's own admin-API invite flow; \"recovery\" is\n// forgot-password. \"magiclink\" belongs here too — deliberately, not an\n// oversight — because inviteUser() below sends one (see its own comment for\n// why) rather than an \"invite\"-typed email, so this is the type Guardrail's\n// actual, real invite flow produces. A returning magic-link user who already\n// has a password sees this redundantly, but it's skippable, and the\n// alternative — never prompting the person this feature exists for — isn't\n// an acceptable trade to avoid that.\nconst NEEDS_PASSWORD_TYPES = new Set([\"invite\", \"recovery\", \"magiclink\"]);\n\nexport function supabaseAuth(creds: SupabaseCredentials): GRAuthAdapter {\n assertCredentials(creds);\n\n return {\n async authenticate(email, password) {\n const client = await sessionClient(creds);\n const { data, error } = await client.auth.signInWithPassword({\n email,\n password,\n });\n\n if (error) return { success: false, error: error.message };\n if (!data.user) return { success: false, error: \"User not found.\" };\n\n return {\n success: true,\n session: { userId: data.user.id, email: data.user.email },\n };\n },\n\n /**\n * Uses getUser(), which revalidates the JWT against the Supabase auth\n * server. getSession() only decodes the cookie, so it would trust a forged\n * token.\n */\n async getSession(): Promise<GRSession | null> {\n const client = await sessionClient(creds);\n const { data, error } = await client.auth.getUser();\n if (error || !data.user) return null;\n return { userId: data.user.id, email: data.user.email };\n },\n\n /**\n * Sends a sign-in link that creates the account on first use.\n *\n * Requested through the cookie-free client on purpose. A session client\n * would use PKCE and drop the code verifier into the *admin's* browser,\n * so the link would fail when the invitee opened it anywhere else. The\n * email template must point at the confirm route with {{ .TokenHash }},\n * which verifies server-side and needs no verifier.\n */\n async inviteUser(email, options) {\n const { error } = await anonClient(creds).auth.signInWithOtp({\n email,\n options: {\n shouldCreateUser: true,\n ...(options?.redirectTo ? { emailRedirectTo: options.redirectTo } : {}),\n },\n });\n return error\n ? { success: false, error: error.message }\n : { success: true };\n },\n\n async logout() {\n const client = await sessionClient(creds);\n await client.auth.signOut();\n },\n\n /**\n * Whichever of hash/code/tokenHash is present decides the exchange —\n * these correspond to Supabase's three redirect styles (legacy implicit\n * grant, PKCE, and the token_hash style Guardrail's own confirm route\n * already used), so this handles a link regardless of which one a\n * project's email templates happen to produce, including the\n * unconfigured default (implicit grant, hash-based — what an unedited\n * Supabase project actually sends).\n *\n * Goes through sessionClient(), the same cookie-writing client\n * everything else in this adapter uses — setSession/exchangeCodeForSession/\n * verifyOtp all persist the resulting session through its existing\n * getAll/setAll bridge, so this needs no browser-side Supabase client of\n * its own.\n */\n async completeAuthRedirect({ hash, code, tokenHash, type }: AuthRedirectParams) {\n const client = await sessionClient(creds);\n\n if (hash) {\n const hashParams = new URLSearchParams(hash);\n const access_token = hashParams.get(\"access_token\");\n const refresh_token = hashParams.get(\"refresh_token\");\n const hashType = hashParams.get(\"type\") ?? type;\n if (!access_token || !refresh_token) {\n return { success: false, error: \"Redirect link is missing its tokens.\" };\n }\n const { error } = await client.auth.setSession({ access_token, refresh_token });\n if (error) return { success: false, error: error.message };\n return { success: true, needsPassword: NEEDS_PASSWORD_TYPES.has(hashType ?? \"\") };\n }\n\n if (code) {\n const { error } = await client.auth.exchangeCodeForSession(code);\n if (error) return { success: false, error: error.message };\n return { success: true, needsPassword: NEEDS_PASSWORD_TYPES.has(type ?? \"\") };\n }\n\n if (tokenHash && type) {\n const { error } = await client.auth.verifyOtp({\n type: type as EmailOtpType,\n token_hash: tokenHash,\n });\n if (error) return { success: false, error: error.message };\n return { success: true, needsPassword: NEEDS_PASSWORD_TYPES.has(type) };\n }\n\n return { success: false, error: \"No redirect data to complete.\" };\n },\n\n async setPassword(password: string) {\n const client = await sessionClient(creds);\n const { error } = await client.auth.updateUser({ password });\n return error ? { success: false, error: error.message } : { success: true };\n },\n };\n}\n"],"mappings":"AACA,SAAS,YAAY,mBAAmB,qBAAqB;AAgB7D,MAAM,uBAAuB,oBAAI,IAAI,CAAC,UAAU,YAAY,WAAW,CAAC;AAEjE,SAAS,aAAa,OAA2C;AACtE,oBAAkB,KAAK;AAEvB,SAAO;AAAA,IACL,MAAM,aAAa,OAAO,UAAU;AAClC,YAAM,SAAS,MAAM,cAAc,KAAK;AACxC,YAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK,mBAAmB;AAAA,QAC3D;AAAA,QACA;AAAA,MACF,CAAC;AAED,UAAI,MAAO,QAAO,EAAE,SAAS,OAAO,OAAO,MAAM,QAAQ;AACzD,UAAI,CAAC,KAAK,KAAM,QAAO,EAAE,SAAS,OAAO,OAAO,kBAAkB;AAElE,aAAO;AAAA,QACL,SAAS;AAAA,QACT,SAAS,EAAE,QAAQ,KAAK,KAAK,IAAI,OAAO,KAAK,KAAK,MAAM;AAAA,MAC1D;AAAA,IACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAOA,MAAM,aAAwC;AAC5C,YAAM,SAAS,MAAM,cAAc,KAAK;AACxC,YAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK,QAAQ;AAClD,UAAI,SAAS,CAAC,KAAK,KAAM,QAAO;AAChC,aAAO,EAAE,QAAQ,KAAK,KAAK,IAAI,OAAO,KAAK,KAAK,MAAM;AAAA,IACxD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAWA,MAAM,WAAW,OAAO,SAAS;AAC/B,YAAM,EAAE,MAAM,IAAI,MAAM,WAAW,KAAK,EAAE,KAAK,cAAc;AAAA,QAC3D;AAAA,QACA,SAAS;AAAA,UACP,kBAAkB;AAAA,UAClB,GAAI,SAAS,aAAa,EAAE,iBAAiB,QAAQ,WAAW,IAAI,CAAC;AAAA,QACvE;AAAA,MACF,CAAC;AACD,aAAO,QACH,EAAE,SAAS,OAAO,OAAO,MAAM,QAAQ,IACvC,EAAE,SAAS,KAAK;AAAA,IACtB;AAAA,IAEA,MAAM,SAAS;AACb,YAAM,SAAS,MAAM,cAAc,KAAK;AACxC,YAAM,OAAO,KAAK,QAAQ;AAAA,IAC5B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAiBA,MAAM,qBAAqB,EAAE,MAAM,MAAM,WAAW,KAAK,GAAuB;AAC9E,YAAM,SAAS,MAAM,cAAc,KAAK;AAExC,UAAI,MAAM;AACR,cAAM,aAAa,IAAI,gBAAgB,IAAI;AAC3C,cAAM,eAAe,WAAW,IAAI,cAAc;AAClD,cAAM,gBAAgB,WAAW,IAAI,eAAe;AACpD,cAAM,WAAW,WAAW,IAAI,MAAM,KAAK;AAC3C,YAAI,CAAC,gBAAgB,CAAC,eAAe;AACnC,iBAAO,EAAE,SAAS,OAAO,OAAO,uCAAuC;AAAA,QACzE;AACA,cAAM,EAAE,MAAM,IAAI,MAAM,OAAO,KAAK,WAAW,EAAE,cAAc,cAAc,CAAC;AAC9E,YAAI,MAAO,QAAO,EAAE,SAAS,OAAO,OAAO,MAAM,QAAQ;AACzD,eAAO,EAAE,SAAS,MAAM,eAAe,qBAAqB,IAAI,YAAY,EAAE,EAAE;AAAA,MAClF;AAEA,UAAI,MAAM;AACR,cAAM,EAAE,MAAM,IAAI,MAAM,OAAO,KAAK,uBAAuB,IAAI;AAC/D,YAAI,MAAO,QAAO,EAAE,SAAS,OAAO,OAAO,MAAM,QAAQ;AACzD,eAAO,EAAE,SAAS,MAAM,eAAe,qBAAqB,IAAI,QAAQ,EAAE,EAAE;AAAA,MAC9E;AAEA,UAAI,aAAa,MAAM;AACrB,cAAM,EAAE,MAAM,IAAI,MAAM,OAAO,KAAK,UAAU;AAAA,UAC5C;AAAA,UACA,YAAY;AAAA,QACd,CAAC;AACD,YAAI,MAAO,QAAO,EAAE,SAAS,OAAO,OAAO,MAAM,QAAQ;AACzD,eAAO,EAAE,SAAS,MAAM,eAAe,qBAAqB,IAAI,IAAI,EAAE;AAAA,MACxE;AAEA,aAAO,EAAE,SAAS,OAAO,OAAO,gCAAgC;AAAA,IAClE;AAAA,IAEA,MAAM,YAAY,UAAkB;AAClC,YAAM,SAAS,MAAM,cAAc,KAAK;AACxC,YAAM,EAAE,MAAM,IAAI,MAAM,OAAO,KAAK,WAAW,EAAE,SAAS,CAAC;AAC3D,aAAO,QAAQ,EAAE,SAAS,OAAO,OAAO,MAAM,QAAQ,IAAI,EAAE,SAAS,KAAK;AAAA,IAC5E;AAAA,EACF;AACF;","names":[]}
@@ -20,6 +20,17 @@ function supabaseDB(creds) {
20
20
  }
21
21
  return (data ?? []).map(toOverrideRow);
22
22
  },
23
+ async listOverrides(siteId, { prefix, limit }) {
24
+ let query = anonClient(creds).schema(GR_SCHEMA).from(GR_TABLE).select("id, path_key, value").eq("site_id", siteId).order("path_key").limit(limit + 1);
25
+ if (prefix?.length) {
26
+ const quote = (p) => `"${p.replace(/["\\]/g, "\\$&")}"`;
27
+ query = query.or(prefix.map((p) => `path_key.like.${quote(`${p}*`)}`).join(","));
28
+ }
29
+ const { data, error } = await query;
30
+ if (error) throw new Error(error.message);
31
+ const rows = (data ?? []).map(toOverrideRow);
32
+ return { rows: rows.slice(0, limit), truncated: rows.length > limit };
33
+ },
23
34
  async setOverride(row) {
24
35
  const client = await sessionClient(creds);
25
36
  const { error } = await client.schema(GR_SCHEMA).from(GR_TABLE).upsert(
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/adapters/supabase/db.ts"],"sourcesContent":["import { anonClient, assertCredentials, sessionClient } from \"./client\";\nimport type { SupabaseCredentials } from \"./client\";\nimport { GR_SCHEMA, GR_TABLE } from \"../../core/defs\";\nimport type { GRDBAdapter, GRStorageRow } from \"../../core/adapter-types\";\nimport type { OverrideRow, OverrideValue } from \"../../core/types\";\n\n/** Shape as stored in Postgres. Column naming stops at this boundary. */\ninterface ContentRow {\n id: string;\n path_key: string;\n value: OverrideValue | string;\n}\n\nfunction toOverrideRow(row: ContentRow): OverrideRow {\n return {\n id: row.id,\n pathKey: row.path_key,\n // Some drivers hand back jsonb columns as strings.\n value:\n typeof row.value === \"string\"\n ? (JSON.parse(row.value) as OverrideValue)\n : row.value,\n };\n}\n\nexport function supabaseDB(creds: SupabaseCredentials): GRDBAdapter {\n assertCredentials(creds);\n\n return {\n async getOverrides(pathKeys, siteId) {\n if (pathKeys.length === 0) return [];\n\n // Anon client keeps the read path cookie-free so routes stay static.\n const { data, error } = await anonClient(creds)\n .schema(GR_SCHEMA)\n .from(GR_TABLE)\n .select(\"id, path_key, value\")\n .eq(\"site_id\", siteId)\n .in(\"path_key\", pathKeys);\n\n if (error) {\n console.error(\"Guardrail (Supabase): read failed\", error.message);\n return [];\n }\n return ((data ?? []) as ContentRow[]).map(toOverrideRow);\n },\n\n async setOverride(row: GRStorageRow) {\n const client = await sessionClient(creds);\n const { error } = await client\n .schema(GR_SCHEMA)\n .from(GR_TABLE)\n .upsert(\n {\n path_key: row.pathKey,\n site_id: row.siteId,\n type: row.type,\n value: row.value,\n updated_at: new Date().toISOString(),\n },\n { onConflict: \"path_key,site_id\" },\n );\n return error\n ? { success: false, error: error.message }\n : { success: true };\n },\n\n async deleteOverrides(pathKeyPrefix: string, siteId: string) {\n const client = await sessionClient(creds);\n const { error } = await client\n .schema(GR_SCHEMA)\n .from(GR_TABLE)\n .delete()\n .eq(\"site_id\", siteId)\n .like(\"path_key\", `${pathKeyPrefix}%`);\n if (error) {\n console.error(\"Guardrail (Supabase): deleteOverrides failed\", error.message);\n }\n },\n };\n}\n"],"mappings":"AAAA,SAAS,YAAY,mBAAmB,qBAAqB;AAE7D,SAAS,WAAW,gBAAgB;AAWpC,SAAS,cAAc,KAA8B;AACnD,SAAO;AAAA,IACL,IAAI,IAAI;AAAA,IACR,SAAS,IAAI;AAAA;AAAA,IAEb,OACE,OAAO,IAAI,UAAU,WAChB,KAAK,MAAM,IAAI,KAAK,IACrB,IAAI;AAAA,EACZ;AACF;AAEO,SAAS,WAAW,OAAyC;AAClE,oBAAkB,KAAK;AAEvB,SAAO;AAAA,IACL,MAAM,aAAa,UAAU,QAAQ;AACnC,UAAI,SAAS,WAAW,EAAG,QAAO,CAAC;AAGnC,YAAM,EAAE,MAAM,MAAM,IAAI,MAAM,WAAW,KAAK,EAC3C,OAAO,SAAS,EAChB,KAAK,QAAQ,EACb,OAAO,qBAAqB,EAC5B,GAAG,WAAW,MAAM,EACpB,GAAG,YAAY,QAAQ;AAE1B,UAAI,OAAO;AACT,gBAAQ,MAAM,qCAAqC,MAAM,OAAO;AAChE,eAAO,CAAC;AAAA,MACV;AACA,cAAS,QAAQ,CAAC,GAAoB,IAAI,aAAa;AAAA,IACzD;AAAA,IAEA,MAAM,YAAY,KAAmB;AACnC,YAAM,SAAS,MAAM,cAAc,KAAK;AACxC,YAAM,EAAE,MAAM,IAAI,MAAM,OACrB,OAAO,SAAS,EAChB,KAAK,QAAQ,EACb;AAAA,QACC;AAAA,UACE,UAAU,IAAI;AAAA,UACd,SAAS,IAAI;AAAA,UACb,MAAM,IAAI;AAAA,UACV,OAAO,IAAI;AAAA,UACX,aAAY,oBAAI,KAAK,GAAE,YAAY;AAAA,QACrC;AAAA,QACA,EAAE,YAAY,mBAAmB;AAAA,MACnC;AACF,aAAO,QACH,EAAE,SAAS,OAAO,OAAO,MAAM,QAAQ,IACvC,EAAE,SAAS,KAAK;AAAA,IACtB;AAAA,IAEA,MAAM,gBAAgB,eAAuB,QAAgB;AAC3D,YAAM,SAAS,MAAM,cAAc,KAAK;AACxC,YAAM,EAAE,MAAM,IAAI,MAAM,OACrB,OAAO,SAAS,EAChB,KAAK,QAAQ,EACb,OAAO,EACP,GAAG,WAAW,MAAM,EACpB,KAAK,YAAY,GAAG,aAAa,GAAG;AACvC,UAAI,OAAO;AACT,gBAAQ,MAAM,gDAAgD,MAAM,OAAO;AAAA,MAC7E;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
1
+ {"version":3,"sources":["../../../src/adapters/supabase/db.ts"],"sourcesContent":["import { anonClient, assertCredentials, sessionClient } from \"./client\";\nimport type { SupabaseCredentials } from \"./client\";\nimport { GR_SCHEMA, GR_TABLE } from \"../../core/defs\";\nimport type { GRDBAdapter, GRStorageRow } from \"../../core/adapter-types\";\nimport type { OverrideRow, OverrideValue } from \"../../core/types\";\n\n/** Shape as stored in Postgres. Column naming stops at this boundary. */\ninterface ContentRow {\n id: string;\n path_key: string;\n value: OverrideValue | string;\n}\n\nfunction toOverrideRow(row: ContentRow): OverrideRow {\n return {\n id: row.id,\n pathKey: row.path_key,\n // Some drivers hand back jsonb columns as strings.\n value:\n typeof row.value === \"string\"\n ? (JSON.parse(row.value) as OverrideValue)\n : row.value,\n };\n}\n\nexport function supabaseDB(creds: SupabaseCredentials): GRDBAdapter {\n assertCredentials(creds);\n\n return {\n async getOverrides(pathKeys, siteId) {\n if (pathKeys.length === 0) return [];\n\n // Anon client keeps the read path cookie-free so routes stay static.\n const { data, error } = await anonClient(creds)\n .schema(GR_SCHEMA)\n .from(GR_TABLE)\n .select(\"id, path_key, value\")\n .eq(\"site_id\", siteId)\n .in(\"path_key\", pathKeys);\n\n if (error) {\n console.error(\"Guardrail (Supabase): read failed\", error.message);\n return [];\n }\n return ((data ?? []) as ContentRow[]).map(toOverrideRow);\n },\n\n async listOverrides(siteId, { prefix, limit }) {\n let query = anonClient(creds)\n .schema(GR_SCHEMA)\n .from(GR_TABLE)\n .select(\"id, path_key, value\")\n .eq(\"site_id\", siteId)\n // Ordered so that truncation, when it happens, is deterministic.\n .order(\"path_key\")\n // One past the ceiling, purely to learn whether anything was cut off.\n .limit(limit + 1);\n\n if (prefix?.length) {\n // Quoted so a prefix containing PostgREST's own separators (comma,\n // parens) can't be read as filter syntax.\n const quote = (p: string) => `\"${p.replace(/[\"\\\\]/g, \"\\\\$&\")}\"`;\n query = query.or(prefix.map((p) => `path_key.like.${quote(`${p}*`)}`).join(\",\"));\n }\n\n const { data, error } = await query;\n // Thrown, not swallowed to []: an empty list here would read as \"this\n // site has no content\" and short-circuit every field's own lookup.\n if (error) throw new Error(error.message);\n\n const rows = ((data ?? []) as ContentRow[]).map(toOverrideRow);\n return { rows: rows.slice(0, limit), truncated: rows.length > limit };\n },\n\n async setOverride(row: GRStorageRow) {\n const client = await sessionClient(creds);\n const { error } = await client\n .schema(GR_SCHEMA)\n .from(GR_TABLE)\n .upsert(\n {\n path_key: row.pathKey,\n site_id: row.siteId,\n type: row.type,\n value: row.value,\n updated_at: new Date().toISOString(),\n },\n { onConflict: \"path_key,site_id\" },\n );\n return error\n ? { success: false, error: error.message }\n : { success: true };\n },\n\n async deleteOverrides(pathKeyPrefix: string, siteId: string) {\n const client = await sessionClient(creds);\n const { error } = await client\n .schema(GR_SCHEMA)\n .from(GR_TABLE)\n .delete()\n .eq(\"site_id\", siteId)\n .like(\"path_key\", `${pathKeyPrefix}%`);\n if (error) {\n console.error(\"Guardrail (Supabase): deleteOverrides failed\", error.message);\n }\n },\n };\n}\n"],"mappings":"AAAA,SAAS,YAAY,mBAAmB,qBAAqB;AAE7D,SAAS,WAAW,gBAAgB;AAWpC,SAAS,cAAc,KAA8B;AACnD,SAAO;AAAA,IACL,IAAI,IAAI;AAAA,IACR,SAAS,IAAI;AAAA;AAAA,IAEb,OACE,OAAO,IAAI,UAAU,WAChB,KAAK,MAAM,IAAI,KAAK,IACrB,IAAI;AAAA,EACZ;AACF;AAEO,SAAS,WAAW,OAAyC;AAClE,oBAAkB,KAAK;AAEvB,SAAO;AAAA,IACL,MAAM,aAAa,UAAU,QAAQ;AACnC,UAAI,SAAS,WAAW,EAAG,QAAO,CAAC;AAGnC,YAAM,EAAE,MAAM,MAAM,IAAI,MAAM,WAAW,KAAK,EAC3C,OAAO,SAAS,EAChB,KAAK,QAAQ,EACb,OAAO,qBAAqB,EAC5B,GAAG,WAAW,MAAM,EACpB,GAAG,YAAY,QAAQ;AAE1B,UAAI,OAAO;AACT,gBAAQ,MAAM,qCAAqC,MAAM,OAAO;AAChE,eAAO,CAAC;AAAA,MACV;AACA,cAAS,QAAQ,CAAC,GAAoB,IAAI,aAAa;AAAA,IACzD;AAAA,IAEA,MAAM,cAAc,QAAQ,EAAE,QAAQ,MAAM,GAAG;AAC7C,UAAI,QAAQ,WAAW,KAAK,EACzB,OAAO,SAAS,EAChB,KAAK,QAAQ,EACb,OAAO,qBAAqB,EAC5B,GAAG,WAAW,MAAM,EAEpB,MAAM,UAAU,EAEhB,MAAM,QAAQ,CAAC;AAElB,UAAI,QAAQ,QAAQ;AAGlB,cAAM,QAAQ,CAAC,MAAc,IAAI,EAAE,QAAQ,UAAU,MAAM,CAAC;AAC5D,gBAAQ,MAAM,GAAG,OAAO,IAAI,CAAC,MAAM,iBAAiB,MAAM,GAAG,CAAC,GAAG,CAAC,EAAE,EAAE,KAAK,GAAG,CAAC;AAAA,MACjF;AAEA,YAAM,EAAE,MAAM,MAAM,IAAI,MAAM;AAG9B,UAAI,MAAO,OAAM,IAAI,MAAM,MAAM,OAAO;AAExC,YAAM,QAAS,QAAQ,CAAC,GAAoB,IAAI,aAAa;AAC7D,aAAO,EAAE,MAAM,KAAK,MAAM,GAAG,KAAK,GAAG,WAAW,KAAK,SAAS,MAAM;AAAA,IACtE;AAAA,IAEA,MAAM,YAAY,KAAmB;AACnC,YAAM,SAAS,MAAM,cAAc,KAAK;AACxC,YAAM,EAAE,MAAM,IAAI,MAAM,OACrB,OAAO,SAAS,EAChB,KAAK,QAAQ,EACb;AAAA,QACC;AAAA,UACE,UAAU,IAAI;AAAA,UACd,SAAS,IAAI;AAAA,UACb,MAAM,IAAI;AAAA,UACV,OAAO,IAAI;AAAA,UACX,aAAY,oBAAI,KAAK,GAAE,YAAY;AAAA,QACrC;AAAA,QACA,EAAE,YAAY,mBAAmB;AAAA,MACnC;AACF,aAAO,QACH,EAAE,SAAS,OAAO,OAAO,MAAM,QAAQ,IACvC,EAAE,SAAS,KAAK;AAAA,IACtB;AAAA,IAEA,MAAM,gBAAgB,eAAuB,QAAgB;AAC3D,YAAM,SAAS,MAAM,cAAc,KAAK;AACxC,YAAM,EAAE,MAAM,IAAI,MAAM,OACrB,OAAO,SAAS,EAChB,KAAK,QAAQ,EACb,OAAO,EACP,GAAG,WAAW,MAAM,EACpB,KAAK,YAAY,GAAG,aAAa,GAAG;AACvC,UAAI,OAAO;AACT,gBAAQ,MAAM,gDAAgD,MAAM,OAAO;AAAA,MAC7E;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import {
4
+ existsSync,
5
+ mkdirSync,
6
+ copyFileSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ writeFileSync
10
+ } from "node:fs";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ function commandExists(cmd) {
14
+ const res = process.platform === "win32" ? spawnSync("where", [cmd]) : spawnSync("which", [cmd]);
15
+ return res.status === 0;
16
+ }
17
+ function run(cmd, args) {
18
+ const res = spawnSync(cmd, args, { stdio: "inherit" });
19
+ if (res.status !== 0) {
20
+ throw new Error(`${cmd} ${args.join(" ")} exited with code ${res.status}`);
21
+ }
22
+ }
23
+ function fail(message) {
24
+ console.error(`
25
+ ${message}
26
+ `);
27
+ process.exit(1);
28
+ }
29
+ const USAGE = `Usage:
30
+ guardrail-init-db [--push]
31
+
32
+ Copies Guardrail's migration(s) from this package into ./supabase/migrations/
33
+ (skipping any already present there), runs \`supabase init\` if this project
34
+ has no supabase/config.toml yet, and adds "guardrail" to its exposed schemas
35
+ for local dev.
36
+
37
+ --push Also run \`supabase db push\` once the files are in place. Without it,
38
+ the migration is copied but not applied \u2014 run \`supabase db push\`
39
+ yourself (after \`supabase link\`, if this project isn't linked yet).`;
40
+ function patchExposedSchemas(configPath) {
41
+ const content = readFileSync(configPath, "utf8");
42
+ const patched = content.replace(
43
+ /(\[api\][\s\S]*?schemas\s*=\s*\[)([^\]]*)(\])/,
44
+ (match, pre, inner, post) => {
45
+ if (inner.includes('"guardrail"')) return match;
46
+ const trimmed = inner.trimEnd();
47
+ const sep = trimmed === "" || trimmed.endsWith(",") ? " " : ", ";
48
+ return `${pre}${inner}${sep}"guardrail"${post}`;
49
+ }
50
+ );
51
+ if (patched === content && !content.includes('"guardrail"')) {
52
+ console.warn(
53
+ 'Could not find the [api] schemas setting in supabase/config.toml to patch automatically \u2014 add "guardrail" to it yourself before `supabase start`.'
54
+ );
55
+ return;
56
+ }
57
+ writeFileSync(configPath, patched);
58
+ }
59
+ async function main() {
60
+ const args = process.argv.slice(2);
61
+ if (args.includes("--help") || args.includes("-h")) {
62
+ console.log(USAGE);
63
+ return;
64
+ }
65
+ const push = args.includes("--push");
66
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
67
+ const pkgRoot = path.join(__dirname, "..", "..");
68
+ const srcDir = path.join(pkgRoot, "supabase", "migrations");
69
+ if (!existsSync(srcDir)) {
70
+ fail(`No bundled migrations found at ${srcDir} \u2014 is @guard-rail/core installed correctly?`);
71
+ }
72
+ const destDir = path.join(process.cwd(), "supabase", "migrations");
73
+ mkdirSync(destDir, { recursive: true });
74
+ const existing = new Set(readdirSync(destDir));
75
+ const copied = [];
76
+ for (const file of readdirSync(srcDir)) {
77
+ if (existing.has(file)) continue;
78
+ copyFileSync(path.join(srcDir, file), path.join(destDir, file));
79
+ copied.push(file);
80
+ }
81
+ if (copied.length === 0) {
82
+ console.log("supabase/migrations/ is already up to date \u2014 nothing to copy.");
83
+ } else {
84
+ console.log(`Copied ${copied.length} migration${copied.length === 1 ? "" : "s"}:`);
85
+ for (const file of copied) console.log(` supabase/migrations/${file}`);
86
+ }
87
+ if (!commandExists("supabase")) {
88
+ console.log(
89
+ "\nSupabase CLI not found \u2014 apply the migration yourself (paste it into the SQL editor, or install the CLI and run `supabase db push`)."
90
+ );
91
+ return;
92
+ }
93
+ const configPath = path.join(process.cwd(), "supabase", "config.toml");
94
+ if (!existsSync(configPath)) {
95
+ try {
96
+ run("supabase", ["init"]);
97
+ } catch {
98
+ console.warn("\n`supabase init` failed \u2014 you can run it yourself.");
99
+ return;
100
+ }
101
+ }
102
+ if (existsSync(configPath)) patchExposedSchemas(configPath);
103
+ if (!push) {
104
+ console.log(
105
+ "\nRun `guardrail-init-db --push` (or `supabase db push` yourself) to apply it."
106
+ );
107
+ return;
108
+ }
109
+ try {
110
+ run("supabase", ["db", "push"]);
111
+ } catch {
112
+ console.warn(
113
+ "\n`supabase db push` failed \u2014 if this project isn't linked yet, run `supabase link` first, then `supabase db push`."
114
+ );
115
+ }
116
+ }
117
+ main().catch((err) => {
118
+ console.error(err);
119
+ process.exit(1);
120
+ });
121
+ //# sourceMappingURL=init-db.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/cli/init-db.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * Copies Guardrail's SQL migration(s) into the current project's own\n * supabase/migrations/, then (optionally) applies them — the step\n * create-guardrail already automates for a brand-new scaffold (see\n * copyMigration()/maybeLinkAndPush() in that package), but has no\n * equivalent for an existing Next.js + Supabase app that just ran\n * `npm install @guard-rail/core`. Until now, postinstall.mjs pointed at\n * doing this by hand: \"Copy\n * node_modules/@guard-rail/core/supabase/migrations/*.sql into your own\n * supabase/migrations/, then `supabase db push`.\"\n *\n * Copies by filename, skipping anything already present in the destination\n * — safe to re-run after upgrading the package: it only ever adds\n * migrations this version ships that a prior run didn't already copy, and\n * never overwrites one that's already there.\n *\n * Deliberately non-interactive (flags only, no prompts), unlike\n * create-guardrail's wizard-style prompts — this is meant to be a single\n * command a developer (or a script) runs once against a project that\n * already exists, not a guided setup flow.\n */\nimport { spawnSync } from \"node:child_process\";\nimport {\n existsSync,\n mkdirSync,\n copyFileSync,\n readdirSync,\n readFileSync,\n writeFileSync,\n} from \"node:fs\";\nimport path from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nfunction commandExists(cmd: string): boolean {\n // See create-guardrail's util.ts for why `which`/`where` over `command -v`\n // with shell:true — no shell needed, no Node deprecation warning.\n const res =\n process.platform === \"win32\" ? spawnSync(\"where\", [cmd]) : spawnSync(\"which\", [cmd]);\n return res.status === 0;\n}\n\n/** Throws with the failing command's name — every call site decides whether that's fatal. */\nfunction run(cmd: string, args: string[]): void {\n const res = spawnSync(cmd, args, { stdio: \"inherit\" });\n if (res.status !== 0) {\n throw new Error(`${cmd} ${args.join(\" \")} exited with code ${res.status}`);\n }\n}\n\nfunction fail(message: string): never {\n console.error(`\\n${message}\\n`);\n process.exit(1);\n}\n\nconst USAGE = `Usage:\n guardrail-init-db [--push]\n\nCopies Guardrail's migration(s) from this package into ./supabase/migrations/\n(skipping any already present there), runs \\`supabase init\\` if this project\nhas no supabase/config.toml yet, and adds \"guardrail\" to its exposed schemas\nfor local dev.\n\n--push Also run \\`supabase db push\\` once the files are in place. Without it,\n the migration is copied but not applied — run \\`supabase db push\\`\n yourself (after \\`supabase link\\`, if this project isn't linked yet).`;\n\n/**\n * Idempotent by design (a no-op if \"guardrail\" is already listed) — safe to\n * run on every invocation rather than only the first, including against a\n * config.toml a developer has since hand-edited.\n */\nfunction patchExposedSchemas(configPath: string): void {\n const content = readFileSync(configPath, \"utf8\");\n const patched = content.replace(\n /(\\[api\\][\\s\\S]*?schemas\\s*=\\s*\\[)([^\\]]*)(\\])/,\n (match, pre: string, inner: string, post: string) => {\n if (inner.includes('\"guardrail\"')) return match;\n const trimmed = inner.trimEnd();\n const sep = trimmed === \"\" || trimmed.endsWith(\",\") ? \" \" : \", \";\n return `${pre}${inner}${sep}\"guardrail\"${post}`;\n },\n );\n\n if (patched === content && !content.includes('\"guardrail\"')) {\n console.warn(\n 'Could not find the [api] schemas setting in supabase/config.toml to patch ' +\n 'automatically — add \"guardrail\" to it yourself before `supabase start`.',\n );\n return;\n }\n writeFileSync(configPath, patched);\n}\n\nasync function main() {\n const args = process.argv.slice(2);\n if (args.includes(\"--help\") || args.includes(\"-h\")) {\n console.log(USAGE);\n return;\n }\n const push = args.includes(\"--push\");\n\n // This file's own location, not the invoking project's node_modules —\n // resolves the migrations this installed package version ships,\n // regardless of how the command was invoked (npx, a package.json script,\n // a workspace link).\n const __dirname = path.dirname(fileURLToPath(import.meta.url));\n const pkgRoot = path.join(__dirname, \"..\", \"..\");\n const srcDir = path.join(pkgRoot, \"supabase\", \"migrations\");\n if (!existsSync(srcDir)) {\n fail(`No bundled migrations found at ${srcDir} — is @guard-rail/core installed correctly?`);\n }\n\n const destDir = path.join(process.cwd(), \"supabase\", \"migrations\");\n mkdirSync(destDir, { recursive: true });\n const existing = new Set(readdirSync(destDir));\n\n const copied: string[] = [];\n for (const file of readdirSync(srcDir)) {\n if (existing.has(file)) continue;\n copyFileSync(path.join(srcDir, file), path.join(destDir, file));\n copied.push(file);\n }\n\n if (copied.length === 0) {\n console.log(\"supabase/migrations/ is already up to date — nothing to copy.\");\n } else {\n console.log(`Copied ${copied.length} migration${copied.length === 1 ? \"\" : \"s\"}:`);\n for (const file of copied) console.log(` supabase/migrations/${file}`);\n }\n\n if (!commandExists(\"supabase\")) {\n console.log(\n \"\\nSupabase CLI not found — apply the migration yourself (paste it into the SQL \" +\n \"editor, or install the CLI and run `supabase db push`).\",\n );\n return;\n }\n\n const configPath = path.join(process.cwd(), \"supabase\", \"config.toml\");\n if (!existsSync(configPath)) {\n try {\n run(\"supabase\", [\"init\"]);\n } catch {\n console.warn(\"\\n`supabase init` failed — you can run it yourself.\");\n return;\n }\n }\n if (existsSync(configPath)) patchExposedSchemas(configPath);\n\n if (!push) {\n console.log(\n \"\\nRun `guardrail-init-db --push` (or `supabase db push` yourself) to apply it.\",\n );\n return;\n }\n\n try {\n run(\"supabase\", [\"db\", \"push\"]);\n } catch {\n console.warn(\n \"\\n`supabase db push` failed — if this project isn't linked yet, run `supabase \" +\n \"link` first, then `supabase db push`.\",\n );\n }\n}\n\nmain().catch((err) => {\n console.error(err);\n process.exit(1);\n});\n"],"mappings":"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","names":[]}
@@ -30,6 +30,30 @@ interface GRDBAdapter {
30
30
  * row is still removed, just not its old field values.
31
31
  */
32
32
  deleteOverrides?: (pathKeyPrefix: string, siteId: string) => Promise<void>;
33
+ /**
34
+ * List a site's rows without naming keys — what makes <GRail> usable inside
35
+ * Client Components, which cannot await a per-field query. One round trip.
36
+ *
37
+ * Optional: an adapter without it still works fully for Server Components;
38
+ * client-rendered fields just show their JSX fallback. `limit` is a ceiling,
39
+ * not a page size — return `truncated: true` if rows beyond it existed, so
40
+ * the caller knows the list is partial. `prefix`, when given, restricts to
41
+ * keys starting with any of those strings.
42
+ */
43
+ listOverrides?: (siteId: string, opts: {
44
+ prefix?: string[];
45
+ limit: number;
46
+ }) => Promise<{
47
+ rows: OverrideRow[];
48
+ truncated: boolean;
49
+ }>;
50
+ }
51
+ /** How much content to preload for <GRail>s inside Client Components. */
52
+ interface GRPrefetch {
53
+ /** Only preload keys starting with one of these (e.g. `["home.", "header."]`). */
54
+ prefix?: string[];
55
+ /** Ceiling on preloaded rows. Default 500. */
56
+ limit?: number;
33
57
  }
34
58
  /** Identity of a signed-in editor, as verified by the auth adapter. */
35
59
  interface GRSession {
@@ -73,6 +97,48 @@ interface GRAuthAdapter {
73
97
  */
74
98
  getSession: () => Promise<GRSession | null>;
75
99
  logout: () => Promise<void>;
100
+ /**
101
+ * Completes a session that arrived via a redirect link — an invite, magic
102
+ * link, or password-recovery email — rather than the normal sign-in form.
103
+ * Optional: an adapter with no such concept (an OAuth provider whose own
104
+ * SDK already handles its callback route, say) simply omits it, and links
105
+ * of this kind pass through Guardrail untouched.
106
+ *
107
+ * Whatever the client found in the URL is handed through as-is — which of
108
+ * `hash`/`code`/`tokenHash` is present depends on the provider's own
109
+ * redirect style, not a choice Guardrail's core makes. `needsPassword` in
110
+ * the result tells the caller to prompt for one: an invited or
111
+ * password-recovering person has a valid session but no password yet.
112
+ */
113
+ completeAuthRedirect?: (params: AuthRedirectParams) => Promise<{
114
+ success: boolean;
115
+ needsPassword?: boolean;
116
+ error?: string;
117
+ }>;
118
+ /**
119
+ * Sets the current session's password — the other half of
120
+ * `completeAuthRedirect`'s `needsPassword`. Optional for the same reason
121
+ * `authenticate` is: only meaningful for a password-based adapter.
122
+ */
123
+ setPassword?: (password: string) => Promise<{
124
+ success: boolean;
125
+ error?: string;
126
+ }>;
127
+ }
128
+ /**
129
+ * Raw redirect data as the browser found it — see completeAuthRedirect.
130
+ * Hash-based tokens never reach the server on their own; the browser has to
131
+ * read them and hand them here.
132
+ */
133
+ interface AuthRedirectParams {
134
+ /** window.location.hash, minus its leading '#' (the implicit-grant style). */
135
+ hash?: string;
136
+ /** The `code` query param (the PKCE style). */
137
+ code?: string;
138
+ /** The `token_hash` query param (Guardrail's own documented confirm-route style, detected here too so that route stays opt-in rather than required). */
139
+ tokenHash?: string;
140
+ /** Accompanies `code` or `tokenHash` — e.g. "invite", "recovery", "magiclink". */
141
+ type?: string;
76
142
  }
77
143
  type AuthResult = {
78
144
  success: true;
@@ -207,6 +273,16 @@ interface GRConfig {
207
273
  * an already-resolved element, not a function.
208
274
  */
209
275
  toolbarExtra?: ReactNode;
276
+ /**
277
+ * Content preloaded for <GRail>s inside Client Components. On by default
278
+ * with a row ceiling and no scoping: zero config, and one query per request
279
+ * total, since server-rendered fields read from the same preload instead of
280
+ * issuing their own. Scope it with `prefix` if a site outgrows the ceiling
281
+ * (a dev-mode warning says when), or pass `false` to turn it off — client
282
+ * fields then render their JSX fallback and server fields query per batch as
283
+ * before.
284
+ */
285
+ prefetch?: false | GRPrefetch;
210
286
  }
211
287
 
212
- export type { AuthResult, GRAuthAdapter, GRConfig, GRDBAdapter, GRInviteEvent, GRMembershipAdapter, GRRepeaterAdapter, GRSession, GRStorageAdapter, GRStorageRow, GRUploadTarget };
288
+ export type { AuthRedirectParams, AuthResult, GRAuthAdapter, GRConfig, GRDBAdapter, GRInviteEvent, GRMembershipAdapter, GRPrefetch, GRRepeaterAdapter, GRSession, GRStorageAdapter, GRStorageRow, GRUploadTarget };
@@ -21,7 +21,13 @@ declare function getMembers(): GRMembershipAdapter;
21
21
  declare function getStorage(): GRStorageAdapter | undefined;
22
22
  /** Throws with a clear message if no page on this site uses <GRepeater>. */
23
23
  declare function getRepeaters(): GRRepeaterAdapter;
24
+ declare const DEFAULT_PREFETCH_LIMIT = 500;
25
+ /** Normalized preload settings, or null when preloading is off. */
26
+ declare function getPrefetch(): {
27
+ prefix?: string[];
28
+ limit: number;
29
+ } | null;
24
30
  declare function getInviteHandler(): GRConfig["onInvite"];
25
31
  declare function resolveSiteId(): Promise<string>;
26
32
 
27
- export { configureGuardrail, getAuth, getDB, getInviteHandler, getMembers, getRepeaters, getStorage, resolveSiteId };
33
+ export { DEFAULT_PREFETCH_LIMIT, configureGuardrail, getAuth, getDB, getInviteHandler, getMembers, getPrefetch, getRepeaters, getStorage, resolveSiteId };
@@ -26,6 +26,15 @@ function getStorage() {
26
26
  function getRepeaters() {
27
27
  return required("repeaters");
28
28
  }
29
+ const DEFAULT_PREFETCH_LIMIT = 500;
30
+ function getPrefetch() {
31
+ const p = _config?.prefetch;
32
+ if (p === false) return null;
33
+ return {
34
+ ...p?.prefix?.length ? { prefix: p.prefix } : {},
35
+ limit: p?.limit ?? DEFAULT_PREFETCH_LIMIT
36
+ };
37
+ }
29
38
  function getInviteHandler() {
30
39
  return _config?.onInvite;
31
40
  }
@@ -34,11 +43,13 @@ async function resolveSiteId() {
34
43
  return typeof sid === "function" ? await sid() : sid || "default";
35
44
  }
36
45
  export {
46
+ DEFAULT_PREFETCH_LIMIT,
37
47
  configureGuardrail,
38
48
  getAuth,
39
49
  getDB,
40
50
  getInviteHandler,
41
51
  getMembers,
52
+ getPrefetch,
42
53
  getRepeaters,
43
54
  getStorage,
44
55
  resolveSiteId