@guard-rail/core 0.3.0 → 0.6.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 (46) hide show
  1. package/README.md +111 -28
  2. package/dist/adapters/supabase/auth.js +50 -0
  3. package/dist/adapters/supabase/auth.js.map +1 -1
  4. package/dist/cli/init-db.d.ts +1 -0
  5. package/dist/cli/init-db.js +121 -0
  6. package/dist/cli/init-db.js.map +1 -0
  7. package/dist/core/adapter-types.d.ts +43 -1
  8. package/dist/core/defs.d.ts +43 -2
  9. package/dist/core/defs.js +29 -2
  10. package/dist/core/defs.js.map +1 -1
  11. package/dist/core/types.d.ts +16 -1
  12. package/dist/index.client.d.ts +7 -4
  13. package/dist/index.client.js +22 -1
  14. package/dist/index.client.js.map +1 -1
  15. package/dist/index.d.ts +4 -3
  16. package/dist/index.js +11 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/react/Admin.js +83 -7
  19. package/dist/react/Admin.js.map +1 -1
  20. package/dist/react/AuthRedirectSync.d.ts +20 -0
  21. package/dist/react/AuthRedirectSync.js +38 -0
  22. package/dist/react/AuthRedirectSync.js.map +1 -0
  23. package/dist/react/GRail/client.js +5 -1
  24. package/dist/react/GRail/client.js.map +1 -1
  25. package/dist/react/Provider.js +5 -1
  26. package/dist/react/Provider.js.map +1 -1
  27. package/dist/react/Syncer.d.ts +10 -8
  28. package/dist/react/Syncer.js +8 -6
  29. package/dist/react/Syncer.js.map +1 -1
  30. package/dist/react/useAnchorRect.d.ts +17 -1
  31. package/dist/react/useAnchorRect.js +37 -9
  32. package/dist/react/useAnchorRect.js.map +1 -1
  33. package/dist/server/access.d.ts +76 -0
  34. package/dist/server/access.js +33 -0
  35. package/dist/server/access.js.map +1 -0
  36. package/dist/server/actions.d.ts +49 -8
  37. package/dist/server/actions.js +65 -10
  38. package/dist/server/actions.js.map +1 -1
  39. package/dist/server/fieldData.d.ts +4 -2
  40. package/dist/server/fieldData.js.map +1 -1
  41. package/dist/server/repeaterActions.js +1 -1
  42. package/dist/server/repeaterActions.js.map +1 -1
  43. package/dist/server/wrapper.js +9 -4
  44. package/dist/server/wrapper.js.map +1 -1
  45. package/package.json +3 -2
  46. 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
@@ -114,7 +120,15 @@ Links take `as="a"` (so `href` type-checks) and edit both the text and the URL:
114
120
  ```
115
121
 
116
122
  To edit, visit any page with `?edit=true`, sign in with a Supabase user, and
117
- 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.
118
132
 
119
133
  ### In Client Components
120
134
 
@@ -192,26 +206,42 @@ export default withGuardrail(RootLayout, {
192
206
  });
193
207
  ```
194
208
 
