@guard-rail/core 0.10.0 → 0.12.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 (42) hide show
  1. package/README.md +135 -4
  2. package/dist/adapters/supabase/db.js +33 -0
  3. package/dist/adapters/supabase/db.js.map +1 -1
  4. package/dist/cli/doctor.js +175 -6
  5. package/dist/cli/doctor.js.map +1 -1
  6. package/dist/core/adapter-types.d.ts +35 -2
  7. package/dist/core/csp.d.ts +32 -0
  8. package/dist/core/csp.js +26 -0
  9. package/dist/core/csp.js.map +1 -0
  10. package/dist/index.client.d.ts +3 -2
  11. package/dist/index.client.js +8 -1
  12. package/dist/index.client.js.map +1 -1
  13. package/dist/index.d.ts +3 -2
  14. package/dist/index.js +8 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/react/Admin.d.ts +5 -3
  17. package/dist/react/Admin.js +29 -41
  18. package/dist/react/Admin.js.map +1 -1
  19. package/dist/react/AuthDrawer.js +22 -34
  20. package/dist/react/AuthDrawer.js.map +1 -1
  21. package/dist/react/GRail/client.js +95 -56
  22. package/dist/react/GRail/client.js.map +1 -1
  23. package/dist/react/Icons.d.ts +3 -1
  24. package/dist/react/Icons.js +5 -0
  25. package/dist/react/Icons.js.map +1 -1
  26. package/dist/react/MediaPicker.js +12 -16
  27. package/dist/react/MediaPicker.js.map +1 -1
  28. package/dist/react/Repeater/client.js +12 -13
  29. package/dist/react/Repeater/client.js.map +1 -1
  30. package/dist/react/Team.d.ts +1 -7
  31. package/dist/react/Team.js +21 -46
  32. package/dist/react/Team.js.map +1 -1
  33. package/dist/react/guardrail.css +563 -0
  34. package/dist/server/actions.d.ts +19 -2
  35. package/dist/server/actions.js +35 -0
  36. package/dist/server/actions.js.map +1 -1
  37. package/package.json +11 -10
  38. package/supabase/admin/check-grants.sql +12 -3
  39. package/supabase/migrations/20261103000000_content_history.sql +211 -0
  40. package/dist/react/styles.d.ts +0 -504
  41. package/dist/react/styles.js +0 -500
  42. package/dist/react/styles.js.map +0 -1
package/README.md CHANGED
@@ -435,6 +435,43 @@ scratch. A visual page builder (Puck, say) is a better fit for that and can
435
435
  sit alongside `<GRepeater>` later — it would decide composition, while
436
436
  `<GRail>` still owns each block's leaf content.
437
437
 
438
+ ## Reverting a change
439
+
440
+ Every editor popup has a circular-arrow **revert** button next to save and close.
441
+ It steps one version back:
442
+
443
+ - If the field was edited more than once, it restores the **previous saved value**
444
+ — the button reads "Revert to the version from Oct 28".
445
+ - If there's nothing earlier, it removes the saved value so the field shows the
446
+ **default written in the page** — the button reads "Reset to default".
447
+ - If the field has nothing saved, the button isn't shown.
448
+
449
+ The field updates in place, and everyone else sees the change straight away. There's
450
+ no confirmation step; the label says what it will do.
451
+
452
+ Behind it, each save that changes a field's value moves the old value into a
453
+ separate `gr_content_history` table, which only editors of the site can read (the
454
+ public `gr_content` table still holds just the current value). Saving the same value
455
+ again records nothing. History is kept for the newest **20 versions of each field**
456
+ and for **90 days**, whichever is less, and is cleaned up as part of saving — no
457
+ scheduled job. Removing a field (a deleted repeater item, say) removes its history.
458
+
459
+ Things to know:
460
+
461
+ - **Images:** reverting restores the earlier reference. Uploaded files aren't
462
+ deleted, so they stay in the media picker for reuse.
463
+ - **Repeaters:** a field inside an item reverts like any other. Adding, removing and
464
+ reordering items isn't part of history.
465
+ - **Existing content has no history yet.** Revert becomes available after a field's
466
+ next edit.
467
+ - **It needs the `content_history` migration.** Without it, the button just doesn't
468
+ appear. `pnpm exec guardrail-init-db --push`, or `guardrail-doctor --fix --push`,
469
+ applies it.
470
+
471
+ Building your own editing UI? `getRevertInfo(id)` reports what a revert would do
472
+ (`{ kind: "previous" | "default" | "none", at }`) and `revertField(id)` does it. Both
473
+ check the caller is an editor or admin.
474
+
438
475
  ## Custom editing UI
439
476
 
