@guard-rail/core 0.11.0 → 0.13.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.
- package/README.md +102 -7
- package/dist/adapters/supabase/db.js +37 -1
- package/dist/adapters/supabase/db.js.map +1 -1
- package/dist/cli/doctor.js +37 -3
- package/dist/cli/doctor.js.map +1 -1
- package/dist/core/adapter-types.d.ts +39 -3
- package/dist/core/seo.d.ts +69 -0
- package/dist/core/seo.js +23 -0
- package/dist/core/seo.js.map +1 -0
- package/dist/index.client.d.ts +5 -3
- package/dist/index.client.js +10 -1
- package/dist/index.client.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/react/Admin.js +14 -1
- package/dist/react/Admin.js.map +1 -1
- package/dist/react/GRail/client.js +59 -9
- package/dist/react/GRail/client.js.map +1 -1
- package/dist/react/Icons.d.ts +5 -1
- package/dist/react/Icons.js +10 -0
- package/dist/react/Icons.js.map +1 -1
- package/dist/react/PageSettings.d.ts +12 -0
- package/dist/react/PageSettings.js +275 -0
- package/dist/react/PageSettings.js.map +1 -0
- package/dist/react/guardrail.css +8 -0
- package/dist/react/revert.d.ts +8 -0
- package/dist/react/revert.js +14 -0
- package/dist/react/revert.js.map +1 -0
- package/dist/server/actions.d.ts +37 -2
- package/dist/server/actions.js +112 -0
- package/dist/server/actions.js.map +1 -1
- package/dist/server/loader.js +3 -1
- package/dist/server/loader.js.map +1 -1
- package/dist/server/seo.d.ts +33 -0
- package/dist/server/seo.js +51 -0
- package/dist/server/seo.js.map +1 -0
- package/package.json +1 -1
- package/supabase/admin/check-grants.sql +12 -3
- package/supabase/migrations/20261103000000_content_history.sql +211 -0
package/README.md
CHANGED
|
@@ -435,18 +435,106 @@ 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
|
+
|
|
475
|
+
## Page settings and metadata
|
|
476
|
+
|
|
477
|
+
Editors can set each page's search and sharing metadata without touching code. The
|
|
478
|
+
**gear** in the toolbar, next to the people icon, opens a **Page settings** drawer for
|
|
479
|
+
the page they're on, with a **title**, a **description**, a **social image** (with
|
|
480
|
+
alt text) and a **hide this page from search engines** switch. It looks like the
|
|
481
|
+
Team drawer, with a revert arrow on each field.
|
|
482
|
+
|
|
483
|
+
In the page, give `guardrailMetadata()` the page's path and your own defaults:
|
|
484
|
+
|
|
485
|
+
```tsx
|
|
486
|
+
// app/about/page.tsx
|
|
487
|
+
import { guardrailMetadata } from "@guard-rail/core";
|
|
488
|
+
|
|
489
|
+
export async function generateMetadata() {
|
|
490
|
+
return guardrailMetadata("/about", {
|
|
491
|
+
title: "About us",
|
|
492
|
+
description: "Who we are.",
|
|
493
|
+
image: "https://example.com/about.png", // optional
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
Whatever an editor saved wins; your values are the fallback, and clearing a field in
|
|
499
|
+
the drawer goes back to them. For a dynamic route, pass the real path:
|
|
500
|
+
`` guardrailMetadata(`/products/${slug}`, { … }) ``. It returns the title, description,
|
|
501
|
+
Open Graph and Twitter card tags, plus a robots tag when the page is hidden from search
|
|
502
|
+
engines. With nothing saved and no defaults given, it returns nothing, so the page
|
|
503
|
+
inherits your layout's metadata. All four settings are read in one query.
|
|
504
|
+
|
|
505
|
+
Things to know:
|
|
506
|
+
|
|
507
|
+
- **The path has to match.** The drawer keys values by the path the browser shows, and
|
|
508
|
+
`guardrailMetadata()` by the path you pass. Query strings, hashes and trailing slashes
|
|
509
|
+
are ignored; a rewrite, locale prefix or `basePath` that changes the visible path will
|
|
510
|
+
need the same path passed in.
|
|
511
|
+
- **Only pages that call `guardrailMetadata()` pick the settings up.** The drawer says so.
|
|
512
|
+
- **Next replaces a layout's `openGraph` and `twitter` objects rather than merging
|
|
513
|
+
them**, so if your layout sets something there (a `siteName`, say) and you want to keep
|
|
514
|
+
it, merge it into what you return.
|
|
515
|
+
- **Social images need an absolute URL.** Uploaded images already have one. A relative
|
|
516
|
+
path needs `metadataBase` set in your layout.
|
|
517
|
+
- **Versioned like everything else.** Settings are ordinary content rows (keyed
|
|
518
|
+
`seo./about.title` and so on), so each one gets the history and one-step revert
|
|
519
|
+
described above. Clearing a field is saved as a version too, so it can be reverted.
|
|
520
|
+
- **No new migration.** They're kept out of the content preloaded into Client Components,
|
|
521
|
+
so a site with many pages doesn't crowd out its real content.
|
|
522
|
+
|
|
438
523
|
## Custom editing UI
|
|
439
524
|
|
|
440
|
-
`<GRail>` edits what's on the page. Some fields have nothing to click —
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
authorization in
|
|
525
|
+
`<GRail>` edits what's on the page. Some fields have nothing to click — and
|
|
526
|
+
some data isn't copy at all, like stock or price. (A page's own SEO title,
|
|
527
|
+
description and og:image have a built-in drawer: see "Page settings and metadata".)
|
|
528
|
+
For anything else, build a small drawer of your own and keep the authorization in
|
|
529
|
+
Guardrail's hands:
|
|
444
530
|
|
|
445
531
|
- **`getFieldValue(id)`** reads any field (server-side).
|
|
446
532
|
- **`setFieldValue(id, value)`** writes one. A string is shorthand for a text
|
|
447
533
|
field; pass `{ type: "image", src, alt }` etc. for the others. It checks the
|
|
448
534
|
caller is an editor or admin itself, and writes the same row a `<GRail>` with
|
|
449
535
|
that id would, so the two stay interchangeable.
|
|
536
|
+
- **`getRevertInfo(id)` / `revertField(id)`** report and perform a one-step revert
|
|
537
|
+
(see "Reverting a change").
|
|
450
538
|
- **`isEditor()` / `isAdmin()`** are the role checks for your *own* Server
|
|
451
539
|
Actions. `isEditor()` is true for editors and admins; `isAdmin()` only for
|
|
452
540
|
admins (team management).
|
|
@@ -624,6 +712,13 @@ work inside Client Components (see above): one round trip returning the site's r
|
|
|
624
712
|
and whether the list was cut off at `limit`. An adapter without it still works
|
|
625
713
|
fully for Server Components — client-rendered fields just show their JSX fallback.
|
|
626
714
|
|
|
715
|
+
`getRevertInfo(pathKey, siteId)` and `revertOverride(pathKey, siteId)` are optional and
|
|
716
|
+
go together: they back the revert button (see "Reverting a change"). `getRevertInfo`
|
|
717
|
+
returns `{ kind: "previous" | "default" | "none", at? }`; `revertOverride` restores the
|
|
718
|
+
previous saved value (returning it) or removes the saved value, and reports which.
|
|
719
|
+
An adapter without them simply never shows the button — keeping the history is up to
|
|
720
|
+
your backend.
|
|
721
|
+
|
|
627
722
|
`getOverrides(pathKeys, siteId)` is batched by design — it receives every key a
|
|
628
723
|
render pass needs, already deduplicated. Adapters map their own storage naming
|
|
629
724
|
at this boundary; core speaks `pathKey` / `siteId` only.
|
|
@@ -660,14 +755,14 @@ By default it only reads. It checks:
|
|
|
660
755
|
`supabase/migrations/`, that `next.config` sets anti-framing headers, and that
|
|
661
756
|
the root layout uses `withGuardrail()`.
|
|
662
757
|
- **Database:** that the `guardrail` schema is exposed, the tables and functions
|
|
663
|
-
exist, rate limiting works, and what your project's sign-up settings are
|
|
758
|
+
exist (including the revert history), rate limiting works, and what your project's sign-up settings are
|
|
664
759
|
(whether **Confirm email** is on).
|
|
665
760
|
|
|
666
761
|
| Flag | What it adds |
|
|
667
762
|
|---|---|
|
|
668
763
|
| `--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`. |
|
|
669
764
|
| `--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. |
|
|
670
|
-
| `--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. |
|
|
765
|
+
| `--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. |
|
|
671
766
|
| `--csp` | Prints a starter `Content-Security-Policy-Report-Only` header built from your `SUPABASE_URL` and exits. |
|
|
672
767
|
| `--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. |
|
|
673
768
|
| `--production` | Treats production-only requirements as failures instead of warnings. |
|
|
@@ -833,4 +928,4 @@ one, is set by your site (Next.js does it from a proxy); Guardrail doesn't need
|
|
|
833
928
|
- `withGuardrail` reads cookies in the root layout, which opts routes into
|
|
834
929
|
dynamic rendering. See the repo README for the plan.
|
|
835
930
|
- Saving revalidates the entire layout tree rather than the affected route.
|
|
836
|
-
- No drafts
|
|
931
|
+
- 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 {
|
|
@@ -20,12 +23,15 @@ function supabaseDB(creds) {
|
|
|
20
23
|
}
|
|
21
24
|
return (data ?? []).map(toOverrideRow);
|
|
22
25
|
},
|
|
23
|
-
async listOverrides(siteId, { prefix, limit }) {
|
|
26
|
+
async listOverrides(siteId, { prefix, limit, exclude }) {
|
|
24
27
|
let query = anonClient(creds).schema(GR_SCHEMA).from(GR_TABLE).select("id, path_key, value").eq("site_id", siteId).order("path_key").limit(limit + 1);
|
|
25
28
|
if (prefix?.length) {
|
|
26
29
|
const quote = (p) => `"${p.replace(/["\\]/g, "\\$&")}"`;
|
|
27
30
|
query = query.or(prefix.map((p) => `path_key.like.${quote(`${p}*`)}`).join(","));
|
|
28
31
|
}
|
|
32
|
+
for (const p of exclude ?? []) {
|
|
33
|
+
query = query.not("path_key", "like", `${p.replace(/[%_\\]/g, "\\$&")}%`);
|
|
34
|
+
}
|
|
29
35
|
const { data, error } = await query;
|
|
30
36
|
if (error) throw new Error(error.message);
|
|
31
37
|
const rows = (data ?? []).map(toOverrideRow);
|
|
@@ -45,6 +51,36 @@ function supabaseDB(creds) {
|
|
|
45
51
|
);
|
|
46
52
|
return error ? { success: false, error: error.message } : { success: true };
|
|
47
53
|
},
|
|
54
|
+
// Reverting runs in the database (gr_revert_content / gr_revert_info) so a
|
|
55
|
+
// step back is one atomic move, and the editor's own access rules apply.
|
|
56
|
+
async getRevertInfo(pathKey, siteId) {
|
|
57
|
+
const client = await sessionClient(creds);
|
|
58
|
+
const { data, error } = await client.schema(GR_SCHEMA).rpc("gr_revert_info", { p_site: siteId, p_key: pathKey });
|
|
59
|
+
if (error) {
|
|
60
|
+
if (!isMissingFunction(error)) {
|
|
61
|
+
console.error("Guardrail (Supabase): gr_revert_info failed", error.message);
|
|
62
|
+
}
|
|
63
|
+
return { kind: "none" };
|
|
64
|
+
}
|
|
65
|
+
const info = data;
|
|
66
|
+
return info && ["previous", "default", "none"].includes(info.kind) ? info : { kind: "none" };
|
|
67
|
+
},
|
|
68
|
+
async revertOverride(pathKey, siteId) {
|
|
69
|
+
const client = await sessionClient(creds);
|
|
70
|
+
const { data, error } = await client.schema(GR_SCHEMA).rpc("gr_revert_content", { p_site: siteId, p_key: pathKey });
|
|
71
|
+
if (error) {
|
|
72
|
+
return {
|
|
73
|
+
success: false,
|
|
74
|
+
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
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const result = data;
|
|
78
|
+
if (result?.action === "restored" && result.value) {
|
|
79
|
+
const value = typeof result.value === "string" ? JSON.parse(result.value) : result.value;
|
|
80
|
+
return { success: true, action: "restored", value };
|
|
81
|
+
}
|
|
82
|
+
return { success: true, action: result?.action === "reset" ? "reset" : "none" };
|
|
83
|
+
},
|
|
48
84
|
async deleteOverrides(pathKeyPrefix, siteId) {
|
|
49
85
|
const client = await sessionClient(creds);
|
|
50
86
|
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,
|
|
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, exclude }) {\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 for (const p of exclude ?? []) {\n query = query.not(\"path_key\", \"like\", `${p.replace(/[%_\\\\]/g, \"\\\\$&\")}%`);\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":[]}
|
package/dist/cli/doctor.js
CHANGED
|
@@ -545,16 +545,19 @@ async function checkDatabase(ctx, sb) {
|
|
|
545
545
|
}
|
|
546
546
|
const admin = sb.createClient(ctx.url, ctx.serviceKey, { auth: { persistSession: false, autoRefreshToken: false } });
|
|
547
547
|
const gr = () => admin.schema(GR_SCHEMA);
|
|
548
|
-
for (const table of [GR_MEMBERS_TABLE, "gr_content", "gr_repeater_items", "gr_rate_limits"]) {
|
|
549
|
-
const { error: error2 } = await gr().from(table).select("*"
|
|
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);
|
|
550
550
|
if (!error2) pass(`Table ${table}`);
|
|
551
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");
|
|
552
553
|
else fail(`Table ${table} isn't readable`, error2.message, "Run: pnpm exec guardrail-init-db --push");
|
|
553
554
|
}
|
|
554
555
|
const probes = [
|
|
555
556
|
["gr_confirmed_email", {}, "Confirmed-email identity check"],
|
|
556
557
|
["gr_claim_invite", { target_site: "doctor-probe" }, "Invite-claim function"],
|
|
557
|
-
["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"]
|
|
558
561
|
];
|
|
559
562
|
for (const [fn, args, label] of probes) {
|
|
560
563
|
const { error: error2 } = await gr().rpc(fn, args);
|
|
@@ -835,6 +838,37 @@ async function runE2E(ctx, sb) {
|
|
|
835
838
|
value: { type: "text", text: "nope" }
|
|
836
839
|
});
|
|
837
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");
|
|
838
872
|
const demote = await gr(user).from(GR_MEMBERS_TABLE).update({ role: "editor" }).eq("site_id", siteId).eq("email", email);
|
|
839
873
|
expect(
|
|
840
874
|
Boolean(demote.error),
|