@spfn/auth 0.3.0-beta.4 → 0.3.0-beta.6

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(index12) {
901
- return CreateType({ [Kind]: "Argument", index: index12 });
900
+ function Argument(index13) {
901
+ return CreateType({ [Kind]: "Argument", index: index13 });
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, index12, char) {
1140
- return pattern[index12] === char && pattern.charCodeAt(index12 - 1) !== 92;
1139
+ function IsNonEscaped(pattern, index13, char) {
1140
+ return pattern[index13] === char && pattern.charCodeAt(index13 - 1) !== 92;
1141
1141
  }
1142
- function IsOpenParen(pattern, index12) {
1143
- return IsNonEscaped(pattern, index12, "(");
1142
+ function IsOpenParen(pattern, index13) {
1143
+ return IsNonEscaped(pattern, index13, "(");
1144
1144
  }
1145
- function IsCloseParen(pattern, index12) {
1146
- return IsNonEscaped(pattern, index12, ")");
1145
+ function IsCloseParen(pattern, index13) {
1146
+ return IsNonEscaped(pattern, index13, ")");
1147
1147
  }
1148
- function IsSeparator(pattern, index12) {
1149
- return IsNonEscaped(pattern, index12, "|");
1148
+ function IsSeparator(pattern, index13) {
1149
+ return IsNonEscaped(pattern, index13, "|");
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 count = 0;
1155
- for (let index12 = 0; index12 < pattern.length; index12++) {
1156
- if (IsOpenParen(pattern, index12))
1155
+ for (let index13 = 0; index13 < pattern.length; index13++) {
1156
+ if (IsOpenParen(pattern, index13))
1157
1157
  count += 1;
1158
- if (IsCloseParen(pattern, index12))
1158
+ if (IsCloseParen(pattern, index13))
1159
1159
  count -= 1;
1160
- if (count === 0 && index12 !== pattern.length - 1)
1160
+ if (count === 0 && index13 !== 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 count = 0;
1170
- for (let index12 = 0; index12 < pattern.length; index12++) {
1171
- if (IsOpenParen(pattern, index12))
1170
+ for (let index13 = 0; index13 < pattern.length; index13++) {
1171
+ if (IsOpenParen(pattern, index13))
1172
1172
  count += 1;
1173
- if (IsCloseParen(pattern, index12))
1173
+ if (IsCloseParen(pattern, index13))
1174
1174
  count -= 1;
1175
- if (IsSeparator(pattern, index12) && count === 0)
1175
+ if (IsSeparator(pattern, index13) && count === 0)
1176
1176
  return true;
1177
1177
  }
1178
1178
  return false;
1179
1179
  }
1180
1180
  function IsPrecedenceAnd(pattern) {
1181
- for (let index12 = 0; index12 < pattern.length; index12++) {
1182
- if (IsOpenParen(pattern, index12))
1181
+ for (let index13 = 0; index13 < pattern.length; index13++) {
1182
+ if (IsOpenParen(pattern, index13))
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 [count, start] = [0, 0];
1189
1189
  const expressions = [];
1190
- for (let index12 = 0; index12 < pattern.length; index12++) {
1191
- if (IsOpenParen(pattern, index12))
1190
+ for (let index13 = 0; index13 < pattern.length; index13++) {
1191
+ if (IsOpenParen(pattern, index13))
1192
1192
  count += 1;
1193
- if (IsCloseParen(pattern, index12))
1193
+ if (IsCloseParen(pattern, index13))
1194
1194
  count -= 1;
1195
- if (IsSeparator(pattern, index12) && count === 0) {
1196
- const range2 = pattern.slice(start, index12);
1195
+ if (IsSeparator(pattern, index13) && count === 0) {
1196
+ const range2 = pattern.slice(start, index13);
1197
1197
  if (range2.length > 0)
1198
1198
  expressions.push(TemplateLiteralParse(range2));
1199
- start = index12 + 1;
1199
+ start = index13 + 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, index12) {
1213
- if (!IsOpenParen(value, index12))
1212
+ function Group(value, index13) {
1213
+ if (!IsOpenParen(value, index13))
1214
1214
  throw new TemplateLiteralParserError(`TemplateLiteralParser: Index must point to open parens`);
1215
1215
  let count = 0;
1216
- for (let scan = index12; scan < value.length; scan++) {
1216
+ for (let scan = index13; scan < value.length; scan++) {
1217
1217
  if (IsOpenParen(value, scan))
1218
1218
  count += 1;
1219
1219
  if (IsCloseParen(value, scan))
1220
1220
  count -= 1;
1221
1221
  if (count === 0)
1222
- return [index12, scan];
1222
+ return [index13, scan];
1223
1223
  }
1224
1224
  throw new TemplateLiteralParserError(`TemplateLiteralParser: Unclosed group parens in expression`);
1225
1225
  }
1226
- function Range(pattern2, index12) {
1227
- for (let scan = index12; scan < pattern2.length; scan++) {
1226
+ function Range(pattern2, index13) {
1227
+ for (let scan = index13; scan < pattern2.length; scan++) {
1228
1228
  if (IsOpenParen(pattern2, scan))
1229
- return [index12, scan];
1229
+ return [index13, scan];
1230
1230
  }
1231
- return [index12, pattern2.length];
1231
+ return [index13, pattern2.length];
1232
1232
  }
1233
1233
  const expressions = [];
1234
- for (let index12 = 0; index12 < pattern.length; index12++) {
1235
- if (IsOpenParen(pattern, index12)) {
1236
- const [start, end] = Group(pattern, index12);
1234
+ for (let index13 = 0; index13 < pattern.length; index13++) {
1235
+ if (IsOpenParen(pattern, index13)) {
1236
+ const [start, end] = Group(pattern, index13);
1237
1237
  const range = pattern.slice(start, end + 1);
1238
1238
  expressions.push(TemplateLiteralParse(range));
1239
- index12 = end;
1239
+ index13 = end;
1240
1240
  } else {
1241
- const [start, end] = Range(pattern, index12);
1241
+ const [start, end] = Range(pattern, index13);
1242
1242
  const range = pattern.slice(start, end);
1243
1243
  if (range.length > 0)
1244
1244
  expressions.push(TemplateLiteralParse(range));
1245
- index12 = end - 1;
1245
+ index13 = 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, index12) => IntoBooleanResult(Visit3(right.parameters[index12], 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, index13) => IntoBooleanResult(Visit3(right.parameters[index13], 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, index12) => IntoBooleanResult(Visit3(right.parameters[index12], 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, index13) => IntoBooleanResult(Visit3(right.parameters[index13], 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, index12) => Visit3(schema, right.items[index12]) === 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, index13) => Visit3(schema, right.items[index13]) === 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;
@@ -4521,6 +4521,19 @@ var init_schema3 = __esm({
4521
4521
  }
4522
4522
  });
4523
4523
 
4524
+ // src/server/helpers/email.ts
4525
+ function normalizeEmail(email) {
4526
+ return email.trim().toLowerCase();
4527
+ }
4528
+ function normalizeOptionalEmail(email) {
4529
+ return typeof email === "string" ? normalizeEmail(email) : email;
4530
+ }
4531
+ var init_email = __esm({
4532
+ "src/server/helpers/email.ts"() {
4533
+ "use strict";
4534
+ }
4535
+ });
4536
+
4524
4537
  // src/server/entities/schema.ts
4525
4538
  import { createSchema } from "@spfn/core/db";
4526
4539
  var authSchema;
@@ -4965,9 +4978,59 @@ var init_verification_codes = __esm({
4965
4978
  }
4966
4979
  });
4967
4980
 
4981
+ // src/server/entities/signup-link-tokens.ts
4982
+ import { text as text7, index as index7, uniqueIndex as uniqueIndex2 } from "drizzle-orm/pg-core";
4983
+ import { id as id7, timestamps as timestamps6, utcTimestamp as utcTimestamp5 } from "@spfn/core/db";
4984
+ var signupLinkTokens;
4985
+ var init_signup_link_tokens = __esm({
4986
+ "src/server/entities/signup-link-tokens.ts"() {
4987
+ "use strict";
4988
+ init_schema4();
4989
+ signupLinkTokens = authSchema.table(
4990
+ "signup_link_tokens",
4991
+ {
4992
+ id: id7(),
4993
+ // Normalized email address the link was issued for
4994
+ email: text7("email").notNull(),
4995
+ // SHA-256 of the emailed token, base64url
4996
+ // The token itself (32 random bytes) is never stored
4997
+ tokenHash: text7("token_hash").notNull(),
4998
+ // Relative path to return the user to after signup completes
4999
+ // Validated as a relative path on the way in; never an absolute URL
5000
+ returnPath: text7("return_path"),
5001
+ // Link expiry — SPFN_AUTH_SIGNUP_LINK_TTL_MINUTES from creation
5002
+ expiresAt: utcTimestamp5("expires_at").notNull(),
5003
+ // Set when the link is exchanged for a setup session
5004
+ // Non-null means the link is spent; it is one-time regardless of expiry
5005
+ consumedAt: utcTimestamp5("consumed_at"),
5006
+ // Set when a newer link for the same email replaced this one
5007
+ // A superseded row is refused even if it has not expired or been consumed
5008
+ supersededAt: utcTimestamp5("superseded_at"),
5009
+ // SHA-256 of the setup session secret, written at consume time
5010
+ // The secret lives only in the caller's HttpOnly cookie
5011
+ setupSecretHash: text7("setup_secret_hash"),
5012
+ // Setup session expiry — SPFN_AUTH_SIGNUP_SETUP_TTL_MINUTES from consume
5013
+ setupExpiresAt: utcTimestamp5("setup_expires_at"),
5014
+ // Terminal: the password was set and the account created
5015
+ completedAt: utcTimestamp5("completed_at"),
5016
+ ...timestamps6()
5017
+ },
5018
+ (table) => [
5019
+ // Lookup path for confirming a link
5020
+ uniqueIndex2("signup_link_token_hash_idx").on(table.tokenHash),
5021
+ // Lookup path for setting a password
5022
+ // Unique so a setup secret can never address two rows
5023
+ uniqueIndex2("signup_link_setup_secret_hash_idx").on(table.setupSecretHash),
5024
+ // Supersede-on-resend scans the live rows for one address
5025
+ index7("signup_link_email_idx").on(table.email, table.expiresAt)
5026
+ ]
5027
+ );
5028
+ }
5029
+ });
5030
+
4968
5031
  // src/server/entities/user-invitations.ts
4969
- import { text as text7, index as index7 } from "drizzle-orm/pg-core";
4970
- import { id as id7, timestamps as timestamps6, enumText as enumText5, utcTimestamp as utcTimestamp5, typedJsonb as typedJsonb2, foreignKey as foreignKey5 } from "@spfn/core/db";
5032
+ import { text as text8, index as index8 } from "drizzle-orm/pg-core";
5033
+ import { id as id8, timestamps as timestamps7, enumText as enumText5, utcTimestamp as utcTimestamp6, typedJsonb as typedJsonb2, foreignKey as foreignKey5 } from "@spfn/core/db";
4971
5034
  var userInvitations;
4972
5035
  var init_user_invitations = __esm({
4973
5036
  "src/server/entities/user-invitations.ts"() {
@@ -4980,14 +5043,14 @@ var init_user_invitations = __esm({
4980
5043
  "user_invitations",
4981
5044
  {
4982
5045
  // Primary key
4983
- id: id7(),
5046
+ id: id8(),
4984
5047
  // Target email address for the invitation
4985
5048
  // Will become the user's email upon acceptance
4986
- email: text7("email").notNull(),
5049
+ email: text8("email").notNull(),
4987
5050
  // Unique invitation token (UUID v4)
4988
5051
  // Used in invitation URL: /auth/invite/{token}
4989
5052
  // Single-use token that expires after acceptance
4990
- token: text7("token").notNull().unique(),
5053
+ token: text8("token").notNull().unique(),
4991
5054
  // Role to be assigned when invitation is accepted
4992
5055
  // Foreign key to roles table
4993
5056
  roleId: foreignKey5("role", () => roles.id),
@@ -5004,15 +5067,15 @@ var init_user_invitations = __esm({
5004
5067
  // Expiration timestamp (default: 7 days from creation)
5005
5068
  // Invitation cannot be accepted after this time
5006
5069
  // Background job should update status to 'expired'
5007
- expiresAt: utcTimestamp5("expires_at").notNull(),
5070
+ expiresAt: utcTimestamp6("expires_at").notNull(),
5008
5071
  // Timestamp when invitation was accepted
5009
5072
  // null = not yet accepted
5010
5073
  // Used for: audit trail, analytics
5011
- acceptedAt: utcTimestamp5("accepted_at"),
5074
+ acceptedAt: utcTimestamp6("accepted_at"),
5012
5075
  // Timestamp when invitation was cancelled
5013
5076
  // null = not cancelled
5014
5077
  // Used for: audit trail
5015
- cancelledAt: utcTimestamp5("cancelled_at"),
5078
+ cancelledAt: utcTimestamp6("cancelled_at"),
5016
5079
  // Additional metadata (JSONB)
5017
5080
  // Use cases:
5018
5081
  // - Custom welcome message
@@ -5021,26 +5084,26 @@ var init_user_invitations = __esm({
5021
5084
  // - Custom fields for app-specific data
5022
5085
  // Example: { message: "Welcome!", department: "Engineering" }
5023
5086
  metadata: typedJsonb2("metadata"),
5024
- ...timestamps6()
5087
+ ...timestamps7()
5025
5088
  },
5026
5089
  (table) => [
5027
5090
  // Indexes for query optimization
5028
- index7("invitations_token_idx").on(table.token),
5029
- index7("invitations_email_idx").on(table.email),
5030
- index7("invitations_status_idx").on(table.status),
5031
- index7("invitations_invited_by_idx").on(table.invitedBy),
5032
- index7("invitations_expires_at_idx").on(table.expiresAt),
5091
+ index8("invitations_token_idx").on(table.token),
5092
+ index8("invitations_email_idx").on(table.email),
5093
+ index8("invitations_status_idx").on(table.status),
5094
+ index8("invitations_invited_by_idx").on(table.invitedBy),
5095
+ index8("invitations_expires_at_idx").on(table.expiresAt),
5033
5096
  // For cleanup jobs
5034
- index7("invitations_role_id_idx").on(table.roleId)
5097
+ index8("invitations_role_id_idx").on(table.roleId)
5035
5098
  ]
5036
5099
  );
5037
5100
  }
5038
5101
  });
5039
5102
 
5040
5103
  // src/server/entities/account-deletion-requests.ts
5041
- import { text as text8, index as index8, uniqueIndex as uniqueIndex2 } from "drizzle-orm/pg-core";
5104
+ import { text as text9, index as index9, uniqueIndex as uniqueIndex3 } from "drizzle-orm/pg-core";
5042
5105
  import { sql as sql2 } from "drizzle-orm";
5043
- import { id as id8, timestamps as timestamps7, enumText as enumText6, utcTimestamp as utcTimestamp6, optionalForeignKey } from "@spfn/core/db";
5106
+ import { id as id9, timestamps as timestamps8, enumText as enumText6, utcTimestamp as utcTimestamp7, optionalForeignKey } from "@spfn/core/db";
5044
5107
  var accountDeletionRequests;
5045
5108
  var init_account_deletion_requests = __esm({
5046
5109
  "src/server/entities/account-deletion-requests.ts"() {
@@ -5051,18 +5114,18 @@ var init_account_deletion_requests = __esm({
5051
5114
  accountDeletionRequests = authSchema.table(
5052
5115
  "account_deletion_requests",
5053
5116
  {
5054
- id: id8(),
5117
+ id: id9(),
5055
5118
  // Foreign key to users table. `set null` (optionalForeignKey default) so this
5056
5119
  // row survives a hard-delete purge of the user it refers to.
5057
5120
  userId: optionalForeignKey("user", () => users.id),
5058
5121
  // Snapshot of the user's public UUID at request time — stays readable even
5059
5122
  // after userId is nulled out or the account is anonymized.
5060
- userPublicId: text8("user_public_id").notNull(),
5123
+ userPublicId: text9("user_public_id").notNull(),
5061
5124
  // When the deletion was requested
5062
- requestedAt: utcTimestamp6("requested_at").notNull().defaultNow(),
5125
+ requestedAt: utcTimestamp7("requested_at").notNull().defaultNow(),
5063
5126
  // When the purge job is allowed to run (requestedAt + grace period; equals
5064
5127
  // requestedAt itself for immediate/zero-grace deletions)
5065
- purgeScheduledAt: utcTimestamp6("purge_scheduled_at").notNull(),
5128
+ purgeScheduledAt: utcTimestamp7("purge_scheduled_at").notNull(),
5066
5129
  // Request lifecycle status
5067
5130
  // - pending: awaiting purgeScheduledAt (or immediate purge)
5068
5131
  // - cancelled: recovered before purge
@@ -5071,20 +5134,20 @@ var init_account_deletion_requests = __esm({
5071
5134
  // Who initiated the request
5072
5135
  requestedBy: enumText6("requested_by", ACCOUNT_DELETION_REQUESTED_BY).default("self").notNull(),
5073
5136
  // Optional free-text reason (self-service UI, admin note, DSR reference, ...)
5074
- reason: text8("reason"),
5075
- cancelledAt: utcTimestamp6("cancelled_at"),
5076
- completedAt: utcTimestamp6("completed_at"),
5137
+ reason: text9("reason"),
5138
+ cancelledAt: utcTimestamp7("cancelled_at"),
5139
+ completedAt: utcTimestamp7("completed_at"),
5077
5140
  // Purge strategy actually executed (set on completion; null while pending)
5078
5141
  purgeStrategy: enumText6("purge_strategy", PURGE_STRATEGIES),
5079
- ...timestamps7()
5142
+ ...timestamps8()
5080
5143
  },
5081
5144
  (table) => [
5082
- index8("account_deletion_requests_user_id_idx").on(table.userId),
5083
- index8("account_deletion_requests_status_idx").on(table.status),
5084
- index8("account_deletion_requests_purge_scheduled_at_idx").on(table.purgeScheduledAt),
5085
- index8("account_deletion_requests_user_public_id_idx").on(table.userPublicId),
5145
+ index9("account_deletion_requests_user_id_idx").on(table.userId),
5146
+ index9("account_deletion_requests_status_idx").on(table.status),
5147
+ index9("account_deletion_requests_purge_scheduled_at_idx").on(table.purgeScheduledAt),
5148
+ index9("account_deletion_requests_user_public_id_idx").on(table.userPublicId),
5086
5149
  // Partial unique index: at most one pending request per user at a time.
5087
- uniqueIndex2("account_deletion_requests_user_pending_unique_idx").on(table.userId).where(sql2`${table.status} = 'pending'`)
5150
+ uniqueIndex3("account_deletion_requests_user_pending_unique_idx").on(table.userId).where(sql2`${table.status} = 'pending'`)
5088
5151
  ]
5089
5152
  );
5090
5153
  }
@@ -5236,8 +5299,8 @@ var init_rbac = __esm({
5236
5299
  });
5237
5300
 
5238
5301
  // src/server/entities/permissions.ts
5239
- import { text as text9, boolean as boolean4, index as index9 } from "drizzle-orm/pg-core";
5240
- import { id as id9, timestamps as timestamps8, enumText as enumText7, typedJsonb as typedJsonb3 } from "@spfn/core/db";
5302
+ import { text as text10, boolean as boolean4, index as index10 } from "drizzle-orm/pg-core";
5303
+ import { id as id10, timestamps as timestamps9, enumText as enumText7, typedJsonb as typedJsonb3 } from "@spfn/core/db";
5241
5304
  var permissions;
5242
5305
  var init_permissions = __esm({
5243
5306
  "src/server/entities/permissions.ts"() {
@@ -5248,7 +5311,7 @@ var init_permissions = __esm({
5248
5311
  "permissions",
5249
5312
  {
5250
5313
  // Primary key
5251
- id: id9(),
5314
+ id: id10(),
5252
5315
  // Permission identifier
5253
5316
  // Format: resource:action or namespace:resource:action
5254
5317
  // Examples:
@@ -5256,15 +5319,15 @@ var init_permissions = __esm({
5256
5319
  // - Namespaced: 'auth:user:delete', 'cms:post:publish'
5257
5320
  // Must be unique across all permissions
5258
5321
  // Used in: permission checks, role assignments, API guards
5259
- name: text9("name").notNull().unique(),
5322
+ name: text10("name").notNull().unique(),
5260
5323
  // Display name for UI
5261
5324
  // Human-readable name shown in admin panels
5262
5325
  // Example: "Delete Users", "Publish Posts"
5263
- displayName: text9("display_name").notNull(),
5326
+ displayName: text10("display_name").notNull(),
5264
5327
  // Permission description
5265
5328
  // Detailed explanation of what this permission allows
5266
5329
  // Example: "Allows deletion of user accounts from the system"
5267
- description: text9("description"),
5330
+ description: text10("description"),
5268
5331
  // Category for grouping
5269
5332
  // Used for: organizing permissions in UI, filtering
5270
5333
  // Built-in categories: auth, user, rbac, system
@@ -5300,22 +5363,22 @@ var init_permissions = __esm({
5300
5363
  // - Audit: { createdBy: 123, source: 'migration', version: '1.0.0' }
5301
5364
  // Example: { icon: 'trash', color: 'red', requiresMfa: true }
5302
5365
  metadata: typedJsonb3("metadata"),
5303
- ...timestamps8()
5366
+ ...timestamps9()
5304
5367
  },
5305
5368
  (table) => [
5306
- index9("permissions_name_idx").on(table.name),
5307
- index9("permissions_category_idx").on(table.category),
5308
- index9("permissions_is_system_idx").on(table.isSystem),
5309
- index9("permissions_is_active_idx").on(table.isActive),
5310
- index9("permissions_is_builtin_idx").on(table.isBuiltin)
5369
+ index10("permissions_name_idx").on(table.name),
5370
+ index10("permissions_category_idx").on(table.category),
5371
+ index10("permissions_is_system_idx").on(table.isSystem),
5372
+ index10("permissions_is_active_idx").on(table.isActive),
5373
+ index10("permissions_is_builtin_idx").on(table.isBuiltin)
5311
5374
  ]
5312
5375
  );
5313
5376
  }
5314
5377
  });
5315
5378
 
5316
5379
  // src/server/entities/role-permissions.ts
5317
- import { index as index10, unique } from "drizzle-orm/pg-core";
5318
- import { id as id10, timestamps as timestamps9, foreignKey as foreignKey6 } from "@spfn/core/db";
5380
+ import { index as index11, unique } from "drizzle-orm/pg-core";
5381
+ import { id as id11, timestamps as timestamps10, foreignKey as foreignKey6 } from "@spfn/core/db";
5319
5382
  var rolePermissions;
5320
5383
  var init_role_permissions = __esm({
5321
5384
  "src/server/entities/role-permissions.ts"() {
@@ -5327,7 +5390,7 @@ var init_role_permissions = __esm({
5327
5390
  "role_permissions",
5328
5391
  {
5329
5392
  // Primary key
5330
- id: id10(),
5393
+ id: id11(),
5331
5394
  // Role reference
5332
5395
  // Foreign key to roles table
5333
5396
  // Cascade delete: when role is deleted, all role-permission mappings are removed
@@ -5340,12 +5403,12 @@ var init_role_permissions = __esm({
5340
5403
  // Used for: granting permissions to roles
5341
5404
  // Example: user:delete permission → [Admin, Superadmin]
5342
5405
  permissionId: foreignKey6("permission", () => permissions.id, { onDelete: "cascade" }),
5343
- ...timestamps9()
5406
+ ...timestamps10()
5344
5407
  },
5345
5408
  (table) => [
5346
5409
  // Indexes for query performance
5347
- index10("role_permissions_role_id_idx").on(table.roleId),
5348
- index10("role_permissions_permission_id_idx").on(table.permissionId),
5410
+ index11("role_permissions_role_id_idx").on(table.roleId),
5411
+ index11("role_permissions_permission_id_idx").on(table.permissionId),
5349
5412
  // Unique constraint: one role-permission pair only
5350
5413
  unique("role_permissions_unique").on(table.roleId, table.permissionId)
5351
5414
  ]
@@ -5354,8 +5417,8 @@ var init_role_permissions = __esm({
5354
5417
  });
5355
5418
 
5356
5419
  // src/server/entities/user-permissions.ts
5357
- import { boolean as boolean5, text as text10, index as index11, unique as unique2 } from "drizzle-orm/pg-core";
5358
- import { id as id11, timestamps as timestamps10, utcTimestamp as utcTimestamp7, foreignKey as foreignKey7 } from "@spfn/core/db";
5420
+ import { boolean as boolean5, text as text11, index as index12, unique as unique2 } from "drizzle-orm/pg-core";
5421
+ import { id as id12, timestamps as timestamps11, utcTimestamp as utcTimestamp8, foreignKey as foreignKey7 } from "@spfn/core/db";
5359
5422
  var userPermissions;
5360
5423
  var init_user_permissions = __esm({
5361
5424
  "src/server/entities/user-permissions.ts"() {
@@ -5367,7 +5430,7 @@ var init_user_permissions = __esm({
5367
5430
  "user_permissions",
5368
5431
  {
5369
5432
  // Primary key
5370
- id: id11(),
5433
+ id: id12(),
5371
5434
  // User reference
5372
5435
  // Foreign key to users table
5373
5436
  // Cascade delete: when user is deleted, all overrides are removed
@@ -5390,19 +5453,19 @@ var init_user_permissions = __esm({
5390
5453
  // Reason for grant/revocation
5391
5454
  // Used for: audit trail, compliance documentation
5392
5455
  // Example: "Temporary access for project X", "Security incident - restricted"
5393
- reason: text10("reason"),
5456
+ reason: text11("reason"),
5394
5457
  // Expiration timestamp (optional)
5395
5458
  // null: Permanent override (remains until manually removed)
5396
5459
  // timestamp: Permission expires at this time (auto-revoked by background job)
5397
5460
  // Use case: Time-limited elevated access, temporary restrictions
5398
- expiresAt: utcTimestamp7("expires_at"),
5399
- ...timestamps10()
5461
+ expiresAt: utcTimestamp8("expires_at"),
5462
+ ...timestamps11()
5400
5463
  },
5401
5464
  (table) => [
5402
5465
  // Indexes for query performance
5403
- index11("user_permissions_user_id_idx").on(table.userId),
5404
- index11("user_permissions_permission_id_idx").on(table.permissionId),
5405
- index11("user_permissions_expires_at_idx").on(table.expiresAt),
5466
+ index12("user_permissions_user_id_idx").on(table.userId),
5467
+ index12("user_permissions_permission_id_idx").on(table.permissionId),
5468
+ index12("user_permissions_expires_at_idx").on(table.expiresAt),
5406
5469
  // Unique constraint: one user-permission pair only
5407
5470
  unique2("user_permissions_unique").on(table.userId, table.permissionId)
5408
5471
  ]
@@ -5412,7 +5475,7 @@ var init_user_permissions = __esm({
5412
5475
 
5413
5476
  // src/server/entities/auth-metadata.ts
5414
5477
  import { sql as sql3 } from "drizzle-orm";
5415
- import { text as text11, timestamp } from "drizzle-orm/pg-core";
5478
+ import { text as text12, timestamp } from "drizzle-orm/pg-core";
5416
5479
  var authMetadata;
5417
5480
  var init_auth_metadata = __esm({
5418
5481
  "src/server/entities/auth-metadata.ts"() {
@@ -5422,9 +5485,9 @@ var init_auth_metadata = __esm({
5422
5485
  "auth_metadata",
5423
5486
  {
5424
5487
  // Metadata key (primary key)
5425
- key: text11("key").primaryKey(),
5488
+ key: text12("key").primaryKey(),
5426
5489
  // Metadata value
5427
- value: text11("value").notNull(),
5490
+ value: text12("value").notNull(),
5428
5491
  // Last updated timestamp — stamped by the database on insert and on update
5429
5492
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => sql3`now()`)
5430
5493
  }
@@ -5433,8 +5496,8 @@ var init_auth_metadata = __esm({
5433
5496
  });
5434
5497
 
5435
5498
  // src/server/entities/ops-tokens.ts
5436
- import { text as text12 } from "drizzle-orm/pg-core";
5437
- import { id as id12, timestamps as timestamps11, utcTimestamp as utcTimestamp8 } from "@spfn/core/db";
5499
+ import { text as text13 } from "drizzle-orm/pg-core";
5500
+ import { id as id13, timestamps as timestamps12, utcTimestamp as utcTimestamp9 } from "@spfn/core/db";
5438
5501
  var opsTokens;
5439
5502
  var init_ops_tokens = __esm({
5440
5503
  "src/server/entities/ops-tokens.ts"() {
@@ -5443,22 +5506,22 @@ var init_ops_tokens = __esm({
5443
5506
  opsTokens = authSchema.table(
5444
5507
  "ops_tokens",
5445
5508
  {
5446
- id: id12(),
5509
+ id: id13(),
5447
5510
  // Operator-facing label ("ci-deploy", "rayim-laptop")
5448
- name: text12("name").notNull(),
5511
+ name: text13("name").notNull(),
5449
5512
  // SHA-256 hex of the token secret. Lookup key — the secret never lands
5450
5513
  // here, and the unique constraint doubles as the lookup index.
5451
- tokenHash: text12("token_hash").notNull().unique(),
5514
+ tokenHash: text13("token_hash").notNull().unique(),
5452
5515
  // Granted scopes as permission strings ('waitlist:read', ...).
5453
5516
  // '*' grants every scope.
5454
- scopes: text12("scopes").array().notNull(),
5517
+ scopes: text13("scopes").array().notNull(),
5455
5518
  // null = the token does not expire
5456
- expiresAt: utcTimestamp8("expires_at"),
5519
+ expiresAt: utcTimestamp9("expires_at"),
5457
5520
  // null = active; a timestamp revokes the token permanently
5458
- revokedAt: utcTimestamp8("revoked_at"),
5521
+ revokedAt: utcTimestamp9("revoked_at"),
5459
5522
  // Last successful verification, updated fire-and-forget
5460
- lastUsedAt: utcTimestamp8("last_used_at"),
5461
- ...timestamps11()
5523
+ lastUsedAt: utcTimestamp9("last_used_at"),
5524
+ ...timestamps12()
5462
5525
  }
5463
5526
  );
5464
5527
  }
@@ -5474,6 +5537,7 @@ var init_entities = __esm({
5474
5537
  init_user_public_keys();
5475
5538
  init_user_social_accounts();
5476
5539
  init_verification_codes();
5540
+ init_signup_link_tokens();
5477
5541
  init_user_invitations();
5478
5542
  init_account_deletion_requests();
5479
5543
  init_roles();
@@ -5486,7 +5550,7 @@ var init_entities = __esm({
5486
5550
  });
5487
5551
 
5488
5552
  // src/server/repositories/users.repository.ts
5489
- import { eq, and } from "drizzle-orm";
5553
+ import { eq, and, sql as sql4 } from "drizzle-orm";
5490
5554
  import { BaseRepository } from "@spfn/core/db";
5491
5555
  import { EntityNotFoundError, NotFoundError } from "@spfn/core/errors";
5492
5556
  var UsersRepository, usersRepository;
@@ -5494,13 +5558,14 @@ var init_users_repository = __esm({
5494
5558
  "src/server/repositories/users.repository.ts"() {
5495
5559
  "use strict";
5496
5560
  init_entities();
5561
+ init_email();
5497
5562
  UsersRepository = class extends BaseRepository {
5498
5563
  /**
5499
5564
  * ID로 사용자 조회
5500
5565
  * Read replica 사용
5501
5566
  */
5502
- async findById(id13) {
5503
- const result = await this.readDb.select().from(users).where(eq(users.id, id13)).limit(1);
5567
+ async findById(id14) {
5568
+ const result = await this.readDb.select().from(users).where(eq(users.id, id14)).limit(1);
5504
5569
  return result[0] ?? null;
5505
5570
  }
5506
5571
  /**
@@ -5510,8 +5575,8 @@ var init_users_repository = __esm({
5510
5575
  * 안 되는 게이트(OAuth 세션 발급 등)가 사용한다. 일반 조회는 `findById`(replica)를
5511
5576
  * 계속 사용할 것.
5512
5577
  */
5513
- async findByIdOnPrimary(id13) {
5514
- const result = await this.db.select().from(users).where(eq(users.id, id13)).limit(1);
5578
+ async findByIdOnPrimary(id14) {
5579
+ const result = await this.db.select().from(users).where(eq(users.id, id14)).limit(1);
5515
5580
  return result[0] ?? null;
5516
5581
  }
5517
5582
  /**
@@ -5519,7 +5584,25 @@ var init_users_repository = __esm({
5519
5584
  * Read replica 사용
5520
5585
  */
5521
5586
  async findByEmail(email) {
5522
- const result = await this.readDb.select().from(users).where(eq(users.email, email)).limit(1);
5587
+ const result = await this.readDb.select().from(users).where(eq(users.email, normalizeEmail(email))).limit(1);
5588
+ return result[0] ?? null;
5589
+ }
5590
+ /**
5591
+ * 이메일로 사용자 조회 — 저장된 형태와 무관하게 찾는다.
5592
+ *
5593
+ * `findByEmail` asks whether a row holds this exact address; this asks
5594
+ * whether any row *is* this address, whatever form it was written in. The
5595
+ * difference matters to a caller that answers "no" by creating an account:
5596
+ * a lookup that misses a row stored in another form would make a second
5597
+ * account for a person who already has one, and the unique constraint
5598
+ * cannot object because the two stored strings differ.
5599
+ *
5600
+ * Folding in the predicate means no index on `email` applies, so this is for
5601
+ * the few addresses a caller decides about — admin seeding — not for a
5602
+ * request path. Write primary: the answer decides whether to insert.
5603
+ */
5604
+ async findByEmailInAnyStoredForm(email) {
5605
+ const result = await this.db.select().from(users).where(sql4`lower(btrim(${users.email})) = ${normalizeEmail(email)}`).limit(1);
5523
5606
  return result[0] ?? null;
5524
5607
  }
5525
5608
  /**
@@ -5564,13 +5647,13 @@ var init_users_repository = __esm({
5564
5647
  *
5565
5648
  * roleId가 null인 유저는 role: null 반환
5566
5649
  */
5567
- async findByIdWithRole(id13) {
5650
+ async findByIdWithRole(id14) {
5568
5651
  const result = await this.readDb.select({
5569
5652
  user: users,
5570
5653
  roleName: roles.name,
5571
5654
  roleDisplayName: roles.displayName,
5572
5655
  rolePriority: roles.priority
5573
- }).from(users).leftJoin(roles, eq(users.roleId, roles.id)).where(eq(users.id, id13)).limit(1);
5656
+ }).from(users).leftJoin(roles, eq(users.roleId, roles.id)).where(eq(users.id, id14)).limit(1);
5574
5657
  const row = result[0];
5575
5658
  if (!row) {
5576
5659
  return null;
@@ -5585,14 +5668,69 @@ var init_users_repository = __esm({
5585
5668
  * Write primary 사용
5586
5669
  */
5587
5670
  async create(data) {
5588
- return await this._create(users, data);
5671
+ return await this._create(users, { ...data, email: normalizeOptionalEmail(data.email) });
5672
+ }
5673
+ /**
5674
+ * User ids grouped by an address two or more rows share once folded.
5675
+ *
5676
+ * The whole comparison happens in the database and only the colliding groups
5677
+ * come back, so the size of the answer is the size of the problem rather
5678
+ * than the size of the table. `users.email` is unique, so a group of more
5679
+ * than one can only be rows that differ by capitalization or padding —
5680
+ * exactly the ones a rewrite cannot decide between.
5681
+ *
5682
+ * Every member id is returned, including a row already holding the
5683
+ * canonical form, because the operator has to compare the accounts against
5684
+ * each other to settle which is real.
5685
+ *
5686
+ * Write primary: the caller is about to rewrite rows and a replica could
5687
+ * still be showing the pre-fix state.
5688
+ */
5689
+ async findEmailConflictGroups() {
5690
+ const rows = await this.db.select({ ids: sql4`array_agg(${users.id} ORDER BY ${users.id})` }).from(users).where(sql4`${users.email} IS NOT NULL`).groupBy(sql4`lower(btrim(${users.email}))`).having(sql4`count(*) > 1`);
5691
+ return rows.map((row) => row.ids.map(Number));
5692
+ }
5693
+ /**
5694
+ * Fold every stored address to canonical form, leaving the given ids alone.
5695
+ *
5696
+ * One statement rather than a row at a time: the rewrite is the same
5697
+ * expression the detection uses, so the database can do it in place. A
5698
+ * legacy install with a large users table therefore pays one update instead
5699
+ * of a round trip per row on the boot path, and no list of addresses is ever
5700
+ * carried through the application.
5701
+ *
5702
+ * The excluded ids travel as a single array parameter, so the count of
5703
+ * conflicts cannot run into the protocol's limit on bind parameters.
5704
+ *
5705
+ * One statement also means all or nothing. `users.email` is unique, so if an
5706
+ * instance still running the old code registers a canonical address in the
5707
+ * moment between the conflict query and this update, the update aborts and
5708
+ * nothing is folded on this boot. The next boot sees that pair as a conflict
5709
+ * and folds everything else, so the repair is deferred rather than lost.
5710
+ *
5711
+ * `lower(btrim(...))` is the SQL spelling of `normalizeEmail`. The two agree
5712
+ * on every address this package's validation accepts (ASCII, no interior
5713
+ * whitespace); an address outside that set — reachable only by an app
5714
+ * writing to the repository directly — may fold differently in a database
5715
+ * whose collation lower-cases non-ASCII letters.
5716
+ *
5717
+ * @param excludedIds - Rows to leave untouched, normally the conflict groups
5718
+ * @returns How many rows were rewritten
5719
+ */
5720
+ async normalizeEmailsExcept(excludedIds) {
5721
+ const keepConflicts = excludedIds.length > 0 ? sql4` AND NOT (${users.id} = ANY(string_to_array(${excludedIds.join(",")}, ',')::bigint[]))` : sql4``;
5722
+ const pending = sql4`${users.email} IS NOT NULL AND ${users.email} <> lower(btrim(${users.email}))${keepConflicts}`;
5723
+ const [counted] = await this.db.select({ rows: sql4`count(*)` }).from(users).where(pending);
5724
+ await this.db.update(users).set({ email: sql4`lower(btrim(${users.email}))` }).where(pending);
5725
+ return Number(counted?.rows ?? 0);
5589
5726
  }
5590
5727
  /**
5591
5728
  * 사용자 정보 업데이트
5592
5729
  * Write primary 사용
5593
5730
  */
5594
- async updateById(id13, data) {
5595
- const result = await this.db.update(users).set(data).where(eq(users.id, id13)).returning();
5731
+ async updateById(id14, data) {
5732
+ const patch = "email" in data ? { ...data, email: normalizeOptionalEmail(data.email) } : data;
5733
+ const result = await this.db.update(users).set(patch).where(eq(users.id, id14)).returning();
5596
5734
  return result[0] ?? null;
5597
5735
  }
5598
5736
  /**
@@ -5604,10 +5742,10 @@ var init_users_repository = __esm({
5604
5742
  * status가 바뀐 상태) 시 null을 반환하며 예외를 던지지 않는다.
5605
5743
  * Write primary 사용
5606
5744
  */
5607
- async reactivateFromPendingDeletion(id13) {
5745
+ async reactivateFromPendingDeletion(id14) {
5608
5746
  const result = await this.db.update(users).set({ status: "active" }).where(
5609
5747
  and(
5610
- eq(users.id, id13),
5748
+ eq(users.id, id14),
5611
5749
  eq(users.status, "pending_deletion")
5612
5750
  )
5613
5751
  ).returning();
@@ -5617,32 +5755,32 @@ var init_users_repository = __esm({
5617
5755
  * 비밀번호 업데이트
5618
5756
  * Write primary 사용
5619
5757
  */
5620
- async updatePassword(id13, passwordHash, clearPasswordChangeRequired = true) {
5758
+ async updatePassword(id14, passwordHash, clearPasswordChangeRequired = true) {
5621
5759
  const updateData = {
5622
5760
  passwordHash
5623
5761
  };
5624
5762
  if (clearPasswordChangeRequired) {
5625
5763
  updateData.passwordChangeRequired = false;
5626
5764
  }
5627
- const result = await this.db.update(users).set(updateData).where(eq(users.id, id13)).returning();
5765
+ const result = await this.db.update(users).set(updateData).where(eq(users.id, id14)).returning();
5628
5766
  return result[0] ?? null;
5629
5767
  }
5630
5768
  /**
5631
5769
  * 마지막 로그인 시간 업데이트
5632
5770
  * Write primary 사용
5633
5771
  */
5634
- async updateLastLogin(id13) {
5772
+ async updateLastLogin(id14) {
5635
5773
  const result = await this.db.update(users).set({
5636
5774
  lastLoginAt: /* @__PURE__ */ new Date()
5637
- }).where(eq(users.id, id13)).returning();
5775
+ }).where(eq(users.id, id14)).returning();
5638
5776
  return result[0] ?? null;
5639
5777
  }
5640
5778
  /**
5641
5779
  * 사용자 삭제
5642
5780
  * Write primary 사용
5643
5781
  */
5644
- async deleteById(id13) {
5645
- const result = await this.db.delete(users).where(eq(users.id, id13)).returning();
5782
+ async deleteById(id14) {
5783
+ const result = await this.db.delete(users).where(eq(users.id, id14)).returning();
5646
5784
  return result[0] ?? null;
5647
5785
  }
5648
5786
  /**
@@ -5759,7 +5897,7 @@ var init_users_repository = __esm({
5759
5897
 
5760
5898
  // src/server/repositories/keys.repository.ts
5761
5899
  import { BaseRepository as BaseRepository2 } from "@spfn/core/db";
5762
- import { eq as eq2, and as and2, or, isNull, lt, ne, desc, sql as sql4 } from "drizzle-orm";
5900
+ import { eq as eq2, and as and2, or, isNull, lt, ne, desc, sql as sql5 } from "drizzle-orm";
5763
5901
  var LAST_USED_THROTTLE_MS, KeysRepository, keysRepository;
5764
5902
  var init_keys_repository = __esm({
5765
5903
  "src/server/repositories/keys.repository.ts"() {
@@ -5990,31 +6128,31 @@ var init_keys_repository = __esm({
5990
6128
  * stored, so it answers "since when has this device been on this release"
5991
6129
  * rather than "when was it last seen", which lastUsedAt already answers.
5992
6130
  */
5993
- async updateLastUsedById(id13, identity) {
6131
+ async updateLastUsedById(id14, identity) {
5994
6132
  const staleBefore = new Date(Date.now() - LAST_USED_THROTTLE_MS);
5995
6133
  const lastUsedIsStale = or(
5996
6134
  isNull(userPublicKeys.lastUsedAt),
5997
6135
  lt(userPublicKeys.lastUsedAt, staleBefore)
5998
6136
  );
5999
6137
  if (!identity) {
6000
- await this.db.update(userPublicKeys).set({ lastUsedAt: /* @__PURE__ */ new Date() }).where(and2(eq2(userPublicKeys.id, id13), lastUsedIsStale));
6138
+ await this.db.update(userPublicKeys).set({ lastUsedAt: /* @__PURE__ */ new Date() }).where(and2(eq2(userPublicKeys.id, id14), lastUsedIsStale));
6001
6139
  return;
6002
6140
  }
6003
- const identityChanged = sql4`(
6141
+ const identityChanged = sql5`(
6004
6142
  ${userPublicKeys.clientKind} IS DISTINCT FROM ${identity.kind}
6005
6143
  OR ${userPublicKeys.clientVersion} IS DISTINCT FROM ${identity.version}
6006
6144
  OR ${userPublicKeys.clientContractVersion} IS DISTINCT FROM ${identity.contractVersion}
6007
6145
  )`;
6008
6146
  const now = /* @__PURE__ */ new Date();
6009
- const nowParam = sql4`${now.toISOString()}::timestamptz`;
6147
+ const nowParam = sql5`${now.toISOString()}::timestamptz`;
6010
6148
  await this.db.update(userPublicKeys).set({
6011
6149
  lastUsedAt: now,
6012
6150
  clientKind: identity.kind,
6013
6151
  clientVersion: identity.version,
6014
6152
  clientContractVersion: identity.contractVersion,
6015
- clientSeenAt: sql4`CASE WHEN ${identityChanged} THEN ${nowParam} ELSE ${userPublicKeys.clientSeenAt} END`
6153
+ clientSeenAt: sql5`CASE WHEN ${identityChanged} THEN ${nowParam} ELSE ${userPublicKeys.clientSeenAt} END`
6016
6154
  }).where(and2(
6017
- eq2(userPublicKeys.id, id13),
6155
+ eq2(userPublicKeys.id, id14),
6018
6156
  or(lastUsedIsStale, identityChanged)
6019
6157
  ));
6020
6158
  }
@@ -6053,8 +6191,8 @@ var init_verification_codes_repository = __esm({
6053
6191
  * ID로 인증 코드 조회
6054
6192
  * Read replica 사용
6055
6193
  */
6056
- async findById(id13) {
6057
- const result = await this.readDb.select().from(verificationCodes).where(eq3(verificationCodes.id, id13)).limit(1);
6194
+ async findById(id14) {
6195
+ const result = await this.readDb.select().from(verificationCodes).where(eq3(verificationCodes.id, id14)).limit(1);
6058
6196
  return result[0] ?? null;
6059
6197
  }
6060
6198
  /**
@@ -6068,22 +6206,22 @@ var init_verification_codes_repository = __esm({
6068
6206
  * 인증 코드 사용 처리
6069
6207
  * Write primary 사용
6070
6208
  */
6071
- async markAsUsed(id13) {
6209
+ async markAsUsed(id14) {
6072
6210
  const result = await this.db.update(verificationCodes).set({
6073
6211
  usedAt: /* @__PURE__ */ new Date()
6074
- }).where(eq3(verificationCodes.id, id13)).returning();
6212
+ }).where(eq3(verificationCodes.id, id14)).returning();
6075
6213
  return result[0] ?? null;
6076
6214
  }
6077
6215
  /**
6078
6216
  * 시도 횟수 증가
6079
6217
  * Write primary 사용
6080
6218
  */
6081
- async incrementAttempts(id13) {
6082
- const code = await this.findById(id13);
6219
+ async incrementAttempts(id14) {
6220
+ const code = await this.findById(id14);
6083
6221
  if (!code) return null;
6084
6222
  const result = await this.db.update(verificationCodes).set({
6085
6223
  attempts: code.attempts + 1
6086
- }).where(eq3(verificationCodes.id, id13)).returning();
6224
+ }).where(eq3(verificationCodes.id, id14)).returning();
6087
6225
  return result[0] ?? null;
6088
6226
  }
6089
6227
  /**
@@ -6127,27 +6265,136 @@ var init_verification_codes_repository = __esm({
6127
6265
  }
6128
6266
  });
6129
6267
 
6130
- // src/server/repositories/roles.repository.ts
6268
+ // src/server/repositories/signup-link-tokens.repository.ts
6131
6269
  import { BaseRepository as BaseRepository4 } from "@spfn/core/db";
6132
- import { eq as eq4, asc } from "drizzle-orm";
6270
+ import { eq as eq4, and as and4, isNull as isNull3 } from "drizzle-orm";
6271
+ var SignupLinkTokensRepository, signupLinkTokensRepository;
6272
+ var init_signup_link_tokens_repository = __esm({
6273
+ "src/server/repositories/signup-link-tokens.repository.ts"() {
6274
+ "use strict";
6275
+ init_signup_link_tokens();
6276
+ SignupLinkTokensRepository = class extends BaseRepository4 {
6277
+ /**
6278
+ * Create a signup link row.
6279
+ * Write primary.
6280
+ */
6281
+ async create(data) {
6282
+ const result = await this.db.insert(signupLinkTokens).values(data).returning();
6283
+ return result[0];
6284
+ }
6285
+ /**
6286
+ * Find a row by the hash of an emailed token, in any state.
6287
+ *
6288
+ * Deliberately unfiltered: the caller decides why a link is refused, and a
6289
+ * row filtered out here would be indistinguishable from an unknown token in
6290
+ * the logs.
6291
+ *
6292
+ * Read replica.
6293
+ */
6294
+ async findByTokenHash(tokenHash) {
6295
+ const result = await this.readDb.select().from(signupLinkTokens).where(eq4(signupLinkTokens.tokenHash, tokenHash)).limit(1);
6296
+ return result[0] ?? null;
6297
+ }
6298
+ /**
6299
+ * Find a row by the hash of a setup session secret, in any state.
6300
+ * Read replica.
6301
+ */
6302
+ async findBySetupSecretHash(setupSecretHash) {
6303
+ const result = await this.readDb.select().from(signupLinkTokens).where(eq4(signupLinkTokens.setupSecretHash, setupSecretHash)).limit(1);
6304
+ return result[0] ?? null;
6305
+ }
6306
+ /**
6307
+ * Consume a link and open a setup session on it, but only if it is still
6308
+ * unconsumed and not superseded.
6309
+ *
6310
+ * @returns the updated row, or null if another request claimed it first
6311
+ */
6312
+ async claimLink(id14, setupSecretHash, setupExpiresAt) {
6313
+ const result = await this.db.update(signupLinkTokens).set({
6314
+ consumedAt: /* @__PURE__ */ new Date(),
6315
+ setupSecretHash,
6316
+ setupExpiresAt
6317
+ }).where(
6318
+ and4(
6319
+ eq4(signupLinkTokens.id, id14),
6320
+ isNull3(signupLinkTokens.consumedAt),
6321
+ isNull3(signupLinkTokens.supersededAt)
6322
+ )
6323
+ ).returning();
6324
+ return result[0] ?? null;
6325
+ }
6326
+ /**
6327
+ * Mark a setup session as completed, but only if it has not completed
6328
+ * already.
6329
+ *
6330
+ * @returns the updated row, or null if another request completed it first
6331
+ */
6332
+ async claimSetupSession(id14) {
6333
+ const result = await this.db.update(signupLinkTokens).set({ completedAt: /* @__PURE__ */ new Date() }).where(
6334
+ and4(
6335
+ eq4(signupLinkTokens.id, id14),
6336
+ isNull3(signupLinkTokens.completedAt),
6337
+ isNull3(signupLinkTokens.supersededAt)
6338
+ )
6339
+ ).returning();
6340
+ return result[0] ?? null;
6341
+ }
6342
+ /**
6343
+ * Supersede every live link for an address, so requesting a new one
6344
+ * invalidates the previous one and any setup session opened from it.
6345
+ *
6346
+ * Write primary.
6347
+ *
6348
+ * @returns number of rows superseded
6349
+ */
6350
+ async supersedeLiveForEmail(email) {
6351
+ const result = await this.db.update(signupLinkTokens).set({ supersededAt: /* @__PURE__ */ new Date() }).where(
6352
+ and4(
6353
+ eq4(signupLinkTokens.email, email),
6354
+ isNull3(signupLinkTokens.supersededAt),
6355
+ isNull3(signupLinkTokens.completedAt)
6356
+ )
6357
+ ).returning();
6358
+ return result.length;
6359
+ }
6360
+ /**
6361
+ * Delete every signup link row for an address (account destruction).
6362
+ *
6363
+ * Rows key on the email text and carry no user FK, so destruction has to
6364
+ * clean them up by address the way verification codes are cleaned up.
6365
+ *
6366
+ * Write primary.
6367
+ */
6368
+ async deleteByEmail(email) {
6369
+ const result = await this.db.delete(signupLinkTokens).where(eq4(signupLinkTokens.email, email)).returning();
6370
+ return result.length;
6371
+ }
6372
+ };
6373
+ signupLinkTokensRepository = new SignupLinkTokensRepository();
6374
+ }
6375
+ });
6376
+
6377
+ // src/server/repositories/roles.repository.ts
6378
+ import { BaseRepository as BaseRepository5 } from "@spfn/core/db";
6379
+ import { eq as eq5, asc } from "drizzle-orm";
6133
6380
  var RolesRepository, rolesRepository;
6134
6381
  var init_roles_repository = __esm({
6135
6382
  "src/server/repositories/roles.repository.ts"() {
6136
6383
  "use strict";
6137
6384
  init_roles();
6138
- RolesRepository = class extends BaseRepository4 {
6385
+ RolesRepository = class extends BaseRepository5 {
6139
6386
  /**
6140
6387
  * ID로 역할 조회
6141
6388
  */
6142
- async findById(id13) {
6143
- const result = await this.readDb.select().from(roles).where(eq4(roles.id, id13)).limit(1);
6389
+ async findById(id14) {
6390
+ const result = await this.readDb.select().from(roles).where(eq5(roles.id, id14)).limit(1);
6144
6391
  return result[0] ?? null;
6145
6392
  }
6146
6393
  /**
6147
6394
  * Name으로 역할 조회
6148
6395
  */
6149
6396
  async findByName(name) {
6150
- const result = await this.readDb.select().from(roles).where(eq4(roles.name, name)).limit(1);
6397
+ const result = await this.readDb.select().from(roles).where(eq5(roles.name, name)).limit(1);
6151
6398
  return result[0] ?? null;
6152
6399
  }
6153
6400
  /**
@@ -6160,7 +6407,7 @@ var init_roles_repository = __esm({
6160
6407
  * 활성 역할만 조회
6161
6408
  */
6162
6409
  async findActive() {
6163
- return this.readDb.select().from(roles).where(eq4(roles.isActive, true)).orderBy(asc(roles.priority));
6410
+ return this.readDb.select().from(roles).where(eq5(roles.isActive, true)).orderBy(asc(roles.priority));
6164
6411
  }
6165
6412
  /**
6166
6413
  * 역할 생성
@@ -6171,15 +6418,15 @@ var init_roles_repository = __esm({
6171
6418
  /**
6172
6419
  * 역할 업데이트
6173
6420
  */
6174
- async updateById(id13, data) {
6175
- const result = await this.db.update(roles).set(data).where(eq4(roles.id, id13)).returning();
6421
+ async updateById(id14, data) {
6422
+ const result = await this.db.update(roles).set(data).where(eq5(roles.id, id14)).returning();
6176
6423
  return result[0] ?? null;
6177
6424
  }
6178
6425
  /**
6179
6426
  * 역할 삭제
6180
6427
  */
6181
- async deleteById(id13) {
6182
- const result = await this.db.delete(roles).where(eq4(roles.id, id13)).returning();
6428
+ async deleteById(id14) {
6429
+ const result = await this.db.delete(roles).where(eq5(roles.id, id14)).returning();
6183
6430
  return result[0] ?? null;
6184
6431
  }
6185
6432
  };
@@ -6188,26 +6435,26 @@ var init_roles_repository = __esm({
6188
6435
  });
6189
6436
 
6190
6437
  // src/server/repositories/permissions.repository.ts
6191
- import { BaseRepository as BaseRepository5 } from "@spfn/core/db";
6192
- import { asc as asc2, eq as eq5, inArray } from "drizzle-orm";
6438
+ import { BaseRepository as BaseRepository6 } from "@spfn/core/db";
6439
+ import { asc as asc2, eq as eq6, inArray } from "drizzle-orm";
6193
6440
  var PermissionsRepository, permissionsRepository;
6194
6441
  var init_permissions_repository = __esm({
6195
6442
  "src/server/repositories/permissions.repository.ts"() {
6196
6443
  "use strict";
6197
6444
  init_permissions();
6198
- PermissionsRepository = class extends BaseRepository5 {
6445
+ PermissionsRepository = class extends BaseRepository6 {
6199
6446
  /**
6200
6447
  * ID로 권한 조회
6201
6448
  */
6202
- async findById(id13) {
6203
- const result = await this.readDb.select().from(permissions).where(eq5(permissions.id, id13)).limit(1);
6449
+ async findById(id14) {
6450
+ const result = await this.readDb.select().from(permissions).where(eq6(permissions.id, id14)).limit(1);
6204
6451
  return result[0] ?? null;
6205
6452
  }
6206
6453
  /**
6207
6454
  * Name으로 권한 조회
6208
6455
  */
6209
6456
  async findByName(name) {
6210
- const result = await this.readDb.select().from(permissions).where(eq5(permissions.name, name)).limit(1);
6457
+ const result = await this.readDb.select().from(permissions).where(eq6(permissions.name, name)).limit(1);
6211
6458
  return result[0] ?? null;
6212
6459
  }
6213
6460
  /**
@@ -6227,13 +6474,13 @@ var init_permissions_repository = __esm({
6227
6474
  * 활성 권한만 조회
6228
6475
  */
6229
6476
  async findActive() {
6230
- return this.readDb.select().from(permissions).where(eq5(permissions.isActive, true)).orderBy(asc2(permissions.name));
6477
+ return this.readDb.select().from(permissions).where(eq6(permissions.isActive, true)).orderBy(asc2(permissions.name));
6231
6478
  }
6232
6479
  /**
6233
6480
  * 카테고리별 권한 조회
6234
6481
  */
6235
6482
  async findByCategory(category) {
6236
- return this.readDb.select().from(permissions).where(eq5(permissions.category, category)).orderBy(asc2(permissions.name));
6483
+ return this.readDb.select().from(permissions).where(eq6(permissions.category, category)).orderBy(asc2(permissions.name));
6237
6484
  }
6238
6485
  /**
6239
6486
  * 권한 생성
@@ -6251,15 +6498,15 @@ var init_permissions_repository = __esm({
6251
6498
  /**
6252
6499
  * 권한 업데이트
6253
6500
  */
6254
- async updateById(id13, data) {
6255
- const result = await this.db.update(permissions).set(data).where(eq5(permissions.id, id13)).returning();
6501
+ async updateById(id14, data) {
6502
+ const result = await this.db.update(permissions).set(data).where(eq6(permissions.id, id14)).returning();
6256
6503
  return result[0] ?? null;
6257
6504
  }
6258
6505
  /**
6259
6506
  * 권한 삭제
6260
6507
  */
6261
- async deleteById(id13) {
6262
- const result = await this.db.delete(permissions).where(eq5(permissions.id, id13)).returning();
6508
+ async deleteById(id14) {
6509
+ const result = await this.db.delete(permissions).where(eq6(permissions.id, id14)).returning();
6263
6510
  return result[0] ?? null;
6264
6511
  }
6265
6512
  };
@@ -6268,25 +6515,25 @@ var init_permissions_repository = __esm({
6268
6515
  });
6269
6516
 
6270
6517
  // src/server/repositories/role-permissions.repository.ts
6271
- import { BaseRepository as BaseRepository6 } from "@spfn/core/db";
6272
- import { and as and4, eq as eq6 } from "drizzle-orm";
6518
+ import { BaseRepository as BaseRepository7 } from "@spfn/core/db";
6519
+ import { and as and5, eq as eq7 } from "drizzle-orm";
6273
6520
  var RolePermissionsRepository, rolePermissionsRepository;
6274
6521
  var init_role_permissions_repository = __esm({
6275
6522
  "src/server/repositories/role-permissions.repository.ts"() {
6276
6523
  "use strict";
6277
6524
  init_role_permissions();
6278
- RolePermissionsRepository = class extends BaseRepository6 {
6525
+ RolePermissionsRepository = class extends BaseRepository7 {
6279
6526
  /**
6280
6527
  * 역할 ID로 모든 권한 조회
6281
6528
  */
6282
6529
  async findByRoleId(roleId) {
6283
- return this.readDb.select().from(rolePermissions).where(eq6(rolePermissions.roleId, roleId));
6530
+ return this.readDb.select().from(rolePermissions).where(eq7(rolePermissions.roleId, roleId));
6284
6531
  }
6285
6532
  /**
6286
6533
  * 권한 ID로 모든 역할 조회
6287
6534
  */
6288
6535
  async findByPermissionId(permissionId) {
6289
- return this.readDb.select().from(rolePermissions).where(eq6(rolePermissions.permissionId, permissionId));
6536
+ return this.readDb.select().from(rolePermissions).where(eq7(rolePermissions.permissionId, permissionId));
6290
6537
  }
6291
6538
  /**
6292
6539
  * 역할-권한 매핑 생성
@@ -6306,9 +6553,9 @@ var init_role_permissions_repository = __esm({
6306
6553
  */
6307
6554
  async deleteByRoleIdAndPermissionId(roleId, permissionId) {
6308
6555
  const result = await this.db.delete(rolePermissions).where(
6309
- and4(
6310
- eq6(rolePermissions.roleId, roleId),
6311
- eq6(rolePermissions.permissionId, permissionId)
6556
+ and5(
6557
+ eq7(rolePermissions.roleId, roleId),
6558
+ eq7(rolePermissions.permissionId, permissionId)
6312
6559
  )
6313
6560
  ).returning();
6314
6561
  return result[0] ?? null;
@@ -6317,7 +6564,7 @@ var init_role_permissions_repository = __esm({
6317
6564
  * 역할의 모든 권한 매핑 삭제
6318
6565
  */
6319
6566
  async deleteByRoleId(roleId) {
6320
- const result = await this.db.delete(rolePermissions).where(eq6(rolePermissions.roleId, roleId)).returning();
6567
+ const result = await this.db.delete(rolePermissions).where(eq7(rolePermissions.roleId, roleId)).returning();
6321
6568
  return result.length;
6322
6569
  }
6323
6570
  /**
@@ -6338,19 +6585,19 @@ var init_role_permissions_repository = __esm({
6338
6585
  });
6339
6586
 
6340
6587
  // src/server/repositories/user-permissions.repository.ts
6341
- import { BaseRepository as BaseRepository7 } from "@spfn/core/db";
6342
- import { eq as eq7, and as and5, or as or2, isNull as isNull3, isNotNull, lt as lt3, gt as gt2 } from "drizzle-orm";
6588
+ import { BaseRepository as BaseRepository8 } from "@spfn/core/db";
6589
+ import { eq as eq8, and as and6, or as or2, isNull as isNull4, isNotNull, lt as lt3, gt as gt2 } from "drizzle-orm";
6343
6590
  var UserPermissionsRepository, userPermissionsRepository;
6344
6591
  var init_user_permissions_repository = __esm({
6345
6592
  "src/server/repositories/user-permissions.repository.ts"() {
6346
6593
  "use strict";
6347
6594
  init_user_permissions();
6348
- UserPermissionsRepository = class extends BaseRepository7 {
6595
+ UserPermissionsRepository = class extends BaseRepository8 {
6349
6596
  /**
6350
6597
  * 사용자 ID로 모든 권한 오버라이드 조회
6351
6598
  */
6352
6599
  async findByUserId(userId) {
6353
- return this.readDb.select().from(userPermissions).where(eq7(userPermissions.userId, userId));
6600
+ return this.readDb.select().from(userPermissions).where(eq8(userPermissions.userId, userId));
6354
6601
  }
6355
6602
  /**
6356
6603
  * 사용자 ID로 유효한 권한 오버라이드만 조회
@@ -6359,10 +6606,10 @@ var init_user_permissions_repository = __esm({
6359
6606
  async findValidByUserId(userId) {
6360
6607
  const now = /* @__PURE__ */ new Date();
6361
6608
  return this.readDb.select().from(userPermissions).where(
6362
- and5(
6363
- eq7(userPermissions.userId, userId),
6609
+ and6(
6610
+ eq8(userPermissions.userId, userId),
6364
6611
  or2(
6365
- isNull3(userPermissions.expiresAt),
6612
+ isNull4(userPermissions.expiresAt),
6366
6613
  gt2(userPermissions.expiresAt, now)
6367
6614
  )
6368
6615
  )
@@ -6373,9 +6620,9 @@ var init_user_permissions_repository = __esm({
6373
6620
  */
6374
6621
  async findByUserIdAndPermissionId(userId, permissionId) {
6375
6622
  const result = await this.readDb.select().from(userPermissions).where(
6376
- and5(
6377
- eq7(userPermissions.userId, userId),
6378
- eq7(userPermissions.permissionId, permissionId)
6623
+ and6(
6624
+ eq8(userPermissions.userId, userId),
6625
+ eq8(userPermissions.permissionId, permissionId)
6379
6626
  )
6380
6627
  ).limit(1);
6381
6628
  return result[0] ?? null;
@@ -6389,8 +6636,8 @@ var init_user_permissions_repository = __esm({
6389
6636
  /**
6390
6637
  * 사용자 권한 오버라이드 업데이트
6391
6638
  */
6392
- async updateById(id13, data) {
6393
- const result = await this.db.update(userPermissions).set(data).where(eq7(userPermissions.id, id13)).returning();
6639
+ async updateById(id14, data) {
6640
+ const result = await this.db.update(userPermissions).set(data).where(eq8(userPermissions.id, id14)).returning();
6394
6641
  return result[0] ?? null;
6395
6642
  }
6396
6643
  /**
@@ -6398,9 +6645,9 @@ var init_user_permissions_repository = __esm({
6398
6645
  */
6399
6646
  async deleteByUserIdAndPermissionId(userId, permissionId) {
6400
6647
  const result = await this.db.delete(userPermissions).where(
6401
- and5(
6402
- eq7(userPermissions.userId, userId),
6403
- eq7(userPermissions.permissionId, permissionId)
6648
+ and6(
6649
+ eq8(userPermissions.userId, userId),
6650
+ eq8(userPermissions.permissionId, permissionId)
6404
6651
  )
6405
6652
  ).returning();
6406
6653
  return result[0] ?? null;
@@ -6409,7 +6656,7 @@ var init_user_permissions_repository = __esm({
6409
6656
  * 사용자의 모든 권한 오버라이드 삭제
6410
6657
  */
6411
6658
  async deleteByUserId(userId) {
6412
- const result = await this.db.delete(userPermissions).where(eq7(userPermissions.userId, userId)).returning();
6659
+ const result = await this.db.delete(userPermissions).where(eq8(userPermissions.userId, userId)).returning();
6413
6660
  return result.length;
6414
6661
  }
6415
6662
  /**
@@ -6418,7 +6665,7 @@ var init_user_permissions_repository = __esm({
6418
6665
  async deleteExpired() {
6419
6666
  const now = /* @__PURE__ */ new Date();
6420
6667
  const result = await this.db.delete(userPermissions).where(
6421
- and5(
6668
+ and6(
6422
6669
  isNotNull(userPermissions.expiresAt),
6423
6670
  lt3(userPermissions.expiresAt, now)
6424
6671
  )
@@ -6431,33 +6678,33 @@ var init_user_permissions_repository = __esm({
6431
6678
  });
6432
6679
 
6433
6680
  // src/server/repositories/user-profiles.repository.ts
6434
- import { BaseRepository as BaseRepository8 } from "@spfn/core/db";
6435
- import { eq as eq8 } from "drizzle-orm";
6681
+ import { BaseRepository as BaseRepository9 } from "@spfn/core/db";
6682
+ import { eq as eq9 } from "drizzle-orm";
6436
6683
  var UserProfilesRepository, userProfilesRepository;
6437
6684
  var init_user_profiles_repository = __esm({
6438
6685
  "src/server/repositories/user-profiles.repository.ts"() {
6439
6686
  "use strict";
6440
6687
  init_user_profiles();
6441
- UserProfilesRepository = class extends BaseRepository8 {
6688
+ UserProfilesRepository = class extends BaseRepository9 {
6442
6689
  /**
6443
6690
  * ID로 프로필 조회
6444
6691
  */
6445
- async findById(id13) {
6446
- const result = await this.readDb.select().from(userProfiles).where(eq8(userProfiles.id, id13)).limit(1);
6692
+ async findById(id14) {
6693
+ const result = await this.readDb.select().from(userProfiles).where(eq9(userProfiles.id, id14)).limit(1);
6447
6694
  return result[0] ?? null;
6448
6695
  }
6449
6696
  /**
6450
6697
  * User ID로 locale만 조회 (경량)
6451
6698
  */
6452
6699
  async findLocaleByUserId(userId) {
6453
- const result = await this.readDb.select({ locale: userProfiles.locale }).from(userProfiles).where(eq8(userProfiles.userId, userId)).limit(1);
6700
+ const result = await this.readDb.select({ locale: userProfiles.locale }).from(userProfiles).where(eq9(userProfiles.userId, userId)).limit(1);
6454
6701
  return result[0]?.locale || "en";
6455
6702
  }
6456
6703
  /**
6457
6704
  * User ID로 프로필 조회
6458
6705
  */
6459
6706
  async findByUserId(userId) {
6460
- const result = await this.readDb.select().from(userProfiles).where(eq8(userProfiles.userId, userId)).limit(1);
6707
+ const result = await this.readDb.select().from(userProfiles).where(eq9(userProfiles.userId, userId)).limit(1);
6461
6708
  return result[0] ?? null;
6462
6709
  }
6463
6710
  /**
@@ -6469,29 +6716,29 @@ var init_user_profiles_repository = __esm({
6469
6716
  /**
6470
6717
  * 프로필 업데이트 (by ID)
6471
6718
  */
6472
- async updateById(id13, data) {
6473
- const result = await this.db.update(userProfiles).set(data).where(eq8(userProfiles.id, id13)).returning();
6719
+ async updateById(id14, data) {
6720
+ const result = await this.db.update(userProfiles).set(data).where(eq9(userProfiles.id, id14)).returning();
6474
6721
  return result[0] ?? null;
6475
6722
  }
6476
6723
  /**
6477
6724
  * 프로필 업데이트 (by User ID)
6478
6725
  */
6479
6726
  async updateByUserId(userId, data) {
6480
- const result = await this.db.update(userProfiles).set(data).where(eq8(userProfiles.userId, userId)).returning();
6727
+ const result = await this.db.update(userProfiles).set(data).where(eq9(userProfiles.userId, userId)).returning();
6481
6728
  return result[0] ?? null;
6482
6729
  }
6483
6730
  /**
6484
6731
  * 프로필 삭제 (by ID)
6485
6732
  */
6486
- async deleteById(id13) {
6487
- const result = await this.db.delete(userProfiles).where(eq8(userProfiles.id, id13)).returning();
6733
+ async deleteById(id14) {
6734
+ const result = await this.db.delete(userProfiles).where(eq9(userProfiles.id, id14)).returning();
6488
6735
  return result[0] ?? null;
6489
6736
  }
6490
6737
  /**
6491
6738
  * 프로필 삭제 (by User ID)
6492
6739
  */
6493
6740
  async deleteByUserId(userId) {
6494
- const result = await this.db.delete(userProfiles).where(eq8(userProfiles.userId, userId)).returning();
6741
+ const result = await this.db.delete(userProfiles).where(eq9(userProfiles.userId, userId)).returning();
6495
6742
  return result[0] ?? null;
6496
6743
  }
6497
6744
  /**
@@ -6533,7 +6780,7 @@ var init_user_profiles_repository = __esm({
6533
6780
  metadata: userProfiles.metadata,
6534
6781
  createdAt: userProfiles.createdAt,
6535
6782
  updatedAt: userProfiles.updatedAt
6536
- }).from(userProfiles).where(eq8(userProfiles.userId, userId)).limit(1).then((rows) => rows[0] ?? null);
6783
+ }).from(userProfiles).where(eq9(userProfiles.userId, userId)).limit(1).then((rows) => rows[0] ?? null);
6537
6784
  if (!profile) {
6538
6785
  return null;
6539
6786
  }
@@ -6561,8 +6808,8 @@ var init_user_profiles_repository = __esm({
6561
6808
  });
6562
6809
 
6563
6810
  // src/server/repositories/invitations.repository.ts
6564
- import { eq as eq9, and as and6, lt as lt4, desc as desc2, sql as sql5 } from "drizzle-orm";
6565
- import { BaseRepository as BaseRepository9 } from "@spfn/core/db";
6811
+ import { eq as eq10, and as and7, lt as lt4, desc as desc2, sql as sql6 } from "drizzle-orm";
6812
+ import { BaseRepository as BaseRepository10 } from "@spfn/core/db";
6566
6813
  var InvitationsRepository, invitationsRepository;
6567
6814
  var init_invitations_repository = __esm({
6568
6815
  "src/server/repositories/invitations.repository.ts"() {
@@ -6570,19 +6817,20 @@ var init_invitations_repository = __esm({
6570
6817
  init_users();
6571
6818
  init_roles();
6572
6819
  init_user_invitations();
6573
- InvitationsRepository = class extends BaseRepository9 {
6820
+ init_email();
6821
+ InvitationsRepository = class extends BaseRepository10 {
6574
6822
  /**
6575
6823
  * ID로 초대 조회
6576
6824
  */
6577
- async findById(id13) {
6578
- const result = await this.readDb.select().from(userInvitations).where(eq9(userInvitations.id, id13)).limit(1);
6825
+ async findById(id14) {
6826
+ const result = await this.readDb.select().from(userInvitations).where(eq10(userInvitations.id, id14)).limit(1);
6579
6827
  return result[0] ?? null;
6580
6828
  }
6581
6829
  /**
6582
6830
  * Token으로 초대 조회
6583
6831
  */
6584
6832
  async findByToken(token) {
6585
- const result = await this.readDb.select().from(userInvitations).where(eq9(userInvitations.token, token)).limit(1);
6833
+ const result = await this.readDb.select().from(userInvitations).where(eq10(userInvitations.token, token)).limit(1);
6586
6834
  return result[0] ?? null;
6587
6835
  }
6588
6836
  /**
@@ -6590,9 +6838,9 @@ var init_invitations_repository = __esm({
6590
6838
  */
6591
6839
  async findPendingByEmail(email) {
6592
6840
  const result = await this.readDb.select().from(userInvitations).where(
6593
- and6(
6594
- eq9(userInvitations.email, email),
6595
- eq9(userInvitations.status, "pending")
6841
+ and7(
6842
+ eq10(userInvitations.email, normalizeEmail(email)),
6843
+ eq10(userInvitations.status, "pending")
6596
6844
  )
6597
6845
  ).limit(1);
6598
6846
  return result[0] ?? null;
@@ -6601,24 +6849,24 @@ var init_invitations_repository = __esm({
6601
6849
  * 초대자 ID로 모든 초대 조회
6602
6850
  */
6603
6851
  async findByInvitedBy(invitedBy) {
6604
- return this.readDb.select().from(userInvitations).where(eq9(userInvitations.invitedBy, invitedBy));
6852
+ return this.readDb.select().from(userInvitations).where(eq10(userInvitations.invitedBy, invitedBy));
6605
6853
  }
6606
6854
  /**
6607
6855
  * 상태별 초대 조회
6608
6856
  */
6609
6857
  async findByStatus(status) {
6610
- return this.readDb.select().from(userInvitations).where(eq9(userInvitations.status, status));
6858
+ return this.readDb.select().from(userInvitations).where(eq10(userInvitations.status, status));
6611
6859
  }
6612
6860
  /**
6613
6861
  * 초대 생성
6614
6862
  */
6615
6863
  async create(data) {
6616
- return await this._create(userInvitations, data);
6864
+ return await this._create(userInvitations, { ...data, email: normalizeEmail(data.email) });
6617
6865
  }
6618
6866
  /**
6619
6867
  * 초대 상태 업데이트
6620
6868
  */
6621
- async updateStatus(id13, status, timestamp2) {
6869
+ async updateStatus(id14, status, timestamp2) {
6622
6870
  const updates = {
6623
6871
  status
6624
6872
  };
@@ -6629,14 +6877,14 @@ var init_invitations_repository = __esm({
6629
6877
  updates.cancelledAt = timestamp2;
6630
6878
  }
6631
6879
  }
6632
- const result = await this.db.update(userInvitations).set(updates).where(eq9(userInvitations.id, id13)).returning();
6880
+ const result = await this.db.update(userInvitations).set(updates).where(eq10(userInvitations.id, id14)).returning();
6633
6881
  return result[0] ?? null;
6634
6882
  }
6635
6883
  /**
6636
6884
  * 초대 삭제
6637
6885
  */
6638
- async deleteById(id13) {
6639
- const result = await this.db.delete(userInvitations).where(eq9(userInvitations.id, id13)).returning();
6886
+ async deleteById(id14) {
6887
+ const result = await this.db.delete(userInvitations).where(eq10(userInvitations.id, id14)).returning();
6640
6888
  return result[0] ?? null;
6641
6889
  }
6642
6890
  /**
@@ -6645,8 +6893,8 @@ var init_invitations_repository = __esm({
6645
6893
  async updateExpiredInvitations() {
6646
6894
  const now = /* @__PURE__ */ new Date();
6647
6895
  const result = await this.db.update(userInvitations).set({ status: "expired" }).where(
6648
- and6(
6649
- eq9(userInvitations.status, "pending"),
6896
+ and7(
6897
+ eq10(userInvitations.status, "pending"),
6650
6898
  lt4(userInvitations.expiresAt, now)
6651
6899
  )
6652
6900
  ).returning();
@@ -6678,7 +6926,7 @@ var init_invitations_repository = __esm({
6678
6926
  id: users.id,
6679
6927
  email: users.email
6680
6928
  }
6681
- }).from(userInvitations).innerJoin(roles, eq9(userInvitations.roleId, roles.id)).innerJoin(users, eq9(userInvitations.invitedBy, users.id)).where(eq9(userInvitations.token, token)).limit(1);
6929
+ }).from(userInvitations).innerJoin(roles, eq10(userInvitations.roleId, roles.id)).innerJoin(users, eq10(userInvitations.invitedBy, users.id)).where(eq10(userInvitations.token, token)).limit(1);
6682
6930
  return result[0] ?? null;
6683
6931
  }
6684
6932
  /**
@@ -6689,13 +6937,13 @@ var init_invitations_repository = __esm({
6689
6937
  const offset = (page - 1) * limit;
6690
6938
  const conditions = [];
6691
6939
  if (status) {
6692
- conditions.push(eq9(userInvitations.status, status));
6940
+ conditions.push(eq10(userInvitations.status, status));
6693
6941
  }
6694
6942
  if (invitedBy) {
6695
- conditions.push(eq9(userInvitations.invitedBy, invitedBy));
6943
+ conditions.push(eq10(userInvitations.invitedBy, invitedBy));
6696
6944
  }
6697
- const whereClause = conditions.length > 0 ? and6(...conditions) : void 0;
6698
- const countResult = await this.readDb.select({ count: sql5`count(*)` }).from(userInvitations).where(whereClause);
6945
+ const whereClause = conditions.length > 0 ? and7(...conditions) : void 0;
6946
+ const countResult = await this.readDb.select({ count: sql6`count(*)` }).from(userInvitations).where(whereClause);
6699
6947
  const total = Number(countResult[0]?.count || 0);
6700
6948
  const results = await this.readDb.select({
6701
6949
  id: userInvitations.id,
@@ -6719,7 +6967,7 @@ var init_invitations_repository = __esm({
6719
6967
  id: users.id,
6720
6968
  email: users.email
6721
6969
  }
6722
- }).from(userInvitations).innerJoin(roles, eq9(userInvitations.roleId, roles.id)).innerJoin(users, eq9(userInvitations.invitedBy, users.id)).where(whereClause).orderBy(desc2(userInvitations.createdAt)).limit(limit).offset(offset);
6970
+ }).from(userInvitations).innerJoin(roles, eq10(userInvitations.roleId, roles.id)).innerJoin(users, eq10(userInvitations.invitedBy, users.id)).where(whereClause).orderBy(desc2(userInvitations.createdAt)).limit(limit).offset(offset);
6723
6971
  return {
6724
6972
  invitations: results,
6725
6973
  total,
@@ -6731,30 +6979,31 @@ var init_invitations_repository = __esm({
6731
6979
  /**
6732
6980
  * 초대 업데이트 (일반 업데이트 - 모든 필드 가능)
6733
6981
  */
6734
- async updateById(id13, data) {
6735
- const result = await this.db.update(userInvitations).set(data).where(eq9(userInvitations.id, id13)).returning();
6982
+ async updateById(id14, data) {
6983
+ const patch = "email" in data && typeof data.email === "string" ? { ...data, email: normalizeEmail(data.email) } : data;
6984
+ const result = await this.db.update(userInvitations).set(patch).where(eq10(userInvitations.id, id14)).returning();
6736
6985
  return result[0] ?? null;
6737
6986
  }
6738
6987
  /**
6739
6988
  * 초대 재전송 (status와 expiresAt 동시 업데이트)
6740
6989
  */
6741
- async resend(id13, newExpiresAt) {
6990
+ async resend(id14, newExpiresAt) {
6742
6991
  const result = await this.db.update(userInvitations).set({
6743
6992
  status: "pending",
6744
6993
  expiresAt: newExpiresAt
6745
- }).where(eq9(userInvitations.id, id13)).returning();
6994
+ }).where(eq10(userInvitations.id, id14)).returning();
6746
6995
  return result[0] ?? null;
6747
6996
  }
6748
6997
  /**
6749
6998
  * 초대 취소 (status, metadata 동시 업데이트)
6750
6999
  */
6751
- async cancel(id13, cancelledBy, reason, currentMetadata) {
7000
+ async cancel(id14, cancelledBy, reason, currentMetadata) {
6752
7001
  const newMetadata = currentMetadata ? { ...currentMetadata, cancelReason: reason, cancelledBy } : { cancelReason: reason, cancelledBy };
6753
7002
  const result = await this.db.update(userInvitations).set({
6754
7003
  status: "cancelled",
6755
7004
  cancelledAt: /* @__PURE__ */ new Date(),
6756
7005
  metadata: newMetadata
6757
- }).where(eq9(userInvitations.id, id13)).returning();
7006
+ }).where(eq10(userInvitations.id, id14)).returning();
6758
7007
  return result[0] ?? null;
6759
7008
  }
6760
7009
  };
@@ -6966,6 +7215,33 @@ var init_schema5 = __esm({
6966
7215
  })
6967
7216
  },
6968
7217
  // ============================================================================
7218
+ // Verified-email signup
7219
+ // ============================================================================
7220
+ SPFN_AUTH_SIGNUP_LINK_TTL_MINUTES: {
7221
+ ...envNumber({
7222
+ description: "How long an emailed signup confirmation link stays valid. Long enough to survive a mail delay, short enough that a link left in an inbox stops working.",
7223
+ default: 30,
7224
+ required: false,
7225
+ examples: [15, 30, 60]
7226
+ })
7227
+ },
7228
+ SPFN_AUTH_SIGNUP_SETUP_TTL_MINUTES: {
7229
+ ...envNumber({
7230
+ description: "How long the password-setup session opened by a confirmation link stays valid. Covers one sitting at the password form, not an abandoned tab.",
7231
+ default: 15,
7232
+ required: false,
7233
+ examples: [10, 15, 30]
7234
+ })
7235
+ },
7236
+ SPFN_AUTH_SIGNUP_CONFIRM_PATH: {
7237
+ ...envString({
7238
+ description: "App page the emailed confirmation link opens, as a path on {NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}. The page reads the token from the query string and posts it to /_auth/signup/email/confirm; it is a page in your app, not an API route.",
7239
+ default: "/signup/confirm",
7240
+ required: false,
7241
+ examples: ["/signup/confirm", "/auth/confirm", "/join/verify"]
7242
+ })
7243
+ },
7244
+ // ============================================================================
6969
7245
  // API Configuration
6970
7246
  // ============================================================================
6971
7247
  SPFN_API_URL: {
@@ -7374,15 +7650,15 @@ var init_token_cipher = __esm({
7374
7650
  });
7375
7651
 
7376
7652
  // src/server/repositories/social-accounts.repository.ts
7377
- import { eq as eq10, and as and7 } from "drizzle-orm";
7378
- import { BaseRepository as BaseRepository10 } from "@spfn/core/db";
7653
+ import { eq as eq11, and as and8 } from "drizzle-orm";
7654
+ import { BaseRepository as BaseRepository11 } from "@spfn/core/db";
7379
7655
  var SocialAccountsRepository, socialAccountsRepository;
7380
7656
  var init_social_accounts_repository = __esm({
7381
7657
  "src/server/repositories/social-accounts.repository.ts"() {
7382
7658
  "use strict";
7383
7659
  init_entities();
7384
7660
  init_token_cipher();
7385
- SocialAccountsRepository = class extends BaseRepository10 {
7661
+ SocialAccountsRepository = class extends BaseRepository11 {
7386
7662
  /**
7387
7663
  * 저장 row 의 토큰을 평문으로 복호화해 반환한다.
7388
7664
  *
@@ -7410,10 +7686,10 @@ var init_social_accounts_repository = __esm({
7410
7686
  if (refresh?.needsRotation) {
7411
7687
  heal.refreshToken = await encryptToken(refresh.value, context("refresh"));
7412
7688
  }
7413
- await this.db.update(userSocialAccounts).set(heal).where(and7(
7414
- eq10(userSocialAccounts.id, account.id),
7415
- access?.needsRotation && account.accessToken !== null ? eq10(userSocialAccounts.accessToken, account.accessToken) : void 0,
7416
- refresh?.needsRotation && account.refreshToken !== null ? eq10(userSocialAccounts.refreshToken, account.refreshToken) : void 0
7689
+ await this.db.update(userSocialAccounts).set(heal).where(and8(
7690
+ eq11(userSocialAccounts.id, account.id),
7691
+ access?.needsRotation && account.accessToken !== null ? eq11(userSocialAccounts.accessToken, account.accessToken) : void 0,
7692
+ refresh?.needsRotation && account.refreshToken !== null ? eq11(userSocialAccounts.refreshToken, account.refreshToken) : void 0
7417
7693
  ));
7418
7694
  } catch {
7419
7695
  }
@@ -7430,9 +7706,9 @@ var init_social_accounts_repository = __esm({
7430
7706
  */
7431
7707
  async findByProviderAndProviderId(provider, providerUserId) {
7432
7708
  const result = await this.readDb.select().from(userSocialAccounts).where(
7433
- and7(
7434
- eq10(userSocialAccounts.provider, provider),
7435
- eq10(userSocialAccounts.providerUserId, providerUserId)
7709
+ and8(
7710
+ eq11(userSocialAccounts.provider, provider),
7711
+ eq11(userSocialAccounts.providerUserId, providerUserId)
7436
7712
  )
7437
7713
  ).limit(1);
7438
7714
  return this.decryptAccount(result[0] ?? null);
@@ -7442,7 +7718,7 @@ var init_social_accounts_repository = __esm({
7442
7718
  * Read replica 사용
7443
7719
  */
7444
7720
  async findByUserId(userId) {
7445
- const result = await this.readDb.select().from(userSocialAccounts).where(eq10(userSocialAccounts.userId, userId));
7721
+ const result = await this.readDb.select().from(userSocialAccounts).where(eq11(userSocialAccounts.userId, userId));
7446
7722
  return Promise.all(result.map((account) => this.decryptAccount(account)));
7447
7723
  }
7448
7724
  /**
@@ -7451,9 +7727,9 @@ var init_social_accounts_repository = __esm({
7451
7727
  */
7452
7728
  async findByUserIdAndProvider(userId, provider) {
7453
7729
  const result = await this.readDb.select().from(userSocialAccounts).where(
7454
- and7(
7455
- eq10(userSocialAccounts.userId, userId),
7456
- eq10(userSocialAccounts.provider, provider)
7730
+ and8(
7731
+ eq11(userSocialAccounts.userId, userId),
7732
+ eq11(userSocialAccounts.provider, provider)
7457
7733
  )
7458
7734
  ).limit(1);
7459
7735
  return this.decryptAccount(result[0] ?? null);
@@ -7479,11 +7755,11 @@ var init_social_accounts_repository = __esm({
7479
7755
  * 토큰 정보 업데이트
7480
7756
  * Write primary 사용
7481
7757
  */
7482
- async updateTokens(id13, data) {
7758
+ async updateTokens(id14, data) {
7483
7759
  const accounts = await this.db.select({
7484
7760
  provider: userSocialAccounts.provider,
7485
7761
  providerUserId: userSocialAccounts.providerUserId
7486
- }).from(userSocialAccounts).where(eq10(userSocialAccounts.id, id13)).limit(1);
7762
+ }).from(userSocialAccounts).where(eq11(userSocialAccounts.id, id14)).limit(1);
7487
7763
  const account = accounts[0];
7488
7764
  if (!account) {
7489
7765
  return null;
@@ -7497,15 +7773,15 @@ var init_social_accounts_repository = __esm({
7497
7773
  ...data,
7498
7774
  accessToken: data.accessToken ? await encryptToken(data.accessToken, context("access")) : data.accessToken,
7499
7775
  refreshToken: data.refreshToken ? await encryptToken(data.refreshToken, context("refresh")) : data.refreshToken
7500
- }).where(eq10(userSocialAccounts.id, id13)).returning();
7776
+ }).where(eq11(userSocialAccounts.id, id14)).returning();
7501
7777
  return this.decryptAccount(result[0] ?? null);
7502
7778
  }
7503
7779
  /**
7504
7780
  * 소셜 계정 삭제
7505
7781
  * Write primary 사용
7506
7782
  */
7507
- async deleteById(id13) {
7508
- const result = await this.db.delete(userSocialAccounts).where(eq10(userSocialAccounts.id, id13)).returning();
7783
+ async deleteById(id14) {
7784
+ const result = await this.db.delete(userSocialAccounts).where(eq11(userSocialAccounts.id, id14)).returning();
7509
7785
  return result[0] ?? null;
7510
7786
  }
7511
7787
  /**
@@ -7514,9 +7790,9 @@ var init_social_accounts_repository = __esm({
7514
7790
  */
7515
7791
  async deleteByUserIdAndProvider(userId, provider) {
7516
7792
  const result = await this.db.delete(userSocialAccounts).where(
7517
- and7(
7518
- eq10(userSocialAccounts.userId, userId),
7519
- eq10(userSocialAccounts.provider, provider)
7793
+ and8(
7794
+ eq11(userSocialAccounts.userId, userId),
7795
+ eq11(userSocialAccounts.provider, provider)
7520
7796
  )
7521
7797
  ).returning();
7522
7798
  return result[0] ?? null;
@@ -7529,7 +7805,7 @@ var init_social_accounts_repository = __esm({
7529
7805
  * Write primary 사용
7530
7806
  */
7531
7807
  async deleteAllByUserId(userId) {
7532
- const result = await this.db.delete(userSocialAccounts).where(eq10(userSocialAccounts.userId, userId)).returning();
7808
+ const result = await this.db.delete(userSocialAccounts).where(eq11(userSocialAccounts.userId, userId)).returning();
7533
7809
  return result.length;
7534
7810
  }
7535
7811
  };
@@ -7538,19 +7814,19 @@ var init_social_accounts_repository = __esm({
7538
7814
  });
7539
7815
 
7540
7816
  // src/server/repositories/auth-metadata.repository.ts
7541
- import { BaseRepository as BaseRepository11 } from "@spfn/core/db";
7542
- import { eq as eq11 } from "drizzle-orm";
7817
+ import { BaseRepository as BaseRepository12 } from "@spfn/core/db";
7818
+ import { eq as eq12 } from "drizzle-orm";
7543
7819
  var AuthMetadataRepository, authMetadataRepository;
7544
7820
  var init_auth_metadata_repository = __esm({
7545
7821
  "src/server/repositories/auth-metadata.repository.ts"() {
7546
7822
  "use strict";
7547
7823
  init_auth_metadata();
7548
- AuthMetadataRepository = class extends BaseRepository11 {
7824
+ AuthMetadataRepository = class extends BaseRepository12 {
7549
7825
  /**
7550
7826
  * 키로 값 조회
7551
7827
  */
7552
7828
  async get(key) {
7553
- const result = await this.readDb.select().from(authMetadata).where(eq11(authMetadata.key, key)).limit(1);
7829
+ const result = await this.readDb.select().from(authMetadata).where(eq12(authMetadata.key, key)).limit(1);
7554
7830
  return result[0]?.value ?? null;
7555
7831
  }
7556
7832
  /**
@@ -7573,20 +7849,20 @@ var init_auth_metadata_repository = __esm({
7573
7849
  });
7574
7850
 
7575
7851
  // src/server/repositories/account-deletion-requests.repository.ts
7576
- import { eq as eq12, and as and8, lte } from "drizzle-orm";
7577
- import { BaseRepository as BaseRepository12 } from "@spfn/core/db";
7852
+ import { eq as eq13, and as and9, lte } from "drizzle-orm";
7853
+ import { BaseRepository as BaseRepository13 } from "@spfn/core/db";
7578
7854
  var AccountDeletionRequestsRepository, accountDeletionRequestsRepository;
7579
7855
  var init_account_deletion_requests_repository = __esm({
7580
7856
  "src/server/repositories/account-deletion-requests.repository.ts"() {
7581
7857
  "use strict";
7582
7858
  init_account_deletion_requests();
7583
- AccountDeletionRequestsRepository = class extends BaseRepository12 {
7859
+ AccountDeletionRequestsRepository = class extends BaseRepository13 {
7584
7860
  /**
7585
7861
  * ID로 요청 조회
7586
7862
  * Read replica 사용
7587
7863
  */
7588
- async findById(id13) {
7589
- const result = await this.readDb.select().from(accountDeletionRequests).where(eq12(accountDeletionRequests.id, id13)).limit(1);
7864
+ async findById(id14) {
7865
+ const result = await this.readDb.select().from(accountDeletionRequests).where(eq13(accountDeletionRequests.id, id14)).limit(1);
7590
7866
  return result[0] ?? null;
7591
7867
  }
7592
7868
  /**
@@ -7595,9 +7871,9 @@ var init_account_deletion_requests_repository = __esm({
7595
7871
  */
7596
7872
  async findPendingByUserId(userId) {
7597
7873
  const result = await this.readDb.select().from(accountDeletionRequests).where(
7598
- and8(
7599
- eq12(accountDeletionRequests.userId, userId),
7600
- eq12(accountDeletionRequests.status, "pending")
7874
+ and9(
7875
+ eq13(accountDeletionRequests.userId, userId),
7876
+ eq13(accountDeletionRequests.status, "pending")
7601
7877
  )
7602
7878
  ).limit(1);
7603
7879
  return result[0] ?? null;
@@ -7611,9 +7887,9 @@ var init_account_deletion_requests_repository = __esm({
7611
7887
  */
7612
7888
  async findPendingByUserIdOnPrimary(userId) {
7613
7889
  const result = await this.db.select().from(accountDeletionRequests).where(
7614
- and8(
7615
- eq12(accountDeletionRequests.userId, userId),
7616
- eq12(accountDeletionRequests.status, "pending")
7890
+ and9(
7891
+ eq13(accountDeletionRequests.userId, userId),
7892
+ eq13(accountDeletionRequests.status, "pending")
7617
7893
  )
7618
7894
  ).limit(1);
7619
7895
  return result[0] ?? null;
@@ -7624,8 +7900,8 @@ var init_account_deletion_requests_repository = __esm({
7624
7900
  */
7625
7901
  async findDueForPurge(now) {
7626
7902
  return this.readDb.select().from(accountDeletionRequests).where(
7627
- and8(
7628
- eq12(accountDeletionRequests.status, "pending"),
7903
+ and9(
7904
+ eq13(accountDeletionRequests.status, "pending"),
7629
7905
  lte(accountDeletionRequests.purgeScheduledAt, now)
7630
7906
  )
7631
7907
  );
@@ -7645,14 +7921,14 @@ var init_account_deletion_requests_repository = __esm({
7645
7921
  * cancelled) 시 null을 반환하니 호출자가 그 결과를 확인해야 한다.
7646
7922
  * Write primary 사용
7647
7923
  */
7648
- async markCancelled(id13) {
7924
+ async markCancelled(id14) {
7649
7925
  const result = await this.db.update(accountDeletionRequests).set({
7650
7926
  status: "cancelled",
7651
7927
  cancelledAt: /* @__PURE__ */ new Date()
7652
7928
  }).where(
7653
- and8(
7654
- eq12(accountDeletionRequests.id, id13),
7655
- eq12(accountDeletionRequests.status, "pending")
7929
+ and9(
7930
+ eq13(accountDeletionRequests.id, id14),
7931
+ eq13(accountDeletionRequests.status, "pending")
7656
7932
  )
7657
7933
  ).returning();
7658
7934
  return result[0] ?? null;
@@ -7667,15 +7943,15 @@ var init_account_deletion_requests_repository = __esm({
7667
7943
  * destructive DML을 실행하기 **전에** 반드시 이 결과를 확인해야 한다.
7668
7944
  * Write primary 사용
7669
7945
  */
7670
- async markCompleted(id13, purgeStrategy) {
7946
+ async markCompleted(id14, purgeStrategy) {
7671
7947
  const result = await this.db.update(accountDeletionRequests).set({
7672
7948
  status: "completed",
7673
7949
  completedAt: /* @__PURE__ */ new Date(),
7674
7950
  purgeStrategy
7675
7951
  }).where(
7676
- and8(
7677
- eq12(accountDeletionRequests.id, id13),
7678
- eq12(accountDeletionRequests.status, "pending")
7952
+ and9(
7953
+ eq13(accountDeletionRequests.id, id14),
7954
+ eq13(accountDeletionRequests.status, "pending")
7679
7955
  )
7680
7956
  ).returning();
7681
7957
  return result[0] ?? null;
@@ -7686,14 +7962,14 @@ var init_account_deletion_requests_repository = __esm({
7686
7962
  });
7687
7963
 
7688
7964
  // src/server/repositories/ops-tokens.repository.ts
7689
- import { and as and9, desc as desc3, eq as eq13, isNull as isNull4 } from "drizzle-orm";
7690
- import { BaseRepository as BaseRepository13 } from "@spfn/core/db";
7965
+ import { and as and10, desc as desc3, eq as eq14, isNull as isNull5 } from "drizzle-orm";
7966
+ import { BaseRepository as BaseRepository14 } from "@spfn/core/db";
7691
7967
  var OpsTokensRepository, opsTokensRepository;
7692
7968
  var init_ops_tokens_repository = __esm({
7693
7969
  "src/server/repositories/ops-tokens.repository.ts"() {
7694
7970
  "use strict";
7695
7971
  init_ops_tokens();
7696
- OpsTokensRepository = class extends BaseRepository13 {
7972
+ OpsTokensRepository = class extends BaseRepository14 {
7697
7973
  /**
7698
7974
  * Lookup by the secret's hash — the verification path.
7699
7975
  *
@@ -7703,7 +7979,7 @@ var init_ops_tokens_repository = __esm({
7703
7979
  * and revocation is documented as taking effect immediately.
7704
7980
  */
7705
7981
  async findByTokenHash(tokenHash) {
7706
- const result = await this.db.select().from(opsTokens).where(eq13(opsTokens.tokenHash, tokenHash)).limit(1);
7982
+ const result = await this.db.select().from(opsTokens).where(eq14(opsTokens.tokenHash, tokenHash)).limit(1);
7707
7983
  return result[0] ?? null;
7708
7984
  }
7709
7985
  async create(data) {
@@ -7718,13 +7994,13 @@ var init_ops_tokens_repository = __esm({
7718
7994
  * token is already revoked — the first revocation's timestamp is never
7719
7995
  * overwritten.
7720
7996
  */
7721
- async revokeById(id13) {
7722
- const result = await this.db.update(opsTokens).set({ revokedAt: /* @__PURE__ */ new Date() }).where(and9(eq13(opsTokens.id, id13), isNull4(opsTokens.revokedAt))).returning();
7997
+ async revokeById(id14) {
7998
+ const result = await this.db.update(opsTokens).set({ revokedAt: /* @__PURE__ */ new Date() }).where(and10(eq14(opsTokens.id, id14), isNull5(opsTokens.revokedAt))).returning();
7723
7999
  return result[0] ?? null;
7724
8000
  }
7725
8001
  /** Fire-and-forget from the verification path. */
7726
- async updateLastUsedById(id13) {
7727
- await this.db.update(opsTokens).set({ lastUsedAt: /* @__PURE__ */ new Date() }).where(eq13(opsTokens.id, id13));
8002
+ async updateLastUsedById(id14) {
8003
+ await this.db.update(opsTokens).set({ lastUsedAt: /* @__PURE__ */ new Date() }).where(eq14(opsTokens.id, id14));
7728
8004
  }
7729
8005
  };
7730
8006
  opsTokensRepository = new OpsTokensRepository();
@@ -7738,6 +8014,7 @@ var init_repositories = __esm({
7738
8014
  init_users_repository();
7739
8015
  init_keys_repository();
7740
8016
  init_verification_codes_repository();
8017
+ init_signup_link_tokens_repository();
7741
8018
  init_roles_repository();
7742
8019
  init_permissions_repository();
7743
8020
  init_role_permissions_repository();
@@ -7839,7 +8116,7 @@ async function removePermissionFromRole(roleId, permissionId) {
7839
8116
  }
7840
8117
  async function setRolePermissions(roleId, permissionIds) {
7841
8118
  const roleIdNum = Number(roleId);
7842
- const permissionIdNums = permissionIds.map((id13) => Number(id13));
8119
+ const permissionIdNums = permissionIds.map((id14) => Number(id14));
7843
8120
  await rolePermissionsRepository.setPermissionsForRole(roleIdNum, permissionIdNums);
7844
8121
  }
7845
8122
  async function getAllRoles(includeInactive = false) {
@@ -7859,7 +8136,7 @@ async function getRolePermissions(roleId) {
7859
8136
  }
7860
8137
  const permissionIds = mappings.map((m) => m.permissionId);
7861
8138
  const perms = await Promise.all(
7862
- permissionIds.map((id13) => permissionsRepository.findById(id13))
8139
+ permissionIds.map((id14) => permissionsRepository.findById(id14))
7863
8140
  );
7864
8141
  return perms.filter((p) => p !== null).map((p) => p.name);
7865
8142
  }
@@ -7879,6 +8156,9 @@ import { defineRouter as defineRouter6 } from "@spfn/core/route";
7879
8156
  // src/server/routes/auth/index.ts
7880
8157
  init_schema3();
7881
8158
 
8159
+ // src/server/helpers/index.ts
8160
+ init_email();
8161
+
7882
8162
  // src/server/helpers/password.ts
7883
8163
  import * as bcrypt from "@node-rs/bcrypt";
7884
8164
  import { env } from "@spfn/auth/config";
@@ -8055,6 +8335,7 @@ function getKeyId(c) {
8055
8335
  // src/server/routes/auth/index.ts
8056
8336
  init_types();
8057
8337
  import { KeyNotFoundError } from "@spfn/auth/errors";
8338
+ import { ValidationError as ValidationError9 } from "@spfn/core/errors";
8058
8339
 
8059
8340
  // src/server/services/auth.service.ts
8060
8341
  init_repositories();
@@ -8070,6 +8351,7 @@ import {
8070
8351
  } from "@spfn/auth/errors";
8071
8352
 
8072
8353
  // src/server/lib/config.ts
8354
+ init_email();
8073
8355
  import { env as env4 } from "@spfn/auth/config";
8074
8356
  function getCookieSuffix() {
8075
8357
  const port = process.env.SPFN_PORT;
@@ -8091,6 +8373,10 @@ var COOKIE_NAMES = {
8091
8373
  /** OAuth CSRF nonce — double-submit against the (encrypted) state.nonce at callback */
8092
8374
  get OAUTH_CSRF() {
8093
8375
  return `spfn_oauth_csrf${getCookieSuffix()}`;
8376
+ },
8377
+ /** Password-setup session for verified-email signup — temporary, single-purpose */
8378
+ get SIGNUP_SETUP() {
8379
+ return `spfn_signup_setup${getCookieSuffix()}`;
8094
8380
  }
8095
8381
  };
8096
8382
  function matchOAuthCsrfCookies(cookies) {
@@ -8135,7 +8421,7 @@ function getAuthConfig() {
8135
8421
  async function runBeforeRegister(context) {
8136
8422
  const { beforeRegister } = globalConfig;
8137
8423
  if (beforeRegister) {
8138
- await beforeRegister(context);
8424
+ await beforeRegister({ ...context, email: normalizeOptionalEmail(context.email) });
8139
8425
  }
8140
8426
  }
8141
8427
  function getSessionTtl(override) {
@@ -8178,11 +8464,15 @@ var authLogger = {
8178
8464
  };
8179
8465
 
8180
8466
  // src/server/services/verification.service.ts
8467
+ init_email();
8181
8468
  init_repositories();
8182
8469
  var ACCOUNT_EXISTS_NOTICE_DEDUPE_MINUTES = 60;
8183
8470
  var VERIFICATION_TOKEN_EXPIRY = "15m";
8184
8471
  var VERIFICATION_CODE_EXPIRY_MINUTES = 5;
8185
8472
  var MAX_VERIFICATION_ATTEMPTS = 5;
8473
+ function normalizeVerificationTarget(target, targetType) {
8474
+ return targetType === "email" ? normalizeEmail(target) : target.trim();
8475
+ }
8186
8476
  function generateVerificationCode() {
8187
8477
  return crypto4.randomInt(0, 1e6).toString().padStart(6, "0");
8188
8478
  }
@@ -8290,23 +8580,28 @@ async function sendAccountExistsNotice(target, targetType) {
8290
8580
  log.error("Failed to send account-exists notice", { target, error: result.error });
8291
8581
  }
8292
8582
  }
8583
+ async function noticeAccountExistsOnce(target, targetType) {
8584
+ const recentNotice = await verificationCodesRepository.findValidByTargetAndPurpose(target, "registration");
8585
+ if (recentNotice) {
8586
+ return;
8587
+ }
8588
+ const dedupeExpiresAt = new Date(Date.now() + ACCOUNT_EXISTS_NOTICE_DEDUPE_MINUTES * 6e4);
8589
+ await verificationCodesRepository.invalidatePreviousCodes(target, "registration");
8590
+ await verificationCodesRepository.create({
8591
+ target,
8592
+ targetType,
8593
+ code: generateVerificationCode(),
8594
+ purpose: "registration",
8595
+ expiresAt: dedupeExpiresAt,
8596
+ attempts: 0
8597
+ });
8598
+ await sendAccountExistsNotice(target, targetType);
8599
+ }
8293
8600
  async function sendVerificationCodeService(params) {
8294
- const { target, targetType, purpose } = params;
8601
+ const { targetType, purpose } = params;
8602
+ const target = normalizeVerificationTarget(params.target, targetType);
8295
8603
  if (purpose === "registration" && await accountExistsForTarget(target, targetType)) {
8296
- const recentNotice = await verificationCodesRepository.findValidByTargetAndPurpose(target, purpose);
8297
- if (!recentNotice) {
8298
- const dedupeExpiresAt = new Date(Date.now() + ACCOUNT_EXISTS_NOTICE_DEDUPE_MINUTES * 6e4);
8299
- await verificationCodesRepository.invalidatePreviousCodes(target, purpose);
8300
- await verificationCodesRepository.create({
8301
- target,
8302
- targetType,
8303
- code: generateVerificationCode(),
8304
- purpose,
8305
- expiresAt: dedupeExpiresAt,
8306
- attempts: 0
8307
- });
8308
- await sendAccountExistsNotice(target, targetType);
8309
- }
8604
+ await noticeAccountExistsOnce(target, targetType);
8310
8605
  return {
8311
8606
  success: true,
8312
8607
  expiresAt: new Date(Date.now() + VERIFICATION_CODE_EXPIRY_MINUTES * 6e4).toISOString()
@@ -8325,7 +8620,8 @@ async function sendVerificationCodeService(params) {
8325
8620
  };
8326
8621
  }
8327
8622
  async function verifyCodeService(params) {
8328
- const { target, targetType, code, purpose } = params;
8623
+ const { targetType, code, purpose } = params;
8624
+ const target = normalizeVerificationTarget(params.target, targetType);
8329
8625
  const validation = await validateVerificationCode(target, code, purpose);
8330
8626
  if (!validation.valid) {
8331
8627
  throw new InvalidVerificationCodeError({ message: validation.error || "Invalid verification code" });
@@ -8675,8 +8971,8 @@ async function verifyReauthCredential(user, params) {
8675
8971
  throw new VerificationTokenTargetMismatchError();
8676
8972
  }
8677
8973
  }
8678
- async function sendDeletionEmail(to, subject, text13) {
8679
- const result = await sendEmail2({ to, subject, text: text13 });
8974
+ async function sendDeletionEmail(to, subject, text14) {
8975
+ const result = await sendEmail2({ to, subject, text: text14 });
8680
8976
  if (!result.success) {
8681
8977
  authLogger.email.error("Failed to send account deletion email", { to, subject, error: result.error });
8682
8978
  }
@@ -8923,7 +9219,8 @@ async function sweepDuePurges(now = /* @__PURE__ */ new Date()) {
8923
9219
 
8924
9220
  // src/server/services/auth.service.ts
8925
9221
  async function registerService(params) {
8926
- const { email, phone, verificationToken, password, publicKey, keyId, fingerprint, algorithm, metadata } = params;
9222
+ const { email, verificationToken } = params;
9223
+ const phone = params.phone?.trim();
8927
9224
  const tokenPayload = validateVerificationToken(verificationToken);
8928
9225
  if (!tokenPayload) {
8929
9226
  throw new InvalidVerificationTokenError2();
@@ -8931,7 +9228,7 @@ async function registerService(params) {
8931
9228
  if (tokenPayload.purpose !== "registration") {
8932
9229
  throw new VerificationTokenPurposeMismatchError2({ expected: "registration", actual: tokenPayload.purpose });
8933
9230
  }
8934
- const providedTarget = email || phone;
9231
+ const providedTarget = email ? normalizeEmail(email) : phone;
8935
9232
  if (tokenPayload.target !== providedTarget) {
8936
9233
  throw new VerificationTokenTargetMismatchError2();
8937
9234
  }
@@ -8939,6 +9236,13 @@ async function registerService(params) {
8939
9236
  if (tokenPayload.targetType !== providedTargetType) {
8940
9237
  throw new VerificationTokenTargetMismatchError2();
8941
9238
  }
9239
+ return await createVerifiedAccount({ ...params, phone });
9240
+ }
9241
+ async function createVerifiedAccount(params) {
9242
+ const { email, phone, password, publicKey, keyId, fingerprint, algorithm, metadata } = params;
9243
+ if (!publicKey || !keyId || !fingerprint) {
9244
+ throw new ValidationError3({ message: "Device key material is required to register" });
9245
+ }
8942
9246
  const existingUser = await usersRepository.findByEmailOrPhone(email, phone);
8943
9247
  if (existingUser) {
8944
9248
  const identifierType = email ? "email" : "phone";
@@ -9070,6 +9374,158 @@ async function changePasswordService(params) {
9070
9374
  await keysRepository.revokeAllActiveByUserId(userId, "Revoked by password change");
9071
9375
  }
9072
9376
 
9377
+ // src/server/services/signup-link.service.ts
9378
+ import crypto5 from "crypto";
9379
+ import { env as env7 } from "@spfn/auth/config";
9380
+ import { InvalidSignupLinkError, InvalidSignupSetupSessionError } from "@spfn/auth/errors";
9381
+ import { sendEmail as sendEmail3 } from "@spfn/notification/server";
9382
+ init_repositories();
9383
+ var CREDENTIAL_BYTES = 32;
9384
+ function mintCredential() {
9385
+ const secret = crypto5.randomBytes(CREDENTIAL_BYTES).toString("base64url");
9386
+ return { secret, hash: hashCredential(secret) };
9387
+ }
9388
+ function hashCredential(secret) {
9389
+ return crypto5.createHash("sha256").update(secret).digest("base64url");
9390
+ }
9391
+ function isSafeReturnPath(returnPath) {
9392
+ if (!returnPath.startsWith("/")) {
9393
+ return false;
9394
+ }
9395
+ if (returnPath.startsWith("//") || returnPath.includes("\\")) {
9396
+ return false;
9397
+ }
9398
+ if (returnPath.includes("..")) {
9399
+ return false;
9400
+ }
9401
+ return !/^\/[^/?#]*:/.test(returnPath);
9402
+ }
9403
+ function buildConfirmUrl(token) {
9404
+ const appUrl = (env7.NEXT_PUBLIC_SPFN_APP_URL || env7.SPFN_APP_URL || "").replace(/\/$/, "");
9405
+ const path = env7.SPFN_AUTH_SIGNUP_CONFIRM_PATH || "/signup/confirm";
9406
+ return `${appUrl}${path}?token=${encodeURIComponent(token)}`;
9407
+ }
9408
+ async function sendSignupLinkEmail(email, confirmUrl, expiresInMinutes) {
9409
+ const result = await sendEmail3({
9410
+ to: email,
9411
+ template: "signup-link",
9412
+ data: { confirmUrl, expiresInMinutes }
9413
+ });
9414
+ if (!result.success) {
9415
+ authLogger.email.error("Failed to send signup link email", {
9416
+ email,
9417
+ error: result.error
9418
+ });
9419
+ }
9420
+ }
9421
+ async function requestSignupLinkService(params) {
9422
+ const email = params.email.trim();
9423
+ const returnPath = params.returnPath;
9424
+ const ttlMinutes = env7.SPFN_AUTH_SIGNUP_LINK_TTL_MINUTES ?? 30;
9425
+ const expiresAt = new Date(Date.now() + ttlMinutes * 6e4);
9426
+ const existingUser = await usersRepository.findByEmail(email);
9427
+ if (existingUser) {
9428
+ await noticeAccountExistsOnce(email, "email");
9429
+ return { success: true, expiresAt: expiresAt.toISOString() };
9430
+ }
9431
+ await signupLinkTokensRepository.supersedeLiveForEmail(email);
9432
+ const { secret, hash: hash2 } = mintCredential();
9433
+ await signupLinkTokensRepository.create({
9434
+ email,
9435
+ tokenHash: hash2,
9436
+ returnPath: returnPath ?? null,
9437
+ expiresAt
9438
+ });
9439
+ await sendSignupLinkEmail(email, buildConfirmUrl(secret), ttlMinutes);
9440
+ return { success: true, expiresAt: expiresAt.toISOString() };
9441
+ }
9442
+ function linkRefusalReason(row) {
9443
+ if (!row) {
9444
+ return "unknown token";
9445
+ }
9446
+ if (row.completedAt) {
9447
+ return "signup already completed";
9448
+ }
9449
+ if (row.supersededAt) {
9450
+ return "superseded by a newer request";
9451
+ }
9452
+ if (row.consumedAt) {
9453
+ return "link already used";
9454
+ }
9455
+ if (/* @__PURE__ */ new Date() > new Date(row.expiresAt)) {
9456
+ return "link expired";
9457
+ }
9458
+ return null;
9459
+ }
9460
+ async function confirmSignupLinkService(params) {
9461
+ const row = await signupLinkTokensRepository.findByTokenHash(hashCredential(params.token));
9462
+ const refusal = linkRefusalReason(row);
9463
+ if (refusal || !row) {
9464
+ authLogger.service.warn("Signup link refused", { reason: refusal });
9465
+ throw new InvalidSignupLinkError();
9466
+ }
9467
+ if (await usersRepository.findByEmail(row.email)) {
9468
+ authLogger.service.warn("Signup link refused", { reason: "account created meanwhile" });
9469
+ throw new InvalidSignupLinkError();
9470
+ }
9471
+ const setupTtlMinutes = env7.SPFN_AUTH_SIGNUP_SETUP_TTL_MINUTES ?? 15;
9472
+ const setupExpiresAt = new Date(Date.now() + setupTtlMinutes * 6e4);
9473
+ const setup = mintCredential();
9474
+ const claimed = await signupLinkTokensRepository.claimLink(row.id, setup.hash, setupExpiresAt);
9475
+ if (!claimed) {
9476
+ authLogger.service.warn("Signup link refused", { reason: "lost the claim race" });
9477
+ throw new InvalidSignupLinkError();
9478
+ }
9479
+ return {
9480
+ email: claimed.email,
9481
+ returnPath: claimed.returnPath,
9482
+ setupSecret: setup.secret,
9483
+ setupExpiresAt: setupExpiresAt.toISOString()
9484
+ };
9485
+ }
9486
+ function setupRefusalReason(row) {
9487
+ if (!row) {
9488
+ return "unknown setup session";
9489
+ }
9490
+ if (row.completedAt) {
9491
+ return "setup session already used";
9492
+ }
9493
+ if (row.supersededAt) {
9494
+ return "superseded by a newer request";
9495
+ }
9496
+ if (!row.setupExpiresAt || /* @__PURE__ */ new Date() > new Date(row.setupExpiresAt)) {
9497
+ return "setup session expired";
9498
+ }
9499
+ return null;
9500
+ }
9501
+ async function completeSignupService(params) {
9502
+ if (!params.setupSecret) {
9503
+ throw new InvalidSignupSetupSessionError();
9504
+ }
9505
+ const row = await signupLinkTokensRepository.findBySetupSecretHash(hashCredential(params.setupSecret));
9506
+ const refusal = setupRefusalReason(row);
9507
+ if (refusal || !row) {
9508
+ authLogger.service.warn("Signup setup session refused", { reason: refusal });
9509
+ throw new InvalidSignupSetupSessionError();
9510
+ }
9511
+ const claimed = await signupLinkTokensRepository.claimSetupSession(row.id);
9512
+ if (!claimed) {
9513
+ authLogger.service.warn("Signup setup session refused", { reason: "lost the claim race" });
9514
+ throw new InvalidSignupSetupSessionError();
9515
+ }
9516
+ return await createVerifiedAccount({
9517
+ email: claimed.email,
9518
+ password: params.password,
9519
+ publicKey: params.publicKey,
9520
+ keyId: params.keyId,
9521
+ fingerprint: params.fingerprint,
9522
+ algorithm: params.algorithm,
9523
+ deviceName: params.deviceName,
9524
+ platform: params.platform,
9525
+ metadata: params.metadata
9526
+ });
9527
+ }
9528
+
9073
9529
  // src/server/services/rbac.service.ts
9074
9530
  init_repositories();
9075
9531
  init_rbac();
@@ -9210,6 +9666,29 @@ async function syncMappings(allMappings, rolesByName, permsByName) {
9210
9666
  }
9211
9667
  }
9212
9668
 
9669
+ // src/server/services/email-normalization.service.ts
9670
+ init_repositories();
9671
+ var BACKFILL_KEY = "auth:email_normalization";
9672
+ async function normalizeStoredEmails() {
9673
+ if (await authMetadataRepository.get(BACKFILL_KEY)) {
9674
+ return { normalized: 0, conflicts: [] };
9675
+ }
9676
+ const conflicts = await usersRepository.findEmailConflictGroups();
9677
+ const normalized = await usersRepository.normalizeEmailsExcept(conflicts.flat());
9678
+ if (normalized > 0) {
9679
+ authLogger.service.info(`\u2709\uFE0F Normalized ${normalized} stored email address(es)`);
9680
+ }
9681
+ if (conflicts.length > 0) {
9682
+ authLogger.service.error(
9683
+ `${conflicts.length} email group(s) differ only by capitalization and cannot be normalized automatically. The accounts are untouched and the ones stored in mixed case cannot sign in until this is resolved.`,
9684
+ { conflictingUserIds: conflicts }
9685
+ );
9686
+ return { normalized, conflicts };
9687
+ }
9688
+ await authMetadataRepository.set(BACKFILL_KEY, "done");
9689
+ return { normalized, conflicts };
9690
+ }
9691
+
9213
9692
  // src/server/services/permission.service.ts
9214
9693
  init_repositories();
9215
9694
  import { ForbiddenError } from "@spfn/core/errors";
@@ -9224,7 +9703,7 @@ async function getUserPermissions(userId) {
9224
9703
  const permIds = rolePermMappings.map((rp) => rp.permissionId);
9225
9704
  if (permIds.length > 0) {
9226
9705
  const rolePerms = await Promise.all(
9227
- permIds.map((id13) => permissionsRepository.findById(id13))
9706
+ permIds.map((id14) => permissionsRepository.findById(id14))
9228
9707
  );
9229
9708
  for (const perm of rolePerms) {
9230
9709
  if (perm && perm.isActive) {
@@ -9299,10 +9778,10 @@ init_role_service();
9299
9778
 
9300
9779
  // src/server/services/invitation.service.ts
9301
9780
  init_repositories();
9302
- import crypto5 from "crypto";
9781
+ import crypto6 from "crypto";
9303
9782
  import { BadRequestError, NotFoundError as NotFoundError3, ConflictError } from "@spfn/core/errors";
9304
9783
  function generateInvitationToken() {
9305
- return crypto5.randomUUID();
9784
+ return crypto6.randomUUID();
9306
9785
  }
9307
9786
  function calculateExpiresAt(days = 7) {
9308
9787
  const expiresAt = /* @__PURE__ */ new Date();
@@ -9437,20 +9916,20 @@ async function acceptInvitation(params) {
9437
9916
  async function listInvitations(params) {
9438
9917
  return await invitationsRepository.list(params);
9439
9918
  }
9440
- async function cancelInvitation(id13, cancelledBy, reason) {
9441
- const invitation = await invitationsRepository.findById(id13);
9919
+ async function cancelInvitation(id14, cancelledBy, reason) {
9920
+ const invitation = await invitationsRepository.findById(id14);
9442
9921
  if (!invitation) {
9443
9922
  throw new NotFoundError3({ message: "Invitation not found", resource: "Invitation" });
9444
9923
  }
9445
9924
  if (invitation.status !== "pending") {
9446
9925
  throw new ConflictError({ message: `Cannot cancel ${invitation.status} invitation` });
9447
9926
  }
9448
- await invitationsRepository.cancel(id13, cancelledBy, reason, invitation.metadata);
9927
+ await invitationsRepository.cancel(id14, cancelledBy, reason, invitation.metadata);
9449
9928
  console.log(`[Auth] \u26A0\uFE0F Invitation cancelled: ${invitation.email} (reason: ${reason || "none"})`);
9450
9929
  }
9451
- async function deleteInvitation(id13) {
9452
- await invitationsRepository.deleteById(id13);
9453
- console.log(`[Auth] \u{1F5D1}\uFE0F Invitation deleted: ${id13}`);
9930
+ async function deleteInvitation(id14) {
9931
+ await invitationsRepository.deleteById(id14);
9932
+ console.log(`[Auth] \u{1F5D1}\uFE0F Invitation deleted: ${id14}`);
9454
9933
  }
9455
9934
  async function expireOldInvitations() {
9456
9935
  const count = await invitationsRepository.updateExpiredInvitations();
@@ -9459,8 +9938,8 @@ async function expireOldInvitations() {
9459
9938
  }
9460
9939
  return count;
9461
9940
  }
9462
- async function resendInvitation(id13, expiresInDays = 7) {
9463
- const invitation = await invitationsRepository.findById(id13);
9941
+ async function resendInvitation(id14, expiresInDays = 7) {
9942
+ const invitation = await invitationsRepository.findById(id14);
9464
9943
  if (!invitation) {
9465
9944
  throw new NotFoundError3({ message: "Invitation not found", resource: "Invitation" });
9466
9945
  }
@@ -9468,7 +9947,7 @@ async function resendInvitation(id13, expiresInDays = 7) {
9468
9947
  throw new ConflictError({ message: `Cannot resend ${invitation.status} invitation` });
9469
9948
  }
9470
9949
  const newExpiresAt = calculateExpiresAt(expiresInDays);
9471
- const updated = await invitationsRepository.resend(id13, newExpiresAt);
9950
+ const updated = await invitationsRepository.resend(id14, newExpiresAt);
9472
9951
  if (!updated) {
9473
9952
  throw new Error("Failed to update invitation");
9474
9953
  }
@@ -9622,7 +10101,7 @@ async function updateUserProfileService(userId, params) {
9622
10101
 
9623
10102
  // src/server/services/oauth.service.ts
9624
10103
  init_repositories();
9625
- import { env as env11 } from "@spfn/auth/config";
10104
+ import { env as env12 } from "@spfn/auth/config";
9626
10105
  import { ValidationError as ValidationError8 } from "@spfn/core/errors";
9627
10106
  import {
9628
10107
  AccountDisabledError as AccountDisabledError2,
@@ -9631,21 +10110,21 @@ import {
9631
10110
  } from "@spfn/auth/errors";
9632
10111
 
9633
10112
  // src/server/lib/oauth/google.ts
9634
- import { env as env7 } from "@spfn/auth/config";
10113
+ import { env as env8 } from "@spfn/auth/config";
9635
10114
  var GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
9636
10115
  var GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
9637
10116
  var GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo";
9638
10117
  function isGoogleOAuthEnabled() {
9639
- return !!(env7.SPFN_AUTH_GOOGLE_CLIENT_ID && env7.SPFN_AUTH_GOOGLE_CLIENT_SECRET);
10118
+ return !!(env8.SPFN_AUTH_GOOGLE_CLIENT_ID && env8.SPFN_AUTH_GOOGLE_CLIENT_SECRET);
9640
10119
  }
9641
10120
  function getGoogleOAuthConfig() {
9642
- const clientId = env7.SPFN_AUTH_GOOGLE_CLIENT_ID;
9643
- const clientSecret = env7.SPFN_AUTH_GOOGLE_CLIENT_SECRET;
10121
+ const clientId = env8.SPFN_AUTH_GOOGLE_CLIENT_ID;
10122
+ const clientSecret = env8.SPFN_AUTH_GOOGLE_CLIENT_SECRET;
9644
10123
  if (!clientId || !clientSecret) {
9645
10124
  throw new Error("Google OAuth is not configured. Set SPFN_AUTH_GOOGLE_CLIENT_ID and SPFN_AUTH_GOOGLE_CLIENT_SECRET.");
9646
10125
  }
9647
- const baseUrl = env7.NEXT_PUBLIC_SPFN_APP_URL || env7.SPFN_APP_URL;
9648
- const redirectUri = env7.SPFN_AUTH_GOOGLE_REDIRECT_URI || `${baseUrl}/_auth/oauth/google/callback`;
10126
+ const baseUrl = env8.NEXT_PUBLIC_SPFN_APP_URL || env8.SPFN_APP_URL;
10127
+ const redirectUri = env8.SPFN_AUTH_GOOGLE_REDIRECT_URI || `${baseUrl}/_auth/oauth/google/callback`;
9649
10128
  return {
9650
10129
  clientId,
9651
10130
  clientSecret,
@@ -9653,7 +10132,7 @@ function getGoogleOAuthConfig() {
9653
10132
  };
9654
10133
  }
9655
10134
  function getDefaultScopes() {
9656
- const envScopes = env7.SPFN_AUTH_GOOGLE_SCOPES;
10135
+ const envScopes = env8.SPFN_AUTH_GOOGLE_SCOPES;
9657
10136
  if (envScopes) {
9658
10137
  return envScopes.split(",").map((s) => s.trim()).filter(Boolean);
9659
10138
  }
@@ -9731,9 +10210,9 @@ async function refreshAccessToken(refreshToken) {
9731
10210
 
9732
10211
  // src/server/lib/oauth/state.ts
9733
10212
  import * as jose from "jose";
9734
- import { env as env8 } from "@spfn/auth/config";
10213
+ import { env as env9 } from "@spfn/auth/config";
9735
10214
  async function getStateKey() {
9736
- const secret = env8.SPFN_AUTH_SESSION_SECRET;
10215
+ const secret = env9.SPFN_AUTH_SESSION_SECRET;
9737
10216
  const encoder = new TextEncoder();
9738
10217
  const data = encoder.encode(`oauth-state:${secret}`);
9739
10218
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
@@ -9782,8 +10261,8 @@ var registry2 = /* @__PURE__ */ new Map();
9782
10261
  function registerOAuthProvider(provider) {
9783
10262
  registry2.set(provider.id, provider);
9784
10263
  }
9785
- function getOAuthProvider(id13) {
9786
- return registry2.get(id13);
10264
+ function getOAuthProvider(id14) {
10265
+ return registry2.get(id14);
9787
10266
  }
9788
10267
  function getRegisteredProviders() {
9789
10268
  return [...registry2.values()];
@@ -9836,14 +10315,14 @@ async function verifySignature(params) {
9836
10315
  init_token_cipher();
9837
10316
 
9838
10317
  // src/server/lib/oauth/google-provider.ts
9839
- import { env as env9 } from "@spfn/auth/config";
10318
+ import { env as env10 } from "@spfn/auth/config";
9840
10319
  import { NativeSignInUnsupportedError } from "@spfn/auth/errors";
9841
10320
  var GOOGLE_JWKS_URI = "https://www.googleapis.com/oauth2/v3/certs";
9842
10321
  var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
9843
10322
  function getGoogleNativeAudiences() {
9844
- const ids = (env9.SPFN_AUTH_GOOGLE_NATIVE_CLIENT_IDS || "").split(",").map((s) => s.trim()).filter(Boolean);
9845
- if (env9.SPFN_AUTH_GOOGLE_CLIENT_ID) {
9846
- ids.push(env9.SPFN_AUTH_GOOGLE_CLIENT_ID);
10323
+ const ids = (env10.SPFN_AUTH_GOOGLE_NATIVE_CLIENT_IDS || "").split(",").map((s) => s.trim()).filter(Boolean);
10324
+ if (env10.SPFN_AUTH_GOOGLE_CLIENT_ID) {
10325
+ ids.push(env10.SPFN_AUTH_GOOGLE_CLIENT_ID);
9847
10326
  }
9848
10327
  return ids;
9849
10328
  }
@@ -9905,13 +10384,13 @@ registerOAuthProvider(googleProvider);
9905
10384
 
9906
10385
  // src/server/lib/oauth/apple-provider.ts
9907
10386
  import { createHash as createHash2 } from "crypto";
9908
- import { env as env10 } from "@spfn/auth/config";
10387
+ import { env as env11 } from "@spfn/auth/config";
9909
10388
  import { ValidationError as ValidationError4 } from "@spfn/core/errors";
9910
10389
  import { NativeSignInUnsupportedError as NativeSignInUnsupportedError2 } from "@spfn/auth/errors";
9911
10390
  var APPLE_JWKS_URI = "https://appleid.apple.com/auth/keys";
9912
10391
  var APPLE_ISSUER = "https://appleid.apple.com";
9913
10392
  function getAppleClientIds() {
9914
- return (env10.SPFN_AUTH_APPLE_CLIENT_IDS || "").split(",").map((s) => s.trim()).filter(Boolean);
10393
+ return (env11.SPFN_AUTH_APPLE_CLIENT_IDS || "").split(",").map((s) => s.trim()).filter(Boolean);
9915
10394
  }
9916
10395
  function hashNonce(rawNonce) {
9917
10396
  return createHash2("sha256").update(rawNonce).digest("hex");
@@ -10624,8 +11103,8 @@ async function oauthCallbackService(params) {
10624
11103
  algorithm: stateData.algorithm
10625
11104
  });
10626
11105
  await updateLastLoginService(userId);
10627
- const appUrl = env11.NEXT_PUBLIC_SPFN_APP_URL || env11.SPFN_APP_URL;
10628
- const callbackPath = env11.SPFN_AUTH_OAUTH_SUCCESS_URL || "/auth/callback";
11106
+ const appUrl = env12.NEXT_PUBLIC_SPFN_APP_URL || env12.SPFN_APP_URL;
11107
+ const callbackPath = env12.SPFN_AUTH_OAUTH_SUCCESS_URL || "/auth/callback";
10629
11108
  const callbackUrl = callbackPath.startsWith("http") ? callbackPath : `${appUrl}${callbackPath}`;
10630
11109
  const redirectUrl = buildRedirectUrl(callbackUrl, {
10631
11110
  userId: String(userId),
@@ -10753,7 +11232,7 @@ function buildRedirectUrl(baseUrl, params) {
10753
11232
  return `${url.pathname}${url.search}`;
10754
11233
  }
10755
11234
  function buildOAuthErrorUrl(error) {
10756
- const errorUrl = env11.SPFN_AUTH_OAUTH_ERROR_URL || "/auth/error?error={error}";
11235
+ const errorUrl = env12.SPFN_AUTH_OAUTH_ERROR_URL || "/auth/error?error={error}";
10757
11236
  return errorUrl.replace("{error}", encodeURIComponent(error));
10758
11237
  }
10759
11238
  function isOAuthProviderEnabled(provider) {
@@ -10919,8 +11398,8 @@ async function verifyOpsTokenService(token) {
10919
11398
  scopes: record.scopes
10920
11399
  };
10921
11400
  }
10922
- async function revokeOpsTokenService(id13) {
10923
- return await opsTokensRepository.revokeById(id13);
11401
+ async function revokeOpsTokenService(id14) {
11402
+ return await opsTokensRepository.revokeById(id14);
10924
11403
  }
10925
11404
  async function listOpsTokensService() {
10926
11405
  return await opsTokensRepository.list();
@@ -10932,6 +11411,7 @@ import { Transactional } from "@spfn/core/db";
10932
11411
  import { rateLimitPolicy } from "@spfn/core/middleware";
10933
11412
 
10934
11413
  // src/server/lib/rate-limit-keys.ts
11414
+ init_email();
10935
11415
  import { createHash as createHash5 } from "crypto";
10936
11416
  import { getClientIp } from "@spfn/core/middleware";
10937
11417
  async function readJsonBody(c) {
@@ -10943,7 +11423,7 @@ async function readJsonBody(c) {
10943
11423
  }
10944
11424
  function accountKey(body) {
10945
11425
  if (typeof body.email === "string" && body.email.trim()) {
10946
- return `email:${body.email.trim().toLowerCase()}`;
11426
+ return `email:${normalizeEmail(body.email)}`;
10947
11427
  }
10948
11428
  if (typeof body.phone === "string" && body.phone.trim()) {
10949
11429
  return `phone:${body.phone.trim()}`;
@@ -10955,7 +11435,7 @@ function targetKey(body) {
10955
11435
  return void 0;
10956
11436
  }
10957
11437
  const type = typeof body.targetType === "string" ? body.targetType : "target";
10958
- const value = type === "email" ? body.target.trim().toLowerCase() : body.target.trim();
11438
+ const value = type === "email" ? normalizeEmail(body.target) : body.target.trim();
10959
11439
  return `${type}:${value}`;
10960
11440
  }
10961
11441
  function byIpAndAccount(options = {}) {
@@ -11055,6 +11535,54 @@ var register = route.post("/_auth/register").input({
11055
11535
  const { body } = await c.data();
11056
11536
  return await registerService(body);
11057
11537
  });
11538
+ var requestSignupLink = route.post("/_auth/signup/email").input({
11539
+ body: Type.Object({
11540
+ email: EmailSchema,
11541
+ returnPath: Type.Optional(Type.String({
11542
+ maxLength: 512,
11543
+ description: "Relative path within the app to return to after signup. Absolute URLs are rejected."
11544
+ }))
11545
+ })
11546
+ }).use([rateLimitPolicy("auth-signup-link", { limit: 5, windowMs: 6e4, by: byIpAndAccount({ ipLimit: 20 }) })]).skip(["auth"]).handler(async (c) => {
11547
+ const { body } = await c.data();
11548
+ if (body.returnPath !== void 0 && !isSafeReturnPath(body.returnPath)) {
11549
+ throw new ValidationError9({ message: "returnPath must be a relative path within the app" });
11550
+ }
11551
+ return await requestSignupLinkService(body);
11552
+ });
11553
+ var confirmSignupLink = route.post("/_auth/signup/email/confirm").input({
11554
+ body: Type.Object({
11555
+ token: Type.String({
11556
+ minLength: 16,
11557
+ maxLength: 256,
11558
+ description: "Token from the confirmation link"
11559
+ })
11560
+ })
11561
+ }).use([rateLimitPolicy("auth-signup-confirm", { limit: 10, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
11562
+ const { body } = await c.data();
11563
+ return await confirmSignupLinkService(body);
11564
+ });
11565
+ var completeSignup = route.post("/_auth/signup/password").input({
11566
+ body: Type.Object({
11567
+ password: PasswordSchema,
11568
+ metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
11569
+ description: "Custom metadata passed to authRegisterEvent (e.g. referral code, UTM params)"
11570
+ }))
11571
+ })
11572
+ }).interceptor({
11573
+ body: Type.Object({
11574
+ setupSecret: Type.String({ description: "Password-setup session secret, from the HttpOnly cookie" }),
11575
+ publicKey: Type.String({ description: "Client public key" }),
11576
+ keyId: Type.String({ description: "Key identifier" }),
11577
+ fingerprint: Type.String({ description: "Key fingerprint" }),
11578
+ algorithm: Type.Union(KEY_ALGORITHM.map((algo) => Type.Literal(algo)), { description: "Signature algorithm" }),
11579
+ deviceName: Type.Optional(DeviceNameSchema),
11580
+ platform: Type.Optional(PlatformSchema)
11581
+ })
11582
+ }).use([rateLimitPolicy("auth-signup-password", { limit: 10, windowMs: 6e4 }), Transactional()]).skip(["auth"]).handler(async (c) => {
11583
+ const { body } = await c.data();
11584
+ return await completeSignupService(body);
11585
+ });
11058
11586
  var login = route.post("/_auth/login").input({
11059
11587
  body: Type.Object({
11060
11588
  email: Type.Optional(EmailSchema),
@@ -11227,13 +11755,13 @@ var CanonicalJsonError = class extends Error {
11227
11755
  var INT64_MIN = -(2n ** 63n);
11228
11756
  var INT64_MAX = 2n ** 63n - 1n;
11229
11757
  function parseCanonicalJson(bytes) {
11230
- let text13;
11758
+ let text14;
11231
11759
  try {
11232
- text13 = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
11760
+ text14 = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
11233
11761
  } catch {
11234
11762
  throw new CanonicalJsonError("INVALID_UTF8");
11235
11763
  }
11236
- const parser = new Parser(text13);
11764
+ const parser = new Parser(text14);
11237
11765
  const value = parser.parseValue();
11238
11766
  parser.skipWhitespace();
11239
11767
  if (!parser.atEnd()) {
@@ -11254,8 +11782,8 @@ function isCanonicalBytes(bytes, value) {
11254
11782
  return true;
11255
11783
  }
11256
11784
  var Parser = class {
11257
- constructor(text13) {
11258
- this.text = text13;
11785
+ constructor(text14) {
11786
+ this.text = text14;
11259
11787
  }
11260
11788
  pos = 0;
11261
11789
  atEnd() {
@@ -13168,7 +13696,7 @@ var deleteCookie = (c, name, opt) => {
13168
13696
  init_types();
13169
13697
  init_schema3();
13170
13698
  import { Transactional as Transactional3 } from "@spfn/core/db";
13171
- import { ValidationError as ValidationError9 } from "@spfn/core/errors";
13699
+ import { ValidationError as ValidationError10 } from "@spfn/core/errors";
13172
13700
  import { rateLimitPolicy as rateLimitPolicy4 } from "@spfn/core/middleware";
13173
13701
  import { defineRouter as defineRouter4, route as route4 } from "@spfn/core/route";
13174
13702
  var providerParams = Type.Object({
@@ -13288,10 +13816,10 @@ var getGoogleOAuthUrl = route4.post("/_auth/oauth/google/url").input({
13288
13816
  }).use([rateLimitPolicy4("oauth-start", { limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
13289
13817
  const { body } = await c.data();
13290
13818
  if (!isGoogleOAuthEnabled()) {
13291
- throw new ValidationError9({ message: "Google OAuth is not configured" });
13819
+ throw new ValidationError10({ message: "Google OAuth is not configured" });
13292
13820
  }
13293
13821
  if (!body.state) {
13294
- throw new ValidationError9({
13822
+ throw new ValidationError10({
13295
13823
  message: "OAuth state is required. Ensure the OAuth interceptor is configured."
13296
13824
  });
13297
13825
  }
@@ -13386,7 +13914,7 @@ var getProviderOAuthUrl = route4.post("/_auth/oauth/:provider/url").input({
13386
13914
  const { params, body } = await c.data();
13387
13915
  const provider = requireEnabledProvider(params.provider);
13388
13916
  if (!body.state) {
13389
- throw new ValidationError9({
13917
+ throw new ValidationError10({
13390
13918
  message: "OAuth state is required. Ensure the OAuth interceptor is configured."
13391
13919
  });
13392
13920
  }
@@ -13687,6 +14215,10 @@ var mainAuthRouter = defineRouter6({
13687
14215
  sendVerificationCode,
13688
14216
  verifyCode,
13689
14217
  register,
14218
+ // Verified-email signup routes
14219
+ requestSignupLink,
14220
+ confirmSignupLink,
14221
+ completeSignup,
13690
14222
  login,
13691
14223
  logout,
13692
14224
  rotateKey,
@@ -13746,11 +14278,11 @@ init_types();
13746
14278
  init_schema3();
13747
14279
 
13748
14280
  // src/server/lib/crypto.ts
13749
- import crypto6 from "crypto";
14281
+ import crypto7 from "crypto";
13750
14282
  import jwt3 from "jsonwebtoken";
13751
14283
  function generateKeyPairES256() {
13752
- const keyId = crypto6.randomUUID();
13753
- const { privateKey, publicKey } = crypto6.generateKeyPairSync("ec", {
14284
+ const keyId = crypto7.randomUUID();
14285
+ const { privateKey, publicKey } = crypto7.generateKeyPairSync("ec", {
13754
14286
  namedCurve: "P-256",
13755
14287
  // ES256
13756
14288
  publicKeyEncoding: {
@@ -13764,7 +14296,7 @@ function generateKeyPairES256() {
13764
14296
  });
13765
14297
  const privateKeyB64 = privateKey.toString("base64");
13766
14298
  const publicKeyB64 = publicKey.toString("base64");
13767
- const fingerprint = crypto6.createHash("sha256").update(publicKey).digest("hex");
14299
+ const fingerprint = crypto7.createHash("sha256").update(publicKey).digest("hex");
13768
14300
  return {
13769
14301
  privateKey: privateKeyB64,
13770
14302
  publicKey: publicKeyB64,
@@ -13774,8 +14306,8 @@ function generateKeyPairES256() {
13774
14306
  };
13775
14307
  }
13776
14308
  function generateKeyPairRS256() {
13777
- const keyId = crypto6.randomUUID();
13778
- const { privateKey, publicKey } = crypto6.generateKeyPairSync("rsa", {
14309
+ const keyId = crypto7.randomUUID();
14310
+ const { privateKey, publicKey } = crypto7.generateKeyPairSync("rsa", {
13779
14311
  modulusLength: 2048,
13780
14312
  publicKeyEncoding: {
13781
14313
  type: "spki",
@@ -13788,7 +14320,7 @@ function generateKeyPairRS256() {
13788
14320
  });
13789
14321
  const privateKeyB64 = privateKey.toString("base64");
13790
14322
  const publicKeyB64 = publicKey.toString("base64");
13791
- const fingerprint = crypto6.createHash("sha256").update(publicKey).digest("hex");
14323
+ const fingerprint = crypto7.createHash("sha256").update(publicKey).digest("hex");
13792
14324
  return {
13793
14325
  privateKey: privateKeyB64,
13794
14326
  publicKey: publicKeyB64,
@@ -13803,7 +14335,7 @@ function generateKeyPair(algorithm = "ES256") {
13803
14335
  function generateClientToken(payload, privateKeyB64, algorithm, options) {
13804
14336
  try {
13805
14337
  const privateKeyDER = Buffer.from(privateKeyB64, "base64");
13806
- const privateKeyObject = crypto6.createPrivateKey({
14338
+ const privateKeyObject = crypto7.createPrivateKey({
13807
14339
  key: privateKeyDER,
13808
14340
  format: "der",
13809
14341
  type: "pkcs8"
@@ -13847,10 +14379,10 @@ function shouldRotateKey(createdAt, rotationDays = 90) {
13847
14379
 
13848
14380
  // src/server/lib/session.ts
13849
14381
  import * as jose2 from "jose";
13850
- import { env as env12 } from "@spfn/auth/config";
14382
+ import { env as env13 } from "@spfn/auth/config";
13851
14383
  import { env as coreEnv } from "@spfn/core/config";
13852
14384
  async function getSessionSecretKey() {
13853
- const secret = env12.SPFN_AUTH_SESSION_SECRET;
14385
+ const secret = env13.SPFN_AUTH_SESSION_SECRET;
13854
14386
  const encoder = new TextEncoder();
13855
14387
  const data = encoder.encode(secret);
13856
14388
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
@@ -13932,14 +14464,14 @@ async function shouldRefreshSession(jwt4, thresholdHours = 24) {
13932
14464
  }
13933
14465
 
13934
14466
  // src/server/setup.ts
13935
- import { env as env13 } from "@spfn/auth/config";
14467
+ import { env as env14 } from "@spfn/auth/config";
13936
14468
  import { getRoleByName as getRoleByName2 } from "@spfn/auth/server";
13937
14469
  init_repositories();
13938
14470
  function parseAdminAccounts() {
13939
14471
  const accounts = [];
13940
- if (env13.SPFN_AUTH_ADMIN_ACCOUNTS) {
14472
+ if (env14.SPFN_AUTH_ADMIN_ACCOUNTS) {
13941
14473
  try {
13942
- const accountsJson = env13.SPFN_AUTH_ADMIN_ACCOUNTS;
14474
+ const accountsJson = env14.SPFN_AUTH_ADMIN_ACCOUNTS;
13943
14475
  const parsed = JSON.parse(accountsJson);
13944
14476
  if (!Array.isArray(parsed)) {
13945
14477
  authLogger.setup.error("\u274C SPFN_AUTH_ADMIN_ACCOUNTS must be an array");
@@ -13966,11 +14498,11 @@ function parseAdminAccounts() {
13966
14498
  return accounts;
13967
14499
  }
13968
14500
  }
13969
- const adminEmails = env13.SPFN_AUTH_ADMIN_EMAILS;
14501
+ const adminEmails = env14.SPFN_AUTH_ADMIN_EMAILS;
13970
14502
  if (adminEmails) {
13971
14503
  const emails = adminEmails.split(",").map((s) => s.trim());
13972
- const passwords = (env13.SPFN_AUTH_ADMIN_PASSWORDS || "").split(",").map((s) => s.trim());
13973
- const roles2 = (env13.SPFN_AUTH_ADMIN_ROLES || "").split(",").map((s) => s.trim());
14504
+ const passwords = (env14.SPFN_AUTH_ADMIN_PASSWORDS || "").split(",").map((s) => s.trim());
14505
+ const roles2 = (env14.SPFN_AUTH_ADMIN_ROLES || "").split(",").map((s) => s.trim());
13974
14506
  if (passwords.length !== emails.length) {
13975
14507
  authLogger.setup.error("\u274C SPFN_AUTH_ADMIN_EMAILS and SPFN_AUTH_ADMIN_PASSWORDS length mismatch");
13976
14508
  return accounts;
@@ -13992,8 +14524,8 @@ function parseAdminAccounts() {
13992
14524
  }
13993
14525
  return accounts;
13994
14526
  }
13995
- const adminEmail = env13.SPFN_AUTH_ADMIN_EMAIL;
13996
- const adminPassword = env13.SPFN_AUTH_ADMIN_PASSWORD;
14527
+ const adminEmail = env14.SPFN_AUTH_ADMIN_EMAIL;
14528
+ const adminPassword = env14.SPFN_AUTH_ADMIN_PASSWORD;
13997
14529
  if (adminEmail && adminPassword) {
13998
14530
  accounts.push({
13999
14531
  email: adminEmail,
@@ -14016,7 +14548,7 @@ async function ensureAdminExists() {
14016
14548
  for (const account of accounts) {
14017
14549
  authLogger.setup.info(`Creating ${account.email} admin account(s)...`);
14018
14550
  try {
14019
- const existing = await usersRepository.findByEmail(account.email);
14551
+ const existing = await usersRepository.findByEmailInAnyStoredForm(account.email);
14020
14552
  if (existing) {
14021
14553
  authLogger.setup.info(`\u26A0\uFE0F Account already exists: ${account.email} (skipped)`);
14022
14554
  skipped++;
@@ -14068,6 +14600,14 @@ function createAuthLifecycle(options = {}) {
14068
14600
  */
14069
14601
  afterInfrastructure: async () => {
14070
14602
  await initializeAuth(options);
14603
+ try {
14604
+ await normalizeStoredEmails();
14605
+ } catch (error) {
14606
+ authLogger.service.error(
14607
+ "Stored email normalization did not complete. Addresses stay as they are, so an account stored in mixed case cannot sign in until a later boot succeeds.",
14608
+ { error }
14609
+ );
14610
+ }
14071
14611
  await ensureAdminExists();
14072
14612
  initOneTimeTokenManager(options.oneTimeToken);
14073
14613
  }
@@ -14121,6 +14661,7 @@ export {
14121
14661
  RolePermissionsRepository,
14122
14662
  RolesRepository,
14123
14663
  SOCIAL_PROVIDERS,
14664
+ SignupLinkTokensRepository,
14124
14665
  SocialAccountsRepository,
14125
14666
  TargetTypeSchema,
14126
14667
  USER_STATUSES,
@@ -14155,9 +14696,11 @@ export {
14155
14696
  cancelInvitation,
14156
14697
  changePasswordService,
14157
14698
  checkUsernameAvailableService,
14699
+ completeSignupService,
14158
14700
  configureAuth,
14159
14701
  configureDeletion,
14160
14702
  configureOAuthTokenCipher,
14703
+ confirmSignupLinkService,
14161
14704
  createAuthDeletionJobRouter,
14162
14705
  createAuthDeletionPurgeJob,
14163
14706
  createAuthLifecycle,
@@ -14228,6 +14771,7 @@ export {
14228
14771
  isEncrypted,
14229
14772
  isGoogleOAuthEnabled,
14230
14773
  isOAuthProviderEnabled,
14774
+ isSafeReturnPath,
14231
14775
  issueOneTimeTokenService,
14232
14776
  issueOpsTokenService,
14233
14777
  kakaoProvider,
@@ -14239,6 +14783,9 @@ export {
14239
14783
  logoutService,
14240
14784
  matchOAuthCsrfCookies,
14241
14785
  naverProvider,
14786
+ normalizeEmail,
14787
+ normalizeOptionalEmail,
14788
+ normalizeStoredEmails,
14242
14789
  oauthCallbackService,
14243
14790
  oauthNativeService,
14244
14791
  oauthStartService,
@@ -14259,6 +14806,7 @@ export {
14259
14806
  registerService,
14260
14807
  removePermissionFromRole,
14261
14808
  requestAccountDeletionService,
14809
+ requestSignupLinkService,
14262
14810
  requireAnyPermission,
14263
14811
  requireEnabledProvider,
14264
14812
  requireOpsScope,
@@ -14283,6 +14831,8 @@ export {
14283
14831
  setRolePermissions,
14284
14832
  shouldRefreshSession,
14285
14833
  shouldRotateKey,
14834
+ signupLinkTokens,
14835
+ signupLinkTokensRepository,
14286
14836
  socialAccountsRepository,
14287
14837
  sweepDuePurges,
14288
14838
  unsealSession,