@spfn/auth 0.3.0-beta.24 → 0.3.0-beta.25

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/dist/server.js CHANGED
@@ -897,8 +897,8 @@ var init_array2 = __esm({
897
897
  });
898
898
 
899
899
  // ../../node_modules/.pnpm/@sinclair+typebox@0.34.41/node_modules/@sinclair/typebox/build/esm/type/argument/argument.mjs
900
- function Argument(index20) {
901
- return CreateType({ [Kind]: "Argument", index: index20 });
900
+ function Argument(index21) {
901
+ return CreateType({ [Kind]: "Argument", index: index21 });
902
902
  }
903
903
  var init_argument = __esm({
904
904
  "../../node_modules/.pnpm/@sinclair+typebox@0.34.41/node_modules/@sinclair/typebox/build/esm/type/argument/argument.mjs"() {
@@ -1136,28 +1136,28 @@ var init_union2 = __esm({
1136
1136
  function Unescape(pattern) {
1137
1137
  return pattern.replace(/\\\$/g, "$").replace(/\\\*/g, "*").replace(/\\\^/g, "^").replace(/\\\|/g, "|").replace(/\\\(/g, "(").replace(/\\\)/g, ")");
1138
1138
  }
1139
- function IsNonEscaped(pattern, index20, char) {
1140
- return pattern[index20] === char && pattern.charCodeAt(index20 - 1) !== 92;
1139
+ function IsNonEscaped(pattern, index21, char) {
1140
+ return pattern[index21] === char && pattern.charCodeAt(index21 - 1) !== 92;
1141
1141
  }
1142
- function IsOpenParen(pattern, index20) {
1143
- return IsNonEscaped(pattern, index20, "(");
1142
+ function IsOpenParen(pattern, index21) {
1143
+ return IsNonEscaped(pattern, index21, "(");
1144
1144
  }
1145
- function IsCloseParen(pattern, index20) {
1146
- return IsNonEscaped(pattern, index20, ")");
1145
+ function IsCloseParen(pattern, index21) {
1146
+ return IsNonEscaped(pattern, index21, ")");
1147
1147
  }
1148
- function IsSeparator(pattern, index20) {
1149
- return IsNonEscaped(pattern, index20, "|");
1148
+ function IsSeparator(pattern, index21) {
1149
+ return IsNonEscaped(pattern, index21, "|");
1150
1150
  }
1151
1151
  function IsGroup(pattern) {
1152
1152
  if (!(IsOpenParen(pattern, 0) && IsCloseParen(pattern, pattern.length - 1)))
1153
1153
  return false;
1154
1154
  let count2 = 0;
1155
- for (let index20 = 0; index20 < pattern.length; index20++) {
1156
- if (IsOpenParen(pattern, index20))
1155
+ for (let index21 = 0; index21 < pattern.length; index21++) {
1156
+ if (IsOpenParen(pattern, index21))
1157
1157
  count2 += 1;
1158
- if (IsCloseParen(pattern, index20))
1158
+ if (IsCloseParen(pattern, index21))
1159
1159
  count2 -= 1;
1160
- if (count2 === 0 && index20 !== pattern.length - 1)
1160
+ if (count2 === 0 && index21 !== pattern.length - 1)
1161
1161
  return false;
1162
1162
  }
1163
1163
  return true;
@@ -1167,19 +1167,19 @@ function InGroup(pattern) {
1167
1167
  }
1168
1168
  function IsPrecedenceOr(pattern) {
1169
1169
  let count2 = 0;
1170
- for (let index20 = 0; index20 < pattern.length; index20++) {
1171
- if (IsOpenParen(pattern, index20))
1170
+ for (let index21 = 0; index21 < pattern.length; index21++) {
1171
+ if (IsOpenParen(pattern, index21))
1172
1172
  count2 += 1;
1173
- if (IsCloseParen(pattern, index20))
1173
+ if (IsCloseParen(pattern, index21))
1174
1174
  count2 -= 1;
1175
- if (IsSeparator(pattern, index20) && count2 === 0)
1175
+ if (IsSeparator(pattern, index21) && count2 === 0)
1176
1176
  return true;
1177
1177
  }
1178
1178
  return false;
1179
1179
  }
1180
1180
  function IsPrecedenceAnd(pattern) {
1181
- for (let index20 = 0; index20 < pattern.length; index20++) {
1182
- if (IsOpenParen(pattern, index20))
1181
+ for (let index21 = 0; index21 < pattern.length; index21++) {
1182
+ if (IsOpenParen(pattern, index21))
1183
1183
  return true;
1184
1184
  }
1185
1185
  return false;
@@ -1187,16 +1187,16 @@ function IsPrecedenceAnd(pattern) {
1187
1187
  function Or(pattern) {
1188
1188
  let [count2, start] = [0, 0];
1189
1189
  const expressions = [];
1190
- for (let index20 = 0; index20 < pattern.length; index20++) {
1191
- if (IsOpenParen(pattern, index20))
1190
+ for (let index21 = 0; index21 < pattern.length; index21++) {
1191
+ if (IsOpenParen(pattern, index21))
1192
1192
  count2 += 1;
1193
- if (IsCloseParen(pattern, index20))
1193
+ if (IsCloseParen(pattern, index21))
1194
1194
  count2 -= 1;
1195
- if (IsSeparator(pattern, index20) && count2 === 0) {
1196
- const range2 = pattern.slice(start, index20);
1195
+ if (IsSeparator(pattern, index21) && count2 === 0) {
1196
+ const range2 = pattern.slice(start, index21);
1197
1197
  if (range2.length > 0)
1198
1198
  expressions.push(TemplateLiteralParse(range2));
1199
- start = index20 + 1;
1199
+ start = index21 + 1;
1200
1200
  }
1201
1201
  }
1202
1202
  const range = pattern.slice(start);
@@ -1209,40 +1209,40 @@ function Or(pattern) {
1209
1209
  return { type: "or", expr: expressions };
1210
1210
  }
1211
1211
  function And(pattern) {
1212
- function Group(value, index20) {
1213
- if (!IsOpenParen(value, index20))
1212
+ function Group(value, index21) {
1213
+ if (!IsOpenParen(value, index21))
1214
1214
  throw new TemplateLiteralParserError(`TemplateLiteralParser: Index must point to open parens`);
1215
1215
  let count2 = 0;
1216
- for (let scan = index20; scan < value.length; scan++) {
1216
+ for (let scan = index21; scan < value.length; scan++) {
1217
1217
  if (IsOpenParen(value, scan))
1218
1218
  count2 += 1;
1219
1219
  if (IsCloseParen(value, scan))
1220
1220
  count2 -= 1;
1221
1221
  if (count2 === 0)
1222
- return [index20, scan];
1222
+ return [index21, scan];
1223
1223
  }
1224
1224
  throw new TemplateLiteralParserError(`TemplateLiteralParser: Unclosed group parens in expression`);
1225
1225
  }
1226
- function Range(pattern2, index20) {
1227
- for (let scan = index20; scan < pattern2.length; scan++) {
1226
+ function Range(pattern2, index21) {
1227
+ for (let scan = index21; scan < pattern2.length; scan++) {
1228
1228
  if (IsOpenParen(pattern2, scan))
1229
- return [index20, scan];
1229
+ return [index21, scan];
1230
1230
  }
1231
- return [index20, pattern2.length];
1231
+ return [index21, pattern2.length];
1232
1232
  }
1233
1233
  const expressions = [];
1234
- for (let index20 = 0; index20 < pattern.length; index20++) {
1235
- if (IsOpenParen(pattern, index20)) {
1236
- const [start, end] = Group(pattern, index20);
1234
+ for (let index21 = 0; index21 < pattern.length; index21++) {
1235
+ if (IsOpenParen(pattern, index21)) {
1236
+ const [start, end] = Group(pattern, index21);
1237
1237
  const range = pattern.slice(start, end + 1);
1238
1238
  expressions.push(TemplateLiteralParse(range));
1239
- index20 = end;
1239
+ index21 = end;
1240
1240
  } else {
1241
- const [start, end] = Range(pattern, index20);
1241
+ const [start, end] = Range(pattern, index21);
1242
1242
  const range = pattern.slice(start, end);
1243
1243
  if (range.length > 0)
1244
1244
  expressions.push(TemplateLiteralParse(range));
1245
- index20 = end - 1;
1245
+ index21 = end - 1;
1246
1246
  }
1247
1247
  }
1248
1248
  return expressions.length === 0 ? { type: "const", const: "" } : expressions.length === 1 ? expressions[0] : { type: "and", expr: expressions };
@@ -2577,13 +2577,13 @@ function FromBoolean(left, right) {
2577
2577
  return IsStructuralRight(right) ? StructuralRight(left, right) : type_exports.IsObject(right) ? FromObjectRight(left, right) : type_exports.IsRecord(right) ? FromRecordRight(left, right) : type_exports.IsBoolean(right) ? ExtendsResult.True : ExtendsResult.False;
2578
2578
  }
2579
2579
  function FromConstructor(left, right) {
2580
- return IsStructuralRight(right) ? StructuralRight(left, right) : type_exports.IsObject(right) ? FromObjectRight(left, right) : !type_exports.IsConstructor(right) ? ExtendsResult.False : left.parameters.length > right.parameters.length ? ExtendsResult.False : !left.parameters.every((schema, index20) => IntoBooleanResult(Visit3(right.parameters[index20], schema)) === ExtendsResult.True) ? ExtendsResult.False : IntoBooleanResult(Visit3(left.returns, right.returns));
2580
+ return IsStructuralRight(right) ? StructuralRight(left, right) : type_exports.IsObject(right) ? FromObjectRight(left, right) : !type_exports.IsConstructor(right) ? ExtendsResult.False : left.parameters.length > right.parameters.length ? ExtendsResult.False : !left.parameters.every((schema, index21) => IntoBooleanResult(Visit3(right.parameters[index21], schema)) === ExtendsResult.True) ? ExtendsResult.False : IntoBooleanResult(Visit3(left.returns, right.returns));
2581
2581
  }
2582
2582
  function FromDate(left, right) {
2583
2583
  return IsStructuralRight(right) ? StructuralRight(left, right) : type_exports.IsObject(right) ? FromObjectRight(left, right) : type_exports.IsRecord(right) ? FromRecordRight(left, right) : type_exports.IsDate(right) ? ExtendsResult.True : ExtendsResult.False;
2584
2584
  }
2585
2585
  function FromFunction(left, right) {
2586
- return IsStructuralRight(right) ? StructuralRight(left, right) : type_exports.IsObject(right) ? FromObjectRight(left, right) : !type_exports.IsFunction(right) ? ExtendsResult.False : left.parameters.length > right.parameters.length ? ExtendsResult.False : !left.parameters.every((schema, index20) => IntoBooleanResult(Visit3(right.parameters[index20], schema)) === ExtendsResult.True) ? ExtendsResult.False : IntoBooleanResult(Visit3(left.returns, right.returns));
2586
+ return IsStructuralRight(right) ? StructuralRight(left, right) : type_exports.IsObject(right) ? FromObjectRight(left, right) : !type_exports.IsFunction(right) ? ExtendsResult.False : left.parameters.length > right.parameters.length ? ExtendsResult.False : !left.parameters.every((schema, index21) => IntoBooleanResult(Visit3(right.parameters[index21], schema)) === ExtendsResult.True) ? ExtendsResult.False : IntoBooleanResult(Visit3(left.returns, right.returns));
2587
2587
  }
2588
2588
  function FromIntegerRight(left, right) {
2589
2589
  return type_exports.IsLiteral(left) && value_exports.IsNumber(left.const) ? ExtendsResult.True : type_exports.IsNumber(left) || type_exports.IsInteger(left) ? ExtendsResult.True : ExtendsResult.False;
@@ -2743,7 +2743,7 @@ function FromTupleRight(left, right) {
2743
2743
  return type_exports.IsNever(left) ? ExtendsResult.True : type_exports.IsUnknown(left) ? ExtendsResult.False : type_exports.IsAny(left) ? ExtendsResult.Union : ExtendsResult.False;
2744
2744
  }
2745
2745
  function FromTuple3(left, right) {
2746
- return IsStructuralRight(right) ? StructuralRight(left, right) : type_exports.IsObject(right) && IsObjectArrayLike(right) ? ExtendsResult.True : type_exports.IsArray(right) && IsArrayOfTuple(left, right) ? ExtendsResult.True : !type_exports.IsTuple(right) ? ExtendsResult.False : value_exports.IsUndefined(left.items) && !value_exports.IsUndefined(right.items) || !value_exports.IsUndefined(left.items) && value_exports.IsUndefined(right.items) ? ExtendsResult.False : value_exports.IsUndefined(left.items) && !value_exports.IsUndefined(right.items) ? ExtendsResult.True : left.items.every((schema, index20) => Visit3(schema, right.items[index20]) === ExtendsResult.True) ? ExtendsResult.True : ExtendsResult.False;
2746
+ return IsStructuralRight(right) ? StructuralRight(left, right) : type_exports.IsObject(right) && IsObjectArrayLike(right) ? ExtendsResult.True : type_exports.IsArray(right) && IsArrayOfTuple(left, right) ? ExtendsResult.True : !type_exports.IsTuple(right) ? ExtendsResult.False : value_exports.IsUndefined(left.items) && !value_exports.IsUndefined(right.items) || !value_exports.IsUndefined(left.items) && value_exports.IsUndefined(right.items) ? ExtendsResult.False : value_exports.IsUndefined(left.items) && !value_exports.IsUndefined(right.items) ? ExtendsResult.True : left.items.every((schema, index21) => Visit3(schema, right.items[index21]) === ExtendsResult.True) ? ExtendsResult.True : ExtendsResult.False;
2747
2747
  }
2748
2748
  function FromUint8Array(left, right) {
2749
2749
  return IsStructuralRight(right) ? StructuralRight(left, right) : type_exports.IsObject(right) ? FromObjectRight(left, right) : type_exports.IsRecord(right) ? FromRecordRight(left, right) : type_exports.IsUint8Array(right) ? ExtendsResult.True : ExtendsResult.False;
@@ -4520,6 +4520,12 @@ var init_schema3 = __esm({
4520
4520
  }),
4521
4521
  Type.Object({
4522
4522
  status: Type.Literal("approved"),
4523
+ // The `LoginResult` discriminant, carried because the approved branch is
4524
+ // a `LoginResult` spread whole (#95). Always false here: a device-code
4525
+ // approval is the owner saying yes on a device that is already signed
4526
+ // in, which is itself a second factor, so this channel never steps up
4527
+ // and never carries a challenge.
4528
+ mfaRequired: Type.Boolean(),
4523
4529
  userId: Type.String(),
4524
4530
  publicId: Type.String(),
4525
4531
  email: Type.Optional(Type.String()),
@@ -4815,7 +4821,7 @@ var init_wire_headers = __esm({
4815
4821
  });
4816
4822
 
4817
4823
  // src/server/entities/user-public-keys.ts
4818
- import { text as text4, boolean as boolean3, index as index4 } from "drizzle-orm/pg-core";
4824
+ import { text as text4, boolean as boolean3, bigint, index as index4 } from "drizzle-orm/pg-core";
4819
4825
  import { id as id4, foreignKey as foreignKey3, enumText as enumText2, utcTimestamp as utcTimestamp2 } from "@spfn/core/db";
4820
4826
  var userPublicKeys;
4821
4827
  var init_user_public_keys = __esm({
@@ -4946,6 +4952,19 @@ var init_user_public_keys = __esm({
4946
4952
  // false: Key is deactivated (cannot be used for verification)
4947
4953
  // Used for: soft key rotation, temporary key suspension
4948
4954
  isActive: boolean3("is_active").notNull().default(true),
4955
+ // The second-factor challenge gating this key, when it is pending (#95)
4956
+ // null: the ordinary state — the key is whatever `is_active` says it is
4957
+ // set: the key was registered inactive because the account has a second
4958
+ // factor and this device is new, and it stays that way until the
4959
+ // challenge is verified. `is_active` alone cannot say this, because a
4960
+ // revoked key is inactive too, and the two must not read alike:
4961
+ // `listKeys` hides a pending key in both modes and a global revocation
4962
+ // deletes it outright rather than revoking it
4963
+ // NOT a foreign key, deliberately. `mfa_challenges.key_id` points back at
4964
+ // this row, and a pair of constraints in a cycle leaves neither row
4965
+ // writable first. The link that decides anything is that one; this column
4966
+ // is what makes "is this row pending" a predicate on the keys table
4967
+ pendingMfaChallengeId: bigint("pending_mfa_challenge_id", { mode: "number" }),
4949
4968
  // Key creation timestamp
4950
4969
  // Automatically set on insertion
4951
4970
  createdAt: utcTimestamp2("created_at").notNull().defaultNow(),
@@ -5339,7 +5358,7 @@ var init_passkeys = __esm({
5339
5358
  });
5340
5359
 
5341
5360
  // src/server/entities/webauthn-challenges.ts
5342
- import { bigint, index as index11, text as text11, uniqueIndex as uniqueIndex5 } from "drizzle-orm/pg-core";
5361
+ import { bigint as bigint2, index as index11, text as text11, uniqueIndex as uniqueIndex5 } from "drizzle-orm/pg-core";
5343
5362
  import { id as id11, enumText as enumText6, utcTimestamp as utcTimestamp9 } from "@spfn/core/db";
5344
5363
  var WEBAUTHN_CHALLENGE_KINDS, webauthnChallenges;
5345
5364
  var init_webauthn_challenges = __esm({
@@ -5364,7 +5383,7 @@ var init_webauthn_challenges = __esm({
5364
5383
  // Nullable, so `foreignKey()` (which is notNull by construction) does
5365
5384
  // not apply; the cascade is still declared, so destroying an account
5366
5385
  // takes its in-flight enrollment challenges with it.
5367
- userId: bigint("user_id", { mode: "number" }).references(() => users.id, { onDelete: "cascade" }),
5386
+ userId: bigint2("user_id", { mode: "number" }).references(() => users.id, { onDelete: "cascade" }),
5368
5387
  // SPFN_AUTH_PASSKEY_CHALLENGE_TTL_SECONDS from creation
5369
5388
  expiresAt: utcTimestamp9("expires_at").notNull(),
5370
5389
  // Set by the verify that spent it. Non-null means the challenge is
@@ -5384,7 +5403,7 @@ var init_webauthn_challenges = __esm({
5384
5403
  });
5385
5404
 
5386
5405
  // src/server/entities/mfa-totp.ts
5387
- import { bigint as bigint2, integer as integer6, text as text12, uniqueIndex as uniqueIndex6 } from "drizzle-orm/pg-core";
5406
+ import { bigint as bigint3, integer as integer6, text as text12, uniqueIndex as uniqueIndex6 } from "drizzle-orm/pg-core";
5388
5407
  import { id as id12, foreignKey as foreignKey8, timestamps as timestamps10, utcTimestamp as utcTimestamp10 } from "@spfn/core/db";
5389
5408
  var MFA_CONFIRM_ATTEMPT_LIMIT, mfaTotp;
5390
5409
  var init_mfa_totp = __esm({
@@ -5407,7 +5426,7 @@ var init_mfa_totp = __esm({
5407
5426
  // the screen cannot be replayed inside its own window. Per account
5408
5427
  // rather than per device: the cost is that a second device signing in
5409
5428
  // within the same step is refused and retries on the next one.
5410
- lastUsedStep: bigint2("last_used_step", { mode: "number" }),
5429
+ lastUsedStep: bigint3("last_used_step", { mode: "number" }),
5411
5430
  // Wrong codes against the unconfirmed row. Reset by a fresh `enroll`.
5412
5431
  failedConfirmAttempts: integer6("failed_confirm_attempts").notNull().default(0),
5413
5432
  ...timestamps10()
@@ -5480,9 +5499,62 @@ var init_mfa_verifications = __esm({
5480
5499
  }
5481
5500
  });
5482
5501
 
5502
+ // src/server/entities/mfa-challenges.ts
5503
+ import { index as index13, integer as integer8, jsonb, text as text15, uniqueIndex as uniqueIndex7 } from "drizzle-orm/pg-core";
5504
+ import { id as id14, enumText as enumText8, foreignKey as foreignKey11, timestamps as timestamps12, utcTimestamp as utcTimestamp13 } from "@spfn/core/db";
5505
+ var MFA_CHALLENGE_CHANNELS, MFA_CHALLENGE_ATTEMPT_LIMIT, mfaChallenges;
5506
+ var init_mfa_challenges = __esm({
5507
+ "src/server/entities/mfa-challenges.ts"() {
5508
+ "use strict";
5509
+ init_users();
5510
+ init_user_public_keys();
5511
+ init_schema4();
5512
+ MFA_CHALLENGE_CHANNELS = ["password", "oauth", "oauth-native", "password-reset"];
5513
+ MFA_CHALLENGE_ATTEMPT_LIMIT = 5;
5514
+ mfaChallenges = authSchema.table(
5515
+ "mfa_challenges",
5516
+ {
5517
+ id: id14(),
5518
+ userId: foreignKey11("user", () => users.id),
5519
+ // SHA-256 of the 32-byte secret, base64url. The secret leaves the server
5520
+ // in the 202 body, the OAuth callback query and the verify request, and
5521
+ // is nowhere else — not in a log line and not in this column.
5522
+ challengeHash: text15("challenge_hash").notNull(),
5523
+ // The inactive key this challenge would activate. Cascade, so deleting a
5524
+ // pending key — the sweep, five wrong proofs, a global revocation —
5525
+ // takes the challenge with it rather than leaving one pointing at
5526
+ // nothing.
5527
+ keyId: text15("key_id").notNull().references(() => userPublicKeys.keyId, { onDelete: "cascade" }),
5528
+ channel: enumText8("channel", MFA_CHALLENGE_CHANNELS).notNull(),
5529
+ // The account's key generation when this was minted. A challenge from an
5530
+ // earlier generation verifies nothing.
5531
+ keyEpoch: integer8("key_epoch").notNull(),
5532
+ // Wrong proofs so far. MFA_CHALLENGE_ATTEMPT_LIMIT of them expire the
5533
+ // challenge and delete the pending key.
5534
+ attempts: integer8("attempts").notNull().default(0),
5535
+ // SPFN_AUTH_MFA_CHALLENGE_TTL_MINUTES from creation.
5536
+ expiresAt: utcTimestamp13("expires_at").notNull(),
5537
+ // Set by the verify that spent it, in a conditional UPDATE, so two
5538
+ // concurrent verifies produce one winner.
5539
+ verifiedAt: utcTimestamp13("verified_at"),
5540
+ // The login announcement the 202 held back. See DeferredLoginEvent.
5541
+ loginEvent: jsonb("login_event").$type(),
5542
+ ...timestamps12()
5543
+ },
5544
+ (table) => [
5545
+ // The verify path addresses a row by this alone. Unique so one presented
5546
+ // secret can never match two rows.
5547
+ uniqueIndex7("mfa_challenge_hash_idx").on(table.challengeHash),
5548
+ // The sweep and the live-challenge lookups both scan by expiry.
5549
+ index13("mfa_challenges_expires_at_idx").on(table.expiresAt)
5550
+ ]
5551
+ );
5552
+ }
5553
+ });
5554
+
5483
5555
  // src/server/entities/device-authorizations.ts
5484
- import { text as text15, uniqueIndex as uniqueIndex7 } from "drizzle-orm/pg-core";
5485
- import { id as id14, timestamps as timestamps12, enumText as enumText8, utcTimestamp as utcTimestamp13, optionalForeignKey } from "@spfn/core/db";
5556
+ import { text as text16, uniqueIndex as uniqueIndex8 } from "drizzle-orm/pg-core";
5557
+ import { id as id15, timestamps as timestamps13, enumText as enumText9, utcTimestamp as utcTimestamp14, optionalForeignKey } from "@spfn/core/db";
5486
5558
  var DEVICE_AUTH_STATUSES, deviceAuthorizations;
5487
5559
  var init_device_authorizations = __esm({
5488
5560
  "src/server/entities/device-authorizations.ts"() {
@@ -5494,58 +5566,58 @@ var init_device_authorizations = __esm({
5494
5566
  deviceAuthorizations = authSchema.table(
5495
5567
  "device_authorizations",
5496
5568
  {
5497
- id: id14(),
5569
+ id: id15(),
5498
5570
  // SHA-256 of the device code, hex
5499
5571
  // The code itself (32 random bytes, base64url) is returned to the waiting
5500
5572
  // device once and never stored: it is the only thing that device holds,
5501
5573
  // so a dump of this table must not let its reader finish someone's login
5502
- deviceCodeHash: text15("device_code_hash").notNull().unique(),
5574
+ deviceCodeHash: text16("device_code_hash").notNull().unique(),
5503
5575
  // The code a person reads off the waiting device's screen
5504
5576
  // Stored normalized — uppercase, no dash — because that is the only form
5505
5577
  // a lookup can match; the dash is put back for display
5506
5578
  // Plaintext on purpose: it authorizes nothing without an approver who is
5507
5579
  // already authenticated, and it has to be found by exact match
5508
- userCode: text15("user_code").notNull(),
5580
+ userCode: text16("user_code").notNull(),
5509
5581
  // Key material the waiting device generated, same shapes as user_public_keys
5510
5582
  // Parked, not registered: it can sign nothing until a poll moves it over
5511
- publicKey: text15("public_key").notNull(),
5512
- keyId: text15("key_id").notNull(),
5513
- fingerprint: text15("fingerprint").notNull(),
5514
- algorithm: enumText8("algorithm", KEY_ALGORITHM).notNull().default("ES256"),
5583
+ publicKey: text16("public_key").notNull(),
5584
+ keyId: text16("key_id").notNull(),
5585
+ fingerprint: text16("fingerprint").notNull(),
5586
+ algorithm: enumText9("algorithm", KEY_ALGORITHM).notNull().default("ES256"),
5515
5587
  // Device labels the waiting device supplied
5516
5588
  // null: it sent none
5517
5589
  // Shown to the approver so they can tell whether the device asking is the
5518
5590
  // one in front of them — display only, so a client that lies gains nothing
5519
5591
  // but a wrong line on the approval screen
5520
- deviceName: text15("device_name"),
5521
- platform: enumText8("platform", KEY_PLATFORM),
5522
- status: enumText8("status", DEVICE_AUTH_STATUSES).notNull().default("pending"),
5592
+ deviceName: text16("device_name"),
5593
+ platform: enumText9("platform", KEY_PLATFORM),
5594
+ status: enumText9("status", DEVICE_AUTH_STATUSES).notNull().default("pending"),
5523
5595
  // Who approved, written at approve time from the approver's session
5524
5596
  // null while pending, and on every record that was denied or expired
5525
5597
  // Never taken from a request body — that would be the whole authorization
5526
5598
  userId: optionalForeignKey("user", () => users.id, { onDelete: "cascade" }),
5527
5599
  // Expiry — 10 minutes from creation by default
5528
5600
  // Judged on read; no job clears the row
5529
- expiresAt: utcTimestamp13("expires_at").notNull(),
5601
+ expiresAt: utcTimestamp14("expires_at").notNull(),
5530
5602
  // Set when the approver said yes
5531
- approvedAt: utcTimestamp13("approved_at"),
5603
+ approvedAt: utcTimestamp14("approved_at"),
5532
5604
  // Set when the poll that registered the key won the race
5533
5605
  // Non-null means the record is spent, whatever else it says
5534
- consumedAt: utcTimestamp13("consumed_at"),
5535
- ...timestamps12()
5606
+ consumedAt: utcTimestamp14("consumed_at"),
5607
+ ...timestamps13()
5536
5608
  },
5537
5609
  (table) => [
5538
5610
  // Lookup path for info/approve/deny
5539
5611
  // Unique so a typed code can never address two records
5540
- uniqueIndex7("device_authorization_user_code_idx").on(table.userCode)
5612
+ uniqueIndex8("device_authorization_user_code_idx").on(table.userCode)
5541
5613
  ]
5542
5614
  );
5543
5615
  }
5544
5616
  });
5545
5617
 
5546
5618
  // src/server/entities/user-invitations.ts
5547
- import { text as text16, index as index13 } from "drizzle-orm/pg-core";
5548
- import { id as id15, timestamps as timestamps13, enumText as enumText9, utcTimestamp as utcTimestamp14, typedJsonb as typedJsonb2, foreignKey as foreignKey11 } from "@spfn/core/db";
5619
+ import { text as text17, index as index14 } from "drizzle-orm/pg-core";
5620
+ import { id as id16, timestamps as timestamps14, enumText as enumText10, utcTimestamp as utcTimestamp15, typedJsonb as typedJsonb2, foreignKey as foreignKey12 } from "@spfn/core/db";
5549
5621
  var userInvitations;
5550
5622
  var init_user_invitations = __esm({
5551
5623
  "src/server/entities/user-invitations.ts"() {
@@ -5558,39 +5630,39 @@ var init_user_invitations = __esm({
5558
5630
  "user_invitations",
5559
5631
  {
5560
5632
  // Primary key
5561
- id: id15(),
5633
+ id: id16(),
5562
5634
  // Target email address for the invitation
5563
5635
  // Will become the user's email upon acceptance
5564
- email: text16("email").notNull(),
5636
+ email: text17("email").notNull(),
5565
5637
  // Unique invitation token (UUID v4)
5566
5638
  // Used in invitation URL: /auth/invite/{token}
5567
5639
  // Single-use token that expires after acceptance
5568
- token: text16("token").notNull().unique(),
5640
+ token: text17("token").notNull().unique(),
5569
5641
  // Role to be assigned when invitation is accepted
5570
5642
  // Foreign key to roles table
5571
- roleId: foreignKey11("role", () => roles.id),
5643
+ roleId: foreignKey12("role", () => roles.id),
5572
5644
  // User who created this invitation
5573
5645
  // Foreign key to users table
5574
5646
  // Used for: audit trail, permission checks
5575
- invitedBy: foreignKey11("invited_by", () => users.id, { onDelete: "cascade" }),
5647
+ invitedBy: foreignKey12("invited_by", () => users.id, { onDelete: "cascade" }),
5576
5648
  // Invitation status
5577
5649
  // - pending: Invitation sent, awaiting acceptance
5578
5650
  // - accepted: User accepted and account created
5579
5651
  // - expired: Invitation expired (automatic)
5580
5652
  // - cancelled: Invitation cancelled by admin
5581
- status: enumText9("status", INVITATION_STATUSES).default("pending").notNull(),
5653
+ status: enumText10("status", INVITATION_STATUSES).default("pending").notNull(),
5582
5654
  // Expiration timestamp (default: 7 days from creation)
5583
5655
  // Invitation cannot be accepted after this time
5584
5656
  // Background job should update status to 'expired'
5585
- expiresAt: utcTimestamp14("expires_at").notNull(),
5657
+ expiresAt: utcTimestamp15("expires_at").notNull(),
5586
5658
  // Timestamp when invitation was accepted
5587
5659
  // null = not yet accepted
5588
5660
  // Used for: audit trail, analytics
5589
- acceptedAt: utcTimestamp14("accepted_at"),
5661
+ acceptedAt: utcTimestamp15("accepted_at"),
5590
5662
  // Timestamp when invitation was cancelled
5591
5663
  // null = not cancelled
5592
5664
  // Used for: audit trail
5593
- cancelledAt: utcTimestamp14("cancelled_at"),
5665
+ cancelledAt: utcTimestamp15("cancelled_at"),
5594
5666
  // Additional metadata (JSONB)
5595
5667
  // Use cases:
5596
5668
  // - Custom welcome message
@@ -5599,26 +5671,26 @@ var init_user_invitations = __esm({
5599
5671
  // - Custom fields for app-specific data
5600
5672
  // Example: { message: "Welcome!", department: "Engineering" }
5601
5673
  metadata: typedJsonb2("metadata"),
5602
- ...timestamps13()
5674
+ ...timestamps14()
5603
5675
  },
5604
5676
  (table) => [
5605
5677
  // Indexes for query optimization
5606
- index13("invitations_token_idx").on(table.token),
5607
- index13("invitations_email_idx").on(table.email),
5608
- index13("invitations_status_idx").on(table.status),
5609
- index13("invitations_invited_by_idx").on(table.invitedBy),
5610
- index13("invitations_expires_at_idx").on(table.expiresAt),
5678
+ index14("invitations_token_idx").on(table.token),
5679
+ index14("invitations_email_idx").on(table.email),
5680
+ index14("invitations_status_idx").on(table.status),
5681
+ index14("invitations_invited_by_idx").on(table.invitedBy),
5682
+ index14("invitations_expires_at_idx").on(table.expiresAt),
5611
5683
  // For cleanup jobs
5612
- index13("invitations_role_id_idx").on(table.roleId)
5684
+ index14("invitations_role_id_idx").on(table.roleId)
5613
5685
  ]
5614
5686
  );
5615
5687
  }
5616
5688
  });
5617
5689
 
5618
5690
  // src/server/entities/account-deletion-requests.ts
5619
- import { text as text17, index as index14, uniqueIndex as uniqueIndex8 } from "drizzle-orm/pg-core";
5691
+ import { text as text18, index as index15, uniqueIndex as uniqueIndex9 } from "drizzle-orm/pg-core";
5620
5692
  import { sql as sql2 } from "drizzle-orm";
5621
- import { id as id16, timestamps as timestamps14, enumText as enumText10, utcTimestamp as utcTimestamp15, optionalForeignKey as optionalForeignKey2 } from "@spfn/core/db";
5693
+ import { id as id17, timestamps as timestamps15, enumText as enumText11, utcTimestamp as utcTimestamp16, optionalForeignKey as optionalForeignKey2 } from "@spfn/core/db";
5622
5694
  var accountDeletionRequests;
5623
5695
  var init_account_deletion_requests = __esm({
5624
5696
  "src/server/entities/account-deletion-requests.ts"() {
@@ -5629,40 +5701,40 @@ var init_account_deletion_requests = __esm({
5629
5701
  accountDeletionRequests = authSchema.table(
5630
5702
  "account_deletion_requests",
5631
5703
  {
5632
- id: id16(),
5704
+ id: id17(),
5633
5705
  // Foreign key to users table. `set null` (optionalForeignKey default) so this
5634
5706
  // row survives a hard-delete purge of the user it refers to.
5635
5707
  userId: optionalForeignKey2("user", () => users.id),
5636
5708
  // Snapshot of the user's public UUID at request time — stays readable even
5637
5709
  // after userId is nulled out or the account is anonymized.
5638
- userPublicId: text17("user_public_id").notNull(),
5710
+ userPublicId: text18("user_public_id").notNull(),
5639
5711
  // When the deletion was requested
5640
- requestedAt: utcTimestamp15("requested_at").notNull().defaultNow(),
5712
+ requestedAt: utcTimestamp16("requested_at").notNull().defaultNow(),
5641
5713
  // When the purge job is allowed to run (requestedAt + grace period; equals
5642
5714
  // requestedAt itself for immediate/zero-grace deletions)
5643
- purgeScheduledAt: utcTimestamp15("purge_scheduled_at").notNull(),
5715
+ purgeScheduledAt: utcTimestamp16("purge_scheduled_at").notNull(),
5644
5716
  // Request lifecycle status
5645
5717
  // - pending: awaiting purgeScheduledAt (or immediate purge)
5646
5718
  // - cancelled: recovered before purge
5647
5719
  // - completed: purge ran
5648
- status: enumText10("status", ACCOUNT_DELETION_REQUEST_STATUSES).default("pending").notNull(),
5720
+ status: enumText11("status", ACCOUNT_DELETION_REQUEST_STATUSES).default("pending").notNull(),
5649
5721
  // Who initiated the request
5650
- requestedBy: enumText10("requested_by", ACCOUNT_DELETION_REQUESTED_BY).default("self").notNull(),
5722
+ requestedBy: enumText11("requested_by", ACCOUNT_DELETION_REQUESTED_BY).default("self").notNull(),
5651
5723
  // Optional free-text reason (self-service UI, admin note, DSR reference, ...)
5652
- reason: text17("reason"),
5653
- cancelledAt: utcTimestamp15("cancelled_at"),
5654
- completedAt: utcTimestamp15("completed_at"),
5724
+ reason: text18("reason"),
5725
+ cancelledAt: utcTimestamp16("cancelled_at"),
5726
+ completedAt: utcTimestamp16("completed_at"),
5655
5727
  // Purge strategy actually executed (set on completion; null while pending)
5656
- purgeStrategy: enumText10("purge_strategy", PURGE_STRATEGIES),
5657
- ...timestamps14()
5728
+ purgeStrategy: enumText11("purge_strategy", PURGE_STRATEGIES),
5729
+ ...timestamps15()
5658
5730
  },
5659
5731
  (table) => [
5660
- index14("account_deletion_requests_user_id_idx").on(table.userId),
5661
- index14("account_deletion_requests_status_idx").on(table.status),
5662
- index14("account_deletion_requests_purge_scheduled_at_idx").on(table.purgeScheduledAt),
5663
- index14("account_deletion_requests_user_public_id_idx").on(table.userPublicId),
5732
+ index15("account_deletion_requests_user_id_idx").on(table.userId),
5733
+ index15("account_deletion_requests_status_idx").on(table.status),
5734
+ index15("account_deletion_requests_purge_scheduled_at_idx").on(table.purgeScheduledAt),
5735
+ index15("account_deletion_requests_user_public_id_idx").on(table.userPublicId),
5664
5736
  // Partial unique index: at most one pending request per user at a time.
5665
- uniqueIndex8("account_deletion_requests_user_pending_unique_idx").on(table.userId).where(sql2`${table.status} = 'pending'`)
5737
+ uniqueIndex9("account_deletion_requests_user_pending_unique_idx").on(table.userId).where(sql2`${table.status} = 'pending'`)
5666
5738
  ]
5667
5739
  );
5668
5740
  }
@@ -5814,8 +5886,8 @@ var init_rbac = __esm({
5814
5886
  });
5815
5887
 
5816
5888
  // src/server/entities/permissions.ts
5817
- import { text as text18, boolean as boolean5, index as index15 } from "drizzle-orm/pg-core";
5818
- import { id as id17, timestamps as timestamps15, enumText as enumText11, typedJsonb as typedJsonb3 } from "@spfn/core/db";
5889
+ import { text as text19, boolean as boolean5, index as index16 } from "drizzle-orm/pg-core";
5890
+ import { id as id18, timestamps as timestamps16, enumText as enumText12, typedJsonb as typedJsonb3 } from "@spfn/core/db";
5819
5891
  var permissions;
5820
5892
  var init_permissions = __esm({
5821
5893
  "src/server/entities/permissions.ts"() {
@@ -5826,7 +5898,7 @@ var init_permissions = __esm({
5826
5898
  "permissions",
5827
5899
  {
5828
5900
  // Primary key
5829
- id: id17(),
5901
+ id: id18(),
5830
5902
  // Permission identifier
5831
5903
  // Format: resource:action or namespace:resource:action
5832
5904
  // Examples:
@@ -5834,20 +5906,20 @@ var init_permissions = __esm({
5834
5906
  // - Namespaced: 'auth:user:delete', 'cms:post:publish'
5835
5907
  // Must be unique across all permissions
5836
5908
  // Used in: permission checks, role assignments, API guards
5837
- name: text18("name").notNull().unique(),
5909
+ name: text19("name").notNull().unique(),
5838
5910
  // Display name for UI
5839
5911
  // Human-readable name shown in admin panels
5840
5912
  // Example: "Delete Users", "Publish Posts"
5841
- displayName: text18("display_name").notNull(),
5913
+ displayName: text19("display_name").notNull(),
5842
5914
  // Permission description
5843
5915
  // Detailed explanation of what this permission allows
5844
5916
  // Example: "Allows deletion of user accounts from the system"
5845
- description: text18("description"),
5917
+ description: text19("description"),
5846
5918
  // Category for grouping
5847
5919
  // Used for: organizing permissions in UI, filtering
5848
5920
  // Built-in categories: auth, user, rbac, system
5849
5921
  // Custom categories: any app-specific category
5850
- category: enumText11("category", PERMISSION_CATEGORIES),
5922
+ category: enumText12("category", PERMISSION_CATEGORIES),
5851
5923
  // Built-in permission flag
5852
5924
  // true: Core package permissions (auth:*, user:*, rbac:*)
5853
5925
  // - Cannot be deleted or modified
@@ -5878,22 +5950,22 @@ var init_permissions = __esm({
5878
5950
  // - Audit: { createdBy: 123, source: 'migration', version: '1.0.0' }
5879
5951
  // Example: { icon: 'trash', color: 'red', requiresMfa: true }
5880
5952
  metadata: typedJsonb3("metadata"),
5881
- ...timestamps15()
5953
+ ...timestamps16()
5882
5954
  },
5883
5955
  (table) => [
5884
- index15("permissions_name_idx").on(table.name),
5885
- index15("permissions_category_idx").on(table.category),
5886
- index15("permissions_is_system_idx").on(table.isSystem),
5887
- index15("permissions_is_active_idx").on(table.isActive),
5888
- index15("permissions_is_builtin_idx").on(table.isBuiltin)
5956
+ index16("permissions_name_idx").on(table.name),
5957
+ index16("permissions_category_idx").on(table.category),
5958
+ index16("permissions_is_system_idx").on(table.isSystem),
5959
+ index16("permissions_is_active_idx").on(table.isActive),
5960
+ index16("permissions_is_builtin_idx").on(table.isBuiltin)
5889
5961
  ]
5890
5962
  );
5891
5963
  }
5892
5964
  });
5893
5965
 
5894
5966
  // src/server/entities/role-permissions.ts
5895
- import { index as index16, unique } from "drizzle-orm/pg-core";
5896
- import { id as id18, timestamps as timestamps16, foreignKey as foreignKey12 } from "@spfn/core/db";
5967
+ import { index as index17, unique } from "drizzle-orm/pg-core";
5968
+ import { id as id19, timestamps as timestamps17, foreignKey as foreignKey13 } from "@spfn/core/db";
5897
5969
  var rolePermissions;
5898
5970
  var init_role_permissions = __esm({
5899
5971
  "src/server/entities/role-permissions.ts"() {
@@ -5905,25 +5977,25 @@ var init_role_permissions = __esm({
5905
5977
  "role_permissions",
5906
5978
  {
5907
5979
  // Primary key
5908
- id: id18(),
5980
+ id: id19(),
5909
5981
  // Role reference
5910
5982
  // Foreign key to roles table
5911
5983
  // Cascade delete: when role is deleted, all role-permission mappings are removed
5912
5984
  // Used for: defining which permissions each role has
5913
5985
  // Example: Admin role → [user:create, user:delete, user:update]
5914
- roleId: foreignKey12("role", () => roles.id, { onDelete: "cascade" }),
5986
+ roleId: foreignKey13("role", () => roles.id, { onDelete: "cascade" }),
5915
5987
  // Permission reference
5916
5988
  // Foreign key to permissions table
5917
5989
  // Cascade delete: when permission is deleted, all role-permission mappings are removed
5918
5990
  // Used for: granting permissions to roles
5919
5991
  // Example: user:delete permission → [Admin, Superadmin]
5920
- permissionId: foreignKey12("permission", () => permissions.id, { onDelete: "cascade" }),
5921
- ...timestamps16()
5992
+ permissionId: foreignKey13("permission", () => permissions.id, { onDelete: "cascade" }),
5993
+ ...timestamps17()
5922
5994
  },
5923
5995
  (table) => [
5924
5996
  // Indexes for query performance
5925
- index16("role_permissions_role_id_idx").on(table.roleId),
5926
- index16("role_permissions_permission_id_idx").on(table.permissionId),
5997
+ index17("role_permissions_role_id_idx").on(table.roleId),
5998
+ index17("role_permissions_permission_id_idx").on(table.permissionId),
5927
5999
  // Unique constraint: one role-permission pair only
5928
6000
  unique("role_permissions_unique").on(table.roleId, table.permissionId)
5929
6001
  ]
@@ -5932,8 +6004,8 @@ var init_role_permissions = __esm({
5932
6004
  });
5933
6005
 
5934
6006
  // src/server/entities/user-permissions.ts
5935
- import { boolean as boolean6, text as text19, index as index17, unique as unique2 } from "drizzle-orm/pg-core";
5936
- import { id as id19, timestamps as timestamps17, utcTimestamp as utcTimestamp16, foreignKey as foreignKey13 } from "@spfn/core/db";
6007
+ import { boolean as boolean6, text as text20, index as index18, unique as unique2 } from "drizzle-orm/pg-core";
6008
+ import { id as id20, timestamps as timestamps18, utcTimestamp as utcTimestamp17, foreignKey as foreignKey14 } from "@spfn/core/db";
5937
6009
  var userPermissions;
5938
6010
  var init_user_permissions = __esm({
5939
6011
  "src/server/entities/user-permissions.ts"() {
@@ -5945,15 +6017,15 @@ var init_user_permissions = __esm({
5945
6017
  "user_permissions",
5946
6018
  {
5947
6019
  // Primary key
5948
- id: id19(),
6020
+ id: id20(),
5949
6021
  // User reference
5950
6022
  // Foreign key to users table
5951
6023
  // Cascade delete: when user is deleted, all overrides are removed
5952
- userId: foreignKey13("user", () => users.id, { onDelete: "cascade" }),
6024
+ userId: foreignKey14("user", () => users.id, { onDelete: "cascade" }),
5953
6025
  // Permission reference
5954
6026
  // Foreign key to permissions table
5955
6027
  // Cascade delete: when permission is deleted, all overrides are removed
5956
- permissionId: foreignKey13("permission", () => permissions.id, { onDelete: "cascade" }),
6028
+ permissionId: foreignKey14("permission", () => permissions.id, { onDelete: "cascade" }),
5957
6029
  // Grant or revoke flag
5958
6030
  // true: GRANT this permission to the user (additive override)
5959
6031
  // - Grants permission even if role doesn't have it
@@ -5968,19 +6040,19 @@ var init_user_permissions = __esm({
5968
6040
  // Reason for grant/revocation
5969
6041
  // Used for: audit trail, compliance documentation
5970
6042
  // Example: "Temporary access for project X", "Security incident - restricted"
5971
- reason: text19("reason"),
6043
+ reason: text20("reason"),
5972
6044
  // Expiration timestamp (optional)
5973
6045
  // null: Permanent override (remains until manually removed)
5974
6046
  // timestamp: Permission expires at this time (auto-revoked by background job)
5975
6047
  // Use case: Time-limited elevated access, temporary restrictions
5976
- expiresAt: utcTimestamp16("expires_at"),
5977
- ...timestamps17()
6048
+ expiresAt: utcTimestamp17("expires_at"),
6049
+ ...timestamps18()
5978
6050
  },
5979
6051
  (table) => [
5980
6052
  // Indexes for query performance
5981
- index17("user_permissions_user_id_idx").on(table.userId),
5982
- index17("user_permissions_permission_id_idx").on(table.permissionId),
5983
- index17("user_permissions_expires_at_idx").on(table.expiresAt),
6053
+ index18("user_permissions_user_id_idx").on(table.userId),
6054
+ index18("user_permissions_permission_id_idx").on(table.permissionId),
6055
+ index18("user_permissions_expires_at_idx").on(table.expiresAt),
5984
6056
  // Unique constraint: one user-permission pair only
5985
6057
  unique2("user_permissions_unique").on(table.userId, table.permissionId)
5986
6058
  ]
@@ -5989,8 +6061,8 @@ var init_user_permissions = __esm({
5989
6061
  });
5990
6062
 
5991
6063
  // src/server/entities/oauth2-clients.ts
5992
- import { text as text20 } from "drizzle-orm/pg-core";
5993
- import { id as id20, timestamps as timestamps18, utcTimestamp as utcTimestamp17 } from "@spfn/core/db";
6064
+ import { text as text21 } from "drizzle-orm/pg-core";
6065
+ import { id as id21, timestamps as timestamps19, utcTimestamp as utcTimestamp18 } from "@spfn/core/db";
5994
6066
  var oauth2Clients;
5995
6067
  var init_oauth2_clients = __esm({
5996
6068
  "src/server/entities/oauth2-clients.ts"() {
@@ -5999,35 +6071,35 @@ var init_oauth2_clients = __esm({
5999
6071
  oauth2Clients = authSchema.table(
6000
6072
  "oauth2_clients",
6001
6073
  {
6002
- id: id20(),
6074
+ id: id21(),
6003
6075
  // The `client_id` the client sends on every later request. Random, opaque,
6004
6076
  // and the unique constraint doubles as the lookup index.
6005
- clientId: text20("client_id").notNull().unique(),
6077
+ clientId: text21("client_id").notNull().unique(),
6006
6078
  // Self-declared label, shown on the consent screen and nowhere else.
6007
6079
  // A client that lies about it gains one wrong line on that screen.
6008
- clientName: text20("client_name").notNull(),
6080
+ clientName: text21("client_name").notNull(),
6009
6081
  // Registered redirect URIs, stored exactly as the client wrote them.
6010
6082
  // The registration answer echoes them back verbatim, so normalising here
6011
6083
  // would answer a client with a URI it did not register. Matching
6012
6084
  // normalises both sides instead — see lib/oauth2/redirect-uri.ts.
6013
- redirectUris: text20("redirect_uris").array().notNull(),
6085
+ redirectUris: text21("redirect_uris").array().notNull(),
6014
6086
  // Client IP the registration arrived from, so the per-IP cap on clients
6015
6087
  // nobody has approved yet can be counted. Nullable: an IP is not always
6016
6088
  // knowable behind a proxy that forwards none, and a registration is not
6017
6089
  // worth refusing over that.
6018
- createdIp: text20("created_ip"),
6090
+ createdIp: text21("created_ip"),
6019
6091
  // Last token issuance or refresh under this client, updated
6020
6092
  // fire-and-forget. Operator-facing only; nothing is authorized by it.
6021
- lastUsedAt: utcTimestamp17("last_used_at"),
6022
- ...timestamps18()
6093
+ lastUsedAt: utcTimestamp18("last_used_at"),
6094
+ ...timestamps19()
6023
6095
  }
6024
6096
  );
6025
6097
  }
6026
6098
  });
6027
6099
 
6028
6100
  // src/server/entities/oauth2-grants.ts
6029
- import { uniqueIndex as uniqueIndex9, index as index18, text as text21 } from "drizzle-orm/pg-core";
6030
- import { id as id21, timestamps as timestamps19, utcTimestamp as utcTimestamp18, foreignKey as foreignKey14 } from "@spfn/core/db";
6101
+ import { uniqueIndex as uniqueIndex10, index as index19, text as text22 } from "drizzle-orm/pg-core";
6102
+ import { id as id22, timestamps as timestamps20, utcTimestamp as utcTimestamp19, foreignKey as foreignKey15 } from "@spfn/core/db";
6031
6103
  var oauth2Grants;
6032
6104
  var init_oauth2_grants = __esm({
6033
6105
  "src/server/entities/oauth2-grants.ts"() {
@@ -6038,39 +6110,39 @@ var init_oauth2_grants = __esm({
6038
6110
  oauth2Grants = authSchema.table(
6039
6111
  "oauth2_grants",
6040
6112
  {
6041
- id: id21(),
6113
+ id: id22(),
6042
6114
  // `oauth2_client_id` and not `client_id`: the opaque string a client
6043
6115
  // sends is called `client_id` everywhere in the protocol, and a bigint
6044
6116
  // foreign key under that name in this schema would read as that value.
6045
- client: foreignKey14("oauth2_client", () => oauth2Clients.id, { onDelete: "cascade" }),
6117
+ client: foreignKey15("oauth2_client", () => oauth2Clients.id, { onDelete: "cascade" }),
6046
6118
  // Whose consent this is. Read from the approving session at authorize
6047
6119
  // time, never from a request body — that is the whole authorization.
6048
- user: foreignKey14("user", () => users.id, { onDelete: "cascade" }),
6120
+ user: foreignKey15("user", () => users.id, { onDelete: "cascade" }),
6049
6121
  // The RFC 8707 target this consent is for, normalised (see lib/oauth2).
6050
6122
  // An access token is only good against the resource its grant names.
6051
- resource: text21("resource").notNull(),
6123
+ resource: text22("resource").notNull(),
6052
6124
  // Scope names the user approved. A refresh may ask for a subset of these
6053
6125
  // and never for more.
6054
- scopes: text21("scopes").array().notNull(),
6126
+ scopes: text22("scopes").array().notNull(),
6055
6127
  // null = live; a timestamp cuts off every code and token beneath it
6056
- revokedAt: utcTimestamp18("revoked_at"),
6057
- ...timestamps19()
6128
+ revokedAt: utcTimestamp19("revoked_at"),
6129
+ ...timestamps20()
6058
6130
  },
6059
6131
  (table) => [
6060
6132
  // One consent per (client, user, resource) — the re-consent path updates
6061
6133
  // this row rather than inserting beside it.
6062
- uniqueIndex9("oauth2_grant_client_user_resource_idx").on(table.client, table.user, table.resource),
6134
+ uniqueIndex10("oauth2_grant_client_user_resource_idx").on(table.client, table.user, table.resource),
6063
6135
  // The global-revocation path and the user's own grant list both address
6064
6136
  // rows by user alone.
6065
- index18("oauth2_grant_user_idx").on(table.user)
6137
+ index19("oauth2_grant_user_idx").on(table.user)
6066
6138
  ]
6067
6139
  );
6068
6140
  }
6069
6141
  });
6070
6142
 
6071
6143
  // src/server/entities/oauth2-authorization-codes.ts
6072
- import { text as text22 } from "drizzle-orm/pg-core";
6073
- import { id as id22, timestamps as timestamps20, utcTimestamp as utcTimestamp19, foreignKey as foreignKey15 } from "@spfn/core/db";
6144
+ import { text as text23 } from "drizzle-orm/pg-core";
6145
+ import { id as id23, timestamps as timestamps21, utcTimestamp as utcTimestamp20, foreignKey as foreignKey16 } from "@spfn/core/db";
6074
6146
  var oauth2AuthorizationCodes;
6075
6147
  var init_oauth2_authorization_codes = __esm({
6076
6148
  "src/server/entities/oauth2-authorization-codes.ts"() {
@@ -6080,33 +6152,33 @@ var init_oauth2_authorization_codes = __esm({
6080
6152
  oauth2AuthorizationCodes = authSchema.table(
6081
6153
  "oauth2_authorization_codes",
6082
6154
  {
6083
- id: id22(),
6155
+ id: id23(),
6084
6156
  // SHA-256 hex of the code. The code itself (32 random bytes, base64url)
6085
6157
  // is in the redirect and nowhere else.
6086
- codeHash: text22("code_hash").notNull().unique(),
6087
- grant: foreignKey15("oauth2_grant", () => oauth2Grants.id, { onDelete: "cascade" }),
6158
+ codeHash: text23("code_hash").notNull().unique(),
6159
+ grant: foreignKey16("oauth2_grant", () => oauth2Grants.id, { onDelete: "cascade" }),
6088
6160
  // The exact redirect_uri string the authorize request carried. The token
6089
6161
  // request must repeat it character for character (RFC 6749 §4.1.3).
6090
- redirectUri: text22("redirect_uri").notNull(),
6162
+ redirectUri: text23("redirect_uri").notNull(),
6091
6163
  // The PKCE S256 challenge. `code_verifier` at the token endpoint is
6092
6164
  // hashed and compared against this; `plain` is not accepted anywhere,
6093
6165
  // so no method column is needed.
6094
- codeChallenge: text22("code_challenge").notNull(),
6166
+ codeChallenge: text23("code_challenge").notNull(),
6095
6167
  // 60 seconds from issuance. Judged by the database in the statement that
6096
6168
  // spends the row, not only in a read before it.
6097
- expiresAt: utcTimestamp19("expires_at").notNull(),
6169
+ expiresAt: utcTimestamp20("expires_at").notNull(),
6098
6170
  // null = unspent. Non-null means spent, and a second presentation of the
6099
6171
  // same code revokes the grant.
6100
- usedAt: utcTimestamp19("used_at"),
6101
- ...timestamps20()
6172
+ usedAt: utcTimestamp20("used_at"),
6173
+ ...timestamps21()
6102
6174
  }
6103
6175
  );
6104
6176
  }
6105
6177
  });
6106
6178
 
6107
6179
  // src/server/entities/oauth2-tokens.ts
6108
- import { index as index19, text as text23 } from "drizzle-orm/pg-core";
6109
- import { id as id23, timestamps as timestamps21, enumText as enumText12, utcTimestamp as utcTimestamp20, foreignKey as foreignKey16 } from "@spfn/core/db";
6180
+ import { index as index20, text as text24 } from "drizzle-orm/pg-core";
6181
+ import { id as id24, timestamps as timestamps22, enumText as enumText13, utcTimestamp as utcTimestamp21, foreignKey as foreignKey17 } from "@spfn/core/db";
6110
6182
  var OAUTH2_TOKEN_KINDS, oauth2Tokens;
6111
6183
  var init_oauth2_tokens = __esm({
6112
6184
  "src/server/entities/oauth2-tokens.ts"() {
@@ -6117,30 +6189,30 @@ var init_oauth2_tokens = __esm({
6117
6189
  oauth2Tokens = authSchema.table(
6118
6190
  "oauth2_tokens",
6119
6191
  {
6120
- id: id23(),
6192
+ id: id24(),
6121
6193
  // SHA-256 hex of the token value. Lookup key; the unique constraint
6122
6194
  // doubles as the index.
6123
- tokenHash: text23("token_hash").notNull().unique(),
6124
- kind: enumText12("kind", OAUTH2_TOKEN_KINDS).notNull(),
6125
- grant: foreignKey16("oauth2_grant", () => oauth2Grants.id, { onDelete: "cascade" }),
6195
+ tokenHash: text24("token_hash").notNull().unique(),
6196
+ kind: enumText13("kind", OAUTH2_TOKEN_KINDS).notNull(),
6197
+ grant: foreignKey17("oauth2_grant", () => oauth2Grants.id, { onDelete: "cascade" }),
6126
6198
  // What this particular token carries, which may be narrower than its
6127
6199
  // grant's scopes: a refresh request is allowed to ask for a subset.
6128
6200
  // Never wider — that is `invalid_scope`.
6129
- scopes: text23("scopes").array().notNull(),
6130
- expiresAt: utcTimestamp20("expires_at").notNull(),
6201
+ scopes: text24("scopes").array().notNull(),
6202
+ expiresAt: utcTimestamp21("expires_at").notNull(),
6131
6203
  // null = live. Set by revoke, by a grant revocation, and by the two
6132
6204
  // replay detections (code reuse, refresh reuse).
6133
- revokedAt: utcTimestamp20("revoked_at"),
6205
+ revokedAt: utcTimestamp21("revoked_at"),
6134
6206
  // Refresh only: set when a rotation issued the successor. A row with
6135
6207
  // this set is spent, and presenting it revokes the grant.
6136
- replacedAt: utcTimestamp20("replaced_at"),
6208
+ replacedAt: utcTimestamp21("replaced_at"),
6137
6209
  // Last successful verification, updated fire-and-forget like ops tokens
6138
- lastUsedAt: utcTimestamp20("last_used_at"),
6139
- ...timestamps21()
6210
+ lastUsedAt: utcTimestamp21("last_used_at"),
6211
+ ...timestamps22()
6140
6212
  },
6141
6213
  (table) => [
6142
6214
  // Revocation addresses every token under one grant.
6143
- index19("oauth2_token_grant_idx").on(table.grant)
6215
+ index20("oauth2_token_grant_idx").on(table.grant)
6144
6216
  ]
6145
6217
  );
6146
6218
  }
@@ -6148,7 +6220,7 @@ var init_oauth2_tokens = __esm({
6148
6220
 
6149
6221
  // src/server/entities/auth-metadata.ts
6150
6222
  import { sql as sql3 } from "drizzle-orm";
6151
- import { text as text24, timestamp } from "drizzle-orm/pg-core";
6223
+ import { text as text25, timestamp } from "drizzle-orm/pg-core";
6152
6224
  var authMetadata;
6153
6225
  var init_auth_metadata = __esm({
6154
6226
  "src/server/entities/auth-metadata.ts"() {
@@ -6158,9 +6230,9 @@ var init_auth_metadata = __esm({
6158
6230
  "auth_metadata",
6159
6231
  {
6160
6232
  // Metadata key (primary key)
6161
- key: text24("key").primaryKey(),
6233
+ key: text25("key").primaryKey(),
6162
6234
  // Metadata value
6163
- value: text24("value").notNull(),
6235
+ value: text25("value").notNull(),
6164
6236
  // Last updated timestamp — stamped by the database on insert and on update
6165
6237
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => sql3`now()`)
6166
6238
  }
@@ -6169,8 +6241,8 @@ var init_auth_metadata = __esm({
6169
6241
  });
6170
6242
 
6171
6243
  // src/server/entities/ops-tokens.ts
6172
- import { text as text25 } from "drizzle-orm/pg-core";
6173
- import { id as id24, timestamps as timestamps22, utcTimestamp as utcTimestamp21 } from "@spfn/core/db";
6244
+ import { text as text26 } from "drizzle-orm/pg-core";
6245
+ import { id as id25, timestamps as timestamps23, utcTimestamp as utcTimestamp22 } from "@spfn/core/db";
6174
6246
  var opsTokens;
6175
6247
  var init_ops_tokens = __esm({
6176
6248
  "src/server/entities/ops-tokens.ts"() {
@@ -6179,22 +6251,22 @@ var init_ops_tokens = __esm({
6179
6251
  opsTokens = authSchema.table(
6180
6252
  "ops_tokens",
6181
6253
  {
6182
- id: id24(),
6254
+ id: id25(),
6183
6255
  // Operator-facing label ("ci-deploy", "rayim-laptop")
6184
- name: text25("name").notNull(),
6256
+ name: text26("name").notNull(),
6185
6257
  // SHA-256 hex of the token secret. Lookup key — the secret never lands
6186
6258
  // here, and the unique constraint doubles as the lookup index.
6187
- tokenHash: text25("token_hash").notNull().unique(),
6259
+ tokenHash: text26("token_hash").notNull().unique(),
6188
6260
  // Granted scopes as permission strings ('waitlist:read', ...).
6189
6261
  // '*' grants every scope.
6190
- scopes: text25("scopes").array().notNull(),
6262
+ scopes: text26("scopes").array().notNull(),
6191
6263
  // null = the token does not expire
6192
- expiresAt: utcTimestamp21("expires_at"),
6264
+ expiresAt: utcTimestamp22("expires_at"),
6193
6265
  // null = active; a timestamp revokes the token permanently
6194
- revokedAt: utcTimestamp21("revoked_at"),
6266
+ revokedAt: utcTimestamp22("revoked_at"),
6195
6267
  // Last successful verification, updated fire-and-forget
6196
- lastUsedAt: utcTimestamp21("last_used_at"),
6197
- ...timestamps22()
6268
+ lastUsedAt: utcTimestamp22("last_used_at"),
6269
+ ...timestamps23()
6198
6270
  }
6199
6271
  );
6200
6272
  }
@@ -6218,6 +6290,7 @@ var init_entities = __esm({
6218
6290
  init_mfa_totp();
6219
6291
  init_mfa_recovery_codes();
6220
6292
  init_mfa_verifications();
6293
+ init_mfa_challenges();
6221
6294
  init_device_authorizations();
6222
6295
  init_user_invitations();
6223
6296
  init_account_deletion_requests();
@@ -6245,12 +6318,26 @@ var init_users_repository = __esm({
6245
6318
  init_entities();
6246
6319
  init_email();
6247
6320
  UsersRepository = class extends BaseRepository {
6321
+ /**
6322
+ * The account's current key generation.
6323
+ *
6324
+ * Read from the primary, and this is the one place that matters: the value
6325
+ * is stamped onto a second-factor challenge that must die with the
6326
+ * generation it was minted in, and a replica lagging behind a revoke-all
6327
+ * would stamp the generation that has just ended.
6328
+ *
6329
+ * Write primary 사용
6330
+ */
6331
+ async currentKeyEpoch(id26) {
6332
+ const result = await this.db.select({ keyEpoch: users.keyEpoch }).from(users).where(eq(users.id, id26)).limit(1);
6333
+ return result[0]?.keyEpoch ?? 0;
6334
+ }
6248
6335
  /**
6249
6336
  * ID로 사용자 조회
6250
6337
  * Read replica 사용
6251
6338
  */
6252
- async findById(id25) {
6253
- const result = await this.readDb.select().from(users).where(eq(users.id, id25)).limit(1);
6339
+ async findById(id26) {
6340
+ const result = await this.readDb.select().from(users).where(eq(users.id, id26)).limit(1);
6254
6341
  return result[0] ?? null;
6255
6342
  }
6256
6343
  /**
@@ -6260,8 +6347,8 @@ var init_users_repository = __esm({
6260
6347
  * 안 되는 게이트(OAuth 세션 발급 등)가 사용한다. 일반 조회는 `findById`(replica)를
6261
6348
  * 계속 사용할 것.
6262
6349
  */
6263
- async findByIdOnPrimary(id25) {
6264
- const result = await this.db.select().from(users).where(eq(users.id, id25)).limit(1);
6350
+ async findByIdOnPrimary(id26) {
6351
+ const result = await this.db.select().from(users).where(eq(users.id, id26)).limit(1);
6265
6352
  return result[0] ?? null;
6266
6353
  }
6267
6354
  /**
@@ -6277,8 +6364,8 @@ var init_users_repository = __esm({
6277
6364
  * Only meaningful inside a transaction — the lock is released at commit.
6278
6365
  * Write primary.
6279
6366
  */
6280
- async lockById(id25) {
6281
- await this.db.select({ id: users.id }).from(users).where(eq(users.id, id25)).for("update");
6367
+ async lockById(id26) {
6368
+ await this.db.select({ id: users.id }).from(users).where(eq(users.id, id26)).for("update");
6282
6369
  }
6283
6370
  /**
6284
6371
  * 이메일로 사용자 조회
@@ -6348,13 +6435,13 @@ var init_users_repository = __esm({
6348
6435
  *
6349
6436
  * roleId가 null인 유저는 role: null 반환
6350
6437
  */
6351
- async findByIdWithRole(id25) {
6438
+ async findByIdWithRole(id26) {
6352
6439
  const result = await this.readDb.select({
6353
6440
  user: users,
6354
6441
  roleName: roles.name,
6355
6442
  roleDisplayName: roles.displayName,
6356
6443
  rolePriority: roles.priority
6357
- }).from(users).leftJoin(roles, eq(users.roleId, roles.id)).where(eq(users.id, id25)).limit(1);
6444
+ }).from(users).leftJoin(roles, eq(users.roleId, roles.id)).where(eq(users.id, id26)).limit(1);
6358
6445
  const row = result[0];
6359
6446
  if (!row) {
6360
6447
  return null;
@@ -6429,9 +6516,9 @@ var init_users_repository = __esm({
6429
6516
  * 사용자 정보 업데이트
6430
6517
  * Write primary 사용
6431
6518
  */
6432
- async updateById(id25, data) {
6519
+ async updateById(id26, data) {
6433
6520
  const patch = "email" in data ? { ...data, email: normalizeOptionalEmail(data.email) } : data;
6434
- const result = await this.db.update(users).set(patch).where(eq(users.id, id25)).returning();
6521
+ const result = await this.db.update(users).set(patch).where(eq(users.id, id26)).returning();
6435
6522
  return result[0] ?? null;
6436
6523
  }
6437
6524
  /**
@@ -6443,10 +6530,10 @@ var init_users_repository = __esm({
6443
6530
  * status가 바뀐 상태) 시 null을 반환하며 예외를 던지지 않는다.
6444
6531
  * Write primary 사용
6445
6532
  */
6446
- async reactivateFromPendingDeletion(id25) {
6533
+ async reactivateFromPendingDeletion(id26) {
6447
6534
  const result = await this.db.update(users).set({ status: "active" }).where(
6448
6535
  and(
6449
- eq(users.id, id25),
6536
+ eq(users.id, id26),
6450
6537
  eq(users.status, "pending_deletion")
6451
6538
  )
6452
6539
  ).returning();
@@ -6456,32 +6543,32 @@ var init_users_repository = __esm({
6456
6543
  * 비밀번호 업데이트
6457
6544
  * Write primary 사용
6458
6545
  */
6459
- async updatePassword(id25, passwordHash, clearPasswordChangeRequired = true) {
6546
+ async updatePassword(id26, passwordHash, clearPasswordChangeRequired = true) {
6460
6547
  const updateData = {
6461
6548
  passwordHash
6462
6549
  };
6463
6550
  if (clearPasswordChangeRequired) {
6464
6551
  updateData.passwordChangeRequired = false;
6465
6552
  }
6466
- const result = await this.db.update(users).set(updateData).where(eq(users.id, id25)).returning();
6553
+ const result = await this.db.update(users).set(updateData).where(eq(users.id, id26)).returning();
6467
6554
  return result[0] ?? null;
6468
6555
  }
6469
6556
  /**
6470
6557
  * 마지막 로그인 시간 업데이트
6471
6558
  * Write primary 사용
6472
6559
  */
6473
- async updateLastLogin(id25) {
6560
+ async updateLastLogin(id26) {
6474
6561
  const result = await this.db.update(users).set({
6475
6562
  lastLoginAt: /* @__PURE__ */ new Date()
6476
- }).where(eq(users.id, id25)).returning();
6563
+ }).where(eq(users.id, id26)).returning();
6477
6564
  return result[0] ?? null;
6478
6565
  }
6479
6566
  /**
6480
6567
  * 사용자 삭제
6481
6568
  * Write primary 사용
6482
6569
  */
6483
- async deleteById(id25) {
6484
- const result = await this.db.delete(users).where(eq(users.id, id25)).returning();
6570
+ async deleteById(id26) {
6571
+ const result = await this.db.delete(users).where(eq(users.id, id26)).returning();
6485
6572
  return result[0] ?? null;
6486
6573
  }
6487
6574
  /**
@@ -6839,12 +6926,15 @@ function assertPasskeyConfig(env21 = passkeyEnvSource()) {
6839
6926
  );
6840
6927
  }
6841
6928
  }
6929
+ function minutesOr(configured2, fallback) {
6930
+ const minutes = Number(configured2);
6931
+ return Number.isFinite(minutes) && minutes > 0 ? minutes : fallback;
6932
+ }
6842
6933
  function getMfaConfig() {
6843
- const configuredMinutes = Number(process.env.SPFN_AUTH_MFA_STEP_UP_MINUTES);
6844
- const minutes = Number.isFinite(configuredMinutes) && configuredMinutes > 0 ? configuredMinutes : DEFAULT_STEP_UP_MINUTES;
6845
6934
  return {
6846
6935
  issuer: process.env.SPFN_AUTH_MFA_ISSUER?.trim() || process.env.SPFN_AUTH_PASSKEY_RP_NAME?.trim() || mfaIssuerFromAppUrl() || DEFAULT_MFA_ISSUER,
6847
- stepUpWindowMs: minutes * 6e4
6936
+ stepUpWindowMs: minutesOr(process.env.SPFN_AUTH_MFA_STEP_UP_MINUTES, DEFAULT_STEP_UP_MINUTES) * 6e4,
6937
+ challengeTtlMs: minutesOr(process.env.SPFN_AUTH_MFA_CHALLENGE_TTL_MINUTES, DEFAULT_CHALLENGE_TTL_MINUTES) * 6e4
6848
6938
  };
6849
6939
  }
6850
6940
  function mfaIssuerFromAppUrl() {
@@ -6858,7 +6948,7 @@ function mfaIssuerFromAppUrl() {
6858
6948
  return null;
6859
6949
  }
6860
6950
  }
6861
- var COOKIE_NAMES, globalConfig, CSRF_MODES, unrecognizedCsrfModeReported, PACKAGE_CSRF_EXEMPT_PATHS, DEFAULT_SESSION_RENEW_PATH, PASSKEY_USER_VERIFICATIONS, DEFAULT_CHALLENGE_TTL_SECONDS, DEFAULT_RECENT_AUTH_MINUTES, PASSKEY_VARS, DEFAULT_MFA_ISSUER, DEFAULT_STEP_UP_MINUTES;
6951
+ var COOKIE_NAMES, globalConfig, CSRF_MODES, unrecognizedCsrfModeReported, PACKAGE_CSRF_EXEMPT_PATHS, DEFAULT_SESSION_RENEW_PATH, PASSKEY_USER_VERIFICATIONS, DEFAULT_CHALLENGE_TTL_SECONDS, DEFAULT_RECENT_AUTH_MINUTES, PASSKEY_VARS, DEFAULT_MFA_ISSUER, DEFAULT_STEP_UP_MINUTES, DEFAULT_CHALLENGE_TTL_MINUTES;
6862
6952
  var init_config = __esm({
6863
6953
  "src/server/lib/config.ts"() {
6864
6954
  "use strict";
@@ -6878,6 +6968,18 @@ var init_config = __esm({
6878
6968
  get OAUTH_PENDING() {
6879
6969
  return `spfn_oauth_pending${getCookieSuffix()}`;
6880
6970
  },
6971
+ /**
6972
+ * Pending second-factor session (privateKey, keyId, challengeHash) (#95)
6973
+ *
6974
+ * Its own name and its own audience, separate from OAUTH_PENDING. The two
6975
+ * coexist: a person who starts a social login in one tab while a password
6976
+ * step-up is outstanding in another has both flows live, and one name would
6977
+ * mean the second overwrote the first — sealing a session with a private key
6978
+ * that does not match the key being activated.
6979
+ */
6980
+ get MFA_PENDING() {
6981
+ return `spfn_mfa_pending${getCookieSuffix()}`;
6982
+ },
6881
6983
  /** OAuth CSRF nonce — double-submit against the (encrypted) state.nonce at callback */
6882
6984
  get OAUTH_CSRF() {
6883
6985
  return `spfn_oauth_csrf${getCookieSuffix()}`;
@@ -6920,6 +7022,7 @@ var init_config = __esm({
6920
7022
  ];
6921
7023
  DEFAULT_MFA_ISSUER = "SPFN";
6922
7024
  DEFAULT_STEP_UP_MINUTES = 10;
7025
+ DEFAULT_CHALLENGE_TTL_MINUTES = 10;
6923
7026
  }
6924
7027
  });
6925
7028
 
@@ -6932,6 +7035,7 @@ var init_keys_repository = __esm({
6932
7035
  "use strict";
6933
7036
  init_user_public_keys();
6934
7037
  init_users();
7038
+ init_mfa_challenges();
6935
7039
  init_config();
6936
7040
  LAST_USED_THROTTLE_MS = 6e4;
6937
7041
  KeysRepository = class extends BaseRepository2 {
@@ -6979,6 +7083,12 @@ var init_keys_repository = __esm({
6979
7083
  * `lastSeenIp` is not selected, deliberately. The concurrent-use moment is
6980
7084
  * what the owner acts on; the trail of addresses behind it is stored PII the
6981
7085
  * account surface has no use for.
7086
+ *
7087
+ * A key still waiting on a second factor is excluded in **both** modes
7088
+ * (#95). It is not a device that can sign, so it does not belong in the
7089
+ * default list; and it is not a device the owner signed out either, so it
7090
+ * does not belong in the revoked one — where it would read exactly like a
7091
+ * revoked key with no revocation time, which is a different thing entirely.
6982
7092
  * Read replica 사용
6983
7093
  */
6984
7094
  async listForUser(userId, includeRevoked = false) {
@@ -6997,12 +7107,11 @@ var init_keys_repository = __esm({
6997
7107
  registeredUserAgent: userPublicKeys.registeredUserAgent,
6998
7108
  binding: userPublicKeys.binding,
6999
7109
  concurrentUseAt: userPublicKeys.concurrentUseAt
7000
- }).from(userPublicKeys).where(
7001
- includeRevoked ? eq2(userPublicKeys.userId, userId) : and2(
7002
- eq2(userPublicKeys.userId, userId),
7003
- eq2(userPublicKeys.isActive, true)
7004
- )
7005
- ).orderBy(desc(userPublicKeys.createdAt));
7110
+ }).from(userPublicKeys).where(and2(
7111
+ eq2(userPublicKeys.userId, userId),
7112
+ isNull(userPublicKeys.pendingMfaChallengeId),
7113
+ includeRevoked ? void 0 : eq2(userPublicKeys.isActive, true)
7114
+ )).orderBy(desc(userPublicKeys.createdAt));
7006
7115
  }
7007
7116
  /**
7008
7117
  * 공개키 생성
@@ -7071,6 +7180,17 @@ var init_keys_repository = __esm({
7071
7180
  * active keys still had its generation ended, and an outstanding
7072
7181
  * sign-out-everywhere link must die with it.
7073
7182
  *
7183
+ * The two second-factor CTEs are here for the same "no caller can forget"
7184
+ * reason (#95). A pending key is `is_active = false`, so the UPDATE below
7185
+ * does not touch it and neither would anything else: the owner who sees an
7186
+ * unexpected second-factor prompt and does exactly what the notice says —
7187
+ * change the password, sign out everywhere, open the revoke-all link, run a
7188
+ * reset — would otherwise leave the attacker's pending key and its live
7189
+ * challenge untouched. Deleting the key cascades onto its challenge; the
7190
+ * expiry beside it catches a challenge whose key is already gone. A pending
7191
+ * row and an active row are disjoint sets, so the two statements never
7192
+ * contend for the same row.
7193
+ *
7074
7194
  * Write primary 사용
7075
7195
  */
7076
7196
  async revokeActive(userId, reason, keepKeyId) {
@@ -7080,6 +7200,18 @@ var init_keys_repository = __esm({
7080
7200
  UPDATE ${users}
7081
7201
  SET key_epoch = key_epoch + 1
7082
7202
  WHERE id = ${userId}
7203
+ ),
7204
+ challenges_expired AS (
7205
+ UPDATE ${mfaChallenges}
7206
+ SET expires_at = now()
7207
+ WHERE user_id = ${userId}
7208
+ AND verified_at IS NULL
7209
+ AND expires_at > now()
7210
+ ),
7211
+ pending_dropped AS (
7212
+ DELETE FROM ${userPublicKeys}
7213
+ WHERE user_id = ${userId}
7214
+ AND pending_mfa_challenge_id IS NOT NULL
7083
7215
  )
7084
7216
  UPDATE ${userPublicKeys} t
7085
7217
  SET is_active = false,
@@ -7234,7 +7366,7 @@ var init_keys_repository = __esm({
7234
7366
  * replica-lagged address compared in application code both misses real
7235
7367
  * switches and invents ones that did not happen.
7236
7368
  */
7237
- async updateLastUsedById(id25, identity, ip) {
7369
+ async updateLastUsedById(id26, identity, ip) {
7238
7370
  const now = /* @__PURE__ */ new Date();
7239
7371
  const nowParam = sql5`${now.toISOString()}::timestamptz`;
7240
7372
  const staleBefore = new Date(Date.now() - LAST_USED_THROTTLE_MS);
@@ -7275,7 +7407,7 @@ var init_keys_repository = __esm({
7275
7407
  clientSeenAt: sql5`CASE WHEN ${identityChanged} THEN ${nowParam} ELSE ${userPublicKeys.clientSeenAt} END`
7276
7408
  } : {}
7277
7409
  }).where(and2(
7278
- eq2(userPublicKeys.id, id25),
7410
+ eq2(userPublicKeys.id, id26),
7279
7411
  or(lastUsedIsStale, identityChanged, sql5`(${ipChanged} AND ${notStampedThisWindow})`)
7280
7412
  ));
7281
7413
  }
@@ -7326,8 +7458,8 @@ var init_verification_codes_repository = __esm({
7326
7458
  * ID로 인증 코드 조회
7327
7459
  * Read replica 사용
7328
7460
  */
7329
- async findById(id25) {
7330
- const result = await this.readDb.select().from(verificationCodes).where(eq3(verificationCodes.id, id25)).limit(1);
7461
+ async findById(id26) {
7462
+ const result = await this.readDb.select().from(verificationCodes).where(eq3(verificationCodes.id, id26)).limit(1);
7331
7463
  return result[0] ?? null;
7332
7464
  }
7333
7465
  /**
@@ -7341,22 +7473,22 @@ var init_verification_codes_repository = __esm({
7341
7473
  * 인증 코드 사용 처리
7342
7474
  * Write primary 사용
7343
7475
  */
7344
- async markAsUsed(id25) {
7476
+ async markAsUsed(id26) {
7345
7477
  const result = await this.db.update(verificationCodes).set({
7346
7478
  usedAt: /* @__PURE__ */ new Date()
7347
- }).where(eq3(verificationCodes.id, id25)).returning();
7479
+ }).where(eq3(verificationCodes.id, id26)).returning();
7348
7480
  return result[0] ?? null;
7349
7481
  }
7350
7482
  /**
7351
7483
  * 시도 횟수 증가
7352
7484
  * Write primary 사용
7353
7485
  */
7354
- async incrementAttempts(id25) {
7355
- const code = await this.findById(id25);
7486
+ async incrementAttempts(id26) {
7487
+ const code = await this.findById(id26);
7356
7488
  if (!code) return null;
7357
7489
  const result = await this.db.update(verificationCodes).set({
7358
7490
  attempts: code.attempts + 1
7359
- }).where(eq3(verificationCodes.id, id25)).returning();
7491
+ }).where(eq3(verificationCodes.id, id26)).returning();
7360
7492
  return result[0] ?? null;
7361
7493
  }
7362
7494
  /**
@@ -7440,10 +7572,10 @@ var init_signup_link_tokens_repository = __esm({
7440
7572
  *
7441
7573
  * @returns the issued row, or null if the row is no longer deliverable
7442
7574
  */
7443
- async issue(id25, tokenHash) {
7575
+ async issue(id26, tokenHash) {
7444
7576
  const result = await this.db.update(signupLinkTokens).set({ tokenHash }).where(
7445
7577
  and4(
7446
- eq4(signupLinkTokens.id, id25),
7578
+ eq4(signupLinkTokens.id, id26),
7447
7579
  isNull3(signupLinkTokens.consumedAt),
7448
7580
  isNull3(signupLinkTokens.supersededAt),
7449
7581
  isNull3(signupLinkTokens.completedAt),
@@ -7479,14 +7611,14 @@ var init_signup_link_tokens_repository = __esm({
7479
7611
  *
7480
7612
  * @returns the updated row, or null if another request claimed it first
7481
7613
  */
7482
- async claimLink(id25, setupSecretHash, setupExpiresAt) {
7614
+ async claimLink(id26, setupSecretHash, setupExpiresAt) {
7483
7615
  const result = await this.db.update(signupLinkTokens).set({
7484
7616
  consumedAt: /* @__PURE__ */ new Date(),
7485
7617
  setupSecretHash,
7486
7618
  setupExpiresAt
7487
7619
  }).where(
7488
7620
  and4(
7489
- eq4(signupLinkTokens.id, id25),
7621
+ eq4(signupLinkTokens.id, id26),
7490
7622
  isNull3(signupLinkTokens.consumedAt),
7491
7623
  isNull3(signupLinkTokens.supersededAt)
7492
7624
  )
@@ -7499,10 +7631,10 @@ var init_signup_link_tokens_repository = __esm({
7499
7631
  *
7500
7632
  * @returns the updated row, or null if another request completed it first
7501
7633
  */
7502
- async claimSetupSession(id25) {
7634
+ async claimSetupSession(id26) {
7503
7635
  const result = await this.db.update(signupLinkTokens).set({ completedAt: /* @__PURE__ */ new Date() }).where(
7504
7636
  and4(
7505
- eq4(signupLinkTokens.id, id25),
7637
+ eq4(signupLinkTokens.id, id26),
7506
7638
  isNull3(signupLinkTokens.completedAt),
7507
7639
  isNull3(signupLinkTokens.supersededAt)
7508
7640
  )
@@ -7584,10 +7716,10 @@ var init_password_reset_tokens_repository = __esm({
7584
7716
  *
7585
7717
  * @returns the issued row, or null if the row is no longer deliverable
7586
7718
  */
7587
- async issue(id25, tokenHash) {
7719
+ async issue(id26, tokenHash) {
7588
7720
  const result = await this.db.update(passwordResetTokens).set({ tokenHash }).where(
7589
7721
  and5(
7590
- eq5(passwordResetTokens.id, id25),
7722
+ eq5(passwordResetTokens.id, id26),
7591
7723
  isNull4(passwordResetTokens.consumedAt),
7592
7724
  isNull4(passwordResetTokens.supersededAt),
7593
7725
  isNull4(passwordResetTokens.completedAt),
@@ -7635,14 +7767,14 @@ var init_password_reset_tokens_repository = __esm({
7635
7767
  *
7636
7768
  * @returns the updated row, or null if another request claimed it first
7637
7769
  */
7638
- async consume(id25, setupSecretHash, setupExpiresAt) {
7770
+ async consume(id26, setupSecretHash, setupExpiresAt) {
7639
7771
  const result = await this.db.update(passwordResetTokens).set({
7640
7772
  consumedAt: /* @__PURE__ */ new Date(),
7641
7773
  setupSecretHash,
7642
7774
  setupExpiresAt
7643
7775
  }).where(
7644
7776
  and5(
7645
- eq5(passwordResetTokens.id, id25),
7777
+ eq5(passwordResetTokens.id, id26),
7646
7778
  isNull4(passwordResetTokens.consumedAt),
7647
7779
  isNull4(passwordResetTokens.supersededAt)
7648
7780
  )
@@ -7655,10 +7787,10 @@ var init_password_reset_tokens_repository = __esm({
7655
7787
  *
7656
7788
  * @returns the updated row, or null if another request completed it first
7657
7789
  */
7658
- async complete(id25) {
7790
+ async complete(id26) {
7659
7791
  const result = await this.db.update(passwordResetTokens).set({ completedAt: /* @__PURE__ */ new Date() }).where(
7660
7792
  and5(
7661
- eq5(passwordResetTokens.id, id25),
7793
+ eq5(passwordResetTokens.id, id26),
7662
7794
  isNull4(passwordResetTokens.completedAt),
7663
7795
  isNull4(passwordResetTokens.supersededAt)
7664
7796
  )
@@ -7856,9 +7988,9 @@ var init_passkeys_repository = __esm({
7856
7988
  * Owner-scoped, so an id belonging to someone else answers null rather than
7857
7989
  * a row — a management route can only ever say "not yours".
7858
7990
  */
7859
- async findLiveByIdAndUserId(id25, userId) {
7991
+ async findLiveByIdAndUserId(id26, userId) {
7860
7992
  const result = await this.db.select().from(passkeys).where(and7(
7861
- eq7(passkeys.id, id25),
7993
+ eq7(passkeys.id, id26),
7862
7994
  eq7(passkeys.userId, userId),
7863
7995
  isNull6(passkeys.revokedAt)
7864
7996
  )).limit(1);
@@ -7868,17 +8000,17 @@ var init_passkeys_repository = __esm({
7868
8000
  * Record a successful assertion.
7869
8001
  * Write primary.
7870
8002
  */
7871
- async recordUse(id25, counter) {
7872
- await this.db.update(passkeys).set({ counter, lastUsedAt: /* @__PURE__ */ new Date() }).where(eq7(passkeys.id, id25));
8003
+ async recordUse(id26, counter) {
8004
+ await this.db.update(passkeys).set({ counter, lastUsedAt: /* @__PURE__ */ new Date() }).where(eq7(passkeys.id, id26));
7873
8005
  }
7874
8006
  /**
7875
8007
  * Rename, but only a credential this user still owns and has not revoked.
7876
8008
  *
7877
8009
  * @returns the updated row, or null if it is not theirs or already revoked
7878
8010
  */
7879
- async renameByIdAndUserId(id25, userId, label) {
8011
+ async renameByIdAndUserId(id26, userId, label) {
7880
8012
  const result = await this.db.update(passkeys).set({ label }).where(and7(
7881
- eq7(passkeys.id, id25),
8013
+ eq7(passkeys.id, id26),
7882
8014
  eq7(passkeys.userId, userId),
7883
8015
  isNull6(passkeys.revokedAt)
7884
8016
  )).returning();
@@ -7893,9 +8025,9 @@ var init_passkeys_repository = __esm({
7893
8025
  *
7894
8026
  * @returns the updated row, or null if it is not theirs or already revoked
7895
8027
  */
7896
- async revokeByIdAndUserId(id25, userId, reason) {
8028
+ async revokeByIdAndUserId(id26, userId, reason) {
7897
8029
  const result = await this.db.update(passkeys).set({ revokedAt: /* @__PURE__ */ new Date(), revokedReason: reason }).where(and7(
7898
- eq7(passkeys.id, id25),
8030
+ eq7(passkeys.id, id26),
7899
8031
  eq7(passkeys.userId, userId),
7900
8032
  isNull6(passkeys.revokedAt)
7901
8033
  )).returning();
@@ -7910,9 +8042,9 @@ var init_passkeys_repository = __esm({
7910
8042
  *
7911
8043
  * @returns the updated row, or null if it is not theirs or already revoked
7912
8044
  */
7913
- async markSecondFactorByIdAndUserId(id25, userId, secondFactor) {
8045
+ async markSecondFactorByIdAndUserId(id26, userId, secondFactor) {
7914
8046
  const result = await this.db.update(passkeys).set({ secondFactor }).where(and7(
7915
- eq7(passkeys.id, id25),
8047
+ eq7(passkeys.id, id26),
7916
8048
  eq7(passkeys.userId, userId),
7917
8049
  isNull6(passkeys.revokedAt)
7918
8050
  )).returning();
@@ -8133,8 +8265,8 @@ var init_mfa_recovery_codes_repository = __esm({
8133
8265
  *
8134
8266
  * @returns true when this call spent the row
8135
8267
  */
8136
- async consume(id25) {
8137
- const result = await this.db.update(mfaRecoveryCodes).set({ usedAt: /* @__PURE__ */ new Date() }).where(and10(eq10(mfaRecoveryCodes.id, id25), isNull9(mfaRecoveryCodes.usedAt))).returning();
8268
+ async consume(id26) {
8269
+ const result = await this.db.update(mfaRecoveryCodes).set({ usedAt: /* @__PURE__ */ new Date() }).where(and10(eq10(mfaRecoveryCodes.id, id26), isNull9(mfaRecoveryCodes.usedAt))).returning();
8138
8270
  return result.length > 0;
8139
8271
  }
8140
8272
  /** Drop every generation the account has. Write primary. */
@@ -8186,9 +8318,161 @@ var init_mfa_verifications_repository = __esm({
8186
8318
  }
8187
8319
  });
8188
8320
 
8189
- // src/server/repositories/mfa-enrolment.repository.ts
8321
+ // src/server/repositories/mfa-challenges.repository.ts
8190
8322
  import { BaseRepository as BaseRepository12 } from "@spfn/core/db";
8191
- import { and as and12, eq as eq12, isNotNull, isNull as isNull10 } from "drizzle-orm";
8323
+ import { and as and12, eq as eq12, gt as gt6, inArray, isNotNull, isNull as isNull10, lte, or as or3, sql as sql8 } from "drizzle-orm";
8324
+ var MfaChallengesRepository, mfaChallengesRepository;
8325
+ var init_mfa_challenges_repository = __esm({
8326
+ "src/server/repositories/mfa-challenges.repository.ts"() {
8327
+ "use strict";
8328
+ init_mfa_challenges();
8329
+ init_user_public_keys();
8330
+ MfaChallengesRepository = class extends BaseRepository12 {
8331
+ /** Park a fresh challenge. The caller has already written the pending key. */
8332
+ async create(data) {
8333
+ return await this._create(mfaChallenges, data);
8334
+ }
8335
+ /**
8336
+ * The challenge this secret names, whatever state it is in.
8337
+ *
8338
+ * Reads the primary rather than the replica: `verify` decides whether a key
8339
+ * becomes usable, and a replica lagging behind the 202 that created the row
8340
+ * would refuse a challenge the caller was handed a moment ago.
8341
+ */
8342
+ async findByHash(challengeHash) {
8343
+ const result = await this.db.select().from(mfaChallenges).where(eq12(mfaChallenges.challengeHash, challengeHash)).limit(1);
8344
+ return result[0] ?? null;
8345
+ }
8346
+ /**
8347
+ * The live challenge already outstanding for this key, if there is one.
8348
+ *
8349
+ * What makes a retried registration resume rather than collide: a native
8350
+ * client reusing its keyId, or an OAuth state replayed from the back button,
8351
+ * gets the challenge it was already given instead of a 409 about a key it
8352
+ * does own.
8353
+ */
8354
+ async findLiveByKeyId(userId, keyId) {
8355
+ const result = await this.db.select().from(mfaChallenges).where(and12(
8356
+ eq12(mfaChallenges.userId, userId),
8357
+ eq12(mfaChallenges.keyId, keyId),
8358
+ isNull10(mfaChallenges.verifiedAt),
8359
+ gt6(mfaChallenges.expiresAt, /* @__PURE__ */ new Date())
8360
+ )).limit(1);
8361
+ return result[0] ?? null;
8362
+ }
8363
+ /**
8364
+ * Spend the challenge, or answer false because somebody else already did.
8365
+ *
8366
+ * A conditional UPDATE and not a read followed by a write: two verifies
8367
+ * arriving together would both pass a read, and a challenge is single use.
8368
+ * Every condition the caller checked is restated here, so the row that is
8369
+ * marked is a row that was still spendable at the moment it was marked.
8370
+ */
8371
+ async markVerified(id26, keyEpoch) {
8372
+ const result = await this.db.update(mfaChallenges).set({ verifiedAt: /* @__PURE__ */ new Date() }).where(and12(
8373
+ eq12(mfaChallenges.id, id26),
8374
+ eq12(mfaChallenges.keyEpoch, keyEpoch),
8375
+ isNull10(mfaChallenges.verifiedAt),
8376
+ gt6(mfaChallenges.expiresAt, /* @__PURE__ */ new Date())
8377
+ )).returning();
8378
+ return result.length > 0;
8379
+ }
8380
+ /**
8381
+ * Count a wrong proof, and say whether that was the last one allowed.
8382
+ *
8383
+ * The increment is on the row the presented secret actually hashed to, which
8384
+ * is the whole reason lookup is by hash: there is no id for a caller to name,
8385
+ * so no counter but their own can be moved.
8386
+ */
8387
+ async countFailure(id26) {
8388
+ const result = await this.db.update(mfaChallenges).set({ attempts: sql8`${mfaChallenges.attempts} + 1` }).where(eq12(mfaChallenges.id, id26)).returning({ attempts: mfaChallenges.attempts });
8389
+ return (result[0]?.attempts ?? 0) >= MFA_CHALLENGE_ATTEMPT_LIMIT;
8390
+ }
8391
+ /**
8392
+ * Point an inactive key at the challenge that gates it.
8393
+ *
8394
+ * A second statement rather than a column on the insert, because the
8395
+ * challenge references the key: the key row has to exist before the
8396
+ * challenge can, so the back-reference is written once both do. Both run
8397
+ * inside the registering route's transaction.
8398
+ */
8399
+ async markKeyPending(keyId, challengeId) {
8400
+ await this.db.update(userPublicKeys).set({ pendingMfaChallengeId: challengeId }).where(eq12(userPublicKeys.keyId, keyId));
8401
+ }
8402
+ /**
8403
+ * Give a live challenge a fresh secret, leaving everything else alone.
8404
+ *
8405
+ * What a retried registration gets. The row keeps its id, its expiry and its
8406
+ * spent attempts, so retrying is not a way to reset the counter or buy
8407
+ * another ten minutes; only the secret is new, because the old one was never
8408
+ * stored and cannot be handed out twice.
8409
+ */
8410
+ async resecret(id26, challengeHash) {
8411
+ const result = await this.db.update(mfaChallenges).set({ challengeHash }).where(and12(
8412
+ eq12(mfaChallenges.id, id26),
8413
+ isNull10(mfaChallenges.verifiedAt),
8414
+ gt6(mfaChallenges.expiresAt, /* @__PURE__ */ new Date())
8415
+ )).returning({ id: mfaChallenges.id });
8416
+ return result.length > 0;
8417
+ }
8418
+ /**
8419
+ * Delete the pending key a challenge was gating, which takes the challenge
8420
+ * with it through the cascade.
8421
+ *
8422
+ * Scoped to `pending_mfa_challenge_id`, so a key that has since been
8423
+ * activated is never deleted by a late failure or a sweep.
8424
+ */
8425
+ async dropPendingKey(keyId, challengeId) {
8426
+ await this.db.delete(userPublicKeys).where(and12(
8427
+ eq12(userPublicKeys.keyId, keyId),
8428
+ eq12(userPublicKeys.pendingMfaChallengeId, challengeId)
8429
+ ));
8430
+ }
8431
+ /**
8432
+ * Mark a key active and no longer pending — what a verified challenge buys.
8433
+ *
8434
+ * Conditioned on the challenge id, so this can only activate the key that
8435
+ * challenge was minted for.
8436
+ */
8437
+ async activateKey(keyId, challengeId) {
8438
+ await this.db.update(userPublicKeys).set({ isActive: true, pendingMfaChallengeId: null }).where(and12(
8439
+ eq12(userPublicKeys.keyId, keyId),
8440
+ eq12(userPublicKeys.pendingMfaChallengeId, challengeId)
8441
+ ));
8442
+ }
8443
+ /**
8444
+ * Clear out challenges nobody finished, and the keys they were gating.
8445
+ *
8446
+ * Counted from the read rather than from either delete, because the two
8447
+ * overlap: deleting a pending key cascades onto its challenge, so the second
8448
+ * statement finds only the challenges whose key had already gone some other
8449
+ * way — and a count taken there would report a sweep that did nothing.
8450
+ *
8451
+ * A spent challenge is kept for the same span as an expired one, so a replay
8452
+ * inside the window meets "already verified" from a row rather than
8453
+ * "unknown" from an absence. The 401 is the same either way; the record is
8454
+ * not.
8455
+ *
8456
+ * @returns how many challenge rows are gone
8457
+ */
8458
+ async sweepFinished(cutoff) {
8459
+ const finished = or3(lte(mfaChallenges.expiresAt, cutoff), isNotNull(mfaChallenges.verifiedAt));
8460
+ const doomed = await this.readDb.select({ id: mfaChallenges.id }).from(mfaChallenges).where(finished);
8461
+ if (doomed.length === 0) {
8462
+ return 0;
8463
+ }
8464
+ await this.db.delete(userPublicKeys).where(inArray(userPublicKeys.pendingMfaChallengeId, doomed.map((row) => row.id)));
8465
+ await this.db.delete(mfaChallenges).where(finished);
8466
+ return doomed.length;
8467
+ }
8468
+ };
8469
+ mfaChallengesRepository = new MfaChallengesRepository();
8470
+ }
8471
+ });
8472
+
8473
+ // src/server/repositories/mfa-enrolment.repository.ts
8474
+ import { BaseRepository as BaseRepository13 } from "@spfn/core/db";
8475
+ import { and as and13, eq as eq13, isNotNull as isNotNull2, isNull as isNull11 } from "drizzle-orm";
8192
8476
  import { unionAll } from "drizzle-orm/pg-core";
8193
8477
  var MfaEnrolmentRepository, mfaEnrolmentRepository;
8194
8478
  var init_mfa_enrolment_repository = __esm({
@@ -8196,19 +8480,19 @@ var init_mfa_enrolment_repository = __esm({
8196
8480
  "use strict";
8197
8481
  init_mfa_totp();
8198
8482
  init_passkeys();
8199
- MfaEnrolmentRepository = class extends BaseRepository12 {
8483
+ MfaEnrolmentRepository = class extends BaseRepository13 {
8200
8484
  /** Whether the account has a confirmed TOTP or a live second-factor passkey. */
8201
8485
  async isEnrolled(userId) {
8202
- const confirmedTotp = this.readDb.select({ owner: mfaTotp.userId }).from(mfaTotp).where(and12(eq12(mfaTotp.userId, userId), isNotNull(mfaTotp.confirmedAt)));
8486
+ const confirmedTotp = this.readDb.select({ owner: mfaTotp.userId }).from(mfaTotp).where(and13(eq13(mfaTotp.userId, userId), isNotNull2(mfaTotp.confirmedAt)));
8203
8487
  const rows = await unionAll(confirmedTotp, this.liveSecondFactorPasskeys(userId)).limit(1);
8204
8488
  return rows.length > 0;
8205
8489
  }
8206
8490
  /** The other half of the union, as its own read for `status` to reuse. */
8207
8491
  liveSecondFactorPasskeys(userId) {
8208
- return this.readDb.select({ owner: passkeys.userId }).from(passkeys).where(and12(
8209
- eq12(passkeys.userId, userId),
8210
- eq12(passkeys.secondFactor, true),
8211
- isNull10(passkeys.revokedAt)
8492
+ return this.readDb.select({ owner: passkeys.userId }).from(passkeys).where(and13(
8493
+ eq13(passkeys.userId, userId),
8494
+ eq13(passkeys.secondFactor, true),
8495
+ isNull11(passkeys.revokedAt)
8212
8496
  ));
8213
8497
  }
8214
8498
  /** Whether at least one live passkey is marked as a second factor. */
@@ -8222,15 +8506,15 @@ var init_mfa_enrolment_repository = __esm({
8222
8506
  });
8223
8507
 
8224
8508
  // src/server/repositories/device-authorizations.repository.ts
8225
- import { BaseRepository as BaseRepository13 } from "@spfn/core/db";
8226
- import { eq as eq13, and as and13, gt as gt6, inArray, sql as sql8 } from "drizzle-orm";
8509
+ import { BaseRepository as BaseRepository14 } from "@spfn/core/db";
8510
+ import { eq as eq14, and as and14, gt as gt7, inArray as inArray2, sql as sql9 } from "drizzle-orm";
8227
8511
  var notExpired, DeviceAuthorizationsRepository, deviceAuthorizationsRepository;
8228
8512
  var init_device_authorizations_repository = __esm({
8229
8513
  "src/server/repositories/device-authorizations.repository.ts"() {
8230
8514
  "use strict";
8231
8515
  init_device_authorizations();
8232
- notExpired = () => gt6(deviceAuthorizations.expiresAt, sql8`now()`);
8233
- DeviceAuthorizationsRepository = class extends BaseRepository13 {
8516
+ notExpired = () => gt7(deviceAuthorizations.expiresAt, sql9`now()`);
8517
+ DeviceAuthorizationsRepository = class extends BaseRepository14 {
8234
8518
  /**
8235
8519
  * Insert a pending authorization, unless one of its codes is already taken.
8236
8520
  *
@@ -8258,7 +8542,7 @@ var init_device_authorizations_repository = __esm({
8258
8542
  * Read replica.
8259
8543
  */
8260
8544
  async findByUserCode(userCode) {
8261
- const result = await this.readDb.select().from(deviceAuthorizations).where(eq13(deviceAuthorizations.userCode, userCode)).limit(1);
8545
+ const result = await this.readDb.select().from(deviceAuthorizations).where(eq14(deviceAuthorizations.userCode, userCode)).limit(1);
8262
8546
  return result[0] ?? null;
8263
8547
  }
8264
8548
  /**
@@ -8268,7 +8552,7 @@ var init_device_authorizations_repository = __esm({
8268
8552
  * Read replica.
8269
8553
  */
8270
8554
  async findByDeviceCodeHash(deviceCodeHash) {
8271
- const result = await this.readDb.select().from(deviceAuthorizations).where(eq13(deviceAuthorizations.deviceCodeHash, deviceCodeHash)).limit(1);
8555
+ const result = await this.readDb.select().from(deviceAuthorizations).where(eq14(deviceAuthorizations.deviceCodeHash, deviceCodeHash)).limit(1);
8272
8556
  return result[0] ?? null;
8273
8557
  }
8274
8558
  /**
@@ -8280,11 +8564,11 @@ var init_device_authorizations_repository = __esm({
8280
8564
  *
8281
8565
  * @returns the updated row, or null if it was no longer pending, or expired
8282
8566
  */
8283
- async approve(id25, userId) {
8567
+ async approve(id26, userId) {
8284
8568
  const result = await this.db.update(deviceAuthorizations).set({ status: "approved", userId, approvedAt: /* @__PURE__ */ new Date() }).where(
8285
- and13(
8286
- eq13(deviceAuthorizations.id, id25),
8287
- eq13(deviceAuthorizations.status, "pending"),
8569
+ and14(
8570
+ eq14(deviceAuthorizations.id, id26),
8571
+ eq14(deviceAuthorizations.status, "pending"),
8288
8572
  notExpired()
8289
8573
  )
8290
8574
  ).returning();
@@ -8297,11 +8581,11 @@ var init_device_authorizations_repository = __esm({
8297
8581
  *
8298
8582
  * @returns the updated row, or null if it was no longer pending, or expired
8299
8583
  */
8300
- async deny(id25) {
8584
+ async deny(id26) {
8301
8585
  const result = await this.db.update(deviceAuthorizations).set({ status: "denied" }).where(
8302
- and13(
8303
- eq13(deviceAuthorizations.id, id25),
8304
- eq13(deviceAuthorizations.status, "pending"),
8586
+ and14(
8587
+ eq14(deviceAuthorizations.id, id26),
8588
+ eq14(deviceAuthorizations.status, "pending"),
8305
8589
  notExpired()
8306
8590
  )
8307
8591
  ).returning();
@@ -8334,9 +8618,9 @@ var init_device_authorizations_repository = __esm({
8334
8618
  */
8335
8619
  async denyAllActiveByUserId(userId) {
8336
8620
  return await this.db.update(deviceAuthorizations).set({ status: "denied" }).where(
8337
- and13(
8338
- eq13(deviceAuthorizations.userId, userId),
8339
- inArray(deviceAuthorizations.status, ["pending", "approved"])
8621
+ and14(
8622
+ eq14(deviceAuthorizations.userId, userId),
8623
+ inArray2(deviceAuthorizations.status, ["pending", "approved"])
8340
8624
  )
8341
8625
  ).returning();
8342
8626
  }
@@ -8353,9 +8637,9 @@ var init_device_authorizations_repository = __esm({
8353
8637
  */
8354
8638
  async consumeApproved(deviceCodeHash) {
8355
8639
  const result = await this.db.update(deviceAuthorizations).set({ status: "consumed", consumedAt: /* @__PURE__ */ new Date() }).where(
8356
- and13(
8357
- eq13(deviceAuthorizations.deviceCodeHash, deviceCodeHash),
8358
- eq13(deviceAuthorizations.status, "approved"),
8640
+ and14(
8641
+ eq14(deviceAuthorizations.deviceCodeHash, deviceCodeHash),
8642
+ eq14(deviceAuthorizations.status, "approved"),
8359
8643
  notExpired()
8360
8644
  )
8361
8645
  ).returning();
@@ -8367,26 +8651,26 @@ var init_device_authorizations_repository = __esm({
8367
8651
  });
8368
8652
 
8369
8653
  // src/server/repositories/roles.repository.ts
8370
- import { BaseRepository as BaseRepository14 } from "@spfn/core/db";
8371
- import { eq as eq14, asc } from "drizzle-orm";
8654
+ import { BaseRepository as BaseRepository15 } from "@spfn/core/db";
8655
+ import { eq as eq15, asc } from "drizzle-orm";
8372
8656
  var RolesRepository, rolesRepository;
8373
8657
  var init_roles_repository = __esm({
8374
8658
  "src/server/repositories/roles.repository.ts"() {
8375
8659
  "use strict";
8376
8660
  init_roles();
8377
- RolesRepository = class extends BaseRepository14 {
8661
+ RolesRepository = class extends BaseRepository15 {
8378
8662
  /**
8379
8663
  * ID로 역할 조회
8380
8664
  */
8381
- async findById(id25) {
8382
- const result = await this.readDb.select().from(roles).where(eq14(roles.id, id25)).limit(1);
8665
+ async findById(id26) {
8666
+ const result = await this.readDb.select().from(roles).where(eq15(roles.id, id26)).limit(1);
8383
8667
  return result[0] ?? null;
8384
8668
  }
8385
8669
  /**
8386
8670
  * Name으로 역할 조회
8387
8671
  */
8388
8672
  async findByName(name) {
8389
- const result = await this.readDb.select().from(roles).where(eq14(roles.name, name)).limit(1);
8673
+ const result = await this.readDb.select().from(roles).where(eq15(roles.name, name)).limit(1);
8390
8674
  return result[0] ?? null;
8391
8675
  }
8392
8676
  /**
@@ -8399,7 +8683,7 @@ var init_roles_repository = __esm({
8399
8683
  * 활성 역할만 조회
8400
8684
  */
8401
8685
  async findActive() {
8402
- return this.readDb.select().from(roles).where(eq14(roles.isActive, true)).orderBy(asc(roles.priority));
8686
+ return this.readDb.select().from(roles).where(eq15(roles.isActive, true)).orderBy(asc(roles.priority));
8403
8687
  }
8404
8688
  /**
8405
8689
  * 역할 생성
@@ -8410,15 +8694,15 @@ var init_roles_repository = __esm({
8410
8694
  /**
8411
8695
  * 역할 업데이트
8412
8696
  */
8413
- async updateById(id25, data) {
8414
- const result = await this.db.update(roles).set(data).where(eq14(roles.id, id25)).returning();
8697
+ async updateById(id26, data) {
8698
+ const result = await this.db.update(roles).set(data).where(eq15(roles.id, id26)).returning();
8415
8699
  return result[0] ?? null;
8416
8700
  }
8417
8701
  /**
8418
8702
  * 역할 삭제
8419
8703
  */
8420
- async deleteById(id25) {
8421
- const result = await this.db.delete(roles).where(eq14(roles.id, id25)).returning();
8704
+ async deleteById(id26) {
8705
+ const result = await this.db.delete(roles).where(eq15(roles.id, id26)).returning();
8422
8706
  return result[0] ?? null;
8423
8707
  }
8424
8708
  };
@@ -8427,26 +8711,26 @@ var init_roles_repository = __esm({
8427
8711
  });
8428
8712
 
8429
8713
  // src/server/repositories/permissions.repository.ts
8430
- import { BaseRepository as BaseRepository15 } from "@spfn/core/db";
8431
- import { asc as asc2, eq as eq15, inArray as inArray2 } from "drizzle-orm";
8714
+ import { BaseRepository as BaseRepository16 } from "@spfn/core/db";
8715
+ import { asc as asc2, eq as eq16, inArray as inArray3 } from "drizzle-orm";
8432
8716
  var PermissionsRepository, permissionsRepository;
8433
8717
  var init_permissions_repository = __esm({
8434
8718
  "src/server/repositories/permissions.repository.ts"() {
8435
8719
  "use strict";
8436
8720
  init_permissions();
8437
- PermissionsRepository = class extends BaseRepository15 {
8721
+ PermissionsRepository = class extends BaseRepository16 {
8438
8722
  /**
8439
8723
  * ID로 권한 조회
8440
8724
  */
8441
- async findById(id25) {
8442
- const result = await this.readDb.select().from(permissions).where(eq15(permissions.id, id25)).limit(1);
8725
+ async findById(id26) {
8726
+ const result = await this.readDb.select().from(permissions).where(eq16(permissions.id, id26)).limit(1);
8443
8727
  return result[0] ?? null;
8444
8728
  }
8445
8729
  /**
8446
8730
  * Name으로 권한 조회
8447
8731
  */
8448
8732
  async findByName(name) {
8449
- const result = await this.readDb.select().from(permissions).where(eq15(permissions.name, name)).limit(1);
8733
+ const result = await this.readDb.select().from(permissions).where(eq16(permissions.name, name)).limit(1);
8450
8734
  return result[0] ?? null;
8451
8735
  }
8452
8736
  /**
@@ -8454,7 +8738,7 @@ var init_permissions_repository = __esm({
8454
8738
  */
8455
8739
  async findByNames(names) {
8456
8740
  if (names.length === 0) return [];
8457
- return this.readDb.select().from(permissions).where(inArray2(permissions.name, names));
8741
+ return this.readDb.select().from(permissions).where(inArray3(permissions.name, names));
8458
8742
  }
8459
8743
  /**
8460
8744
  * 모든 권한 조회
@@ -8466,13 +8750,13 @@ var init_permissions_repository = __esm({
8466
8750
  * 활성 권한만 조회
8467
8751
  */
8468
8752
  async findActive() {
8469
- return this.readDb.select().from(permissions).where(eq15(permissions.isActive, true)).orderBy(asc2(permissions.name));
8753
+ return this.readDb.select().from(permissions).where(eq16(permissions.isActive, true)).orderBy(asc2(permissions.name));
8470
8754
  }
8471
8755
  /**
8472
8756
  * 카테고리별 권한 조회
8473
8757
  */
8474
8758
  async findByCategory(category) {
8475
- return this.readDb.select().from(permissions).where(eq15(permissions.category, category)).orderBy(asc2(permissions.name));
8759
+ return this.readDb.select().from(permissions).where(eq16(permissions.category, category)).orderBy(asc2(permissions.name));
8476
8760
  }
8477
8761
  /**
8478
8762
  * 권한 생성
@@ -8490,15 +8774,15 @@ var init_permissions_repository = __esm({
8490
8774
  /**
8491
8775
  * 권한 업데이트
8492
8776
  */
8493
- async updateById(id25, data) {
8494
- const result = await this.db.update(permissions).set(data).where(eq15(permissions.id, id25)).returning();
8777
+ async updateById(id26, data) {
8778
+ const result = await this.db.update(permissions).set(data).where(eq16(permissions.id, id26)).returning();
8495
8779
  return result[0] ?? null;
8496
8780
  }
8497
8781
  /**
8498
8782
  * 권한 삭제
8499
8783
  */
8500
- async deleteById(id25) {
8501
- const result = await this.db.delete(permissions).where(eq15(permissions.id, id25)).returning();
8784
+ async deleteById(id26) {
8785
+ const result = await this.db.delete(permissions).where(eq16(permissions.id, id26)).returning();
8502
8786
  return result[0] ?? null;
8503
8787
  }
8504
8788
  };
@@ -8507,25 +8791,25 @@ var init_permissions_repository = __esm({
8507
8791
  });
8508
8792
 
8509
8793
  // src/server/repositories/role-permissions.repository.ts
8510
- import { BaseRepository as BaseRepository16 } from "@spfn/core/db";
8511
- import { and as and14, eq as eq16 } from "drizzle-orm";
8794
+ import { BaseRepository as BaseRepository17 } from "@spfn/core/db";
8795
+ import { and as and15, eq as eq17 } from "drizzle-orm";
8512
8796
  var RolePermissionsRepository, rolePermissionsRepository;
8513
8797
  var init_role_permissions_repository = __esm({
8514
8798
  "src/server/repositories/role-permissions.repository.ts"() {
8515
8799
  "use strict";
8516
8800
  init_role_permissions();
8517
- RolePermissionsRepository = class extends BaseRepository16 {
8801
+ RolePermissionsRepository = class extends BaseRepository17 {
8518
8802
  /**
8519
8803
  * 역할 ID로 모든 권한 조회
8520
8804
  */
8521
8805
  async findByRoleId(roleId) {
8522
- return this.readDb.select().from(rolePermissions).where(eq16(rolePermissions.roleId, roleId));
8806
+ return this.readDb.select().from(rolePermissions).where(eq17(rolePermissions.roleId, roleId));
8523
8807
  }
8524
8808
  /**
8525
8809
  * 권한 ID로 모든 역할 조회
8526
8810
  */
8527
8811
  async findByPermissionId(permissionId) {
8528
- return this.readDb.select().from(rolePermissions).where(eq16(rolePermissions.permissionId, permissionId));
8812
+ return this.readDb.select().from(rolePermissions).where(eq17(rolePermissions.permissionId, permissionId));
8529
8813
  }
8530
8814
  /**
8531
8815
  * 역할-권한 매핑 생성
@@ -8545,9 +8829,9 @@ var init_role_permissions_repository = __esm({
8545
8829
  */
8546
8830
  async deleteByRoleIdAndPermissionId(roleId, permissionId) {
8547
8831
  const result = await this.db.delete(rolePermissions).where(
8548
- and14(
8549
- eq16(rolePermissions.roleId, roleId),
8550
- eq16(rolePermissions.permissionId, permissionId)
8832
+ and15(
8833
+ eq17(rolePermissions.roleId, roleId),
8834
+ eq17(rolePermissions.permissionId, permissionId)
8551
8835
  )
8552
8836
  ).returning();
8553
8837
  return result[0] ?? null;
@@ -8556,7 +8840,7 @@ var init_role_permissions_repository = __esm({
8556
8840
  * 역할의 모든 권한 매핑 삭제
8557
8841
  */
8558
8842
  async deleteByRoleId(roleId) {
8559
- const result = await this.db.delete(rolePermissions).where(eq16(rolePermissions.roleId, roleId)).returning();
8843
+ const result = await this.db.delete(rolePermissions).where(eq17(rolePermissions.roleId, roleId)).returning();
8560
8844
  return result.length;
8561
8845
  }
8562
8846
  /**
@@ -8577,19 +8861,19 @@ var init_role_permissions_repository = __esm({
8577
8861
  });
8578
8862
 
8579
8863
  // src/server/repositories/user-permissions.repository.ts
8580
- import { BaseRepository as BaseRepository17 } from "@spfn/core/db";
8581
- import { eq as eq17, and as and15, or as or3, isNull as isNull11, isNotNull as isNotNull2, lt as lt6, gt as gt7 } from "drizzle-orm";
8864
+ import { BaseRepository as BaseRepository18 } from "@spfn/core/db";
8865
+ import { eq as eq18, and as and16, or as or4, isNull as isNull12, isNotNull as isNotNull3, lt as lt6, gt as gt8 } from "drizzle-orm";
8582
8866
  var UserPermissionsRepository, userPermissionsRepository;
8583
8867
  var init_user_permissions_repository = __esm({
8584
8868
  "src/server/repositories/user-permissions.repository.ts"() {
8585
8869
  "use strict";
8586
8870
  init_user_permissions();
8587
- UserPermissionsRepository = class extends BaseRepository17 {
8871
+ UserPermissionsRepository = class extends BaseRepository18 {
8588
8872
  /**
8589
8873
  * 사용자 ID로 모든 권한 오버라이드 조회
8590
8874
  */
8591
8875
  async findByUserId(userId) {
8592
- return this.readDb.select().from(userPermissions).where(eq17(userPermissions.userId, userId));
8876
+ return this.readDb.select().from(userPermissions).where(eq18(userPermissions.userId, userId));
8593
8877
  }
8594
8878
  /**
8595
8879
  * 사용자 ID로 유효한 권한 오버라이드만 조회
@@ -8598,11 +8882,11 @@ var init_user_permissions_repository = __esm({
8598
8882
  async findValidByUserId(userId) {
8599
8883
  const now = /* @__PURE__ */ new Date();
8600
8884
  return this.readDb.select().from(userPermissions).where(
8601
- and15(
8602
- eq17(userPermissions.userId, userId),
8603
- or3(
8604
- isNull11(userPermissions.expiresAt),
8605
- gt7(userPermissions.expiresAt, now)
8885
+ and16(
8886
+ eq18(userPermissions.userId, userId),
8887
+ or4(
8888
+ isNull12(userPermissions.expiresAt),
8889
+ gt8(userPermissions.expiresAt, now)
8606
8890
  )
8607
8891
  )
8608
8892
  );
@@ -8612,9 +8896,9 @@ var init_user_permissions_repository = __esm({
8612
8896
  */
8613
8897
  async findByUserIdAndPermissionId(userId, permissionId) {
8614
8898
  const result = await this.readDb.select().from(userPermissions).where(
8615
- and15(
8616
- eq17(userPermissions.userId, userId),
8617
- eq17(userPermissions.permissionId, permissionId)
8899
+ and16(
8900
+ eq18(userPermissions.userId, userId),
8901
+ eq18(userPermissions.permissionId, permissionId)
8618
8902
  )
8619
8903
  ).limit(1);
8620
8904
  return result[0] ?? null;
@@ -8628,8 +8912,8 @@ var init_user_permissions_repository = __esm({
8628
8912
  /**
8629
8913
  * 사용자 권한 오버라이드 업데이트
8630
8914
  */
8631
- async updateById(id25, data) {
8632
- const result = await this.db.update(userPermissions).set(data).where(eq17(userPermissions.id, id25)).returning();
8915
+ async updateById(id26, data) {
8916
+ const result = await this.db.update(userPermissions).set(data).where(eq18(userPermissions.id, id26)).returning();
8633
8917
  return result[0] ?? null;
8634
8918
  }
8635
8919
  /**
@@ -8637,9 +8921,9 @@ var init_user_permissions_repository = __esm({
8637
8921
  */
8638
8922
  async deleteByUserIdAndPermissionId(userId, permissionId) {
8639
8923
  const result = await this.db.delete(userPermissions).where(
8640
- and15(
8641
- eq17(userPermissions.userId, userId),
8642
- eq17(userPermissions.permissionId, permissionId)
8924
+ and16(
8925
+ eq18(userPermissions.userId, userId),
8926
+ eq18(userPermissions.permissionId, permissionId)
8643
8927
  )
8644
8928
  ).returning();
8645
8929
  return result[0] ?? null;
@@ -8648,7 +8932,7 @@ var init_user_permissions_repository = __esm({
8648
8932
  * 사용자의 모든 권한 오버라이드 삭제
8649
8933
  */
8650
8934
  async deleteByUserId(userId) {
8651
- const result = await this.db.delete(userPermissions).where(eq17(userPermissions.userId, userId)).returning();
8935
+ const result = await this.db.delete(userPermissions).where(eq18(userPermissions.userId, userId)).returning();
8652
8936
  return result.length;
8653
8937
  }
8654
8938
  /**
@@ -8657,8 +8941,8 @@ var init_user_permissions_repository = __esm({
8657
8941
  async deleteExpired() {
8658
8942
  const now = /* @__PURE__ */ new Date();
8659
8943
  const result = await this.db.delete(userPermissions).where(
8660
- and15(
8661
- isNotNull2(userPermissions.expiresAt),
8944
+ and16(
8945
+ isNotNull3(userPermissions.expiresAt),
8662
8946
  lt6(userPermissions.expiresAt, now)
8663
8947
  )
8664
8948
  ).returning();
@@ -8670,33 +8954,33 @@ var init_user_permissions_repository = __esm({
8670
8954
  });
8671
8955
 
8672
8956
  // src/server/repositories/user-profiles.repository.ts
8673
- import { BaseRepository as BaseRepository18 } from "@spfn/core/db";
8674
- import { eq as eq18 } from "drizzle-orm";
8957
+ import { BaseRepository as BaseRepository19 } from "@spfn/core/db";
8958
+ import { eq as eq19 } from "drizzle-orm";
8675
8959
  var UserProfilesRepository, userProfilesRepository;
8676
8960
  var init_user_profiles_repository = __esm({
8677
8961
  "src/server/repositories/user-profiles.repository.ts"() {
8678
8962
  "use strict";
8679
8963
  init_user_profiles();
8680
- UserProfilesRepository = class extends BaseRepository18 {
8964
+ UserProfilesRepository = class extends BaseRepository19 {
8681
8965
  /**
8682
8966
  * ID로 프로필 조회
8683
8967
  */
8684
- async findById(id25) {
8685
- const result = await this.readDb.select().from(userProfiles).where(eq18(userProfiles.id, id25)).limit(1);
8968
+ async findById(id26) {
8969
+ const result = await this.readDb.select().from(userProfiles).where(eq19(userProfiles.id, id26)).limit(1);
8686
8970
  return result[0] ?? null;
8687
8971
  }
8688
8972
  /**
8689
8973
  * User ID로 locale만 조회 (경량)
8690
8974
  */
8691
8975
  async findLocaleByUserId(userId) {
8692
- const result = await this.readDb.select({ locale: userProfiles.locale }).from(userProfiles).where(eq18(userProfiles.userId, userId)).limit(1);
8976
+ const result = await this.readDb.select({ locale: userProfiles.locale }).from(userProfiles).where(eq19(userProfiles.userId, userId)).limit(1);
8693
8977
  return result[0]?.locale || "en";
8694
8978
  }
8695
8979
  /**
8696
8980
  * User ID로 프로필 조회
8697
8981
  */
8698
8982
  async findByUserId(userId) {
8699
- const result = await this.readDb.select().from(userProfiles).where(eq18(userProfiles.userId, userId)).limit(1);
8983
+ const result = await this.readDb.select().from(userProfiles).where(eq19(userProfiles.userId, userId)).limit(1);
8700
8984
  return result[0] ?? null;
8701
8985
  }
8702
8986
  /**
@@ -8708,29 +8992,29 @@ var init_user_profiles_repository = __esm({
8708
8992
  /**
8709
8993
  * 프로필 업데이트 (by ID)
8710
8994
  */
8711
- async updateById(id25, data) {
8712
- const result = await this.db.update(userProfiles).set(data).where(eq18(userProfiles.id, id25)).returning();
8995
+ async updateById(id26, data) {
8996
+ const result = await this.db.update(userProfiles).set(data).where(eq19(userProfiles.id, id26)).returning();
8713
8997
  return result[0] ?? null;
8714
8998
  }
8715
8999
  /**
8716
9000
  * 프로필 업데이트 (by User ID)
8717
9001
  */
8718
9002
  async updateByUserId(userId, data) {
8719
- const result = await this.db.update(userProfiles).set(data).where(eq18(userProfiles.userId, userId)).returning();
9003
+ const result = await this.db.update(userProfiles).set(data).where(eq19(userProfiles.userId, userId)).returning();
8720
9004
  return result[0] ?? null;
8721
9005
  }
8722
9006
  /**
8723
9007
  * 프로필 삭제 (by ID)
8724
9008
  */
8725
- async deleteById(id25) {
8726
- const result = await this.db.delete(userProfiles).where(eq18(userProfiles.id, id25)).returning();
9009
+ async deleteById(id26) {
9010
+ const result = await this.db.delete(userProfiles).where(eq19(userProfiles.id, id26)).returning();
8727
9011
  return result[0] ?? null;
8728
9012
  }
8729
9013
  /**
8730
9014
  * 프로필 삭제 (by User ID)
8731
9015
  */
8732
9016
  async deleteByUserId(userId) {
8733
- const result = await this.db.delete(userProfiles).where(eq18(userProfiles.userId, userId)).returning();
9017
+ const result = await this.db.delete(userProfiles).where(eq19(userProfiles.userId, userId)).returning();
8734
9018
  return result[0] ?? null;
8735
9019
  }
8736
9020
  /**
@@ -8772,7 +9056,7 @@ var init_user_profiles_repository = __esm({
8772
9056
  metadata: userProfiles.metadata,
8773
9057
  createdAt: userProfiles.createdAt,
8774
9058
  updatedAt: userProfiles.updatedAt
8775
- }).from(userProfiles).where(eq18(userProfiles.userId, userId)).limit(1).then((rows) => rows[0] ?? null);
9059
+ }).from(userProfiles).where(eq19(userProfiles.userId, userId)).limit(1).then((rows) => rows[0] ?? null);
8776
9060
  if (!profile) {
8777
9061
  return null;
8778
9062
  }
@@ -8800,8 +9084,8 @@ var init_user_profiles_repository = __esm({
8800
9084
  });
8801
9085
 
8802
9086
  // src/server/repositories/invitations.repository.ts
8803
- import { eq as eq19, and as and16, lt as lt7, desc as desc3, sql as sql9 } from "drizzle-orm";
8804
- import { BaseRepository as BaseRepository19 } from "@spfn/core/db";
9087
+ import { eq as eq20, and as and17, lt as lt7, desc as desc3, sql as sql10 } from "drizzle-orm";
9088
+ import { BaseRepository as BaseRepository20 } from "@spfn/core/db";
8805
9089
  var InvitationsRepository, invitationsRepository;
8806
9090
  var init_invitations_repository = __esm({
8807
9091
  "src/server/repositories/invitations.repository.ts"() {
@@ -8810,19 +9094,19 @@ var init_invitations_repository = __esm({
8810
9094
  init_roles();
8811
9095
  init_user_invitations();
8812
9096
  init_email();
8813
- InvitationsRepository = class extends BaseRepository19 {
9097
+ InvitationsRepository = class extends BaseRepository20 {
8814
9098
  /**
8815
9099
  * ID로 초대 조회
8816
9100
  */
8817
- async findById(id25) {
8818
- const result = await this.readDb.select().from(userInvitations).where(eq19(userInvitations.id, id25)).limit(1);
9101
+ async findById(id26) {
9102
+ const result = await this.readDb.select().from(userInvitations).where(eq20(userInvitations.id, id26)).limit(1);
8819
9103
  return result[0] ?? null;
8820
9104
  }
8821
9105
  /**
8822
9106
  * Token으로 초대 조회
8823
9107
  */
8824
9108
  async findByToken(token) {
8825
- const result = await this.readDb.select().from(userInvitations).where(eq19(userInvitations.token, token)).limit(1);
9109
+ const result = await this.readDb.select().from(userInvitations).where(eq20(userInvitations.token, token)).limit(1);
8826
9110
  return result[0] ?? null;
8827
9111
  }
8828
9112
  /**
@@ -8830,9 +9114,9 @@ var init_invitations_repository = __esm({
8830
9114
  */
8831
9115
  async findPendingByEmail(email) {
8832
9116
  const result = await this.readDb.select().from(userInvitations).where(
8833
- and16(
8834
- eq19(userInvitations.email, normalizeEmail(email)),
8835
- eq19(userInvitations.status, "pending")
9117
+ and17(
9118
+ eq20(userInvitations.email, normalizeEmail(email)),
9119
+ eq20(userInvitations.status, "pending")
8836
9120
  )
8837
9121
  ).limit(1);
8838
9122
  return result[0] ?? null;
@@ -8841,13 +9125,13 @@ var init_invitations_repository = __esm({
8841
9125
  * 초대자 ID로 모든 초대 조회
8842
9126
  */
8843
9127
  async findByInvitedBy(invitedBy) {
8844
- return this.readDb.select().from(userInvitations).where(eq19(userInvitations.invitedBy, invitedBy));
9128
+ return this.readDb.select().from(userInvitations).where(eq20(userInvitations.invitedBy, invitedBy));
8845
9129
  }
8846
9130
  /**
8847
9131
  * 상태별 초대 조회
8848
9132
  */
8849
9133
  async findByStatus(status) {
8850
- return this.readDb.select().from(userInvitations).where(eq19(userInvitations.status, status));
9134
+ return this.readDb.select().from(userInvitations).where(eq20(userInvitations.status, status));
8851
9135
  }
8852
9136
  /**
8853
9137
  * 초대 생성
@@ -8858,7 +9142,7 @@ var init_invitations_repository = __esm({
8858
9142
  /**
8859
9143
  * 초대 상태 업데이트
8860
9144
  */
8861
- async updateStatus(id25, status, timestamp2) {
9145
+ async updateStatus(id26, status, timestamp2) {
8862
9146
  const updates = {
8863
9147
  status
8864
9148
  };
@@ -8869,14 +9153,14 @@ var init_invitations_repository = __esm({
8869
9153
  updates.cancelledAt = timestamp2;
8870
9154
  }
8871
9155
  }
8872
- const result = await this.db.update(userInvitations).set(updates).where(eq19(userInvitations.id, id25)).returning();
9156
+ const result = await this.db.update(userInvitations).set(updates).where(eq20(userInvitations.id, id26)).returning();
8873
9157
  return result[0] ?? null;
8874
9158
  }
8875
9159
  /**
8876
9160
  * 초대 삭제
8877
9161
  */
8878
- async deleteById(id25) {
8879
- const result = await this.db.delete(userInvitations).where(eq19(userInvitations.id, id25)).returning();
9162
+ async deleteById(id26) {
9163
+ const result = await this.db.delete(userInvitations).where(eq20(userInvitations.id, id26)).returning();
8880
9164
  return result[0] ?? null;
8881
9165
  }
8882
9166
  /**
@@ -8885,8 +9169,8 @@ var init_invitations_repository = __esm({
8885
9169
  async updateExpiredInvitations() {
8886
9170
  const now = /* @__PURE__ */ new Date();
8887
9171
  const result = await this.db.update(userInvitations).set({ status: "expired" }).where(
8888
- and16(
8889
- eq19(userInvitations.status, "pending"),
9172
+ and17(
9173
+ eq20(userInvitations.status, "pending"),
8890
9174
  lt7(userInvitations.expiresAt, now)
8891
9175
  )
8892
9176
  ).returning();
@@ -8918,7 +9202,7 @@ var init_invitations_repository = __esm({
8918
9202
  id: users.id,
8919
9203
  email: users.email
8920
9204
  }
8921
- }).from(userInvitations).innerJoin(roles, eq19(userInvitations.roleId, roles.id)).innerJoin(users, eq19(userInvitations.invitedBy, users.id)).where(eq19(userInvitations.token, token)).limit(1);
9205
+ }).from(userInvitations).innerJoin(roles, eq20(userInvitations.roleId, roles.id)).innerJoin(users, eq20(userInvitations.invitedBy, users.id)).where(eq20(userInvitations.token, token)).limit(1);
8922
9206
  return result[0] ?? null;
8923
9207
  }
8924
9208
  /**
@@ -8929,13 +9213,13 @@ var init_invitations_repository = __esm({
8929
9213
  const offset = (page - 1) * limit;
8930
9214
  const conditions = [];
8931
9215
  if (status) {
8932
- conditions.push(eq19(userInvitations.status, status));
9216
+ conditions.push(eq20(userInvitations.status, status));
8933
9217
  }
8934
9218
  if (invitedBy) {
8935
- conditions.push(eq19(userInvitations.invitedBy, invitedBy));
9219
+ conditions.push(eq20(userInvitations.invitedBy, invitedBy));
8936
9220
  }
8937
- const whereClause = conditions.length > 0 ? and16(...conditions) : void 0;
8938
- const countResult = await this.readDb.select({ count: sql9`count(*)` }).from(userInvitations).where(whereClause);
9221
+ const whereClause = conditions.length > 0 ? and17(...conditions) : void 0;
9222
+ const countResult = await this.readDb.select({ count: sql10`count(*)` }).from(userInvitations).where(whereClause);
8939
9223
  const total = Number(countResult[0]?.count || 0);
8940
9224
  const results = await this.readDb.select({
8941
9225
  id: userInvitations.id,
@@ -8959,7 +9243,7 @@ var init_invitations_repository = __esm({
8959
9243
  id: users.id,
8960
9244
  email: users.email
8961
9245
  }
8962
- }).from(userInvitations).innerJoin(roles, eq19(userInvitations.roleId, roles.id)).innerJoin(users, eq19(userInvitations.invitedBy, users.id)).where(whereClause).orderBy(desc3(userInvitations.createdAt)).limit(limit).offset(offset);
9246
+ }).from(userInvitations).innerJoin(roles, eq20(userInvitations.roleId, roles.id)).innerJoin(users, eq20(userInvitations.invitedBy, users.id)).where(whereClause).orderBy(desc3(userInvitations.createdAt)).limit(limit).offset(offset);
8963
9247
  return {
8964
9248
  invitations: results,
8965
9249
  total,
@@ -8971,31 +9255,31 @@ var init_invitations_repository = __esm({
8971
9255
  /**
8972
9256
  * 초대 업데이트 (일반 업데이트 - 모든 필드 가능)
8973
9257
  */
8974
- async updateById(id25, data) {
9258
+ async updateById(id26, data) {
8975
9259
  const patch = "email" in data && typeof data.email === "string" ? { ...data, email: normalizeEmail(data.email) } : data;
8976
- const result = await this.db.update(userInvitations).set(patch).where(eq19(userInvitations.id, id25)).returning();
9260
+ const result = await this.db.update(userInvitations).set(patch).where(eq20(userInvitations.id, id26)).returning();
8977
9261
  return result[0] ?? null;
8978
9262
  }
8979
9263
  /**
8980
9264
  * 초대 재전송 (status와 expiresAt 동시 업데이트)
8981
9265
  */
8982
- async resend(id25, newExpiresAt) {
9266
+ async resend(id26, newExpiresAt) {
8983
9267
  const result = await this.db.update(userInvitations).set({
8984
9268
  status: "pending",
8985
9269
  expiresAt: newExpiresAt
8986
- }).where(eq19(userInvitations.id, id25)).returning();
9270
+ }).where(eq20(userInvitations.id, id26)).returning();
8987
9271
  return result[0] ?? null;
8988
9272
  }
8989
9273
  /**
8990
9274
  * 초대 취소 (status, metadata 동시 업데이트)
8991
9275
  */
8992
- async cancel(id25, cancelledBy, reason, currentMetadata) {
9276
+ async cancel(id26, cancelledBy, reason, currentMetadata) {
8993
9277
  const newMetadata = currentMetadata ? { ...currentMetadata, cancelReason: reason, cancelledBy } : { cancelReason: reason, cancelledBy };
8994
9278
  const result = await this.db.update(userInvitations).set({
8995
9279
  status: "cancelled",
8996
9280
  cancelledAt: /* @__PURE__ */ new Date(),
8997
9281
  metadata: newMetadata
8998
- }).where(eq19(userInvitations.id, id25)).returning();
9282
+ }).where(eq20(userInvitations.id, id26)).returning();
8999
9283
  return result[0] ?? null;
9000
9284
  }
9001
9285
  };
@@ -9366,6 +9650,22 @@ var init_schema5 = __esm({
9366
9650
  examples: [5, 10, 30]
9367
9651
  })
9368
9652
  },
9653
+ SPFN_AUTH_MFA_CONFIRM_PATH: {
9654
+ ...envString({
9655
+ description: "App page that asks for the second factor after a 202 sign-in, as a path on {NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}. createOAuthCallbackHandler redirects the browser there with ?challenge= when a social sign-in needs a step-up; the page posts that challenge and a code to /_auth/mfa/verify. It is a page in your app, not an API route.",
9656
+ default: "/auth/mfa",
9657
+ required: false,
9658
+ examples: ["/auth/mfa", "/sign-in/two-factor"]
9659
+ })
9660
+ },
9661
+ SPFN_AUTH_MFA_CHALLENGE_TTL_MINUTES: {
9662
+ ...envNumber({
9663
+ description: "How long a new-device second-factor challenge stays spendable. A sign-in on an enrolled account from a device it has never seen answers 202 with a challenge instead of a session, and the key it registered is inactive until POST /_auth/mfa/verify spends it. This is how long the person has to reach for their authenticator \u2014 and how long an attacker holding only the password has. The Next.js proxy seals its pending cookie for the same span.",
9664
+ default: 10,
9665
+ required: false,
9666
+ examples: [5, 10, 15]
9667
+ })
9668
+ },
9369
9669
  // ============================================================================
9370
9670
  // Session binding (#97)
9371
9671
  // ============================================================================
@@ -9819,15 +10119,15 @@ var init_token_cipher = __esm({
9819
10119
  });
9820
10120
 
9821
10121
  // src/server/repositories/social-accounts.repository.ts
9822
- import { eq as eq20, and as and17 } from "drizzle-orm";
9823
- import { BaseRepository as BaseRepository20 } from "@spfn/core/db";
10122
+ import { eq as eq21, and as and18 } from "drizzle-orm";
10123
+ import { BaseRepository as BaseRepository21 } from "@spfn/core/db";
9824
10124
  var SocialAccountsRepository, socialAccountsRepository;
9825
10125
  var init_social_accounts_repository = __esm({
9826
10126
  "src/server/repositories/social-accounts.repository.ts"() {
9827
10127
  "use strict";
9828
10128
  init_entities();
9829
10129
  init_token_cipher();
9830
- SocialAccountsRepository = class extends BaseRepository20 {
10130
+ SocialAccountsRepository = class extends BaseRepository21 {
9831
10131
  /**
9832
10132
  * 저장 row 의 토큰을 평문으로 복호화해 반환한다.
9833
10133
  *
@@ -9855,10 +10155,10 @@ var init_social_accounts_repository = __esm({
9855
10155
  if (refresh?.needsRotation) {
9856
10156
  heal.refreshToken = await encryptToken(refresh.value, context("refresh"));
9857
10157
  }
9858
- await this.db.update(userSocialAccounts).set(heal).where(and17(
9859
- eq20(userSocialAccounts.id, account.id),
9860
- access?.needsRotation && account.accessToken !== null ? eq20(userSocialAccounts.accessToken, account.accessToken) : void 0,
9861
- refresh?.needsRotation && account.refreshToken !== null ? eq20(userSocialAccounts.refreshToken, account.refreshToken) : void 0
10158
+ await this.db.update(userSocialAccounts).set(heal).where(and18(
10159
+ eq21(userSocialAccounts.id, account.id),
10160
+ access?.needsRotation && account.accessToken !== null ? eq21(userSocialAccounts.accessToken, account.accessToken) : void 0,
10161
+ refresh?.needsRotation && account.refreshToken !== null ? eq21(userSocialAccounts.refreshToken, account.refreshToken) : void 0
9862
10162
  ));
9863
10163
  } catch {
9864
10164
  }
@@ -9875,9 +10175,9 @@ var init_social_accounts_repository = __esm({
9875
10175
  */
9876
10176
  async findByProviderAndProviderId(provider, providerUserId) {
9877
10177
  const result = await this.readDb.select().from(userSocialAccounts).where(
9878
- and17(
9879
- eq20(userSocialAccounts.provider, provider),
9880
- eq20(userSocialAccounts.providerUserId, providerUserId)
10178
+ and18(
10179
+ eq21(userSocialAccounts.provider, provider),
10180
+ eq21(userSocialAccounts.providerUserId, providerUserId)
9881
10181
  )
9882
10182
  ).limit(1);
9883
10183
  return this.decryptAccount(result[0] ?? null);
@@ -9887,7 +10187,7 @@ var init_social_accounts_repository = __esm({
9887
10187
  * Read replica 사용
9888
10188
  */
9889
10189
  async findByUserId(userId) {
9890
- const result = await this.readDb.select().from(userSocialAccounts).where(eq20(userSocialAccounts.userId, userId));
10190
+ const result = await this.readDb.select().from(userSocialAccounts).where(eq21(userSocialAccounts.userId, userId));
9891
10191
  return Promise.all(result.map((account) => this.decryptAccount(account)));
9892
10192
  }
9893
10193
  /**
@@ -9896,9 +10196,9 @@ var init_social_accounts_repository = __esm({
9896
10196
  */
9897
10197
  async findByUserIdAndProvider(userId, provider) {
9898
10198
  const result = await this.readDb.select().from(userSocialAccounts).where(
9899
- and17(
9900
- eq20(userSocialAccounts.userId, userId),
9901
- eq20(userSocialAccounts.provider, provider)
10199
+ and18(
10200
+ eq21(userSocialAccounts.userId, userId),
10201
+ eq21(userSocialAccounts.provider, provider)
9902
10202
  )
9903
10203
  ).limit(1);
9904
10204
  return this.decryptAccount(result[0] ?? null);
@@ -9924,11 +10224,11 @@ var init_social_accounts_repository = __esm({
9924
10224
  * 토큰 정보 업데이트
9925
10225
  * Write primary 사용
9926
10226
  */
9927
- async updateTokens(id25, data) {
10227
+ async updateTokens(id26, data) {
9928
10228
  const accounts = await this.db.select({
9929
10229
  provider: userSocialAccounts.provider,
9930
10230
  providerUserId: userSocialAccounts.providerUserId
9931
- }).from(userSocialAccounts).where(eq20(userSocialAccounts.id, id25)).limit(1);
10231
+ }).from(userSocialAccounts).where(eq21(userSocialAccounts.id, id26)).limit(1);
9932
10232
  const account = accounts[0];
9933
10233
  if (!account) {
9934
10234
  return null;
@@ -9942,15 +10242,15 @@ var init_social_accounts_repository = __esm({
9942
10242
  ...data,
9943
10243
  accessToken: data.accessToken ? await encryptToken(data.accessToken, context("access")) : data.accessToken,
9944
10244
  refreshToken: data.refreshToken ? await encryptToken(data.refreshToken, context("refresh")) : data.refreshToken
9945
- }).where(eq20(userSocialAccounts.id, id25)).returning();
10245
+ }).where(eq21(userSocialAccounts.id, id26)).returning();
9946
10246
  return this.decryptAccount(result[0] ?? null);
9947
10247
  }
9948
10248
  /**
9949
10249
  * 소셜 계정 삭제
9950
10250
  * Write primary 사용
9951
10251
  */
9952
- async deleteById(id25) {
9953
- const result = await this.db.delete(userSocialAccounts).where(eq20(userSocialAccounts.id, id25)).returning();
10252
+ async deleteById(id26) {
10253
+ const result = await this.db.delete(userSocialAccounts).where(eq21(userSocialAccounts.id, id26)).returning();
9954
10254
  return result[0] ?? null;
9955
10255
  }
9956
10256
  /**
@@ -9959,9 +10259,9 @@ var init_social_accounts_repository = __esm({
9959
10259
  */
9960
10260
  async deleteByUserIdAndProvider(userId, provider) {
9961
10261
  const result = await this.db.delete(userSocialAccounts).where(
9962
- and17(
9963
- eq20(userSocialAccounts.userId, userId),
9964
- eq20(userSocialAccounts.provider, provider)
10262
+ and18(
10263
+ eq21(userSocialAccounts.userId, userId),
10264
+ eq21(userSocialAccounts.provider, provider)
9965
10265
  )
9966
10266
  ).returning();
9967
10267
  return result[0] ?? null;
@@ -9974,7 +10274,7 @@ var init_social_accounts_repository = __esm({
9974
10274
  * Write primary 사용
9975
10275
  */
9976
10276
  async deleteAllByUserId(userId) {
9977
- const result = await this.db.delete(userSocialAccounts).where(eq20(userSocialAccounts.userId, userId)).returning();
10277
+ const result = await this.db.delete(userSocialAccounts).where(eq21(userSocialAccounts.userId, userId)).returning();
9978
10278
  return result.length;
9979
10279
  }
9980
10280
  };
@@ -9983,19 +10283,19 @@ var init_social_accounts_repository = __esm({
9983
10283
  });
9984
10284
 
9985
10285
  // src/server/repositories/auth-metadata.repository.ts
9986
- import { BaseRepository as BaseRepository21 } from "@spfn/core/db";
9987
- import { eq as eq21 } from "drizzle-orm";
10286
+ import { BaseRepository as BaseRepository22 } from "@spfn/core/db";
10287
+ import { eq as eq22 } from "drizzle-orm";
9988
10288
  var AuthMetadataRepository, authMetadataRepository;
9989
10289
  var init_auth_metadata_repository = __esm({
9990
10290
  "src/server/repositories/auth-metadata.repository.ts"() {
9991
10291
  "use strict";
9992
10292
  init_auth_metadata();
9993
- AuthMetadataRepository = class extends BaseRepository21 {
10293
+ AuthMetadataRepository = class extends BaseRepository22 {
9994
10294
  /**
9995
10295
  * 키로 값 조회
9996
10296
  */
9997
10297
  async get(key) {
9998
- const result = await this.readDb.select().from(authMetadata).where(eq21(authMetadata.key, key)).limit(1);
10298
+ const result = await this.readDb.select().from(authMetadata).where(eq22(authMetadata.key, key)).limit(1);
9999
10299
  return result[0]?.value ?? null;
10000
10300
  }
10001
10301
  /**
@@ -10018,20 +10318,20 @@ var init_auth_metadata_repository = __esm({
10018
10318
  });
10019
10319
 
10020
10320
  // src/server/repositories/account-deletion-requests.repository.ts
10021
- import { eq as eq22, and as and18, lte } from "drizzle-orm";
10022
- import { BaseRepository as BaseRepository22 } from "@spfn/core/db";
10321
+ import { eq as eq23, and as and19, lte as lte2 } from "drizzle-orm";
10322
+ import { BaseRepository as BaseRepository23 } from "@spfn/core/db";
10023
10323
  var AccountDeletionRequestsRepository, accountDeletionRequestsRepository;
10024
10324
  var init_account_deletion_requests_repository = __esm({
10025
10325
  "src/server/repositories/account-deletion-requests.repository.ts"() {
10026
10326
  "use strict";
10027
10327
  init_account_deletion_requests();
10028
- AccountDeletionRequestsRepository = class extends BaseRepository22 {
10328
+ AccountDeletionRequestsRepository = class extends BaseRepository23 {
10029
10329
  /**
10030
10330
  * ID로 요청 조회
10031
10331
  * Read replica 사용
10032
10332
  */
10033
- async findById(id25) {
10034
- const result = await this.readDb.select().from(accountDeletionRequests).where(eq22(accountDeletionRequests.id, id25)).limit(1);
10333
+ async findById(id26) {
10334
+ const result = await this.readDb.select().from(accountDeletionRequests).where(eq23(accountDeletionRequests.id, id26)).limit(1);
10035
10335
  return result[0] ?? null;
10036
10336
  }
10037
10337
  /**
@@ -10040,9 +10340,9 @@ var init_account_deletion_requests_repository = __esm({
10040
10340
  */
10041
10341
  async findPendingByUserId(userId) {
10042
10342
  const result = await this.readDb.select().from(accountDeletionRequests).where(
10043
- and18(
10044
- eq22(accountDeletionRequests.userId, userId),
10045
- eq22(accountDeletionRequests.status, "pending")
10343
+ and19(
10344
+ eq23(accountDeletionRequests.userId, userId),
10345
+ eq23(accountDeletionRequests.status, "pending")
10046
10346
  )
10047
10347
  ).limit(1);
10048
10348
  return result[0] ?? null;
@@ -10056,9 +10356,9 @@ var init_account_deletion_requests_repository = __esm({
10056
10356
  */
10057
10357
  async findPendingByUserIdOnPrimary(userId) {
10058
10358
  const result = await this.db.select().from(accountDeletionRequests).where(
10059
- and18(
10060
- eq22(accountDeletionRequests.userId, userId),
10061
- eq22(accountDeletionRequests.status, "pending")
10359
+ and19(
10360
+ eq23(accountDeletionRequests.userId, userId),
10361
+ eq23(accountDeletionRequests.status, "pending")
10062
10362
  )
10063
10363
  ).limit(1);
10064
10364
  return result[0] ?? null;
@@ -10069,9 +10369,9 @@ var init_account_deletion_requests_repository = __esm({
10069
10369
  */
10070
10370
  async findDueForPurge(now) {
10071
10371
  return this.readDb.select().from(accountDeletionRequests).where(
10072
- and18(
10073
- eq22(accountDeletionRequests.status, "pending"),
10074
- lte(accountDeletionRequests.purgeScheduledAt, now)
10372
+ and19(
10373
+ eq23(accountDeletionRequests.status, "pending"),
10374
+ lte2(accountDeletionRequests.purgeScheduledAt, now)
10075
10375
  )
10076
10376
  );
10077
10377
  }
@@ -10090,14 +10390,14 @@ var init_account_deletion_requests_repository = __esm({
10090
10390
  * cancelled) 시 null을 반환하니 호출자가 그 결과를 확인해야 한다.
10091
10391
  * Write primary 사용
10092
10392
  */
10093
- async markCancelled(id25) {
10393
+ async markCancelled(id26) {
10094
10394
  const result = await this.db.update(accountDeletionRequests).set({
10095
10395
  status: "cancelled",
10096
10396
  cancelledAt: /* @__PURE__ */ new Date()
10097
10397
  }).where(
10098
- and18(
10099
- eq22(accountDeletionRequests.id, id25),
10100
- eq22(accountDeletionRequests.status, "pending")
10398
+ and19(
10399
+ eq23(accountDeletionRequests.id, id26),
10400
+ eq23(accountDeletionRequests.status, "pending")
10101
10401
  )
10102
10402
  ).returning();
10103
10403
  return result[0] ?? null;
@@ -10112,15 +10412,15 @@ var init_account_deletion_requests_repository = __esm({
10112
10412
  * destructive DML을 실행하기 **전에** 반드시 이 결과를 확인해야 한다.
10113
10413
  * Write primary 사용
10114
10414
  */
10115
- async markCompleted(id25, purgeStrategy) {
10415
+ async markCompleted(id26, purgeStrategy) {
10116
10416
  const result = await this.db.update(accountDeletionRequests).set({
10117
10417
  status: "completed",
10118
10418
  completedAt: /* @__PURE__ */ new Date(),
10119
10419
  purgeStrategy
10120
10420
  }).where(
10121
- and18(
10122
- eq22(accountDeletionRequests.id, id25),
10123
- eq22(accountDeletionRequests.status, "pending")
10421
+ and19(
10422
+ eq23(accountDeletionRequests.id, id26),
10423
+ eq23(accountDeletionRequests.status, "pending")
10124
10424
  )
10125
10425
  ).returning();
10126
10426
  return result[0] ?? null;
@@ -10131,14 +10431,14 @@ var init_account_deletion_requests_repository = __esm({
10131
10431
  });
10132
10432
 
10133
10433
  // src/server/repositories/ops-tokens.repository.ts
10134
- import { and as and19, desc as desc4, eq as eq23, isNull as isNull12 } from "drizzle-orm";
10135
- import { BaseRepository as BaseRepository23 } from "@spfn/core/db";
10434
+ import { and as and20, desc as desc4, eq as eq24, isNull as isNull13 } from "drizzle-orm";
10435
+ import { BaseRepository as BaseRepository24 } from "@spfn/core/db";
10136
10436
  var OpsTokensRepository, opsTokensRepository;
10137
10437
  var init_ops_tokens_repository = __esm({
10138
10438
  "src/server/repositories/ops-tokens.repository.ts"() {
10139
10439
  "use strict";
10140
10440
  init_ops_tokens();
10141
- OpsTokensRepository = class extends BaseRepository23 {
10441
+ OpsTokensRepository = class extends BaseRepository24 {
10142
10442
  /**
10143
10443
  * Lookup by the secret's hash — the verification path.
10144
10444
  *
@@ -10148,7 +10448,7 @@ var init_ops_tokens_repository = __esm({
10148
10448
  * and revocation is documented as taking effect immediately.
10149
10449
  */
10150
10450
  async findByTokenHash(tokenHash) {
10151
- const result = await this.db.select().from(opsTokens).where(eq23(opsTokens.tokenHash, tokenHash)).limit(1);
10451
+ const result = await this.db.select().from(opsTokens).where(eq24(opsTokens.tokenHash, tokenHash)).limit(1);
10152
10452
  return result[0] ?? null;
10153
10453
  }
10154
10454
  async create(data) {
@@ -10163,13 +10463,13 @@ var init_ops_tokens_repository = __esm({
10163
10463
  * token is already revoked — the first revocation's timestamp is never
10164
10464
  * overwritten.
10165
10465
  */
10166
- async revokeById(id25) {
10167
- const result = await this.db.update(opsTokens).set({ revokedAt: /* @__PURE__ */ new Date() }).where(and19(eq23(opsTokens.id, id25), isNull12(opsTokens.revokedAt))).returning();
10466
+ async revokeById(id26) {
10467
+ const result = await this.db.update(opsTokens).set({ revokedAt: /* @__PURE__ */ new Date() }).where(and20(eq24(opsTokens.id, id26), isNull13(opsTokens.revokedAt))).returning();
10168
10468
  return result[0] ?? null;
10169
10469
  }
10170
10470
  /** Fire-and-forget from the verification path. */
10171
- async updateLastUsedById(id25) {
10172
- await this.db.update(opsTokens).set({ lastUsedAt: /* @__PURE__ */ new Date() }).where(eq23(opsTokens.id, id25));
10471
+ async updateLastUsedById(id26) {
10472
+ await this.db.update(opsTokens).set({ lastUsedAt: /* @__PURE__ */ new Date() }).where(eq24(opsTokens.id, id26));
10173
10473
  }
10174
10474
  };
10175
10475
  opsTokensRepository = new OpsTokensRepository();
@@ -10177,15 +10477,15 @@ var init_ops_tokens_repository = __esm({
10177
10477
  });
10178
10478
 
10179
10479
  // src/server/repositories/oauth2-clients.repository.ts
10180
- import { and as and20, eq as eq24, gt as gt8, lt as lt8, sql as sql10 } from "drizzle-orm";
10181
- import { BaseRepository as BaseRepository24, runInTransaction } from "@spfn/core/db";
10480
+ import { and as and21, eq as eq25, gt as gt9, lt as lt8, sql as sql11 } from "drizzle-orm";
10481
+ import { BaseRepository as BaseRepository25, runInTransaction } from "@spfn/core/db";
10182
10482
  var OAuth2ClientsRepository, oauth2ClientsRepository;
10183
10483
  var init_oauth2_clients_repository = __esm({
10184
10484
  "src/server/repositories/oauth2-clients.repository.ts"() {
10185
10485
  "use strict";
10186
10486
  init_oauth2_clients();
10187
10487
  init_oauth2_grants();
10188
- OAuth2ClientsRepository = class extends BaseRepository24 {
10488
+ OAuth2ClientsRepository = class extends BaseRepository25 {
10189
10489
  async create(data) {
10190
10490
  const result = await this.db.insert(oauth2Clients).values(data).returning();
10191
10491
  return result[0];
@@ -10199,12 +10499,12 @@ var init_oauth2_clients_repository = __esm({
10199
10499
  * at the one moment a CLI is being connected.
10200
10500
  */
10201
10501
  async findByClientId(clientId) {
10202
- const result = await this.db.select().from(oauth2Clients).where(eq24(oauth2Clients.clientId, clientId)).limit(1);
10502
+ const result = await this.db.select().from(oauth2Clients).where(eq25(oauth2Clients.clientId, clientId)).limit(1);
10203
10503
  return result[0] ?? null;
10204
10504
  }
10205
10505
  /** Fire-and-forget from the token-issuing path. */
10206
- async updateLastUsedById(id25) {
10207
- await this.db.update(oauth2Clients).set({ lastUsedAt: /* @__PURE__ */ new Date() }).where(eq24(oauth2Clients.id, id25));
10506
+ async updateLastUsedById(id26) {
10507
+ await this.db.update(oauth2Clients).set({ lastUsedAt: /* @__PURE__ */ new Date() }).where(eq25(oauth2Clients.id, id26));
10208
10508
  }
10209
10509
  /**
10210
10510
  * Register a client unless this address is already at its standing cap.
@@ -10226,7 +10526,7 @@ var init_oauth2_clients_repository = __esm({
10226
10526
  */
10227
10527
  async createWithinStandingCap(data, limit) {
10228
10528
  return await runInTransaction(async () => {
10229
- await this.db.execute(sql10`select pg_advisory_xact_lock(hashtext(${limit.ip}))`);
10529
+ await this.db.execute(sql11`select pg_advisory_xact_lock(hashtext(${limit.ip}))`);
10230
10530
  const standing = await this.countRecentUngrantedByIp(limit.ip, limit.windowMs);
10231
10531
  return standing < limit.max ? await this.create(data) : null;
10232
10532
  });
@@ -10248,11 +10548,11 @@ var init_oauth2_clients_repository = __esm({
10248
10548
  * count exists to see.
10249
10549
  */
10250
10550
  async countRecentUngrantedByIp(ip, windowMs) {
10251
- const result = await this.db.select({ count: sql10`count(*)::int` }).from(oauth2Clients).where(
10252
- and20(
10253
- eq24(oauth2Clients.createdIp, ip),
10254
- gt8(oauth2Clients.createdAt, new Date(Date.now() - windowMs)),
10255
- sql10`not exists (select 1 from ${oauth2Grants} where ${oauth2Grants.client} = ${oauth2Clients.id})`
10551
+ const result = await this.db.select({ count: sql11`count(*)::int` }).from(oauth2Clients).where(
10552
+ and21(
10553
+ eq25(oauth2Clients.createdIp, ip),
10554
+ gt9(oauth2Clients.createdAt, new Date(Date.now() - windowMs)),
10555
+ sql11`not exists (select 1 from ${oauth2Grants} where ${oauth2Grants.client} = ${oauth2Clients.id})`
10256
10556
  )
10257
10557
  );
10258
10558
  return result[0]?.count ?? 0;
@@ -10269,9 +10569,9 @@ var init_oauth2_clients_repository = __esm({
10269
10569
  */
10270
10570
  async deleteStaleUngranted(before) {
10271
10571
  const deleted = await this.db.delete(oauth2Clients).where(
10272
- and20(
10572
+ and21(
10273
10573
  lt8(oauth2Clients.createdAt, before),
10274
- sql10`not exists (select 1 from ${oauth2Grants} where ${oauth2Grants.client} = ${oauth2Clients.id})`
10574
+ sql11`not exists (select 1 from ${oauth2Grants} where ${oauth2Grants.client} = ${oauth2Clients.id})`
10275
10575
  )
10276
10576
  ).returning({ id: oauth2Clients.id });
10277
10577
  return deleted.length;
@@ -10282,8 +10582,8 @@ var init_oauth2_clients_repository = __esm({
10282
10582
  });
10283
10583
 
10284
10584
  // src/server/repositories/oauth2-grants.repository.ts
10285
- import { and as and21, desc as desc5, eq as eq25, inArray as inArray3, isNull as isNull13, sql as sql11 } from "drizzle-orm";
10286
- import { BaseRepository as BaseRepository25 } from "@spfn/core/db";
10585
+ import { and as and22, desc as desc5, eq as eq26, inArray as inArray4, isNull as isNull14, sql as sql12 } from "drizzle-orm";
10586
+ import { BaseRepository as BaseRepository26 } from "@spfn/core/db";
10287
10587
  var OAuth2GrantsRepository, oauth2GrantsRepository;
10288
10588
  var init_oauth2_grants_repository = __esm({
10289
10589
  "src/server/repositories/oauth2-grants.repository.ts"() {
@@ -10291,7 +10591,7 @@ var init_oauth2_grants_repository = __esm({
10291
10591
  init_oauth2_grants();
10292
10592
  init_oauth2_clients();
10293
10593
  init_oauth2_tokens();
10294
- OAuth2GrantsRepository = class extends BaseRepository25 {
10594
+ OAuth2GrantsRepository = class extends BaseRepository26 {
10295
10595
  /**
10296
10596
  * Record a consent, or refresh the scopes of the one already there.
10297
10597
  *
@@ -10318,13 +10618,13 @@ var init_oauth2_grants_repository = __esm({
10318
10618
  *
10319
10619
  * Read primary: revocation is documented as taking effect immediately.
10320
10620
  */
10321
- async findWithClientById(id25) {
10322
- const result = await this.db.select({ grant: oauth2Grants, client: oauth2Clients }).from(oauth2Grants).innerJoin(oauth2Clients, eq25(oauth2Grants.client, oauth2Clients.id)).where(eq25(oauth2Grants.id, id25)).limit(1);
10621
+ async findWithClientById(id26) {
10622
+ const result = await this.db.select({ grant: oauth2Grants, client: oauth2Clients }).from(oauth2Grants).innerJoin(oauth2Clients, eq26(oauth2Grants.client, oauth2Clients.id)).where(eq26(oauth2Grants.id, id26)).limit(1);
10323
10623
  return result[0] ?? null;
10324
10624
  }
10325
10625
  /** What the user's "connected apps" screen lists. Read replica. */
10326
10626
  async listActiveByUserId(userId) {
10327
- return await this.readDb.select({ grant: oauth2Grants, client: oauth2Clients }).from(oauth2Grants).innerJoin(oauth2Clients, eq25(oauth2Grants.client, oauth2Clients.id)).where(and21(eq25(oauth2Grants.user, userId), isNull13(oauth2Grants.revokedAt))).orderBy(desc5(oauth2Grants.createdAt));
10627
+ return await this.readDb.select({ grant: oauth2Grants, client: oauth2Clients }).from(oauth2Grants).innerJoin(oauth2Clients, eq26(oauth2Grants.client, oauth2Clients.id)).where(and22(eq26(oauth2Grants.user, userId), isNull14(oauth2Grants.revokedAt))).orderBy(desc5(oauth2Grants.createdAt));
10328
10628
  }
10329
10629
  /**
10330
10630
  * Revoke one live grant belonging to one user.
@@ -10337,19 +10637,19 @@ var init_oauth2_grants_repository = __esm({
10337
10637
  * @returns the revoked row, or null if there was no live grant of that id
10338
10638
  * for that user
10339
10639
  */
10340
- async revokeByIdForUser(id25, userId) {
10640
+ async revokeByIdForUser(id26, userId) {
10341
10641
  const result = await this.db.update(oauth2Grants).set({ revokedAt: /* @__PURE__ */ new Date() }).where(
10342
- and21(
10343
- eq25(oauth2Grants.id, id25),
10344
- eq25(oauth2Grants.user, userId),
10345
- isNull13(oauth2Grants.revokedAt)
10642
+ and22(
10643
+ eq26(oauth2Grants.id, id26),
10644
+ eq26(oauth2Grants.user, userId),
10645
+ isNull14(oauth2Grants.revokedAt)
10346
10646
  )
10347
10647
  ).returning();
10348
10648
  return result[0] ?? null;
10349
10649
  }
10350
10650
  /** Revoke one grant by id, whoever it belongs to — the replay detections. */
10351
- async revokeById(id25) {
10352
- const result = await this.db.update(oauth2Grants).set({ revokedAt: /* @__PURE__ */ new Date() }).where(and21(eq25(oauth2Grants.id, id25), isNull13(oauth2Grants.revokedAt))).returning();
10651
+ async revokeById(id26) {
10652
+ const result = await this.db.update(oauth2Grants).set({ revokedAt: /* @__PURE__ */ new Date() }).where(and22(eq26(oauth2Grants.id, id26), isNull14(oauth2Grants.revokedAt))).returning();
10353
10653
  return result[0] ?? null;
10354
10654
  }
10355
10655
  /**
@@ -10359,7 +10659,7 @@ var init_oauth2_grants_repository = __esm({
10359
10659
  * @returns the grant ids this call revoked
10360
10660
  */
10361
10661
  async revokeAllActiveByUserId(userId) {
10362
- const revoked = await this.db.update(oauth2Grants).set({ revokedAt: /* @__PURE__ */ new Date() }).where(and21(eq25(oauth2Grants.user, userId), isNull13(oauth2Grants.revokedAt))).returning({ id: oauth2Grants.id });
10662
+ const revoked = await this.db.update(oauth2Grants).set({ revokedAt: /* @__PURE__ */ new Date() }).where(and22(eq26(oauth2Grants.user, userId), isNull14(oauth2Grants.revokedAt))).returning({ id: oauth2Grants.id });
10363
10663
  return revoked.map((row) => row.id);
10364
10664
  }
10365
10665
  /**
@@ -10375,7 +10675,7 @@ var init_oauth2_grants_repository = __esm({
10375
10675
  if (grantIds.length === 0) {
10376
10676
  return 0;
10377
10677
  }
10378
- const revoked = await this.db.update(oauth2Tokens).set({ revokedAt: sql11`now()` }).where(and21(inArray3(oauth2Tokens.grant, grantIds), isNull13(oauth2Tokens.revokedAt))).returning({ id: oauth2Tokens.id });
10678
+ const revoked = await this.db.update(oauth2Tokens).set({ revokedAt: sql12`now()` }).where(and22(inArray4(oauth2Tokens.grant, grantIds), isNull14(oauth2Tokens.revokedAt))).returning({ id: oauth2Tokens.id });
10379
10679
  return revoked.length;
10380
10680
  }
10381
10681
  };
@@ -10384,14 +10684,14 @@ var init_oauth2_grants_repository = __esm({
10384
10684
  });
10385
10685
 
10386
10686
  // src/server/repositories/oauth2-authorization-codes.repository.ts
10387
- import { and as and22, eq as eq26, gt as gt9, isNull as isNull14, sql as sql12 } from "drizzle-orm";
10388
- import { BaseRepository as BaseRepository26 } from "@spfn/core/db";
10687
+ import { and as and23, eq as eq27, gt as gt10, isNull as isNull15, sql as sql13 } from "drizzle-orm";
10688
+ import { BaseRepository as BaseRepository27 } from "@spfn/core/db";
10389
10689
  var OAuth2AuthorizationCodesRepository, oauth2AuthorizationCodesRepository;
10390
10690
  var init_oauth2_authorization_codes_repository = __esm({
10391
10691
  "src/server/repositories/oauth2-authorization-codes.repository.ts"() {
10392
10692
  "use strict";
10393
10693
  init_oauth2_authorization_codes();
10394
- OAuth2AuthorizationCodesRepository = class extends BaseRepository26 {
10694
+ OAuth2AuthorizationCodesRepository = class extends BaseRepository27 {
10395
10695
  async create(data) {
10396
10696
  const result = await this.db.insert(oauth2AuthorizationCodes).values(data).returning();
10397
10697
  return result[0];
@@ -10403,11 +10703,11 @@ var init_oauth2_authorization_codes_repository = __esm({
10403
10703
  * spent, or past its 60 seconds
10404
10704
  */
10405
10705
  async consume(codeHash) {
10406
- const result = await this.db.update(oauth2AuthorizationCodes).set({ usedAt: sql12`now()` }).where(
10407
- and22(
10408
- eq26(oauth2AuthorizationCodes.codeHash, codeHash),
10409
- isNull14(oauth2AuthorizationCodes.usedAt),
10410
- gt9(oauth2AuthorizationCodes.expiresAt, sql12`now()`)
10706
+ const result = await this.db.update(oauth2AuthorizationCodes).set({ usedAt: sql13`now()` }).where(
10707
+ and23(
10708
+ eq27(oauth2AuthorizationCodes.codeHash, codeHash),
10709
+ isNull15(oauth2AuthorizationCodes.usedAt),
10710
+ gt10(oauth2AuthorizationCodes.expiresAt, sql13`now()`)
10411
10711
  )
10412
10712
  ).returning();
10413
10713
  return result[0] ?? null;
@@ -10418,7 +10718,7 @@ var init_oauth2_authorization_codes_repository = __esm({
10418
10718
  * would be indistinguishable from a code that never existed.
10419
10719
  */
10420
10720
  async findByCodeHash(codeHash) {
10421
- const result = await this.db.select().from(oauth2AuthorizationCodes).where(eq26(oauth2AuthorizationCodes.codeHash, codeHash)).limit(1);
10721
+ const result = await this.db.select().from(oauth2AuthorizationCodes).where(eq27(oauth2AuthorizationCodes.codeHash, codeHash)).limit(1);
10422
10722
  return result[0] ?? null;
10423
10723
  }
10424
10724
  };
@@ -10427,14 +10727,14 @@ var init_oauth2_authorization_codes_repository = __esm({
10427
10727
  });
10428
10728
 
10429
10729
  // src/server/repositories/oauth2-tokens.repository.ts
10430
- import { and as and23, eq as eq27, gt as gt10, isNull as isNull15, sql as sql13 } from "drizzle-orm";
10431
- import { BaseRepository as BaseRepository27 } from "@spfn/core/db";
10730
+ import { and as and24, eq as eq28, gt as gt11, isNull as isNull16, sql as sql14 } from "drizzle-orm";
10731
+ import { BaseRepository as BaseRepository28 } from "@spfn/core/db";
10432
10732
  var OAuth2TokensRepository, oauth2TokensRepository;
10433
10733
  var init_oauth2_tokens_repository = __esm({
10434
10734
  "src/server/repositories/oauth2-tokens.repository.ts"() {
10435
10735
  "use strict";
10436
10736
  init_oauth2_tokens();
10437
- OAuth2TokensRepository = class extends BaseRepository27 {
10737
+ OAuth2TokensRepository = class extends BaseRepository28 {
10438
10738
  async create(data) {
10439
10739
  const result = await this.db.insert(oauth2Tokens).values(data).returning();
10440
10740
  return result[0];
@@ -10448,7 +10748,7 @@ var init_oauth2_tokens_repository = __esm({
10448
10748
  * Unfiltered, so the service can tell revoked from expired from unknown.
10449
10749
  */
10450
10750
  async findByTokenHash(tokenHash) {
10451
- const result = await this.db.select().from(oauth2Tokens).where(eq27(oauth2Tokens.tokenHash, tokenHash)).limit(1);
10751
+ const result = await this.db.select().from(oauth2Tokens).where(eq28(oauth2Tokens.tokenHash, tokenHash)).limit(1);
10452
10752
  return result[0] ?? null;
10453
10753
  }
10454
10754
  /**
@@ -10458,13 +10758,13 @@ var init_oauth2_tokens_repository = __esm({
10458
10758
  * rotated, revoked, or expired
10459
10759
  */
10460
10760
  async rotate(tokenHash) {
10461
- const result = await this.db.update(oauth2Tokens).set({ replacedAt: sql13`now()` }).where(
10462
- and23(
10463
- eq27(oauth2Tokens.tokenHash, tokenHash),
10464
- eq27(oauth2Tokens.kind, "refresh"),
10465
- isNull15(oauth2Tokens.replacedAt),
10466
- isNull15(oauth2Tokens.revokedAt),
10467
- gt10(oauth2Tokens.expiresAt, sql13`now()`)
10761
+ const result = await this.db.update(oauth2Tokens).set({ replacedAt: sql14`now()` }).where(
10762
+ and24(
10763
+ eq28(oauth2Tokens.tokenHash, tokenHash),
10764
+ eq28(oauth2Tokens.kind, "refresh"),
10765
+ isNull16(oauth2Tokens.replacedAt),
10766
+ isNull16(oauth2Tokens.revokedAt),
10767
+ gt11(oauth2Tokens.expiresAt, sql14`now()`)
10468
10768
  )
10469
10769
  ).returning();
10470
10770
  return result[0] ?? null;
@@ -10477,12 +10777,12 @@ var init_oauth2_tokens_repository = __esm({
10477
10777
  * about whether the value it presented ever existed.
10478
10778
  */
10479
10779
  async revokeByTokenHash(tokenHash) {
10480
- const result = await this.db.update(oauth2Tokens).set({ revokedAt: /* @__PURE__ */ new Date() }).where(and23(eq27(oauth2Tokens.tokenHash, tokenHash), isNull15(oauth2Tokens.revokedAt))).returning();
10780
+ const result = await this.db.update(oauth2Tokens).set({ revokedAt: /* @__PURE__ */ new Date() }).where(and24(eq28(oauth2Tokens.tokenHash, tokenHash), isNull16(oauth2Tokens.revokedAt))).returning();
10481
10781
  return result[0] ?? null;
10482
10782
  }
10483
10783
  /** Fire-and-forget from the verification path, as ops tokens do. */
10484
- async updateLastUsedById(id25) {
10485
- await this.db.update(oauth2Tokens).set({ lastUsedAt: /* @__PURE__ */ new Date() }).where(eq27(oauth2Tokens.id, id25));
10784
+ async updateLastUsedById(id26) {
10785
+ await this.db.update(oauth2Tokens).set({ lastUsedAt: /* @__PURE__ */ new Date() }).where(eq28(oauth2Tokens.id, id26));
10486
10786
  }
10487
10787
  };
10488
10788
  oauth2TokensRepository = new OAuth2TokensRepository();
@@ -10504,6 +10804,7 @@ var init_repositories = __esm({
10504
10804
  init_mfa_totp_repository();
10505
10805
  init_mfa_recovery_codes_repository();
10506
10806
  init_mfa_verifications_repository();
10807
+ init_mfa_challenges_repository();
10507
10808
  init_mfa_enrolment_repository();
10508
10809
  init_device_authorizations_repository();
10509
10810
  init_roles_repository();
@@ -10611,7 +10912,7 @@ async function removePermissionFromRole(roleId, permissionId) {
10611
10912
  }
10612
10913
  async function setRolePermissions(roleId, permissionIds) {
10613
10914
  const roleIdNum = Number(roleId);
10614
- const permissionIdNums = permissionIds.map((id25) => Number(id25));
10915
+ const permissionIdNums = permissionIds.map((id26) => Number(id26));
10615
10916
  await rolePermissionsRepository.setPermissionsForRole(roleIdNum, permissionIdNums);
10616
10917
  }
10617
10918
  async function getAllRoles(includeInactive = false) {
@@ -10631,7 +10932,7 @@ async function getRolePermissions(roleId) {
10631
10932
  }
10632
10933
  const permissionIds = mappings.map((m) => m.permissionId);
10633
10934
  const perms = await Promise.all(
10634
- permissionIds.map((id25) => permissionsRepository.findById(id25))
10935
+ permissionIds.map((id26) => permissionsRepository.findById(id26))
10635
10936
  );
10636
10937
  return perms.filter((p) => p !== null).map((p) => p.name);
10637
10938
  }
@@ -10895,8 +11196,8 @@ async function listOAuth2GrantsService(userId) {
10895
11196
  lastUsedAtMillis: client.lastUsedAt?.getTime()
10896
11197
  }));
10897
11198
  }
10898
- async function revokeOAuth2GrantService(id25, userId) {
10899
- const revoked = await oauth2GrantsRepository.revokeByIdForUser(id25, userId);
11199
+ async function revokeOAuth2GrantService(id26, userId) {
11200
+ const revoked = await oauth2GrantsRepository.revokeByIdForUser(id26, userId);
10900
11201
  if (!revoked) {
10901
11202
  throw new OAuth2GrantNotFoundError();
10902
11203
  }
@@ -11283,10 +11584,10 @@ function uaFamily(userAgent) {
11283
11584
  // src/server/services/key.service.ts
11284
11585
  init_repositories();
11285
11586
  import { InvalidKeyFingerprintError, KeyIdAlreadyRegisteredError } from "@spfn/auth/errors";
11286
- import { ValidationError as ValidationError2 } from "@spfn/core/errors";
11587
+ import { ValidationError as ValidationError3 } from "@spfn/core/errors";
11287
11588
 
11288
11589
  // src/server/services/device-registration.service.ts
11289
- import { onAfterCommit as onAfterCommit2 } from "@spfn/core/db";
11590
+ import { onAfterCommit } from "@spfn/core/db";
11290
11591
 
11291
11592
  // src/server/events/index.ts
11292
11593
  init_esm();
@@ -11433,11 +11734,31 @@ var authPasswordResetEvent = defineEvent(
11433
11734
  })
11434
11735
  );
11435
11736
 
11737
+ // src/server/services/device-registration.service.ts
11738
+ init_repositories();
11739
+ var DEVICE_EVENT_FINGERPRINT_PREFIX_LENGTH = 12;
11740
+ async function emitDeviceRegistered(row, channel) {
11741
+ const mfaEnrolled = await mfaEnrolmentRepository.isEnrolled(row.userId);
11742
+ onAfterCommit(() => authDeviceRegisteredEvent.emit({
11743
+ userId: String(row.userId),
11744
+ keyId: row.keyId,
11745
+ algorithm: row.algorithm,
11746
+ fingerprintPrefix: row.fingerprint.slice(0, DEVICE_EVENT_FINGERPRINT_PREFIX_LENGTH),
11747
+ deviceName: row.deviceName ?? void 0,
11748
+ platform: row.platform ?? void 0,
11749
+ ip: row.registeredIp ?? void 0,
11750
+ userAgent: row.registeredUserAgent ?? void 0,
11751
+ createdAtMillis: row.createdAt.getTime(),
11752
+ channel,
11753
+ mfaEnrolled
11754
+ }));
11755
+ }
11756
+
11436
11757
  // src/server/services/mfa.service.ts
11437
11758
  init_logger();
11438
11759
  init_config();
11439
- import { onAfterCommit, runInTransaction as runInTransaction2 } from "@spfn/core/db";
11440
- import { ValidationError } from "@spfn/core/errors";
11760
+ import { onAfterCommit as onAfterCommit2, runInTransaction as runInTransaction2 } from "@spfn/core/db";
11761
+ import { ValidationError as ValidationError2 } from "@spfn/core/errors";
11441
11762
  import {
11442
11763
  MfaAlreadyEnrolledError,
11443
11764
  MfaNotEnrolledError,
@@ -11534,16 +11855,16 @@ function encodeBase32(bytes) {
11534
11855
  }
11535
11856
  return bits > 0 ? output + BASE32_ALPHABET[value << 5 - bits & 31] : output;
11536
11857
  }
11537
- function decodeBase32(text26) {
11858
+ function decodeBase32(text27) {
11538
11859
  let bits = 0;
11539
11860
  let value = 0;
11540
11861
  const bytes = [];
11541
- for (const character of text26.replace(/=+$/, "").toUpperCase()) {
11542
- const index20 = BASE32_ALPHABET.indexOf(character);
11543
- if (index20 < 0) {
11862
+ for (const character of text27.replace(/=+$/, "").toUpperCase()) {
11863
+ const index21 = BASE32_ALPHABET.indexOf(character);
11864
+ if (index21 < 0) {
11544
11865
  throw new Error("Value is not RFC 4648 base32");
11545
11866
  }
11546
- value = value << 5 | index20;
11867
+ value = value << 5 | index21;
11547
11868
  bits += 5;
11548
11869
  if (bits >= 8) {
11549
11870
  bytes.push(value >>> bits - 8 & 255);
@@ -11724,6 +12045,87 @@ async function verifyAuthentication(params) {
11724
12045
 
11725
12046
  // src/server/services/mfa.service.ts
11726
12047
  init_repositories();
12048
+
12049
+ // src/server/services/login-result.ts
12050
+ function loginBindingFields(registered) {
12051
+ if (registered.binding !== "passkey" || !registered.expiresAt) {
12052
+ return {};
12053
+ }
12054
+ return { sessionBinding: "passkey", keyExpiresAtMillis: registered.expiresAt.getTime() };
12055
+ }
12056
+
12057
+ // src/server/services/user.service.ts
12058
+ init_repositories();
12059
+ import { ValidationError } from "@spfn/core/errors";
12060
+ import { ReservedUsernameError, UsernameAlreadyTakenError } from "@spfn/auth/errors";
12061
+ import { env as env9 } from "@spfn/auth/config";
12062
+ async function getUserByIdService(userId) {
12063
+ return await usersRepository.findById(userId);
12064
+ }
12065
+ async function getUserByEmailService(email) {
12066
+ return await usersRepository.findByEmail(email);
12067
+ }
12068
+ async function getUserByPhoneService(phone) {
12069
+ return await usersRepository.findByPhone(phone);
12070
+ }
12071
+ async function updateLastLoginService(userId) {
12072
+ await usersRepository.updateLastLogin(userId);
12073
+ }
12074
+ async function updateUserService(userId, updates) {
12075
+ await usersRepository.updateById(userId, updates);
12076
+ }
12077
+ function getReservedUsernames() {
12078
+ const raw = env9.SPFN_AUTH_RESERVED_USERNAMES ?? "";
12079
+ if (!raw) {
12080
+ return /* @__PURE__ */ new Set();
12081
+ }
12082
+ return new Set(
12083
+ raw.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean)
12084
+ );
12085
+ }
12086
+ function isReservedUsername(username) {
12087
+ return getReservedUsernames().has(username.toLowerCase());
12088
+ }
12089
+ function validateUsernameLength(username) {
12090
+ const min = env9.SPFN_AUTH_USERNAME_MIN_LENGTH ?? 3;
12091
+ const max2 = env9.SPFN_AUTH_USERNAME_MAX_LENGTH ?? 30;
12092
+ if (username.length < min) {
12093
+ throw new ValidationError({
12094
+ message: `Username must be at least ${min} characters`,
12095
+ details: { minLength: min, actual: username.length }
12096
+ });
12097
+ }
12098
+ if (username.length > max2) {
12099
+ throw new ValidationError({
12100
+ message: `Username must be at most ${max2} characters`,
12101
+ details: { maxLength: max2, actual: username.length }
12102
+ });
12103
+ }
12104
+ }
12105
+ async function checkUsernameAvailableService(username) {
12106
+ validateUsernameLength(username);
12107
+ if (isReservedUsername(username)) {
12108
+ return false;
12109
+ }
12110
+ const existing = await usersRepository.findByUsername(username);
12111
+ return !existing;
12112
+ }
12113
+ async function updateUsernameService(userId, username) {
12114
+ const userIdNum = typeof userId === "string" ? Number(userId) : Number(userId);
12115
+ if (username !== null) {
12116
+ validateUsernameLength(username);
12117
+ if (isReservedUsername(username)) {
12118
+ throw new ReservedUsernameError({ username });
12119
+ }
12120
+ const existing = await usersRepository.findByUsername(username);
12121
+ if (existing && existing.id !== userIdNum) {
12122
+ throw new UsernameAlreadyTakenError({ username });
12123
+ }
12124
+ }
12125
+ return await usersRepository.updateById(userIdNum, { username });
12126
+ }
12127
+
12128
+ // src/server/services/mfa.service.ts
11727
12129
  init_mfa_totp();
11728
12130
 
11729
12131
  // src/server/services/webauthn-challenge.service.ts
@@ -11752,9 +12154,9 @@ function presentedChallenge(clientDataJSON) {
11752
12154
  const decoded = parseJson(Buffer.from(clientDataJSON, "base64url").toString("utf8"));
11753
12155
  return typeof decoded?.challenge === "string" ? decoded.challenge : "";
11754
12156
  }
11755
- function parseJson(text26) {
12157
+ function parseJson(text27) {
11756
12158
  try {
11757
- return JSON.parse(text26);
12159
+ return JSON.parse(text27);
11758
12160
  } catch {
11759
12161
  return null;
11760
12162
  }
@@ -11774,10 +12176,110 @@ async function assertStepUp(params) {
11774
12176
  if (verification?.userId === params.userId && Date.now() - verification.verifiedAt.getTime() <= window) {
11775
12177
  return;
11776
12178
  }
11777
- throw new StepUpRequiredError();
12179
+ throw new StepUpRequiredError();
12180
+ }
12181
+ function carryStepUpVerification(userId, fromKeyId, toKeyId) {
12182
+ return mfaVerificationsRepository.moveToKeyId(userId, fromKeyId, toKeyId);
12183
+ }
12184
+ async function openStepUpChallengeService(params) {
12185
+ const { secret, hash: hash2 } = mintCredential();
12186
+ const expiresAt = new Date(Date.now() + getMfaConfig().challengeTtlMs);
12187
+ const row = await mfaChallengesRepository.create({
12188
+ userId: params.userId,
12189
+ challengeHash: hash2,
12190
+ keyId: params.keyId,
12191
+ channel: params.channel,
12192
+ keyEpoch: params.keyEpoch,
12193
+ expiresAt,
12194
+ loginEvent: params.loginEvent ?? null
12195
+ });
12196
+ await mfaChallengesRepository.markKeyPending(params.keyId, row.id);
12197
+ return { secret, expiresAtMillis: expiresAt.getTime() };
12198
+ }
12199
+ async function resumeStepUpChallengeService(userId, keyId) {
12200
+ const live = await mfaChallengesRepository.findLiveByKeyId(userId, keyId);
12201
+ if (!live) {
12202
+ return null;
12203
+ }
12204
+ const { secret, hash: hash2 } = mintCredential();
12205
+ return await mfaChallengesRepository.resecret(live.id, hash2) ? { secret, expiresAtMillis: live.expiresAt.getTime() } : null;
12206
+ }
12207
+ async function spendableChallenge(challengeHash) {
12208
+ const row = await mfaChallengesRepository.findByHash(challengeHash);
12209
+ if (!row || row.verifiedAt || row.expiresAt.getTime() <= Date.now()) {
12210
+ throw new MfaVerificationFailedError();
12211
+ }
12212
+ if (row.keyEpoch !== await usersRepository.currentKeyEpoch(row.userId)) {
12213
+ throw new MfaVerificationFailedError();
12214
+ }
12215
+ return row;
12216
+ }
12217
+ async function countChallengeFailure(row) {
12218
+ if (await mfaChallengesRepository.countFailure(row.id)) {
12219
+ await mfaChallengesRepository.dropPendingKey(row.keyId, row.id);
12220
+ authLogger.service.warn("Second-factor challenge discarded after repeated wrong proofs", {
12221
+ userId: row.userId
12222
+ });
12223
+ }
12224
+ throw new MfaVerificationFailedError();
12225
+ }
12226
+ async function releaseDeferredAnnouncements(row, key) {
12227
+ await updateLastLoginService(row.userId);
12228
+ await emitDeviceRegistered(key, row.channel);
12229
+ if (!row.loginEvent) {
12230
+ return;
12231
+ }
12232
+ const mfaEnrolled = await mfaEnrolledForUser(row.userId);
12233
+ onAfterCommit2(() => authLoginEvent.emit({ ...row.loginEvent, userId: String(row.userId), mfaEnrolled }));
12234
+ }
12235
+ async function verifyMfaChallengeService(params) {
12236
+ const challengeHash = hashCredential(params.challenge);
12237
+ const row = await spendableChallenge(challengeHash);
12238
+ const method = await attemptSecondFactor({
12239
+ userId: row.userId,
12240
+ keyId: row.keyId,
12241
+ code: params.code,
12242
+ recoveryCode: params.recoveryCode,
12243
+ response: params.response
12244
+ });
12245
+ if (!method) {
12246
+ await countChallengeFailure(row);
12247
+ }
12248
+ return await runInTransaction2(async () => {
12249
+ if (!await mfaChallengesRepository.markVerified(row.id, row.keyEpoch)) {
12250
+ throw new MfaVerificationFailedError();
12251
+ }
12252
+ await mfaChallengesRepository.activateKey(row.keyId, row.id);
12253
+ await mfaVerificationsRepository.record(row.keyId, row.userId, method);
12254
+ return { ...await settleStepUpLogin(row), keyId: row.keyId, challengeHash };
12255
+ }, { context: "auth:mfa-verify" });
12256
+ }
12257
+ async function settleStepUpLogin(row) {
12258
+ const user = await usersRepository.findByIdOnPrimary(row.userId);
12259
+ const key = await keysRepository.findByKeyIdAndUserId(row.keyId, row.userId);
12260
+ if (!user || !key) {
12261
+ throw new MfaVerificationFailedError();
12262
+ }
12263
+ await releaseDeferredAnnouncements(row, key);
12264
+ return {
12265
+ mfaRequired: false,
12266
+ userId: String(user.id),
12267
+ publicId: user.publicId,
12268
+ email: user.email || void 0,
12269
+ phone: user.phone || void 0,
12270
+ passwordChangeRequired: user.passwordChangeRequired,
12271
+ ...loginBindingFields(key)
12272
+ };
11778
12273
  }
11779
- function carryStepUpVerification(userId, fromKeyId, toKeyId) {
11780
- return mfaVerificationsRepository.moveToKeyId(userId, fromKeyId, toKeyId);
12274
+ async function startMfaChallengeAssertionService(challenge) {
12275
+ const row = await spendableChallenge(hashCredential(challenge));
12276
+ return await buildAuthenticationOptions({
12277
+ config: getPasskeyConfig(),
12278
+ challenge: await mintChallenge("mfa", row.userId)
12279
+ });
12280
+ }
12281
+ async function sweepMfaChallengesService() {
12282
+ return { deleted: await mfaChallengesRepository.sweepFinished(/* @__PURE__ */ new Date()) };
11781
12283
  }
11782
12284
  async function startTotpEnrolmentService(userId) {
11783
12285
  const existing = await mfaTotpRepository.findByUserId(userId);
@@ -11822,7 +12324,7 @@ async function countConfirmFailure(userId) {
11822
12324
  function readSecret(secretEnc, userId) {
11823
12325
  const { value, needsRotation } = decryptMfaSecret(secretEnc, userId);
11824
12326
  if (needsRotation) {
11825
- onAfterCommit(() => mfaTotpRepository.updateSecret(userId, encryptMfaSecret(value, userId)));
12327
+ onAfterCommit2(() => mfaTotpRepository.updateSecret(userId, encryptMfaSecret(value, userId)));
11826
12328
  }
11827
12329
  return value;
11828
12330
  }
@@ -11884,10 +12386,10 @@ async function stepUpService(params) {
11884
12386
  const method = await verifySecondFactor(params);
11885
12387
  await mfaVerificationsRepository.record(params.keyId, params.userId, method);
11886
12388
  }
11887
- async function verifySecondFactor(params) {
12389
+ async function attemptSecondFactor(params) {
11888
12390
  const presented = [params.code, params.recoveryCode, params.response].filter((value) => value !== void 0);
11889
12391
  if (presented.length !== 1) {
11890
- throw new ValidationError({
12392
+ throw new ValidationError2({
11891
12393
  message: "Send exactly one of code, recoveryCode or response."
11892
12394
  });
11893
12395
  }
@@ -11896,10 +12398,17 @@ async function verifySecondFactor(params) {
11896
12398
  }
11897
12399
  return params.recoveryCode !== void 0 ? await verifyRecoveryProof(params.userId, params.recoveryCode) : await verifyPasskeyProof(params.userId, params.response);
11898
12400
  }
12401
+ async function verifySecondFactor(params) {
12402
+ const method = await attemptSecondFactor(params);
12403
+ if (!method) {
12404
+ throw new MfaVerificationFailedError();
12405
+ }
12406
+ return method;
12407
+ }
11899
12408
  async function verifyTotpProof(userId, code) {
11900
12409
  const row = await mfaTotpRepository.findByUserId(userId);
11901
12410
  if (!row?.confirmedAt) {
11902
- throw new MfaVerificationFailedError();
12411
+ return null;
11903
12412
  }
11904
12413
  const step = verifyTotp({
11905
12414
  secret: readSecret(row.secretEnc, userId),
@@ -11907,7 +12416,7 @@ async function verifyTotpProof(userId, code) {
11907
12416
  lastUsedStep: row.lastUsedStep
11908
12417
  });
11909
12418
  if (step === null) {
11910
- throw new MfaVerificationFailedError();
12419
+ return null;
11911
12420
  }
11912
12421
  await mfaTotpRepository.recordUsedStep(userId, step);
11913
12422
  return "totp";
@@ -11921,17 +12430,17 @@ async function verifyRecoveryProof(userId, submitted) {
11921
12430
  return "recovery";
11922
12431
  }
11923
12432
  }
11924
- throw new MfaVerificationFailedError();
12433
+ return null;
11925
12434
  }
11926
12435
  async function verifyPasskeyProof(userId, response) {
11927
12436
  const challenge = presentedChallenge(response.response.clientDataJSON);
11928
12437
  if (!await spendChallenge(challenge, "mfa", userId)) {
11929
- throw new MfaVerificationFailedError();
12438
+ return null;
11930
12439
  }
11931
12440
  const marked = await passkeysRepository.listLiveSecondFactorByUserId(userId);
11932
12441
  const passkey = marked.find((row) => row.credentialId === response.id);
11933
12442
  if (!passkey) {
11934
- throw new MfaVerificationFailedError();
12443
+ return null;
11935
12444
  }
11936
12445
  const outcome = await verifyAuthentication({
11937
12446
  config: getPasskeyConfig(),
@@ -11945,7 +12454,7 @@ async function verifyPasskeyProof(userId, response) {
11945
12454
  }
11946
12455
  });
11947
12456
  if (!outcome.verified) {
11948
- throw new MfaVerificationFailedError();
12457
+ return null;
11949
12458
  }
11950
12459
  await passkeysRepository.recordUse(passkey.id, outcome.newCounter);
11951
12460
  return "passkey";
@@ -11955,26 +12464,14 @@ async function sweepUnconfirmedMfaService() {
11955
12464
  return { deleted: await mfaTotpRepository.deleteUnconfirmedBefore(cutoff) };
11956
12465
  }
11957
12466
 
11958
- // src/server/services/device-registration.service.ts
11959
- var DEVICE_EVENT_FINGERPRINT_PREFIX_LENGTH = 12;
11960
- async function emitDeviceRegistered(row, channel) {
11961
- const mfaEnrolled = await mfaEnrolledForUser(row.userId);
11962
- onAfterCommit2(() => authDeviceRegisteredEvent.emit({
11963
- userId: String(row.userId),
11964
- keyId: row.keyId,
11965
- algorithm: row.algorithm,
11966
- fingerprintPrefix: row.fingerprint.slice(0, DEVICE_EVENT_FINGERPRINT_PREFIX_LENGTH),
11967
- deviceName: row.deviceName ?? void 0,
11968
- platform: row.platform ?? void 0,
11969
- ip: row.registeredIp ?? void 0,
11970
- userAgent: row.registeredUserAgent ?? void 0,
11971
- createdAtMillis: row.createdAt.getTime(),
11972
- channel,
11973
- mfaEnrolled
11974
- }));
11975
- }
11976
-
11977
12467
  // src/server/services/key.service.ts
12468
+ init_mfa_challenges();
12469
+ function registeredBinding(result) {
12470
+ if (result.pending) {
12471
+ throw new Error("This registration channel cannot require a second factor");
12472
+ }
12473
+ return result;
12474
+ }
11978
12475
  var KEY_FINGERPRINT_PREFIX_LENGTH = 8;
11979
12476
  var DEFAULT_KEY_ALGORITHM = "ES256";
11980
12477
  function getKeyExpiryDate(binding) {
@@ -11988,21 +12485,36 @@ function getKeyExpiryDate(binding) {
11988
12485
  function isExpired(expiresAt) {
11989
12486
  return expiresAt !== null && /* @__PURE__ */ new Date() > expiresAt;
11990
12487
  }
12488
+ async function decideStepUp(params) {
12489
+ const channel = MFA_CHALLENGE_CHANNELS.find((candidate) => candidate === params.channel);
12490
+ if (!channel || params.replacesKeyId) {
12491
+ return null;
12492
+ }
12493
+ return await mfaEnrolledForUser(params.userId) ? channel : null;
12494
+ }
12495
+ async function answerForExistingKey(existing, userId) {
12496
+ if (existing.userId === userId && existing.isActive) {
12497
+ return { pending: false, ...await reRegisterOwnActiveKey(existing, userId) };
12498
+ }
12499
+ const resumed = existing.userId === userId && existing.pendingMfaChallengeId ? await resumeStepUpChallengeService(userId, existing.keyId) : null;
12500
+ if (resumed) {
12501
+ return { pending: true, challenge: resumed };
12502
+ }
12503
+ throw new KeyIdAlreadyRegisteredError();
12504
+ }
11991
12505
  async function registerPublicKeyService(params) {
11992
12506
  const { userId, keyId, publicKey, fingerprint, algorithm = DEFAULT_KEY_ALGORITHM, deviceName, platform } = params;
11993
12507
  const binding = params.binding ?? "none";
11994
12508
  const existing = await keysRepository.findByKeyId(keyId);
11995
12509
  if (existing) {
11996
- if (existing.userId === userId && existing.isActive) {
11997
- return await reRegisterOwnActiveKey(existing, userId);
11998
- }
11999
- throw new KeyIdAlreadyRegisteredError();
12510
+ return await answerForExistingKey(existing, userId);
12000
12511
  }
12001
12512
  const isValidFingerprint = verifyKeyFingerprint(publicKey, fingerprint);
12002
12513
  if (!isValidFingerprint) {
12003
12514
  throw new InvalidKeyFingerprintError();
12004
12515
  }
12005
12516
  assertKeyMatchesAlgorithm(publicKey, algorithm);
12517
+ const stepUp = await decideStepUp(params);
12006
12518
  const row = await keysRepository.create({
12007
12519
  userId,
12008
12520
  keyId,
@@ -12015,15 +12527,24 @@ async function registerPublicKeyService(params) {
12015
12527
  registeredIp: params.ip ?? null,
12016
12528
  registeredUserAgent: params.userAgent ?? null,
12017
12529
  registeredUaFamily: params.userAgent ? uaFamily(params.userAgent) : null,
12018
- isActive: true,
12530
+ isActive: stepUp === null,
12019
12531
  expiresAt: getKeyExpiryDate(binding)
12020
12532
  });
12533
+ if (stepUp) {
12534
+ return { pending: true, challenge: await openStepUpChallengeService({
12535
+ userId,
12536
+ keyId,
12537
+ channel: stepUp,
12538
+ keyEpoch: await usersRepository.currentKeyEpoch(userId),
12539
+ loginEvent: params.loginEvent
12540
+ }) };
12541
+ }
12021
12542
  if (params.replacesKeyId) {
12022
12543
  await carryStepUpVerification(userId, params.replacesKeyId, keyId);
12023
12544
  } else {
12024
12545
  await emitDeviceRegistered(row, params.channel);
12025
12546
  }
12026
- return { binding, expiresAt: row.expiresAt };
12547
+ return { pending: false, binding, expiresAt: row.expiresAt };
12027
12548
  }
12028
12549
  async function reRegisterOwnActiveKey(existing, userId) {
12029
12550
  if (existing.binding !== "passkey" && isExpired(existing.expiresAt)) {
@@ -12095,7 +12616,7 @@ async function listKeysService(params) {
12095
12616
  async function revokeAllKeysService(params) {
12096
12617
  const { userId, currentKeyId, includeCurrent = false, reason } = params;
12097
12618
  if (!includeCurrent && !currentKeyId) {
12098
- throw new ValidationError2({ message: "currentKeyId is required unless includeCurrent is set" });
12619
+ throw new ValidationError3({ message: "currentKeyId is required unless includeCurrent is set" });
12099
12620
  }
12100
12621
  if (currentKeyId) {
12101
12622
  await assertStepUp({ userId, keyId: currentKeyId });
@@ -12109,77 +12630,6 @@ async function revokeAllKeysService(params) {
12109
12630
  // src/server/services/auth.service.ts
12110
12631
  init_key_policy();
12111
12632
 
12112
- // src/server/services/user.service.ts
12113
- init_repositories();
12114
- import { ValidationError as ValidationError3 } from "@spfn/core/errors";
12115
- import { ReservedUsernameError, UsernameAlreadyTakenError } from "@spfn/auth/errors";
12116
- import { env as env9 } from "@spfn/auth/config";
12117
- async function getUserByIdService(userId) {
12118
- return await usersRepository.findById(userId);
12119
- }
12120
- async function getUserByEmailService(email) {
12121
- return await usersRepository.findByEmail(email);
12122
- }
12123
- async function getUserByPhoneService(phone) {
12124
- return await usersRepository.findByPhone(phone);
12125
- }
12126
- async function updateLastLoginService(userId) {
12127
- await usersRepository.updateLastLogin(userId);
12128
- }
12129
- async function updateUserService(userId, updates) {
12130
- await usersRepository.updateById(userId, updates);
12131
- }
12132
- function getReservedUsernames() {
12133
- const raw = env9.SPFN_AUTH_RESERVED_USERNAMES ?? "";
12134
- if (!raw) {
12135
- return /* @__PURE__ */ new Set();
12136
- }
12137
- return new Set(
12138
- raw.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean)
12139
- );
12140
- }
12141
- function isReservedUsername(username) {
12142
- return getReservedUsernames().has(username.toLowerCase());
12143
- }
12144
- function validateUsernameLength(username) {
12145
- const min = env9.SPFN_AUTH_USERNAME_MIN_LENGTH ?? 3;
12146
- const max2 = env9.SPFN_AUTH_USERNAME_MAX_LENGTH ?? 30;
12147
- if (username.length < min) {
12148
- throw new ValidationError3({
12149
- message: `Username must be at least ${min} characters`,
12150
- details: { minLength: min, actual: username.length }
12151
- });
12152
- }
12153
- if (username.length > max2) {
12154
- throw new ValidationError3({
12155
- message: `Username must be at most ${max2} characters`,
12156
- details: { maxLength: max2, actual: username.length }
12157
- });
12158
- }
12159
- }
12160
- async function checkUsernameAvailableService(username) {
12161
- validateUsernameLength(username);
12162
- if (isReservedUsername(username)) {
12163
- return false;
12164
- }
12165
- const existing = await usersRepository.findByUsername(username);
12166
- return !existing;
12167
- }
12168
- async function updateUsernameService(userId, username) {
12169
- const userIdNum = typeof userId === "string" ? Number(userId) : Number(userId);
12170
- if (username !== null) {
12171
- validateUsernameLength(username);
12172
- if (isReservedUsername(username)) {
12173
- throw new ReservedUsernameError({ username });
12174
- }
12175
- const existing = await usersRepository.findByUsername(username);
12176
- if (existing && existing.id !== userIdNum) {
12177
- throw new UsernameAlreadyTakenError({ username });
12178
- }
12179
- }
12180
- return await usersRepository.updateById(userIdNum, { username });
12181
- }
12182
-
12183
12633
  // src/server/services/account-deletion.service.ts
12184
12634
  init_repositories();
12185
12635
  import { ValidationError as ValidationError4, NotFoundError as NotFoundError2 } from "@spfn/core/errors";
@@ -12258,8 +12708,8 @@ async function verifyReauthCredential(user, params) {
12258
12708
  throw new VerificationTokenTargetMismatchError();
12259
12709
  }
12260
12710
  }
12261
- async function sendDeletionEmail(to, subject, text26) {
12262
- const result = await sendEmail3({ to, subject, text: text26 });
12711
+ async function sendDeletionEmail(to, subject, text27) {
12712
+ const result = await sendEmail3({ to, subject, text: text27 });
12263
12713
  if (!result.success) {
12264
12714
  authLogger.email.error("Failed to send account deletion email", { to, subject, error: result.error });
12265
12715
  }
@@ -12508,12 +12958,6 @@ async function sweepDuePurges(now = /* @__PURE__ */ new Date()) {
12508
12958
  }
12509
12959
 
12510
12960
  // src/server/services/auth.service.ts
12511
- function loginBindingFields(registered) {
12512
- if (registered.binding !== "passkey" || !registered.expiresAt) {
12513
- return {};
12514
- }
12515
- return { sessionBinding: "passkey", keyExpiresAtMillis: registered.expiresAt.getTime() };
12516
- }
12517
12961
  async function registerService(params) {
12518
12962
  const { email, verificationToken } = params;
12519
12963
  const phone = params.phone?.trim();
@@ -12629,10 +13073,19 @@ async function loginService(params) {
12629
13073
  ip: params.ip,
12630
13074
  userAgent: params.userAgent,
12631
13075
  binding: decideKeyBinding(user.sessionBinding, params.webProxy),
12632
- replacesKeyId
13076
+ replacesKeyId,
13077
+ loginEvent: {
13078
+ provider: email ? "email" : "phone",
13079
+ email: user.email || void 0,
13080
+ phone: user.phone || void 0
13081
+ }
12633
13082
  });
13083
+ if (registered.pending) {
13084
+ return { mfaRequired: true, challenge: registered.challenge };
13085
+ }
12634
13086
  await updateLastLoginService(user.id);
12635
13087
  const result = {
13088
+ mfaRequired: false,
12636
13089
  userId: String(user.id),
12637
13090
  publicId: user.publicId,
12638
13091
  email: user.email || void 0,
@@ -12641,7 +13094,7 @@ async function loginService(params) {
12641
13094
  ...loginBindingFields(registered)
12642
13095
  };
12643
13096
  await authLoginEvent.emit({
12644
- userId: result.userId,
13097
+ userId: String(user.id),
12645
13098
  provider: email ? "email" : "phone",
12646
13099
  email: result.email,
12647
13100
  phone: result.phone,
@@ -12899,12 +13352,11 @@ async function replaceCredentials(row, user, params) {
12899
13352
  userAgent: params.userAgent,
12900
13353
  binding: decideKeyBinding(user.sessionBinding, params.webProxy)
12901
13354
  });
12902
- await updateLastLoginService(user.id);
12903
13355
  if (!await passwordResetTokensRepository.complete(row.id)) {
12904
13356
  authLogger.service.warn("Password reset session refused", { reason: "lost the claim race" });
12905
13357
  throw new PasswordResetSessionError();
12906
13358
  }
12907
- return loginBindingFields(registered);
13359
+ return registered;
12908
13360
  }
12909
13361
  async function completePasswordResetService(params) {
12910
13362
  if (!params.publicKey || !params.keyId || !params.fingerprint) {
@@ -12923,17 +13375,22 @@ async function completePasswordResetService(params) {
12923
13375
  authLogger.service.warn("Password reset session refused", { reason: "account is no longer active" });
12924
13376
  throw new PasswordResetSessionError();
12925
13377
  }
12926
- const binding = await replaceCredentials(row, user, params);
13378
+ const registered = await replaceCredentials(row, user, params);
12927
13379
  onAfterCommit4(() => authPasswordResetEvent.emit({
12928
13380
  userId: String(user.id),
12929
13381
  email: row.email
12930
13382
  }));
13383
+ if (registered.pending) {
13384
+ return { mfaRequired: true, challenge: registered.challenge };
13385
+ }
13386
+ await updateLastLoginService(user.id);
12931
13387
  return {
13388
+ mfaRequired: false,
12932
13389
  userId: String(user.id),
12933
13390
  publicId: user.publicId,
12934
13391
  email: user.email || void 0,
12935
13392
  phone: user.phone || void 0,
12936
- ...binding
13393
+ ...loginBindingFields(registered)
12937
13394
  };
12938
13395
  }
12939
13396
 
@@ -13219,16 +13676,20 @@ async function completeDeviceLogin(record, provenance) {
13219
13676
  });
13220
13677
  await updateLastLoginService(user.id);
13221
13678
  const result = {
13679
+ // A device-code approval is itself a second factor: the owner read the
13680
+ // code on a device that is already signed in and said yes, so this
13681
+ // channel never steps up (#95).
13682
+ mfaRequired: false,
13222
13683
  userId: String(user.id),
13223
13684
  publicId: user.publicId,
13224
13685
  email: user.email || void 0,
13225
13686
  phone: user.phone || void 0,
13226
13687
  passwordChangeRequired: user.passwordChangeRequired,
13227
- ...loginBindingFields(registered)
13688
+ ...loginBindingFields(registeredBinding(registered))
13228
13689
  };
13229
- const mfaEnrolled = await mfaEnrolledForUser(Number(result.userId));
13690
+ const mfaEnrolled = await mfaEnrolledForUser(user.id);
13230
13691
  onAfterCommit5(() => authLoginEvent.emit({
13231
- userId: result.userId,
13692
+ userId: String(user.id),
13232
13693
  provider: "device",
13233
13694
  email: result.email,
13234
13695
  phone: result.phone,
@@ -13447,16 +13908,19 @@ async function startSession(user, params) {
13447
13908
  });
13448
13909
  await updateLastLoginService(user.id);
13449
13910
  const result = {
13911
+ // A passkey sign-in never steps up: the assertion is already a second
13912
+ // factor, so this answer is always the session itself (#95).
13913
+ mfaRequired: false,
13450
13914
  userId: String(user.id),
13451
13915
  publicId: user.publicId,
13452
13916
  email: user.email || void 0,
13453
13917
  phone: user.phone || void 0,
13454
13918
  passwordChangeRequired: user.passwordChangeRequired,
13455
- ...loginBindingFields(registered)
13919
+ ...loginBindingFields(registeredBinding(registered))
13456
13920
  };
13457
13921
  const mfaEnrolled = await mfaEnrolledForUser(user.id);
13458
13922
  onAfterCommit6(() => authLoginEvent.emit({
13459
- userId: result.userId,
13923
+ userId: String(user.id),
13460
13924
  provider: "passkey",
13461
13925
  email: result.email,
13462
13926
  phone: result.phone,
@@ -13727,7 +14191,7 @@ async function getUserPermissions(userId) {
13727
14191
  const permIds = rolePermMappings.map((rp) => rp.permissionId);
13728
14192
  if (permIds.length > 0) {
13729
14193
  const rolePerms = await Promise.all(
13730
- permIds.map((id25) => permissionsRepository.findById(id25))
14194
+ permIds.map((id26) => permissionsRepository.findById(id26))
13731
14195
  );
13732
14196
  for (const perm of rolePerms) {
13733
14197
  if (perm && perm.isActive) {
@@ -13945,20 +14409,20 @@ async function acceptInvitation(params) {
13945
14409
  async function listInvitations(params) {
13946
14410
  return await invitationsRepository.list(params);
13947
14411
  }
13948
- async function cancelInvitation(id25, cancelledBy, reason) {
13949
- const invitation = await invitationsRepository.findById(id25);
14412
+ async function cancelInvitation(id26, cancelledBy, reason) {
14413
+ const invitation = await invitationsRepository.findById(id26);
13950
14414
  if (!invitation) {
13951
14415
  throw new NotFoundError4({ message: "Invitation not found", resource: "Invitation" });
13952
14416
  }
13953
14417
  if (invitation.status !== "pending") {
13954
14418
  throw new ConflictError({ message: `Cannot cancel ${invitation.status} invitation` });
13955
14419
  }
13956
- await invitationsRepository.cancel(id25, cancelledBy, reason, invitation.metadata);
14420
+ await invitationsRepository.cancel(id26, cancelledBy, reason, invitation.metadata);
13957
14421
  console.log(`[Auth] \u26A0\uFE0F Invitation cancelled: ${invitation.email} (reason: ${reason || "none"})`);
13958
14422
  }
13959
- async function deleteInvitation(id25) {
13960
- await invitationsRepository.deleteById(id25);
13961
- console.log(`[Auth] \u{1F5D1}\uFE0F Invitation deleted: ${id25}`);
14423
+ async function deleteInvitation(id26) {
14424
+ await invitationsRepository.deleteById(id26);
14425
+ console.log(`[Auth] \u{1F5D1}\uFE0F Invitation deleted: ${id26}`);
13962
14426
  }
13963
14427
  async function expireOldInvitations() {
13964
14428
  const count2 = await invitationsRepository.updateExpiredInvitations();
@@ -13967,8 +14431,8 @@ async function expireOldInvitations() {
13967
14431
  }
13968
14432
  return count2;
13969
14433
  }
13970
- async function resendInvitation(id25, expiresInDays = 7) {
13971
- const invitation = await invitationsRepository.findById(id25);
14434
+ async function resendInvitation(id26, expiresInDays = 7) {
14435
+ const invitation = await invitationsRepository.findById(id26);
13972
14436
  if (!invitation) {
13973
14437
  throw new NotFoundError4({ message: "Invitation not found", resource: "Invitation" });
13974
14438
  }
@@ -13976,7 +14440,7 @@ async function resendInvitation(id25, expiresInDays = 7) {
13976
14440
  throw new ConflictError({ message: `Cannot resend ${invitation.status} invitation` });
13977
14441
  }
13978
14442
  const newExpiresAt = calculateExpiresAt(expiresInDays);
13979
- const updated = await invitationsRepository.resend(id25, newExpiresAt);
14443
+ const updated = await invitationsRepository.resend(id26, newExpiresAt);
13980
14444
  if (!updated) {
13981
14445
  throw new Error("Failed to update invitation");
13982
14446
  }
@@ -14292,8 +14756,8 @@ var registry2 = /* @__PURE__ */ new Map();
14292
14756
  function registerOAuthProvider(provider) {
14293
14757
  registry2.set(provider.id, provider);
14294
14758
  }
14295
- function getOAuthProvider(id25) {
14296
- return registry2.get(id25);
14759
+ function getOAuthProvider(id26) {
14760
+ return registry2.get(id26);
14297
14761
  }
14298
14762
  function getRegisteredProviders() {
14299
14763
  return [...registry2.values()];
@@ -15140,12 +15604,32 @@ async function oauthCallbackService(params) {
15140
15604
  channel: "oauth",
15141
15605
  ip: params.ip,
15142
15606
  userAgent: params.userAgent,
15143
- binding: decideKeyBinding(user?.sessionBinding ?? "none", params.webProxy)
15607
+ binding: decideKeyBinding(user?.sessionBinding ?? "none", params.webProxy),
15608
+ loginEvent: {
15609
+ provider,
15610
+ email: user?.email || void 0,
15611
+ phone: user?.phone || void 0,
15612
+ metadata: stateData.metadata
15613
+ }
15144
15614
  });
15145
- await updateLastLoginService(userId);
15146
15615
  const appUrl = env17.NEXT_PUBLIC_SPFN_APP_URL || env17.SPFN_APP_URL;
15147
15616
  const callbackPath = env17.SPFN_AUTH_OAUTH_SUCCESS_URL || "/auth/callback";
15148
15617
  const callbackUrl = callbackPath.startsWith("http") ? callbackPath : `${appUrl}${callbackPath}`;
15618
+ const returnUrl = isSafeReturnPath(stateData.returnUrl) ? stateData.returnUrl : "/";
15619
+ if (registered.pending) {
15620
+ return {
15621
+ redirectUrl: buildRedirectUrl(callbackUrl, {
15622
+ mfaChallenge: registered.challenge.secret,
15623
+ returnUrl,
15624
+ isNewUser: String(isNewUser)
15625
+ }),
15626
+ userId: String(userId),
15627
+ keyId: stateData.keyId,
15628
+ isNewUser,
15629
+ mfaChallenge: registered.challenge.secret
15630
+ };
15631
+ }
15632
+ await updateLastLoginService(userId);
15149
15633
  const redirectUrl = buildRedirectUrl(callbackUrl, {
15150
15634
  userId: String(userId),
15151
15635
  keyId: stateData.keyId,
@@ -15153,7 +15637,7 @@ async function oauthCallbackService(params) {
15153
15637
  // where it was sealed. Checking it again costs one comparison and keeps a
15154
15638
  // destination that leaves the app out of the callback URL no matter which
15155
15639
  // seam sealed it.
15156
- returnUrl: isSafeReturnPath(stateData.returnUrl) ? stateData.returnUrl : "/",
15640
+ returnUrl,
15157
15641
  isNewUser: String(isNewUser),
15158
15642
  // The callback page and `createOAuthCallbackHandler` are the two seams
15159
15643
  // that seal a session out of this redirect, and neither calls a route
@@ -15395,7 +15879,13 @@ async function persistNativeLogin(identity, params) {
15395
15879
  isNewUser = result.isNewUser;
15396
15880
  }
15397
15881
  await assertActiveForOAuthSession(userId);
15398
- await registerPublicKeyService({
15882
+ const eventPayload = {
15883
+ userId: String(userId),
15884
+ provider: params.provider,
15885
+ email: identity.email || void 0,
15886
+ metadata: params.metadata
15887
+ };
15888
+ const registered = await registerPublicKeyService({
15399
15889
  userId,
15400
15890
  keyId: params.keyId,
15401
15891
  publicKey: params.publicKey,
@@ -15405,18 +15895,16 @@ async function persistNativeLogin(identity, params) {
15405
15895
  platform: params.platform,
15406
15896
  channel: "oauth-native",
15407
15897
  ip: params.ip,
15408
- userAgent: params.userAgent
15898
+ userAgent: params.userAgent,
15899
+ loginEvent: { provider: params.provider, email: identity.email || void 0, metadata: params.metadata }
15409
15900
  });
15901
+ if (registered.pending) {
15902
+ return { mfaRequired: true, challenge: registered.challenge };
15903
+ }
15410
15904
  await updateLastLoginService(userId);
15411
- const eventPayload = {
15412
- userId: String(userId),
15413
- provider: params.provider,
15414
- email: identity.email || void 0,
15415
- metadata: params.metadata
15416
- };
15417
15905
  const mfaEnrolled = isNewUser ? false : await mfaEnrolledForUser(userId);
15418
15906
  onAfterCommit7(() => isNewUser ? authRegisterEvent.emit(eventPayload) : authLoginEvent.emit({ ...eventPayload, mfaEnrolled }));
15419
- return { userId: String(userId), keyId: params.keyId, isNewUser };
15907
+ return { mfaRequired: false, userId: String(userId), keyId: params.keyId, isNewUser };
15420
15908
  }, { context: "auth:oauth-native" });
15421
15909
  }
15422
15910
 
@@ -15465,8 +15953,8 @@ async function verifyOpsTokenService(token) {
15465
15953
  scopes: record.scopes
15466
15954
  };
15467
15955
  }
15468
- async function revokeOpsTokenService(id25) {
15469
- return await opsTokensRepository.revokeById(id25);
15956
+ async function revokeOpsTokenService(id26) {
15957
+ return await opsTokensRepository.revokeById(id26);
15470
15958
  }
15471
15959
  async function listOpsTokensService() {
15472
15960
  return await opsTokensRepository.list();
@@ -16324,13 +16812,16 @@ async function finishSessionRenewService(params) {
16324
16812
  replacesKeyId: key.keyId
16325
16813
  });
16326
16814
  return {
16815
+ // A renewal replaces the key of a device that is already signed in, so
16816
+ // it is a rotation rather than an arrival and never steps up (#95).
16817
+ mfaRequired: false,
16327
16818
  keyId: params.keyId,
16328
16819
  userId: String(user.id),
16329
16820
  publicId: user.publicId,
16330
16821
  email: user.email || void 0,
16331
16822
  phone: user.phone || void 0,
16332
16823
  passwordChangeRequired: user.passwordChangeRequired,
16333
- ...loginBindingFields(registered)
16824
+ ...loginBindingFields(registeredBinding(registered))
16334
16825
  };
16335
16826
  }
16336
16827
  async function admitForRenewal(expiredKeyId) {
@@ -16579,7 +17070,8 @@ var login = route.post("/_auth/login").input({
16579
17070
  })
16580
17071
  }).use([rateLimitPolicy("auth-login", { limit: 10, windowMs: 6e4, by: byIpAndAccount({ ipLimit: 100 }) }), Transactional()]).skip(["auth"]).handler(async (c) => {
16581
17072
  const { body } = await c.data();
16582
- return await loginService({ ...body, ...deviceProvenance(c.raw) });
17073
+ const result = await loginService({ ...body, ...deviceProvenance(c.raw) });
17074
+ return result.mfaRequired ? c.accepted(result) : result;
16583
17075
  });
16584
17076
  var startDeviceAuth = route.post("/_auth/device/start").input({
16585
17077
  // Bounded, unlike the interceptor bodies the authenticated enrolment
@@ -16820,7 +17312,8 @@ var completePasswordReset = route2.post("/_auth/password/reset/complete").input(
16820
17312
  })
16821
17313
  }).use([rateLimitPolicy2("auth-password-reset-complete", { limit: 10, windowMs: 6e4 }), Transactional2()]).skip(["auth"]).handler(async (c) => {
16822
17314
  const { body } = await c.data();
16823
- return await completePasswordResetService({ ...body, ...deviceProvenance(c.raw) });
17315
+ const result = await completePasswordResetService({ ...body, ...deviceProvenance(c.raw) });
17316
+ return result.mfaRequired ? c.accepted(result) : result;
16824
17317
  });
16825
17318
 
16826
17319
  // src/server/routes/auth/revoke-all-link.ts
@@ -16916,6 +17409,36 @@ var mfaStatus = route4.get("/_auth/mfa/status").handler(async (c) => {
16916
17409
  const { userId } = getAuth(c);
16917
17410
  return await mfaStatusService(Number(userId));
16918
17411
  });
17412
+ var CHALLENGE_LIMIT = { limit: 10, windowMs: 6e4 };
17413
+ var ChallengeSchema = Type.String({
17414
+ minLength: 16,
17415
+ maxLength: 256,
17416
+ description: "The challenge secret from the 202 sign-in answer or the OAuth callback query"
17417
+ });
17418
+ var mfaVerify = route4.post("/_auth/mfa/verify").input({
17419
+ body: Type.Object({
17420
+ challenge: ChallengeSchema,
17421
+ code: Type.Optional(TotpCodeSchema),
17422
+ recoveryCode: Type.Optional(RecoveryCodeSchema),
17423
+ response: Type.Optional(AssertionSchema)
17424
+ })
17425
+ }).use([rateLimitPolicy4("auth-mfa-verify", CHALLENGE_LIMIT)]).skip(["auth"]).handler(async (c) => {
17426
+ const { body } = await c.data();
17427
+ return await verifyMfaChallengeService({
17428
+ challenge: body.challenge,
17429
+ code: body.code,
17430
+ recoveryCode: body.recoveryCode,
17431
+ response: body.response
17432
+ });
17433
+ });
17434
+ var mfaVerifyOptions = route4.post("/_auth/mfa/verify/options").input({
17435
+ body: Type.Object({
17436
+ challenge: ChallengeSchema
17437
+ })
17438
+ }).use([rateLimitPolicy4("auth-mfa-verify", CHALLENGE_LIMIT)]).skip(["auth"]).handler(async (c) => {
17439
+ const { body } = await c.data();
17440
+ return await startMfaChallengeAssertionService(body.challenge);
17441
+ });
16919
17442
  var mfaStepUp = route4.post("/_auth/mfa/step-up").input({
16920
17443
  body: Type.Object({
16921
17444
  code: Type.Optional(TotpCodeSchema),
@@ -17035,13 +17558,13 @@ var CanonicalJsonError = class extends Error {
17035
17558
  var INT64_MIN = -(2n ** 63n);
17036
17559
  var INT64_MAX = 2n ** 63n - 1n;
17037
17560
  function parseCanonicalJson(bytes) {
17038
- let text26;
17561
+ let text27;
17039
17562
  try {
17040
- text26 = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
17563
+ text27 = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
17041
17564
  } catch {
17042
17565
  throw new CanonicalJsonError("INVALID_UTF8");
17043
17566
  }
17044
- const parser = new Parser(text26);
17567
+ const parser = new Parser(text27);
17045
17568
  const value = parser.parseValue();
17046
17569
  parser.skipWhitespace();
17047
17570
  if (!parser.atEnd()) {
@@ -17062,8 +17585,8 @@ function isCanonicalBytes(bytes, value) {
17062
17585
  return true;
17063
17586
  }
17064
17587
  var Parser = class {
17065
- constructor(text26) {
17066
- this.text = text26;
17588
+ constructor(text27) {
17589
+ this.text = text27;
17067
17590
  }
17068
17591
  pos = 0;
17069
17592
  atEnd() {
@@ -17653,8 +18176,8 @@ var CORE_PREREQUISITE_OPERATIONS = [
17653
18176
 
17654
18177
  // src/server/client-proof/contract-bundle.ts
17655
18178
  init_wire_headers();
17656
- var CONTRACT_VERSION = "0.12.0";
17657
- var CONTRACT_SUPPORTED_RANGE = ">=0.12.0 <0.13.0";
18179
+ var CONTRACT_VERSION = "0.13.0";
18180
+ var CONTRACT_SUPPORTED_RANGE = ">=0.13.0 <0.14.0";
17658
18181
  function required(name, type) {
17659
18182
  return { name, type, optional: false };
17660
18183
  }
@@ -17768,18 +18291,100 @@ var CONTRACT_TYPES = [
17768
18291
  optional("oldKeyId", "string")
17769
18292
  ]
17770
18293
  },
18294
+ /**
18295
+ * The sign-in union, flattened the way the poll union was.
18296
+ *
18297
+ * `mfaRequired` is the discriminant and the only required field; everything
18298
+ * else belongs to one branch. False is a session and carries the five login
18299
+ * fields; true is a 202 carrying `challenge` and nothing else, which the
18300
+ * client spends at `auth.mfa.verify` (#95). This grammar has no union type,
18301
+ * so a required discriminant plus optional fields is the only way to say it
18302
+ * — and the typed web client infers one result type from the same
18303
+ * declaration, so it could not have been a union there either.
18304
+ */
17771
18305
  {
17772
18306
  name: "LoginResponse",
17773
18307
  fields: [
17774
- required("userId", "string"),
17775
- required("publicId", "string"),
18308
+ required("mfaRequired", "boolean"),
18309
+ optional("challenge", "MfaChallenge"),
18310
+ optional("userId", "string"),
18311
+ optional("publicId", "string"),
18312
+ optional("email", "string"),
18313
+ optional("phone", "string"),
18314
+ optional("passwordChangeRequired", "boolean"),
18315
+ optional("sessionBinding", "KeyBinding"),
18316
+ optional("keyExpiresAtMillis", "integer")
18317
+ ]
18318
+ },
18319
+ /**
18320
+ * What a sign-in hands back instead of a session when the account has a
18321
+ * second factor and this device is new to it.
18322
+ *
18323
+ * `secret` is the challenge itself, returned once. The server stores only its
18324
+ * hash and addresses the row by that, so this value is not recoverable from
18325
+ * the database and is not a bearer credential for anything but the one
18326
+ * `auth.mfa.verify` call it belongs to.
18327
+ */
18328
+ {
18329
+ name: "MfaChallenge",
18330
+ fields: [
18331
+ required("secret", "string"),
18332
+ required("expiresAtMillis", "integer")
18333
+ ]
18334
+ },
18335
+ /**
18336
+ * Exactly one of `code` and `recoveryCode`, beside the challenge.
18337
+ *
18338
+ * The third form the server accepts — an assertion from a passkey the owner
18339
+ * marked as a second factor — is not declared: a WebAuthn assertion is a
18340
+ * nested browser object outside this grammar, and the passkey ceremonies are
18341
+ * not on this surface for the same reason.
18342
+ */
18343
+ {
18344
+ name: "MfaVerifyRequest",
18345
+ fields: [
18346
+ required("challenge", "string"),
18347
+ optional("code", "string"),
18348
+ optional("recoveryCode", "string")
18349
+ ]
18350
+ },
18351
+ /**
18352
+ * The sign-in the challenge was standing in for, plus what the proxy needs.
18353
+ *
18354
+ * `mfaRequired` is false on every 200 here — the step-up just happened — and
18355
+ * it is carried so the body is the same `LoginResponse` shape a direct
18356
+ * sign-in answers. `keyId` and `challengeHash` are for the Next.js proxy: it
18357
+ * seals a session only when both match the pending cookie it baked at the
18358
+ * 202, which is what stops a cookie from one flow sealing a session for
18359
+ * another's key.
18360
+ */
18361
+ {
18362
+ name: "MfaVerifyResponse",
18363
+ fields: [
18364
+ required("mfaRequired", "boolean"),
18365
+ required("keyId", "string"),
18366
+ required("challengeHash", "string"),
18367
+ optional("userId", "string"),
18368
+ optional("publicId", "string"),
17776
18369
  optional("email", "string"),
17777
18370
  optional("phone", "string"),
17778
- required("passwordChangeRequired", "boolean"),
18371
+ optional("passwordChangeRequired", "boolean"),
17779
18372
  optional("sessionBinding", "KeyBinding"),
17780
18373
  optional("keyExpiresAtMillis", "integer")
17781
18374
  ]
17782
18375
  },
18376
+ /**
18377
+ * What the account has enrolled. No secret, no otpauth URI, no recovery code
18378
+ * — only the counts and names an account screen renders.
18379
+ */
18380
+ {
18381
+ name: "MfaStatusResponse",
18382
+ fields: [
18383
+ required("enrolled", "boolean"),
18384
+ required("methods", "array<MfaMethod>"),
18385
+ required("recoveryCodesRemaining", "integer")
18386
+ ]
18387
+ },
17783
18388
  {
17784
18389
  name: "OauthNativeRequest",
17785
18390
  fields: [
@@ -17792,12 +18397,20 @@ var CONTRACT_TYPES = [
17792
18397
  required("algorithm", "KeyAlgorithm")
17793
18398
  ]
17794
18399
  },
18400
+ /**
18401
+ * Flattened on the same terms as `LoginResponse`: a native social sign-in on
18402
+ * an enrolled account and a device it has not seen answers 202 with a
18403
+ * challenge rather than a key, so the discriminant is required and the three
18404
+ * login fields are the false branch.
18405
+ */
17795
18406
  {
17796
18407
  name: "OauthNativeResponse",
17797
18408
  fields: [
17798
- required("userId", "string"),
17799
- required("keyId", "string"),
17800
- required("isNewUser", "boolean")
18409
+ required("mfaRequired", "boolean"),
18410
+ optional("challenge", "MfaChallenge"),
18411
+ optional("userId", "string"),
18412
+ optional("keyId", "string"),
18413
+ optional("isNewUser", "boolean")
17801
18414
  ]
17802
18415
  },
17803
18416
  {
@@ -17915,6 +18528,7 @@ var CONTRACT_TYPES = [
17915
18528
  fields: [
17916
18529
  required("status", "DeviceAuthPollStatus"),
17917
18530
  optional("intervalMillis", "integer"),
18531
+ optional("mfaRequired", "boolean"),
17918
18532
  optional("userId", "string"),
17919
18533
  optional("publicId", "string"),
17920
18534
  optional("email", "string"),
@@ -17963,7 +18577,12 @@ var CONTRACT_ENUMS = [
17963
18577
  { name: "KeyAlgorithm", values: [...KEY_ALGORITHM] },
17964
18578
  { name: "KeyPlatform", values: [...KEY_PLATFORM] },
17965
18579
  { name: "KeyBinding", values: [...SESSION_BINDINGS] },
17966
- { name: "DeviceAuthPollStatus", values: ["pending", "approved"] }
18580
+ { name: "DeviceAuthPollStatus", values: ["pending", "approved"] },
18581
+ // The two things an account screen can show as enrolled. `recovery` is a
18582
+ // verification method but never a method the account *has* — a recovery code
18583
+ // is what is left when the authenticator is not to hand, so `mfa/status`
18584
+ // reports it as a count and not as a factor.
18585
+ { name: "MfaMethod", values: ["totp", "passkey"] }
17967
18586
  ];
17968
18587
  var BUNDLE_FILENAME = "spfn-mobile-contract.json";
17969
18588
  var BUNDLE_REPO_PATH = `contracts/mobile/${BUNDLE_FILENAME}`;
@@ -19043,8 +19662,8 @@ function bearerOf(header) {
19043
19662
  function chain(handlers) {
19044
19663
  return async (c, next) => {
19045
19664
  let answer;
19046
- const runFrom = async (index20) => {
19047
- const produced = index20 < handlers.length ? await handlers[index20](c, () => runFrom(index20 + 1)) : await next();
19665
+ const runFrom = async (index21) => {
19666
+ const produced = index21 < handlers.length ? await handlers[index21](c, () => runFrom(index21 + 1)) : await next();
19048
19667
  if (produced instanceof Response) {
19049
19668
  answer = produced;
19050
19669
  }
@@ -19653,8 +20272,13 @@ var getGoogleOAuthUrl = route10.post("/_auth/oauth/google/url").input({
19653
20272
  });
19654
20273
  var oauthFinalize = route10.post("/_auth/oauth/finalize").input({
19655
20274
  body: Type.Object({
19656
- userId: Type.String({ description: "User ID from OAuth callback" }),
19657
- keyId: Type.String({ description: "Key ID from OAuth state" }),
20275
+ userId: Type.Optional(Type.String({ description: "User ID from OAuth callback" })),
20276
+ keyId: Type.Optional(Type.String({ description: "Key ID from OAuth state" })),
20277
+ mfaChallenge: Type.Optional(Type.String({
20278
+ minLength: 16,
20279
+ maxLength: 256,
20280
+ description: "Second-factor challenge from the callback query, when the callback carried one"
20281
+ })),
19658
20282
  returnUrl: Type.Optional(Type.String({ description: "URL to redirect after login" }))
19659
20283
  })
19660
20284
  }).use([rateLimitPolicy10("oauth-start", { limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
@@ -19662,8 +20286,20 @@ var oauthFinalize = route10.post("/_auth/oauth/finalize").input({
19662
20286
  if (body.returnUrl && !isSafeReturnPath(body.returnUrl)) {
19663
20287
  throw new ValidationError17({ message: "returnUrl must be a relative path within the app" });
19664
20288
  }
20289
+ if (body.mfaChallenge) {
20290
+ return c.accepted({
20291
+ success: true,
20292
+ mfaRequired: true,
20293
+ challenge: body.mfaChallenge,
20294
+ returnUrl: body.returnUrl || "/"
20295
+ });
20296
+ }
20297
+ if (!body.userId || !body.keyId) {
20298
+ throw new ValidationError17({ message: "userId and keyId are required unless mfaChallenge is sent" });
20299
+ }
19665
20300
  return {
19666
20301
  success: true,
20302
+ mfaRequired: false,
19667
20303
  userId: body.userId,
19668
20304
  keyId: body.keyId,
19669
20305
  returnUrl: body.returnUrl || "/",
@@ -19784,7 +20420,8 @@ var oauthNative = route10.post("/_auth/oauth/:provider/native").input({
19784
20420
  by: byIpAndIdToken({ ipLimit: 20 })
19785
20421
  })]).skip(["auth"]).handler(async (c) => {
19786
20422
  const { params, body } = await c.data();
19787
- return await oauthNativeService({ provider: params.provider, ...body, ...deviceProvenance(c.raw) });
20423
+ const result = await oauthNativeService({ provider: params.provider, ...body, ...deviceProvenance(c.raw) });
20424
+ return result.mfaRequired ? c.accepted(result) : result;
19788
20425
  });
19789
20426
  async function collectUnlinkNotifyFields(raw) {
19790
20427
  const fields = { ...raw.req.query() };
@@ -20258,6 +20895,8 @@ var mainAuthRouter = defineRouter6({
20258
20895
  mfaStatus,
20259
20896
  mfaStepUp,
20260
20897
  mfaStepUpOptions,
20898
+ mfaVerify,
20899
+ mfaVerifyOptions,
20261
20900
  logout,
20262
20901
  rotateKey,
20263
20902
  listKeys,
@@ -20804,8 +21443,9 @@ var DEFAULT_MFA_SWEEP_CRON = "0 7 * * *";
20804
21443
  function createMfaSweepJob(cronExpression = DEFAULT_MFA_SWEEP_CRON) {
20805
21444
  return job5("auth.mfa.sweep").cron(cronExpression).options({ retryLimit: 1 }).handler(async () => {
20806
21445
  const { deleted } = await sweepUnconfirmedMfaService();
20807
- if (deleted > 0) {
20808
- authLogger.service.info("[auth.mfa.sweep] sweep complete", { deleted });
21446
+ const { deleted: challenges } = await sweepMfaChallengesService();
21447
+ if (deleted > 0 || challenges > 0) {
21448
+ authLogger.service.info("[auth.mfa.sweep] sweep complete", { deleted, challenges });
20809
21449
  }
20810
21450
  });
20811
21451
  }
@@ -20859,8 +21499,11 @@ export {
20859
21499
  KeyRevokeAllTokensRepository,
20860
21500
  KeysRepository,
20861
21501
  MAX_UNGRANTED_CLIENTS_PER_IP,
21502
+ MFA_CHALLENGE_ATTEMPT_LIMIT,
21503
+ MFA_CHALLENGE_CHANNELS,
20862
21504
  MFA_CONFIRM_ATTEMPT_LIMIT,
20863
21505
  MFA_VERIFICATION_METHODS,
21506
+ MfaChallengesRepository,
20864
21507
  MfaEnrolmentRepository,
20865
21508
  MfaRecoveryCodesRepository,
20866
21509
  MfaTotpRepository,
@@ -20925,6 +21568,7 @@ export {
20925
21568
  assertPasskeyConfig,
20926
21569
  assertRecentAuthentication,
20927
21570
  assertStepUp,
21571
+ attemptSecondFactor,
20928
21572
  authDeletionCancelledEvent,
20929
21573
  authDeletionCompletedEvent,
20930
21574
  authDeletionRequestedEvent,
@@ -21092,6 +21736,8 @@ export {
21092
21736
  matchOAuthCsrfCookies,
21093
21737
  matchesCsrfToken,
21094
21738
  matchesRegisteredRedirectUri,
21739
+ mfaChallenges,
21740
+ mfaChallengesRepository,
21095
21741
  mfaEnrolledForUser,
21096
21742
  mfaEnrolmentRepository,
21097
21743
  mfaRecoveryCodes,
@@ -21122,6 +21768,7 @@ export {
21122
21768
  oauthUnlinkNotifyService,
21123
21769
  oauthUnlinkedEvent,
21124
21770
  oneTimeTokenAuth,
21771
+ openStepUpChallengeService,
21125
21772
  opsOrUser,
21126
21773
  opsTokenAuth,
21127
21774
  opsTokens,
@@ -21149,6 +21796,7 @@ export {
21149
21796
  registerOAuthProvider,
21150
21797
  registerPublicKeyService,
21151
21798
  registerService,
21799
+ registeredBinding,
21152
21800
  removePermissionFromRole,
21153
21801
  renamePasskeyService,
21154
21802
  requestAccountDeletionService,
@@ -21163,6 +21811,7 @@ export {
21163
21811
  resendInvitation,
21164
21812
  resolveAuthenticatedUser,
21165
21813
  resolveIssuerSource,
21814
+ resumeStepUpChallengeService,
21166
21815
  revokeAllKeysService,
21167
21816
  revokeAllOAuth2GrantsForUser,
21168
21817
  revokeKeyService,
@@ -21191,6 +21840,7 @@ export {
21191
21840
  signupLinkTokensRepository,
21192
21841
  socialAccountsRepository,
21193
21842
  startDeviceAuthService,
21843
+ startMfaChallengeAssertionService,
21194
21844
  startPasskeyEnrollmentService,
21195
21845
  startPasskeyLoginService,
21196
21846
  startSessionBindingDisableService,
@@ -21199,6 +21849,7 @@ export {
21199
21849
  startTotpEnrolmentService,
21200
21850
  stepUpService,
21201
21851
  sweepDuePurges,
21852
+ sweepMfaChallengesService,
21202
21853
  sweepUnconfirmedMfaService,
21203
21854
  toEpochSeconds,
21204
21855
  unsealSession,
@@ -21226,6 +21877,7 @@ export {
21226
21877
  verifyCodeService,
21227
21878
  verifyIdToken,
21228
21879
  verifyKeyFingerprint,
21880
+ verifyMfaChallengeService,
21229
21881
  verifyOAuthState,
21230
21882
  verifyOneTimeTokenService,
21231
21883
  verifyOpsTokenService,