@guard-rail/core 0.9.1 → 0.10.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 +168 -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 +911 -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/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 +1 -1
- package/dist/index.client.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/react/AuthRedirectSync.js +28 -22
- package/dist/react/AuthRedirectSync.js.map +1 -1
- package/dist/react/GRail/render.js +3 -2
- package/dist/react/GRail/render.js.map +1 -1
- 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 +14 -13
- 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/scripts/postinstall.mjs +0 -48
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const OVERRIDE_TYPES = ["text", "link", "image", "button"];
|
|
2
|
+
const LIMITS = {
|
|
3
|
+
pathKey: 200,
|
|
4
|
+
text: 2e4,
|
|
5
|
+
url: 2048,
|
|
6
|
+
alt: 1e3,
|
|
7
|
+
dimension: 2e4
|
|
8
|
+
};
|
|
9
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
10
|
+
const SCHEME = /^([a-z][a-z0-9+.\-]*):/i;
|
|
11
|
+
const LINK_SCHEMES = ["http", "https", "mailto", "tel"];
|
|
12
|
+
const IMAGE_SCHEMES = ["http", "https"];
|
|
13
|
+
function allowedUrl(raw, schemes) {
|
|
14
|
+
if (typeof raw !== "string") return null;
|
|
15
|
+
const value = raw.trim();
|
|
16
|
+
if (!value || value.length > LIMITS.url || CONTROL_CHARS.test(value)) return null;
|
|
17
|
+
const scheme = value.replace(/\s/g, "").match(SCHEME)?.[1]?.toLowerCase();
|
|
18
|
+
if (scheme && !schemes.includes(scheme)) return null;
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
function safeHref(raw) {
|
|
22
|
+
return allowedUrl(raw, LINK_SCHEMES);
|
|
23
|
+
}
|
|
24
|
+
function safeImageSrc(raw) {
|
|
25
|
+
return allowedUrl(raw, IMAGE_SCHEMES);
|
|
26
|
+
}
|
|
27
|
+
function validPathKey(raw) {
|
|
28
|
+
if (typeof raw !== "string") return null;
|
|
29
|
+
const key = raw.trim();
|
|
30
|
+
return key && key.length <= LIMITS.pathKey && !CONTROL_CHARS.test(key) ? key : null;
|
|
31
|
+
}
|
|
32
|
+
function validateOverrideValue(input) {
|
|
33
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
34
|
+
return { ok: false, error: "Invalid value." };
|
|
35
|
+
}
|
|
36
|
+
const v = input;
|
|
37
|
+
if (typeof v.type !== "string" || !OVERRIDE_TYPES.includes(v.type)) {
|
|
38
|
+
return { ok: false, error: `Unknown field type "${String(v.type)}".` };
|
|
39
|
+
}
|
|
40
|
+
const out = { type: v.type };
|
|
41
|
+
if (v.text !== void 0) {
|
|
42
|
+
if (typeof v.text !== "string" || v.text.length > LIMITS.text) {
|
|
43
|
+
return { ok: false, error: `Text must be a string under ${LIMITS.text} characters.` };
|
|
44
|
+
}
|
|
45
|
+
out.text = v.text;
|
|
46
|
+
}
|
|
47
|
+
if (v.alt !== void 0) {
|
|
48
|
+
if (typeof v.alt !== "string" || v.alt.length > LIMITS.alt) {
|
|
49
|
+
return { ok: false, error: `Alt text must be under ${LIMITS.alt} characters.` };
|
|
50
|
+
}
|
|
51
|
+
out.alt = v.alt;
|
|
52
|
+
}
|
|
53
|
+
if (v.href !== void 0 && v.href !== "") {
|
|
54
|
+
const href = safeHref(v.href);
|
|
55
|
+
if (href === null) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
error: "That link isn't allowed. Use a path like /about, or an http(s), mailto or tel address."
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
out.href = href;
|
|
62
|
+
} else if (v.href === "") {
|
|
63
|
+
out.href = "";
|
|
64
|
+
}
|
|
65
|
+
if (v.src !== void 0 && v.src !== "") {
|
|
66
|
+
const src = safeImageSrc(v.src);
|
|
67
|
+
if (src === null) {
|
|
68
|
+
return { ok: false, error: "That image address isn't allowed. Use a path or an http(s) URL." };
|
|
69
|
+
}
|
|
70
|
+
out.src = src;
|
|
71
|
+
} else if (v.src === "") {
|
|
72
|
+
out.src = "";
|
|
73
|
+
}
|
|
74
|
+
for (const key of ["width", "height"]) {
|
|
75
|
+
const n = v[key];
|
|
76
|
+
if (n === void 0) continue;
|
|
77
|
+
if (typeof n !== "number" || !Number.isFinite(n) || n <= 0 || n > LIMITS.dimension) {
|
|
78
|
+
return { ok: false, error: `Image ${key} is out of range.` };
|
|
79
|
+
}
|
|
80
|
+
out[key] = Math.round(n);
|
|
81
|
+
}
|
|
82
|
+
return { ok: true, value: out };
|
|
83
|
+
}
|
|
84
|
+
const EMAIL_PATTERN = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
85
|
+
function cleanEmail(raw) {
|
|
86
|
+
if (typeof raw !== "string") return null;
|
|
87
|
+
const email = raw.trim().toLowerCase();
|
|
88
|
+
return email.length <= 254 && !CONTROL_CHARS.test(email) && EMAIL_PATTERN.test(email) ? email : null;
|
|
89
|
+
}
|
|
90
|
+
function isRole(raw) {
|
|
91
|
+
return raw === "admin" || raw === "editor";
|
|
92
|
+
}
|
|
93
|
+
function validId(raw) {
|
|
94
|
+
return typeof raw === "string" && raw.length > 0 && raw.length <= LIMITS.pathKey && !CONTROL_CHARS.test(raw) ? raw : null;
|
|
95
|
+
}
|
|
96
|
+
function validIdList(raw, max = 1e3) {
|
|
97
|
+
if (!Array.isArray(raw) || raw.length > max) return null;
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const item of raw) {
|
|
100
|
+
const id = validId(item);
|
|
101
|
+
if (id === null) return null;
|
|
102
|
+
out.push(id);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
export {
|
|
107
|
+
LIMITS,
|
|
108
|
+
OVERRIDE_TYPES,
|
|
109
|
+
cleanEmail,
|
|
110
|
+
isRole,
|
|
111
|
+
safeHref,
|
|
112
|
+
safeImageSrc,
|
|
113
|
+
validId,
|
|
114
|
+
validIdList,
|
|
115
|
+
validPathKey,
|
|
116
|
+
validateOverrideValue
|
|
117
|
+
};
|
|
118
|
+
//# sourceMappingURL=validate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/core/validate.ts"],"sourcesContent":["import type { GRRole, OverrideType, OverrideValue } from \"./types\";\n\n/**\n * Checks on field values before they are stored and again before they are\n * rendered. Pure and dependency-free, so the same rules run on the server (what\n * may be written) and in the renderer (what rows already in the table may do).\n *\n * Why it matters: an editor types these, a visitor's browser then acts on them.\n * A link whose href is `javascript:...` is a script that runs for whoever\n * clicks it, so a low-privilege editor account must not be able to store one.\n */\n\nexport const OVERRIDE_TYPES: readonly OverrideType[] = [\"text\", \"link\", \"image\", \"button\"];\n\nexport const LIMITS = {\n pathKey: 200,\n text: 20_000,\n url: 2_048,\n alt: 1_000,\n dimension: 20_000,\n} as const;\n\nconst CONTROL_CHARS = /[\\u0000-\\u001f\\u007f]/;\nconst SCHEME = /^([a-z][a-z0-9+.\\-]*):/i;\n\nconst LINK_SCHEMES = [\"http\", \"https\", \"mailto\", \"tel\"];\nconst IMAGE_SCHEMES = [\"http\", \"https\"];\n\nfunction allowedUrl(raw: unknown, schemes: readonly string[]): string | null {\n if (typeof raw !== \"string\") return null;\n const value = raw.trim();\n if (!value || value.length > LIMITS.url || CONTROL_CHARS.test(value)) return null;\n // A browser ignores whitespace inside a scheme (\"java\\tscript:\"), so look for\n // one with it removed. Control characters are already refused above.\n const scheme = value.replace(/\\s/g, \"\").match(SCHEME)?.[1]?.toLowerCase();\n if (scheme && !schemes.includes(scheme)) return null;\n return value;\n}\n\n/** The link, if it is a relative path, an anchor, or http(s)/mailto/tel; otherwise null. */\nexport function safeHref(raw: unknown): string | null {\n return allowedUrl(raw, LINK_SCHEMES);\n}\n\n/** The image address, if it is relative or http(s); otherwise null. */\nexport function safeImageSrc(raw: unknown): string | null {\n return allowedUrl(raw, IMAGE_SCHEMES);\n}\n\nexport function validPathKey(raw: unknown): string | null {\n if (typeof raw !== \"string\") return null;\n const key = raw.trim();\n return key && key.length <= LIMITS.pathKey && !CONTROL_CHARS.test(key) ? key : null;\n}\n\ntype Checked = { ok: true; value: OverrideValue } | { ok: false; error: string };\n\n/**\n * Validates a value from the browser and returns a clean copy: only known\n * fields, only the right shapes, bounded sizes. Anything else is an error, not\n * silently dropped, so a bad write fails visibly.\n */\nexport function validateOverrideValue(input: unknown): Checked {\n if (!input || typeof input !== \"object\" || Array.isArray(input)) {\n return { ok: false, error: \"Invalid value.\" };\n }\n const v = input as Record<string, unknown>;\n if (typeof v.type !== \"string\" || !OVERRIDE_TYPES.includes(v.type as OverrideType)) {\n return { ok: false, error: `Unknown field type \"${String(v.type)}\".` };\n }\n const out: OverrideValue = { type: v.type as OverrideType };\n\n if (v.text !== undefined) {\n if (typeof v.text !== \"string\" || v.text.length > LIMITS.text) {\n return { ok: false, error: `Text must be a string under ${LIMITS.text} characters.` };\n }\n out.text = v.text;\n }\n if (v.alt !== undefined) {\n if (typeof v.alt !== \"string\" || v.alt.length > LIMITS.alt) {\n return { ok: false, error: `Alt text must be under ${LIMITS.alt} characters.` };\n }\n out.alt = v.alt;\n }\n if (v.href !== undefined && v.href !== \"\") {\n const href = safeHref(v.href);\n if (href === null) {\n return {\n ok: false,\n error: \"That link isn't allowed. Use a path like /about, or an http(s), mailto or tel address.\",\n };\n }\n out.href = href;\n } else if (v.href === \"\") {\n out.href = \"\";\n }\n if (v.src !== undefined && v.src !== \"\") {\n const src = safeImageSrc(v.src);\n if (src === null) {\n return { ok: false, error: \"That image address isn't allowed. Use a path or an http(s) URL.\" };\n }\n out.src = src;\n } else if (v.src === \"\") {\n out.src = \"\";\n }\n for (const key of [\"width\", \"height\"] as const) {\n const n = v[key];\n if (n === undefined) continue;\n if (typeof n !== \"number\" || !Number.isFinite(n) || n <= 0 || n > LIMITS.dimension) {\n return { ok: false, error: `Image ${key} is out of range.` };\n }\n out[key] = Math.round(n);\n }\n return { ok: true, value: out };\n}\n\nconst EMAIL_PATTERN = /^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/;\n\n/** A normalized (trimmed, lower-cased) address, or null if it isn't a plausible one. RFC 5321 caps an address at 254 characters. */\nexport function cleanEmail(raw: unknown): string | null {\n if (typeof raw !== \"string\") return null;\n const email = raw.trim().toLowerCase();\n return email.length <= 254 && !CONTROL_CHARS.test(email) && EMAIL_PATTERN.test(email) ? email : null;\n}\n\nexport function isRole(raw: unknown): raw is GRRole {\n return raw === \"admin\" || raw === \"editor\";\n}\n\n/** An identifier from the browser (a repeater or item id): a bounded string without control characters. */\nexport function validId(raw: unknown): string | null {\n return typeof raw === \"string\" && raw.length > 0 && raw.length <= LIMITS.pathKey && !CONTROL_CHARS.test(raw)\n ? raw\n : null;\n}\n\n/** A list of ids: an array of valid ids, no longer than `max`. */\nexport function validIdList(raw: unknown, max = 1_000): string[] | null {\n if (!Array.isArray(raw) || raw.length > max) return null;\n const out: string[] = [];\n for (const item of raw) {\n const id = validId(item);\n if (id === null) return null;\n out.push(id);\n }\n return out;\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.client.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export { addRepeaterItem, removeRepeaterItem, reorderRepeaterItems } from './ser
|
|
|
5
5
|
export { GRepeaterProps, RepeaterItemProps } from './react/Repeater/index.js';
|
|
6
6
|
export { ResendEmailOptions, ResendInviteContent, ResendInviteOptions, ResendInviteTemplateEvent, ResendResetTemplateEvent } from './email/resend.js';
|
|
7
7
|
export { ActionState, GRAsset, GRMember, GRRepeaterItem, GRRole, GRState, GRViewer, HTMLKeys, OverrideRow, OverrideType, OverrideValue, PolymorphicProps, SetPasswordState } from './core/types.js';
|
|
8
|
-
export { AuthRedirectParams, AuthResult, GRAuthAdapter, GRConfig, GRDBAdapter, GRInviteEvent, GRMembershipAdapter, GRPasswordResetEvent, GRPrefetch, GRRepeaterAdapter, GRSession, GRStorageAdapter, GRStorageRow, GRUploadTarget } from './core/adapter-types.js';
|
|
8
|
+
export { AuthRedirectParams, AuthResult, GRAuthAdapter, GRConfig, GRDBAdapter, GRInviteEvent, GRMembershipAdapter, GRPasswordResetEvent, GRPrefetch, GRRateLimitHit, GRRateLimitName, GRRateLimiter, GRRepeaterAdapter, GRSession, GRStorageAdapter, GRStorageRow, GRUploadTarget } from './core/adapter-types.js';
|
|
9
9
|
import 'react/jsx-runtime';
|
|
10
10
|
import 'react';
|
|
11
11
|
|
package/dist/index.client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.client.ts"],"sourcesContent":["// Client-graph entry. `import ... from \"@guard-rail/core\"` resolves here from a\n// Client Component (see \"exports\" in package.json) and to ./index from a\n// Server Component, so an app uses one import path everywhere.\n//\n// Nothing server-only may be imported here — this is bundled for the browser.\n// Server Actions are fine: a \"use server\" module is replaced by references in\n// the client bundle, which is what lets an app build its own team/repeater UI\n// in a Client Component.\n\nexport { GRail } from \"./react/GRail/universal\";\nexport { useGRail } from \"./react/Provider\";\n\nexport {\n listTeam,\n inviteMember,\n updateMemberRole,\n removeMember,\n createAssetUpload,\n listAssets,\n setPasswordAction,\n skipSetPasswordAction,\n forgotPasswordAction,\n sendPasswordResetAction,\n setFieldValue,\n} from \"./server/actions\";\nexport {\n addRepeaterItem,\n removeRepeaterItem,\n reorderRepeaterItems,\n} from \"./server/repeaterActions\";\n\n/**\n * Stand-ins for the server-only exports, so importing one from a Client\n * Component fails with an explanation instead of an undefined-is-not-a-function\n * deep in a render. Also what a tool that doesn't set the \"react-server\"\n * condition (a test runner, a plain Node script) gets from the package root —\n * `@guard-rail/core/server` is the explicit path to the real ones there.\n */\nfunction serverOnly(name: string, hint: string) {\n return (..._args: unknown[]): never => {\n throw new Error(\n `Guardrail: ${name} only works in a Server Component. ${hint} ` +\n `(If this isn't a Client Component, your tooling may not set the ` +\n `\"react-server\" condition — import from \"@guard-rail/core/server\" instead.)`,\n );\n };\n}\n\nexport const withGuardrail = serverOnly(\n \"withGuardrail()\",\n \"Call it in your root layout, which is a Server Component.\",\n);\nexport const configureGuardrail = serverOnly(\n \"configureGuardrail()\",\n \"Configuration is applied by withGuardrail() in your root layout.\",\n);\nexport const GRepeater = serverOnly(\n \"<GRepeater>\",\n \"Render it in a Server Component and pass it into this one as children or a prop.\",\n);\nexport const listRepeaterItems = serverOnly(\n \"listRepeaterItems()\",\n \"Read repeater items in a Server Component and pass them down.\",\n);\nexport const getRepeaterItemBySlug = serverOnly(\n \"getRepeaterItemBySlug()\",\n \"Resolve the item in a Server Component (your [slug]/page.tsx) and pass it down.\",\n);\nexport const getFieldValue = serverOnly(\n \"getFieldValue()\",\n \"Read the value in a Server Component and pass it down as a prop.\",\n);\nexport const isAdmin = serverOnly(\n \"isAdmin()\",\n \"Call it inside your own Server Action to authorize it; in a Client Component, read useGRail().role to decide what to show.\",\n);\nexport const isEditor = serverOnly(\n \"isEditor()\",\n \"Call it inside your own Server Action to authorize it; in a Client Component, read useGRail().role to decide what to show.\",\n);\nexport const canEdit = serverOnly(\n \"canEdit()\",\n \"Call it in a Server Component; in a Client Component, read `canEdit` from useGRail().\",\n);\nexport const resendInvite = serverOnly(\n \"resendInvite()\",\n \"Pass it as onInvite in withGuardrail(), which runs in your root layout.\",\n);\nexport const resendEmails = serverOnly(\n \"resendEmails()\",\n \"Spread it into withGuardrail(), which runs in your root layout.\",\n);\nexport const consoleEmails = serverOnly(\n \"consoleEmails()\",\n \"Spread it into withGuardrail(), which runs in your root layout.\",\n);\n\nexport type { GRepeaterProps, RepeaterItemProps } from \"./react/Repeater\";\nexport type {\n ResendInviteOptions,\n ResendEmailOptions,\n ResendInviteContent,\n ResendInviteTemplateEvent,\n ResendResetTemplateEvent,\n} from \"./email/resend\";\nexport type {\n GRState,\n GRRole,\n GRMember,\n GRViewer,\n GRAsset,\n GRRepeaterItem,\n ActionState,\n SetPasswordState,\n OverrideRow,\n OverrideValue,\n OverrideType,\n PolymorphicProps,\n HTMLKeys,\n} from \"./core/types\";\nexport type {\n GRConfig,\n GRPrefetch,\n GRMembershipAdapter,\n GRInviteEvent,\n GRStorageAdapter,\n GRUploadTarget,\n GRRepeaterAdapter,\n GRDBAdapter,\n GRAuthAdapter,\n GRSession,\n GRStorageRow,\n AuthResult,\n AuthRedirectParams,\n GRPasswordResetEvent,\n} from \"./core/adapter-types\";\n"],"mappings":"AASA,SAAS,aAAa;AACtB,SAAS,gBAAgB;AAEzB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AASP,SAAS,WAAW,MAAc,MAAc;AAC9C,SAAO,IAAI,UAA4B;AACrC,UAAM,IAAI;AAAA,MACR,cAAc,IAAI,sCAAsC,IAAI;AAAA,IAG9D;AAAA,EACF;AACF;AAEO,MAAM,gBAAgB;AAAA,EAC3B;AAAA,EACA;AACF;AACO,MAAM,qBAAqB;AAAA,EAChC;AAAA,EACA;AACF;AACO,MAAM,YAAY;AAAA,EACvB;AAAA,EACA;AACF;AACO,MAAM,oBAAoB;AAAA,EAC/B;AAAA,EACA;AACF;AACO,MAAM,wBAAwB;AAAA,EACnC;AAAA,EACA;AACF;AACO,MAAM,gBAAgB;AAAA,EAC3B;AAAA,EACA;AACF;AACO,MAAM,UAAU;AAAA,EACrB;AAAA,EACA;AACF;AACO,MAAM,WAAW;AAAA,EACtB;AAAA,EACA;AACF;AACO,MAAM,UAAU;AAAA,EACrB;AAAA,EACA;AACF;AACO,MAAM,eAAe;AAAA,EAC1B;AAAA,EACA;AACF;AACO,MAAM,eAAe;AAAA,EAC1B;AAAA,EACA;AACF;AACO,MAAM,gBAAgB;AAAA,EAC3B;AAAA,EACA;AACF;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.client.ts"],"sourcesContent":["// Client-graph entry. `import ... from \"@guard-rail/core\"` resolves here from a\n// Client Component (see \"exports\" in package.json) and to ./index from a\n// Server Component, so an app uses one import path everywhere.\n//\n// Nothing server-only may be imported here — this is bundled for the browser.\n// Server Actions are fine: a \"use server\" module is replaced by references in\n// the client bundle, which is what lets an app build its own team/repeater UI\n// in a Client Component.\n\nexport { GRail } from \"./react/GRail/universal\";\nexport { useGRail } from \"./react/Provider\";\n\nexport {\n listTeam,\n inviteMember,\n updateMemberRole,\n removeMember,\n createAssetUpload,\n listAssets,\n setPasswordAction,\n skipSetPasswordAction,\n forgotPasswordAction,\n sendPasswordResetAction,\n setFieldValue,\n} from \"./server/actions\";\nexport {\n addRepeaterItem,\n removeRepeaterItem,\n reorderRepeaterItems,\n} from \"./server/repeaterActions\";\n\n/**\n * Stand-ins for the server-only exports, so importing one from a Client\n * Component fails with an explanation instead of an undefined-is-not-a-function\n * deep in a render. Also what a tool that doesn't set the \"react-server\"\n * condition (a test runner, a plain Node script) gets from the package root —\n * `@guard-rail/core/server` is the explicit path to the real ones there.\n */\nfunction serverOnly(name: string, hint: string) {\n return (..._args: unknown[]): never => {\n throw new Error(\n `Guardrail: ${name} only works in a Server Component. ${hint} ` +\n `(If this isn't a Client Component, your tooling may not set the ` +\n `\"react-server\" condition — import from \"@guard-rail/core/server\" instead.)`,\n );\n };\n}\n\nexport const withGuardrail = serverOnly(\n \"withGuardrail()\",\n \"Call it in your root layout, which is a Server Component.\",\n);\nexport const configureGuardrail = serverOnly(\n \"configureGuardrail()\",\n \"Configuration is applied by withGuardrail() in your root layout.\",\n);\nexport const GRepeater = serverOnly(\n \"<GRepeater>\",\n \"Render it in a Server Component and pass it into this one as children or a prop.\",\n);\nexport const listRepeaterItems = serverOnly(\n \"listRepeaterItems()\",\n \"Read repeater items in a Server Component and pass them down.\",\n);\nexport const getRepeaterItemBySlug = serverOnly(\n \"getRepeaterItemBySlug()\",\n \"Resolve the item in a Server Component (your [slug]/page.tsx) and pass it down.\",\n);\nexport const getFieldValue = serverOnly(\n \"getFieldValue()\",\n \"Read the value in a Server Component and pass it down as a prop.\",\n);\nexport const isAdmin = serverOnly(\n \"isAdmin()\",\n \"Call it inside your own Server Action to authorize it; in a Client Component, read useGRail().role to decide what to show.\",\n);\nexport const isEditor = serverOnly(\n \"isEditor()\",\n \"Call it inside your own Server Action to authorize it; in a Client Component, read useGRail().role to decide what to show.\",\n);\nexport const canEdit = serverOnly(\n \"canEdit()\",\n \"Call it in a Server Component; in a Client Component, read `canEdit` from useGRail().\",\n);\nexport const resendInvite = serverOnly(\n \"resendInvite()\",\n \"Pass it as onInvite in withGuardrail(), which runs in your root layout.\",\n);\nexport const resendEmails = serverOnly(\n \"resendEmails()\",\n \"Spread it into withGuardrail(), which runs in your root layout.\",\n);\nexport const consoleEmails = serverOnly(\n \"consoleEmails()\",\n \"Spread it into withGuardrail(), which runs in your root layout.\",\n);\n\nexport type { GRepeaterProps, RepeaterItemProps } from \"./react/Repeater\";\nexport type {\n ResendInviteOptions,\n ResendEmailOptions,\n ResendInviteContent,\n ResendInviteTemplateEvent,\n ResendResetTemplateEvent,\n} from \"./email/resend\";\nexport type {\n GRState,\n GRRole,\n GRMember,\n GRViewer,\n GRAsset,\n GRRepeaterItem,\n ActionState,\n SetPasswordState,\n OverrideRow,\n OverrideValue,\n OverrideType,\n PolymorphicProps,\n HTMLKeys,\n} from \"./core/types\";\nexport type {\n GRConfig,\n GRPrefetch,\n GRMembershipAdapter,\n GRInviteEvent,\n GRStorageAdapter,\n GRUploadTarget,\n GRRepeaterAdapter,\n GRDBAdapter,\n GRAuthAdapter,\n GRSession,\n GRStorageRow,\n AuthResult,\n AuthRedirectParams,\n GRPasswordResetEvent,\n GRRateLimiter,\n GRRateLimitHit,\n GRRateLimitName,\n} from \"./core/adapter-types\";\n"],"mappings":"AASA,SAAS,aAAa;AACtB,SAAS,gBAAgB;AAEzB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AASP,SAAS,WAAW,MAAc,MAAc;AAC9C,SAAO,IAAI,UAA4B;AACrC,UAAM,IAAI;AAAA,MACR,cAAc,IAAI,sCAAsC,IAAI;AAAA,IAG9D;AAAA,EACF;AACF;AAEO,MAAM,gBAAgB;AAAA,EAC3B;AAAA,EACA;AACF;AACO,MAAM,qBAAqB;AAAA,EAChC;AAAA,EACA;AACF;AACO,MAAM,YAAY;AAAA,EACvB;AAAA,EACA;AACF;AACO,MAAM,oBAAoB;AAAA,EAC/B;AAAA,EACA;AACF;AACO,MAAM,wBAAwB;AAAA,EACnC;AAAA,EACA;AACF;AACO,MAAM,gBAAgB;AAAA,EAC3B;AAAA,EACA;AACF;AACO,MAAM,UAAU;AAAA,EACrB;AAAA,EACA;AACF;AACO,MAAM,WAAW;AAAA,EACtB;AAAA,EACA;AACF;AACO,MAAM,UAAU;AAAA,EACrB;AAAA,EACA;AACF;AACO,MAAM,eAAe;AAAA,EAC1B;AAAA,EACA;AACF;AACO,MAAM,eAAe;AAAA,EAC1B;AAAA,EACA;AACF;AACO,MAAM,gBAAgB;AAAA,EAC3B;AAAA,EACA;AACF;","names":[]}
|
package/dist/index.d.ts
CHANGED
|
@@ -11,6 +11,6 @@ export { consoleEmails } from './email/console.js';
|
|
|
11
11
|
export { addRepeaterItem, removeRepeaterItem, reorderRepeaterItems } from './server/repeaterActions.js';
|
|
12
12
|
export { useGRail } from './react/Provider.js';
|
|
13
13
|
export { ActionState, GRAsset, GRMember, GRRepeaterItem, GRRole, GRState, GRViewer, HTMLKeys, OverrideRow, OverrideType, OverrideValue, PolymorphicProps, SetPasswordState } from './core/types.js';
|
|
14
|
-
export { AuthRedirectParams, AuthResult, GRAuthAdapter, GRConfig, GRDBAdapter, GRInviteEvent, GRMembershipAdapter, GRPasswordResetEvent, GRPrefetch, GRRepeaterAdapter, GRSession, GRStorageAdapter, GRStorageRow, GRUploadTarget } from './core/adapter-types.js';
|
|
14
|
+
export { AuthRedirectParams, AuthResult, GRAuthAdapter, GRConfig, GRDBAdapter, GRInviteEvent, GRMembershipAdapter, GRPasswordResetEvent, GRPrefetch, GRRateLimitHit, GRRateLimitName, GRRateLimiter, GRRepeaterAdapter, GRSession, GRStorageAdapter, GRStorageRow, GRUploadTarget } from './core/adapter-types.js';
|
|
15
15
|
import 'react/jsx-runtime';
|
|
16
16
|
import 'react';
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["// Server-safe entry. Client components are reached through the files that\n// carry their own \"use client\" directive, so importing this from a Server\n// Component does not pull the editor into the client bundle.\nexport { withGuardrail } from \"./server/wrapper\";\n\n// Team management and the set-password step, exported so an app can build\n// its own UI instead of using the bundled drawer / sign-in bar.\nexport {\n listTeam,\n inviteMember,\n updateMemberRole,\n removeMember,\n createAssetUpload,\n listAssets,\n setPasswordAction,\n skipSetPasswordAction,\n forgotPasswordAction,\n sendPasswordResetAction,\n setFieldValue,\n} from \"./server/actions\";\nexport { GRail } from \"./react/GRail\";\nexport { GRepeater } from \"./react/Repeater\";\nexport type { GRepeaterProps, RepeaterItemProps } from \"./react/Repeater\";\nexport { configureGuardrail } from \"./core/config\";\nexport {\n listRepeaterItems,\n getRepeaterItemBySlug,\n} from \"./server/repeaterData\";\nexport { getFieldValue } from \"./server/fieldData\";\n// Role / edit-mode checks for a custom admin UI and the Server Actions behind\n// it. Plain server functions, not actions — see access.ts for why.\nexport { isAdmin, isEditor, canEdit } from \"./server/access\";\n// Invite delivery through Resend, as an onInvite handler — see email/resend.ts.\nexport { resendInvite, resendEmails } from \"./email/resend\";\nexport { consoleEmails } from \"./email/console\";\nexport type {\n ResendInviteOptions,\n ResendEmailOptions,\n ResendInviteContent,\n ResendInviteTemplateEvent,\n ResendResetTemplateEvent,\n} from \"./email/resend\";\n// Mutations, exported so an app can build its own add/remove/reorder UI\n// instead of the bundled chrome — same rationale as the team actions above.\nexport {\n addRepeaterItem,\n removeRepeaterItem,\n reorderRepeaterItems,\n} from \"./server/repeaterActions\";\nexport { useGRail } from \"./react/Provider\";\n\nexport type {\n GRState,\n GRRole,\n GRMember,\n GRViewer,\n GRAsset,\n GRRepeaterItem,\n ActionState,\n SetPasswordState,\n OverrideRow,\n OverrideValue,\n OverrideType,\n PolymorphicProps,\n HTMLKeys,\n} from \"./core/types\";\nexport type {\n GRConfig,\n GRPrefetch,\n GRMembershipAdapter,\n GRInviteEvent,\n GRStorageAdapter,\n GRUploadTarget,\n GRRepeaterAdapter,\n GRDBAdapter,\n GRAuthAdapter,\n GRSession,\n GRStorageRow,\n AuthResult,\n AuthRedirectParams,\n GRPasswordResetEvent,\n} from \"./core/adapter-types\";\n"],"mappings":"AAGA,SAAS,qBAAqB;AAI9B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,aAAa;AACtB,SAAS,iBAAiB;AAE1B,SAAS,0BAA0B;AACnC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,qBAAqB;AAG9B,SAAS,SAAS,UAAU,eAAe;AAE3C,SAAS,cAAc,oBAAoB;AAC3C,SAAS,qBAAqB;AAU9B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,gBAAgB;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["// Server-safe entry. Client components are reached through the files that\n// carry their own \"use client\" directive, so importing this from a Server\n// Component does not pull the editor into the client bundle.\nexport { withGuardrail } from \"./server/wrapper\";\n\n// Team management and the set-password step, exported so an app can build\n// its own UI instead of using the bundled drawer / sign-in bar.\nexport {\n listTeam,\n inviteMember,\n updateMemberRole,\n removeMember,\n createAssetUpload,\n listAssets,\n setPasswordAction,\n skipSetPasswordAction,\n forgotPasswordAction,\n sendPasswordResetAction,\n setFieldValue,\n} from \"./server/actions\";\nexport { GRail } from \"./react/GRail\";\nexport { GRepeater } from \"./react/Repeater\";\nexport type { GRepeaterProps, RepeaterItemProps } from \"./react/Repeater\";\nexport { configureGuardrail } from \"./core/config\";\nexport {\n listRepeaterItems,\n getRepeaterItemBySlug,\n} from \"./server/repeaterData\";\nexport { getFieldValue } from \"./server/fieldData\";\n// Role / edit-mode checks for a custom admin UI and the Server Actions behind\n// it. Plain server functions, not actions — see access.ts for why.\nexport { isAdmin, isEditor, canEdit } from \"./server/access\";\n// Invite delivery through Resend, as an onInvite handler — see email/resend.ts.\nexport { resendInvite, resendEmails } from \"./email/resend\";\nexport { consoleEmails } from \"./email/console\";\nexport type {\n ResendInviteOptions,\n ResendEmailOptions,\n ResendInviteContent,\n ResendInviteTemplateEvent,\n ResendResetTemplateEvent,\n} from \"./email/resend\";\n// Mutations, exported so an app can build its own add/remove/reorder UI\n// instead of the bundled chrome — same rationale as the team actions above.\nexport {\n addRepeaterItem,\n removeRepeaterItem,\n reorderRepeaterItems,\n} from \"./server/repeaterActions\";\nexport { useGRail } from \"./react/Provider\";\n\nexport type {\n GRState,\n GRRole,\n GRMember,\n GRViewer,\n GRAsset,\n GRRepeaterItem,\n ActionState,\n SetPasswordState,\n OverrideRow,\n OverrideValue,\n OverrideType,\n PolymorphicProps,\n HTMLKeys,\n} from \"./core/types\";\nexport type {\n GRConfig,\n GRPrefetch,\n GRMembershipAdapter,\n GRInviteEvent,\n GRStorageAdapter,\n GRUploadTarget,\n GRRepeaterAdapter,\n GRDBAdapter,\n GRAuthAdapter,\n GRSession,\n GRStorageRow,\n AuthResult,\n AuthRedirectParams,\n GRPasswordResetEvent,\n GRRateLimiter,\n GRRateLimitHit,\n GRRateLimitName,\n} from \"./core/adapter-types\";\n"],"mappings":"AAGA,SAAS,qBAAqB;AAI9B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,aAAa;AACtB,SAAS,iBAAiB;AAE1B,SAAS,0BAA0B;AACnC;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,qBAAqB;AAG9B,SAAS,SAAS,UAAU,eAAe;AAE3C,SAAS,cAAc,oBAAoB;AAC3C,SAAS,qBAAqB;AAU9B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,gBAAgB;","names":[]}
|
|
@@ -6,29 +6,35 @@ function AuthRedirectSync() {
|
|
|
6
6
|
const searchParams = useSearchParams();
|
|
7
7
|
const handled = useRef(false);
|
|
8
8
|
useEffect(() => {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
9
|
+
const check = () => {
|
|
10
|
+
if (handled.current) return;
|
|
11
|
+
const rawHash = window.location.hash.startsWith("#") ? window.location.hash.slice(1) : window.location.hash;
|
|
12
|
+
const hasHashTokens = /(?:^|&)access_token=/.test(rawHash);
|
|
13
|
+
const hashParams = new URLSearchParams(rawHash);
|
|
14
|
+
const code = searchParams.get("code") ?? void 0;
|
|
15
|
+
const tokenHash = hashParams.get("token_hash") ?? searchParams.get("token_hash") ?? void 0;
|
|
16
|
+
const type = hashParams.get("type") ?? searchParams.get("type") ?? void 0;
|
|
17
|
+
if (!hasHashTokens && !code && !tokenHash) return;
|
|
18
|
+
handled.current = true;
|
|
19
|
+
const params = {
|
|
20
|
+
...hasHashTokens ? { hash: rawHash } : {},
|
|
21
|
+
...code ? { code } : {},
|
|
22
|
+
...tokenHash ? { tokenHash } : {},
|
|
23
|
+
...type ? { type } : {}
|
|
24
|
+
};
|
|
25
|
+
completeAuthRedirectAction(params).then((res) => {
|
|
26
|
+
const url = new URL(window.location.href);
|
|
27
|
+
url.hash = "";
|
|
28
|
+
url.searchParams.delete("code");
|
|
29
|
+
url.searchParams.delete("token_hash");
|
|
30
|
+
url.searchParams.delete("type");
|
|
31
|
+
window.history.replaceState(null, "", url.toString());
|
|
32
|
+
window.location.reload();
|
|
33
|
+
});
|
|
22
34
|
};
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
url.searchParams.delete("code");
|
|
27
|
-
url.searchParams.delete("token_hash");
|
|
28
|
-
url.searchParams.delete("type");
|
|
29
|
-
window.history.replaceState(null, "", url.toString());
|
|
30
|
-
window.location.reload();
|
|
31
|
-
});
|
|
35
|
+
check();
|
|
36
|
+
window.addEventListener("hashchange", check);
|
|
37
|
+
return () => window.removeEventListener("hashchange", check);
|
|
32
38
|
}, [searchParams]);
|
|
33
39
|
return null;
|
|
34
40
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/react/AuthRedirectSync.tsx"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { useSearchParams } from \"next/navigation\";\nimport { completeAuthRedirectAction } from \"../server/actions\";\n\n/**\n * Notices a session that arrived via a redirect link — an invite, magic\n * link, or password-recovery email — and hands whatever it found to\n * completeAuthRedirectAction, which defers to the auth adapter's own\n * completeAuthRedirect for the actual exchange. Deliberately generic: this\n * file has no idea what a Supabase token looks like, only that a hash\n * carrying `access_token=` or a `code`/`token_hash` query param might be one.\n *\n * Has to run client-side and unconditionally (not gated behind edit mode,\n * unlike EditSyncer) — an invite link redirects to the site's plain URL, not\n * necessarily one with ?edit=true, and hash-based tokens never reach the\n * server on their own; only the browser ever sees window.location.hash.\n *\n * Mirrors the community-documented pattern for this exact problem, just\n * generalized across whichever redirect style a provider's own email\n * templates happen to produce, rather than requiring one specific route.\n */\nexport function AuthRedirectSync() {\n const searchParams = useSearchParams();\n const handled = useRef(false);\n\n useEffect(() => {\n if (handled.current) return;\n\n
|
|
1
|
+
{"version":3,"sources":["../../src/react/AuthRedirectSync.tsx"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { useSearchParams } from \"next/navigation\";\nimport { completeAuthRedirectAction } from \"../server/actions\";\n\n/**\n * Notices a session that arrived via a redirect link — an invite, magic\n * link, or password-recovery email — and hands whatever it found to\n * completeAuthRedirectAction, which defers to the auth adapter's own\n * completeAuthRedirect for the actual exchange. Deliberately generic: this\n * file has no idea what a Supabase token looks like, only that a hash\n * carrying `access_token=` or a `code`/`token_hash` query param might be one.\n *\n * Has to run client-side and unconditionally (not gated behind edit mode,\n * unlike EditSyncer) — an invite link redirects to the site's plain URL, not\n * necessarily one with ?edit=true, and hash-based tokens never reach the\n * server on their own; only the browser ever sees window.location.hash.\n *\n * Mirrors the community-documented pattern for this exact problem, just\n * generalized across whichever redirect style a provider's own email\n * templates happen to produce, rather than requiring one specific route.\n */\nexport function AuthRedirectSync() {\n const searchParams = useSearchParams();\n const handled = useRef(false);\n\n useEffect(() => {\n const check = () => {\n if (handled.current) return;\n\n const rawHash = window.location.hash.startsWith(\"#\")\n ? window.location.hash.slice(1)\n : window.location.hash;\n // The one thing worth checking before assuming this hash is ours: every\n // normal page load has an empty hash, and this runs on all of them.\n const hasHashTokens = /(?:^|&)access_token=/.test(rawHash);\n\n // Links Guardrail mints carry token_hash/type in the fragment; the query\n // string is still read for links emailed before that change.\n const hashParams = new URLSearchParams(rawHash);\n const code = searchParams.get(\"code\") ?? undefined;\n const tokenHash =\n hashParams.get(\"token_hash\") ?? searchParams.get(\"token_hash\") ?? undefined;\n const type = hashParams.get(\"type\") ?? searchParams.get(\"type\") ?? undefined;\n\n if (!hasHashTokens && !code && !tokenHash) return;\n handled.current = true;\n\n const params = {\n ...(hasHashTokens ? { hash: rawHash } : {}),\n ...(code ? { code } : {}),\n ...(tokenHash ? { tokenHash } : {}),\n ...(type ? { type } : {}),\n };\n\n completeAuthRedirectAction(params).then((res) => {\n // Stripped either way — a token that failed to exchange (already used,\n // expired) has no business lingering in the address bar or history\n // either.\n const url = new URL(window.location.href);\n url.hash = \"\";\n url.searchParams.delete(\"code\");\n url.searchParams.delete(\"token_hash\");\n url.searchParams.delete(\"type\");\n window.history.replaceState(null, \"\", url.toString());\n\n // A full reload, not router.refresh(): the session was just set via\n // cookies a Server Action wrote, and the current page's React tree\n // (including this component) was built from the pre-session render —\n // simplest to just start over. On failure the action set a cookie\n // explaining it, and a reload is what shows that, rather than leaving the\n // person on a page where nothing visibly happened.\n window.location.reload();\n });\n };\n\n check();\n // A link pasted into a tab that already has the site open changes only the\n // fragment, which is not a page load.\n window.addEventListener(\"hashchange\", check);\n return () => window.removeEventListener(\"hashchange\", check);\n }, [searchParams]);\n\n return null;\n}\n"],"mappings":";AAEA,SAAS,WAAW,cAAc;AAClC,SAAS,uBAAuB;AAChC,SAAS,kCAAkC;AAmBpC,SAAS,mBAAmB;AACjC,QAAM,eAAe,gBAAgB;AACrC,QAAM,UAAU,OAAO,KAAK;AAE5B,YAAU,MAAM;AACd,UAAM,QAAQ,MAAM;AAClB,UAAI,QAAQ,QAAS;AAErB,YAAM,UAAU,OAAO,SAAS,KAAK,WAAW,GAAG,IAC/C,OAAO,SAAS,KAAK,MAAM,CAAC,IAC5B,OAAO,SAAS;AAGpB,YAAM,gBAAgB,uBAAuB,KAAK,OAAO;AAIzD,YAAM,aAAa,IAAI,gBAAgB,OAAO;AAC9C,YAAM,OAAO,aAAa,IAAI,MAAM,KAAK;AACzC,YAAM,YACJ,WAAW,IAAI,YAAY,KAAK,aAAa,IAAI,YAAY,KAAK;AACpE,YAAM,OAAO,WAAW,IAAI,MAAM,KAAK,aAAa,IAAI,MAAM,KAAK;AAEnE,UAAI,CAAC,iBAAiB,CAAC,QAAQ,CAAC,UAAW;AAC3C,cAAQ,UAAU;AAElB,YAAM,SAAS;AAAA,QACb,GAAI,gBAAgB,EAAE,MAAM,QAAQ,IAAI,CAAC;AAAA,QACzC,GAAI,OAAO,EAAE,KAAK,IAAI,CAAC;AAAA,QACvB,GAAI,YAAY,EAAE,UAAU,IAAI,CAAC;AAAA,QACjC,GAAI,OAAO,EAAE,KAAK,IAAI,CAAC;AAAA,MACzB;AAEA,iCAA2B,MAAM,EAAE,KAAK,CAAC,QAAQ;AAI/C,cAAM,MAAM,IAAI,IAAI,OAAO,SAAS,IAAI;AACxC,YAAI,OAAO;AACX,YAAI,aAAa,OAAO,MAAM;AAC9B,YAAI,aAAa,OAAO,YAAY;AACpC,YAAI,aAAa,OAAO,MAAM;AAC9B,eAAO,QAAQ,aAAa,MAAM,IAAI,IAAI,SAAS,CAAC;AAQpD,eAAO,SAAS,OAAO;AAAA,MACzB,CAAC;AAAA,IACH;AAEA,UAAM;AAGN,WAAO,iBAAiB,cAAc,KAAK;AAC3C,WAAO,MAAM,OAAO,oBAAoB,cAAc,KAAK;AAAA,EAC7D,GAAG,CAAC,YAAY,CAAC;AAEjB,SAAO;AACT;","names":[]}
|
|
@@ -2,6 +2,7 @@ import { jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import React from "react";
|
|
3
3
|
import NextImage from "next/image";
|
|
4
4
|
import NextLink from "next/link";
|
|
5
|
+
import { safeHref, safeImageSrc } from "../../core/validate";
|
|
5
6
|
function intrinsicSize(override, width, height) {
|
|
6
7
|
const { width: nw, height: nh } = override;
|
|
7
8
|
if (nw && nh) {
|
|
@@ -22,7 +23,7 @@ function GRailRender({ as, override, children, ref, ...rest }) {
|
|
|
22
23
|
type: _overrideType,
|
|
23
24
|
...imgRest
|
|
24
25
|
} = rest;
|
|
25
|
-
const src = override.src || propSrc || "";
|
|
26
|
+
const src = safeImageSrc(override.src) || propSrc || "";
|
|
26
27
|
const alt = override.alt ?? propAlt ?? "";
|
|
27
28
|
if (!src) return null;
|
|
28
29
|
const imgRef = ref;
|
|
@@ -53,7 +54,7 @@ function GRailRender({ as, override, children, ref, ...rest }) {
|
|
|
53
54
|
{
|
|
54
55
|
ref,
|
|
55
56
|
...linkRest,
|
|
56
|
-
href: override.href || propHref || "#",
|
|
57
|
+
href: safeHref(override.href) || propHref || "#",
|
|
57
58
|
children: override.text || children
|
|
58
59
|
}
|
|
59
60
|
);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/react/GRail/render.tsx"],"sourcesContent":["import React from \"react\";\nimport NextImage from \"next/image\";\nimport NextLink from \"next/link\";\nimport type { HTMLKeys, OverrideValue } from \"../../core/types\";\n\ninterface RenderProps {\n as?: HTMLKeys;\n override: OverrideValue;\n children?: React.ReactNode;\n /** Forwarded to the rendered element so the editor can measure it. */\n ref?: React.Ref<HTMLElement>;\n [key: string]: unknown;\n}\n\n/**\n * The developer's width/height set the layout; the image's own proportions set\n * the shape. Keeping the developer's width and deriving height from the stored\n * natural size means a differently shaped replacement neither breaks the\n * layout nor gets stretched.\n */\nfunction intrinsicSize(override: OverrideValue, width?: number, height?: number) {\n const { width: nw, height: nh } = override;\n if (nw && nh) {\n if (width) return { width, height: Math.round((width * nh) / nw) };\n if (height) return { width: Math.round((height * nw) / nh), height };\n return { width: nw, height: nh };\n }\n return { width: width || 500, height: height || 500 };\n}\n\n/**\n * Renders a resolved value as plain markup. Shared by the anonymous SSR path\n * and the editor, so what an editor sees matches what ships.\n *\n * Resolved props are applied *after* the spread. The developer's original\n * src/href are still in `rest`, and spreading them last silently masked every\n * saved image and link override.\n */\nexport function GRailRender({ as, override, children, ref, ...rest }: RenderProps) {\n if (override.type === \"image\") {\n const {\n width,\n height,\n fill,\n src: propSrc,\n alt: propAlt,\n type: _overrideType,\n ...imgRest\n } = rest as Record<string, any>;\n const src = override.src || propSrc || \"\";\n const alt = override.alt ?? propAlt ?? \"\";\n if (!src) return null;\n\n const imgRef = ref as React.Ref<HTMLImageElement>;\n if (fill) {\n return <NextImage ref={imgRef} {...imgRest} src={src} alt={alt} fill />;\n }\n\n const size = intrinsicSize(\n override,\n Number(width) || undefined,\n Number(height) || undefined,\n );\n return (\n <NextImage\n ref={imgRef}\n {...imgRest}\n src={src}\n alt={alt}\n width={size.width}\n height={size.height}\n />\n );\n }\n\n if (override.type === \"link\") {\n const { href: propHref, type: _overrideType, ...linkRest } =\n rest as Record<string, any>;\n return (\n <NextLink\n ref={ref as React.Ref<HTMLAnchorElement>}\n {...linkRest}\n href={override.href || propHref || \"#\"}\n >\n {override.text || children}\n </NextLink>\n );\n }\n\n const Component = (as || \"span\") as React.ElementType;\n return (\n <Component ref={ref} {...rest}>\n {override.text || children}\n </Component>\n );\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"sources":["../../../src/react/GRail/render.tsx"],"sourcesContent":["import React from \"react\";\nimport NextImage from \"next/image\";\nimport NextLink from \"next/link\";\nimport type { HTMLKeys, OverrideValue } from \"../../core/types\";\nimport { safeHref, safeImageSrc } from \"../../core/validate\";\n\ninterface RenderProps {\n as?: HTMLKeys;\n override: OverrideValue;\n children?: React.ReactNode;\n /** Forwarded to the rendered element so the editor can measure it. */\n ref?: React.Ref<HTMLElement>;\n [key: string]: unknown;\n}\n\n/**\n * The developer's width/height set the layout; the image's own proportions set\n * the shape. Keeping the developer's width and deriving height from the stored\n * natural size means a differently shaped replacement neither breaks the\n * layout nor gets stretched.\n */\nfunction intrinsicSize(override: OverrideValue, width?: number, height?: number) {\n const { width: nw, height: nh } = override;\n if (nw && nh) {\n if (width) return { width, height: Math.round((width * nh) / nw) };\n if (height) return { width: Math.round((height * nw) / nh), height };\n return { width: nw, height: nh };\n }\n return { width: width || 500, height: height || 500 };\n}\n\n/**\n * Renders a resolved value as plain markup. Shared by the anonymous SSR path\n * and the editor, so what an editor sees matches what ships.\n *\n * Resolved props are applied *after* the spread. The developer's original\n * src/href are still in `rest`, and spreading them last silently masked every\n * saved image and link override.\n */\nexport function GRailRender({ as, override, children, ref, ...rest }: RenderProps) {\n if (override.type === \"image\") {\n const {\n width,\n height,\n fill,\n src: propSrc,\n alt: propAlt,\n type: _overrideType,\n ...imgRest\n } = rest as Record<string, any>;\n // Rows written before values were validated may hold anything; only\n // http(s) and relative addresses are rendered.\n const src = safeImageSrc(override.src) || propSrc || \"\";\n const alt = override.alt ?? propAlt ?? \"\";\n if (!src) return null;\n\n const imgRef = ref as React.Ref<HTMLImageElement>;\n if (fill) {\n return <NextImage ref={imgRef} {...imgRest} src={src} alt={alt} fill />;\n }\n\n const size = intrinsicSize(\n override,\n Number(width) || undefined,\n Number(height) || undefined,\n );\n return (\n <NextImage\n ref={imgRef}\n {...imgRest}\n src={src}\n alt={alt}\n width={size.width}\n height={size.height}\n />\n );\n }\n\n if (override.type === \"link\") {\n const { href: propHref, type: _overrideType, ...linkRest } =\n rest as Record<string, any>;\n return (\n <NextLink\n ref={ref as React.Ref<HTMLAnchorElement>}\n {...linkRest}\n href={safeHref(override.href) || propHref || \"#\"}\n >\n {override.text || children}\n </NextLink>\n );\n }\n\n const Component = (as || \"span\") as React.ElementType;\n return (\n <Component ref={ref} {...rest}>\n {override.text || children}\n </Component>\n );\n}\n"],"mappings":"AA0Da;AA1Db,OAAO,WAAW;AAClB,OAAO,eAAe;AACtB,OAAO,cAAc;AAErB,SAAS,UAAU,oBAAoB;AAiBvC,SAAS,cAAc,UAAyB,OAAgB,QAAiB;AAC/E,QAAM,EAAE,OAAO,IAAI,QAAQ,GAAG,IAAI;AAClC,MAAI,MAAM,IAAI;AACZ,QAAI,MAAO,QAAO,EAAE,OAAO,QAAQ,KAAK,MAAO,QAAQ,KAAM,EAAE,EAAE;AACjE,QAAI,OAAQ,QAAO,EAAE,OAAO,KAAK,MAAO,SAAS,KAAM,EAAE,GAAG,OAAO;AACnE,WAAO,EAAE,OAAO,IAAI,QAAQ,GAAG;AAAA,EACjC;AACA,SAAO,EAAE,OAAO,SAAS,KAAK,QAAQ,UAAU,IAAI;AACtD;AAUO,SAAS,YAAY,EAAE,IAAI,UAAU,UAAU,KAAK,GAAG,KAAK,GAAgB;AACjF,MAAI,SAAS,SAAS,SAAS;AAC7B,UAAM;AAAA,MACJ;AAAA,MACA;AAAA,MACA;AAAA,MACA,KAAK;AAAA,MACL,KAAK;AAAA,MACL,MAAM;AAAA,MACN,GAAG;AAAA,IACL,IAAI;AAGJ,UAAM,MAAM,aAAa,SAAS,GAAG,KAAK,WAAW;AACrD,UAAM,MAAM,SAAS,OAAO,WAAW;AACvC,QAAI,CAAC,IAAK,QAAO;AAEjB,UAAM,SAAS;AACf,QAAI,MAAM;AACR,aAAO,oBAAC,aAAU,KAAK,QAAS,GAAG,SAAS,KAAU,KAAU,MAAI,MAAC;AAAA,IACvE;AAEA,UAAM,OAAO;AAAA,MACX;AAAA,MACA,OAAO,KAAK,KAAK;AAAA,MACjB,OAAO,MAAM,KAAK;AAAA,IACpB;AACA,WACE;AAAA,MAAC;AAAA;AAAA,QACC,KAAK;AAAA,QACJ,GAAG;AAAA,QACJ;AAAA,QACA;AAAA,QACA,OAAO,KAAK;AAAA,QACZ,QAAQ,KAAK;AAAA;AAAA,IACf;AAAA,EAEJ;AAEA,MAAI,SAAS,SAAS,QAAQ;AAC5B,UAAM,EAAE,MAAM,UAAU,MAAM,eAAe,GAAG,SAAS,IACvD;AACF,WACE;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACC,GAAG;AAAA,QACJ,MAAM,SAAS,SAAS,IAAI,KAAK,YAAY;AAAA,QAE5C,mBAAS,QAAQ;AAAA;AAAA,IACpB;AAAA,EAEJ;AAEA,QAAM,YAAa,MAAM;AACzB,SACE,oBAAC,aAAU,KAAW,GAAG,MACtB,mBAAS,QAAQ,UACpB;AAEJ;","names":[]}
|
package/dist/server/actions.js
CHANGED
|
@@ -8,10 +8,13 @@ import {
|
|
|
8
8
|
getMembers,
|
|
9
9
|
getPasswordResetHandler,
|
|
10
10
|
getStorage,
|
|
11
|
-
resolveSiteId
|
|
11
|
+
resolveSiteId,
|
|
12
|
+
resolveSiteUrl
|
|
12
13
|
} from "../core/config";
|
|
13
14
|
import { getCachedViewer } from "./session";
|
|
14
15
|
import { requireRole } from "./access";
|
|
16
|
+
import { rateLimited } from "./rateLimit";
|
|
17
|
+
import { cleanEmail, isRole, validateOverrideValue, validPathKey } from "../core/validate";
|
|
15
18
|
import { rethrowIfBailout } from "../core/bailout";
|
|
16
19
|
import {
|
|
17
20
|
GR_AUTH_ERROR_TTL_SECONDS,
|
|
@@ -22,21 +25,20 @@ const DENIED = { success: false, error: "Unauthorized." };
|
|
|
22
25
|
async function saveOverride(row) {
|
|
23
26
|
const viewer = await requireRole(["admin", "editor"]);
|
|
24
27
|
if (!viewer) return DENIED;
|
|
25
|
-
return writeOverride(row
|
|
28
|
+
return writeOverride(row?.pathKey, row?.value);
|
|
26
29
|
}
|
|
27
|
-
const OVERRIDE_TYPES = ["text", "link", "image", "button"];
|
|
28
30
|
async function setFieldValue(id, value) {
|
|
29
31
|
const viewer = await requireRole(["admin", "editor"]);
|
|
30
32
|
if (!viewer) return DENIED;
|
|
31
|
-
const pathKey = id.trim();
|
|
32
|
-
if (!pathKey) return { success: false, error: "A field id is required." };
|
|
33
33
|
const normalized = typeof value === "string" ? { type: "text", text: value } : value;
|
|
34
|
-
|
|
35
|
-
return { success: false, error: `Unknown field type "${normalized.type}".` };
|
|
36
|
-
}
|
|
37
|
-
return writeOverride(pathKey, normalized);
|
|
34
|
+
return writeOverride(id, normalized);
|
|
38
35
|
}
|
|
39
|
-
async function writeOverride(
|
|
36
|
+
async function writeOverride(rawPathKey, rawValue) {
|
|
37
|
+
const pathKey = validPathKey(rawPathKey);
|
|
38
|
+
if (!pathKey) return { success: false, error: "A valid field id is required." };
|
|
39
|
+
const checked = validateOverrideValue(rawValue);
|
|
40
|
+
if (!checked.ok) return { success: false, error: checked.error };
|
|
41
|
+
const value = checked.value;
|
|
40
42
|
const rowToSend = {
|
|
41
43
|
pathKey,
|
|
42
44
|
siteId: await resolveSiteId(),
|
|
@@ -60,6 +62,8 @@ const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
|
60
62
|
async function createAssetUpload(file) {
|
|
61
63
|
const viewer = await requireRole(["admin", "editor"]);
|
|
62
64
|
if (!viewer) return { success: false, error: "Unauthorized." };
|
|
65
|
+
const slow = await rateLimited("upload", viewer.userId);
|
|
66
|
+
if (slow) return { success: false, error: slow };
|
|
63
67
|
const storage = getStorage();
|
|
64
68
|
if (!storage) {
|
|
65
69
|
return { success: false, error: "No storage adapter is configured." };
|
|
@@ -95,10 +99,9 @@ async function listTeam() {
|
|
|
95
99
|
async function inviteMember(email, role) {
|
|
96
100
|
const viewer = await requireRole(["admin"]);
|
|
97
101
|
if (!viewer) return DENIED;
|
|
98
|
-
const normalized = email
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
+
const normalized = cleanEmail(email);
|
|
103
|
+
if (!normalized) return { success: false, error: "Enter a valid email address." };
|
|
104
|
+
if (!isRole(role)) return { success: false, error: "Choose admin or editor." };
|
|
102
105
|
const siteId = await resolveSiteId();
|
|
103
106
|
const members = getMembers();
|
|
104
107
|
const existing = await members.getMember(siteId, { email: normalized });
|
|
@@ -116,7 +119,14 @@ async function inviteMember(email, role) {
|
|
|
116
119
|
}
|
|
117
120
|
const auth = getAuth();
|
|
118
121
|
const onInvite = getInviteHandler();
|
|
119
|
-
const
|
|
122
|
+
const slow = await rateLimited("invite", viewer.userId);
|
|
123
|
+
if (slow) return { success: false, error: `Invite saved, but not sent. ${slow}` };
|
|
124
|
+
const where = await linkOrigin();
|
|
125
|
+
if ("error" in where) {
|
|
126
|
+
revalidatePath("/", "layout");
|
|
127
|
+
return { success: false, error: `Invite saved, but ${where.error}` };
|
|
128
|
+
}
|
|
129
|
+
const origin = where.origin;
|
|
120
130
|
const made = auth.createInviteLink ? await auth.createInviteLink(normalized, { origin }) : null;
|
|
121
131
|
if (made && !made.success) {
|
|
122
132
|
revalidatePath("/", "layout");
|
|
@@ -155,11 +165,12 @@ const ALREADY_HAS_ACCOUNT = (email) => `${email} already has an account, so they
|
|
|
155
165
|
async function updateMemberRole(email, role) {
|
|
156
166
|
const viewer = await requireRole(["admin"]);
|
|
157
167
|
if (!viewer) return DENIED;
|
|
168
|
+
const targetEmail = cleanEmail(email);
|
|
169
|
+
if (!targetEmail) return { success: false, error: "No such member." };
|
|
170
|
+
if (!isRole(role)) return { success: false, error: "Choose admin or editor." };
|
|
158
171
|
const siteId = await resolveSiteId();
|
|
159
172
|
const members = getMembers();
|
|
160
|
-
const target = await members.getMember(siteId, {
|
|
161
|
-
email: email.toLowerCase().trim()
|
|
162
|
-
});
|
|
173
|
+
const target = await members.getMember(siteId, { email: targetEmail });
|
|
163
174
|
if (!target) return { success: false, error: "No such member." };
|
|
164
175
|
if (target.role === "admin" && role !== "admin") {
|
|
165
176
|
const guard = await wouldStrandSite(siteId, target.email);
|
|
@@ -177,9 +188,10 @@ async function updateMemberRole(email, role) {
|
|
|
177
188
|
async function removeMember(email) {
|
|
178
189
|
const viewer = await requireRole(["admin"]);
|
|
179
190
|
if (!viewer) return DENIED;
|
|
191
|
+
const normalized = cleanEmail(email);
|
|
192
|
+
if (!normalized) return { success: false, error: "No such member." };
|
|
180
193
|
const siteId = await resolveSiteId();
|
|
181
194
|
const members = getMembers();
|
|
182
|
-
const normalized = email.toLowerCase().trim();
|
|
183
195
|
const guard = await wouldStrandSite(siteId, normalized);
|
|
184
196
|
if (guard) return guard;
|
|
185
197
|
const res = await members.removeMember(siteId, normalized);
|
|
@@ -189,7 +201,6 @@ async function removeMember(email) {
|
|
|
189
201
|
const RESET_COOLDOWN_MS = 6e4;
|
|
190
202
|
const lastResetAt = /* @__PURE__ */ new Map();
|
|
191
203
|
const RESET_MIN_DURATION_MS = 600;
|
|
192
|
-
const EMAIL_PATTERN = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
193
204
|
async function dispatchPasswordReset(email, requestedBy) {
|
|
194
205
|
const auth = getAuth();
|
|
195
206
|
const handler = getPasswordResetHandler();
|
|
@@ -205,7 +216,9 @@ async function dispatchPasswordReset(email, requestedBy) {
|
|
|
205
216
|
error: "this site has no onPasswordReset handler \u2014 add resendEmails() (or your own) to withGuardrail()."
|
|
206
217
|
};
|
|
207
218
|
}
|
|
208
|
-
const
|
|
219
|
+
const where = await linkOrigin();
|
|
220
|
+
if ("error" in where) return { success: false, error: where.error };
|
|
221
|
+
const made = await auth.createPasswordResetLink(email, { origin: where.origin });
|
|
209
222
|
if (!made.success) return { success: false, error: made.error };
|
|
210
223
|
try {
|
|
211
224
|
await handler({ email, siteId: await resolveSiteId(), link: made.link, requestedBy });
|
|
@@ -217,10 +230,10 @@ async function dispatchPasswordReset(email, requestedBy) {
|
|
|
217
230
|
}
|
|
218
231
|
async function forgotPasswordAction(email) {
|
|
219
232
|
const started = Date.now();
|
|
220
|
-
const normalized =
|
|
221
|
-
if (!
|
|
222
|
-
|
|
223
|
-
}
|
|
233
|
+
const normalized = cleanEmail(email);
|
|
234
|
+
if (!normalized) return { success: false, error: "Enter a valid email address." };
|
|
235
|
+
const slow = await rateLimited("forgot-password", normalized);
|
|
236
|
+
if (slow) return { success: false, error: slow };
|
|
224
237
|
try {
|
|
225
238
|
const siteId = await resolveSiteId();
|
|
226
239
|
const key = `${siteId}:${normalized}`;
|
|
@@ -248,19 +261,24 @@ async function forgotPasswordAction(email) {
|
|
|
248
261
|
async function sendPasswordResetAction(email) {
|
|
249
262
|
const viewer = await requireRole(["admin"]);
|
|
250
263
|
if (!viewer) return DENIED;
|
|
251
|
-
const normalized =
|
|
264
|
+
const normalized = cleanEmail(email);
|
|
265
|
+
if (!normalized) return { success: false, error: "No such member." };
|
|
252
266
|
const target = await getMembers().getMember(await resolveSiteId(), { email: normalized });
|
|
253
267
|
if (!target) return { success: false, error: "No such member." };
|
|
268
|
+
const slow = await rateLimited("password-reset", viewer.userId);
|
|
269
|
+
if (slow) return { success: false, error: slow };
|
|
254
270
|
const res = await dispatchPasswordReset(normalized, viewer.email ?? viewer.userId);
|
|
255
271
|
return res.success ? { success: true, notice: `Password reset email sent to ${normalized}.` } : { success: false, error: `The reset email couldn't be sent: ${res.error}` };
|
|
256
272
|
}
|
|
257
|
-
|
|
273
|
+
const SITE_URL_MISSING = "siteUrl isn't set, so sign-in links can't be built safely \u2014 set GUARDRAIL_SITE_URL (or siteUrl in withGuardrail()) to this site's public address.";
|
|
274
|
+
async function linkOrigin() {
|
|
275
|
+
const configured = await resolveSiteUrl();
|
|
276
|
+
if (configured) return { origin: configured };
|
|
277
|
+
if (process.env.NODE_ENV === "production") return { error: SITE_URL_MISSING };
|
|
258
278
|
const h = await headers();
|
|
259
|
-
const
|
|
260
|
-
if (origin) return origin;
|
|
261
|
-
const host = h.get("x-forwarded-host") ?? h.get("host") ?? "localhost";
|
|
279
|
+
const host = h.get("host") ?? "localhost";
|
|
262
280
|
const proto = h.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
|
|
263
|
-
return `${proto}://${host}
|
|
281
|
+
return { origin: `${proto}://${host}` };
|
|
264
282
|
}
|
|
265
283
|
async function wouldStrandSite(siteId, email) {
|
|
266
284
|
const members = getMembers();
|
|
@@ -285,6 +303,8 @@ async function loginAction(_prev, formData) {
|
|
|
285
303
|
email
|
|
286
304
|
};
|
|
287
305
|
}
|
|
306
|
+
const slow = await rateLimited("login", cleanEmail(email) ?? email.trim().toLowerCase().slice(0, 254));
|
|
307
|
+
if (slow) return { success: false, error: slow, email };
|
|
288
308
|
const result = await auth.authenticate(email, password);
|
|
289
309
|
if (!result.success) {
|
|
290
310
|
return { success: false, error: result.error, email };
|
|
@@ -305,6 +325,7 @@ async function logoutAction() {
|
|
|
305
325
|
async function completeAuthRedirectAction(params) {
|
|
306
326
|
const auth = getAuth();
|
|
307
327
|
if (!auth.completeAuthRedirect) return { success: false };
|
|
328
|
+
if (await rateLimited("auth-link")) return { success: false, error: "Too many attempts." };
|
|
308
329
|
const res = await auth.completeAuthRedirect(params);
|
|
309
330
|
const cookieStore = await cookies();
|
|
310
331
|
if (res.success) {
|