@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.
Files changed (51) hide show
  1. package/README.md +168 -36
  2. package/dist/adapters/supabase/auth.js +1 -2
  3. package/dist/adapters/supabase/auth.js.map +1 -1
  4. package/dist/adapters/supabase/client.js +6 -1
  5. package/dist/adapters/supabase/client.js.map +1 -1
  6. package/dist/adapters/supabase/confirm.d.ts +11 -11
  7. package/dist/adapters/supabase/confirm.js +14 -18
  8. package/dist/adapters/supabase/confirm.js.map +1 -1
  9. package/dist/adapters/supabase/index.d.ts +3 -1
  10. package/dist/adapters/supabase/index.js +5 -0
  11. package/dist/adapters/supabase/index.js.map +1 -1
  12. package/dist/adapters/supabase/rateLimit.d.ts +24 -0
  13. package/dist/adapters/supabase/rateLimit.js +37 -0
  14. package/dist/adapters/supabase/rateLimit.js.map +1 -0
  15. package/dist/cli/doctor.d.ts +1 -0
  16. package/dist/cli/doctor.js +911 -0
  17. package/dist/cli/doctor.js.map +1 -0
  18. package/dist/cli/init-db.js +23 -5
  19. package/dist/cli/init-db.js.map +1 -1
  20. package/dist/core/adapter-types.d.ts +71 -1
  21. package/dist/core/config.d.ts +12 -1
  22. package/dist/core/config.js +29 -1
  23. package/dist/core/config.js.map +1 -1
  24. package/dist/core/validate.d.ts +47 -0
  25. package/dist/core/validate.js +118 -0
  26. package/dist/core/validate.js.map +1 -0
  27. package/dist/index.client.d.ts +1 -1
  28. package/dist/index.client.js.map +1 -1
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/react/AuthRedirectSync.js +28 -22
  32. package/dist/react/AuthRedirectSync.js.map +1 -1
  33. package/dist/react/GRail/render.js +3 -2
  34. package/dist/react/GRail/render.js.map +1 -1
  35. package/dist/server/actions.js +52 -31
  36. package/dist/server/actions.js.map +1 -1
  37. package/dist/server/loader.js +4 -4
  38. package/dist/server/loader.js.map +1 -1
  39. package/dist/server/rateLimit.d.ts +21 -0
  40. package/dist/server/rateLimit.js +94 -0
  41. package/dist/server/rateLimit.js.map +1 -0
  42. package/dist/server/repeaterActions.js +9 -1
  43. package/dist/server/repeaterActions.js.map +1 -1
  44. package/dist/server/wrapper.js +18 -1
  45. package/dist/server/wrapper.js.map +1 -1
  46. package/package.json +14 -13
  47. package/supabase/admin/check-grants.sql +41 -0
  48. package/supabase/migrations/20261028000000_require_confirmed_email.sql +53 -0
  49. package/supabase/migrations/20261028010000_rate_limits.sql +67 -0
  50. package/supabase/migrations/20261028020000_member_and_bucket_guards.sql +96 -0
  51. 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":[]}
@@ -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
 
@@ -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
- if (handled.current) return;
10
- const rawHash = window.location.hash.startsWith("#") ? window.location.hash.slice(1) : window.location.hash;
11
- const hasHashTokens = /(?:^|&)access_token=/.test(rawHash);
12
- const code = searchParams.get("code") ?? void 0;
13
- const tokenHash = searchParams.get("token_hash") ?? void 0;
14
- const type = searchParams.get("type") ?? void 0;
15
- if (!hasHashTokens && !code && !tokenHash) return;
16
- handled.current = true;
17
- const params = {
18
- ...hasHashTokens ? { hash: rawHash } : {},
19
- ...code ? { code } : {},
20
- ...tokenHash ? { tokenHash } : {},
21
- ...type ? { type } : {}
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
- completeAuthRedirectAction(params).then((res) => {
24
- const url = new URL(window.location.href);
25
- url.hash = "";
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 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 const code = searchParams.get(\"code\") ?? undefined;\n const tokenHash = searchParams.get(\"token_hash\") ?? undefined;\n const 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 }, [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,QAAI,QAAQ,QAAS;AAErB,UAAM,UAAU,OAAO,SAAS,KAAK,WAAW,GAAG,IAC/C,OAAO,SAAS,KAAK,MAAM,CAAC,IAC5B,OAAO,SAAS;AAGpB,UAAM,gBAAgB,uBAAuB,KAAK,OAAO;AAEzD,UAAM,OAAO,aAAa,IAAI,MAAM,KAAK;AACzC,UAAM,YAAY,aAAa,IAAI,YAAY,KAAK;AACpD,UAAM,OAAO,aAAa,IAAI,MAAM,KAAK;AAEzC,QAAI,CAAC,iBAAiB,CAAC,QAAQ,CAAC,UAAW;AAC3C,YAAQ,UAAU;AAElB,UAAM,SAAS;AAAA,MACb,GAAI,gBAAgB,EAAE,MAAM,QAAQ,IAAI,CAAC;AAAA,MACzC,GAAI,OAAO,EAAE,KAAK,IAAI,CAAC;AAAA,MACvB,GAAI,YAAY,EAAE,UAAU,IAAI,CAAC;AAAA,MACjC,GAAI,OAAO,EAAE,KAAK,IAAI,CAAC;AAAA,IACzB;AAEA,+BAA2B,MAAM,EAAE,KAAK,CAAC,QAAQ;AAI/C,YAAM,MAAM,IAAI,IAAI,OAAO,SAAS,IAAI;AACxC,UAAI,OAAO;AACX,UAAI,aAAa,OAAO,MAAM;AAC9B,UAAI,aAAa,OAAO,YAAY;AACpC,UAAI,aAAa,OAAO,MAAM;AAC9B,aAAO,QAAQ,aAAa,MAAM,IAAI,IAAI,SAAS,CAAC;AAQpD,aAAO,SAAS,OAAO;AAAA,IACzB,CAAC;AAAA,EACH,GAAG,CAAC,YAAY,CAAC;AAEjB,SAAO;AACT;","names":[]}
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":"AAuDa;AAvDb,OAAO,WAAW;AAClB,OAAO,eAAe;AACtB,OAAO,cAAc;AAkBrB,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;AACJ,UAAM,MAAM,SAAS,OAAO,WAAW;AACvC,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,QAAQ,YAAY;AAAA,QAElC,mBAAS,QAAQ;AAAA;AAAA,IACpB;AAAA,EAEJ;AAEA,QAAM,YAAa,MAAM;AACzB,SACE,oBAAC,aAAU,KAAW,GAAG,MACtB,mBAAS,QAAQ,UACpB;AAEJ;","names":[]}
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":[]}
@@ -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.pathKey, row.value);
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
- if (!OVERRIDE_TYPES.includes(normalized.type)) {
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(pathKey, value) {
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.toLowerCase().trim();
99
- if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalized)) {
100
- return { success: false, error: "Enter a valid email address." };
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 origin = await requestOrigin();
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 made = await auth.createPasswordResetLink(email, { origin: await requestOrigin() });
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 = String(email ?? "").toLowerCase().trim();
221
- if (!EMAIL_PATTERN.test(normalized)) {
222
- return { success: false, error: "Enter a valid email address." };
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 = String(email ?? "").toLowerCase().trim();
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
- async function requestOrigin() {
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 origin = h.get("origin");
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) {