@guard-rail/core 0.9.0 → 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 (56) hide show
  1. package/README.md +182 -38
  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/email/console.d.ts +24 -0
  28. package/dist/email/console.js +30 -0
  29. package/dist/email/console.js.map +1 -0
  30. package/dist/index.client.d.ts +3 -2
  31. package/dist/index.client.js +5 -0
  32. package/dist/index.client.js.map +1 -1
  33. package/dist/index.d.ts +2 -1
  34. package/dist/index.js +2 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/react/AuthRedirectSync.js +28 -22
  37. package/dist/react/AuthRedirectSync.js.map +1 -1
  38. package/dist/react/GRail/render.js +3 -2
  39. package/dist/react/GRail/render.js.map +1 -1
  40. package/dist/server/actions.js +52 -31
  41. package/dist/server/actions.js.map +1 -1
  42. package/dist/server/loader.js +4 -4
  43. package/dist/server/loader.js.map +1 -1
  44. package/dist/server/rateLimit.d.ts +21 -0
  45. package/dist/server/rateLimit.js +94 -0
  46. package/dist/server/rateLimit.js.map +1 -0
  47. package/dist/server/repeaterActions.js +9 -1
  48. package/dist/server/repeaterActions.js.map +1 -1
  49. package/dist/server/wrapper.js +18 -1
  50. package/dist/server/wrapper.js.map +1 -1
  51. package/package.json +15 -14
  52. package/supabase/admin/check-grants.sql +41 -0
  53. package/supabase/migrations/20261028000000_require_confirmed_email.sql +53 -0
  54. package/supabase/migrations/20261028010000_rate_limits.sql +67 -0
  55. package/supabase/migrations/20261028020000_member_and_bucket_guards.sql +96 -0
  56. package/scripts/postinstall.mjs +0 -48
