@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.
Files changed (111) hide show
  1. package/README.md +339 -0
  2. package/dist/adapters/supabase/auth.d.ts +9 -0
  3. package/dist/adapters/supabase/auth.js +57 -0
  4. package/dist/adapters/supabase/auth.js.map +1 -0
  5. package/dist/adapters/supabase/client.d.ts +28 -0
  6. package/dist/adapters/supabase/client.js +44 -0
  7. package/dist/adapters/supabase/client.js.map +1 -0
  8. package/dist/adapters/supabase/confirm.d.ts +19 -0
  9. package/dist/adapters/supabase/confirm.js +27 -0
  10. package/dist/adapters/supabase/confirm.js.map +1 -0
  11. package/dist/adapters/supabase/db.d.ts +9 -0
  12. package/dist/adapters/supabase/db.js +49 -0
  13. package/dist/adapters/supabase/db.js.map +1 -0
  14. package/dist/adapters/supabase/index.d.ts +25 -0
  15. package/dist/adapters/supabase/index.js +29 -0
  16. package/dist/adapters/supabase/index.js.map +1 -0
  17. package/dist/adapters/supabase/members.d.ts +9 -0
  18. package/dist/adapters/supabase/members.js +73 -0
  19. package/dist/adapters/supabase/members.js.map +1 -0
  20. package/dist/adapters/supabase/repeaters.d.ts +9 -0
  21. package/dist/adapters/supabase/repeaters.js +73 -0
  22. package/dist/adapters/supabase/repeaters.js.map +1 -0
  23. package/dist/adapters/supabase/storage.d.ts +18 -0
  24. package/dist/adapters/supabase/storage.js +93 -0
  25. package/dist/adapters/supabase/storage.js.map +1 -0
  26. package/dist/core/adapter-types.d.ts +212 -0
  27. package/dist/core/adapter-types.js +1 -0
  28. package/dist/core/adapter-types.js.map +1 -0
  29. package/dist/core/bailout.d.ts +9 -0
  30. package/dist/core/bailout.js +14 -0
  31. package/dist/core/bailout.js.map +1 -0
  32. package/dist/core/config.d.ts +27 -0
  33. package/dist/core/config.js +46 -0
  34. package/dist/core/config.js.map +1 -0
  35. package/dist/core/defs.d.ts +22 -0
  36. package/dist/core/defs.js +16 -0
  37. package/dist/core/defs.js.map +1 -0
  38. package/dist/core/types.d.ts +136 -0
  39. package/dist/core/types.js +1 -0
  40. package/dist/core/types.js.map +1 -0
  41. package/dist/index.d.ts +13 -0
  42. package/dist/index.js +43 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/react/Admin.d.ts +8 -0
  45. package/dist/react/Admin.js +116 -0
  46. package/dist/react/Admin.js.map +1 -0
  47. package/dist/react/GRail/client.d.ts +11 -0
  48. package/dist/react/GRail/client.js +242 -0
  49. package/dist/react/GRail/client.js.map +1 -0
  50. package/dist/react/GRail/index.d.ts +12 -0
  51. package/dist/react/GRail/index.js +19 -0
  52. package/dist/react/GRail/index.js.map +1 -0
  53. package/dist/react/GRail/render.d.ts +23 -0
  54. package/dist/react/GRail/render.js +67 -0
  55. package/dist/react/GRail/render.js.map +1 -0
  56. package/dist/react/Icons.d.ts +13 -0
  57. package/dist/react/Icons.js +68 -0
  58. package/dist/react/Icons.js.map +1 -0
  59. package/dist/react/MediaPicker.d.ts +14 -0
  60. package/dist/react/MediaPicker.js +172 -0
  61. package/dist/react/MediaPicker.js.map +1 -0
  62. package/dist/react/Provider.d.ts +11 -0
  63. package/dist/react/Provider.js +26 -0
  64. package/dist/react/Provider.js.map +1 -0
  65. package/dist/react/Repeater/client.d.ts +16 -0
  66. package/dist/react/Repeater/client.js +148 -0
  67. package/dist/react/Repeater/client.js.map +1 -0
  68. package/dist/react/Repeater/index.d.ts +35 -0
  69. package/dist/react/Repeater/index.js +22 -0
  70. package/dist/react/Repeater/index.js.map +1 -0
  71. package/dist/react/Syncer.d.ts +17 -0
  72. package/dist/react/Syncer.js +23 -0
  73. package/dist/react/Syncer.js.map +1 -0
  74. package/dist/react/Team.d.ts +7 -0
  75. package/dist/react/Team.js +190 -0
  76. package/dist/react/Team.js.map +1 -0
  77. package/dist/react/resizeImage.d.ts +25 -0
  78. package/dist/react/resizeImage.js +52 -0
  79. package/dist/react/resizeImage.js.map +1 -0
  80. package/dist/react/styles.d.ts +421 -0
  81. package/dist/react/styles.js +402 -0
  82. package/dist/react/styles.js.map +1 -0
  83. package/dist/react/useAnchorRect.d.ts +17 -0
  84. package/dist/react/useAnchorRect.js +32 -0
  85. package/dist/react/useAnchorRect.js.map +1 -0
  86. package/dist/server/actions.d.ts +41 -0
  87. package/dist/server/actions.js +214 -0
  88. package/dist/server/actions.js.map +1 -0
  89. package/dist/server/data.d.ts +10 -0
  90. package/dist/server/data.js +24 -0
  91. package/dist/server/data.js.map +1 -0
  92. package/dist/server/fieldData.d.ts +26 -0
  93. package/dist/server/fieldData.js +9 -0
  94. package/dist/server/fieldData.js.map +1 -0
  95. package/dist/server/loader.d.ts +6 -0
  96. package/dist/server/loader.js +53 -0
  97. package/dist/server/loader.js.map +1 -0
  98. package/dist/server/repeaterActions.d.ts +20 -0
  99. package/dist/server/repeaterActions.js +72 -0
  100. package/dist/server/repeaterActions.js.map +1 -0
  101. package/dist/server/repeaterData.d.ts +18 -0
  102. package/dist/server/repeaterData.js +27 -0
  103. package/dist/server/repeaterData.js.map +1 -0
  104. package/dist/server/session.d.ts +14 -0
  105. package/dist/server/session.js +37 -0
  106. package/dist/server/session.js.map +1 -0
  107. package/dist/server/wrapper.d.ts +12 -0
  108. package/dist/server/wrapper.js +42 -0
  109. package/dist/server/wrapper.js.map +1 -0
  110. package/package.json +58 -0
  111. 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';