@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
package/README.md
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
# Guardrail
|
|
2
|
+
|
|
3
|
+
Lightweight inline content editing for developer-owned Next.js sites.
|
|
4
|
+
|
|
5
|
+
Guardrail lets a marketer edit the copy and images on a site you built, without
|
|
6
|
+
taking control of your markup, styling, routes, or build. There is no content
|
|
7
|
+
model to define and no dashboard to configure — you wrap the strings you want
|
|
8
|
+
editable, and that's the whole surface area.
|
|
9
|
+
|
|
10
|
+
## How it works
|
|
11
|
+
|
|
12
|
+
- **Anonymous visitors get server-rendered HTML.** `<GRail>` is an async Server
|
|
13
|
+
Component; it resolves content during SSR and ships no editor JavaScript.
|
|
14
|
+
- **The editor only loads for a verified session.** Client components are
|
|
15
|
+
reached only after the auth adapter confirms a real session.
|
|
16
|
+
- **Un-edited fields render the JSX you wrote.** Nothing is written to the
|
|
17
|
+
database until an editor saves, so a fresh install renders your hardcoded
|
|
18
|
+
content unchanged.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pnpm add @guard-rail/core @supabase/ssr @supabase/supabase-js
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Set up the database:
|
|
27
|
+
|
|
28
|
+
1. Copy `node_modules/guardrail/supabase/migrations/*.sql` into your own
|
|
29
|
+
project's `supabase/migrations/`, then run `supabase db push`. No Supabase
|
|
30
|
+
CLI yet? Paste the file's contents into the SQL editor instead — it runs
|
|
31
|
+
the same statements, just without the CLI's version tracking.
|
|
32
|
+
2. **Required:** add `guardrail` under **Settings > API > Exposed schemas** in
|
|
33
|
+
the Supabase dashboard. PostgREST only serves schemas on that list; every
|
|
34
|
+
table below is otherwise invisible to the client with a "not found in
|
|
35
|
+
schema cache" error that looks identical to a missing table.
|
|
36
|
+
|
|
37
|
+
Guardrail's tables live in their own `guardrail` Postgres schema, not `public`
|
|
38
|
+
— so a `supabase db reset`, a broad `DROP ... CASCADE`, or a migration tool
|
|
39
|
+
that treats `public` as its own to reconcile can't touch them.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
SUPABASE_URL=...
|
|
43
|
+
SUPABASE_ANON_KEY=...
|
|
44
|
+
GUARDRAIL_SITE_ID=my-site # optional; defaults to "default"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
Wrap the root layout:
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
// app/layout.tsx
|
|
53
|
+
import { withGuardrail } from "@guard-rail/core";
|
|
54
|
+
|
|
55
|
+
export default withGuardrail(function RootLayout({ children }) {
|
|
56
|
+
return (
|
|
57
|
+
<html lang="en">
|
|
58
|
+
<body>{children}</body>
|
|
59
|
+
</html>
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Mark fields editable:
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
import { GRail } from "@guard-rail/core";
|
|
68
|
+
|
|
69
|
+
<GRail id="home.hero.title" as="h1" className="text-3xl font-bold">
|
|
70
|
+
Ship faster with less friction
|
|
71
|
+
</GRail>
|
|
72
|
+
|
|
73
|
+
<GRail id="home.hero.logo" type="image" as="img" src="/logo.svg" alt="Logo" />
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`id` is the content key. `className` and every other prop pass straight through
|
|
77
|
+
to the element — Guardrail never styles your content.
|
|
78
|
+
|
|
79
|
+
To edit, visit any page with `?edit=true`, sign in with a Supabase user, and
|
|
80
|
+
click a field.
|
|
81
|
+
|
|
82
|
+
## Team and roles
|
|
83
|
+
|
|
84
|
+
Two roles, deliberately not a permission matrix:
|
|
85
|
+
|
|
86
|
+
| Role | Can |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `admin` | edit content, manage the team |
|
|
89
|
+
| `editor` | edit content |
|
|
90
|
+
|
|
91
|
+
There is no read-only "viewer" — without edit access, a member sees exactly
|
|
92
|
+
what an anonymous visitor sees, which is already the site's own frontend.
|
|
93
|
+
|
|
94
|
+
Membership is Guardrail's own table, not the auth provider's, so the identity
|
|
95
|
+
vendor and the database vendor never have to be the same. Invites are recorded
|
|
96
|
+
against an email address and claimed when that person first signs in.
|
|
97
|
+
|
|
98
|
+
Guardrail records an invite but does not send it — wire your own mail provider:
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
export default withGuardrail(RootLayout, {
|
|
102
|
+
onInvite: async ({ email, role, siteId, invitedBy }) => {
|
|
103
|
+
await resend.emails.send({ to: email, /* your template */ });
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**Invite emails (Supabase).** Inviting someone creates their account on first
|
|
109
|
+
use and emails them a sign-in link. Three one-time steps in the Supabase
|
|
110
|
+
dashboard, plus one route in your app:
|
|
111
|
+
|
|
112
|
+
1. **Route** — `app/guardrail/confirm/route.ts`:
|
|
113
|
+
```ts
|
|
114
|
+
export const GET = supabaseConfirmRoute({ url, anonKey });
|
|
115
|
+
```
|
|
116
|
+
2. **Auth → URL Configuration** — set Site URL to your app's origin, and add
|
|
117
|
+
`<origin>/**` to Redirect URLs.
|
|
118
|
+
3. **Auth → Email Templates** — in both **Confirm signup** and **Magic Link**,
|
|
119
|
+
point the link at the confirm route using the token hash:
|
|
120
|
+
```html
|
|
121
|
+
<a href="{{ .SiteURL }}/guardrail/confirm?token_hash={{ .TokenHash }}&type=email">Sign in</a>
|
|
122
|
+
```
|
|
123
|
+
The default `{{ .ConfirmationURL }}` uses a PKCE code that only works in
|
|
124
|
+
the browser that requested it — the admin's, not the invitee's.
|
|
125
|
+
4. **Auth → SMTP** — Supabase's built-in mailer allows only a few emails an
|
|
126
|
+
hour. Configure custom SMTP (Resend, Postmark, SES) before relying on
|
|
127
|
+
invites. Submitting the same address again re-sends a pending invite.
|
|
128
|
+
|
|
129
|
+
**Bootstrapping.** A site with no members has nobody who can add members, and
|
|
130
|
+
there is deliberately no in-app way to grant yourself a role — that would mean
|
|
131
|
+
an RLS policy letting a user write their own membership row, which is the same
|
|
132
|
+
as letting anyone make themselves an admin. Seed the first admin with the SQL
|
|
133
|
+
at the bottom of the init migration.
|
|
134
|
+
|
|
135
|
+
The bundled drawer lives behind the toolbar's **Team** button. To build your
|
|
136
|
+
own instead, `listTeam`, `inviteMember`, `updateMemberRole`, and `removeMember`
|
|
137
|
+
are exported from the package root.
|
|
138
|
+
|
|
139
|
+
## Images and storage
|
|
140
|
+
|
|
141
|
+
Storage is its own adapter slot, separate from the database, so media can live
|
|
142
|
+
with a different provider than content. It is optional — without it, image
|
|
143
|
+
fields fall back to a plain URL input.
|
|
144
|
+
|
|
145
|
+
```tsx
|
|
146
|
+
storage: supabaseStorage({
|
|
147
|
+
url, anonKey,
|
|
148
|
+
bucket: process.env.GUARDRAIL_STORAGE_BUCKET, // optional, defaults to "gr-media"
|
|
149
|
+
}),
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Editors get a picker to browse, upload, and select images. Files go straight
|
|
153
|
+
from the browser to storage via a signed URL — server actions cap bodies at
|
|
154
|
+
~1MB, so bytes never pass through Next.js. PNG, JPEG, WebP, GIF, and AVIF up to
|
|
155
|
+
10 MB; SVG is excluded because it can carry script.
|
|
156
|
+
|
|
157
|
+
Images are scaled down in the browser before upload so their longest edge is at
|
|
158
|
+
most 2560px — WordPress's default — and re-encoding strips EXIF data such as GPS
|
|
159
|
+
location. Anything already within that size uploads untouched. GIF (animation)
|
|
160
|
+
and AVIF (limited browser encoding) are never resized. Because this runs
|
|
161
|
+
client-side it is a convenience, not enforcement; the 10 MB cap still applies to
|
|
162
|
+
the file after resizing.
|
|
163
|
+
|
|
164
|
+
**Setup**
|
|
165
|
+
|
|
166
|
+
1. The init migration creates the default `gr-media` bucket and storage
|
|
167
|
+
policies scoped to the `gr-` prefix. To use a different bucket, just pass
|
|
168
|
+
its name: any `gr-*` bucket is created automatically on the first upload,
|
|
169
|
+
no SQL edits needed.
|
|
170
|
+
2. Allow the storage host in `next.config.ts`, or `next/image` will throw:
|
|
171
|
+
```ts
|
|
172
|
+
images: {
|
|
173
|
+
remotePatterns: [{
|
|
174
|
+
protocol: "https",
|
|
175
|
+
hostname: new URL(process.env.SUPABASE_URL!).hostname,
|
|
176
|
+
pathname: "/storage/v1/object/public/**",
|
|
177
|
+
}],
|
|
178
|
+
},
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Alt text is stored on the field, not the file — one image can need different
|
|
182
|
+
alt text on different pages. Natural size is measured when an image is picked;
|
|
183
|
+
the developer's `width` stays fixed and height follows the new image's
|
|
184
|
+
proportions, so a replacement never stretches or breaks the layout.
|
|
185
|
+
|
|
186
|
+
## Repeaters
|
|
187
|
+
|
|
188
|
+
Field editing answers "change what's here." It has no answer for "add a
|
|
189
|
+
thing" — a new service card, blog post, or product — because there is no JSX
|
|
190
|
+
for a marketer to click on. `<GRepeater>` is the one primitive for that: an
|
|
191
|
+
ordered list of instances of **one developer-authored template component**.
|
|
192
|
+
Not a schema, not a content model — the shape of an item is whatever JSX the
|
|
193
|
+
template renders, using ordinary `<GRail>` fields namespaced under the item's
|
|
194
|
+
id. Adding a field to an item means adding another `<GRail>`, same as
|
|
195
|
+
anywhere else.
|
|
196
|
+
|
|
197
|
+
```tsx
|
|
198
|
+
// components/ServiceCard.tsx — the one place the item's shape lives
|
|
199
|
+
function ServiceCard({ item }: RepeaterItemProps) {
|
|
200
|
+
return (
|
|
201
|
+
<div>
|
|
202
|
+
<GRail id={`services.${item.id}.title`} as="h3">New service</GRail>
|
|
203
|
+
<GRail id={`services.${item.id}.description`}>Describe it.</GRail>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// A row of cards, all on one page:
|
|
209
|
+
<GRepeater id="services" as={ServiceCard} />
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Editors get add/remove/reorder controls; anonymous visitors get the template
|
|
213
|
+
rendered N times with no admin JS, same SSR/CSR split as `<GRail>` itself.
|
|
214
|
+
|
|
215
|
+
**Routed items** — a product, a blog post — additionally get their own page.
|
|
216
|
+
Guardrail never owns routing: pass `routed`, which prompts for a slug when an
|
|
217
|
+
item is added, and the developer writes an ordinary dynamic route that
|
|
218
|
+
resolves the item by that slug:
|
|
219
|
+
|
|
220
|
+
```tsx
|
|
221
|
+
// app/products/[slug]/page.tsx
|
|
222
|
+
export async function generateStaticParams() {
|
|
223
|
+
const items = await listRepeaterItems("products");
|
|
224
|
+
return items.filter((i) => i.slug).map((i) => ({ slug: i.slug! }));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export default async function ProductPage({ params }) {
|
|
228
|
+
const { slug } = await params;
|
|
229
|
+
const item = await getRepeaterItemBySlug("products", slug);
|
|
230
|
+
if (!item) notFound();
|
|
231
|
+
return <GRail id={`products.${item.id}.name`} as="h1">New product</GRail>;
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The developer still owns the template, the route pattern, and whether the page
|
|
236
|
+
is static or dynamic. A repeater only ever answers "how many, and in what
|
|
237
|
+
order" — never "what shape."
|
|
238
|
+
|
|
239
|
+
On a routed item's own summary card, give its fields `editable={false}` so the
|
|
240
|
+
card stays purely a link to the item's page rather than competing with it for
|
|
241
|
+
clicks — editing then only happens on the canonical route, where the same
|
|
242
|
+
fields render fully editable with no `editable` prop needed:
|
|
243
|
+
|
|
244
|
+
```tsx
|
|
245
|
+
// The card that links elsewhere: display only.
|
|
246
|
+
<GRail id={`products.${item.id}.name`} editable={false}>New product</GRail>
|
|
247
|
+
|
|
248
|
+
// The item's own page: same field, fully editable.
|
|
249
|
+
<GRail id={`products.${item.id}.name`}>New product</GRail>
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
`editable={false}` works on any `<GRail>`, not only inside a repeater — useful
|
|
253
|
+
anywhere content is shown without wanting an edit affordance there.
|
|
254
|
+
|
|
255
|
+
**Not covered, on purpose:** relations between items, custom field types,
|
|
256
|
+
filtering/querying, commerce logic, or a marketer freely composing a page from
|
|
257
|
+
scratch. A visual page builder (Puck, say) is a better fit for that and can
|
|
258
|
+
sit alongside `<GRepeater>` later — it would decide composition, while
|
|
259
|
+
`<GRail>` still owns each block's leaf content.
|
|
260
|
+
|
|
261
|
+
## Extending the toolbar
|
|
262
|
+
|
|
263
|
+
The bundled toolbar (edit indicator, Team, Log out) has one extension point:
|
|
264
|
+
|
|
265
|
+
```tsx
|
|
266
|
+
withGuardrail(RootLayout, {
|
|
267
|
+
...supabase,
|
|
268
|
+
siteId,
|
|
269
|
+
toolbarExtra: <a href="/orders">Orders</a>, // your JSX, your styling
|
|
270
|
+
});
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Plain content, not a component reference or a structured item list — same
|
|
274
|
+
"expose the primitive, developer owns the styling" rule as `onInvite`.
|
|
275
|
+
|
|
276
|
+
## Reading a value without rendering it
|
|
277
|
+
|
|
278
|
+
`<GRail>` always renders something. For the cases where the final markup has
|
|
279
|
+
to be something Guardrail doesn't produce — an embed iframe's `src`, an
|
|
280
|
+
`og:image` meta tag, structured data — `getFieldValue` reads the same stored
|
|
281
|
+
value with no markup attached:
|
|
282
|
+
|
|
283
|
+
```tsx
|
|
284
|
+
const video = await getFieldValue("hero.video");
|
|
285
|
+
// video?.href — build your own <iframe src={video.href}> from it
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
It's read-only: give the same id a normal `<GRail>` somewhere so an editor can
|
|
289
|
+
change it. `getFieldValue` returns `null` until something is saved — there's
|
|
290
|
+
no `children` to fall back to when nothing is rendered — so supply your own
|
|
291
|
+
default. It shares `<GRail>`'s batching, so calling it alongside other fields
|
|
292
|
+
(including several via `Promise.all`) still resolves in one query.
|
|
293
|
+
|
|
294
|
+
## Custom adapters
|
|
295
|
+
|
|
296
|
+
Guardrail defaults to Supabase. To use another backend, implement `GRDBAdapter`
|
|
297
|
+
and `GRAuthAdapter` and pass them to `withGuardrail`:
|
|
298
|
+
|
|
299
|
+
```tsx
|
|
300
|
+
export default withGuardrail(RootLayout, {
|
|
301
|
+
db: new MyDBAdapter(),
|
|
302
|
+
auth: new MyAuthAdapter(),
|
|
303
|
+
siteId: "my-site",
|
|
304
|
+
});
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Only `getSession()` and `logout()` are required. `authenticate` is optional —
|
|
308
|
+
adapters built on magic links, SSO, or a hosted sign-in page omit it and set
|
|
309
|
+
`signInUrl`, and Guardrail links there instead of rendering a password form it
|
|
310
|
+
cannot fulfil. `inviteUser` is optional too; without it, invites fall back to
|
|
311
|
+
your `onInvite` callback.
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
const clerkAuth: GRAuthAdapter = {
|
|
315
|
+
getSession: async () => { /* ... */ },
|
|
316
|
+
logout: async () => { /* ... */ },
|
|
317
|
+
signInUrl: "/sign-in", // no password form; Guardrail links here
|
|
318
|
+
};
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
`getSession()` is the sole authorization check for writes. It must validate the
|
|
322
|
+
session against the provider — never trust the presence of a cookie.
|
|
323
|
+
|
|
324
|
+
`getOverrides(pathKeys, siteId)` is batched by design — it receives every key a
|
|
325
|
+
render pass needs, already deduplicated. Adapters map their own storage naming
|
|
326
|
+
at this boundary; core speaks `pathKey` / `siteId` only.
|
|
327
|
+
|
|
328
|
+
## Performance
|
|
329
|
+
|
|
330
|
+
Fields are resolved through a request-scoped batching loader: every `<GRail>`
|
|
331
|
+
rendered in the same pass is collected and fetched in a single query. Set
|
|
332
|
+
`GUARDRAIL_DEBUG=1` to log how many keys each batch resolved.
|
|
333
|
+
|
|
334
|
+
## Known limitations
|
|
335
|
+
|
|
336
|
+
- `withGuardrail` reads cookies in the root layout, which opts routes into
|
|
337
|
+
dynamic rendering. See the repo README for the plan.
|
|
338
|
+
- Saving revalidates the entire layout tree rather than the affected route.
|
|
339
|
+
- No drafts, revisions, or scheduled publishing. Saves are live.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { SupabaseCredentials } from './client.js';
|
|
2
|
+
import { GRAuthAdapter } from '../../core/adapter-types.js';
|
|
3
|
+
import '@supabase/supabase-js';
|
|
4
|
+
import 'react';
|
|
5
|
+
import '../../core/types.js';
|
|
6
|
+
|
|
7
|
+
declare function supabaseAuth(creds: SupabaseCredentials): GRAuthAdapter;
|
|
8
|
+
|
|
9
|
+
export { supabaseAuth };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { anonClient, assertCredentials, sessionClient } from "./client";
|
|
2
|
+
function supabaseAuth(creds) {
|
|
3
|
+
assertCredentials(creds);
|
|
4
|
+
return {
|
|
5
|
+
async authenticate(email, password) {
|
|
6
|
+
const client = await sessionClient(creds);
|
|
7
|
+
const { data, error } = await client.auth.signInWithPassword({
|
|
8
|
+
email,
|
|
9
|
+
password
|
|
10
|
+
});
|
|
11
|
+
if (error) return { success: false, error: error.message };
|
|
12
|
+
if (!data.user) return { success: false, error: "User not found." };
|
|
13
|
+
return {
|
|
14
|
+
success: true,
|
|
15
|
+
session: { userId: data.user.id, email: data.user.email }
|
|
16
|
+
};
|
|
17
|
+
},
|
|
18
|
+
/**
|
|
19
|
+
* Uses getUser(), which revalidates the JWT against the Supabase auth
|
|
20
|
+
* server. getSession() only decodes the cookie, so it would trust a forged
|
|
21
|
+
* token.
|
|
22
|
+
*/
|
|
23
|
+
async getSession() {
|
|
24
|
+
const client = await sessionClient(creds);
|
|
25
|
+
const { data, error } = await client.auth.getUser();
|
|
26
|
+
if (error || !data.user) return null;
|
|
27
|
+
return { userId: data.user.id, email: data.user.email };
|
|
28
|
+
},
|
|
29
|
+
/**
|
|
30
|
+
* Sends a sign-in link that creates the account on first use.
|
|
31
|
+
*
|
|
32
|
+
* Requested through the cookie-free client on purpose. A session client
|
|
33
|
+
* would use PKCE and drop the code verifier into the *admin's* browser,
|
|
34
|
+
* so the link would fail when the invitee opened it anywhere else. The
|
|
35
|
+
* email template must point at the confirm route with {{ .TokenHash }},
|
|
36
|
+
* which verifies server-side and needs no verifier.
|
|
37
|
+
*/
|
|
38
|
+
async inviteUser(email, options) {
|
|
39
|
+
const { error } = await anonClient(creds).auth.signInWithOtp({
|
|
40
|
+
email,
|
|
41
|
+
options: {
|
|
42
|
+
shouldCreateUser: true,
|
|
43
|
+
...options?.redirectTo ? { emailRedirectTo: options.redirectTo } : {}
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
return error ? { success: false, error: error.message } : { success: true };
|
|
47
|
+
},
|
|
48
|
+
async logout() {
|
|
49
|
+
const client = await sessionClient(creds);
|
|
50
|
+
await client.auth.signOut();
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export {
|
|
55
|
+
supabaseAuth
|
|
56
|
+
};
|
|
57
|
+
//# sourceMappingURL=auth.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/adapters/supabase/auth.ts"],"sourcesContent":["import { anonClient, assertCredentials, sessionClient } from \"./client\";\nimport type { SupabaseCredentials } from \"./client\";\nimport type { GRAuthAdapter, GRSession } from \"../../core/adapter-types\";\n\nexport function supabaseAuth(creds: SupabaseCredentials): GRAuthAdapter {\n assertCredentials(creds);\n\n return {\n async authenticate(email, password) {\n const client = await sessionClient(creds);\n const { data, error } = await client.auth.signInWithPassword({\n email,\n password,\n });\n\n if (error) return { success: false, error: error.message };\n if (!data.user) return { success: false, error: \"User not found.\" };\n\n return {\n success: true,\n session: { userId: data.user.id, email: data.user.email },\n };\n },\n\n /**\n * Uses getUser(), which revalidates the JWT against the Supabase auth\n * server. getSession() only decodes the cookie, so it would trust a forged\n * token.\n */\n async getSession(): Promise<GRSession | null> {\n const client = await sessionClient(creds);\n const { data, error } = await client.auth.getUser();\n if (error || !data.user) return null;\n return { userId: data.user.id, email: data.user.email };\n },\n\n /**\n * Sends a sign-in link that creates the account on first use.\n *\n * Requested through the cookie-free client on purpose. A session client\n * would use PKCE and drop the code verifier into the *admin's* browser,\n * so the link would fail when the invitee opened it anywhere else. The\n * email template must point at the confirm route with {{ .TokenHash }},\n * which verifies server-side and needs no verifier.\n */\n async inviteUser(email, options) {\n const { error } = await anonClient(creds).auth.signInWithOtp({\n email,\n options: {\n shouldCreateUser: true,\n ...(options?.redirectTo ? { emailRedirectTo: options.redirectTo } : {}),\n },\n });\n return error\n ? { success: false, error: error.message }\n : { success: true };\n },\n\n async logout() {\n const client = await sessionClient(creds);\n await client.auth.signOut();\n },\n };\n}\n"],"mappings":"AAAA,SAAS,YAAY,mBAAmB,qBAAqB;AAItD,SAAS,aAAa,OAA2C;AACtE,oBAAkB,KAAK;AAEvB,SAAO;AAAA,IACL,MAAM,aAAa,OAAO,UAAU;AAClC,YAAM,SAAS,MAAM,cAAc,KAAK;AACxC,YAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK,mBAAmB;AAAA,QAC3D;AAAA,QACA;AAAA,MACF,CAAC;AAED,UAAI,MAAO,QAAO,EAAE,SAAS,OAAO,OAAO,MAAM,QAAQ;AACzD,UAAI,CAAC,KAAK,KAAM,QAAO,EAAE,SAAS,OAAO,OAAO,kBAAkB;AAElE,aAAO;AAAA,QACL,SAAS;AAAA,QACT,SAAS,EAAE,QAAQ,KAAK,KAAK,IAAI,OAAO,KAAK,KAAK,MAAM;AAAA,MAC1D;AAAA,IACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAOA,MAAM,aAAwC;AAC5C,YAAM,SAAS,MAAM,cAAc,KAAK;AACxC,YAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAAO,KAAK,QAAQ;AAClD,UAAI,SAAS,CAAC,KAAK,KAAM,QAAO;AAChC,aAAO,EAAE,QAAQ,KAAK,KAAK,IAAI,OAAO,KAAK,KAAK,MAAM;AAAA,IACxD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAWA,MAAM,WAAW,OAAO,SAAS;AAC/B,YAAM,EAAE,MAAM,IAAI,MAAM,WAAW,KAAK,EAAE,KAAK,cAAc;AAAA,QAC3D;AAAA,QACA,SAAS;AAAA,UACP,kBAAkB;AAAA,UAClB,GAAI,SAAS,aAAa,EAAE,iBAAiB,QAAQ,WAAW,IAAI,CAAC;AAAA,QACvE;AAAA,MACF,CAAC;AACD,aAAO,QACH,EAAE,SAAS,OAAO,OAAO,MAAM,QAAQ,IACvC,EAAE,SAAS,KAAK;AAAA,IACtB;AAAA,IAEA,MAAM,SAAS;AACb,YAAM,SAAS,MAAM,cAAc,KAAK;AACxC,YAAM,OAAO,KAAK,QAAQ;AAAA,IAC5B;AAAA,EACF;AACF;","names":[]}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { SupabaseClient } from '@supabase/supabase-js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Credentials are passed in, never read from process.env here.
|
|
5
|
+
*
|
|
6
|
+
* An adapter that reaches into the environment hides its own requirements —
|
|
7
|
+
* nothing in the type signature says which vars must exist, so a missing or
|
|
8
|
+
* stale one surfaces as a runtime fetch failure. It also makes two sites on
|
|
9
|
+
* two different projects impossible, since the credentials become global.
|
|
10
|
+
*/
|
|
11
|
+
interface SupabaseCredentials {
|
|
12
|
+
url: string;
|
|
13
|
+
anonKey: string;
|
|
14
|
+
}
|
|
15
|
+
declare function assertCredentials(creds: SupabaseCredentials): void;
|
|
16
|
+
/**
|
|
17
|
+
* Cookie-free client for reads. A single cookies() call in the render path
|
|
18
|
+
* opts the whole route out of static generation, so public content reads must
|
|
19
|
+
* never touch them.
|
|
20
|
+
*/
|
|
21
|
+
declare function anonClient(creds: SupabaseCredentials): SupabaseClient;
|
|
22
|
+
/**
|
|
23
|
+
* Request-scoped client carrying the editor's auth cookies, so writes run as
|
|
24
|
+
* the signed-in user and RLS can enforce authorization independently.
|
|
25
|
+
*/
|
|
26
|
+
declare function sessionClient(creds: SupabaseCredentials): Promise<SupabaseClient>;
|
|
27
|
+
|
|
28
|
+
export { type SupabaseCredentials, anonClient, assertCredentials, sessionClient };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createServerClient } from "@supabase/ssr";
|
|
2
|
+
import { createClient } from "@supabase/supabase-js";
|
|
3
|
+
import { cookies } from "next/headers";
|
|
4
|
+
function assertCredentials(creds) {
|
|
5
|
+
if (!creds?.url || !creds?.anonKey) {
|
|
6
|
+
throw new Error(
|
|
7
|
+
"Guardrail (Supabase): both `url` and `anonKey` are required."
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const anonClients = /* @__PURE__ */ new Map();
|
|
12
|
+
function anonClient(creds) {
|
|
13
|
+
const key = `${creds.url}::${creds.anonKey}`;
|
|
14
|
+
let client = anonClients.get(key);
|
|
15
|
+
if (!client) {
|
|
16
|
+
client = createClient(creds.url, creds.anonKey, {
|
|
17
|
+
auth: { persistSession: false, autoRefreshToken: false }
|
|
18
|
+
});
|
|
19
|
+
anonClients.set(key, client);
|
|
20
|
+
}
|
|
21
|
+
return client;
|
|
22
|
+
}
|
|
23
|
+
async function sessionClient(creds) {
|
|
24
|
+
const cookieStore = await cookies();
|
|
25
|
+
return createServerClient(creds.url, creds.anonKey, {
|
|
26
|
+
cookies: {
|
|
27
|
+
getAll: () => cookieStore.getAll(),
|
|
28
|
+
setAll: (cookiesToSet) => {
|
|
29
|
+
try {
|
|
30
|
+
cookiesToSet.forEach(
|
|
31
|
+
({ name, value, options }) => cookieStore.set(name, value, options)
|
|
32
|
+
);
|
|
33
|
+
} catch {
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export {
|
|
40
|
+
anonClient,
|
|
41
|
+
assertCredentials,
|
|
42
|
+
sessionClient
|
|
43
|
+
};
|
|
44
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/adapters/supabase/client.ts"],"sourcesContent":["import { createServerClient } from \"@supabase/ssr\";\nimport { createClient, type SupabaseClient } from \"@supabase/supabase-js\";\nimport { cookies } from \"next/headers\";\n\n/**\n * Credentials are passed in, never read from process.env here.\n *\n * An adapter that reaches into the environment hides its own requirements —\n * nothing in the type signature says which vars must exist, so a missing or\n * stale one surfaces as a runtime fetch failure. It also makes two sites on\n * two different projects impossible, since the credentials become global.\n */\nexport interface SupabaseCredentials {\n url: string;\n anonKey: string;\n}\n\nexport function assertCredentials(creds: SupabaseCredentials): void {\n if (!creds?.url || !creds?.anonKey) {\n throw new Error(\n \"Guardrail (Supabase): both `url` and `anonKey` are required.\",\n );\n }\n}\n\nconst anonClients = new Map<string, SupabaseClient>();\n\n/**\n * Cookie-free client for reads. A single cookies() call in the render path\n * opts the whole route out of static generation, so public content reads must\n * never touch them.\n */\nexport function anonClient(creds: SupabaseCredentials): SupabaseClient {\n const key = `${creds.url}::${creds.anonKey}`;\n let client = anonClients.get(key);\n if (!client) {\n client = createClient(creds.url, creds.anonKey, {\n auth: { persistSession: false, autoRefreshToken: false },\n });\n anonClients.set(key, client);\n }\n return client;\n}\n\n/**\n * Request-scoped client carrying the editor's auth cookies, so writes run as\n * the signed-in user and RLS can enforce authorization independently.\n */\nexport async function sessionClient(\n creds: SupabaseCredentials,\n): Promise<SupabaseClient> {\n const cookieStore = await cookies();\n\n return createServerClient(creds.url, creds.anonKey, {\n cookies: {\n getAll: () => cookieStore.getAll(),\n setAll: (cookiesToSet) => {\n try {\n cookiesToSet.forEach(({ name, value, options }) =>\n cookieStore.set(name, value, options),\n );\n } catch {\n // Read-only cookies in a Server Component; safe to ignore when a\n // Server Action or middleware refreshes the session.\n }\n },\n },\n });\n}\n"],"mappings":"AAAA,SAAS,0BAA0B;AACnC,SAAS,oBAAyC;AAClD,SAAS,eAAe;AAejB,SAAS,kBAAkB,OAAkC;AAClE,MAAI,CAAC,OAAO,OAAO,CAAC,OAAO,SAAS;AAClC,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACF;AAEA,MAAM,cAAc,oBAAI,IAA4B;AAO7C,SAAS,WAAW,OAA4C;AACrE,QAAM,MAAM,GAAG,MAAM,GAAG,KAAK,MAAM,OAAO;AAC1C,MAAI,SAAS,YAAY,IAAI,GAAG;AAChC,MAAI,CAAC,QAAQ;AACX,aAAS,aAAa,MAAM,KAAK,MAAM,SAAS;AAAA,MAC9C,MAAM,EAAE,gBAAgB,OAAO,kBAAkB,MAAM;AAAA,IACzD,CAAC;AACD,gBAAY,IAAI,KAAK,MAAM;AAAA,EAC7B;AACA,SAAO;AACT;AAMA,eAAsB,cACpB,OACyB;AACzB,QAAM,cAAc,MAAM,QAAQ;AAElC,SAAO,mBAAmB,MAAM,KAAK,MAAM,SAAS;AAAA,IAClD,SAAS;AAAA,MACP,QAAQ,MAAM,YAAY,OAAO;AAAA,MACjC,QAAQ,CAAC,iBAAiB;AACxB,YAAI;AACF,uBAAa;AAAA,YAAQ,CAAC,EAAE,MAAM,OAAO,QAAQ,MAC3C,YAAY,IAAI,MAAM,OAAO,OAAO;AAAA,UACtC;AAAA,QACF,QAAQ;AAAA,QAGR;AAAA,MACF;AAAA,IACF;AAAA,EACF,CAAC;AACH;","names":[]}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { SupabaseCredentials } from './client.js';
|
|
2
|
+
import '@supabase/supabase-js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Route handler that turns an emailed link into a session.
|
|
6
|
+
*
|
|
7
|
+
* Mount it in the app:
|
|
8
|
+
* // app/guardrail/confirm/route.ts
|
|
9
|
+
* export const GET = supabaseConfirmRoute({ url, anonKey });
|
|
10
|
+
*
|
|
11
|
+
* verifyOtp with a token_hash works in whichever browser opens the link, unlike
|
|
12
|
+
* a PKCE code exchange. Once the session cookie is set, the next render claims
|
|
13
|
+
* any pending invitation addressed to that email.
|
|
14
|
+
*/
|
|
15
|
+
declare function supabaseConfirmRoute(creds: SupabaseCredentials, options?: {
|
|
16
|
+
next?: string;
|
|
17
|
+
}): (request: Request) => Promise<Response>;
|
|
18
|
+
|
|
19
|
+
export { supabaseConfirmRoute };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { redirect } from "next/navigation";
|
|
2
|
+
import { assertCredentials, sessionClient } from "./client";
|
|
3
|
+
function safePath(value, fallback) {
|
|
4
|
+
if (!value || !value.startsWith("/") || value.startsWith("//")) return fallback;
|
|
5
|
+
return value;
|
|
6
|
+
}
|
|
7
|
+
function supabaseConfirmRoute(creds, options = {}) {
|
|
8
|
+
assertCredentials(creds);
|
|
9
|
+
const fallback = options.next ?? "/?edit=true";
|
|
10
|
+
return async function GET(request) {
|
|
11
|
+
const url = new URL(request.url);
|
|
12
|
+
const tokenHash = url.searchParams.get("token_hash");
|
|
13
|
+
const type = url.searchParams.get("type");
|
|
14
|
+
const next = safePath(url.searchParams.get("next"), fallback);
|
|
15
|
+
if (tokenHash && type) {
|
|
16
|
+
const client = await sessionClient(creds);
|
|
17
|
+
const { error } = await client.auth.verifyOtp({ type, token_hash: tokenHash });
|
|
18
|
+
if (!error) redirect(next);
|
|
19
|
+
console.error("Guardrail (Supabase): link verification failed", error.message);
|
|
20
|
+
}
|
|
21
|
+
redirect(`${fallback}${fallback.includes("?") ? "&" : "?"}gr_auth_error=link`);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export {
|
|
25
|
+
supabaseConfirmRoute
|
|
26
|
+
};
|
|
27
|
+
//# sourceMappingURL=confirm.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/adapters/supabase/confirm.ts"],"sourcesContent":["import type { EmailOtpType } from \"@supabase/supabase-js\";\nimport { redirect } from \"next/navigation\";\nimport { assertCredentials, sessionClient } from \"./client\";\nimport type { SupabaseCredentials } from \"./client\";\n\n/**\n * Only same-origin paths. Accepting an arbitrary `next` would make this route\n * an open redirect — a signed-in link on your domain that forwards anywhere.\n */\nfunction safePath(value: string | null, fallback: string): string {\n if (!value || !value.startsWith(\"/\") || value.startsWith(\"//\")) return fallback;\n return value;\n}\n\n/**\n * Route handler that turns an emailed link into a session.\n *\n * Mount it in the app:\n * // app/guardrail/confirm/route.ts\n * export const GET = supabaseConfirmRoute({ url, anonKey });\n *\n * verifyOtp with a token_hash works in whichever browser opens the link, unlike\n * a PKCE code exchange. Once the session cookie is set, the next render claims\n * any pending invitation addressed to that email.\n */\nexport function supabaseConfirmRoute(\n creds: SupabaseCredentials,\n options: { next?: string } = {},\n) {\n assertCredentials(creds);\n const fallback = options.next ?? \"/?edit=true\";\n\n return async function GET(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const tokenHash = url.searchParams.get(\"token_hash\");\n const type = url.searchParams.get(\"type\") as EmailOtpType | null;\n const next = safePath(url.searchParams.get(\"next\"), fallback);\n\n if (tokenHash && type) {\n const client = await sessionClient(creds);\n const { error } = await client.auth.verifyOtp({ type, token_hash: tokenHash });\n if (!error) redirect(next);\n console.error(\"Guardrail (Supabase): link verification failed\", error.message);\n }\n\n redirect(`${fallback}${fallback.includes(\"?\") ? \"&\" : \"?\"}gr_auth_error=link`);\n };\n}\n"],"mappings":"AACA,SAAS,gBAAgB;AACzB,SAAS,mBAAmB,qBAAqB;AAOjD,SAAS,SAAS,OAAsB,UAA0B;AAChE,MAAI,CAAC,SAAS,CAAC,MAAM,WAAW,GAAG,KAAK,MAAM,WAAW,IAAI,EAAG,QAAO;AACvE,SAAO;AACT;AAaO,SAAS,qBACd,OACA,UAA6B,CAAC,GAC9B;AACA,oBAAkB,KAAK;AACvB,QAAM,WAAW,QAAQ,QAAQ;AAEjC,SAAO,eAAe,IAAI,SAAqC;AAC7D,UAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;AAC/B,UAAM,YAAY,IAAI,aAAa,IAAI,YAAY;AACnD,UAAM,OAAO,IAAI,aAAa,IAAI,MAAM;AACxC,UAAM,OAAO,SAAS,IAAI,aAAa,IAAI,MAAM,GAAG,QAAQ;AAE5D,QAAI,aAAa,MAAM;AACrB,YAAM,SAAS,MAAM,cAAc,KAAK;AACxC,YAAM,EAAE,MAAM,IAAI,MAAM,OAAO,KAAK,UAAU,EAAE,MAAM,YAAY,UAAU,CAAC;AAC7E,UAAI,CAAC,MAAO,UAAS,IAAI;AACzB,cAAQ,MAAM,kDAAkD,MAAM,OAAO;AAAA,IAC/E;AAEA,aAAS,GAAG,QAAQ,GAAG,SAAS,SAAS,GAAG,IAAI,MAAM,GAAG,oBAAoB;AAAA,EAC/E;AACF;","names":[]}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { SupabaseCredentials } from './client.js';
|
|
2
|
+
import { GRDBAdapter } from '../../core/adapter-types.js';
|
|
3
|
+
import '@supabase/supabase-js';
|
|
4
|
+
import 'react';
|
|
5
|
+
import '../../core/types.js';
|
|
6
|
+
|
|
7
|
+
declare function supabaseDB(creds: SupabaseCredentials): GRDBAdapter;
|
|
8
|
+
|
|
9
|
+
export { supabaseDB };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { anonClient, assertCredentials, sessionClient } from "./client";
|
|
2
|
+
import { GR_SCHEMA, GR_TABLE } from "../../core/defs";
|
|
3
|
+
function toOverrideRow(row) {
|
|
4
|
+
return {
|
|
5
|
+
id: row.id,
|
|
6
|
+
pathKey: row.path_key,
|
|
7
|
+
// Some drivers hand back jsonb columns as strings.
|
|
8
|
+
value: typeof row.value === "string" ? JSON.parse(row.value) : row.value
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
function supabaseDB(creds) {
|
|
12
|
+
assertCredentials(creds);
|
|
13
|
+
return {
|
|
14
|
+
async getOverrides(pathKeys, siteId) {
|
|
15
|
+
if (pathKeys.length === 0) return [];
|
|
16
|
+
const { data, error } = await anonClient(creds).schema(GR_SCHEMA).from(GR_TABLE).select("id, path_key, value").eq("site_id", siteId).in("path_key", pathKeys);
|
|
17
|
+
if (error) {
|
|
18
|
+
console.error("Guardrail (Supabase): read failed", error.message);
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
return (data ?? []).map(toOverrideRow);
|
|
22
|
+
},
|
|
23
|
+
async setOverride(row) {
|
|
24
|
+
const client = await sessionClient(creds);
|
|
25
|
+
const { error } = await client.schema(GR_SCHEMA).from(GR_TABLE).upsert(
|
|
26
|
+
{
|
|
27
|
+
path_key: row.pathKey,
|
|
28
|
+
site_id: row.siteId,
|
|
29
|
+
type: row.type,
|
|
30
|
+
value: row.value,
|
|
31
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
32
|
+
},
|
|
33
|
+
{ onConflict: "path_key,site_id" }
|
|
34
|
+
);
|
|
35
|
+
return error ? { success: false, error: error.message } : { success: true };
|
|
36
|
+
},
|
|
37
|
+
async deleteOverrides(pathKeyPrefix, siteId) {
|
|
38
|
+
const client = await sessionClient(creds);
|
|
39
|
+
const { error } = await client.schema(GR_SCHEMA).from(GR_TABLE).delete().eq("site_id", siteId).like("path_key", `${pathKeyPrefix}%`);
|
|
40
|
+
if (error) {
|
|
41
|
+
console.error("Guardrail (Supabase): deleteOverrides failed", error.message);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export {
|
|
47
|
+
supabaseDB
|
|
48
|
+
};
|
|
49
|
+
//# sourceMappingURL=db.js.map
|
|
@@ -0,0 +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 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,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":[]}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { GRDBAdapter, GRAuthAdapter, GRMembershipAdapter, GRRepeaterAdapter } from '../../core/adapter-types.js';
|
|
2
|
+
export { supabaseDB } from './db.js';
|
|
3
|
+
export { supabaseAuth } from './auth.js';
|
|
4
|
+
export { supabaseMembers } from './members.js';
|
|
5
|
+
export { supabaseConfirmRoute } from './confirm.js';
|
|
6
|
+
export { DEFAULT_BUCKET, SupabaseStorageOptions, supabaseStorage } from './storage.js';
|
|
7
|
+
export { supabaseRepeaters } from './repeaters.js';
|
|
8
|
+
import { SupabaseCredentials } from './client.js';
|
|
9
|
+
import 'react';
|
|
10
|
+
import '../../core/types.js';
|
|
11
|
+
import '@supabase/supabase-js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* All three slots backed by one Supabase project. Use the individual factories
|
|
15
|
+
* instead when mixing backends — Supabase for content, another provider for
|
|
16
|
+
* identity, say.
|
|
17
|
+
*/
|
|
18
|
+
declare function supabaseAdapters(creds: SupabaseCredentials): {
|
|
19
|
+
db: GRDBAdapter;
|
|
20
|
+
auth: GRAuthAdapter;
|
|
21
|
+
members: GRMembershipAdapter;
|
|
22
|
+
repeaters: GRRepeaterAdapter;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export { SupabaseCredentials, supabaseAdapters };
|