@@ -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) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/server/actions.ts"],"sourcesContent":["\"use server\";\n\nimport { revalidatePath } from \"next/cache\";\nimport { cookies, headers } from \"next/headers\";\nimport {\n getAuth,\n getDB,\n getInviteHandler,\n getMembers,\n getPasswordResetHandler,\n getStorage,\n resolveSiteId,\n} from \"../core/config\";\nimport { getCachedViewer } from \"./session\";\nimport { requireRole } from \"./access\";\nimport { rethrowIfBailout } from \"../core/bailout\";\nimport {\n GR_AUTH_ERROR_TTL_SECONDS,\n GR_COOKIES,\n GR_NEEDS_PASSWORD_TTL_SECONDS,\n} from \"../core/defs\";\nimport type {\n ActionState,\n GRAsset,\n GRMember,\n GRRole,\n OverrideRow,\n OverrideType,\n OverrideValue,\n SetPasswordState,\n} from \"../core/types\";\nimport type {\n AuthRedirectParams,\n GRStorageRow,\n GRUploadTarget,\n} from \"../core/adapter-types\";\n\n/** `notice` is for a success the admin should still read, e.g. \"no email was sent because...\". */\ntype Result = { success: boolean; error?: string; notice?: string };\n\nconst DENIED: Result = { success: false, error: \"Unauthorized.\" };\n\n// Every mutation below re-resolves the viewer server-side through\n// requireRole() (see access.ts) — the client's copy of the role decides what\n// to *show*, never what is *allowed*.\n\nexport async function saveOverride(row: OverrideRow): Promise<Result> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return DENIED;\n return writeOverride(row.pathKey, row.value);\n}\n\nconst OVERRIDE_TYPES: readonly OverrideType[] = [\"text\", \"link\", \"image\", \"button\"];\n\n/**\n * Writes one field's value by id, with no <GRail> involved — the setter that\n * pairs with getFieldValue(). It exists for fields with nothing on the page to\n * click: an SEO title or meta description, an og:image, a product's stock\n * note. Build a drawer or form of your own, and have it (or a Server Action of\n * yours) call this.\n *\n * A bare string is shorthand for a text field. Pass an OverrideValue for the\n * other types ({ type: \"image\", src, alt }, a link, a button).\n *\n * Does its own role check — an editor or admin — so it is safe to call\n * directly from a Client Component, the same as saveOverride. If your own\n * action does other work first, check isEditor() there too.\n *\n * Writes the same row a <GRail> with this id would, so the two stay\n * interchangeable: a field first written here can later be given a visible\n * <GRail>, and vice versa.\n */\nexport async function setFieldValue(\n id: string,\n value: string | OverrideValue,\n): Promise<Result> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return DENIED;\n\n const pathKey = id.trim();\n if (!pathKey) return { success: false, error: \"A field id is required.\" };\n\n const normalized: OverrideValue =\n typeof value === \"string\" ? { type: \"text\", text: value } : value;\n if (!OVERRIDE_TYPES.includes(normalized.type)) {\n return { success: false, error: `Unknown field type \"${normalized.type}\".` };\n }\n\n return writeOverride(pathKey, normalized);\n}\n\n/** The write shared by saveOverride and setFieldValue; the caller has already authorized. */\nasync function writeOverride(\n pathKey: string,\n value: OverrideValue,\n): Promise<Result> {\n const rowToSend: GRStorageRow = {\n pathKey,\n siteId: await resolveSiteId(),\n type: value.type,\n value,\n };\n\n try {\n const db = getDB();\n const res = await db.setOverride(rowToSend);\n if (!res.success) return res;\n\n // TODO: too broad — purges every cached route on any single-field save.\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: save failed\", e);\n return { success: false, error: \"Database write failed.\" };\n }\n}\n\n/* --------------------------------------------------------------- media --- */\n\n// SVG is excluded on purpose: it can carry script, and next/image refuses it\n// by default anyway.\nconst IMAGE_TYPES = [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/avif\"];\nconst MAX_UPLOAD_BYTES = 10 * 1024 * 1024;\n\n/**\n * Authorizes an upload and returns where the browser should send the bytes.\n * Only metadata comes through here — the file itself goes straight to storage.\n */\nexport async function createAssetUpload(file: {\n name: string;\n contentType: string;\n size: number;\n}): Promise<\n { success: true; target: GRUploadTarget } | { success: false; error: string }\n> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return { success: false, error: \"Unauthorized.\" };\n\n const storage = getStorage();\n if (!storage) {\n return { success: false, error: \"No storage adapter is configured.\" };\n }\n if (!IMAGE_TYPES.includes(file.contentType)) {\n return { success: false, error: \"Upload a PNG, JPEG, WebP, GIF, or AVIF image.\" };\n }\n if (!(file.size > 0) || file.size > MAX_UPLOAD_BYTES) {\n return { success: false, error: \"Images must be under 10 MB.\" };\n }\n\n try {\n const target = await storage.createUpload(await resolveSiteId(), file);\n return { success: true, target };\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: createUpload failed\", e);\n // Failures here are almost always setup (missing SQL, bucket, or policy),\n // so show the cause while developing. Production keeps it generic rather\n // than exposing backend detail to editors.\n const detail =\n process.env.NODE_ENV !== \"production\" && e instanceof Error\n ? ` ${e.message}`\n : \"\";\n return { success: false, error: `Could not start the upload.${detail}` };\n }\n}\n\nexport async function listAssets(): Promise<GRAsset[]> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n const storage = getStorage();\n if (!viewer || !storage) return [];\n return storage.listAssets(await resolveSiteId());\n}\n\n/* ---------------------------------------------------------------- team --- */\n\nexport async function listTeam(): Promise<GRMember[]> {\n // Any member may see who else is on the site; only admins may change it.\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return [];\n\n const members = getMembers();\n return members.listMembers(await resolveSiteId());\n}\n\nexport async function inviteMember(\n email: string,\n role: GRRole,\n): Promise<Result> {\n const viewer = await requireRole([\"admin\"]);\n if (!viewer) return DENIED;\n\n const normalized = email.toLowerCase().trim();\n if (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(normalized)) {\n return { success: false, error: \"Enter a valid email address.\" };\n }\n\n const siteId = await resolveSiteId();\n const members = getMembers();\n\n const existing = await members.getMember(siteId, { email: normalized });\n if (existing?.status === \"active\") {\n return { success: false, error: \"That address is already on the team.\" };\n }\n\n // A pending invite is re-sent rather than rejected: provider email is\n // rate-limited and unreliable enough that resending has to be possible.\n if (!existing) {\n const res = await members.upsertMember(siteId, {\n email: normalized,\n role,\n userId: null,\n status: \"pending\",\n });\n if (!res.success) return res;\n }\n\n const auth = getAuth();\n const onInvite = getInviteHandler();\n const origin = await requestOrigin();\n\n // The membership is saved either way, so none of what follows can undo it;\n // each failure just says what to fix. Delivery has one shape: the adapter\n // creates the account and a link (if it can), the app's handler emails it.\n const made = auth.createInviteLink\n ? await auth.createInviteLink(normalized, { origin })\n : null;\n if (made && !made.success) {\n revalidatePath(\"/\", \"layout\");\n return {\n success: false,\n error: `Invite saved, but the sign-in link couldn't be created: ${made.error}`,\n };\n }\n if (!onInvite) {\n revalidatePath(\"/\", \"layout\");\n return {\n success: false,\n error:\n \"Invite saved, but this site has nothing set up to email it. Add resendEmails() (or your own onInvite) to withGuardrail() — see the README.\",\n };\n }\n\n try {\n await onInvite({\n email: normalized,\n role: existing?.role ?? role,\n siteId,\n invitedBy: viewer.email ?? viewer.userId,\n ...(made ? { link: made.link, isNewUser: made.isNewUser } : {}),\n });\n } catch (e) {\n console.error(\"Guardrail: onInvite handler threw\", e);\n revalidatePath(\"/\", \"layout\");\n return {\n success: false,\n error: `Invite saved, but the email failed: ${e instanceof Error ? e.message : \"unknown error\"}`,\n };\n }\n\n revalidatePath(\"/\", \"layout\");\n return made && !made.isNewUser\n ? { success: true, notice: ALREADY_HAS_ACCOUNT(normalized) }\n : { success: true };\n}\n\nconst ALREADY_HAS_ACCOUNT = (email: string) =>\n `${email} already has an account, so they were told they've been added rather than sent a link — they sign in with their existing password.`;\n\nexport async function updateMemberRole(\n email: string,\n role: GRRole,\n): Promise<Result> {\n const viewer = await requireRole([\"admin\"]);\n if (!viewer) return DENIED;\n\n const siteId = await resolveSiteId();\n const members = getMembers();\n\n const target = await members.getMember(siteId, {\n email: email.toLowerCase().trim(),\n });\n if (!target) return { success: false, error: \"No such member.\" };\n\n if (target.role === \"admin\" && role !== \"admin\") {\n const guard = await wouldStrandSite(siteId, target.email);\n if (guard) return guard;\n }\n\n const res = await members.upsertMember(siteId, {\n email: target.email,\n role,\n userId: target.userId,\n status: target.status,\n });\n if (res.success) revalidatePath(\"/\", \"layout\");\n return res;\n}\n\nexport async function removeMember(email: string): Promise<Result> {\n const viewer = await requireRole([\"admin\"]);\n if (!viewer) return DENIED;\n\n const siteId = await resolveSiteId();\n const members = getMembers();\n const normalized = email.toLowerCase().trim();\n\n const guard = await wouldStrandSite(siteId, normalized);\n if (guard) return guard;\n\n const res = await members.removeMember(siteId, normalized);\n if (res.success) revalidatePath(\"/\", \"layout\");\n return res;\n}\n\n/* ------------------------------------------------------ password reset --- */\n\n/** One reset email per address per site per minute, so the form can't be used to spam someone. */\nconst RESET_COOLDOWN_MS = 60_000;\nconst lastResetAt = new Map<string, number>();\n\n/** Floor for a self-service request, so how long it takes doesn't reveal whether the address is a member. */\nconst RESET_MIN_DURATION_MS = 600;\n\nconst EMAIL_PATTERN = /^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/;\n\n/**\n * Sends one password-reset email: the adapter mints a one-time link and the\n * app's onPasswordReset handler emails it. If either is missing, says which —\n * there is no quieter fallback to hide behind.\n */\nasync function dispatchPasswordReset(email: string, requestedBy: string): Promise<Result> {\n const auth = getAuth();\n const handler = getPasswordResetHandler();\n\n if (!auth.createPasswordResetLink) {\n return {\n success: false,\n error: \"this site's auth setup can't create reset links — give the Supabase adapter a serviceRoleKey.\",\n };\n }\n if (!handler) {\n return {\n success: false,\n error: \"this site has no onPasswordReset handler — add resendEmails() (or your own) to withGuardrail().\",\n };\n }\n\n const made = await auth.createPasswordResetLink(email, { origin: await requestOrigin() });\n if (!made.success) return { success: false, error: made.error };\n try {\n await handler({ email, siteId: await resolveSiteId(), link: made.link, requestedBy });\n return { success: true };\n } catch (e) {\n console.error(\"Guardrail: onPasswordReset handler threw\", e);\n return { success: false, error: e instanceof Error ? e.message : \"unknown error\" };\n }\n}\n\n/**\n * \"Forgot password?\" from the sign-in bar. Called with no session, so it says\n * the same thing whether or not the address belongs to anyone: a member gets an\n * email, a stranger gets nothing, and neither can tell which happened from the\n * response or how long it took. Only a malformed address is rejected, since\n * that says nothing about anyone.\n */\nexport async function forgotPasswordAction(email: string): Promise<Result> {\n const started = Date.now();\n const normalized = String(email ?? \"\").toLowerCase().trim();\n if (!EMAIL_PATTERN.test(normalized)) {\n return { success: false, error: \"Enter a valid email address.\" };\n }\n\n try {\n const siteId = await resolveSiteId();\n const key = `${siteId}:${normalized}`;\n const last = lastResetAt.get(key);\n if (!last || started - last >= RESET_COOLDOWN_MS) {\n lastResetAt.set(key, started);\n if (lastResetAt.size > 500) {\n for (const [k, t] of lastResetAt) if (started - t >= RESET_COOLDOWN_MS) lastResetAt.delete(k);\n }\n\n // With a privileged membership check, only members are sent anything.\n // Without one (an adapter that can mint reset links but not look members\n // up) any address that asks is — which is why the Supabase adapter offers\n // both together.\n const members = getMembers();\n const known = members.emailHasAccess ? await members.emailHasAccess(siteId, normalized) : true;\n if (known) {\n const res = await dispatchPasswordReset(normalized, \"self\");\n if (!res.success) console.error(\"Guardrail: password reset failed:\", res.error);\n }\n }\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: password reset failed\", e);\n }\n\n const wait = RESET_MIN_DURATION_MS - (Date.now() - started);\n if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));\n return { success: true };\n}\n\n/**\n * An admin sending a password reset to someone on the team, from the Team\n * drawer. Unlike the self-service request this reports what happened — an admin\n * is allowed to know, and needs to when it didn't work.\n */\nexport async function sendPasswordResetAction(email: string): Promise<Result> {\n const viewer = await requireRole([\"admin\"]);\n if (!viewer) return DENIED;\n\n const normalized = String(email ?? \"\").toLowerCase().trim();\n const target = await getMembers().getMember(await resolveSiteId(), { email: normalized });\n if (!target) return { success: false, error: \"No such member.\" };\n\n const res = await dispatchPasswordReset(normalized, viewer.email ?? viewer.userId);\n return res.success\n ? { success: true, notice: `Password reset email sent to ${normalized}.` }\n : { success: false, error: `The reset email couldn't be sent: ${res.error}` };\n}\n\nasync function requestOrigin(): Promise<string> {\n const h = await headers();\n const origin = h.get(\"origin\");\n if (origin) return origin;\n const host = h.get(\"x-forwarded-host\") ?? h.get(\"host\") ?? \"localhost\";\n const proto = h.get(\"x-forwarded-proto\") ?? (host.startsWith(\"localhost\") ? \"http\" : \"https\");\n return `${proto}://${host}`;\n}\n\n/**\n * Refuses changes that would leave a site with no admin, which would lock\n * everyone out of team management with no in-app way to recover.\n */\nasync function wouldStrandSite(\n siteId: string,\n email: string,\n): Promise<Result | null> {\n const members = getMembers();\n const all = await members.listMembers(siteId);\n const admins = all.filter((m) => m.role === \"admin\");\n\n if (admins.length <= 1 && admins.some((m) => m.email === email)) {\n return {\n success: false,\n error: \"A site must keep at least one admin.\",\n };\n }\n return null;\n}\n\n/* ---------------------------------------------------------------- auth --- */\n\nexport async function loginAction(\n _prev: ActionState,\n formData: FormData,\n): Promise<ActionState> {\n const email = String(formData.get(\"email\") ?? \"\");\n const password = String(formData.get(\"password\") ?? \"\");\n\n const auth = getAuth();\n if (!auth.authenticate) {\n return {\n success: false,\n error: \"This site's auth provider does not accept passwords.\",\n email,\n };\n }\n\n const result = await auth.authenticate(email, password);\n if (!result.success) {\n return { success: false, error: result.error, email };\n }\n\n revalidatePath(\"/\", \"layout\");\n return { success: true, email };\n}\n\nexport async function logoutAction(): Promise<{ success: boolean }> {\n const auth = getAuth();\n await auth.logout();\n // Cleared here, not by the client afterward: a Server Action's own\n // revalidatePath() below triggers Next's router to refresh the current\n // route automatically, which can race a client-side navigation attempted\n // right after this resolves — confirmed live, where that automatic\n // refresh (for the pre-logout URL, ?edit=true) won the race and silently\n // undid a client-side `window.location.href` to the plain path. Deleting\n // the cookie here instead makes it part of the one server round-trip that\n // revalidatePath already accounts for, with nothing left to race.\n const cookieStore = await cookies();\n cookieStore.delete(GR_COOKIES.EDIT_MODE);\n // Signing out ends whatever was pending for that session too. A leftover\n // \"needs a password\" flag used to survive this and keep the sign-in bar\n // pinned on screen.\n cookieStore.delete(GR_COOKIES.NEEDS_PASSWORD);\n cookieStore.delete(GR_COOKIES.AUTH_ERROR);\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n}\n\n/* ---------------------------------------------------------- redirects --- */\n\n/**\n * Completes a session that arrived via an invite or password-recovery link. Called by AuthRedirectSync (react/), the\n * generic client component that notices this kind of link and hands off\n * whatever it found in the URL — the actual token exchange is entirely the\n * auth adapter's job (see GRAuthAdapter.completeAuthRedirect); core only\n * orchestrates the result.\n *\n * A no-op {success:false}, not an error, when the adapter doesn't implement\n * this — most page loads carry no redirect data at all, and an adapter with\n * no such concept should not make every one of them fail.\n */\nexport async function completeAuthRedirectAction(\n params: AuthRedirectParams,\n): Promise<{ success: boolean; needsPassword?: boolean; error?: string }> {\n const auth = getAuth();\n if (!auth.completeAuthRedirect) return { success: false };\n\n const res = await auth.completeAuthRedirect(params);\n const cookieStore = await cookies();\n if (res.success) {\n cookieStore.delete(GR_COOKIES.AUTH_ERROR);\n if (res.needsPassword) {\n cookieStore.set(GR_COOKIES.NEEDS_PASSWORD, \"true\", {\n path: \"/\",\n maxAge: GR_NEEDS_PASSWORD_TTL_SECONDS,\n });\n }\n revalidatePath(\"/\", \"layout\");\n } else {\n // Say so, rather than leaving the person on a plain page with nothing\n // visibly different. The provider's own wording stays in the server log; the\n // cookie carries only a code, which the chrome turns into plain English.\n console.error(\"Guardrail: couldn't complete an emailed link:\", res.error);\n cookieStore.set(\n GR_COOKIES.AUTH_ERROR,\n /expired|invalid|already|used|not found/i.test(res.error ?? \"\") ? \"expired\" : \"failed\",\n { path: \"/\", maxAge: GR_AUTH_ERROR_TTL_SECONDS },\n );\n revalidatePath(\"/\", \"layout\");\n }\n return res;\n}\n\n/**\n * Sets the current session's password — the prompt completeAuthRedirectAction\n * triggers via needsPassword. Requires only a valid session, not a role: an\n * invited person's role is claimed as a side effect of getCachedViewer()\n * resolving them, which already happens independently of this.\n */\nexport async function setPasswordAction(\n _prev: SetPasswordState,\n formData: FormData,\n): Promise<SetPasswordState> {\n const viewer = await getCachedViewer();\n if (!viewer) return { success: false, error: \"Not signed in.\" };\n\n const auth = getAuth();\n if (!auth.setPassword) {\n return { success: false, error: \"This site's auth provider does not support setting a password.\" };\n }\n\n const password = String(formData.get(\"password\") ?? \"\");\n // `confirm` is absent from older custom forms, so only checked when sent.\n const confirm = formData.get(\"confirm\");\n if (password.length < 8) {\n return { success: false, error: \"Use at least 8 characters.\" };\n }\n if (confirm !== null && String(confirm) !== password) {\n return { success: false, error: \"The two passwords don't match.\" };\n }\n const res = await auth.setPassword(password);\n if (res.success) {\n const cookieStore = await cookies();\n cookieStore.delete(GR_COOKIES.NEEDS_PASSWORD);\n revalidatePath(\"/\", \"layout\");\n }\n return res;\n}\n\n/**\n * Dismisses the set-password step without setting a password.\n *\n * @deprecated Guardrail's own UI no longer offers this: passwords are the only\n * way back in, so the step can't be skipped. Kept so an app's own UI built on\n * it keeps working.\n */\nexport async function skipSetPasswordAction(): Promise<{ success: boolean }> {\n const cookieStore = await cookies();\n cookieStore.delete(GR_COOKIES.NEEDS_PASSWORD);\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/server/actions.ts"],"sourcesContent":["\"use server\";\n\nimport { revalidatePath } from \"next/cache\";\nimport { cookies, headers } from \"next/headers\";\nimport {\n getAuth,\n getDB,\n getInviteHandler,\n getMembers,\n getPasswordResetHandler,\n getStorage,\n resolveSiteId,\n resolveSiteUrl,\n} from \"../core/config\";\nimport { getCachedViewer } from \"./session\";\nimport { requireRole } from \"./access\";\nimport { rateLimited } from \"./rateLimit\";\nimport { cleanEmail, isRole, validateOverrideValue, validPathKey } from \"../core/validate\";\nimport { rethrowIfBailout } from \"../core/bailout\";\nimport {\n GR_AUTH_ERROR_TTL_SECONDS,\n GR_COOKIES,\n GR_NEEDS_PASSWORD_TTL_SECONDS,\n} from \"../core/defs\";\nimport type {\n ActionState,\n GRAsset,\n GRMember,\n GRRole,\n OverrideRow,\n OverrideValue,\n SetPasswordState,\n} from \"../core/types\";\nimport type {\n AuthRedirectParams,\n GRStorageRow,\n GRUploadTarget,\n} from \"../core/adapter-types\";\n\n/** `notice` is for a success the admin should still read, e.g. \"no email was sent because...\". */\ntype Result = { success: boolean; error?: string; notice?: string };\n\nconst DENIED: Result = { success: false, error: \"Unauthorized.\" };\n\n// Every mutation below re-resolves the viewer server-side through\n// requireRole() (see access.ts) — the client's copy of the role decides what\n// to *show*, never what is *allowed*.\n\nexport async function saveOverride(row: OverrideRow): Promise<Result> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return DENIED;\n return writeOverride(row?.pathKey, row?.value);\n}\n\n/**\n * Writes one field's value by id, with no <GRail> involved — the setter that\n * pairs with getFieldValue(). It exists for fields with nothing on the page to\n * click: an SEO title or meta description, an og:image, a product's stock\n * note. Build a drawer or form of your own, and have it (or a Server Action of\n * yours) call this.\n *\n * A bare string is shorthand for a text field. Pass an OverrideValue for the\n * other types ({ type: \"image\", src, alt }, a link, a button).\n *\n * Does its own role check — an editor or admin — so it is safe to call\n * directly from a Client Component, the same as saveOverride. If your own\n * action does other work first, check isEditor() there too.\n *\n * Writes the same row a <GRail> with this id would, so the two stay\n * interchangeable: a field first written here can later be given a visible\n * <GRail>, and vice versa.\n */\nexport async function setFieldValue(\n id: string,\n value: string | OverrideValue,\n): Promise<Result> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return DENIED;\n\n const normalized: OverrideValue | string =\n typeof value === \"string\" ? { type: \"text\", text: value } : value;\n return writeOverride(id, normalized);\n}\n\n/** The write shared by saveOverride and setFieldValue; the caller has already authorized. */\nasync function writeOverride(\n rawPathKey: unknown,\n rawValue: unknown,\n): Promise<Result> {\n // Server Actions are public endpoints: the TypeScript types above describe\n // what the bundled editor sends, not what a request can contain.\n const pathKey = validPathKey(rawPathKey);\n if (!pathKey) return { success: false, error: \"A valid field id is required.\" };\n const checked = validateOverrideValue(rawValue);\n if (!checked.ok) return { success: false, error: checked.error };\n const value = checked.value;\n\n const rowToSend: GRStorageRow = {\n pathKey,\n siteId: await resolveSiteId(),\n type: value.type,\n value,\n };\n\n try {\n const db = getDB();\n const res = await db.setOverride(rowToSend);\n if (!res.success) return res;\n\n // TODO: too broad — purges every cached route on any single-field save.\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: save failed\", e);\n return { success: false, error: \"Database write failed.\" };\n }\n}\n\n/* --------------------------------------------------------------- media --- */\n\n// SVG is excluded on purpose: it can carry script, and next/image refuses it\n// by default anyway.\nconst IMAGE_TYPES = [\"image/png\", \"image/jpeg\", \"image/webp\", \"image/gif\", \"image/avif\"];\nconst MAX_UPLOAD_BYTES = 10 * 1024 * 1024;\n\n/**\n * Authorizes an upload and returns where the browser should send the bytes.\n * Only metadata comes through here — the file itself goes straight to storage.\n */\nexport async function createAssetUpload(file: {\n name: string;\n contentType: string;\n size: number;\n}): Promise<\n { success: true; target: GRUploadTarget } | { success: false; error: string }\n> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return { success: false, error: \"Unauthorized.\" };\n const slow = await rateLimited(\"upload\", viewer.userId);\n if (slow) return { success: false, error: slow };\n\n const storage = getStorage();\n if (!storage) {\n return { success: false, error: \"No storage adapter is configured.\" };\n }\n if (!IMAGE_TYPES.includes(file.contentType)) {\n return { success: false, error: \"Upload a PNG, JPEG, WebP, GIF, or AVIF image.\" };\n }\n if (!(file.size > 0) || file.size > MAX_UPLOAD_BYTES) {\n return { success: false, error: \"Images must be under 10 MB.\" };\n }\n\n try {\n const target = await storage.createUpload(await resolveSiteId(), file);\n return { success: true, target };\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: createUpload failed\", e);\n // Failures here are almost always setup (missing SQL, bucket, or policy),\n // so show the cause while developing. Production keeps it generic rather\n // than exposing backend detail to editors.\n const detail =\n process.env.NODE_ENV !== \"production\" && e instanceof Error\n ? ` ${e.message}`\n : \"\";\n return { success: false, error: `Could not start the upload.${detail}` };\n }\n}\n\nexport async function listAssets(): Promise<GRAsset[]> {\n const viewer = await requireRole([\"admin\", \"editor\"]);\n const storage = getStorage();\n if (!viewer || !storage) return [];\n return storage.listAssets(await resolveSiteId());\n}\n\n/* ---------------------------------------------------------------- team --- */\n\nexport async function listTeam(): Promise<GRMember[]> {\n // Any member may see who else is on the site; only admins may change it.\n const viewer = await requireRole([\"admin\", \"editor\"]);\n if (!viewer) return [];\n\n const members = getMembers();\n return members.listMembers(await resolveSiteId());\n}\n\nexport async function inviteMember(\n email: string,\n role: GRRole,\n): Promise<Result> {\n const viewer = await requireRole([\"admin\"]);\n if (!viewer) return DENIED;\n\n const normalized = cleanEmail(email);\n if (!normalized) return { success: false, error: \"Enter a valid email address.\" };\n if (!isRole(role)) return { success: false, error: \"Choose admin or editor.\" };\n\n const siteId = await resolveSiteId();\n const members = getMembers();\n\n const existing = await members.getMember(siteId, { email: normalized });\n if (existing?.status === \"active\") {\n return { success: false, error: \"That address is already on the team.\" };\n }\n\n // A pending invite is re-sent rather than rejected: provider email is\n // rate-limited and unreliable enough that resending has to be possible.\n if (!existing) {\n const res = await members.upsertMember(siteId, {\n email: normalized,\n role,\n userId: null,\n status: \"pending\",\n });\n if (!res.success) return res;\n }\n\n const auth = getAuth();\n const onInvite = getInviteHandler();\n const slow = await rateLimited(\"invite\", viewer.userId);\n if (slow) return { success: false, error: `Invite saved, but not sent. ${slow}` };\n\n const where = await linkOrigin();\n if (\"error\" in where) {\n revalidatePath(\"/\", \"layout\");\n return { success: false, error: `Invite saved, but ${where.error}` };\n }\n const origin = where.origin;\n\n // The membership is saved either way, so none of what follows can undo it;\n // each failure just says what to fix. Delivery has one shape: the adapter\n // creates the account and a link (if it can), the app's handler emails it.\n const made = auth.createInviteLink\n ? await auth.createInviteLink(normalized, { origin })\n : null;\n if (made && !made.success) {\n revalidatePath(\"/\", \"layout\");\n return {\n success: false,\n error: `Invite saved, but the sign-in link couldn't be created: ${made.error}`,\n };\n }\n if (!onInvite) {\n revalidatePath(\"/\", \"layout\");\n return {\n success: false,\n error:\n \"Invite saved, but this site has nothing set up to email it. Add resendEmails() (or your own onInvite) to withGuardrail() — see the README.\",\n };\n }\n\n try {\n await onInvite({\n email: normalized,\n role: existing?.role ?? role,\n siteId,\n invitedBy: viewer.email ?? viewer.userId,\n ...(made ? { link: made.link, isNewUser: made.isNewUser } : {}),\n });\n } catch (e) {\n console.error(\"Guardrail: onInvite handler threw\", e);\n revalidatePath(\"/\", \"layout\");\n return {\n success: false,\n error: `Invite saved, but the email failed: ${e instanceof Error ? e.message : \"unknown error\"}`,\n };\n }\n\n revalidatePath(\"/\", \"layout\");\n return made && !made.isNewUser\n ? { success: true, notice: ALREADY_HAS_ACCOUNT(normalized) }\n : { success: true };\n}\n\nconst ALREADY_HAS_ACCOUNT = (email: string) =>\n `${email} already has an account, so they were told they've been added rather than sent a link — they sign in with their existing password.`;\n\nexport async function updateMemberRole(\n email: string,\n role: GRRole,\n): Promise<Result> {\n const viewer = await requireRole([\"admin\"]);\n if (!viewer) return DENIED;\n\n const targetEmail = cleanEmail(email);\n if (!targetEmail) return { success: false, error: \"No such member.\" };\n if (!isRole(role)) return { success: false, error: \"Choose admin or editor.\" };\n\n const siteId = await resolveSiteId();\n const members = getMembers();\n\n const target = await members.getMember(siteId, { email: targetEmail });\n if (!target) return { success: false, error: \"No such member.\" };\n\n if (target.role === \"admin\" && role !== \"admin\") {\n const guard = await wouldStrandSite(siteId, target.email);\n if (guard) return guard;\n }\n\n const res = await members.upsertMember(siteId, {\n email: target.email,\n role,\n userId: target.userId,\n status: target.status,\n });\n if (res.success) revalidatePath(\"/\", \"layout\");\n return res;\n}\n\nexport async function removeMember(email: string): Promise<Result> {\n const viewer = await requireRole([\"admin\"]);\n if (!viewer) return DENIED;\n\n const normalized = cleanEmail(email);\n if (!normalized) return { success: false, error: \"No such member.\" };\n\n const siteId = await resolveSiteId();\n const members = getMembers();\n\n const guard = await wouldStrandSite(siteId, normalized);\n if (guard) return guard;\n\n const res = await members.removeMember(siteId, normalized);\n if (res.success) revalidatePath(\"/\", \"layout\");\n return res;\n}\n\n/* ------------------------------------------------------ password reset --- */\n\n/** One reset email per address per site per minute, so the form can't be used to spam someone. */\nconst RESET_COOLDOWN_MS = 60_000;\nconst lastResetAt = new Map<string, number>();\n\n/** Floor for a self-service request, so how long it takes doesn't reveal whether the address is a member. */\nconst RESET_MIN_DURATION_MS = 600;\n\n/**\n * Sends one password-reset email: the adapter mints a one-time link and the\n * app's onPasswordReset handler emails it. If either is missing, says which —\n * there is no quieter fallback to hide behind.\n */\nasync function dispatchPasswordReset(email: string, requestedBy: string): Promise<Result> {\n const auth = getAuth();\n const handler = getPasswordResetHandler();\n\n if (!auth.createPasswordResetLink) {\n return {\n success: false,\n error: \"this site's auth setup can't create reset links — give the Supabase adapter a serviceRoleKey.\",\n };\n }\n if (!handler) {\n return {\n success: false,\n error: \"this site has no onPasswordReset handler — add resendEmails() (or your own) to withGuardrail().\",\n };\n }\n\n const where = await linkOrigin();\n if (\"error\" in where) return { success: false, error: where.error };\n\n const made = await auth.createPasswordResetLink(email, { origin: where.origin });\n if (!made.success) return { success: false, error: made.error };\n try {\n await handler({ email, siteId: await resolveSiteId(), link: made.link, requestedBy });\n return { success: true };\n } catch (e) {\n console.error(\"Guardrail: onPasswordReset handler threw\", e);\n return { success: false, error: e instanceof Error ? e.message : \"unknown error\" };\n }\n}\n\n/**\n * \"Forgot password?\" from the sign-in bar. Called with no session, so it says\n * the same thing whether or not the address belongs to anyone: a member gets an\n * email, a stranger gets nothing, and neither can tell which happened from the\n * response or how long it took. Only a malformed address is rejected, since\n * that says nothing about anyone.\n */\nexport async function forgotPasswordAction(email: string): Promise<Result> {\n const started = Date.now();\n const normalized = cleanEmail(email);\n if (!normalized) return { success: false, error: \"Enter a valid email address.\" };\n\n // Over the limit reads as an error rather than a fake success: it counts\n // members and strangers alike, so it reveals nothing about the address.\n const slow = await rateLimited(\"forgot-password\", normalized);\n if (slow) return { success: false, error: slow };\n\n try {\n const siteId = await resolveSiteId();\n const key = `${siteId}:${normalized}`;\n const last = lastResetAt.get(key);\n if (!last || started - last >= RESET_COOLDOWN_MS) {\n lastResetAt.set(key, started);\n if (lastResetAt.size > 500) {\n for (const [k, t] of lastResetAt) if (started - t >= RESET_COOLDOWN_MS) lastResetAt.delete(k);\n }\n\n // With a privileged membership check, only members are sent anything.\n // Without one (an adapter that can mint reset links but not look members\n // up) any address that asks is — which is why the Supabase adapter offers\n // both together.\n const members = getMembers();\n const known = members.emailHasAccess ? await members.emailHasAccess(siteId, normalized) : true;\n if (known) {\n const res = await dispatchPasswordReset(normalized, \"self\");\n if (!res.success) console.error(\"Guardrail: password reset failed:\", res.error);\n }\n }\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: password reset failed\", e);\n }\n\n const wait = RESET_MIN_DURATION_MS - (Date.now() - started);\n if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));\n return { success: true };\n}\n\n/**\n * An admin sending a password reset to someone on the team, from the Team\n * drawer. Unlike the self-service request this reports what happened — an admin\n * is allowed to know, and needs to when it didn't work.\n */\nexport async function sendPasswordResetAction(email: string): Promise<Result> {\n const viewer = await requireRole([\"admin\"]);\n if (!viewer) return DENIED;\n\n const normalized = cleanEmail(email);\n if (!normalized) return { success: false, error: \"No such member.\" };\n const target = await getMembers().getMember(await resolveSiteId(), { email: normalized });\n if (!target) return { success: false, error: \"No such member.\" };\n\n const slow = await rateLimited(\"password-reset\", viewer.userId);\n if (slow) return { success: false, error: slow };\n\n const res = await dispatchPasswordReset(normalized, viewer.email ?? viewer.userId);\n return res.success\n ? { success: true, notice: `Password reset email sent to ${normalized}.` }\n : { success: false, error: `The reset email couldn't be sent: ${res.error}` };\n}\n\nconst SITE_URL_MISSING =\n \"siteUrl isn't set, so sign-in links can't be built safely — set GUARDRAIL_SITE_URL (or siteUrl in withGuardrail()) to this site's public address.\";\n\n/**\n * Where an emailed link points. Production takes it from configuration only: a\n * reset can be requested by anyone for any address, so an address read from the\n * request would let a forged Host/Origin header mail a valid token to someone\n * else's domain. Development (no configuration) uses the request's own address.\n */\nasync function linkOrigin(): Promise<{ origin: string } | { error: string }> {\n const configured = await resolveSiteUrl();\n if (configured) return { origin: configured };\n if (process.env.NODE_ENV === \"production\") return { error: SITE_URL_MISSING };\n const h = await headers();\n const host = h.get(\"host\") ?? \"localhost\";\n const proto = h.get(\"x-forwarded-proto\") ?? (host.startsWith(\"localhost\") ? \"http\" : \"https\");\n return { origin: `${proto}://${host}` };\n}\n\n/**\n * Refuses changes that would leave a site with no admin, which would lock\n * everyone out of team management with no in-app way to recover.\n */\nasync function wouldStrandSite(\n siteId: string,\n email: string,\n): Promise<Result | null> {\n const members = getMembers();\n const all = await members.listMembers(siteId);\n const admins = all.filter((m) => m.role === \"admin\");\n\n if (admins.length <= 1 && admins.some((m) => m.email === email)) {\n return {\n success: false,\n error: \"A site must keep at least one admin.\",\n };\n }\n return null;\n}\n\n/* ---------------------------------------------------------------- auth --- */\n\nexport async function loginAction(\n _prev: ActionState,\n formData: FormData,\n): Promise<ActionState> {\n const email = String(formData.get(\"email\") ?? \"\");\n const password = String(formData.get(\"password\") ?? \"\");\n\n const auth = getAuth();\n if (!auth.authenticate) {\n return {\n success: false,\n error: \"This site's auth provider does not accept passwords.\",\n email,\n };\n }\n\n // Bounded before it becomes a counter key: this runs before anything is\n // checked, for anyone, so the raw field could be arbitrarily large.\n const slow = await rateLimited(\"login\", cleanEmail(email) ?? email.trim().toLowerCase().slice(0, 254));\n if (slow) return { success: false, error: slow, email };\n\n const result = await auth.authenticate(email, password);\n if (!result.success) {\n return { success: false, error: result.error, email };\n }\n\n revalidatePath(\"/\", \"layout\");\n return { success: true, email };\n}\n\nexport async function logoutAction(): Promise<{ success: boolean }> {\n const auth = getAuth();\n await auth.logout();\n // Cleared here, not by the client afterward: a Server Action's own\n // revalidatePath() below triggers Next's router to refresh the current\n // route automatically, which can race a client-side navigation attempted\n // right after this resolves — confirmed live, where that automatic\n // refresh (for the pre-logout URL, ?edit=true) won the race and silently\n // undid a client-side `window.location.href` to the plain path. Deleting\n // the cookie here instead makes it part of the one server round-trip that\n // revalidatePath already accounts for, with nothing left to race.\n const cookieStore = await cookies();\n cookieStore.delete(GR_COOKIES.EDIT_MODE);\n // Signing out ends whatever was pending for that session too. A leftover\n // \"needs a password\" flag used to survive this and keep the sign-in bar\n // pinned on screen.\n cookieStore.delete(GR_COOKIES.NEEDS_PASSWORD);\n cookieStore.delete(GR_COOKIES.AUTH_ERROR);\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n}\n\n/* ---------------------------------------------------------- redirects --- */\n\n/**\n * Completes a session that arrived via an invite or password-recovery link. Called by AuthRedirectSync (react/), the\n * generic client component that notices this kind of link and hands off\n * whatever it found in the URL — the actual token exchange is entirely the\n * auth adapter's job (see GRAuthAdapter.completeAuthRedirect); core only\n * orchestrates the result.\n *\n * A no-op {success:false}, not an error, when the adapter doesn't implement\n * this — most page loads carry no redirect data at all, and an adapter with\n * no such concept should not make every one of them fail.\n */\nexport async function completeAuthRedirectAction(\n params: AuthRedirectParams,\n): Promise<{ success: boolean; needsPassword?: boolean; error?: string }> {\n const auth = getAuth();\n if (!auth.completeAuthRedirect) return { success: false };\n if (await rateLimited(\"auth-link\")) return { success: false, error: \"Too many attempts.\" };\n\n const res = await auth.completeAuthRedirect(params);\n const cookieStore = await cookies();\n if (res.success) {\n cookieStore.delete(GR_COOKIES.AUTH_ERROR);\n if (res.needsPassword) {\n cookieStore.set(GR_COOKIES.NEEDS_PASSWORD, \"true\", {\n path: \"/\",\n maxAge: GR_NEEDS_PASSWORD_TTL_SECONDS,\n });\n }\n revalidatePath(\"/\", \"layout\");\n } else {\n // Say so, rather than leaving the person on a plain page with nothing\n // visibly different. The provider's own wording stays in the server log; the\n // cookie carries only a code, which the chrome turns into plain English.\n console.error(\"Guardrail: couldn't complete an emailed link:\", res.error);\n cookieStore.set(\n GR_COOKIES.AUTH_ERROR,\n /expired|invalid|already|used|not found/i.test(res.error ?? \"\") ? \"expired\" : \"failed\",\n { path: \"/\", maxAge: GR_AUTH_ERROR_TTL_SECONDS },\n );\n revalidatePath(\"/\", \"layout\");\n }\n return res;\n}\n\n/**\n * Sets the current session's password — the prompt completeAuthRedirectAction\n * triggers via needsPassword. Requires only a valid session, not a role: an\n * invited person's role is claimed as a side effect of getCachedViewer()\n * resolving them, which already happens independently of this.\n */\nexport async function setPasswordAction(\n _prev: SetPasswordState,\n formData: FormData,\n): Promise<SetPasswordState> {\n const viewer = await getCachedViewer();\n if (!viewer) return { success: false, error: \"Not signed in.\" };\n\n const auth = getAuth();\n if (!auth.setPassword) {\n return { success: false, error: \"This site's auth provider does not support setting a password.\" };\n }\n\n const password = String(formData.get(\"password\") ?? \"\");\n // `confirm` is absent from older custom forms, so only checked when sent.\n const confirm = formData.get(\"confirm\");\n if (password.length < 8) {\n return { success: false, error: \"Use at least 8 characters.\" };\n }\n if (confirm !== null && String(confirm) !== password) {\n return { success: false, error: \"The two passwords don't match.\" };\n }\n const res = await auth.setPassword(password);\n if (res.success) {\n const cookieStore = await cookies();\n cookieStore.delete(GR_COOKIES.NEEDS_PASSWORD);\n revalidatePath(\"/\", \"layout\");\n }\n return res;\n}\n\n/**\n * Dismisses the set-password step without setting a password.\n *\n * @deprecated Guardrail's own UI no longer offers this: passwords are the only\n * way back in, so the step can't be skipped. Kept so an app's own UI built on\n * it keeps working.\n */\nexport async function skipSetPasswordAction(): Promise<{ success: boolean }> {\n const cookieStore = await cookies();\n cookieStore.delete(GR_COOKIES.NEEDS_PASSWORD);\n revalidatePath(\"/\", \"layout\");\n return { success: true };\n}\n"],"mappings":"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","names":[]}
@@ -13,11 +13,11 @@ const getLoader = cache(() => {
13
13
  try {
14
14
  const db = getDB();
15
15
  const siteId = await resolveSiteId();
16
- const started = Date.now();
16
+ const started = process.env.GUARDRAIL_DEBUG ? performance.now() : 0;
17
17
  const rows = await db.getOverrides(keys, siteId);
18
18
  if (process.env.GUARDRAIL_DEBUG) {
19
19
  console.log(
20
- `[guardrail] batch: ${keys.length} key(s) -> ${rows.length} row(s) in 1 query (${Date.now() - started}ms) site=${siteId} keys=${keys.join(", ")}`
20
+ `[guardrail] batch: ${keys.length} key(s) -> ${rows.length} row(s) in 1 query (${Math.round(performance.now() - started)}ms) site=${siteId} keys=${keys.join(", ")}`
21
21
  );
22
22
  }
23
23
  const byKey = new Map(rows.map((r) => [r.pathKey, r]));
@@ -40,11 +40,11 @@ const getLoader = cache(() => {
40
40
  const db = getDB();
41
41
  if (!prefetch || !db.listOverrides) return null;
42
42
  const siteId = await resolveSiteId();
43
- const started = Date.now();
43
+ const started = process.env.GUARDRAIL_DEBUG ? performance.now() : 0;
44
44
  const { rows, truncated } = await db.listOverrides(siteId, prefetch);
45
45
  if (process.env.GUARDRAIL_DEBUG) {
46
46
  console.log(
47
- `[guardrail] preload: ${rows.length} row(s) in 1 query (${Date.now() - started}ms) site=${siteId}${prefetch.prefix ? ` prefix=${prefetch.prefix.join("|")}` : ""}${truncated ? " TRUNCATED" : ""}`
47
+ `[guardrail] preload: ${rows.length} row(s) in 1 query (${Math.round(performance.now() - started)}ms) site=${siteId}${prefetch.prefix ? ` prefix=${prefetch.prefix.join("|")}` : ""}${truncated ? " TRUNCATED" : ""}`
48
48
  );
49
49
  }
50
50
  return {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/server/loader.ts"],"sourcesContent":["import { cache } from \"react\";\nimport { getDB, getPrefetch, resolveSiteId } from \"../core/config\";\nimport { rethrowIfBailout } from \"../core/bailout\";\nimport type { GRContent, OverrideRow } from \"../core/types\";\n\ninterface Pending {\n key: string;\n resolve: (row: OverrideRow | null) => void;\n reject: (error: unknown) => void;\n}\n\ninterface Preloaded {\n byKey: Map<string, OverrideRow>;\n /** Every row the site has — an absent key is known to be unedited. */\n complete: boolean;\n truncated: boolean;\n}\n\n/**\n * Request-scoped content loader.\n *\n * Two jobs, one source of truth:\n *\n * 1. Batching. Every <GRail> resolves its own field, so a naive implementation\n * issues one query per field. Keys requested within a microtask are flushed\n * as a single query. React renders siblings concurrently, so a page's fields\n * land in the same batch; a field nested behind another await simply falls\n * into a later batch rather than degrading to one query each.\n *\n * 2. Preloading. <GRail>s inside Client Components cannot await anything, so\n * their content has to be handed to them up front. That preload lives *here*,\n * as one lazily-created promise per request, rather than as a separate fetch\n * in the layout — Next renders layouts and pages in parallel, so a page's\n * server fields could otherwise ask for content before the layout had\n * loaded any. Server fields read from the same preload: when it is complete,\n * every key resolves from it and no per-field query runs at all.\n *\n * cache() scopes all of it to a single request so nothing leaks between\n * concurrent renders.\n */\nconst getLoader = cache(() => {\n let pending: Pending[] = [];\n let scheduled = false;\n\n const flush = async () => {\n const batch = pending;\n pending = [];\n scheduled = false;\n if (batch.length === 0) return;\n\n // One lookup per distinct key, even if several fields share it.\n const keys = [...new Set(batch.map((p) => p.key))];\n\n try {\n const db = getDB();\n const siteId = await resolveSiteId();\n const started = Date.now();\n const rows = await db.getOverrides(keys, siteId);\n\n if (process.env.GUARDRAIL_DEBUG) {\n console.log(\n `[guardrail] batch: ${keys.length} key(s) -> ${rows.length} row(s) ` +\n `in 1 query (${Date.now() - started}ms) ` +\n `site=${siteId} keys=${keys.join(\", \")}`,\n );\n }\n const byKey = new Map(rows.map((r) => [r.pathKey, r]));\n for (const p of batch) p.resolve(byKey.get(p.key) ?? null);\n } catch (e) {\n for (const p of batch) p.reject(e);\n }\n };\n\n const enqueue = (key: string) =>\n new Promise<OverrideRow | null>((resolve, reject) => {\n pending.push({ key, resolve, reject });\n if (!scheduled) {\n scheduled = true;\n queueMicrotask(flush);\n }\n });\n\n let preload: Promise<Preloaded | null> | undefined;\n\n const runPreload = async (): Promise<Preloaded | null> => {\n try {\n const prefetch = getPrefetch();\n const db = getDB();\n // Off, or an adapter that can't list: behave exactly as before.\n if (!prefetch || !db.listOverrides) return null;\n\n const siteId = await resolveSiteId();\n const started = Date.now();\n const { rows, truncated } = await db.listOverrides(siteId, prefetch);\n\n if (process.env.GUARDRAIL_DEBUG) {\n console.log(\n `[guardrail] preload: ${rows.length} row(s) in 1 query ` +\n `(${Date.now() - started}ms) site=${siteId}` +\n `${prefetch.prefix ? ` prefix=${prefetch.prefix.join(\"|\")}` : \"\"}` +\n `${truncated ? \" TRUNCATED\" : \"\"}`,\n );\n }\n return {\n byKey: new Map(rows.map((r) => [r.pathKey, r])),\n // Scoping by prefix or hitting the ceiling both mean \"not every row.\"\n complete: !prefetch.prefix && !truncated,\n truncated,\n };\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: content preload failed\", e);\n return null;\n }\n };\n\n const getPreload = () => (preload ??= runPreload());\n\n return {\n async load(key: string): Promise<OverrideRow | null> {\n const pre = await getPreload();\n if (pre) {\n const hit = pre.byKey.get(key);\n if (hit) return hit;\n if (pre.complete) return null;\n }\n return enqueue(key);\n },\n getPreload,\n };\n});\n\nexport async function loadOverride(\n pathKey: string,\n): Promise<OverrideRow | null> {\n try {\n return await getLoader().load(pathKey);\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: failed to load override\", e);\n return null;\n }\n}\n\nlet warnedTruncated = false;\nlet warnedSize = false;\nconst SIZE_WARN_CHARS = 100_000;\n\n/**\n * The preload in the shape handed to Client Components, or undefined when\n * preloading is off or unavailable (client fields then render their JSX\n * fallback). Also where the two dev-mode nudges live, since this is the one\n * place that knows the size of what is about to be serialized into the page.\n */\nexport async function loadPreloadedContent(): Promise<GRContent | undefined> {\n const pre = await getLoader().getPreload();\n if (!pre) return undefined;\n\n const values: GRContent[\"values\"] = {};\n for (const [key, row] of pre.byKey) values[key] = row.value;\n\n if (process.env.NODE_ENV !== \"production\") {\n if (pre.truncated && !warnedTruncated) {\n warnedTruncated = true;\n console.warn(\n `Guardrail: this site has more content rows than the preload ceiling ` +\n `(${pre.byKey.size}). <GRail>s inside Client Components beyond it fall ` +\n `back to their JSX. Scope it with withGuardrail(..., { prefetch: { prefix: [\"home.\", \"header.\"] } }) ` +\n `or raise prefetch.limit.`,\n );\n }\n if (!warnedSize) {\n const size = JSON.stringify(values).length;\n if (size > SIZE_WARN_CHARS) {\n warnedSize = true;\n console.warn(\n `Guardrail: preloaded content for Client Components is ~${Math.round(size / 1000)}KB ` +\n `and ships in every page. Consider scoping it with prefetch.prefix.`,\n );\n }\n }\n }\n\n return { values, complete: pre.complete };\n}\n"],"mappings":"AAAA,SAAS,aAAa;AACtB,SAAS,OAAO,aAAa,qBAAqB;AAClD,SAAS,wBAAwB;AAsCjC,MAAM,YAAY,MAAM,MAAM;AAC5B,MAAI,UAAqB,CAAC;AAC1B,MAAI,YAAY;AAEhB,QAAM,QAAQ,YAAY;AACxB,UAAM,QAAQ;AACd,cAAU,CAAC;AACX,gBAAY;AACZ,QAAI,MAAM,WAAW,EAAG;AAGxB,UAAM,OAAO,CAAC,GAAG,IAAI,IAAI,MAAM,IAAI,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;AAEjD,QAAI;AACF,YAAM,KAAK,MAAM;AACjB,YAAM,SAAS,MAAM,cAAc;AACnC,YAAM,UAAU,KAAK,IAAI;AACzB,YAAM,OAAO,MAAM,GAAG,aAAa,MAAM,MAAM;AAE/C,UAAI,QAAQ,IAAI,iBAAiB;AAC/B,gBAAQ;AAAA,UACN,sBAAsB,KAAK,MAAM,cAAc,KAAK,MAAM,uBACzC,KAAK,IAAI,IAAI,OAAO,YAC3B,MAAM,SAAS,KAAK,KAAK,IAAI,CAAC;AAAA,QAC1C;AAAA,MACF;AACA,YAAM,QAAQ,IAAI,IAAI,KAAK,IAAI,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC;AACrD,iBAAW,KAAK,MAAO,GAAE,QAAQ,MAAM,IAAI,EAAE,GAAG,KAAK,IAAI;AAAA,IAC3D,SAAS,GAAG;AACV,iBAAW,KAAK,MAAO,GAAE,OAAO,CAAC;AAAA,IACnC;AAAA,EACF;AAEA,QAAM,UAAU,CAAC,QACf,IAAI,QAA4B,CAAC,SAAS,WAAW;AACnD,YAAQ,KAAK,EAAE,KAAK,SAAS,OAAO,CAAC;AACrC,QAAI,CAAC,WAAW;AACd,kBAAY;AACZ,qBAAe,KAAK;AAAA,IACtB;AAAA,EACF,CAAC;AAEH,MAAI;AAEJ,QAAM,aAAa,YAAuC;AACxD,QAAI;AACF,YAAM,WAAW,YAAY;AAC7B,YAAM,KAAK,MAAM;AAEjB,UAAI,CAAC,YAAY,CAAC,GAAG,cAAe,QAAO;AAE3C,YAAM,SAAS,MAAM,cAAc;AACnC,YAAM,UAAU,KAAK,IAAI;AACzB,YAAM,EAAE,MAAM,UAAU,IAAI,MAAM,GAAG,cAAc,QAAQ,QAAQ;AAEnE,UAAI,QAAQ,IAAI,iBAAiB;AAC/B,gBAAQ;AAAA,UACN,wBAAwB,KAAK,MAAM,uBAC7B,KAAK,IAAI,IAAI,OAAO,YAAY,MAAM,GACvC,SAAS,SAAS,WAAW,SAAS,OAAO,KAAK,GAAG,CAAC,KAAK,EAAE,GAC7D,YAAY,eAAe,EAAE;AAAA,QACpC;AAAA,MACF;AACA,aAAO;AAAA,QACL,OAAO,IAAI,IAAI,KAAK,IAAI,CAAC,MAAM,CAAC,EAAE,SAAS,CAAC,CAAC,CAAC;AAAA;AAAA,QAE9C,UAAU,CAAC,SAAS,UAAU,CAAC;AAAA,QAC/B;AAAA,MACF;AAAA,IACF,SAAS,GAAG;AACV,uBAAiB,CAAC;AAClB,cAAQ,MAAM,qCAAqC,CAAC;AACpD,aAAO;AAAA,IACT;AAAA,EACF;AAEA,QAAM,aAAa,MAAO,YAAY,WAAW;AAEjD,SAAO;AAAA,IACL,MAAM,KAAK,KAA0C;AACnD,YAAM,MAAM,MAAM,WAAW;AAC7B,UAAI,KAAK;AACP,cAAM,MAAM,IAAI,MAAM,IAAI,GAAG;AAC7B,YAAI,IAAK,QAAO;AAChB,YAAI,IAAI,SAAU,QAAO;AAAA,MAC3B;AACA,aAAO,QAAQ,GAAG;AAAA,IACpB;AAAA,IACA;AAAA,EACF;AACF,CAAC;AAED,eAAsB,aACpB,SAC6B;AAC7B,MAAI;AACF,WAAO,MAAM,UAAU,EAAE,KAAK,OAAO;AAAA,EACvC,SAAS,GAAG;AACV,qBAAiB,CAAC;AAClB,YAAQ,MAAM,sCAAsC,CAAC;AACrD,WAAO;AAAA,EACT;AACF;AAEA,IAAI,kBAAkB;AACtB,IAAI,aAAa;AACjB,MAAM,kBAAkB;AAQxB,eAAsB,uBAAuD;AAC3E,QAAM,MAAM,MAAM,UAAU,EAAE,WAAW;AACzC,MAAI,CAAC,IAAK,QAAO;AAEjB,QAAM,SAA8B,CAAC;AACrC,aAAW,CAAC,KAAK,GAAG,KAAK,IAAI,MAAO,QAAO,GAAG,IAAI,IAAI;AAEtD,MAAI,QAAQ,IAAI,aAAa,cAAc;AACzC,QAAI,IAAI,aAAa,CAAC,iBAAiB;AACrC,wBAAkB;AAClB,cAAQ;AAAA,QACN,wEACM,IAAI,MAAM,IAAI;AAAA,MAGtB;AAAA,IACF;AACA,QAAI,CAAC,YAAY;AACf,YAAM,OAAO,KAAK,UAAU,MAAM,EAAE;AACpC,UAAI,OAAO,iBAAiB;AAC1B,qBAAa;AACb,gBAAQ;AAAA,UACN,0DAA0D,KAAK,MAAM,OAAO,GAAI,CAAC;AAAA,QAEnF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,SAAO,EAAE,QAAQ,UAAU,IAAI,SAAS;AAC1C;","names":[]}
1
+ {"version":3,"sources":["../../src/server/loader.ts"],"sourcesContent":["import { cache } from \"react\";\nimport { getDB, getPrefetch, resolveSiteId } from \"../core/config\";\nimport { rethrowIfBailout } from \"../core/bailout\";\nimport type { GRContent, OverrideRow } from \"../core/types\";\n\ninterface Pending {\n key: string;\n resolve: (row: OverrideRow | null) => void;\n reject: (error: unknown) => void;\n}\n\ninterface Preloaded {\n byKey: Map<string, OverrideRow>;\n /** Every row the site has — an absent key is known to be unedited. */\n complete: boolean;\n truncated: boolean;\n}\n\n/**\n * Request-scoped content loader.\n *\n * Two jobs, one source of truth:\n *\n * 1. Batching. Every <GRail> resolves its own field, so a naive implementation\n * issues one query per field. Keys requested within a microtask are flushed\n * as a single query. React renders siblings concurrently, so a page's fields\n * land in the same batch; a field nested behind another await simply falls\n * into a later batch rather than degrading to one query each.\n *\n * 2. Preloading. <GRail>s inside Client Components cannot await anything, so\n * their content has to be handed to them up front. That preload lives *here*,\n * as one lazily-created promise per request, rather than as a separate fetch\n * in the layout — Next renders layouts and pages in parallel, so a page's\n * server fields could otherwise ask for content before the layout had\n * loaded any. Server fields read from the same preload: when it is complete,\n * every key resolves from it and no per-field query runs at all.\n *\n * cache() scopes all of it to a single request so nothing leaks between\n * concurrent renders.\n */\nconst getLoader = cache(() => {\n let pending: Pending[] = [];\n let scheduled = false;\n\n const flush = async () => {\n const batch = pending;\n pending = [];\n scheduled = false;\n if (batch.length === 0) return;\n\n // One lookup per distinct key, even if several fields share it.\n const keys = [...new Set(batch.map((p) => p.key))];\n\n try {\n const db = getDB();\n const siteId = await resolveSiteId();\n // Timing for the debug log only. performance.now(), not Date.now(): Next's\n // Cache Components treats Date.now() during prerender as an error.\n const started = process.env.GUARDRAIL_DEBUG ? performance.now() : 0;\n const rows = await db.getOverrides(keys, siteId);\n\n if (process.env.GUARDRAIL_DEBUG) {\n console.log(\n `[guardrail] batch: ${keys.length} key(s) -> ${rows.length} row(s) ` +\n `in 1 query (${Math.round(performance.now() - started)}ms) ` +\n `site=${siteId} keys=${keys.join(\", \")}`,\n );\n }\n const byKey = new Map(rows.map((r) => [r.pathKey, r]));\n for (const p of batch) p.resolve(byKey.get(p.key) ?? null);\n } catch (e) {\n for (const p of batch) p.reject(e);\n }\n };\n\n const enqueue = (key: string) =>\n new Promise<OverrideRow | null>((resolve, reject) => {\n pending.push({ key, resolve, reject });\n if (!scheduled) {\n scheduled = true;\n queueMicrotask(flush);\n }\n });\n\n let preload: Promise<Preloaded | null> | undefined;\n\n const runPreload = async (): Promise<Preloaded | null> => {\n try {\n const prefetch = getPrefetch();\n const db = getDB();\n // Off, or an adapter that can't list: behave exactly as before.\n if (!prefetch || !db.listOverrides) return null;\n\n const siteId = await resolveSiteId();\n // Timing for the debug log only. performance.now(), not Date.now(): Next's\n // Cache Components treats Date.now() during prerender as an error.\n const started = process.env.GUARDRAIL_DEBUG ? performance.now() : 0;\n const { rows, truncated } = await db.listOverrides(siteId, prefetch);\n\n if (process.env.GUARDRAIL_DEBUG) {\n console.log(\n `[guardrail] preload: ${rows.length} row(s) in 1 query ` +\n `(${Math.round(performance.now() - started)}ms) site=${siteId}` +\n `${prefetch.prefix ? ` prefix=${prefetch.prefix.join(\"|\")}` : \"\"}` +\n `${truncated ? \" TRUNCATED\" : \"\"}`,\n );\n }\n return {\n byKey: new Map(rows.map((r) => [r.pathKey, r])),\n // Scoping by prefix or hitting the ceiling both mean \"not every row.\"\n complete: !prefetch.prefix && !truncated,\n truncated,\n };\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: content preload failed\", e);\n return null;\n }\n };\n\n const getPreload = () => (preload ??= runPreload());\n\n return {\n async load(key: string): Promise<OverrideRow | null> {\n const pre = await getPreload();\n if (pre) {\n const hit = pre.byKey.get(key);\n if (hit) return hit;\n if (pre.complete) return null;\n }\n return enqueue(key);\n },\n getPreload,\n };\n});\n\nexport async function loadOverride(\n pathKey: string,\n): Promise<OverrideRow | null> {\n try {\n return await getLoader().load(pathKey);\n } catch (e) {\n rethrowIfBailout(e);\n console.error(\"Guardrail: failed to load override\", e);\n return null;\n }\n}\n\nlet warnedTruncated = false;\nlet warnedSize = false;\nconst SIZE_WARN_CHARS = 100_000;\n\n/**\n * The preload in the shape handed to Client Components, or undefined when\n * preloading is off or unavailable (client fields then render their JSX\n * fallback). Also where the two dev-mode nudges live, since this is the one\n * place that knows the size of what is about to be serialized into the page.\n */\nexport async function loadPreloadedContent(): Promise<GRContent | undefined> {\n const pre = await getLoader().getPreload();\n if (!pre) return undefined;\n\n const values: GRContent[\"values\"] = {};\n for (const [key, row] of pre.byKey) values[key] = row.value;\n\n if (process.env.NODE_ENV !== \"production\") {\n if (pre.truncated && !warnedTruncated) {\n warnedTruncated = true;\n console.warn(\n `Guardrail: this site has more content rows than the preload ceiling ` +\n `(${pre.byKey.size}). <GRail>s inside Client Components beyond it fall ` +\n `back to their JSX. Scope it with withGuardrail(..., { prefetch: { prefix: [\"home.\", \"header.\"] } }) ` +\n `or raise prefetch.limit.`,\n );\n }\n if (!warnedSize) {\n const size = JSON.stringify(values).length;\n if (size > SIZE_WARN_CHARS) {\n warnedSize = true;\n console.warn(\n `Guardrail: preloaded content for Client Components is ~${Math.round(size / 1000)}KB ` +\n `and ships in every page. Consider scoping it with prefetch.prefix.`,\n );\n }\n }\n }\n\n return { values, complete: pre.complete };\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,21 @@
1
+ import { GRRateLimitName, GRRateLimitHit } from '../core/adapter-types.js';
2
+ import 'react';
3
+ import '../core/types.js';
4
+
5
+ type RateLimitedAction = GRRateLimitName;
6
+ declare function memoryLimiter(hit: GRRateLimitHit): {
7
+ allowed: boolean;
8
+ retryAfterSeconds?: number;
9
+ };
10
+ /**
11
+ * Counts one attempt against each named limit, returning a message to show if
12
+ * any is spent, or null if the request may proceed. Call it before the
13
+ * expensive work. Fails closed: a custom limiter that throws blocks the
14
+ * request rather than waving everything through.
15
+ *
16
+ * `subject` is the account (email) or signed-in user the "account" / "user"
17
+ * rule is keyed on; "ip" rules key on the caller's address.
18
+ */
19
+ declare function rateLimited(action: "login" | "forgot-password" | "auth-link" | "invite" | "password-reset" | "upload", subject?: string): Promise<string | null>;
20
+
21
+ export { type RateLimitedAction, memoryLimiter, rateLimited };
@@ -0,0 +1,94 @@
1
+ import { headers } from "next/headers";
2
+ import { getClientIpResolver, getRateLimiter, getRateLimitRules } from "../core/config";
3
+ const RULES = {
4
+ // Credential stuffing, without handing anyone the power to lock a person out.
5
+ // The tight limit is per account *and* address, so someone hammering an
6
+ // account only exhausts their own pairing; the per-account ceiling is higher,
7
+ // enough to stop a distributed attack without being a few requests away.
8
+ "login:pair": { limit: 8, windowSeconds: 15 * 60 },
9
+ "login:account": { limit: 40, windowSeconds: 15 * 60 },
10
+ "login:ip": { limit: 30, windowSeconds: 15 * 60 },
11
+ // Mail bombing: per address from one place, and per inbox from anywhere. The
12
+ // per-address cooldown in actions.ts is in memory only; these hold across
13
+ // serverless instances.
14
+ "forgot-password:ip": { limit: 10, windowSeconds: 60 * 60 },
15
+ "forgot-password:account": { limit: 5, windowSeconds: 60 * 60 },
16
+ // Guessing one-time tokens (long and random, so this is belt and braces).
17
+ "auth-link:ip": { limit: 30, windowSeconds: 15 * 60 },
18
+ // Signed-in actions that send mail or create storage objects.
19
+ "invite:user": { limit: 30, windowSeconds: 60 * 60 },
20
+ "password-reset:user": { limit: 30, windowSeconds: 60 * 60 },
21
+ "upload:user": { limit: 60, windowSeconds: 10 * 60 }
22
+ };
23
+ function ruleFor(name) {
24
+ const base = RULES[name];
25
+ const override = getRateLimitRules()?.[name];
26
+ const whole = (n) => typeof n === "number" && Number.isInteger(n) && n > 0;
27
+ return {
28
+ limit: whole(override?.limit) ? override.limit : base.limit,
29
+ windowSeconds: whole(override?.windowSeconds) ? override.windowSeconds : base.windowSeconds
30
+ };
31
+ }
32
+ const buckets = /* @__PURE__ */ new Map();
33
+ function memoryLimiter(hit) {
34
+ const now = Date.now();
35
+ const id = `${hit.name}|${hit.key}`;
36
+ let bucket = buckets.get(id);
37
+ if (!bucket || bucket.resetAt <= now) {
38
+ bucket = { count: 0, resetAt: now + hit.windowSeconds * 1e3 };
39
+ buckets.set(id, bucket);
40
+ if (buckets.size > 5e3) {
41
+ for (const [k, b] of buckets) if (b.resetAt <= now) buckets.delete(k);
42
+ }
43
+ }
44
+ bucket.count += 1;
45
+ return bucket.count <= hit.limit ? { allowed: true } : { allowed: false, retryAfterSeconds: Math.ceil((bucket.resetAt - now) / 1e3) };
46
+ }
47
+ async function callerIp() {
48
+ const h = await headers();
49
+ const custom = getClientIpResolver();
50
+ if (custom) {
51
+ try {
52
+ const chosen = (await custom(h))?.trim();
53
+ if (chosen) return chosen;
54
+ } catch (e) {
55
+ console.error("Guardrail: clientIp threw, using the default:", e);
56
+ }
57
+ }
58
+ const forwarded = h.get("x-forwarded-for")?.split(",")[0]?.trim();
59
+ return forwarded || h.get("x-real-ip")?.trim() || "unknown";
60
+ }
61
+ function waitLabel(seconds) {
62
+ if (seconds < 90) return "a minute";
63
+ const minutes = Math.ceil(seconds / 60);
64
+ if (minutes < 60) return `about ${minutes} minutes`;
65
+ const hours = Math.ceil(minutes / 60);
66
+ return `about ${hours} ${hours === 1 ? "hour" : "hours"}`;
67
+ }
68
+ async function rateLimited(action, subject) {
69
+ const configured = getRateLimiter();
70
+ if (configured === false) return null;
71
+ const limiter = configured ?? memoryLimiter;
72
+ const names = Object.keys(RULES).filter((n) => n.startsWith(`${action}:`));
73
+ const needsIp = names.some((n) => n.endsWith(":ip") || n.endsWith(":pair"));
74
+ const ip = needsIp ? await callerIp() : "";
75
+ let worst = 0;
76
+ for (const name of names) {
77
+ const scope = name.slice(action.length + 1);
78
+ const key = scope === "ip" ? ip : scope === "pair" ? subject ? `${subject}|${ip}` : "" : subject;
79
+ if (!key) continue;
80
+ try {
81
+ const res = await limiter({ name, key: key.toLowerCase(), ...ruleFor(name) });
82
+ if (!res.allowed) worst = Math.max(worst, res.retryAfterSeconds ?? 60);
83
+ } catch (e) {
84
+ console.error("Guardrail: rate limiter failed, refusing the request:", e);
85
+ return "Something went wrong. Try again in a moment.";
86
+ }
87
+ }
88
+ return worst > 0 ? `Too many attempts. Try again in ${waitLabel(worst)}.` : null;
89
+ }
90
+ export {
91
+ memoryLimiter,
92
+ rateLimited
93
+ };
94
+ //# sourceMappingURL=rateLimit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/server/rateLimit.ts"],"sourcesContent":["import { headers } from \"next/headers\";\nimport { getClientIpResolver, getRateLimiter, getRateLimitRules } from \"../core/config\";\nimport type { GRRateLimitHit, GRRateLimitName } from \"../core/adapter-types\";\n\n/**\n * Rate limits for the endpoints that cost something or can be guessed at.\n * Server Actions are public HTTP endpoints, so the sign-in form, \"Forgot\n * password?\", the emailed-link exchange and the email-sending admin actions\n * all need a ceiling; nothing else in the library is worth throttling.\n *\n * Not a \"use server\" file, so none of this is callable from the browser.\n */\n\ntype Rule = { limit: number; windowSeconds: number };\n\nconst RULES = {\n // Credential stuffing, without handing anyone the power to lock a person out.\n // The tight limit is per account *and* address, so someone hammering an\n // account only exhausts their own pairing; the per-account ceiling is higher,\n // enough to stop a distributed attack without being a few requests away.\n \"login:pair\": { limit: 8, windowSeconds: 15 * 60 },\n \"login:account\": { limit: 40, windowSeconds: 15 * 60 },\n \"login:ip\": { limit: 30, windowSeconds: 15 * 60 },\n // Mail bombing: per address from one place, and per inbox from anywhere. The\n // per-address cooldown in actions.ts is in memory only; these hold across\n // serverless instances.\n \"forgot-password:ip\": { limit: 10, windowSeconds: 60 * 60 },\n \"forgot-password:account\": { limit: 5, windowSeconds: 60 * 60 },\n // Guessing one-time tokens (long and random, so this is belt and braces).\n \"auth-link:ip\": { limit: 30, windowSeconds: 15 * 60 },\n // Signed-in actions that send mail or create storage objects.\n \"invite:user\": { limit: 30, windowSeconds: 60 * 60 },\n \"password-reset:user\": { limit: 30, windowSeconds: 60 * 60 },\n \"upload:user\": { limit: 60, windowSeconds: 10 * 60 },\n} as const satisfies Record<GRRateLimitName, Rule>;\n\nexport type RateLimitedAction = GRRateLimitName;\n\n/** The default rule with any valid override from the site's config applied. */\nfunction ruleFor(name: RateLimitedAction): Rule {\n const base = RULES[name];\n const override = getRateLimitRules()?.[name];\n const whole = (n: unknown): n is number => typeof n === \"number\" && Number.isInteger(n) && n > 0;\n return {\n limit: whole(override?.limit) ? override.limit : base.limit,\n windowSeconds: whole(override?.windowSeconds) ? override.windowSeconds : base.windowSeconds,\n };\n}\n\n/** Fixed-window counters, per server instance. See `rateLimit` in GRConfig for shared state. */\nconst buckets = new Map<string, { count: number; resetAt: number }>();\n\nexport function memoryLimiter(hit: GRRateLimitHit): { allowed: boolean; retryAfterSeconds?: number } {\n const now = Date.now();\n const id = `${hit.name}|${hit.key}`;\n let bucket = buckets.get(id);\n if (!bucket || bucket.resetAt <= now) {\n bucket = { count: 0, resetAt: now + hit.windowSeconds * 1000 };\n buckets.set(id, bucket);\n if (buckets.size > 5000) {\n for (const [k, b] of buckets) if (b.resetAt <= now) buckets.delete(k);\n }\n }\n bucket.count += 1;\n return bucket.count <= hit.limit\n ? { allowed: true }\n : { allowed: false, retryAfterSeconds: Math.ceil((bucket.resetAt - now) / 1000) };\n}\n\n/**\n * The caller's address, as the platform reports it — or as the site's own\n * `clientIp` option says (see GRConfig). Behind a proxy that overwrites\n * X-Forwarded-For this is trustworthy; on a bare server a client can write it\n * themselves, which is why the account-keyed limits exist alongside the\n * address-keyed ones.\n */\nasync function callerIp(): Promise<string> {\n const h = await headers();\n const custom = getClientIpResolver();\n if (custom) {\n try {\n const chosen = (await custom(h))?.trim();\n if (chosen) return chosen;\n } catch (e) {\n console.error(\"Guardrail: clientIp threw, using the default:\", e);\n }\n }\n const forwarded = h.get(\"x-forwarded-for\")?.split(\",\")[0]?.trim();\n return forwarded || h.get(\"x-real-ip\")?.trim() || \"unknown\";\n}\n\n/** Human wording for a wait, e.g. \"about 12 minutes\". */\nfunction waitLabel(seconds: number): string {\n if (seconds < 90) return \"a minute\";\n const minutes = Math.ceil(seconds / 60);\n if (minutes < 60) return `about ${minutes} minutes`;\n const hours = Math.ceil(minutes / 60);\n return `about ${hours} ${hours === 1 ? \"hour\" : \"hours\"}`;\n}\n\n/**\n * Counts one attempt against each named limit, returning a message to show if\n * any is spent, or null if the request may proceed. Call it before the\n * expensive work. Fails closed: a custom limiter that throws blocks the\n * request rather than waving everything through.\n *\n * `subject` is the account (email) or signed-in user the \"account\" / \"user\"\n * rule is keyed on; \"ip\" rules key on the caller's address.\n */\nexport async function rateLimited(\n action: \"login\" | \"forgot-password\" | \"auth-link\" | \"invite\" | \"password-reset\" | \"upload\",\n subject?: string,\n): Promise<string | null> {\n const configured = getRateLimiter();\n if (configured === false) return null;\n const limiter = configured ?? memoryLimiter;\n\n const names = (Object.keys(RULES) as RateLimitedAction[]).filter((n) => n.startsWith(`${action}:`));\n const needsIp = names.some((n) => n.endsWith(\":ip\") || n.endsWith(\":pair\"));\n const ip = needsIp ? await callerIp() : \"\";\n let worst = 0;\n for (const name of names) {\n const scope = name.slice(action.length + 1);\n // \"pair\" counts one account from one address; the others count one thing.\n const key = scope === \"ip\" ? ip : scope === \"pair\" ? (subject ? `${subject}|${ip}` : \"\") : subject;\n if (!key) continue;\n try {\n const res = await limiter({ name, key: key.toLowerCase(), ...ruleFor(name) });\n if (!res.allowed) worst = Math.max(worst, res.retryAfterSeconds ?? 60);\n } catch (e) {\n console.error(\"Guardrail: rate limiter failed, refusing the request:\", e);\n return \"Something went wrong. Try again in a moment.\";\n }\n }\n return worst > 0 ? `Too many attempts. Try again in ${waitLabel(worst)}.` : null;\n}\n"],"mappings":"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","names":[]}
@@ -2,12 +2,17 @@
2
2
  import { revalidatePath } from "next/cache";
3
3
  import { getDB, getRepeaters, resolveSiteId } from "../core/config";
4
4
  import { requireRole } from "./access";
5
+ import { validId, validIdList } from "../core/validate";
5
6
  import { rethrowIfBailout } from "../core/bailout";
6
7
  const DENIED = { success: false, error: "Unauthorized." };
7
8
  async function addRepeaterItem(repeaterId, options) {
8
9
  const viewer = await requireRole(["admin", "editor"]);
9
10
  if (!viewer) return { success: false, error: "Unauthorized." };
11
+ if (!validId(repeaterId)) return { success: false, error: "Invalid repeater." };
10
12
  if (options?.slug !== void 0) {
13
+ if (typeof options.slug !== "string" || options.slug.length > 200) {
14
+ return { success: false, error: "Use lowercase letters, numbers, and hyphens only." };
15
+ }
11
16
  const slug = options.slug.trim();
12
17
  if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) {
13
18
  return {
@@ -31,6 +36,7 @@ async function addRepeaterItem(repeaterId, options) {
31
36
  async function removeRepeaterItem(repeaterId, itemId) {
32
37
  const viewer = await requireRole(["admin", "editor"]);
33
38
  if (!viewer) return DENIED;
39
+ if (!validId(repeaterId) || !validId(itemId)) return { success: false, error: "Invalid item." };
34
40
  try {
35
41
  const siteId = await resolveSiteId();
36
42
  const repeaters = getRepeaters();
@@ -49,12 +55,14 @@ async function removeRepeaterItem(repeaterId, itemId) {
49
55
  async function reorderRepeaterItems(repeaterId, orderedItemIds) {
50
56
  const viewer = await requireRole(["admin", "editor"]);
51
57
  if (!viewer) return DENIED;
58
+ const ids = validIdList(orderedItemIds);
59
+ if (!validId(repeaterId) || !ids) return { success: false, error: "Invalid order." };
52
60
  try {
53
61
  const repeaters = getRepeaters();
54
62
  const res = await repeaters.reorderItems(
55
63
  repeaterId,
56
64
  await resolveSiteId(),
57
- orderedItemIds
65
+ ids
58
66
  );
59
67
  if (res.success) revalidatePath("/", "layout");
60
68
  return res;