195
- **Invite emails (Supabase).** Inviting someone creates their account on first
196
- use and emails them a sign-in link. Three one-time steps in the Supabase
197
- 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`:
198
236
 
199
- 1. **Route** — `app/guardrail/confirm/route.ts`:
200
- ```ts
201
- export const GET = supabaseConfirmRoute({ url, anonKey });
202
- ```
203
- 2. **Auth → URL Configuration** — set Site URL to your app's origin, and add
204
- `<origin>/**` to Redirect URLs.
205
- 3. **Auth → Email Templates** — in both **Confirm signup** and **Magic Link**,
206
- point the link at the confirm route using the token hash:
207
- ```html
208
- <a href="{{ .SiteURL }}/guardrail/confirm?token_hash={{ .TokenHash }}&type=email">Sign in</a>
209
- ```
210
- The default `{{ .ConfirmationURL }}` uses a PKCE code that only works in
211
- the browser that requested it — the admin's, not the invitee's.
212
- 4. **Auth → SMTP** — Supabase's built-in mailer allows only a few emails an
213
- hour. Configure custom SMTP (Resend, Postmark, SES) before relying on
214
- 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.
215
245
 
216
246
  **Bootstrapping.** A site with no members has nobody who can add members, and
217
247
  there is deliberately no in-app way to grant yourself a role — that would mean
@@ -359,6 +389,52 @@ scratch. A visual page builder (Puck, say) is a better fit for that and can
359
389
  sit alongside `<GRepeater>` later — it would decide composition, while
360
390
  `<GRail>` still owns each block's leaf content.
361
391
 
392
+ ## Custom editing UI
393
+
394
+ `<GRail>` edits what's on the page. Some fields have nothing to click — an SEO
395
+ title, a meta description, an og:image — and some data isn't copy at all, like
396
+ stock or price. For those, build a small drawer of your own and keep the
397
+ authorization in Guardrail's hands:
398
+
399
+ - **`getFieldValue(id)`** reads any field (server-side).
400
+ - **`setFieldValue(id, value)`** writes one. A string is shorthand for a text
401
+ field; pass `{ type: "image", src, alt }` etc. for the others. It checks the
402
+ caller is an editor or admin itself, and writes the same row a `<GRail>` with
403
+ that id would, so the two stay interchangeable.
404
+ - **`isEditor()` / `isAdmin()`** are the role checks for your *own* Server
405
+ Actions. `isEditor()` is true for editors and admins; `isAdmin()` only for
406
+ admins (team management).
407
+ - **`canEdit()`** is true when the viewer holds a role *and* the site is in
408
+ edit mode — the one check for "show my editing UI." Use it in Server
409
+ Components; Client Components read the same flag as `useGRail().canEdit`.
410
+
411
+ ```tsx
412
+ // app/products/[slug]/actions.ts — a hand-rolled save for your own data
413
+ "use server";
414
+ import { isEditor } from "@guard-rail/core";
415
+
416
+ export async function restock(sku: string, quantity: number) {
417
+ if (!(await isEditor())) return { success: false, error: "Unauthorized." };
418
+ // ...write to your own stock table
419
+ }
420
+ ```
421
+
422
+ Show the trigger only when `canEdit` is true — not `isEditMode` alone, since
423
+ edit mode can be switched on by anyone visiting `?edit=true`; `canEdit` also
424
+ requires a role on the site. In a small client component:
425
+
426
+ ```tsx
427
+ "use client";
428
+ function StockEditTrigger({ children }: { children: React.ReactNode }) {
429
+ const { canEdit } = useGRail();
430
+ if (!canEdit) return <>{children}</>;
431
+ return <button onClick={openDrawer}>{children}</button>;
432
+ }
433
+ ```
434
+
435
+ `canEdit` only decides what to *show*: every action behind it must still call
436
+ `isEditor()` itself, because a hidden button is not authorization.
437
+
362
438
  ## Appearance
363
439
 
364
440
  Guardrail's own chrome (sign-in bar, toolbar, edit popups, image picker, team
@@ -434,6 +510,13 @@ const clerkAuth: GRAuthAdapter = {
434
510
  };
435
511
  ```
436
512
 
513
+ `completeAuthRedirect` and `setPassword` are optional too, and go together —
514
+ an adapter with no concept of a redirect-carried session (an OAuth provider
515
+ whose own SDK already handles its callback route, say) omits both, and links
516
+ of that kind pass through untouched. Implement `completeAuthRedirect` to
517
+ support them, returning `needsPassword: true` for a session that has none
518
+ yet; `setPassword` is what the resulting prompt calls.
519
+
437
520
  `getSession()` is the sole authorization check for writes. It must validate the
438
521
  session against the provider — never trust the presence of a cookie.
439
522
 
@@ -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":"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","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":[]}
@@ -97,6 +97,48 @@ interface GRAuthAdapter {
97
97
  */
98
98
  getSession: () => Promise<GRSession | null>;
99
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;
100
142
  }
