@guard-rail/core 0.1.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 +339 -0
- package/dist/adapters/supabase/auth.d.ts +9 -0
- package/dist/adapters/supabase/auth.js +57 -0
- package/dist/adapters/supabase/auth.js.map +1 -0
- package/dist/adapters/supabase/client.d.ts +28 -0
- package/dist/adapters/supabase/client.js +44 -0
- package/dist/adapters/supabase/client.js.map +1 -0
- package/dist/adapters/supabase/confirm.d.ts +19 -0
- package/dist/adapters/supabase/confirm.js +27 -0
- package/dist/adapters/supabase/confirm.js.map +1 -0
- package/dist/adapters/supabase/db.d.ts +9 -0
- package/dist/adapters/supabase/db.js +49 -0
- package/dist/adapters/supabase/db.js.map +1 -0
- package/dist/adapters/supabase/index.d.ts +25 -0
- package/dist/adapters/supabase/index.js +29 -0
- package/dist/adapters/supabase/index.js.map +1 -0
- package/dist/adapters/supabase/members.d.ts +9 -0
- package/dist/adapters/supabase/members.js +73 -0
- package/dist/adapters/supabase/members.js.map +1 -0
- package/dist/adapters/supabase/repeaters.d.ts +9 -0
- package/dist/adapters/supabase/repeaters.js +73 -0
- package/dist/adapters/supabase/repeaters.js.map +1 -0
- package/dist/adapters/supabase/storage.d.ts +18 -0
- package/dist/adapters/supabase/storage.js +93 -0
- package/dist/adapters/supabase/storage.js.map +1 -0
- package/dist/core/adapter-types.d.ts +212 -0
- package/dist/core/adapter-types.js +1 -0
- package/dist/core/adapter-types.js.map +1 -0
- package/dist/core/bailout.d.ts +9 -0
- package/dist/core/bailout.js +14 -0
- package/dist/core/bailout.js.map +1 -0
- package/dist/core/config.d.ts +27 -0
- package/dist/core/config.js +46 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/defs.d.ts +22 -0
- package/dist/core/defs.js +16 -0
- package/dist/core/defs.js.map +1 -0
- package/dist/core/types.d.ts +136 -0
- package/dist/core/types.js +1 -0
- package/dist/core/types.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/react/Admin.d.ts +8 -0
- package/dist/react/Admin.js +116 -0
- package/dist/react/Admin.js.map +1 -0
- package/dist/react/GRail/client.d.ts +11 -0
- package/dist/react/GRail/client.js +242 -0
- package/dist/react/GRail/client.js.map +1 -0
- package/dist/react/GRail/index.d.ts +12 -0
- package/dist/react/GRail/index.js +19 -0
- package/dist/react/GRail/index.js.map +1 -0
- package/dist/react/GRail/render.d.ts +23 -0
- package/dist/react/GRail/render.js +67 -0
- package/dist/react/GRail/render.js.map +1 -0
- package/dist/react/Icons.d.ts +13 -0
- package/dist/react/Icons.js +68 -0
- package/dist/react/Icons.js.map +1 -0
- package/dist/react/MediaPicker.d.ts +14 -0
- package/dist/react/MediaPicker.js +172 -0
- package/dist/react/MediaPicker.js.map +1 -0
- package/dist/react/Provider.d.ts +11 -0
- package/dist/react/Provider.js +26 -0
- package/dist/react/Provider.js.map +1 -0
- package/dist/react/Repeater/client.d.ts +16 -0
- package/dist/react/Repeater/client.js +148 -0
- package/dist/react/Repeater/client.js.map +1 -0
- package/dist/react/Repeater/index.d.ts +35 -0
- package/dist/react/Repeater/index.js +22 -0
- package/dist/react/Repeater/index.js.map +1 -0
- package/dist/react/Syncer.d.ts +17 -0
- package/dist/react/Syncer.js +23 -0
- package/dist/react/Syncer.js.map +1 -0
- package/dist/react/Team.d.ts +7 -0
- package/dist/react/Team.js +190 -0
- package/dist/react/Team.js.map +1 -0
- package/dist/react/resizeImage.d.ts +25 -0
- package/dist/react/resizeImage.js +52 -0
- package/dist/react/resizeImage.js.map +1 -0
- package/dist/react/styles.d.ts +421 -0
- package/dist/react/styles.js +402 -0
- package/dist/react/styles.js.map +1 -0
- package/dist/react/useAnchorRect.d.ts +17 -0
- package/dist/react/useAnchorRect.js +32 -0
- package/dist/react/useAnchorRect.js.map +1 -0
- package/dist/server/actions.d.ts +41 -0
- package/dist/server/actions.js +214 -0
- package/dist/server/actions.js.map +1 -0
- package/dist/server/data.d.ts +10 -0
- package/dist/server/data.js +24 -0
- package/dist/server/data.js.map +1 -0
- package/dist/server/fieldData.d.ts +26 -0
- package/dist/server/fieldData.js +9 -0
- package/dist/server/fieldData.js.map +1 -0
- package/dist/server/loader.d.ts +6 -0
- package/dist/server/loader.js +53 -0
- package/dist/server/loader.js.map +1 -0
- package/dist/server/repeaterActions.d.ts +20 -0
- package/dist/server/repeaterActions.js +72 -0
- package/dist/server/repeaterActions.js.map +1 -0
- package/dist/server/repeaterData.d.ts +18 -0
- package/dist/server/repeaterData.js +27 -0
- package/dist/server/repeaterData.js.map +1 -0
- package/dist/server/session.d.ts +14 -0
- package/dist/server/session.js +37 -0
- package/dist/server/session.js.map +1 -0
- package/dist/server/wrapper.d.ts +12 -0
- package/dist/server/wrapper.js +42 -0
- package/dist/server/wrapper.js.map +1 -0
- package/package.json +58 -0
- package/supabase/migrations/20260911000000_guardrail_init.sql +288 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { Suspense } from "react";
|
|
3
|
+
import { cookies } from "next/headers";
|
|
4
|
+
import {
|
|
5
|
+
configureGuardrail,
|
|
6
|
+
getAuth,
|
|
7
|
+
getStorage,
|
|
8
|
+
resolveSiteId
|
|
9
|
+
} from "../core/config";
|
|
10
|
+
import { getCachedViewer } from "./session";
|
|
11
|
+
import { GR_COOKIES } from "../core/defs";
|
|
12
|
+
import { GRailProvider } from "../react/Provider";
|
|
13
|
+
import { GRailAdmin } from "../react/Admin";
|
|
14
|
+
function withGuardrail(LayoutComponent, config) {
|
|
15
|
+
configureGuardrail(config);
|
|
16
|
+
return async function GuardrailLayout(props) {
|
|
17
|
+
configureGuardrail(config);
|
|
18
|
+
const cookieStore = await cookies();
|
|
19
|
+
const viewer = await getCachedViewer();
|
|
20
|
+
const auth = getAuth();
|
|
21
|
+
const state = {
|
|
22
|
+
storage: getStorage() !== void 0,
|
|
23
|
+
auth: {
|
|
24
|
+
password: typeof auth.authenticate === "function",
|
|
25
|
+
...auth.signInUrl ? { signInUrl: auth.signInUrl } : {}
|
|
26
|
+
},
|
|
27
|
+
isAuth: viewer !== null,
|
|
28
|
+
isEditMode: cookieStore.get(GR_COOKIES.EDIT_MODE)?.value === "true",
|
|
29
|
+
siteId: await resolveSiteId(),
|
|
30
|
+
role: viewer?.role ?? null,
|
|
31
|
+
...viewer?.email ? { email: viewer.email } : {}
|
|
32
|
+
};
|
|
33
|
+
return /* @__PURE__ */ jsxs(GRailProvider, { state, children: [
|
|
34
|
+
/* @__PURE__ */ jsx(LayoutComponent, { ...props }),
|
|
35
|
+
/* @__PURE__ */ jsx(Suspense, { fallback: null, children: /* @__PURE__ */ jsx(GRailAdmin, { toolbarExtra: config.toolbarExtra }) })
|
|
36
|
+
] });
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export {
|
|
40
|
+
withGuardrail
|
|
41
|
+
};
|
|
42
|
+
//# sourceMappingURL=wrapper.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/server/wrapper.tsx"],"sourcesContent":["import React, { Suspense } from \"react\";\nimport { cookies } from \"next/headers\";\nimport {\n configureGuardrail,\n getAuth,\n getStorage,\n resolveSiteId,\n} from \"../core/config\";\nimport { getCachedViewer } from \"./session\";\nimport type { GRConfig } from \"../core/adapter-types\";\nimport { GR_COOKIES } from \"../core/defs\";\nimport { GRailProvider } from \"../react/Provider\";\nimport { GRailAdmin } from \"../react/Admin\";\nimport type { GRState } from \"../core/types\";\n\n/**\n * Wraps a root layout so every route carries Guardrail's edit state and admin\n * chrome. Stays a Server Component: the provider below is the client boundary.\n */\nexport function withGuardrail<P extends object>(\n LayoutComponent: React.ComponentType<P>,\n config: GRConfig,\n) {\n // Applied at module evaluation so the config is present as soon as anything\n // imports the layout, and again per render because Next.js does not\n // guarantee one shared module instance across its server contexts.\n configureGuardrail(config);\n\n return async function GuardrailLayout(props: P) {\n configureGuardrail(config);\n\n const cookieStore = await cookies();\n\n // Verified against the auth provider, not inferred from a cookie.\n const viewer = await getCachedViewer();\n\n const auth = getAuth();\n\n const state: GRState = {\n storage: getStorage() !== undefined,\n auth: {\n password: typeof auth.authenticate === \"function\",\n ...(auth.signInUrl ? { signInUrl: auth.signInUrl } : {}),\n },\n isAuth: viewer !== null,\n isEditMode: cookieStore.get(GR_COOKIES.EDIT_MODE)?.value === \"true\",\n siteId: await resolveSiteId(),\n role: viewer?.role ?? null,\n ...(viewer?.email ? { email: viewer.email } : {}),\n };\n\n return (\n <GRailProvider state={state}>\n <LayoutComponent {...props} />\n <Suspense fallback={null}>\n <GRailAdmin toolbarExtra={config.toolbarExtra} />\n </Suspense>\n </GRailProvider>\n );\n };\n}\n"],"mappings":"AAoDM,SACE,KADF;AApDN,OAAO,SAAS,gBAAgB;AAChC,SAAS,eAAe;AACxB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,uBAAuB;AAEhC,SAAS,kBAAkB;AAC3B,SAAS,qBAAqB;AAC9B,SAAS,kBAAkB;AAOpB,SAAS,cACd,iBACA,QACA;AAIA,qBAAmB,MAAM;AAEzB,SAAO,eAAe,gBAAgB,OAAU;AAC9C,uBAAmB,MAAM;AAEzB,UAAM,cAAc,MAAM,QAAQ;AAGlC,UAAM,SAAS,MAAM,gBAAgB;AAErC,UAAM,OAAO,QAAQ;AAErB,UAAM,QAAiB;AAAA,MACrB,SAAS,WAAW,MAAM;AAAA,MAC1B,MAAM;AAAA,QACJ,UAAU,OAAO,KAAK,iBAAiB;AAAA,QACvC,GAAI,KAAK,YAAY,EAAE,WAAW,KAAK,UAAU,IAAI,CAAC;AAAA,MACxD;AAAA,MACA,QAAQ,WAAW;AAAA,MACnB,YAAY,YAAY,IAAI,WAAW,SAAS,GAAG,UAAU;AAAA,MAC7D,QAAQ,MAAM,cAAc;AAAA,MAC5B,MAAM,QAAQ,QAAQ;AAAA,MACtB,GAAI,QAAQ,QAAQ,EAAE,OAAO,OAAO,MAAM,IAAI,CAAC;AAAA,IACjD;AAEA,WACE,qBAAC,iBAAc,OACb;AAAA,0BAAC,mBAAiB,GAAG,OAAO;AAAA,MAC5B,oBAAC,YAAS,UAAU,MAClB,8BAAC,cAAW,cAAc,OAAO,cAAc,GACjD;AAAA,OACF;AAAA,EAEJ;AACF;","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@guard-rail/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lightweight inline content editing for developer-owned Next.js sites.",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./adapters/supabase": {
|
|
15
|
+
"types": "./dist/adapters/supabase/index.d.ts",
|
|
16
|
+
"default": "./dist/adapters/supabase/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"supabase"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsup",
|
|
29
|
+
"dev": "tsup --watch",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"prepare": "npm run build",
|
|
32
|
+
"prepublishOnly": "npm run build"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@supabase/ssr": "^0.8.0",
|
|
36
|
+
"@supabase/supabase-js": "^2.0.0",
|
|
37
|
+
"next": ">=15",
|
|
38
|
+
"react": ">=19"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"@supabase/ssr": {
|
|
42
|
+
"optional": true
|
|
43
|
+
},
|
|
44
|
+
"@supabase/supabase-js": {
|
|
45
|
+
"optional": true
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@supabase/ssr": "^0.8.0",
|
|
50
|
+
"@supabase/supabase-js": "^2.93.3",
|
|
51
|
+
"@types/node": "^25.1.0",
|
|
52
|
+
"@types/react": "^19.2.10",
|
|
53
|
+
"next": "16.1.6",
|
|
54
|
+
"react": "^19.2.4",
|
|
55
|
+
"tsup": "^8.5.1",
|
|
56
|
+
"typescript": "^5.9.3"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
-- Guardrail: initial schema
|
|
2
|
+
--
|
|
3
|
+
-- Run with the Supabase CLI (`supabase db push`) once this file is copied
|
|
4
|
+
-- into your own project's supabase/migrations/ — not hand-pasted into the SQL
|
|
5
|
+
-- editor. Applied once and tracked by the CLI, so unlike the old hand-run
|
|
6
|
+
-- schema.sql this does not need to be idempotent or re-runnable.
|
|
7
|
+
--
|
|
8
|
+
-- REQUIRED MANUAL STEP: add "guardrail" under Settings > API > Exposed
|
|
9
|
+
-- schemas in the Supabase dashboard. PostgREST only serves schemas on that
|
|
10
|
+
-- list, and no migration can add itself to it — skipping this step makes
|
|
11
|
+
-- every table below invisible to the client with a "not found in schema
|
|
12
|
+
-- cache" error that looks identical to a missing table.
|
|
13
|
+
--
|
|
14
|
+
-- Authorization is enforced in two independent places: Guardrail rejects
|
|
15
|
+
-- mutations from a caller without the right role, and RLS rejects rows that
|
|
16
|
+
-- do not belong to a site the caller is a member of. Neither alone is
|
|
17
|
+
-- sufficient — keep both.
|
|
18
|
+
|
|
19
|
+
create schema if not exists guardrail;
|
|
20
|
+
grant usage on schema guardrail to anon, authenticated;
|
|
21
|
+
|
|
22
|
+
/* ------------------------------------------------------------- members --- */
|
|
23
|
+
|
|
24
|
+
create table guardrail.gr_members (
|
|
25
|
+
site_id text not null,
|
|
26
|
+
email text not null,
|
|
27
|
+
user_id uuid references auth.users (id) on delete set null,
|
|
28
|
+
role text not null check (role in ('admin', 'editor')),
|
|
29
|
+
status text not null default 'pending' check (status in ('active', 'pending')),
|
|
30
|
+
invited_at timestamptz not null default now(),
|
|
31
|
+
primary key (site_id, email)
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
create index gr_members_user_idx on guardrail.gr_members (site_id, user_id);
|
|
35
|
+
|
|
36
|
+
alter table guardrail.gr_members enable row level security;
|
|
37
|
+
|
|
38
|
+
-- Anon has no legitimate reason to read team membership, so it gets no grant
|
|
39
|
+
-- at all here (unlike gr_content, which anonymous visitors must be able to
|
|
40
|
+
-- read to render the site).
|
|
41
|
+
grant select, insert, update, delete on guardrail.gr_members to authenticated;
|
|
42
|
+
|
|
43
|
+
-- Does the current user hold one of these roles on this site? SECURITY
|
|
44
|
+
-- DEFINER so the membership policies below can call it without recursing
|
|
45
|
+
-- into gr_members' own RLS. Restricted to `authenticated` — every caller of
|
|
46
|
+
-- this function is already inside a `to authenticated` policy.
|
|
47
|
+
create function guardrail.gr_has_role(target_site text, roles text[])
|
|
48
|
+
returns boolean
|
|
49
|
+
language sql
|
|
50
|
+
stable
|
|
51
|
+
security definer
|
|
52
|
+
set search_path = guardrail, pg_temp
|
|
53
|
+
as $$
|
|
54
|
+
select exists (
|
|
55
|
+
select 1 from guardrail.gr_members m
|
|
56
|
+
where m.site_id = target_site
|
|
57
|
+
and m.user_id = auth.uid()
|
|
58
|
+
and m.status = 'active'
|
|
59
|
+
and m.role = any(roles)
|
|
60
|
+
);
|
|
61
|
+
$$;
|
|
62
|
+
|
|
63
|
+
revoke all on function guardrail.gr_has_role(text, text[]) from public;
|
|
64
|
+
grant execute on function guardrail.gr_has_role(text, text[]) to authenticated;
|
|
65
|
+
|
|
66
|
+
-- Members may read their own site's roster; an invited address may read the
|
|
67
|
+
-- row addressed to it so the invitation can be found and claimed.
|
|
68
|
+
create policy "members read their own site roster"
|
|
69
|
+
on guardrail.gr_members for select
|
|
70
|
+
to authenticated
|
|
71
|
+
using (
|
|
72
|
+
user_id = auth.uid()
|
|
73
|
+
or lower(email) = lower(coalesce(auth.jwt() ->> 'email', ''))
|
|
74
|
+
or guardrail.gr_has_role(site_id, array['admin', 'editor'])
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
-- Mutations are admin-only, with no self-service exception.
|
|
78
|
+
--
|
|
79
|
+
-- A policy permissive enough to let someone write the row matching their own
|
|
80
|
+
-- email is permissive enough to let them insert themselves as an admin on
|
|
81
|
+
-- any site, straight through PostgREST. Claiming an invitation therefore goes
|
|
82
|
+
-- through gr_claim_invite() below, which cannot change a role.
|
|
83
|
+
create policy "admins manage the roster"
|
|
84
|
+
on guardrail.gr_members for all
|
|
85
|
+
to authenticated
|
|
86
|
+
using (guardrail.gr_has_role(site_id, array['admin']))
|
|
87
|
+
with check (guardrail.gr_has_role(site_id, array['admin']));
|
|
88
|
+
|
|
89
|
+
-- Binds a pending invitation to the caller. Sets only user_id and status; the
|
|
90
|
+
-- invited role is never writable by the invitee.
|
|
91
|
+
create function guardrail.gr_claim_invite(target_site text)
|
|
92
|
+
returns void
|
|
93
|
+
language sql
|
|
94
|
+
security definer
|
|
95
|
+
set search_path = guardrail, pg_temp
|
|
96
|
+
as $$
|
|
97
|
+
update guardrail.gr_members
|
|
98
|
+
set user_id = auth.uid(),
|
|
99
|
+
status = 'active'
|
|
100
|
+
where site_id = target_site
|
|
101
|
+
and lower(email) = lower(coalesce(auth.jwt() ->> 'email', ''))
|
|
102
|
+
and status = 'pending';
|
|
103
|
+
$$;
|
|
104
|
+
|
|
105
|
+
revoke all on function guardrail.gr_claim_invite(text) from public;
|
|
106
|
+
grant execute on function guardrail.gr_claim_invite(text) to authenticated;
|
|
107
|
+
|
|
108
|
+
/* ------------------------------------------------------------- content --- */
|
|
109
|
+
|
|
110
|
+
create table guardrail.gr_content (
|
|
111
|
+
id uuid primary key default gen_random_uuid(),
|
|
112
|
+
site_id text not null,
|
|
113
|
+
path_key text not null,
|
|
114
|
+
type text not null,
|
|
115
|
+
value jsonb not null,
|
|
116
|
+
updated_at timestamptz not null default now(),
|
|
117
|
+
unique (path_key, site_id)
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
create index gr_content_site_path_idx on guardrail.gr_content (site_id, path_key);
|
|
121
|
+
|
|
122
|
+
alter table guardrail.gr_content enable row level security;
|
|
123
|
+
|
|
124
|
+
grant select on guardrail.gr_content to anon, authenticated;
|
|
125
|
+
grant insert, update, delete on guardrail.gr_content to authenticated;
|
|
126
|
+
|
|
127
|
+
-- Anyone may read published content: this is what anonymous visitors render.
|
|
128
|
+
create policy "content is publicly readable"
|
|
129
|
+
on guardrail.gr_content for select
|
|
130
|
+
using (true);
|
|
131
|
+
|
|
132
|
+
-- Writes require an active admin or editor membership *on that site*. This is
|
|
133
|
+
-- what stops a signed-in user from editing a site they were never added to.
|
|
134
|
+
create policy "editors write their own sites"
|
|
135
|
+
on guardrail.gr_content for insert
|
|
136
|
+
to authenticated
|
|
137
|
+
with check (guardrail.gr_has_role(site_id, array['admin', 'editor']));
|
|
138
|
+
|
|
139
|
+
create policy "editors update their own sites"
|
|
140
|
+
on guardrail.gr_content for update
|
|
141
|
+
to authenticated
|
|
142
|
+
using (guardrail.gr_has_role(site_id, array['admin', 'editor']))
|
|
143
|
+
with check (guardrail.gr_has_role(site_id, array['admin', 'editor']));
|
|
144
|
+
|
|
145
|
+
-- Deletes only happen when a repeater item is removed, to clean up its
|
|
146
|
+
-- now-orphaned fields (see gr_repeater_items below).
|
|
147
|
+
create policy "editors delete their own sites"
|
|
148
|
+
on guardrail.gr_content for delete
|
|
149
|
+
to authenticated
|
|
150
|
+
using (guardrail.gr_has_role(site_id, array['admin', 'editor']));
|
|
151
|
+
|
|
152
|
+
/* ------------------------------------------------------------ repeaters --- */
|
|
153
|
+
-- Manifest for <GRepeater>: identity and order only. An item's own fields are
|
|
154
|
+
-- ordinary gr_content rows namespaced "<repeater_id>.<item_id>.<field>" — this
|
|
155
|
+
-- table does not know or care what fields an item has, since that is defined
|
|
156
|
+
-- entirely by the developer's template component, not by a schema here.
|
|
157
|
+
|
|
158
|
+
create table guardrail.gr_repeater_items (
|
|
159
|
+
site_id text not null,
|
|
160
|
+
repeater_id text not null,
|
|
161
|
+
item_id text not null,
|
|
162
|
+
-- Present only for a routed repeater (each item has its own page, e.g. a
|
|
163
|
+
-- product or a post). A plain row of cards on one page leaves this null.
|
|
164
|
+
slug text,
|
|
165
|
+
sort_order integer not null,
|
|
166
|
+
created_at timestamptz not null default now(),
|
|
167
|
+
primary key (site_id, repeater_id, item_id)
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
-- Slugs are the URL segment for a routed item, so they must be unique within
|
|
171
|
+
-- a repeater — but only enforced where a slug exists, since non-routed items
|
|
172
|
+
-- never set one.
|
|
173
|
+
create unique index gr_repeater_items_slug_idx
|
|
174
|
+
on guardrail.gr_repeater_items (site_id, repeater_id, slug)
|
|
175
|
+
where slug is not null;
|
|
176
|
+
|
|
177
|
+
create index gr_repeater_items_order_idx
|
|
178
|
+
on guardrail.gr_repeater_items (site_id, repeater_id, sort_order);
|
|
179
|
+
|
|
180
|
+
alter table guardrail.gr_repeater_items enable row level security;
|
|
181
|
+
|
|
182
|
+
grant select on guardrail.gr_repeater_items to anon, authenticated;
|
|
183
|
+
grant insert, update, delete on guardrail.gr_repeater_items to authenticated;
|
|
184
|
+
|
|
185
|
+
-- Anyone may enumerate items: this is what renders the page for anonymous
|
|
186
|
+
-- visitors, same reasoning as gr_content's public read policy.
|
|
187
|
+
create policy "repeater items are publicly readable"
|
|
188
|
+
on guardrail.gr_repeater_items for select
|
|
189
|
+
using (true);
|
|
190
|
+
|
|
191
|
+
create policy "editors manage repeater items"
|
|
192
|
+
on guardrail.gr_repeater_items for all
|
|
193
|
+
to authenticated
|
|
194
|
+
using (guardrail.gr_has_role(site_id, array['admin', 'editor']))
|
|
195
|
+
with check (guardrail.gr_has_role(site_id, array['admin', 'editor']));
|
|
196
|
+
|
|
197
|
+
/* -------------------------------------------------------------- storage --- */
|
|
198
|
+
-- Editable images live in public buckets named 'gr-*'. 'gr-media' is created
|
|
199
|
+
-- here as the default. Any other 'gr-' name passed to supabaseStorage({
|
|
200
|
+
-- bucket }) is created on first upload by gr_ensure_bucket() below, so
|
|
201
|
+
-- choosing a bucket never means writing a migration for it.
|
|
202
|
+
--
|
|
203
|
+
-- Public means anyone can *fetch* a file by URL, which a website needs.
|
|
204
|
+
-- Listing and uploading still go through the policies below. The size and
|
|
205
|
+
-- type limits are enforced by Storage itself, backing up the server action.
|
|
206
|
+
|
|
207
|
+
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
|
208
|
+
values (
|
|
209
|
+
'gr-media',
|
|
210
|
+
'gr-media',
|
|
211
|
+
true,
|
|
212
|
+
10485760,
|
|
213
|
+
array['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/avif']
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
-- Objects live under '<site_id>/…', so the first folder names the site and
|
|
217
|
+
-- membership on that site decides who may upload or browse. Scoped to the
|
|
218
|
+
-- 'gr-' prefix, not one bucket name, so any bucket a developer names via
|
|
219
|
+
-- supabaseStorage({ bucket }) is covered without another migration.
|
|
220
|
+
create policy "gr editors upload media"
|
|
221
|
+
on storage.objects for insert
|
|
222
|
+
to authenticated
|
|
223
|
+
with check (
|
|
224
|
+
bucket_id like 'gr-%'
|
|
225
|
+
and guardrail.gr_has_role((storage.foldername(name))[1], array['admin', 'editor'])
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
create policy "gr editors browse media"
|
|
229
|
+
on storage.objects for select
|
|
230
|
+
to authenticated
|
|
231
|
+
using (
|
|
232
|
+
bucket_id like 'gr-%'
|
|
233
|
+
and guardrail.gr_has_role((storage.foldername(name))[1], array['admin', 'editor'])
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
-- Creates a Guardrail media bucket on demand. Restricted to 'gr-' names so it
|
|
237
|
+
-- can never touch other buckets, and to active editors so it cannot be called
|
|
238
|
+
-- anonymously. Limits mirror the default bucket above.
|
|
239
|
+
create function guardrail.gr_ensure_bucket(target_bucket text)
|
|
240
|
+
returns void
|
|
241
|
+
language plpgsql
|
|
242
|
+
security definer
|
|
243
|
+
set search_path = guardrail, pg_temp
|
|
244
|
+
as $$
|
|
245
|
+
begin
|
|
246
|
+
if target_bucket !~ '^gr-[a-z0-9][a-z0-9-]{0,50}$' then
|
|
247
|
+
raise exception 'Guardrail bucket names must start with gr- and use a-z, 0-9, hyphens: %',
|
|
248
|
+
target_bucket;
|
|
249
|
+
end if;
|
|
250
|
+
|
|
251
|
+
if not exists (
|
|
252
|
+
select 1 from guardrail.gr_members
|
|
253
|
+
where user_id = auth.uid()
|
|
254
|
+
and status = 'active'
|
|
255
|
+
and role in ('admin', 'editor')
|
|
256
|
+
) then
|
|
257
|
+
raise exception 'Only Guardrail editors can create media buckets';
|
|
258
|
+
end if;
|
|
259
|
+
|
|
260
|
+
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
|
261
|
+
values (
|
|
262
|
+
target_bucket,
|
|
263
|
+
target_bucket,
|
|
264
|
+
true,
|
|
265
|
+
10485760,
|
|
266
|
+
array['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/avif']
|
|
267
|
+
)
|
|
268
|
+
on conflict (id) do nothing;
|
|
269
|
+
end;
|
|
270
|
+
$$;
|
|
271
|
+
|
|
272
|
+
revoke all on function guardrail.gr_ensure_bucket(text) from public;
|
|
273
|
+
grant execute on function guardrail.gr_ensure_bucket(text) to authenticated;
|
|
274
|
+
|
|
275
|
+
/* ----------------------------------------------------------- bootstrap --- */
|
|
276
|
+
-- A site with no members has nobody who can add members, and there is
|
|
277
|
+
-- deliberately no in-app way to grant yourself a role. Seed the first admin
|
|
278
|
+
-- here, replacing both values:
|
|
279
|
+
--
|
|
280
|
+
-- insert into guardrail.gr_members (site_id, email, user_id, role, status)
|
|
281
|
+
-- select 'your-site-id', u.email, u.id, 'admin', 'active'
|
|
282
|
+
-- from auth.users u
|
|
283
|
+
-- where u.email = 'you@example.com'
|
|
284
|
+
-- on conflict (site_id, email) do update
|
|
285
|
+
-- set role = 'admin', status = 'active', user_id = excluded.user_id;
|
|
286
|
+
|
|
287
|
+
/* ------------------------------------------------------------- refresh --- */
|
|
288
|
+
notify pgrst, 'reload schema';
|