sporades 0.9.8 โ†’ 0.9.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGES.md CHANGED
@@ -4,7 +4,26 @@
4
4
 
5
5
  Changes since v0.9.7.
6
6
 
7
- No changes detected.
7
+ ### ๐Ÿš€ Features
8
+
9
+ - Add purpose-bound reauthentication proofs (c4ddd032).
10
+
11
+ ### ๐Ÿ“ Documentation
12
+
13
+ - Request Google signed reauthentication time (2fa291dc).
14
+ - Verify OAuth reauthentication freshness (7a6c989a).
15
+ - Persist and serialize email reauthentication (74ef1c50).
16
+ - Harden email reauthentication contracts (a919df94).
17
+ - Sweep expired proofs before guarded mutations (c888d70c).
18
+ - Harden reauthentication lifecycle (c8189c58).
19
+ - Bind reauthentication to active sessions (e692028f).
20
+
21
+ ### ๐Ÿงช Tests
22
+
23
+ - Retire proofs during Session rotation (b1e25b48).
24
+ - Harden reauthentication failure and ordering proof (8f933b91).
25
+ - Require active User for proof consumption (465bf5a4).
26
+ - Recheck OAuth authorization at callback (19c4159c).
8
27
 
9
28
  ## Unreleased - 2026-08-26
10
29
 
@@ -51,5 +70,3 @@ generated artifacts, and documentation.
51
70
  not select a current Team or automatically partition Capsule data; Sporades