101
143
  type AuthResult = {
102
144
  success: true;
@@ -243,4 +285,4 @@ interface GRConfig {
243
285
  prefetch?: false | GRPrefetch;
244
286
  }
245
287
 
246
- export type { AuthResult, GRAuthAdapter, GRConfig, GRDBAdapter, GRInviteEvent, GRMembershipAdapter, GRPrefetch, GRRepeaterAdapter, GRSession, GRStorageAdapter, GRStorageRow, GRUploadTarget };
288
+ export type { AuthRedirectParams, AuthResult, GRAuthAdapter, GRConfig, GRDBAdapter, GRInviteEvent, GRMembershipAdapter, GRPrefetch, GRRepeaterAdapter, GRSession, GRStorageAdapter, GRStorageRow, GRUploadTarget };
@@ -1,7 +1,48 @@
1
1
  declare const GR_COOKIES: {
2
- /** Signals intent to edit. Not a credential — carries no authority on its own. */
2
+ /**
3
+ * Signals intent to edit. Carries no authority on its own — every write
4
+ * still goes through a real, server-verified role check, independent of
5
+ * this — but it does decide whether the admin chrome (the sign-in bar, or
6
+ * the toolbar) renders at all, so it is not harmless either: a stale copy
7
+ * of it is a real, if low-stakes, exposure ("there's a CMS here") to
8
+ * whoever's browser it's sitting in next, not just its original visitor.
9
+ *
10
+ * Values: "true" (show chrome — the short-lived grace window below, for
11
+ * someone not yet authenticated to reach the sign-in form), "false"
12
+ * (explicitly suppressed, e.g. ?edit=false previewing without chrome while
13
+ * still signed in), or absent. Absent defers entirely to whether a real
14
+ * session exists (see withGuardrail) — an authenticated editor's chrome
15
+ * persists for as long as their actual session does, with no separate
16
+ * timer of its own to fall out of sync with it.
17
+ */
3
18
  readonly EDIT_MODE: "gr_edit_mode";
19
+ /**
20
+ * Set server-side the moment a redirect-carried session (an invite or
21
+ * password-recovery link) completes, so the next render — which has no
22
+ * other way to know that just happened — prompts to set a password
23
+ * instead of showing the normal toolbar. Cleared once they do, or once
24
+ * they explicitly skip.
25
+ */
26
+ readonly NEEDS_PASSWORD: "gr_needs_password";
4
27
  };
28
+ /**
29
+ * How long the edit-mode cookie's "true"/"false" values last before
30
+ * reverting to the session-derived default. Short on purpose: it only ever
31
+ * needs to outlive the handful of clicks between landing on ?edit=true and
32
+ * actually typing a password (or, for ?edit=false, a quick chrome-free
33
+ * preview) — not a whole working session, which persists through real auth
34
+ * instead. Bounds how long a stray copy of this cookie can show anything to
35
+ * whoever's browser it's left in, self-healing without needing an explicit
36
+ * logout.
37
+ */
38
+ declare const GR_EDIT_MODE_TTL_SECONDS: number;
39
+ /**
40
+ * How long the set-a-password prompt stays up after an invite/recovery link
41
+ * completes. Longer than the edit-mode window above — picking a password
42
+ * takes more than one click — but still bounded, rather than nagging
43
+ * indefinitely if it's skipped and the cookie is somehow never cleared.
44
+ */
45
+ declare const GR_NEEDS_PASSWORD_TTL_SECONDS: number;
5
46
  /**
6
47
  * Dedicated Postgres schema, not `public`. A name prefix alone only guards
7
48
  * against accidental collisions; it does nothing against a `supabase db
@@ -19,4 +60,4 @@ declare const GR_TABLE = "gr_content";
19
60
  declare const GR_MEMBERS_TABLE = "gr_members";
20
61
  declare const GR_REPEATER_TABLE = "gr_repeater_items";
21
62
 
22
- export { GR_COOKIES, GR_MEMBERS_TABLE, GR_REPEATER_TABLE, GR_SCHEMA, GR_TABLE };
63
+ export { GR_COOKIES, GR_EDIT_MODE_TTL_SECONDS, GR_MEMBERS_TABLE, GR_NEEDS_PASSWORD_TTL_SECONDS, GR_REPEATER_TABLE, GR_SCHEMA, GR_TABLE };
package/dist/core/defs.js CHANGED
@@ -1,14 +1,41 @@
1
1
  const GR_COOKIES = {
2
- /** Signals intent to edit. Not a credential — carries no authority on its own. */
3
- EDIT_MODE: "gr_edit_mode"
2
+ /**
3
+ * Signals intent to edit. Carries no authority on its own — every write
4
+ * still goes through a real, server-verified role check, independent of
5
+ * this — but it does decide whether the admin chrome (the sign-in bar, or
6
+ * the toolbar) renders at all, so it is not harmless either: a stale copy
7
+ * of it is a real, if low-stakes, exposure ("there's a CMS here") to
8
+ * whoever's browser it's sitting in next, not just its original visitor.
9
+ *
10
+ * Values: "true" (show chrome — the short-lived grace window below, for
11
+ * someone not yet authenticated to reach the sign-in form), "false"
12
+ * (explicitly suppressed, e.g. ?edit=false previewing without chrome while
13
+ * still signed in), or absent. Absent defers entirely to whether a real
14
+ * session exists (see withGuardrail) — an authenticated editor's chrome
15
+ * persists for as long as their actual session does, with no separate
16
+ * timer of its own to fall out of sync with it.
17
+ */
18
+ EDIT_MODE: "gr_edit_mode",
19
+ /**
20
+ * Set server-side the moment a redirect-carried session (an invite or
21
+ * password-recovery link) completes, so the next render — which has no
22
+ * other way to know that just happened — prompts to set a password
23
+ * instead of showing the normal toolbar. Cleared once they do, or once
24
+ * they explicitly skip.
25
+ */
26
+ NEEDS_PASSWORD: "gr_needs_password"
4
27
  };
