@guard-rail/core 0.9.1 → 0.11.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 +253 -36
- package/dist/adapters/supabase/auth.js +1 -2
- package/dist/adapters/supabase/auth.js.map +1 -1
- package/dist/adapters/supabase/client.js +6 -1
- package/dist/adapters/supabase/client.js.map +1 -1
- package/dist/adapters/supabase/confirm.d.ts +11 -11
- package/dist/adapters/supabase/confirm.js +14 -18
- package/dist/adapters/supabase/confirm.js.map +1 -1
- package/dist/adapters/supabase/index.d.ts +3 -1
- package/dist/adapters/supabase/index.js +5 -0
- package/dist/adapters/supabase/index.js.map +1 -1
- package/dist/adapters/supabase/rateLimit.d.ts +24 -0
- package/dist/adapters/supabase/rateLimit.js +37 -0
- package/dist/adapters/supabase/rateLimit.js.map +1 -0
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +1046 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/init-db.js +23 -5
- package/dist/cli/init-db.js.map +1 -1
- package/dist/core/adapter-types.d.ts +71 -1
- package/dist/core/config.d.ts +12 -1
- package/dist/core/config.js +29 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/csp.d.ts +32 -0
- package/dist/core/csp.js +26 -0
- package/dist/core/csp.js.map +1 -0
- package/dist/core/validate.d.ts +47 -0
- package/dist/core/validate.js +118 -0
- package/dist/core/validate.js.map +1 -0
- package/dist/index.client.d.ts +2 -1
- package/dist/index.client.js +3 -0
- package/dist/index.client.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/react/Admin.d.ts +5 -3
- package/dist/react/Admin.js +29 -41
- package/dist/react/Admin.js.map +1 -1
- package/dist/react/AuthDrawer.js +22 -34
- package/dist/react/AuthDrawer.js.map +1 -1
- package/dist/react/AuthRedirectSync.js +28 -22
- package/dist/react/AuthRedirectSync.js.map +1 -1
- package/dist/react/GRail/client.js +27 -47
- package/dist/react/GRail/client.js.map +1 -1
- package/dist/react/GRail/render.js +3 -2
- package/dist/react/GRail/render.js.map +1 -1
- package/dist/react/MediaPicker.js +12 -16
- package/dist/react/MediaPicker.js.map +1 -1
- package/dist/react/Repeater/client.js +12 -13
- package/dist/react/Repeater/client.js.map +1 -1
- package/dist/react/Team.d.ts +1 -7
- package/dist/react/Team.js +21 -46
- package/dist/react/Team.js.map +1 -1
- package/dist/react/guardrail.css +563 -0
- package/dist/server/actions.js +52 -31
- package/dist/server/actions.js.map +1 -1
- package/dist/server/loader.js +4 -4
- package/dist/server/loader.js.map +1 -1
- package/dist/server/rateLimit.d.ts +21 -0
- package/dist/server/rateLimit.js +94 -0
- package/dist/server/rateLimit.js.map +1 -0
- package/dist/server/repeaterActions.js +9 -1
- package/dist/server/repeaterActions.js.map +1 -1
- package/dist/server/wrapper.js +18 -1
- package/dist/server/wrapper.js.map +1 -1
- package/package.json +11 -9
- package/supabase/admin/check-grants.sql +41 -0
- package/supabase/migrations/20261028000000_require_confirmed_email.sql +53 -0
- package/supabase/migrations/20261028010000_rate_limits.sql +67 -0
- package/supabase/migrations/20261028020000_member_and_bucket_guards.sql +96 -0
- package/dist/react/styles.d.ts +0 -504
- package/dist/react/styles.js +0 -500
- package/dist/react/styles.js.map +0 -1
- package/scripts/postinstall.mjs +0 -48
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/server/repeaterActions.ts"],"sourcesContent":["\"use server\";\n\nimport { revalidatePath } from \"next/cache\";\nimport { getDB, getRepeaters, resolveSiteId } from \"../core/config\";\nimport { requireRole } from \"./access\";\nimport { rethrowIfBailout } from \"../core/bailout\";\nimport type { GRRepeaterItem } from \"../core/types\";\n\ntype Result = { success: boolean; error?: string };\n\nconst DENIED: Result = { success: false, error: \"Unauthorized.\" };\n\nexport async function addRepeaterItem(\n repeaterId: string,\n options?: { slug?: string },\n): Promise<\n { success: true; item: GRRepeaterItem } | { success: false; error: string }\n> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return { success: false, error: \"Unauthorized.\" };\n\n if (options?.slug !== undefined) {\n const slug = options.slug.trim();\n if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {\n return {\n success: false,\n error: \"Use lowercase letters, numbers, and hyphens only.\",\n };\n }\n options = { slug };\n }\n\n try {\n const repeaters = getRepeaters();\n const res = await repeaters.addItem(repeaterId, await resolveSiteId(), options);\n if (res.success) revalidatePath(\"/\", \"layout\");\n return res;\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: addRepeaterItem failed\", e);\n return { success: false, error: \"Could not add the item.\" };\n }\n}\n\nexport async function removeRepeaterItem(\n repeaterId: string,\n itemId: string,\n): Promise<Result> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return DENIED;\n\n try {\n const siteId = await resolveSiteId();\n const repeaters = getRepeaters();\n const res = await repeaters.removeItem(repeaterId, siteId, itemId);\n if (!res.success) return res;\n\n // Best-effort: an adapter that can't do this cheaply just leaves the old\n // field values behind, unreachable but harmless.\n const db = getDB();\n await db.deleteOverrides?.(`${repeaterId}.${itemId}.`, siteId);\n\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: removeRepeaterItem failed\", e);\n return { success: false, error: \"Could not remove the item.\" };\n }\n}\n\nexport async function reorderRepeaterItems(\n repeaterId: string,\n orderedItemIds: string[],\n): Promise<Result> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return DENIED;\n\n try {\n const repeaters = getRepeaters();\n const res = await repeaters.reorderItems(\n repeaterId,\n await resolveSiteId(),\n
|
|
1
|
+
{"version":3,"sources":["../../src/server/repeaterActions.ts"],"sourcesContent":["\"use server\";\n\nimport { revalidatePath } from \"next/cache\";\nimport { getDB, getRepeaters, resolveSiteId } from \"../core/config\";\nimport { requireRole } from \"./access\";\nimport { validId, validIdList } from \"../core/validate\";\nimport { rethrowIfBailout } from \"../core/bailout\";\nimport type { GRRepeaterItem } from \"../core/types\";\n\ntype Result = { success: boolean; error?: string };\n\nconst DENIED: Result = { success: false, error: \"Unauthorized.\" };\n\nexport async function addRepeaterItem(\n repeaterId: string,\n options?: { slug?: string },\n): Promise<\n { success: true; item: GRRepeaterItem } | { success: false; error: string }\n> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return { success: false, error: \"Unauthorized.\" };\n if (!validId(repeaterId)) return { success: false, error: \"Invalid repeater.\" };\n\n if (options?.slug !== undefined) {\n if (typeof options.slug !== \"string\" || options.slug.length > 200) {\n return { success: false, error: \"Use lowercase letters, numbers, and hyphens only.\" };\n }\n const slug = options.slug.trim();\n if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {\n return {\n success: false,\n error: \"Use lowercase letters, numbers, and hyphens only.\",\n };\n }\n options = { slug };\n }\n\n try {\n const repeaters = getRepeaters();\n const res = await repeaters.addItem(repeaterId, await resolveSiteId(), options);\n if (res.success) revalidatePath(\"/\", \"layout\");\n return res;\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: addRepeaterItem failed\", e);\n return { success: false, error: \"Could not add the item.\" };\n }\n}\n\nexport async function removeRepeaterItem(\n repeaterId: string,\n itemId: string,\n): Promise<Result> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return DENIED;\n if (!validId(repeaterId) || !validId(itemId)) return { success: false, error: \"Invalid item.\" };\n\n try {\n const siteId = await resolveSiteId();\n const repeaters = getRepeaters();\n const res = await repeaters.removeItem(repeaterId, siteId, itemId);\n if (!res.success) return res;\n\n // Best-effort: an adapter that can't do this cheaply just leaves the old\n // field values behind, unreachable but harmless.\n const db = getDB();\n await db.deleteOverrides?.(`${repeaterId}.${itemId}.`, siteId);\n\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: removeRepeaterItem failed\", e);\n return { success: false, error: \"Could not remove the item.\" };\n }\n}\n\nexport async function reorderRepeaterItems(\n repeaterId: string,\n orderedItemIds: string[],\n): Promise<Result> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return DENIED;\n const ids = validIdList(orderedItemIds);\n if (!validId(repeaterId) || !ids) return { success: false, error: \"Invalid order.\" };\n\n try {\n const repeaters = getRepeaters();\n const res = await repeaters.reorderItems(\n repeaterId,\n await resolveSiteId(),\n ids,\n );\n if (res.success) revalidatePath(\"/\", \"layout\");\n return res;\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: reorderRepeaterItems failed\", e);\n return { success: false, error: \"Could not reorder the items.\" };\n }\n}\n"],"mappings":";AAEA,SAAS,sBAAsB;AAC/B,SAAS,OAAO,cAAc,qBAAqB;AACnD,SAAS,mBAAmB;AAC5B,SAAS,SAAS,mBAAmB;AACrC,SAAS,wBAAwB;AAKjC,MAAM,SAAiB,EAAE,SAAS,OAAO,OAAO,gBAAgB;AAEhE,eAAsB,gBACpB,YACA,SAGA;AACA,QAAM,SAAS,MAAM,YAAY,CAAC,SAAS,QAAQ,CAAC;AACpD,MAAI,CAAC,OAAQ,QAAO,EAAE,SAAS,OAAO,OAAO,gBAAgB;AAC7D,MAAI,CAAC,QAAQ,UAAU,EAAG,QAAO,EAAE,SAAS,OAAO,OAAO,oBAAoB;AAE9E,MAAI,SAAS,SAAS,QAAW;AAC/B,QAAI,OAAO,QAAQ,SAAS,YAAY,QAAQ,KAAK,SAAS,KAAK;AACjE,aAAO,EAAE,SAAS,OAAO,OAAO,oDAAoD;AAAA,IACtF;AACA,UAAM,OAAO,QAAQ,KAAK,KAAK;AAC/B,QAAI,CAAC,2BAA2B,KAAK,IAAI,GAAG;AAC1C,aAAO;AAAA,QACL,SAAS;AAAA,QACT,OAAO;AAAA,MACT;AAAA,IACF;AACA,cAAU,EAAE,KAAK;AAAA,EACnB;AAEA,MAAI;AACF,UAAM,YAAY,aAAa;AAC/B,UAAM,MAAM,MAAM,UAAU,QAAQ,YAAY,MAAM,cAAc,GAAG,OAAO;AAC9E,QAAI,IAAI,QAAS,gBAAe,KAAK,QAAQ;AAC7C,WAAO;AAAA,EACT,SAAS,GAAG;AACV,qBAAiB,CAAC;AAClB,YAAQ,MAAM,qCAAqC,CAAC;AACpD,WAAO,EAAE,SAAS,OAAO,OAAO,0BAA0B;AAAA,EAC5D;AACF;AAEA,eAAsB,mBACpB,YACA,QACiB;AACjB,QAAM,SAAS,MAAM,YAAY,CAAC,SAAS,QAAQ,CAAC;AACpD,MAAI,CAAC,OAAQ,QAAO;AACpB,MAAI,CAAC,QAAQ,UAAU,KAAK,CAAC,QAAQ,MAAM,EAAG,QAAO,EAAE,SAAS,OAAO,OAAO,gBAAgB;AAE9F,MAAI;AACF,UAAM,SAAS,MAAM,cAAc;AACnC,UAAM,YAAY,aAAa;AAC/B,UAAM,MAAM,MAAM,UAAU,WAAW,YAAY,QAAQ,MAAM;AACjE,QAAI,CAAC,IAAI,QAAS,QAAO;AAIzB,UAAM,KAAK,MAAM;AACjB,UAAM,GAAG,kBAAkB,GAAG,UAAU,IAAI,MAAM,KAAK,MAAM;AAE7D,mBAAe,KAAK,QAAQ;AAC5B,WAAO,EAAE,SAAS,KAAK;AAAA,EACzB,SAAS,GAAG;AACV,qBAAiB,CAAC;AAClB,YAAQ,MAAM,wCAAwC,CAAC;AACvD,WAAO,EAAE,SAAS,OAAO,OAAO,6BAA6B;AAAA,EAC/D;AACF;AAEA,eAAsB,qBACpB,YACA,gBACiB;AACjB,QAAM,SAAS,MAAM,YAAY,CAAC,SAAS,QAAQ,CAAC;AACpD,MAAI,CAAC,OAAQ,QAAO;AACpB,QAAM,MAAM,YAAY,cAAc;AACtC,MAAI,CAAC,QAAQ,UAAU,KAAK,CAAC,IAAK,QAAO,EAAE,SAAS,OAAO,OAAO,iBAAiB;AAEnF,MAAI;AACF,UAAM,YAAY,aAAa;AAC/B,UAAM,MAAM,MAAM,UAAU;AAAA,MAC1B;AAAA,MACA,MAAM,cAAc;AAAA,MACpB;AAAA,IACF;AACA,QAAI,IAAI,QAAS,gBAAe,KAAK,QAAQ;AAC7C,WAAO;AAAA,EACT,SAAS,GAAG;AACV,qBAAiB,CAAC;AAClB,YAAQ,MAAM,0CAA0C,CAAC;AACzD,WAAO,EAAE,SAAS,OAAO,OAAO,+BAA+B;AAAA,EACjE;AACF;","names":[]}
|
package/dist/server/wrapper.js
CHANGED
|
@@ -5,17 +5,34 @@ import {
|
|
|
5
5
|
configureGuardrail,
|
|
6
6
|
getAuth,
|
|
7
7
|
getStorage,
|
|
8
|
-
resolveSiteId
|
|
8
|
+
resolveSiteId,
|
|
9
|
+
resolveSiteUrl
|
|
9
10
|
} from "../core/config";
|
|
10
11
|
import { getCachedViewer } from "./session";
|
|
11
12
|
import { resolveEditMode } from "./access";
|
|
12
13
|
import { loadPreloadedContent } from "./loader";
|
|
13
14
|
import { GRailProvider } from "../react/Provider";
|
|
14
15
|
import { GRailAdmin } from "../react/Admin";
|
|
16
|
+
let warnedSiteUrl = false;
|
|
17
|
+
function warnIfSiteUrlMissing() {
|
|
18
|
+
if (warnedSiteUrl || process.env.NODE_ENV !== "production") return;
|
|
19
|
+
if (process.env.NEXT_PHASE === "phase-production-build") return;
|
|
20
|
+
warnedSiteUrl = true;
|
|
21
|
+
resolveSiteUrl().then(
|
|
22
|
+
(url) => {
|
|
23
|
+
if (url) return;
|
|
24
|
+
console.warn(
|
|
25
|
+
"Guardrail: no site URL is configured, so invite and password-reset emails will not be sent. Set GUARDRAIL_SITE_URL (or siteUrl in withGuardrail()) to this site's public address. Run `pnpm exec guardrail-doctor` to check the rest of your setup."
|
|
26
|
+
);
|
|
27
|
+
},
|
|
28
|
+
(e) => console.warn(`Guardrail: ${e instanceof Error ? e.message : e}`)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
15
31
|
function withGuardrail(LayoutComponent, config) {
|
|
16
32
|
configureGuardrail(config);
|
|
17
33
|
return async function GuardrailLayout(props) {
|
|
18
34
|
configureGuardrail(config);
|
|
35
|
+
warnIfSiteUrlMissing();
|
|
19
36
|
const cookieStore = await cookies();
|
|
20
37
|
const [viewer, content] = await Promise.all([
|
|
21
38
|
getCachedViewer(),
|
|
@@ -1 +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 { resolveEditMode } from \"./access\";\nimport { loadPreloadedContent } from \"./loader\";\nimport type { GRConfig } from \"../core/adapter-types\";\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. Loaded\n // alongside the content preload: independent round trips, and the preload\n // is shared with the page's own fields, so it costs them nothing extra.\n const [viewer, content] = await Promise.all([\n getCachedViewer(),\n loadPreloadedContent(),\n ]);\n\n const auth = getAuth();\n\n // The rule for what counts as \"in edit mode\" lives in resolveEditMode —\n // shared with canEdit() so the layout and the server helper can't disagree.\n const isAuth = viewer !== null;\n const { isEditMode, needsPassword, authError } = resolveEditMode(viewer, cookieStore);\n const role = viewer?.role ?? null;\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,\n isEditMode,\n needsPassword,\n ...(authError ? { authError } : {}),\n siteId: await resolveSiteId(),\n role,\n canEdit: isEditMode && role !== null,\n ...(viewer?.email ? { email: viewer.email } : {}),\n };\n\n return (\n <GRailProvider state={state} content={content}>\n <LayoutComponent {...props} />\n <Suspense fallback={null}>\n <GRailAdmin toolbarExtra={config.toolbarExtra} />\n </Suspense>\n </GRailProvider>\n );\n };\n}\n"],"mappings":"
|
|
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 resolveSiteUrl,\n} from \"../core/config\";\nimport { getCachedViewer } from \"./session\";\nimport { resolveEditMode } from \"./access\";\nimport { loadPreloadedContent } from \"./loader\";\nimport type { GRConfig } from \"../core/adapter-types\";\nimport { GRailProvider } from \"../react/Provider\";\nimport { GRailAdmin } from \"../react/Admin\";\nimport type { GRState } from \"../core/types\";\n\nlet warnedSiteUrl = false;\n\n/**\n * Production sends no invite or password-reset email without a site URL, and\n * \"Forgot password?\" is built never to reveal a failure to the person asking —\n * so a site upgraded without setting one just goes quiet. Say so, once, in the\n * server log where whoever deploys it will look.\n */\nfunction warnIfSiteUrlMissing(): void {\n if (warnedSiteUrl || process.env.NODE_ENV !== \"production\") return;\n // Not while `next build` is prerendering: the variable is often only set where\n // the site runs, and a warning on every CI build is noise.\n if (process.env.NEXT_PHASE === \"phase-production-build\") return;\n warnedSiteUrl = true;\n resolveSiteUrl().then(\n (url) => {\n if (url) return;\n console.warn(\n \"Guardrail: no site URL is configured, so invite and password-reset emails will not be sent. \" +\n \"Set GUARDRAIL_SITE_URL (or siteUrl in withGuardrail()) to this site's public address. \" +\n \"Run `pnpm exec guardrail-doctor` to check the rest of your setup.\",\n );\n },\n (e) => console.warn(`Guardrail: ${e instanceof Error ? e.message : e}`),\n );\n}\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 warnIfSiteUrlMissing();\n\n const cookieStore = await cookies();\n\n // Verified against the auth provider, not inferred from a cookie. Loaded\n // alongside the content preload: independent round trips, and the preload\n // is shared with the page's own fields, so it costs them nothing extra.\n const [viewer, content] = await Promise.all([\n getCachedViewer(),\n loadPreloadedContent(),\n ]);\n\n const auth = getAuth();\n\n // The rule for what counts as \"in edit mode\" lives in resolveEditMode —\n // shared with canEdit() so the layout and the server helper can't disagree.\n const isAuth = viewer !== null;\n const { isEditMode, needsPassword, authError } = resolveEditMode(viewer, cookieStore);\n const role = viewer?.role ?? null;\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,\n isEditMode,\n needsPassword,\n ...(authError ? { authError } : {}),\n siteId: await resolveSiteId(),\n role,\n canEdit: isEditMode && role !== null,\n ...(viewer?.email ? { email: viewer.email } : {}),\n };\n\n return (\n <GRailProvider state={state} content={content}>\n <LayoutComponent {...props} />\n <Suspense fallback={null}>\n <GRailAdmin toolbarExtra={config.toolbarExtra} />\n </Suspense>\n </GRailProvider>\n );\n };\n}\n"],"mappings":"AAgGM,SACE,KADF;AAhGN,OAAO,SAAS,gBAAgB;AAChC,SAAS,eAAe;AACxB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,uBAAuB;AAChC,SAAS,uBAAuB;AAChC,SAAS,4BAA4B;AAErC,SAAS,qBAAqB;AAC9B,SAAS,kBAAkB;AAG3B,IAAI,gBAAgB;AAQpB,SAAS,uBAA6B;AACpC,MAAI,iBAAiB,QAAQ,IAAI,aAAa,aAAc;AAG5D,MAAI,QAAQ,IAAI,eAAe,yBAA0B;AACzD,kBAAgB;AAChB,iBAAe,EAAE;AAAA,IACf,CAAC,QAAQ;AACP,UAAI,IAAK;AACT,cAAQ;AAAA,QACN;AAAA,MAGF;AAAA,IACF;AAAA,IACA,CAAC,MAAM,QAAQ,KAAK,cAAc,aAAa,QAAQ,EAAE,UAAU,CAAC,EAAE;AAAA,EACxE;AACF;AAMO,SAAS,cACd,iBACA,QACA;AAIA,qBAAmB,MAAM;AAEzB,SAAO,eAAe,gBAAgB,OAAU;AAC9C,uBAAmB,MAAM;AACzB,yBAAqB;AAErB,UAAM,cAAc,MAAM,QAAQ;AAKlC,UAAM,CAAC,QAAQ,OAAO,IAAI,MAAM,QAAQ,IAAI;AAAA,MAC1C,gBAAgB;AAAA,MAChB,qBAAqB;AAAA,IACvB,CAAC;AAED,UAAM,OAAO,QAAQ;AAIrB,UAAM,SAAS,WAAW;AAC1B,UAAM,EAAE,YAAY,eAAe,UAAU,IAAI,gBAAgB,QAAQ,WAAW;AACpF,UAAM,OAAO,QAAQ,QAAQ;AAE7B,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;AAAA,MACA;AAAA,MACA;AAAA,MACA,GAAI,YAAY,EAAE,UAAU,IAAI,CAAC;AAAA,MACjC,QAAQ,MAAM,cAAc;AAAA,MAC5B;AAAA,MACA,SAAS,cAAc,SAAS;AAAA,MAChC,GAAI,QAAQ,QAAQ,EAAE,OAAO,OAAO,MAAM,IAAI,CAAC;AAAA,IACjD;AAEA,WACE,qBAAC,iBAAc,OAAc,SAC3B;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
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@guard-rail/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Lightweight inline content editing for developer-owned Next.js sites.",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"sideEffects":
|
|
7
|
+
"sideEffects": [
|
|
8
|
+
"**/*.css"
|
|
9
|
+
],
|
|
8
10
|
"types": "./dist/index.d.ts",
|
|
9
11
|
"exports": {
|
|
10
12
|
".": {
|
|
@@ -20,6 +22,7 @@
|
|
|
20
22
|
"types": "./dist/index.client.d.ts",
|
|
21
23
|
"default": "./dist/index.client.js"
|
|
22
24
|
},
|
|
25
|
+
"./styles.css": "./dist/react/guardrail.css",
|
|
23
26
|
"./adapters/supabase": {
|
|
24
27
|
"types": "./dist/adapters/supabase/index.d.ts",
|
|
25
28
|
"default": "./dist/adapters/supabase/index.js"
|
|
@@ -28,12 +31,12 @@
|
|
|
28
31
|
},
|
|
29
32
|
"files": [
|
|
30
33
|
"dist",
|
|
31
|
-
"supabase"
|
|
32
|
-
"scripts"
|
|
34
|
+
"supabase"
|
|
33
35
|
],
|
|
34
36
|
"bin": {
|
|
35
37
|
"guardrail-seed-user": "dist/cli/seed-user.js",
|
|
36
|
-
"guardrail-init-db": "dist/cli/init-db.js"
|
|
38
|
+
"guardrail-init-db": "dist/cli/init-db.js",
|
|
39
|
+
"guardrail-doctor": "dist/cli/doctor.js"
|
|
37
40
|
},
|
|
38
41
|
"publishConfig": {
|
|
39
42
|
"access": "public"
|
|
@@ -41,7 +44,7 @@
|
|
|
41
44
|
"peerDependencies": {
|
|
42
45
|
"@supabase/ssr": ">=0.8.0 <1",
|
|
43
46
|
"@supabase/supabase-js": "^2.0.0",
|
|
44
|
-
"next": ">=
|
|
47
|
+
"next": "^15.5.28 || >=16.4.1",
|
|
45
48
|
"react": ">=19"
|
|
46
49
|
},
|
|
47
50
|
"peerDependenciesMeta": {
|
|
@@ -58,7 +61,7 @@
|
|
|
58
61
|
"@types/node": "^25.1.0",
|
|
59
62
|
"@types/react": "^19.2.10",
|
|
60
63
|
"@types/react-dom": "^19.3.0",
|
|
61
|
-
"next": "16.
|
|
64
|
+
"next": "16.4.3",
|
|
62
65
|
"react": "^19.2.4",
|
|
63
66
|
"tsup": "^8.5.1",
|
|
64
67
|
"typescript": "^5.9.3"
|
|
@@ -66,7 +69,6 @@
|
|
|
66
69
|
"scripts": {
|
|
67
70
|
"build": "tsup",
|
|
68
71
|
"dev": "tsup --watch",
|
|
69
|
-
"typecheck": "tsc --noEmit"
|
|
70
|
-
"postinstall": "node scripts/postinstall.mjs"
|
|
72
|
+
"typecheck": "tsc --noEmit"
|
|
71
73
|
}
|
|
72
74
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
-- Read-only. Reports every Guardrail privilege that is MISSING or EXTRA, so an
|
|
2
|
+
-- empty result means the schema matches what the migration grants.
|
|
3
|
+
with expected(role, object, priv) as (values
|
|
4
|
+
-- schema
|
|
5
|
+
('anon','schema guardrail','USAGE'), ('authenticated','schema guardrail','USAGE'), ('service_role','schema guardrail','USAGE'),
|
|
6
|
+
-- gr_content: public read; signed-in and service_role write
|
|
7
|
+
('anon','gr_content','SELECT'),
|
|
8
|
+
('authenticated','gr_content','SELECT'), ('authenticated','gr_content','INSERT'), ('authenticated','gr_content','UPDATE'), ('authenticated','gr_content','DELETE'),
|
|
9
|
+
('service_role','gr_content','SELECT'), ('service_role','gr_content','INSERT'), ('service_role','gr_content','UPDATE'), ('service_role','gr_content','DELETE'),
|
|
10
|
+
-- gr_repeater_items: same shape
|
|
11
|
+
('anon','gr_repeater_items','SELECT'),
|
|
12
|
+
('authenticated','gr_repeater_items','SELECT'), ('authenticated','gr_repeater_items','INSERT'), ('authenticated','gr_repeater_items','UPDATE'), ('authenticated','gr_repeater_items','DELETE'),
|
|
13
|
+
('service_role','gr_repeater_items','SELECT'), ('service_role','gr_repeater_items','INSERT'), ('service_role','gr_repeater_items','UPDATE'), ('service_role','gr_repeater_items','DELETE'),
|
|
14
|
+
-- gr_members: never readable by anon
|
|
15
|
+
('authenticated','gr_members','SELECT'), ('authenticated','gr_members','INSERT'), ('authenticated','gr_members','UPDATE'), ('authenticated','gr_members','DELETE'),
|
|
16
|
+
('service_role','gr_members','SELECT'), ('service_role','gr_members','INSERT'), ('service_role','gr_members','UPDATE'), ('service_role','gr_members','DELETE'),
|
|
17
|
+
-- functions
|
|
18
|
+
('authenticated','fn gr_has_role','EXECUTE'), ('authenticated','fn gr_claim_invite','EXECUTE'), ('authenticated','fn gr_ensure_bucket','EXECUTE')
|
|
19
|
+
),
|
|
20
|
+
actual(role, object, priv) as (
|
|
21
|
+
select r.rolname, t.tbl, p.priv
|
|
22
|
+
from (values ('anon'),('authenticated'),('service_role')) r(rolname)
|
|
23
|
+
cross join (values ('gr_content'),('gr_members'),('gr_repeater_items')) t(tbl)
|
|
24
|
+
cross join (values ('SELECT'),('INSERT'),('UPDATE'),('DELETE')) p(priv)
|
|
25
|
+
where to_regclass('guardrail.'||t.tbl) is not null
|
|
26
|
+
and has_table_privilege(r.rolname, 'guardrail.'||t.tbl, p.priv)
|
|
27
|
+
union all
|
|
28
|
+
select r.rolname, 'schema guardrail', 'USAGE'
|
|
29
|
+
from (values ('anon'),('authenticated'),('service_role')) r(rolname)
|
|
30
|
+
where has_schema_privilege(r.rolname, 'guardrail', 'USAGE')
|
|
31
|
+
union all
|
|
32
|
+
select r.rolname, 'fn '||f.fn, 'EXECUTE'
|
|
33
|
+
from (values ('anon'),('authenticated'),('service_role')) r(rolname)
|
|
34
|
+
cross join (values ('gr_has_role(text,text[])','gr_has_role'),('gr_claim_invite(text)','gr_claim_invite'),('gr_ensure_bucket(text)','gr_ensure_bucket')) f(sig, fn)
|
|
35
|
+
where to_regprocedure('guardrail.'||f.sig) is not null
|
|
36
|
+
and has_function_privilege(r.rolname, 'guardrail.'||f.sig, 'EXECUTE')
|
|
37
|
+
)
|
|
38
|
+
select 'MISSING' as problem, role, object, priv from (select * from expected except select * from actual) m
|
|
39
|
+
union all
|
|
40
|
+
select 'EXTRA', role, object, priv from (select * from actual except select * from expected) x
|
|
41
|
+
order by 1, 2, 3, 4;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
-- Identity by confirmed email only.
|
|
2
|
+
--
|
|
3
|
+
-- The init migration matched an invitation to a person by the `email` claim in
|
|
4
|
+
-- their JWT. That claim is only as trustworthy as the project's signup
|
|
5
|
+
-- settings: where an address can be registered without being confirmed, anyone
|
|
6
|
+
-- could sign up as an invited admin's address and claim the invitation (or read
|
|
7
|
+
-- the roster row addressed to it). This reads the address from auth.users
|
|
8
|
+
-- instead, and only once Supabase has confirmed the person controls it.
|
|
9
|
+
--
|
|
10
|
+
-- Safe to re-run; changes no data.
|
|
11
|
+
|
|
12
|
+
create or replace function guardrail.gr_confirmed_email()
|
|
13
|
+
returns text
|
|
14
|
+
language sql
|
|
15
|
+
stable
|
|
16
|
+
security definer
|
|
17
|
+
set search_path = pg_temp
|
|
18
|
+
as $$
|
|
19
|
+
select lower(u.email)
|
|
20
|
+
from auth.users u
|
|
21
|
+
where u.id = auth.uid()
|
|
22
|
+
and u.email_confirmed_at is not null;
|
|
23
|
+
$$;
|
|
24
|
+
|
|
25
|
+
revoke all on function guardrail.gr_confirmed_email() from public;
|
|
26
|
+
grant execute on function guardrail.gr_confirmed_email() to authenticated;
|
|
27
|
+
|
|
28
|
+
drop policy if exists "members read their own site roster" on guardrail.gr_members;
|
|
29
|
+
create policy "members read their own site roster"
|
|
30
|
+
on guardrail.gr_members for select
|
|
31
|
+
to authenticated
|
|
32
|
+
using (
|
|
33
|
+
user_id = auth.uid()
|
|
34
|
+
or lower(email) = guardrail.gr_confirmed_email()
|
|
35
|
+
or guardrail.gr_has_role(site_id, array['admin', 'editor'])
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
create or replace function guardrail.gr_claim_invite(target_site text)
|
|
39
|
+
returns void
|
|
40
|
+
language sql
|
|
41
|
+
security definer
|
|
42
|
+
set search_path = guardrail, pg_temp
|
|
43
|
+
as $$
|
|
44
|
+
update guardrail.gr_members
|
|
45
|
+
set user_id = auth.uid(),
|
|
46
|
+
status = 'active'
|
|
47
|
+
where site_id = target_site
|
|
48
|
+
and lower(email) = guardrail.gr_confirmed_email()
|
|
49
|
+
and status = 'pending';
|
|
50
|
+
$$;
|
|
51
|
+
|
|
52
|
+
revoke all on function guardrail.gr_claim_invite(text) from public;
|
|
53
|
+
grant execute on function guardrail.gr_claim_invite(text) to authenticated;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
-- Rate-limit counters, for the Supabase rate limiter that ships with
|
|
2
|
+
-- @guard-rail/core (used automatically when the adapter has a service-role key).
|
|
3
|
+
--
|
|
4
|
+
-- One row per (limit, caller) bucket, fixed window. gr_rate_hit() counts an
|
|
5
|
+
-- attempt and says whether it is still within the limit, in a single atomic
|
|
6
|
+
-- statement, so concurrent requests can't both read "under the limit".
|
|
7
|
+
--
|
|
8
|
+
-- Callable by service_role only. It has to be: sign-in and "Forgot password?"
|
|
9
|
+
-- happen with no session, and handing anyone (anon included) the ability to
|
|
10
|
+
-- bump arbitrary counters would let them lock other people out. The table
|
|
11
|
+
-- itself is unreachable through the Data API.
|
|
12
|
+
|
|
13
|
+
create table if not exists guardrail.gr_rate_limits (
|
|
14
|
+
bucket text primary key,
|
|
15
|
+
window_start timestamptz not null default now(),
|
|
16
|
+
expires_at timestamptz not null,
|
|
17
|
+
hits integer not null default 0
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
alter table guardrail.gr_rate_limits enable row level security;
|
|
21
|
+
-- No policies on purpose: no role but the definer function (and service_role,
|
|
22
|
+
-- which bypasses RLS) can read or write it.
|
|
23
|
+
revoke all on guardrail.gr_rate_limits from public, anon, authenticated;
|
|
24
|
+
grant select, insert, update, delete on guardrail.gr_rate_limits to service_role;
|
|
25
|
+
|
|
26
|
+
create index if not exists gr_rate_limits_expires_idx on guardrail.gr_rate_limits (expires_at);
|
|
27
|
+
|
|
28
|
+
create or replace function guardrail.gr_rate_hit(
|
|
29
|
+
p_name text,
|
|
30
|
+
p_key text,
|
|
31
|
+
p_limit integer,
|
|
32
|
+
p_window_seconds integer
|
|
33
|
+
)
|
|
34
|
+
returns table (allowed boolean, retry_after_seconds integer)
|
|
35
|
+
language plpgsql
|
|
36
|
+
security definer
|
|
37
|
+
set search_path = guardrail, pg_temp
|
|
38
|
+
as $$
|
|
39
|
+
declare
|
|
40
|
+
v_bucket text := p_name || '|' || p_key;
|
|
41
|
+
v_window interval := make_interval(secs => p_window_seconds);
|
|
42
|
+
v_hits integer;
|
|
43
|
+
v_expires timestamptz;
|
|
44
|
+
begin
|
|
45
|
+
-- Start a fresh window if the old one has run out; otherwise count into it.
|
|
46
|
+
insert into guardrail.gr_rate_limits as t (bucket, window_start, expires_at, hits)
|
|
47
|
+
values (v_bucket, now(), now() + v_window, 1)
|
|
48
|
+
on conflict (bucket) do update
|
|
49
|
+
set window_start = case when t.expires_at <= now() then now() else t.window_start end,
|
|
50
|
+
expires_at = case when t.expires_at <= now() then now() + v_window else t.expires_at end,
|
|
51
|
+
hits = case when t.expires_at <= now() then 1 else t.hits + 1 end
|
|
52
|
+
returning t.hits, t.expires_at into v_hits, v_expires;
|
|
53
|
+
|
|
54
|
+
-- Housekeeping, on roughly 1 call in 50, so the table doesn't grow forever.
|
|
55
|
+
if random() < 0.02 then
|
|
56
|
+
delete from guardrail.gr_rate_limits where expires_at < now() - interval '1 hour';
|
|
57
|
+
end if;
|
|
58
|
+
|
|
59
|
+
return query select
|
|
60
|
+
v_hits <= p_limit,
|
|
61
|
+
case when v_hits <= p_limit then 0
|
|
62
|
+
else greatest(1, ceil(extract(epoch from (v_expires - now())))::integer) end;
|
|
63
|
+
end;
|
|
64
|
+
$$;
|
|
65
|
+
|
|
66
|
+
revoke all on function guardrail.gr_rate_hit(text, text, integer, integer) from public, anon, authenticated;
|
|
67
|
+
grant execute on function guardrail.gr_rate_hit(text, text, integer, integer) to service_role;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
-- Two guards that used to live only in application code.
|
|
2
|
+
--
|
|
3
|
+
-- Safe to re-run; changes no data.
|
|
4
|
+
|
|
5
|
+
/* ------------------------------------------------------ last admin --- */
|
|
6
|
+
-- "A site must keep at least one admin" was enforced by the Server Action, but
|
|
7
|
+
-- an admin talking to the Data API directly could delete or demote the last one
|
|
8
|
+
-- and leave the site with nobody able to manage it. This enforces it where the
|
|
9
|
+
-- data is. It applies to signed-in users only: service_role, the SQL editor and
|
|
10
|
+
-- migrations (no auth.uid()) can still do whatever maintenance they need.
|
|
11
|
+
create or replace function guardrail.gr_keep_one_admin()
|
|
12
|
+
returns trigger
|
|
13
|
+
language plpgsql
|
|
14
|
+
security definer
|
|
15
|
+
set search_path = guardrail, pg_temp
|
|
16
|
+
as $$
|
|
17
|
+
begin
|
|
18
|
+
if auth.uid() is null then
|
|
19
|
+
return coalesce(new, old);
|
|
20
|
+
end if;
|
|
21
|
+
|
|
22
|
+
if old.role = 'admin' and old.status = 'active'
|
|
23
|
+
and (
|
|
24
|
+
tg_op = 'DELETE'
|
|
25
|
+
or new.role <> 'admin'
|
|
26
|
+
or new.status <> 'active'
|
|
27
|
+
or new.site_id is distinct from old.site_id
|
|
28
|
+
)
|
|
29
|
+
and not exists (
|
|
30
|
+
select 1
|
|
31
|
+
from guardrail.gr_members m
|
|
32
|
+
where m.site_id = old.site_id
|
|
33
|
+
and m.role = 'admin'
|
|
34
|
+
and m.status = 'active'
|
|
35
|
+
and m.email <> old.email
|
|
36
|
+
)
|
|
37
|
+
then
|
|
38
|
+
raise exception 'A site must keep at least one active admin.'
|
|
39
|
+
using errcode = '23514';
|
|
40
|
+
end if;
|
|
41
|
+
|
|
42
|
+
return coalesce(new, old);
|
|
43
|
+
end;
|
|
44
|
+
$$;
|
|
45
|
+
|
|
46
|
+
revoke all on function guardrail.gr_keep_one_admin() from public;
|
|
47
|
+
|
|
48
|
+
drop trigger if exists gr_keep_one_admin on guardrail.gr_members;
|
|
49
|
+
create trigger gr_keep_one_admin
|
|
50
|
+
before update or delete on guardrail.gr_members
|
|
51
|
+
for each row execute function guardrail.gr_keep_one_admin();
|
|
52
|
+
|
|
53
|
+
/* ------------------------------------------------------ bucket cap --- */
|
|
54
|
+
-- Any editor can call gr_ensure_bucket(), which meant they could create 'gr-'
|
|
55
|
+
-- buckets without limit. Existing buckets are untouched and still resolve; only
|
|
56
|
+
-- creating a new one past the cap is refused.
|
|
57
|
+
create or replace function guardrail.gr_ensure_bucket(target_bucket text)
|
|
58
|
+
returns void
|
|
59
|
+
language plpgsql
|
|
60
|
+
security definer
|
|
61
|
+
set search_path = guardrail, pg_temp
|
|
62
|
+
as $$
|
|
63
|
+
begin
|
|
64
|
+
if target_bucket !~ '^gr-[a-z0-9][a-z0-9-]{0,50}$' then
|
|
65
|
+
raise exception 'Guardrail bucket names must start with gr- and use a-z, 0-9, hyphens: %',
|
|
66
|
+
target_bucket;
|
|
67
|
+
end if;
|
|
68
|
+
|
|
69
|
+
if not exists (
|
|
70
|
+
select 1 from guardrail.gr_members
|
|
71
|
+
where user_id = auth.uid()
|
|
72
|
+
and status = 'active'
|
|
73
|
+
and role in ('admin', 'editor')
|
|
74
|
+
) then
|
|
75
|
+
raise exception 'Only Guardrail editors can create media buckets';
|
|
76
|
+
end if;
|
|
77
|
+
|
|
78
|
+
if not exists (select 1 from storage.buckets where id = target_bucket)
|
|
79
|
+
and (select count(*) from storage.buckets where id like 'gr-%') >= 50 then
|
|
80
|
+
raise exception 'This project already has the maximum number of Guardrail media buckets (50).';
|
|
81
|
+
end if;
|
|
82
|
+
|
|
83
|
+
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
|
84
|
+
values (
|
|
85
|
+
target_bucket,
|
|
86
|
+
target_bucket,
|
|
87
|
+
true,
|
|
88
|
+
10485760,
|
|
89
|
+
array['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/avif']
|
|
90
|
+
)
|
|
91
|
+
on conflict (id) do nothing;
|
|
92
|
+
end;
|
|
93
|
+
$$;
|
|
94
|
+
|
|
95
|
+
revoke all on function guardrail.gr_ensure_bucket(text) from public;
|
|
96
|
+
grant execute on function guardrail.gr_ensure_bucket(text) to authenticated;
|