52
71
  never sends Join-link email. See the [Built-in Teams reference](https://mgscox.github.io/sporades/reference/teams).
53
72
 
54
-
55
-
@@ -622,7 +622,7 @@ function writeEnvelope(result, failed = false) {
622
622
  }
623
623
 
624
624
  // src/cli/cli-version.ts
625
- var CLI_VERSION = "0.9.8";
625
+ var CLI_VERSION = "0.9.9";
626
626
 
627
627
  // src/cli/schedule-inspection-envelope.ts
628
628
  var SCHEDULE_DIAGNOSTIC_FIELDS = /* @__PURE__ */ new Set([
package/bin/sporades.js CHANGED
@@ -210,6 +210,9 @@ export const auth = {
210
210
  signIn(provider, credentials, options) {
211
211
  return connect().signIn(provider, credentials, options);
212
212
  },
213
+ reauthenticate(provider, credentials, purpose) {
214
+ return connect().reauthenticate(provider, credentials, purpose);
215
+ },
213
216
  signOut() {
214
217
  return connect().signOut();
215
218
  },
@@ -311,6 +314,9 @@ export function createHooks(primitives) {
311
314
  signIn(provider, credentials, options) {
312
315
  return connect().signIn(provider, credentials, options);
313
316
  },
317
+ reauthenticate(provider, credentials, purpose) {
318
+ return connect().reauthenticate(provider, credentials, purpose);
319
+ },
314
320
  signOut() {
315
321
  return connect().signOut();
316
322
  },
@@ -372,6 +378,7 @@ export function createVueComposables(primitives) {
372
378
  state.isAuthenticated = () => Boolean(state.auth?.isAuthenticated);
373
379
  state.signUp = (provider, credentials, options) => connect().signUp(provider, credentials, options);
374
380
  state.signIn = (provider, credentials, options) => connect().signIn(provider, credentials, options);
381
+ state.reauthenticate = (provider, credentials, purpose) => connect().reauthenticate(provider, credentials, purpose);
375
382
  state.signOut = () => connect().signOut();
376
383
  state.setPassword = (email, currentPassword, newPassword) => connect().setPassword(email, currentPassword, newPassword);
377
384
  return state;
@@ -425,6 +432,7 @@ export function createSolidPrimitives(primitives) {
425
432
  isAuthenticated: () => Boolean(state().auth?.isAuthenticated),
426
433
  signUp: (provider, credentials, options) => connect().signUp(provider, credentials, options),
427
434
  signIn: (provider, credentials, options) => connect().signIn(provider, credentials, options),
435
+ reauthenticate: (provider, credentials, purpose) => connect().reauthenticate(provider, credentials, purpose),
428
436
  signOut: () => connect().signOut(),
429
437
  setPassword: (email, currentPassword, newPassword) => connect().setPassword(email, currentPassword, newPassword),
430
438
  };
@@ -524,6 +532,7 @@ export function createLitControllers() {
524
532
  controller.isAuthenticated = () => Boolean(controller.state.auth?.isAuthenticated);
525
533
  controller.signUp = (provider, credentials, options) => connect().signUp(provider, credentials, options);
526
534
  controller.signIn = (provider, credentials, options) => connect().signIn(provider, credentials, options);
535
+ controller.reauthenticate = (provider, credentials, purpose) => connect().reauthenticate(provider, credentials, purpose);
527
536
  controller.signOut = () => connect().signOut();
528
537
  controller.setPassword = (email, currentPassword, newPassword) => connect().setPassword(email, currentPassword, newPassword);
529
538
  return controller;
@@ -600,6 +609,7 @@ export function createInfernoAdapters() {
600
609
  adapter.isAuthenticated = () => Boolean(adapter.state.auth?.isAuthenticated);
601
610
  adapter.signUp = (provider, credentials, options) => connect().signUp(provider, credentials, options);
602
611
  adapter.signIn = (provider, credentials, options) => connect().signIn(provider, credentials, options);
612
+ adapter.reauthenticate = (provider, credentials, purpose) => connect().reauthenticate(provider, credentials, purpose);
603
613
  adapter.signOut = () => connect().signOut();
604
614
  adapter.setPassword = (email, currentPassword, newPassword) => connect().setPassword(email, currentPassword, newPassword);
605
615
  return adapter;
@@ -651,6 +661,7 @@ export function createSvelteStores() {
651
661
  subscribe: store.subscribe,
652
662
  signUp: (provider, credentials, options) => connect().signUp(provider, credentials, options),
653
663
  signIn: (provider, credentials, options) => connect().signIn(provider, credentials, options),
664
+ reauthenticate: (provider, credentials, purpose) => connect().reauthenticate(provider, credentials, purpose),
654
665
  signOut: () => connect().signOut(),
655
666
  setPassword: (email, currentPassword, newPassword) => connect().setPassword(email, currentPassword, newPassword),
656
667
  };
@@ -1165,6 +1176,9 @@ function createConnection() {
1165
1176
  return result;
1166
1177
  });
1167
1178
  },
1179
+ reauthenticate(provider, credentials, purpose) {
1180
+ return request("auth.reauthenticate", { provider, credentials, purpose, returnTo: window.location.href }).then((result) => { if (result.data?.url) window.location.assign(result.data.url); return result; });
1181
+ },
1168
1182
  signOut() {
1169
1183
  return request("auth.signOut").then(async (result) => {
1170
1184
  if (!result.error && result.data?.ok === true) {
@@ -2282,8 +2296,8 @@ function readAuthRequirements(handler) {
2282
2296
  return typeof handler === "function" ? handler[AUTH_REQUIREMENTS] ?? null : null;
2283
2297
  }
2284
2298
  function normalizeAuthRequirements(options = {}) {
2285
- if (!isPlainObject(options) || Object.keys(options).some((key) => !["linked", "credentials", "scopes"].includes(key))) {
2286
- throw invalidAuthRequirements("Use only linked, credentials, and scopes in a declarative Auth requirement.");
2299
+ if (!isPlainObject(options) || Object.keys(options).some((key) => !["linked", "credentials", "scopes", "reauthentication"].includes(key))) {
2300
+ throw invalidAuthRequirements("Use only linked, credentials, scopes, and reauthentication in a declarative Auth requirement.");
2287
2301
  }
2288
2302
  if ("linked" in options && typeof options.linked !== "boolean") {
2289
2303
  throw invalidAuthRequirements("linked must be a boolean when supplied.");
@@ -2294,10 +2308,13 @@ function normalizeAuthRequirements(options = {}) {
2294
2308
  code: "INVALID_AUTH_REQUIREMENTS",
2295
2309
  hint: "Required scopes must be unique concrete strings declared by the Capsule."
2296
2310
  });
2311
+ const reauthentication = options.reauthentication;
2312
+ if (reauthentication !== void 0 && (typeof reauthentication !== "string" || !/^[a-z][a-z0-9-]{0,63}$/.test(reauthentication))) throw invalidAuthRequirements("reauthentication must be a declared concrete purpose.");
2297
2313
  return Object.freeze({
2298
2314
  linked: options.linked === true,
2299
2315
  credentials: Object.freeze(credentials),
2300
- scopes: Object.freeze(scopes)
2316
+ scopes: Object.freeze(scopes),
2317
+ reauthentication: reauthentication ?? null
2301
2318
  });
2302
2319
  }
2303
2320
  function normalizeCapsuleAuthDefinition(definition) {
@@ -2323,10 +2340,17 @@ function normalizeCapsuleAuthDefinition(definition) {
2323
2340
  }
2324
2341
  if (Object.hasOwn(normalized, "auth") && normalized.auth !== void 0) {
2325
2342
  const registration = normalized.auth?.registration;
2326
- if (!isPlainObject(normalized.auth) || Object.keys(normalized.auth).some((key) => key !== "registration") || !isPlainObject(registration) || typeof registration.admit !== "function" || typeof registration.finalize !== "function" || Object.keys(registration).some((key) => key !== "admit" && key !== "finalize")) {
2343
+ const reauthentication = normalized.auth?.reauthentication;
2344
+ if (!isPlainObject(normalized.auth) || Object.keys(normalized.auth).some((key) => !["registration", "reauthentication"].includes(key)) || registration !== void 0 && (!isPlainObject(registration) || typeof registration.admit !== "function" || typeof registration.finalize !== "function" || Object.keys(registration).some((key) => key !== "admit" && key !== "finalize"))) {
2327
2345
  throw commandError("Invalid Capsule Registration Admission declaration.", "Declare auth.registration with both admit and finalize server functions.", "INVALID_REGISTRATION_ADMISSION");
2328
2346
  }
2329
- normalized = { ...normalized, auth: Object.freeze({ registration: Object.freeze(registration) }) };
2347
+ if (reauthentication !== void 0 && (!isPlainObject(reauthentication) || Object.keys(reauthentication).some((key) => !["purposes", "authorize"].includes(key)) || reauthentication.authorize !== void 0 && typeof reauthentication.authorize !== "function" || !isPlainObject(reauthentication.purposes) || Object.keys(reauthentication.purposes).length < 1 || Object.entries(reauthentication.purposes).some(([purpose, policy]) => !/^[a-z][a-z0-9-]{0,63}$/.test(purpose) || !isPlainObject(policy) || Object.keys(policy).some((key) => key !== "maxAgeSeconds") || !Number.isInteger(policy.maxAgeSeconds) || policy.maxAgeSeconds < 1 || policy.maxAgeSeconds > 900))) throw commandError("Invalid Reauthentication declaration.", "Declare concrete purposes with maxAgeSeconds from 1 through 900 and an optional authorize callback.", "INVALID_REAUTHENTICATION_DECLARATION");
2348
+ normalized = { ...normalized, auth: Object.freeze({ ...registration ? { registration: Object.freeze(registration) } : {}, ...reauthentication ? { reauthentication: Object.freeze({ purposes: Object.freeze(Object.fromEntries(Object.entries(reauthentication.purposes).map(([purpose, policy]) => [purpose, Object.freeze({ maxAgeSeconds: policy.maxAgeSeconds })]))), ...reauthentication.authorize ? { authorize: reauthentication.authorize } : {} }) } : {} }) };
2349
+ }
2350
+ const declaredPurposes = new Set(Object.keys(normalized.auth?.reauthentication?.purposes ?? {}));
2351
+ for (const item of Object.values(normalized.mutations ?? {})) {
2352
+ const purpose = readAuthRequirements(item?.handler)?.reauthentication;
2353
+ if (purpose && !declaredPurposes.has(purpose)) throw invalidAuthRequirements("Every reauthentication purpose must be declared by the Capsule.");
2330
2354
  }
2331
2355
  return normalizeFileAccessKeyPolicy(normalized);
2332
2356
  }
@@ -2373,12 +2397,15 @@ function normalizeFileAccessKeyPolicy(definition) {
2373
2397
  }
2374
2398
  function validateCapsuleAuthRequirements(definition) {
2375
2399
  const declaredScopes = new Set(definition.accessKeys?.scopes ?? []);
2376
- for (const collection of [definition.queries, definition.mutations, definition.endpoints, definition.messages]) {
2400
+ for (const [kind, collection] of [["query", definition.queries], ["mutation", definition.mutations], ["endpoint", definition.endpoints], ["message", definition.messages]]) {
2377
2401
  for (const item of Object.values(collection ?? {})) {
2378
2402
  const requirements = readAuthRequirements(item?.handler);
2379
2403
  if (!requirements) {
2380
2404
  continue;
2381
2405
  }
2406
+ if (requirements.reauthentication && kind !== "mutation") {
2407
+ throw invalidAuthRequirements("Reauthentication proofs may only guard mutations.");
2408
+ }
2382
2409
  for (const scope of requirements.scopes) {
2383
2410
  if (!declaredScopes.has(scope)) {
2384
2411
  throw invalidAuthRequirements("Every required scope must be declared in capsule({ accessKeys: { scopes } }).");
@@ -2479,8 +2506,8 @@ function normalizeUserAuthOptions(options = {}) {
2479
2506
  }
2480
2507
 
2481
2508
  function normalizeGuardOptions(options = {}) {
2482
- if (!plainObject(options) || Object.keys(options).some((key) => !["linked", "credentials", "scopes"].includes(key))) {
2483
- throw authRequirementsError("Use only linked, credentials, and scopes in a declarative Auth requirement.");
2509
+ if (!plainObject(options) || Object.keys(options).some((key) => !["linked", "credentials", "scopes", "reauthentication"].includes(key))) {
2510
+ throw authRequirementsError("Use only linked, credentials, scopes, and reauthentication in a declarative Auth requirement.");
2484
2511
  }
2485
2512
  if ("linked" in options && typeof options.linked !== "boolean") throw authRequirementsError("linked must be a boolean when supplied.");
2486
2513
  const credentials = options.credentials === undefined ? ["session", "access-key"] : options.credentials;
@@ -2491,7 +2518,9 @@ function normalizeGuardOptions(options = {}) {
2491
2518
  if (!Array.isArray(scopes) || (options.scopes !== undefined && scopes.length === 0) || scopes.length > 1024 || scopes.some((scope) => typeof scope !== "string" || scope.length === 0 || scope.includes("*") || new TextEncoder().encode(scope).byteLength > 256) || new Set(scopes).size !== scopes.length) {
2492
2519
  throw authRequirementsError("Required scopes must be unique concrete strings declared by the Capsule.");
2493
2520
  }
2494
- return Object.freeze({ linked: options.linked === true, credentials: Object.freeze([...credentials]), scopes: Object.freeze([...scopes]) });
2521
+ const reauthentication = options.reauthentication;
2522
+ if (reauthentication !== undefined && (typeof reauthentication !== "string" || !/^[a-z][a-z0-9-]{0,63}$/.test(reauthentication))) throw authRequirementsError("reauthentication must be a declared concrete purpose.");
2523
+ return Object.freeze({ linked: options.linked === true, credentials: Object.freeze([...credentials]), scopes: Object.freeze([...scopes]), reauthentication: reauthentication ?? null });
2495
2524
  }
2496
2525
 
2497
2526
  function decorateAuth(options, handler) {
@@ -12767,6 +12796,7 @@ function createGoogleOAuthProviderAdapter(database) {
12767
12796
  provider: "google",
12768
12797
  responseMode: "query",
12769
12798
  enabled: configured,
12799
+ reauthenticationFreshness: "signed-auth-time",
12770
12800
  begin(context) {
12771
12801
  const clientId = database.serverEnv[google.clientIdEnv];
12772
12802
  const params = new URLSearchParams({
@@ -12779,6 +12809,10 @@ function createGoogleOAuthProviderAdapter(database) {
12779
12809
  code_challenge: context.pkceChallenge,
12780
12810
  code_challenge_method: "S256"
12781
12811
  });
12812
+ if (context.reauthentication) {
12813
+ params.set("max_age", "0");
12814
+ params.set("claims", JSON.stringify({ id_token: { auth_time: { essential: true } } }));
12815
+ }
12782
12816
  return { url: `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}` };
12783
12817
  },
12784
12818
  complete(context) {
@@ -12814,6 +12848,7 @@ function createAppleOAuthProviderAdapter(database) {
12814
12848
  provider: "apple",
12815
12849
  responseMode: "form_post",
12816
12850
  enabled: configured,
12851
+ reauthenticationFreshness: null,
12817
12852
  begin(context) {
12818
12853
  if (!appleOAuthOriginEligible(new URL(context.redirectUri).origin)) {
12819
12854
  throw commandError(
@@ -12831,6 +12866,7 @@ function createAppleOAuthProviderAdapter(database) {
12831
12866
  state: context.state,
12832
12867
  nonce: context.nonce
12833
12868
  });
12869
+ if (context.reauthentication) params.set("prompt", "login");
12834
12870
  return { url: `https://appleid.apple.com/auth/authorize?${params.toString()}` };
12835
12871
  },
12836
12872
  complete(context) {
@@ -12949,6 +12985,7 @@ function createFacebookOAuthProviderAdapter(database) {
12949
12985
  provider: "facebook",
12950
12986
  responseMode: "query",
12951
12987
  enabled: configured,
12988
+ reauthenticationFreshness: null,
12952
12989
  begin(context) {
12953
12990
  const clientId = database.serverEnv[facebook.clientIdEnv];
12954
12991
  if (typeof clientId !== "string" || clientId.length < 1 || clientId.length > 4096) {
@@ -12965,6 +13002,7 @@ function createFacebookOAuthProviderAdapter(database) {
12965
13002
  scope: "public_profile,email",
12966
13003
  state: context.state
12967
13004
  });
13005
+ if (context.reauthentication) params.set("auth_type", "reauthorize");
12968
13006
  const authorizationUrl = facebookOAuthEndpoint(
12969
13007
  process.env.SPORADES_FACEBOOK_AUTH_URL,
12970
13008
  `https://www.facebook.com/${graphVersion}/dialog/oauth`
@@ -13303,7 +13341,8 @@ async function verifyGoogleIdentityToken(database, token, expectedNonce) {
13303
13341
  email: normalizeSimulatedText(claims.email)?.toLowerCase() ?? null,
13304
13342
  emailVerified: claims.email_verified === true,
13305
13343
  displayName: normalizeSimulatedText(claims.name) ?? normalizeSimulatedText(claims.email) ?? "Google user",
13306
- picture: normalizeSimulatedText(claims.picture)
13344
+ picture: normalizeSimulatedText(claims.picture),
13345
+ reauthenticatedAt: Number.isSafeInteger(claims.auth_time) && claims.auth_time >= 0 ? new Date(claims.auth_time * 1e3).toISOString() : null
13307
13346
  };
13308
13347
  }
13309
13348
  function createMicrosoftOAuthProviderAdapter(database) {
@@ -13313,6 +13352,7 @@ function createMicrosoftOAuthProviderAdapter(database) {
13313
13352
  provider: "microsoft",
13314
13353
  responseMode: "query",
13315
13354
  enabled: configured,
13355
+ reauthenticationFreshness: "signed-auth-time",
13316
13356
  async begin(context) {
13317
13357
  const discovery = await discoverMicrosoftOpenIdConfiguration(database, microsoft.tenant);
13318
13358
  const clientId = database.serverEnv[microsoft.clientIdEnv];
@@ -13327,6 +13367,10 @@ function createMicrosoftOAuthProviderAdapter(database) {
13327
13367
  code_challenge: context.pkceChallenge,
13328
13368
  code_challenge_method: "S256"
13329
13369
  });
13370
+ if (context.reauthentication) {
13371
+ params.set("prompt", "login");
13372
+ params.set("max_age", "0");
13373
+ }
13330
13374
  return { url: `${discovery.authorization_endpoint}?${params.toString()}` };
13331
13375
  },
13332
13376
  complete(context) {
@@ -13590,7 +13634,7 @@ async function verifyMicrosoftIdentityToken(database, token, expectedNonce, disc
13590
13634
  const numericDate = (value) => Number.isSafeInteger(value) && value >= 0;
13591
13635
  const optionalNumericDate = (value) => value === void 0 || numericDate(value);
13592
13636
  const optionalProfile = (value, max) => value === void 0 || value === null || typeof value === "string" && value.length <= max;
13593
- const structurallyValid = header2.alg === "RS256" && visible(header2.kid, 255) && visible(claims.iss, 2048) && validAudience && numericDate(claims.exp) && optionalNumericDate(claims.nbf) && optionalNumericDate(claims.iat) && visible(claims.nonce, 512) && typeof claims.tid === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(claims.tid) && visible(claims.sub, 255) && optionalProfile(claims.email, 1024) && optionalProfile(claims.name, 1024) && optionalProfile(claims.preferred_username, 1024);
13637
+ const structurallyValid = header2.alg === "RS256" && visible(header2.kid, 255) && visible(claims.iss, 2048) && validAudience && numericDate(claims.exp) && optionalNumericDate(claims.nbf) && optionalNumericDate(claims.iat) && optionalNumericDate(claims.auth_time) && visible(claims.nonce, 512) && typeof claims.tid === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(claims.tid) && visible(claims.sub, 255) && optionalProfile(claims.email, 1024) && optionalProfile(claims.name, 1024) && optionalProfile(claims.preferred_username, 1024);
13594
13638
  if (!structurallyValid) {
13595
13639
  throw commandError("Microsoft identity token was invalid.", "Retry Microsoft sign-in.", "OAUTH_ID_TOKEN_INVALID");
13596
13640
  }
@@ -13629,7 +13673,8 @@ async function verifyMicrosoftIdentityToken(database, token, expectedNonce, disc
13629
13673
  email,
13630
13674
  emailVerified: null,
13631
13675
  displayName: normalizeSimulatedText(claims.name) ?? normalizeSimulatedText(claims.preferred_username) ?? email ?? "Microsoft user",
13632
- picture: null
13676
+ picture: null,
13677
+ reauthenticatedAt: Number.isSafeInteger(claims.auth_time) && claims.auth_time >= 0 ? new Date(claims.auth_time * 1e3).toISOString() : null
13633
13678
  };
13634
13679
  }
13635
13680
  async function verifyAppleIdentityToken(database, token, expectedNonce) {
@@ -14377,7 +14422,7 @@ async function resolveAnonymousSession(database, sessionToken) {
14377
14422
  const existing = await database.adapter.readAuthSessionWithUser(sessionToken);
14378
14423
  if (existing) {
14379
14424
  if (isExpiredSession(existing)) {
14380
- await database.adapter.deleteAuthSession(sessionToken);
14425
+ await database.adapter.withTransaction((tx) => tx.deleteAuthSession(sessionToken));
14381
14426
  } else {
14382
14427
  return sessionFromRow(existing);
14383
14428
  }
@@ -14933,8 +14978,38 @@ async function routeSporadesAuth(database, request, response) {
14933
14978
  redirectUri: stateRow.redirectUri,
14934
14979
  nonce: stateRow.nonce,
14935
14980
  pkceVerifier: stateRow.pkceVerifier,
14936
- parameters
14981
+ parameters,
14982
+ reauthentication: Boolean(stateRow.reauthPurpose)
14937
14983
  });
14984
+ if (stateRow.reauthPurpose) {
14985
+ const subject = normalizeSimulatedText(profile?.subject ?? profile?.sub);
14986
+ const policy = database.reauthenticationPolicy?.[stateRow.reauthPurpose];
14987
+ const now2 = database.clock.now();
14988
+ const authenticatedAt = typeof profile?.reauthenticatedAt === "string" ? Date.parse(profile.reauthenticatedAt) : NaN;
14989
+ const flowStartedAt = Date.parse(stateRow.createdAt);
14990
+ const freshnessVerified = adapter.reauthenticationFreshness === "signed-auth-time" && Number.isFinite(authenticatedAt) && Number.isFinite(flowStartedAt) && authenticatedAt >= flowStartedAt - 1e3 && authenticatedAt <= now2.getTime() + 6e4;
14991
+ let authorized = false;
14992
+ try {
14993
+ authorized = await database.adapter.withTransaction(async (tx) => {
14994
+ const current = await tx.readAuthSessionWithUser(stateRow.sessionToken);
14995
+ const identity = subject ? await tx.findAuthIdentityByProviderSubject(provider, subject) : null;
14996
+ if (!freshnessVerified || !policy || !current || current.token !== stateRow.sessionToken || current.userId !== stateRow.reauthUserId || !current.isAuthenticated || current.isGuest || Date.parse(current.expiresAt) <= now2.getTime() || identity?.userId !== current.userId) return false;
14997
+ const currentAuth = sessionFromRow(current).auth;
14998
+ if (!await database.authorizeReauthentication(tx, currentAuth, stateRow.reauthPurpose)) return false;
14999
+ await tx.replaceReauthenticationProof({ id: nodeCryptoModule3.randomUUID(), userId: current.userId, sessionToken: stateRow.sessionToken, purpose: stateRow.reauthPurpose, createdAt: now2.toISOString(), expiresAt: new Date(now2.getTime() + policy.maxAgeSeconds * 1e3).toISOString() });
15000
+ return true;
15001
+ });
15002
+ } catch {
15003
+ try {
15004
+ await database.log?.emit?.({ category: "platform", event: "auth.reauthentication.authorization_failed", level: "error", message: "Reauthentication authorization policy failed.", data: { provider, purpose: stateRow.reauthPurpose } });
15005
+ } catch {
15006
+ }
15007
+ throw commandError("Reauthentication failed.", "Verify the current linked identity and retry.", "REAUTHENTICATION_FAILED");
15008
+ }
15009
+ if (!authorized) throw commandError("Reauthentication failed.", "Verify the current linked identity and retry.", "REAUTHENTICATION_FAILED");
15010
+ writeRedirect(response, stateRow.returnTo);
15011
+ return true;
15012
+ }
14938
15013
  const session = await resolveAnonymousSession(database, stateRow.sessionToken);
14939
15014
  let result;
14940
15015
  try {
@@ -15023,6 +15098,8 @@ async function beginOAuthSignIn(database, session, provider, options) {
15023
15098
  const admission = boundedRegistrationInput(options.registration?.admission);
15024
15099
  if (admission === invalidRegistrationAdmission) return registrationDenied();
15025
15100
  const registrationCiphertext = admission === void 0 ? null : await sealOAuthRegistration(database, admission, { provider, sessionToken: session.token, redirectUri, nonce, expiresAt });
15101
+ const reauthentication = options.reauthentication;
15102
+ if (reauthentication?.purpose && adapter.reauthenticationFreshness !== "signed-auth-time") return { ok: false, error: { code: "REAUTHENTICATION_FAILED", message: "Reauthentication failed.", hint: "Verify the current linked identity and retry." } };
15026
15103
  let started;
15027
15104
  try {
15028
15105
  started = await adapter.begin({
@@ -15031,7 +15108,8 @@ async function beginOAuthSignIn(database, session, provider, options) {
15031
15108
  nonce,
15032
15109
  redirectUri,
15033
15110
  pkceChallenge,
15034
- pkceChallengeMethod: "S256"
15111
+ pkceChallengeMethod: "S256",
15112
+ reauthentication: Boolean(reauthentication?.purpose)
15035
15113
  });
15036
15114
  } catch {
15037
15115
  return {
@@ -15063,7 +15141,9 @@ async function beginOAuthSignIn(database, session, provider, options) {
15063
15141
  expiresAt,
15064
15142
  nonce,
15065
15143
  pkceVerifier,
15066
- registrationCiphertext
15144
+ registrationCiphertext,
15145
+ reauthPurpose: reauthentication?.purpose ?? null,
15146
+ reauthUserId: reauthentication?.userId ?? null
15067
15147
  });
15068
15148
  return { ok: true, url: started.url };
15069
15149
  }
@@ -15248,6 +15328,13 @@ function createAnonymousAuthTables(sqlite, _authConfig = null) {
15248
15328
  ),
15249
15329
  () => ensureSessionLifecycleColumns(sqlite),
15250
15330
  () => ensureSessionProvenanceColumn(sqlite),
15331
+ () => sqlite.exec(sql(
15332
+ "CREATE TABLE IF NOT EXISTS [sporades_auth_reauthentication_proofs] ([id] TEXT PRIMARY KEY, [userId] TEXT NOT NULL, [sessionToken] TEXT NOT NULL, [purpose] TEXT NOT NULL, [createdAt] TEXT NOT NULL, [expiresAt] TEXT NOT NULL, UNIQUE ([sessionToken], [purpose]))"
15333
+ )),
15334
+ () => sqlite.exec(sql("CREATE TABLE IF NOT EXISTS [sporades_auth_reauthentication_throttle_fence] ([id] TEXT PRIMARY KEY, [version] INTEGER NOT NULL)")),
15335
+ () => sqlite.prepare(sql("INSERT INTO [sporades_auth_reauthentication_throttle_fence] ([id], [version]) VALUES ('email', 0) ON CONFLICT ([id]) DO NOTHING")).run(),
15336
+ () => sqlite.exec(sql("CREATE TABLE IF NOT EXISTS [sporades_auth_reauthentication_throttle] ([key] TEXT PRIMARY KEY, [count] INTEGER NOT NULL, [resetAt] TEXT NOT NULL)")),
15337
+ () => sqlite.prepare(sql("DELETE FROM [sporades_auth_reauthentication_proofs] WHERE [expiresAt] <= ? OR NOT EXISTS (SELECT 1 FROM [sporades_auth_sessions] [s] WHERE [s].[token] = [sporades_auth_reauthentication_proofs].[sessionToken])")).run((/* @__PURE__ */ new Date()).toISOString()),
15251
15338
  () => createProviderIdentityTables(sqlite),
15252
15339
  () => sqlite.exec(
15253
15340
  sql(
@@ -15261,7 +15348,7 @@ function createAnonymousAuthTables(sqlite, _authConfig = null) {
15261
15348
  ),
15262
15349
  () => sqlite.exec(
15263
15350
  sql(
15264
- "CREATE TABLE IF NOT EXISTS [sporades_auth_oauth_states] ([state] TEXT PRIMARY KEY, [provider] TEXT NOT NULL, [sessionToken] TEXT NOT NULL, [returnTo] TEXT NOT NULL, [redirectUri] TEXT NOT NULL, [createdAt] TEXT NOT NULL, [expiresAt] TEXT NOT NULL, [nonce] TEXT, [pkceVerifier] TEXT, [registrationCiphertext] TEXT)"
15351
+ "CREATE TABLE IF NOT EXISTS [sporades_auth_oauth_states] ([state] TEXT PRIMARY KEY, [provider] TEXT NOT NULL, [sessionToken] TEXT NOT NULL, [returnTo] TEXT NOT NULL, [redirectUri] TEXT NOT NULL, [createdAt] TEXT NOT NULL, [expiresAt] TEXT NOT NULL, [nonce] TEXT, [pkceVerifier] TEXT, [registrationCiphertext] TEXT, [reauthPurpose] TEXT, [reauthUserId] TEXT)"
15265
15352
  )
15266
15353
  ),
15267
15354
  () => ensureOAuthStateColumns(sqlite)
@@ -15275,7 +15362,9 @@ function ensureOAuthStateColumns(sqlite) {
15275
15362
  ["expiresAt", "TEXT"],
15276
15363
  ["nonce", "TEXT"],
15277
15364
  ["pkceVerifier", "TEXT"],
15278
- ["registrationCiphertext", "TEXT"]
15365
+ ["registrationCiphertext", "TEXT"],
15366
+ ["reauthPurpose", "TEXT"],
15367
+ ["reauthUserId", "TEXT"]
15279
15368
  ].map(([name, type]) => () => sqlite.dialect.addMissingColumn(sqlite, "sporades_auth_oauth_states", name, type)),
15280
15369
  () => sqlite.exec(sql("UPDATE [sporades_auth_oauth_states] SET [provider] = 'google' WHERE [provider] IS NULL")),
15281
15370
  () => sqlite.exec(sql("UPDATE [sporades_auth_oauth_states] SET [expiresAt] = [createdAt] WHERE [expiresAt] IS NULL"))
@@ -18646,7 +18735,7 @@ function createSharedDatabaseAdapterMethods(dialect) {
18646
18735
  );
18647
18736
  },
18648
18737
  deleteAuthSession(token) {
18649
- return this.prepare(sql("DELETE FROM [sporades_auth_sessions] WHERE [token] = ?")).run(token);
18738
+ return thenIfPromise(this.prepare(sql("DELETE FROM [sporades_auth_reauthentication_proofs] WHERE [sessionToken] = ?")).run(token), () => this.prepare(sql("DELETE FROM [sporades_auth_sessions] WHERE [token] = ?")).run(token));
18650
18739
  },
18651
18740
  refreshAuthSession(token, expiresAt) {
18652
18741
  return this.prepare(sql("UPDATE [sporades_auth_sessions] SET [expiresAt] = ? WHERE [token] = ?")).run(expiresAt, token);
@@ -18656,7 +18745,7 @@ function createSharedDatabaseAdapterMethods(dialect) {
18656
18745
  },
18657
18746
  rotateAuthSession(previousToken, row) {
18658
18747
  assertNotReservedAuthUserId(row.userId);
18659
- return this.prepare(
18748
+ const rotated = this.prepare(
18660
18749
  sql(
18661
18750
  "UPDATE [sporades_auth_sessions] SET [token] = ?, [userId] = ?, [provider] = ?, [createdAt] = ?, [expiresAt] = ? WHERE [token] = ?"
18662
18751
  )
@@ -18668,6 +18757,10 @@ function createSharedDatabaseAdapterMethods(dialect) {
18668
18757
  row.expiresAt,
18669
18758
  previousToken
18670
18759
  );
18760
+ return thenIfPromise(rotated, (result) => {
18761
+ if (result.changes !== 1 || previousToken === row.token) return result;
18762
+ return thenIfPromise(this.prepare(sql("DELETE FROM [sporades_auth_reauthentication_proofs] WHERE [sessionToken] = ?")).run(previousToken), () => result);
18763
+ });
18671
18764
  },
18672
18765
  readAuthSessionWithUser(token) {
18673
18766
  return thenIfPromise(
@@ -18684,9 +18777,9 @@ function createSharedDatabaseAdapterMethods(dialect) {
18684
18777
  const expiresAt = row.expiresAt ?? new Date(Date.parse(row.createdAt) + 10 * 60 * 1e3).toISOString();
18685
18778
  return this.prepare(
18686
18779
  sql(
18687
- "INSERT INTO [sporades_auth_oauth_states] ([state], [provider], [sessionToken], [returnTo], [redirectUri], [createdAt], [expiresAt], [nonce], [pkceVerifier], [registrationCiphertext]) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
18780
+ "INSERT INTO [sporades_auth_oauth_states] ([state], [provider], [sessionToken], [returnTo], [redirectUri], [createdAt], [expiresAt], [nonce], [pkceVerifier], [registrationCiphertext], [reauthPurpose], [reauthUserId]) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
18688
18781
  )
18689
- ).run(row.state, provider, row.sessionToken, row.returnTo, row.redirectUri, row.createdAt, expiresAt, row.nonce ?? null, row.pkceVerifier ?? null, row.registrationCiphertext ?? null);
18782
+ ).run(row.state, provider, row.sessionToken, row.returnTo, row.redirectUri, row.createdAt, expiresAt, row.nonce ?? null, row.pkceVerifier ?? null, row.registrationCiphertext ?? null, row.reauthPurpose ?? null, row.reauthUserId ?? null);
18690
18783
  },
18691
18784
  // One statement, not a SELECT followed by a DELETE. The two-statement form was correct on
18692
18785
  // SQLite and a race everywhere else: nothing ordered the delete after the read, so on an
@@ -18697,7 +18790,7 @@ function createSharedDatabaseAdapterMethods(dialect) {
18697
18790
  return thenIfPromise(
18698
18791
  this.prepare(
18699
18792
  sql(
18700
- "DELETE FROM [sporades_auth_oauth_states] WHERE [state] = ? RETURNING [state], [provider], [sessionToken], [returnTo], [redirectUri], [createdAt], [expiresAt], [nonce], [pkceVerifier], [registrationCiphertext]"
18793
+ "DELETE FROM [sporades_auth_oauth_states] WHERE [state] = ? RETURNING [state], [provider], [sessionToken], [returnTo], [redirectUri], [createdAt], [expiresAt], [nonce], [pkceVerifier], [registrationCiphertext], [reauthPurpose], [reauthUserId]"
18701
18794
  )
18702
18795
  ).get(state),
18703
18796
  (row) => row ?? null
@@ -18732,8 +18825,45 @@ function createSharedDatabaseAdapterMethods(dialect) {
18732
18825
  (row) => isReservedAuthUserId(row?.userId) ? null : row ?? null
18733
18826
  );
18734
18827
  },
18828
+ replaceReauthenticationProof(row) {
18829
+ return chainMaybePromise([
18830
+ () => this.prepare(sql("DELETE FROM [sporades_auth_reauthentication_proofs] WHERE [sessionToken] = ? AND [purpose] = ?")).run(row.sessionToken, row.purpose),
18831
+ () => this.prepare(sql("INSERT INTO [sporades_auth_reauthentication_proofs] ([id], [userId], [sessionToken], [purpose], [createdAt], [expiresAt]) VALUES (?, ?, ?, ?, ?, ?)")).run(row.id, row.userId, row.sessionToken, row.purpose, row.createdAt, row.expiresAt)
18832
+ ]);
18833
+ },
18834
+ async reserveEmailReauthenticationAttempt(input) {
18835
+ await this.prepare(sql("UPDATE [sporades_auth_reauthentication_throttle_fence] SET [version] = [version] + 1 WHERE [id] = 'email'")).run();
18836
+ await this.prepare(sql("DELETE FROM [sporades_auth_reauthentication_throttle] WHERE [resetAt] <= ?")).run(input.now);
18837
+ const rows = await Promise.all(input.keys.map((key) => this.prepare(sql("SELECT [count], [resetAt] FROM [sporades_auth_reauthentication_throttle] WHERE [key] = ?")).get(key)));
18838
+ if (rows.some((row) => row && row.count >= input.limit)) return false;
18839
+ const active = await this.prepare(sql("SELECT COUNT(*) AS [count] FROM [sporades_auth_reauthentication_throttle]")).get();
18840
+ const missing = rows.filter((row) => !row).length;
18841
+ if (Number(active?.count ?? 0) + missing > input.maxEntries) return false;
18842
+ for (let index = 0; index < input.keys.length; index += 1) {
18843
+ const key = input.keys[index];
18844
+ const row = rows[index];
18845
+ if (row) await this.prepare(sql("UPDATE [sporades_auth_reauthentication_throttle] SET [count] = [count] + 1 WHERE [key] = ?")).run(key);
18846
+ else await this.prepare(sql("INSERT INTO [sporades_auth_reauthentication_throttle] ([key], [count], [resetAt]) VALUES (?, 1, ?)")).run(key, input.resetAt);
18847
+ }
18848
+ return true;
18849
+ },
18850
+ async clearEmailReauthenticationAttempts(keys) {
18851
+ for (const key of keys) await this.prepare(sql("DELETE FROM [sporades_auth_reauthentication_throttle] WHERE [key] = ?")).run(key);
18852
+ },
18853
+ claimEmailCredentialVersion(email, passwordHash, passwordSalt) {
18854
+ return thenIfPromise(this.prepare(sql("UPDATE [sporades_auth_email_credentials] SET [passwordHash] = [passwordHash] WHERE [email] = ? AND [passwordHash] = ? AND [passwordSalt] = ?")).run(email, passwordHash, passwordSalt), (result) => result.changes === 1);
18855
+ },
18856
+ deleteExpiredReauthenticationProofs(now2) {
18857
+ return this.prepare(sql("DELETE FROM [sporades_auth_reauthentication_proofs] WHERE [expiresAt] <= ?")).run(now2);
18858
+ },
18859
+ consumeReauthenticationProof(input) {
18860
+ return thenIfPromise(this.prepare(sql("SELECT [p].[id] FROM [sporades_auth_reauthentication_proofs] [p] JOIN [sporades_auth_sessions] [s] ON [s].[token] = [p].[sessionToken] JOIN [sporades_auth_users] [u] ON [u].[id] = [s].[userId] WHERE [p].[sessionToken] = ? AND [p].[userId] = ? AND [p].[purpose] = ? AND [p].[expiresAt] > ? AND [s].[userId] = ? AND [s].[expiresAt] > ? AND [u].[isAuthenticated] = 1 AND [u].[isGuest] = 0")).get(input.sessionToken, input.userId, input.purpose, input.now, input.userId, input.now), (row) => {
18861
+ if (!row) return false;
18862
+ return thenIfPromise(this.prepare(sql("DELETE FROM [sporades_auth_reauthentication_proofs] WHERE [id] = ?")).run(row.id), (result) => result.changes === 1);
18863
+ });
18864
+ },
18735
18865
  deleteAuthSessionsForUser(userId) {
18736
- return this.prepare(sql("DELETE FROM [sporades_auth_sessions] WHERE [userId] = ?")).run(userId);
18866
+ return thenIfPromise(this.prepare(sql("DELETE FROM [sporades_auth_reauthentication_proofs] WHERE [userId] = ?")).run(userId), () => this.prepare(sql("DELETE FROM [sporades_auth_sessions] WHERE [userId] = ?")).run(userId));
18737
18867
  },
18738
18868
  insertPasswordResetCode(row) {
18739
18869
  assertNotReservedAuthUserId(row.userId);
@@ -20991,6 +21121,23 @@ async function openDevDatabase(databasePath, serverSource, serverEnv = {}, confi
20991
21121
  scheduleTeamBillingJobDispatch: () => scheduleCurrentUserJobWorker(database),
20992
21122
  mail,
20993
21123
  authConfig: authStatus2(config, serverEnv),
21124
+ reauthenticationPolicy: capsuleDefinition?.auth?.reauthentication?.purposes ?? null,
21125
+ authorizeReauthentication: typeof capsuleDefinition?.auth?.reauthentication?.authorize === "function" ? async function(transaction, auth, purpose) {
21126
+ const reauthDatabase = createTransactionDatabase(database, transaction);
21127
+ let active = true;
21128
+ const assertActive = () => {
21129
+ if (!active) throw commandError("Reauthentication access is no longer active.", "Start a new reauthentication attempt.", "REAUTHENTICATION_ACCESS_INACTIVE");
21130
+ };
21131
+ const reauthContext = { purpose: "auth.reauthentication", auth };
21132
+ grantPrivilegedDbAccess(reauthContext);
21133
+ const holder = createContextHolder(reauthContext);
21134
+ try {
21135
+ return await capsuleDefinition.auth.reauthentication.authorize({ db: createEndpointReadOnlyDatabaseApi(reauthDatabase, () => holder.current, assertActive), auth }, purpose) === true;
21136
+ } finally {
21137
+ active = false;
21138
+ revokePrivilegedDbAccess(reauthContext);
21139
+ }
21140
+ } : async () => true,
20994
21141
  passwordResetConfig: resolvePasswordResetConfig(config),
20995
21142
  teamJoinLinkConfig: resolveTeamJoinLinkConfig(config),
20996
21143
  teamApplicationRoles,
@@ -24492,6 +24639,63 @@ function createWebSocketHub(getDatabase, trustedRefresh = null) {
24492
24639
  });
24493
24640
  return;
24494
24641
  }
24642
+ if (message.type === "auth.reauthenticate") {
24643
+ const purpose = message.purpose;
24644
+ const policy = database.reauthenticationPolicy?.[purpose];
24645
+ const normalized = normalizeEmailCredentials(message.credentials ?? {});
24646
+ let ok = false;
24647
+ let expiresAt = null;
24648
+ const authorized = Boolean(policy && client.session.auth?.isAuthenticated && !client.session.auth?.isGuest);
24649
+ const emailProviderEnabled = database.authConfig.providers.email?.enabled === true;
24650
+ if (authorized && message.provider === "email" && emailProviderEnabled && normalized.ok && typeof normalized.password === "string") {
24651
+ const reauthenticationThrottleKeys = [
24652
+ `email:${createHash8("sha256").update(normalized.email).digest("base64url")}`,
24653
+ `session:${createHash8("sha256").update(client.session.token).digest("base64url")}`
24654
+ ];
24655
+ const throttleNow = database.clock.now();
24656
+ let reserved = false;
24657
+ try {
24658
+ reserved = await database.adapter.withTransaction((tx) => tx.reserveEmailReauthenticationAttempt({ keys: reauthenticationThrottleKeys, now: throttleNow.toISOString(), resetAt: new Date(throttleNow.getTime() + EMAIL_SIGN_IN_THROTTLE_WINDOW_MS).toISOString(), limit: EMAIL_SIGN_IN_FAILURE_LIMIT, maxEntries: EMAIL_SIGN_IN_THROTTLE_MAX_ENTRIES }));
24659
+ } catch {
24660
+ try {
24661
+ await database.log?.emit?.({ category: "platform", event: "auth.reauthentication.throttle_failed", level: "error", message: "Reauthentication attempt reservation failed.", data: { provider: "email", purpose } });
24662
+ } catch {
24663
+ }
24664
+ }
24665
+ if (reserved) {
24666
+ const now2 = database.clock.now();
24667
+ expiresAt = new Date(now2.getTime() + policy.maxAgeSeconds * 1e3).toISOString();
24668
+ try {
24669
+ await database.adapter.withTransaction(async (tx) => {
24670
+ const current = await tx.readAuthSessionWithUser(client.session.token);
24671
+ const credential = await tx.findEmailCredentialWithUser(normalized.email);
24672
+ if (!current || current.token !== client.session.token || current.userId !== client.session.auth.userId || !current.isAuthenticated || current.isGuest || Date.parse(current.expiresAt) <= now2.getTime() || credential?.userId !== current.userId || !verifyEmailPassword(normalized.password, credential.passwordSalt, credential.passwordHash)) return;
24673
+ const currentAuth = { userId: current.userId, displayName: current.displayName, email: current.email, picture: current.picture, isAuthenticated: Boolean(current.isAuthenticated), isGuest: Boolean(current.isGuest), provider: current.provider };
24674
+ if (!await tx.claimEmailCredentialVersion(normalized.email, credential.passwordHash, credential.passwordSalt)) return;
24675
+ if (!await database.authorizeReauthentication(tx, currentAuth, purpose)) return;
24676
+ await tx.replaceReauthenticationProof({ id: randomUUID7(), userId: current.userId, sessionToken: current.token, purpose, createdAt: now2.toISOString(), expiresAt });
24677
+ ok = true;
24678
+ await tx.clearEmailReauthenticationAttempts(reauthenticationThrottleKeys);
24679
+ });
24680
+ } catch {
24681
+ try {
24682
+ await database.log?.emit?.({ category: "platform", event: "auth.reauthentication.authorization_failed", level: "error", message: "Reauthentication authorization policy failed.", data: { provider: "email", purpose } });
24683
+ } catch {
24684
+ }
24685
+ ok = false;
24686
+ }
24687
+ }
24688
+ }
24689
+ if (!ok && authorized && message.provider !== "email" && oauthProviderAdapter(database, message.provider)?.enabled) {
24690
+ const started = await beginOAuthSignIn(database, client.session, message.provider, { origin: client.origin, returnTo: message.returnTo, reauthentication: { purpose, userId: client.session.auth.userId } });
24691
+ if (started.ok) {
24692
+ sendJson(client, { id: message.id ?? null, type: "auth.redirect", data: { url: started.url }, error: null });
24693
+ return;
24694
+ }
24695
+ }
24696
+ sendJson(client, { id: message.id ?? null, type: ok ? "auth.reauthenticate.result" : "error", data: ok ? { ok: true, purpose, expiresAt } : null, error: ok ? null : { code: "REAUTHENTICATION_FAILED", message: "Reauthentication failed.", hint: "Verify the current linked identity and retry." } });
24697
+ return;
24698
+ }
24495
24699
  if (message.type === "auth.signIn" || message.type === "auth.signInWithGoogle") {
24496
24700
  const provider = message.type === "auth.signInWithGoogle" ? "google" : message.provider;
24497
24701
  if (provider === "email") {
@@ -25122,7 +25326,7 @@ function createWebSocketHub(getDatabase, trustedRefresh = null) {
25122
25326
  }
25123
25327
  async function signOutSession(database, client) {
25124
25328
  try {
25125
- await database.adapter.deleteAuthSession(client.session.token);
25329
+ await database.adapter.withTransaction((tx) => tx.deleteAuthSession(client.session.token));
25126
25330
  client.session = await resolveAnonymousSession(database, null);
25127
25331
  return { ok: true };
25128
25332
  } catch (error) {
@@ -25464,6 +25668,12 @@ async function runMutation(database, auth, mutationName, args, options = {}) {
25464
25668
  let result;
25465
25669
  const writeState = { didWrite: false };
25466
25670
  try {
25671
+ const declaredHandler = database.mutations.find((candidate) => candidate.name === mutationName);
25672
+ const declaredMutationHandler = declaredHandler ? materializeHandler(declaredHandler) : null;
25673
+ if (readAuthRequirements(declaredMutationHandler)?.reauthentication) {
25674
+ const maintenanceNow = database.clock.now().toISOString();
25675
+ await database.adapter.withTransaction((maintenanceAdapter) => maintenanceAdapter.deleteExpiredReauthenticationProofs(maintenanceNow));
25676
+ }
25467
25677
  const committed = await (database.adapter ?? database.adapter).withTransaction(async (transactionAdapter) => {
25468
25678
  const transactionDatabase = createTransactionDatabase(database, transactionAdapter, writeState);
25469
25679
  let handlerFailed = false;
@@ -25472,6 +25682,11 @@ async function runMutation(database, auth, mutationName, args, options = {}) {
25472
25682
  const customHandler = transactionDatabase.mutations.find((candidate) => candidate.name === mutationName);
25473
25683
  const mutationHandler = customHandler ? materializeHandler(customHandler) : null;
25474
25684
  if (mutationHandler) admitCredentialHandler(mutationHandler, context, "mutation");
25685
+ const reauthenticationPurpose = readAuthRequirements(mutationHandler)?.reauthentication;
25686
+ if (reauthenticationPurpose) {
25687
+ const consumed = typeof options.sessionToken === "string" && await transactionAdapter.consumeReauthenticationProof({ sessionToken: options.sessionToken, userId: auth.userId, purpose: reauthenticationPurpose, now: database.clock.now().toISOString() });
25688
+ if (!consumed) throw commandError("Reauthentication required.", "Verify the current Session for this purpose and retry.", "REAUTHENTICATION_REQUIRED");
25689
+ }
25475
25690
  context = await applyContextMiddleware(transactionDatabase, context, "mutation");
25476
25691
  for (const hookSource of database.mutationHooks.beforeMutation) {
25477
25692
  await runMutationHookAndDrainPendingAclWrites(hookSource, { name: mutationName, args, ctx: context }, context);
@@ -32480,7 +32695,7 @@ jobs:
32480
32695
  }
32481
32696
 
32482
32697
  // src/cli/cli-version.ts
32483
- var CLI_VERSION = "0.9.8";
32698
+ var CLI_VERSION = "0.9.9";
32484
32699
 
32485
32700
  // src/cli/sporades.ts
32486
32701
  var SUPPORTED_TEMPLATES = new Set(CLIENT_TEMPLATES);
@@ -7,6 +7,7 @@ export type AuthRequirements = Readonly<{
7
7
  linked: boolean;
8
8
  credentials: readonly CredentialKind[];
9
9
  scopes: readonly string[];
10
+ reauthentication: string | null;
10
11
  }>;
11
12
  export declare function invalidAuthRequirements(hint: string): import("./runtime-errors.js").HelperError;
12
13
  export declare function normalizeRequireUserAuthOptions(options?: unknown): Readonly<{