440
477
  `<GRail>` edits what's on the page. Some fields have nothing to click — an SEO
@@ -447,6 +484,8 @@ authorization in Guardrail's hands:
447
484
  field; pass `{ type: "image", src, alt }` etc. for the others. It checks the
448
485
  caller is an editor or admin itself, and writes the same row a `<GRail>` with
449
486
  that id would, so the two stay interchangeable.
487
+ - **`getRevertInfo(id)` / `revertField(id)`** report and perform a one-step revert
488
+ (see "Reverting a change").
450
489
  - **`isEditor()` / `isAdmin()`** are the role checks for your *own* Server
451
490
  Actions. `isEditor()` is true for editors and admins; `isAdmin()` only for
452
491
  admins (team management).
@@ -496,6 +535,54 @@ the outline ignores the pointer so your overlaid content stays fully clickable,
496
535
  and the handle is the only thing that opens the editor (it also won't trigger a
497
536
  surrounding `<Link>`).
498
537
 
538
+ ### Theming
539
+
540
+ The chrome is a real stylesheet (`guardrail.css`) that the package imports itself —
541
+ there is nothing to link by hand, and no inline styles, so it works under a strict
542
+ Content-Security-Policy. Every color is a CSS variable, so restyling it is a plain
543
+ `:root` rule in your own CSS:
544
+
545
+ ```css
546
+ :root {
547
+ --gr-accent: #7c3aed;
548
+ --gr-field-outline: #7c3aed;
549
+ --gr-overlay-bg: #1e1b4b;
550
+ --gr-overlay-fg: #e0e7ff;
551
+ }
552
+ ```
553
+
554
+ No `!important` and no selector tricks: Guardrail's defaults are declared with zero
555
+ specificity, so your `:root` rule wins whichever stylesheet loads first.
556
+
557
+ There are two layers. Change a **base** color to retheme everything, or set an
558
+ **overlay** color to restyle only the floating surfaces (the sign-in bar, toolbar,
559
+ edit popups, drawers and image picker). The overlay variables default to the base
560
+ ones, so you only set what you want to differ.
561
+
562
+ | Variable | What it colors |
563
+ |---|---|
564
+ | `--gr-overlay-bg` | Background of the sign-in bar, popups, drawers and picker |
565
+ | `--gr-overlay-bg-glass` | Background of the toolbar and the inline editing bar (translucent) |
566
+ | `--gr-overlay-fg` | Text on all of the above |
567
+ | `--gr-overlay-border` | Their borders |
568
+ | `--gr-field-outline` | The dashed outline around an editable field |
569
+ | `--gr-field-outline-active` | The solid outline while a field is being edited |
570
+ | `--gr-accent`, `--gr-accent-soft`, `--gr-accent-text`, `--gr-on-accent` | Highlights, and text on an accent background |
571
+ | `--gr-primary-bg`, `--gr-primary-fg` | Primary buttons |
572
+ | `--gr-danger`, `--gr-label`, `--gr-badge-bg`, `--gr-badge-fg` | Errors, small labels, the "pending" badge |
573
+ | `--gr-scrim`, `--gr-scrim-modal` | The dimmed backdrop behind drawers and the picker |
574
+ | `--gr-bg`, `--gr-bg-glass`, `--gr-fg`, `--gr-fg2`, `--gr-muted`, `--gr-subtle`, `--gr-border`, `--gr-border-strong`, `--gr-border-soft`, `--gr-input-bg`, `--gr-soft`, `--gr-shadow` | The base palette the overlay variables fall back to, plus inputs and secondary text |
575
+
576
+ Set any of them inside `@media (prefers-color-scheme: dark)` if you want a
577
+ different value in dark mode — the chrome follows the visitor's OS setting. The
578
+ outline around an editable text field is `[data-gr-field="text"]` (and
579
+ `[data-gr-editing]` while it's being edited), so you can restyle or hide it with a
580
+ rule of your own; to hide it, set `--gr-field-outline: transparent`. Every class
581
+ Guardrail adds is prefixed `gr-`.
582
+
583
+ If your setup doesn't bundle CSS imported from a package, import it yourself once,
584
+ in your root layout: `import "@guard-rail/core/styles.css";`.
585
+
499
586
  ## Extending the toolbar
500
587
 
501
588
  The bundled toolbar (edit indicator, Team, Exit edit mode) has one extension point:
@@ -576,6 +663,13 @@ work inside Client Components (see above): one round trip returning the site's r
576
663
  and whether the list was cut off at `limit`. An adapter without it still works
577
664
  fully for Server Components — client-rendered fields just show their JSX fallback.
578
665
 
666
+ `getRevertInfo(pathKey, siteId)` and `revertOverride(pathKey, siteId)` are optional and
667
+ go together: they back the revert button (see "Reverting a change"). `getRevertInfo`
668
+ returns `{ kind: "previous" | "default" | "none", at? }`; `revertOverride` restores the
669
+ previous saved value (returning it) or removes the saved value, and reports which.
670
+ An adapter without them simply never shows the button — keeping the history is up to
671
+ your backend.
672
+
579
673
  `getOverrides(pathKeys, siteId)` is batched by design — it receives every key a
580
674
  render pass needs, already deduplicated. Adapters map their own storage naming
581
675
  at this boundary; core speaks `pathKey` / `siteId` only.
@@ -612,14 +706,15 @@ By default it only reads. It checks:
612
706
  `supabase/migrations/`, that `next.config` sets anti-framing headers, and that
613
707
  the root layout uses `withGuardrail()`.
614
708
  - **Database:** that the `guardrail` schema is exposed, the tables and functions
615
- exist, rate limiting works, and what your project's sign-up settings are
709
+ exist (including the revert history), rate limiting works, and what your project's sign-up settings are
616
710
  (whether **Confirm email** is on).
617
711
 
618
712
  | Flag | What it adds |
619
713
  |---|---|
620
714
  | `--fix` | Repairs what can be repaired in local files: copies missing migrations, adds a `GUARDRAIL_SITE_URL` note to `.env.example` / `.env.local`, adds baseline security headers to `next.config`. It never changes secrets or Supabase dashboard settings, and re-running it is safe. Add `--push` to also run `supabase db push`. |
621
- | `--app-url=http://localhost:3000` | Also checks the response headers of a running copy of your site. |
622
- | `--e2e` | Runs an end-to-end suite against the database. It creates a throwaway user, a few rows and a test image, then exercises sign-in, claiming an invitation, row-level security (an editor can write their own site and nobody else's; visitors can read but not write), the last-admin guard, rate limiting and image uploads, and deletes everything it made. It asks before touching a database that isn't local (`--yes` skips the question), and needs the service-role key. |
715
+ | `--app-url=http://localhost:3000` | Also checks the response headers of a running copy of your site, including that Guardrail's stylesheet reached the page and that its Content-Security-Policy lets Guardrail's uploads and images through. |
716
+ | `--e2e` | Runs an end-to-end suite against the database. It creates a throwaway user, a few rows and a test image, then exercises sign-in, claiming an invitation, row-level security (an editor can write their own site and nobody else's; visitors can read but not write), reverting and the history limits, the last-admin guard, rate limiting and image uploads, and deletes everything it made. It asks before touching a database that isn't local (`--yes` skips the question), and needs the service-role key. |
717
+ | `--csp` | Prints a starter `Content-Security-Policy-Report-Only` header built from your `SUPABASE_URL` and exits. |
623
718
  | `--offline` | Skips the advisory lookup (the only network call besides your own Supabase project). Package names and versions are what's sent otherwise, as with `pnpm audit`; offline, Next.js is checked against a built-in list that may be out of date. |
624
719
  | `--production` | Treats production-only requirements as failures instead of warnings. |
625
720
  | `--strict` / `--json` | Exit non-zero on warnings too; machine-readable output for CI. |
@@ -737,6 +832,42 @@ Run `pnpm exec guardrail-doctor --production` to check everything below.
737
832
  - **Add security headers** in `next.config`: at least a `Content-Security-Policy`
738
833
  with `frame-ancestors 'self'` (so the editing chrome can't be framed by another
739
834
  site), `Strict-Transport-Security`, and `X-Content-Type-Options: nosniff`.
835
+ `guardrail-doctor --fix` adds this baseline. For a policy that also restricts
836
+ scripts and styles, see "Content Security Policy" below.
837
+
838
+ ### Content Security Policy
839
+
840
+ Guardrail works under a strict policy — no `'unsafe-inline'` for styles and no
841
+ nonce. Its chrome is a stylesheet, and it renders no inline scripts and uses no
842
+ `eval`. A strict policy only has to allow the two places the browser talks to
843
+ Supabase directly:
844
+
845
+ | Directive | Allow | Why |
846
+ |---|---|---|
847
+ | `connect-src` | `'self'` and your Supabase origin | image uploads are sent straight from the browser to Supabase storage |
848
+ | `img-src` | `'self'` and your Supabase origin | uploaded images and the media picker's thumbnails load from storage |
849
+ | `style-src` | `'self'` | nothing more |
850
+ | `frame-ancestors` | `'self'` | so the editing chrome can't be framed by another site |
851
+
852
+ Everything else — reads, saves, sign-in — goes through your own server, i.e.
853
+ `'self'`. If you build the header in code, `guardrailCspDirectives()` returns
854
+ these for you to merge into your own:
855
+
856
+ ```ts
857
+ import { guardrailCspDirectives, cspString } from "@guard-rail/core";
858
+
859
+ const gr = guardrailCspDirectives({ supabaseUrl: process.env.SUPABASE_URL });
860
+ // gr["connect-src"] -> ["'self'", "https://abc.supabase.co"]
861
+ // cspString(gr) -> "style-src 'self'; img-src 'self' https://abc.supabase.co; …"
862
+ ```
863
+
864
+ To get started, `pnpm exec guardrail-doctor --csp` prints a starter
865
+ `Content-Security-Policy-Report-Only` header built from your `SUPABASE_URL`: send it
866
+ report-only first, tune it for what your own site loads (analytics, fonts,
867
+ embeds), then enforce it. Once the site is running,
868
+ `pnpm exec guardrail-doctor --app-url=https://your-site.com` checks that the live
869
+ policy lets Guardrail's uploads and images through. A script nonce, if you use
870
+ one, is set by your site (Next.js does it from a proxy); Guardrail doesn't need it.
740
871
 
741
872
  ## Known limitations
742
873
 
@@ -748,4 +879,4 @@ Run `pnpm exec guardrail-doctor --production` to check everything below.
748
879
  - `withGuardrail` reads cookies in the root layout, which opts routes into
749
880
  dynamic rendering. See the repo README for the plan.
750
881
  - Saving revalidates the entire layout tree rather than the affected route.
751
- - No drafts, revisions, or scheduled publishing. Saves are live.
882
+ - No drafts or scheduled publishing. Saves are live; an editor can step back one version (see "Reverting a change"), but there is no full revision browser.
@@ -8,6 +8,9 @@ function toOverrideRow(row) {
8
8
  value: typeof row.value === "string" ? JSON.parse(row.value) : row.value
9
9
  };
10
10
  }
11
+ function isMissingFunction(error) {
12
+ return error.code === "PGRST202" || error.code === "42883" || /could not find the function/i.test(error.message ?? "");
13
+ }
11
14
  function supabaseDB(creds) {
12
15
  assertCredentials(creds);
13
16
  return {
@@ -45,6 +48,36 @@ function supabaseDB(creds) {
45
48
  );
46
49
  return error ? { success: false, error: error.message } : { success: true };
47
50
  },
51
+ // Reverting runs in the database (gr_revert_content / gr_revert_info) so a
52
+ // step back is one atomic move, and the editor's own access rules apply.
53
+ async getRevertInfo(pathKey, siteId) {
54
+ const client = await sessionClient(creds);
55
+ const { data, error } = await client.schema(GR_SCHEMA).rpc("gr_revert_info", { p_site: siteId, p_key: pathKey });
56
+ if (error) {
57
+ if (!isMissingFunction(error)) {
58
+ console.error("Guardrail (Supabase): gr_revert_info failed", error.message);
59
+ }
60
+ return { kind: "none" };
61
+ }
62
+ const info = data;
63
+ return info && ["previous", "default", "none"].includes(info.kind) ? info : { kind: "none" };
64
+ },
65
+ async revertOverride(pathKey, siteId) {
66
+ const client = await sessionClient(creds);
67
+ const { data, error } = await client.schema(GR_SCHEMA).rpc("gr_revert_content", { p_site: siteId, p_key: pathKey });
68
+ if (error) {
69
+ return {
70
+ success: false,
71
+ error: isMissingFunction(error) ? "Reverting needs the latest database migration \u2014 run `pnpm exec guardrail-init-db --push`." : error.code === "42501" ? "You don't have permission to revert this." : error.message
72
+ };
73
+ }
74
+ const result = data;
75
+ if (result?.action === "restored" && result.value) {
76
+ const value = typeof result.value === "string" ? JSON.parse(result.value) : result.value;
77
+ return { success: true, action: "restored", value };
78
+ }
79
+ return { success: true, action: result?.action === "reset" ? "reset" : "none" };
80
+ },
48
81
  async deleteOverrides(pathKeyPrefix, siteId) {
49
82
  const client = await sessionClient(creds);
50
83
  const { error } = await client.schema(GR_SCHEMA).from(GR_TABLE).delete().eq("site_id", siteId).like("path_key", `${pathKeyPrefix}%`);
@@ -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 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":[]}
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, GRRevertInfo, GRRevertResult, 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\n/** PostgREST's \"no such function\" (PGRST202) or Postgres' own (42883): the migration isn't applied. */\nfunction isMissingFunction(error: { code?: string; message?: string }): boolean {\n return error.code === \"PGRST202\" || error.code === \"42883\" || /could not find the function/i.test(error.message ?? \"\");\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 // Reverting runs in the database (gr_revert_content / gr_revert_info) so a\n // step back is one atomic move, and the editor's own access rules apply.\n async getRevertInfo(pathKey: string, siteId: string): Promise<GRRevertInfo> {\n const client = await sessionClient(creds);\n const { data, error } = await client\n .schema(GR_SCHEMA)\n .rpc(\"gr_revert_info\", { p_site: siteId, p_key: pathKey });\n if (error) {\n // Not migrated yet: no history to offer, so just don't offer one.\n if (!isMissingFunction(error)) {\n console.error(\"Guardrail (Supabase): gr_revert_info failed\", error.message);\n }\n return { kind: \"none\" };\n }\n const info = data as GRRevertInfo | null;\n return info && [\"previous\", \"default\", \"none\"].includes(info.kind) ? info : { kind: \"none\" };\n },\n\n async revertOverride(pathKey: string, siteId: string): Promise<GRRevertResult> {\n const client = await sessionClient(creds);\n const { data, error } = await client\n .schema(GR_SCHEMA)\n .rpc(\"gr_revert_content\", { p_site: siteId, p_key: pathKey });\n if (error) {\n return {\n success: false,\n error: isMissingFunction(error)\n ? \"Reverting needs the latest database migration — run `pnpm exec guardrail-init-db --push`.\"\n : error.code === \"42501\"\n ? \"You don't have permission to revert this.\"\n : error.message,\n };\n }\n const result = data as { action?: string; value?: OverrideValue | string } | null;\n if (result?.action === \"restored\" && result.value) {\n const value = typeof result.value === \"string\" ? (JSON.parse(result.value) as OverrideValue) : result.value;\n return { success: true, action: \"restored\", value };\n }\n return { success: true, action: result?.action === \"reset\" ? \"reset\" : \"none\" };\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":"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","names":[]}
@@ -14,6 +14,7 @@ import { createRequire } from "node:module";
14
14
  import { createInterface } from "node:readline/promises";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { GR_SCHEMA, GR_MEMBERS_TABLE } from "../core/defs.js";
17
+ import { cspString, guardrailCspDirectives } from "../core/csp.js";
17
18
  const NEXT_MIN_PATCHED = {
18
19
  15: [15, 5, 28],
19
20
  16: [16, 4, 1]
@@ -66,6 +67,9 @@ Options:
66
67
  versions of next, @supabase/* and @guard-rail/core are
67
68
  checked against its published security advisories (only
68
69
  package names and versions are sent, as \`pnpm audit\` does).
70
+ --csp Print a starter Content-Security-Policy-Report-Only header
71
+ for this site (built from SUPABASE_URL) and exit. Add it
72
+ report-only first, watch the reports, then enforce.
69
73
  --production Treat production-only requirements (like
70
74
  GUARDRAIL_SITE_URL) as failures rather than warnings.
71
75
  --strict Exit non-zero on warnings too.
@@ -101,7 +105,7 @@ function parseFlags(argv) {
101
105
  unknown.push(arg);
102
106
  }
103
107
  }
104
- const known = /* @__PURE__ */ new Set(["fix", "push", "e2e", "yes", "production", "strict", "json", "offline", "help", "h"]);
108
+ const known = /* @__PURE__ */ new Set(["fix", "push", "e2e", "yes", "production", "strict", "json", "offline", "csp", "help", "h"]);
105
109
  for (const f of flags) if (!known.has(f)) unknown.push(`--${f}`);
106
110
  for (const k of Object.keys(values)) if (k !== "app-url") unknown.push(`--${k}`);
107
111
  return { flags, values, unknown };
@@ -541,16 +545,19 @@ async function checkDatabase(ctx, sb) {
541
545
  }
542
546
  const admin = sb.createClient(ctx.url, ctx.serviceKey, { auth: { persistSession: false, autoRefreshToken: false } });
543
547
  const gr = () => admin.schema(GR_SCHEMA);
544
- for (const table of [GR_MEMBERS_TABLE, "gr_content", "gr_repeater_items", "gr_rate_limits"]) {
545
- const { error: error2 } = await gr().from(table).select("*", { head: true, count: "exact" }).limit(1);
548
+ for (const table of [GR_MEMBERS_TABLE, "gr_content", "gr_repeater_items", "gr_rate_limits", "gr_content_history"]) {
549
+ const { error: error2 } = await gr().from(table).select("*").limit(1);
546
550
  if (!error2) pass(`Table ${table}`);
547
551
  else if (table === "gr_rate_limits") warn("Table gr_rate_limits is missing", "Rate limits are counted in memory until the migration is applied.", "Run: pnpm exec guardrail-init-db --push");
552
+ else if (table === "gr_content_history") warn("Table gr_content_history is missing", "Editors won't be offered a revert button until the migration is applied.", "Run: pnpm exec guardrail-init-db --push");
548
553
  else fail(`Table ${table} isn't readable`, error2.message, "Run: pnpm exec guardrail-init-db --push");
549
554
  }
550
555
  const probes = [
551
556
  ["gr_confirmed_email", {}, "Confirmed-email identity check"],
552
557
  ["gr_claim_invite", { target_site: "doctor-probe" }, "Invite-claim function"],
553
- ["gr_ensure_bucket", { target_bucket: "gr-doctor-probe" }, "Media bucket function"]
558
+ ["gr_ensure_bucket", { target_bucket: "gr-doctor-probe" }, "Media bucket function"],
559
+ ["gr_revert_content", { p_site: "doctor-probe", p_key: "doctor.probe" }, "Revert function"],
560
+ ["gr_revert_info", { p_site: "doctor-probe", p_key: "doctor.probe" }, "Revert-info function"]
554
561
  ];
555
562
  for (const [fn, args, label] of probes) {
556
563
  const { error: error2 } = await gr().rpc(fn, args);
@@ -575,7 +582,71 @@ async function checkDatabase(ctx, sb) {
575
582
  info("Admin guard (can't delete the last admin)", "Verified by --e2e; it's a trigger, which can't be probed read-only.");
576
583
  return true;
577
584
  }
578
- async function checkLiveSite(appUrl) {
585
+ function parseCsp(value) {
586
+ const out = {};
587
+ for (const part of value.split(";")) {
588
+ const [name, ...sources] = part.trim().split(/\s+/);
589
+ if (name) out[name.toLowerCase()] = sources;
590
+ }
591
+ return out;
592
+ }
593
+ function effectiveSources(csp, name) {
594
+ return csp[name] ?? csp["default-src"] ?? null;
595
+ }
596
+ function allowsOrigin(sources, origin) {
597
+ if (sources === null) return true;
598
+ const target = new URL(origin);
599
+ return sources.some((raw) => {
600
+ const src = raw.toLowerCase();
601
+ if (src === "*") return true;
602
+ if (src === `${target.protocol}`) return true;
603
+ if (src === origin.toLowerCase()) return true;
604
+ const wild = src.match(/^(https?:)\/\/\*\.(.+?)(?::(\d+))?$/);
605
+ if (wild) {
606
+ const [, scheme, domain, port] = wild;
607
+ return scheme === target.protocol && target.hostname.endsWith(`.${domain}`) && (!port || port === target.port);
608
+ }
609
+ return false;
610
+ });
611
+ }
612
+ function starterPolicy(supabaseUrl) {
613
+ const gr = guardrailCspDirectives(supabaseUrl ? { supabaseUrl } : {});
614
+ return cspString({
615
+ "default-src": ["'self'"],
616
+ "script-src": ["'self'", "'nonce-REPLACE_PER_REQUEST'", "'strict-dynamic'"],
617
+ "style-src": gr["style-src"],
618
+ "img-src": [...gr["img-src"], "data:"],
619
+ "connect-src": gr["connect-src"],
620
+ "font-src": ["'self'", "data:"],
621
+ "object-src": ["'none'"],
622
+ "base-uri": ["'self'"],
623
+ "form-action": ["'self'"],
624
+ "frame-ancestors": gr["frame-ancestors"]
625
+ });
626
+ }
627
+ function printStarterCsp(ctx) {
628
+ console.log(`Starter Content-Security-Policy
629
+
630
+ Content-Security-Policy-Report-Only: ${starterPolicy(ctx.url)}
631
+
632
+ How to use it
633
+ - Send it as Content-Security-Policy-Report-Only first. Browsers report what it
634
+ would block without blocking anything; tune it for the things your site
635
+ loads itself (analytics, fonts, embeds), then switch the header name to
636
+ Content-Security-Policy.
637
+ - Guardrail needs nothing beyond 'self' for styles and scripts. Its stylesheet is
638
+ a real file and it renders no inline scripts, so it needs no 'unsafe-inline' and no
639
+ nonce. The only extras are your Supabase origin in connect-src (image
640
+ uploads) and img-src (the media picker's thumbnails).${ctx.url ? "" : "\n (Set SUPABASE_URL and run this again to include your Supabase origin.)"}
641
+ - script-src above uses a per-request nonce, which Next.js sets from a proxy
642
+ (see "Content Security Policy" in the Next.js docs). With only a static header
643
+ in next.config you can't use a nonce \u2014 use 'self' 'unsafe-inline' for scripts
644
+ instead, which is a weaker policy but still a useful one.
645
+ - guardrailCspDirectives() from @guard-rail/core returns Guardrail's directives
646
+ if you build the header in code.
647
+ `);
648
+ }
649
+ async function checkLiveSite(appUrl, ctx) {
579
650
  group("Live site");
580
651
  let res;
581
652
  try {
@@ -606,6 +677,69 @@ async function checkLiveSite(appUrl) {
606
677
  const setCookie = typeof h.getSetCookie === "function" ? h.getSetCookie() : [];
607
678
  const session = setCookie.filter((c) => /^sb-.*-auth-token/i.test(c));
608
679
  if (session.length) fail("A session cookie was set for an anonymous request", session.map((c) => c.split("=")[0]).join(", "));
680
+ if (res.status === 200) {
681
+ const html = await res.text().catch(() => "");
682
+ const MARKER = /--gr-bg\b|\.gr-banner\b/;
683
+ const inline = [...html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1]).join("\n");
684
+ let found = MARKER.test(inline);
685
+ const hrefs = [...html.matchAll(/<link\b[^>]*rel=["']stylesheet["'][^>]*>/gi)].map((m) => m[0].match(/href=["']([^"']+)["']/i)?.[1]).filter((x) => Boolean(x)).slice(0, 8);
686
+ for (const href of hrefs) {
687
+ if (found) break;
688
+ try {
689
+ const css = await fetch(new URL(href, appUrl), { signal: AbortSignal.timeout(8e3) });
690
+ if (css.ok && MARKER.test(await css.text())) found = true;
691
+ } catch {
692
+ }
693
+ }
694
+ if (found) pass("Guardrail's stylesheet is on the page");
695
+ else if (hrefs.length === 0 && !/<style/i.test(html)) skip("Guardrail's stylesheet", "The page has no stylesheets to inspect.");
696
+ else {
697
+ warn(
698
+ "Guardrail's stylesheet isn't on the page",
699
+ "Its editing chrome would render unstyled. The package imports its own CSS, which your bundler may not be picking up.",
700
+ 'Import it once in your root layout: import "@guard-rail/core/styles.css";'
701
+ );
702
+ }
703
+ }
704
+ const enforced = h.get("content-security-policy");
705
+ const reportOnly = h.get("content-security-policy-report-only");
706
+ const policy = enforced ?? reportOnly;
707
+ if (!policy) {
708
+ warn(
709
+ "No Content-Security-Policy",
710
+ "Nothing limits where scripts and styles can load from, so an injected script would run.",
711
+ "Run: pnpm exec guardrail-doctor --csp (prints a starter report-only policy)"
712
+ );
713
+ return;
714
+ }
715
+ const parsed = parseCsp(policy);
716
+ const label = enforced ? "" : " (report-only)";
717
+ if (!parsed["default-src"] && !parsed["script-src"]) {
718
+ info(`Content-Security-Policy only sets ${Object.keys(parsed).join(", ") || "nothing"}`, "It doesn't restrict scripts. Run: pnpm exec guardrail-doctor --csp for a fuller starter.");
719
+ return;
720
+ }
721
+ const style = effectiveSources(parsed, "style-src");
722
+ if (style && style.map((x) => x.toLowerCase()).includes("'unsafe-inline'")) {
723
+ info(`style-src allows 'unsafe-inline'${label}`, "Guardrail no longer needs it \u2014 its chrome is a stylesheet. Remove it unless something else on the site does.");
724
+ } else {
725
+ pass(`Policy${label} doesn't need 'unsafe-inline' for Guardrail's styles`);
726
+ }
727
+ if (ctx.url) {
728
+ let supabase;
729
+ try {
730
+ supabase = new URL(ctx.url).origin;
731
+ } catch {
732
+ }
733
+ if (supabase && supabase !== new URL(appUrl).origin) {
734
+ for (const [directive, why] of [
735
+ ["connect-src", "image uploads go straight from the browser to Supabase storage"],
736
+ ["img-src", "uploaded images and the media picker's thumbnails load from Supabase storage"]
737
+ ]) {
738
+ if (allowsOrigin(effectiveSources(parsed, directive), supabase)) pass(`${directive}${label} allows ${supabase}`);
739
+ else warn(`${directive}${label} blocks ${supabase}`, `Needed because ${why}.`, `Add ${supabase} to ${directive}.`);
740
+ }
741
+ }
742
+ }
609
743
  }
610
744
  const TINY_PNG = Buffer.from(
611
745
  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
@@ -704,6 +838,37 @@ async function runE2E(ctx, sb) {
704
838
  value: { type: "text", text: "nope" }
705
839
  });
706
840
  expect(Boolean(anonWrite.error), "Visitors can't write content", anonWrite.error ? void 0 : "An unauthenticated write was allowed.");
841
+ const histCount = async () => {
842
+ const r = await gr(admin).from("gr_content_history").select("id", { count: "exact", head: true }).eq("site_id", siteId).eq("path_key", "doctor.check");
843
+ return r.error ? -1 : r.count ?? 0;
844
+ };
845
+ const setText = (text) => gr(user).from("gr_content").update({ value: { type: "text", text }, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("site_id", siteId).eq("path_key", "doctor.check");
846
+ const current = async () => {
847
+ const r = await gr(user).from("gr_content").select("value").eq("site_id", siteId).eq("path_key", "doctor.check").maybeSingle();
848
+ return r.data?.value?.text ?? null;
849
+ };
850
+ const revertRpc = async (site = siteId) => {
851
+ const r = await gr(user).rpc("gr_revert_content", { p_site: site, p_key: "doctor.check" });
852
+ return { error: r.error, data: r.data };
853
+ };
854
+ await setText("v1");
855
+ await setText("v1");
856
+ const afterTwo = await histCount();
857
+ expect(afterTwo === 1, "A save keeps the old value in history, and re-saving the same value doesn't", `Expected 1 history entry, found ${afterTwo}.`, "Apply the latest migrations: pnpm exec guardrail-init-db --push");
858
+ const info1 = await gr(user).rpc("gr_revert_info", { p_site: siteId, p_key: "doctor.check" });
859
+ expect(info1.data?.kind === "previous", "The editor is told a previous version exists", info1.error?.message);
860
+ const peek = await gr(anon).from("gr_content_history").select("id").eq("site_id", siteId);
861
+ expect(Boolean(peek.error) || (peek.data?.length ?? 0) === 0, "Visitors can't read content history", peek.error ? void 0 : "An unauthenticated read returned history rows.");
862
+ const foreign = await revertRpc(otherSite);
863
+ expect(Boolean(foreign.error), "An editor can't revert another site's content", foreign.error ? void 0 : "The revert was allowed.");
864
+ const r1 = await revertRpc();
865
+ expect(!r1.error && r1.data?.action === "restored" && await current() === "doctor" && await histCount() === 0, "Revert steps back one version", r1.error?.message ?? `Got ${JSON.stringify(r1.data)}.`, "Apply the latest migrations: pnpm exec guardrail-init-db --push");
866
+ const r2 = await revertRpc();
867
+ expect(!r2.error && r2.data?.action === "reset" && await current() === null, "Revert with nothing earlier returns the field to its default", r2.error?.message ?? `Got ${JSON.stringify(r2.data)}.`);
868
+ await gr(user).from("gr_content").insert({ site_id: siteId, path_key: "doctor.check", type: "text", value: { type: "text", text: "n0" } });
869
+ for (let i = 1; i <= 22; i++) await setText(`n${i}`);
870
+ const kept = await histCount();
871
+ expect(kept === 20, "History keeps the newest 20 versions of a field", `Expected 20, found ${kept}.`, "Apply the latest migrations: pnpm exec guardrail-init-db --push");
707
872
  const demote = await gr(user).from(GR_MEMBERS_TABLE).update({ role: "editor" }).eq("site_id", siteId).eq("email", email);
708
873
  expect(
709
874
  Boolean(demote.error),
@@ -839,6 +1004,10 @@ ${USAGE}`);
839
1004
  anonKey: env.SUPABASE_ANON_KEY,
840
1005
  serviceKey: env.SUPABASE_SERVICE_ROLE_KEY ?? env.SERVICE_KEY
841
1006
  };
1007
+ if (flags.has("csp")) {
1008
+ printStarterCsp(ctx);
1009
+ return 0;
1010
+ }
842
1011
  const json = flags.has("json");
843
1012
  if (!json) console.log("Guardrail doctor");
844
1013
  if (flags.has("fix")) {
@@ -876,7 +1045,7 @@ ${paint(1, "Fixes")}`);
876
1045
  checkFiles(ctx);
877
1046
  const sb = await loadSupabase();
878
1047
  const dbReachable = await checkDatabase(ctx, sb);
879
- if (values["app-url"]) await checkLiveSite(values["app-url"]);
1048
+ if (values["app-url"]) await checkLiveSite(values["app-url"], ctx);
880
1049
  if (flags.has("e2e")) {
881
1050
  group("End-to-end");
882
1051
  if (!dbReachable || !sb) {