28
+ const GR_EDIT_MODE_TTL_SECONDS = 10 * 60;
29
+ const GR_NEEDS_PASSWORD_TTL_SECONDS = 30 * 60;
5
30
  const GR_SCHEMA = "guardrail";
6
31
  const GR_TABLE = "gr_content";
7
32
  const GR_MEMBERS_TABLE = "gr_members";
8
33
  const GR_REPEATER_TABLE = "gr_repeater_items";
9
34
  export {
10
35
  GR_COOKIES,
36
+ GR_EDIT_MODE_TTL_SECONDS,
11
37
  GR_MEMBERS_TABLE,
38
+ GR_NEEDS_PASSWORD_TTL_SECONDS,
12
39
  GR_REPEATER_TABLE,
13
40
  GR_SCHEMA,
14
41
  GR_TABLE
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/defs.ts"],"sourcesContent":["export const GR_COOKIES = {\n /** Signals intent to edit. Not a credential — carries no authority on its own. */\n EDIT_MODE: \"gr_edit_mode\",\n} as const;\n\n/**\n * Dedicated Postgres schema, not `public`. A name prefix alone only guards\n * against accidental collisions; it does nothing against a `supabase db\n * reset`, a broad `DROP ... CASCADE`, or a migration tool that diffs and\n * \"reconciles\" everything in `public` as if it owns that namespace. A\n * separate schema keeps Guardrail's tables outside that blast radius —\n * the same reason auth.* and storage.* are their own schemas, not public.*.\n *\n * Requires \"guardrail\" to be added under Settings > API > Exposed schemas in\n * the Supabase dashboard — PostgREST only serves schemas on that list, and no\n * migration can add itself to it.\n */\nexport const GR_SCHEMA = \"guardrail\";\n\nexport const GR_TABLE = \"gr_content\";\nexport const GR_MEMBERS_TABLE = \"gr_members\";\nexport const GR_REPEATER_TABLE = \"gr_repeater_items\";\n"],"mappings":"AAAO,MAAM,aAAa;AAAA;AAAA,EAExB,WAAW;AACb;AAcO,MAAM,YAAY;AAElB,MAAM,WAAW;AACjB,MAAM,mBAAmB;AACzB,MAAM,oBAAoB;","names":[]}
1
+ {"version":3,"sources":["../../src/core/defs.ts"],"sourcesContent":["export const GR_COOKIES = {\n /**\n * Signals intent to edit. Carries no authority on its own — every write\n * still goes through a real, server-verified role check, independent of\n * this — but it does decide whether the admin chrome (the sign-in bar, or\n * the toolbar) renders at all, so it is not harmless either: a stale copy\n * of it is a real, if low-stakes, exposure (\"there's a CMS here\") to\n * whoever's browser it's sitting in next, not just its original visitor.\n *\n * Values: \"true\" (show chrome — the short-lived grace window below, for\n * someone not yet authenticated to reach the sign-in form), \"false\"\n * (explicitly suppressed, e.g. ?edit=false previewing without chrome while\n * still signed in), or absent. Absent defers entirely to whether a real\n * session exists (see withGuardrail) — an authenticated editor's chrome\n * persists for as long as their actual session does, with no separate\n * timer of its own to fall out of sync with it.\n */\n EDIT_MODE: \"gr_edit_mode\",\n /**\n * Set server-side the moment a redirect-carried session (an invite or\n * password-recovery link) completes, so the next render — which has no\n * other way to know that just happened — prompts to set a password\n * instead of showing the normal toolbar. Cleared once they do, or once\n * they explicitly skip.\n */\n NEEDS_PASSWORD: \"gr_needs_password\",\n} as const;\n\n/**\n * How long the edit-mode cookie's \"true\"/\"false\" values last before\n * reverting to the session-derived default. Short on purpose: it only ever\n * needs to outlive the handful of clicks between landing on ?edit=true and\n * actually typing a password (or, for ?edit=false, a quick chrome-free\n * preview) — not a whole working session, which persists through real auth\n * instead. Bounds how long a stray copy of this cookie can show anything to\n * whoever's browser it's left in, self-healing without needing an explicit\n * logout.\n */\nexport const GR_EDIT_MODE_TTL_SECONDS = 10 * 60;\n\n/**\n * How long the set-a-password prompt stays up after an invite/recovery link\n * completes. Longer than the edit-mode window above — picking a password\n * takes more than one click — but still bounded, rather than nagging\n * indefinitely if it's skipped and the cookie is somehow never cleared.\n */\nexport const GR_NEEDS_PASSWORD_TTL_SECONDS = 30 * 60;\n\n/**\n * Dedicated Postgres schema, not `public`. A name prefix alone only guards\n * against accidental collisions; it does nothing against a `supabase db\n * reset`, a broad `DROP ... CASCADE`, or a migration tool that diffs and\n * \"reconciles\" everything in `public` as if it owns that namespace. A\n * separate schema keeps Guardrail's tables outside that blast radius —\n * the same reason auth.* and storage.* are their own schemas, not public.*.\n *\n * Requires \"guardrail\" to be added under Settings > API > Exposed schemas in\n * the Supabase dashboard — PostgREST only serves schemas on that list, and no\n * migration can add itself to it.\n */\nexport const GR_SCHEMA = \"guardrail\";\n\nexport const GR_TABLE = \"gr_content\";\nexport const GR_MEMBERS_TABLE = \"gr_members\";\nexport const GR_REPEATER_TABLE = \"gr_repeater_items\";\n"],"mappings":"AAAO,MAAM,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAiBxB,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQX,gBAAgB;AAClB;AAYO,MAAM,2BAA2B,KAAK;AAQtC,MAAM,gCAAgC,KAAK;AAc3C,MAAM,YAAY;AAElB,MAAM,WAAW;AACjB,MAAM,mBAAmB;AACzB,MAAM,oBAAoB;","names":[]}
@@ -113,9 +113,20 @@ interface GRAuthCapabilities {
113
113
  interface GRState {
114
114
  isAuth: boolean;
115
115
  isEditMode: boolean;
116
+ /** A redirect-carried session (an invite or recovery link) just completed
117
+ * and has no password yet — see completeAuthRedirect. */
118
+ needsPassword: boolean;
116
119
  siteId: string;
117
120
  /** Null when the signed-in user holds no role on this site. */
118
121
  role: GRRole | null;
122
+ /**
123
+ * The viewer holds a role on this site AND the site is in edit mode — the
124
+ * one flag to gate custom editing UI on. Equivalent to
125
+ * `isEditMode && role !== null`; server-side, the same answer is canEdit().
126
+ * Decides what to *show* only — a Server Action behind it must still call
127
+ * isEditor() itself.
128
+ */
129
+ canEdit: boolean;
119
130
  email?: string;
120
131
  auth: GRAuthCapabilities;
121
132
  /** A storage adapter is configured, so images can be uploaded and picked. */
@@ -145,5 +156,9 @@ type ActionState = {
145
156
  email?: string;
146
157
  password?: string;
147
158
  };
159
+ type SetPasswordState = {
160
+ success?: boolean;
161
+ error?: string;
162
+ };
148
163
 
149
- export type { ActionState, GRAsset, GRAuthCapabilities, GRContent, GRMember, GRRepeaterItem, GRRole, GRState, GRViewer, HTMLKeys, NextImageExtras, OverrideRow, OverrideType, OverrideValue, PolymorphicProps };
164
+ export type { ActionState, GRAsset, GRAuthCapabilities, GRContent, GRMember, GRRepeaterItem, GRRole, GRState, GRViewer, HTMLKeys, NextImageExtras, OverrideRow, OverrideType, OverrideValue, PolymorphicProps, SetPasswordState };
@@ -1,10 +1,10 @@
1
1
  export { GRail } from './react/GRail/universal.js';
2
2
  export { useGRail } from './react/Provider.js';
3
- export { createAssetUpload, inviteMember, listAssets, listTeam, removeMember, updateMemberRole } from './server/actions.js';
3
+ export { createAssetUpload, inviteMember, listAssets, listTeam, removeMember, setFieldValue, setPasswordAction, skipSetPasswordAction, updateMemberRole } from './server/actions.js';
4
4
  export { addRepeaterItem, removeRepeaterItem, reorderRepeaterItems } from './server/repeaterActions.js';
5
5
  export { GRepeaterProps, RepeaterItemProps } from './react/Repeater/index.js';
6
- export { GRAsset, GRMember, GRRepeaterItem, GRRole, GRState, GRViewer, HTMLKeys, OverrideRow, OverrideType, OverrideValue, PolymorphicProps } from './core/types.js';
7
- export { AuthResult, GRAuthAdapter, GRConfig, GRDBAdapter, GRInviteEvent, GRMembershipAdapter, GRPrefetch, GRRepeaterAdapter, GRSession, GRStorageAdapter, GRStorageRow, GRUploadTarget } from './core/adapter-types.js';
6
+ export { ActionState, GRAsset, GRMember, GRRepeaterItem, GRRole, GRState, GRViewer, HTMLKeys, OverrideRow, OverrideType, OverrideValue, PolymorphicProps, SetPasswordState } from './core/types.js';
7
+ export { AuthRedirectParams, AuthResult, GRAuthAdapter, GRConfig, GRDBAdapter, GRInviteEvent, GRMembershipAdapter, GRPrefetch, GRRepeaterAdapter, GRSession, GRStorageAdapter, GRStorageRow, GRUploadTarget } from './core/adapter-types.js';
8
8
  import 'react/jsx-runtime';
9
9
  import 'react';
10
10
 
@@ -14,5 +14,8 @@ declare const GRepeater: (..._args: unknown[]) => never;
14
14
  declare const listRepeaterItems: (..._args: unknown[]) => never;
15
15
  declare const getRepeaterItemBySlug: (..._args: unknown[]) => never;
16
16
  declare const getFieldValue: (..._args: unknown[]) => never;
17
+ declare const isAdmin: (..._args: unknown[]) => never;
18
+ declare const isEditor: (..._args: unknown[]) => never;
19
+ declare const canEdit: (..._args: unknown[]) => never;
17
20
 
18
- export { GRepeater, configureGuardrail, getFieldValue, getRepeaterItemBySlug, listRepeaterItems, withGuardrail };
21
+ export { GRepeater, canEdit, configureGuardrail, getFieldValue, getRepeaterItemBySlug, isAdmin, isEditor, listRepeaterItems, withGuardrail };