@spfn/auth 0.2.0-beta.74 → 0.2.0-beta.80

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(index11) {
901
- return CreateType({ [Kind]: "Argument", index: index11 });
900
+ function Argument(index12) {
901
+ return CreateType({ [Kind]: "Argument", index: index12 });
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, index11, char) {
1140
- return pattern[index11] === char && pattern.charCodeAt(index11 - 1) !== 92;
1139
+ function IsNonEscaped(pattern, index12, char) {
1140
+ return pattern[index12] === char && pattern.charCodeAt(index12 - 1) !== 92;
1141
1141
  }
1142
- function IsOpenParen(pattern, index11) {
1143
- return IsNonEscaped(pattern, index11, "(");
1142
+ function IsOpenParen(pattern, index12) {
1143
+ return IsNonEscaped(pattern, index12, "(");
1144
1144
  }
1145
- function IsCloseParen(pattern, index11) {
1146
- return IsNonEscaped(pattern, index11, ")");
1145
+ function IsCloseParen(pattern, index12) {
1146
+ return IsNonEscaped(pattern, index12, ")");
1147
1147
  }
1148
- function IsSeparator(pattern, index11) {
1149
- return IsNonEscaped(pattern, index11, "|");
1148
+ function IsSeparator(pattern, index12) {
1149
+ return IsNonEscaped(pattern, index12, "|");
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 index11 = 0; index11 < pattern.length; index11++) {
1156
- if (IsOpenParen(pattern, index11))
1155
+ for (let index12 = 0; index12 < pattern.length; index12++) {
1156
+ if (IsOpenParen(pattern, index12))
1157
1157
  count += 1;
1158
- if (IsCloseParen(pattern, index11))
1158
+ if (IsCloseParen(pattern, index12))
1159
1159
  count -= 1;
1160
- if (count === 0 && index11 !== pattern.length - 1)
1160
+ if (count === 0 && index12 !== 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 index11 = 0; index11 < pattern.length; index11++) {
1171
- if (IsOpenParen(pattern, index11))
1170
+ for (let index12 = 0; index12 < pattern.length; index12++) {
1171
+ if (IsOpenParen(pattern, index12))
1172
1172
  count += 1;
1173
- if (IsCloseParen(pattern, index11))
1173
+ if (IsCloseParen(pattern, index12))
1174
1174
  count -= 1;
1175
- if (IsSeparator(pattern, index11) && count === 0)
1175
+ if (IsSeparator(pattern, index12) && count === 0)
1176
1176
  return true;
1177
1177
  }
1178
1178
  return false;
1179
1179
  }
1180
1180
  function IsPrecedenceAnd(pattern) {
1181
- for (let index11 = 0; index11 < pattern.length; index11++) {
1182
- if (IsOpenParen(pattern, index11))
1181
+ for (let index12 = 0; index12 < pattern.length; index12++) {
1182
+ if (IsOpenParen(pattern, index12))
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 index11 = 0; index11 < pattern.length; index11++) {
1191
- if (IsOpenParen(pattern, index11))
1190
+ for (let index12 = 0; index12 < pattern.length; index12++) {
1191
+ if (IsOpenParen(pattern, index12))
1192
1192
  count += 1;
1193
- if (IsCloseParen(pattern, index11))
1193
+ if (IsCloseParen(pattern, index12))
1194
1194
  count -= 1;
1195
- if (IsSeparator(pattern, index11) && count === 0) {
1196
- const range2 = pattern.slice(start, index11);
1195
+ if (IsSeparator(pattern, index12) && count === 0) {
1196
+ const range2 = pattern.slice(start, index12);
1197
1197
  if (range2.length > 0)
1198
1198
  expressions.push(TemplateLiteralParse(range2));
1199
- start = index11 + 1;
1199
+ start = index12 + 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, index11) {
1213
- if (!IsOpenParen(value, index11))
1212
+ function Group(value, index12) {
1213
+ if (!IsOpenParen(value, index12))
1214
1214
  throw new TemplateLiteralParserError(`TemplateLiteralParser: Index must point to open parens`);
1215
1215
  let count = 0;
1216
- for (let scan = index11; scan < value.length; scan++) {
1216
+ for (let scan = index12; 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 [index11, scan];
1222
+ return [index12, scan];
1223
1223
  }
1224
1224
  throw new TemplateLiteralParserError(`TemplateLiteralParser: Unclosed group parens in expression`);
1225
1225
  }
1226
- function Range(pattern2, index11) {
1227
- for (let scan = index11; scan < pattern2.length; scan++) {
1226
+ function Range(pattern2, index12) {
1227
+ for (let scan = index12; scan < pattern2.length; scan++) {
1228
1228
  if (IsOpenParen(pattern2, scan))
1229
- return [index11, scan];
1229
+ return [index12, scan];
1230
1230
  }
1231
- return [index11, pattern2.length];
1231
+ return [index12, pattern2.length];
1232
1232
  }
1233
1233
  const expressions = [];
1234
- for (let index11 = 0; index11 < pattern.length; index11++) {
1235
- if (IsOpenParen(pattern, index11)) {
1236
- const [start, end] = Group(pattern, index11);
1234
+ for (let index12 = 0; index12 < pattern.length; index12++) {
1235
+ if (IsOpenParen(pattern, index12)) {
1236
+ const [start, end] = Group(pattern, index12);
1237
1237
  const range = pattern.slice(start, end + 1);
1238
1238
  expressions.push(TemplateLiteralParse(range));
1239
- index11 = end;
1239
+ index12 = end;
1240
1240
  } else {
1241
- const [start, end] = Range(pattern, index11);
1241
+ const [start, end] = Range(pattern, index12);
1242
1242
  const range = pattern.slice(start, end);
1243
1243
  if (range.length > 0)
1244
1244
  expressions.push(TemplateLiteralParse(range));
1245
- index11 = end - 1;
1245
+ index12 = 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, index11) => IntoBooleanResult(Visit3(right.parameters[index11], 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, index12) => IntoBooleanResult(Visit3(right.parameters[index12], 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, index11) => IntoBooleanResult(Visit3(right.parameters[index11], 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, index12) => IntoBooleanResult(Visit3(right.parameters[index12], 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, index11) => Visit3(schema, right.items[index11]) === 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, index12) => Visit3(schema, right.items[index12]) === 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;
@@ -4486,23 +4486,27 @@ var init_schema3 = __esm({
4486
4486
  Type.Literal("login"),
4487
4487
  Type.Literal("password_reset"),
4488
4488
  Type.Literal("email_change"),
4489
- Type.Literal("phone_change")
4489
+ Type.Literal("phone_change"),
4490
+ Type.Literal("account_deletion")
4490
4491
  ], {
4491
4492
  description: "Purpose of verification"
4492
4493
  });
4493
- VERIFICATION_PURPOSES = ["registration", "login", "password_reset", "email_change", "phone_change"];
4494
+ VERIFICATION_PURPOSES = ["registration", "login", "password_reset", "email_change", "phone_change", "account_deletion"];
4494
4495
  }
4495
4496
  });
4496
4497
 
4497
4498
  // src/server/types.ts
4498
- var KEY_ALGORITHM, INVITATION_STATUSES, USER_STATUSES, SOCIAL_PROVIDERS;
4499
+ var KEY_ALGORITHM, INVITATION_STATUSES, USER_STATUSES, SOCIAL_PROVIDERS, ACCOUNT_DELETION_REQUEST_STATUSES, ACCOUNT_DELETION_REQUESTED_BY, PURGE_STRATEGIES;
4499
4500
  var init_types = __esm({
4500
4501
  "src/server/types.ts"() {
4501
4502
  "use strict";
4502
4503
  KEY_ALGORITHM = ["ES256", "RS256"];
4503
4504
  INVITATION_STATUSES = ["pending", "accepted", "expired", "cancelled"];
4504
- USER_STATUSES = ["active", "inactive", "suspended"];
4505
+ USER_STATUSES = ["active", "inactive", "suspended", "pending_deletion", "deleted"];
4505
4506
  SOCIAL_PROVIDERS = ["google", "apple", "github", "kakao", "naver", "superself"];
4507
+ ACCOUNT_DELETION_REQUEST_STATUSES = ["pending", "cancelled", "completed"];
4508
+ ACCOUNT_DELETION_REQUESTED_BY = ["self", "admin"];
4509
+ PURGE_STRATEGIES = ["anonymize", "hard-delete"];
4506
4510
  }
4507
4511
  });
4508
4512
 
@@ -4565,9 +4569,8 @@ var init_roles = __esm({
4565
4569
  });
4566
4570
 
4567
4571
  // src/server/entities/users.ts
4568
- import { text as text2, check, boolean as boolean2, index as index2, uuid } from "drizzle-orm/pg-core";
4569
- import { id as id2, timestamps as timestamps2, enumText, utcTimestamp, foreignKey } from "@spfn/core/db";
4570
- import { sql } from "drizzle-orm";
4572
+ import { text as text2, boolean as boolean2, index as index2, uuid } from "drizzle-orm/pg-core";
4573
+ import { id as id2, timestamps as timestamps2, enumText, utcTimestamp, foreignKey, softDelete } from "@spfn/core/db";
4571
4574
  var users;
4572
4575
  var init_users = __esm({
4573
4576
  "src/server/entities/users.ts"() {
@@ -4609,6 +4612,8 @@ var init_users = __esm({
4609
4612
  // - active: Normal operation (default)
4610
4613
  // - inactive: Deactivated (user request, dormant)
4611
4614
  // - suspended: Locked (security incident, ToS violation)
4615
+ // - pending_deletion: Deletion requested, within the grace period (recoverable)
4616
+ // - deleted: Grace period elapsed and the account was purged (anonymize mode)
4612
4617
  status: enumText("status", USER_STATUSES).default("active").notNull(),
4613
4618
  // Verification timestamps
4614
4619
  // null = unverified, timestamp = verified at this time
@@ -4620,15 +4625,13 @@ var init_users = __esm({
4620
4625
  // Last successful login timestamp
4621
4626
  // Used for: security auditing, dormant account detection
4622
4627
  lastLoginAt: utcTimestamp("last_login_at"),
4623
- ...timestamps2()
4628
+ ...timestamps2(),
4629
+ // Soft delete (deletedAt / deletedBy) — set when the account deletion purge job
4630
+ // anonymizes the row (status -> 'deleted'). Hard-delete mode removes the row
4631
+ // instead, so these stay null for that strategy.
4632
+ ...softDelete()
4624
4633
  },
4625
4634
  (table) => [
4626
- // Database constraints
4627
- // Ensure at least one identifier exists (email OR phone)
4628
- check(
4629
- "email_or_phone_check",
4630
- sql`${table.email} IS NOT NULL OR ${table.phone} IS NOT NULL`
4631
- ),
4632
4635
  // Indexes for query optimization
4633
4636
  index2("users_public_id_idx").on(table.publicId),
4634
4637
  index2("users_email_idx").on(table.email),
@@ -4858,9 +4861,9 @@ var init_user_social_accounts = __esm({
4858
4861
  });
4859
4862
 
4860
4863
  // src/server/entities/verification-codes.ts
4861
- import { text as text6, index as index6, integer as integer2, check as check2 } from "drizzle-orm/pg-core";
4864
+ import { text as text6, index as index6, integer as integer2, check } from "drizzle-orm/pg-core";
4862
4865
  import { id as id6, timestamps as timestamps5, enumText as enumText4, utcTimestamp as utcTimestamp4 } from "@spfn/core/db";
4863
- import { sql as sql2 } from "drizzle-orm";
4866
+ import { sql } from "drizzle-orm";
4864
4867
  var verificationCodes;
4865
4868
  var init_verification_codes = __esm({
4866
4869
  "src/server/entities/verification-codes.ts"() {
@@ -4900,7 +4903,7 @@ var init_verification_codes = __esm({
4900
4903
  (table) => [
4901
4904
  // Database constraints
4902
4905
  // Limit verification attempts to prevent brute force attacks
4903
- check2("attempts_limit_check", sql2`${table.attempts} >= 0 AND ${table.attempts} <= 10`),
4906
+ check("attempts_limit_check", sql`${table.attempts} >= 0 AND ${table.attempts} <= 10`),
4904
4907
  // Index for quick lookup by target and purpose
4905
4908
  index6("target_purpose_idx").on(table.target, table.purpose, table.expiresAt)
4906
4909
  ]
@@ -4980,6 +4983,59 @@ var init_user_invitations = __esm({
4980
4983
  }
4981
4984
  });
4982
4985
 
4986
+ // src/server/entities/account-deletion-requests.ts
4987
+ import { text as text8, index as index8, uniqueIndex as uniqueIndex2 } from "drizzle-orm/pg-core";
4988
+ import { sql as sql2 } from "drizzle-orm";
4989
+ import { id as id8, timestamps as timestamps7, enumText as enumText6, utcTimestamp as utcTimestamp6, optionalForeignKey } from "@spfn/core/db";
4990
+ var accountDeletionRequests;
4991
+ var init_account_deletion_requests = __esm({
4992
+ "src/server/entities/account-deletion-requests.ts"() {
4993
+ "use strict";
4994
+ init_types();
4995
+ init_users();
4996
+ init_schema4();
4997
+ accountDeletionRequests = authSchema.table(
4998
+ "account_deletion_requests",
4999
+ {
5000
+ id: id8(),
5001
+ // Foreign key to users table. `set null` (optionalForeignKey default) so this
5002
+ // row survives a hard-delete purge of the user it refers to.
5003
+ userId: optionalForeignKey("user", () => users.id),
5004
+ // Snapshot of the user's public UUID at request time — stays readable even
5005
+ // after userId is nulled out or the account is anonymized.
5006
+ userPublicId: text8("user_public_id").notNull(),
5007
+ // When the deletion was requested
5008
+ requestedAt: utcTimestamp6("requested_at").notNull().defaultNow(),
5009
+ // When the purge job is allowed to run (requestedAt + grace period; equals
5010
+ // requestedAt itself for immediate/zero-grace deletions)
5011
+ purgeScheduledAt: utcTimestamp6("purge_scheduled_at").notNull(),
5012
+ // Request lifecycle status
5013
+ // - pending: awaiting purgeScheduledAt (or immediate purge)
5014
+ // - cancelled: recovered before purge
5015
+ // - completed: purge ran
5016
+ status: enumText6("status", ACCOUNT_DELETION_REQUEST_STATUSES).default("pending").notNull(),
5017
+ // Who initiated the request
5018
+ requestedBy: enumText6("requested_by", ACCOUNT_DELETION_REQUESTED_BY).default("self").notNull(),
5019
+ // Optional free-text reason (self-service UI, admin note, DSR reference, ...)
5020
+ reason: text8("reason"),
5021
+ cancelledAt: utcTimestamp6("cancelled_at"),
5022
+ completedAt: utcTimestamp6("completed_at"),
5023
+ // Purge strategy actually executed (set on completion; null while pending)
5024
+ purgeStrategy: enumText6("purge_strategy", PURGE_STRATEGIES),
5025
+ ...timestamps7()
5026
+ },
5027
+ (table) => [
5028
+ index8("account_deletion_requests_user_id_idx").on(table.userId),
5029
+ index8("account_deletion_requests_status_idx").on(table.status),
5030
+ index8("account_deletion_requests_purge_scheduled_at_idx").on(table.purgeScheduledAt),
5031
+ index8("account_deletion_requests_user_public_id_idx").on(table.userPublicId),
5032
+ // Partial unique index: at most one pending request per user at a time.
5033
+ uniqueIndex2("account_deletion_requests_user_pending_unique_idx").on(table.userId).where(sql2`${table.status} = 'pending'`)
5034
+ ]
5035
+ );
5036
+ }
5037
+ });
5038
+
4983
5039
  // src/server/rbac/types.ts
4984
5040
  var PERMISSION_CATEGORIES;
4985
5041
  var init_types2 = __esm({
@@ -5126,8 +5182,8 @@ var init_rbac = __esm({
5126
5182
  });
5127
5183
 
5128
5184
  // src/server/entities/permissions.ts
5129
- import { text as text8, boolean as boolean4, index as index8 } from "drizzle-orm/pg-core";
5130
- import { id as id8, timestamps as timestamps7, enumText as enumText6, typedJsonb as typedJsonb3 } from "@spfn/core/db";
5185
+ import { text as text9, boolean as boolean4, index as index9 } from "drizzle-orm/pg-core";
5186
+ import { id as id9, timestamps as timestamps8, enumText as enumText7, typedJsonb as typedJsonb3 } from "@spfn/core/db";
5131
5187
  var permissions;
5132
5188
  var init_permissions = __esm({
5133
5189
  "src/server/entities/permissions.ts"() {
@@ -5138,7 +5194,7 @@ var init_permissions = __esm({
5138
5194
  "permissions",
5139
5195
  {
5140
5196
  // Primary key
5141
- id: id8(),
5197
+ id: id9(),
5142
5198
  // Permission identifier
5143
5199
  // Format: resource:action or namespace:resource:action
5144
5200
  // Examples:
@@ -5146,20 +5202,20 @@ var init_permissions = __esm({
5146
5202
  // - Namespaced: 'auth:user:delete', 'cms:post:publish'
5147
5203
  // Must be unique across all permissions
5148
5204
  // Used in: permission checks, role assignments, API guards
5149
- name: text8("name").notNull().unique(),
5205
+ name: text9("name").notNull().unique(),
5150
5206
  // Display name for UI
5151
5207
  // Human-readable name shown in admin panels
5152
5208
  // Example: "Delete Users", "Publish Posts"
5153
- displayName: text8("display_name").notNull(),
5209
+ displayName: text9("display_name").notNull(),
5154
5210
  // Permission description
5155
5211
  // Detailed explanation of what this permission allows
5156
5212
  // Example: "Allows deletion of user accounts from the system"
5157
- description: text8("description"),
5213
+ description: text9("description"),
5158
5214
  // Category for grouping
5159
5215
  // Used for: organizing permissions in UI, filtering
5160
5216
  // Built-in categories: auth, user, rbac, system
5161
5217
  // Custom categories: any app-specific category
5162
- category: enumText6("category", PERMISSION_CATEGORIES),
5218
+ category: enumText7("category", PERMISSION_CATEGORIES),
5163
5219
  // Built-in permission flag
5164
5220
  // true: Core package permissions (auth:*, user:*, rbac:*)
5165
5221
  // - Cannot be deleted or modified
@@ -5190,22 +5246,22 @@ var init_permissions = __esm({
5190
5246
  // - Audit: { createdBy: 123, source: 'migration', version: '1.0.0' }
5191
5247
  // Example: { icon: 'trash', color: 'red', requiresMfa: true }
5192
5248
  metadata: typedJsonb3("metadata"),
5193
- ...timestamps7()
5249
+ ...timestamps8()
5194
5250
  },
5195
5251
  (table) => [
5196
- index8("permissions_name_idx").on(table.name),
5197
- index8("permissions_category_idx").on(table.category),
5198
- index8("permissions_is_system_idx").on(table.isSystem),
5199
- index8("permissions_is_active_idx").on(table.isActive),
5200
- index8("permissions_is_builtin_idx").on(table.isBuiltin)
5252
+ index9("permissions_name_idx").on(table.name),
5253
+ index9("permissions_category_idx").on(table.category),
5254
+ index9("permissions_is_system_idx").on(table.isSystem),
5255
+ index9("permissions_is_active_idx").on(table.isActive),
5256
+ index9("permissions_is_builtin_idx").on(table.isBuiltin)
5201
5257
  ]
5202
5258
  );
5203
5259
  }
5204
5260
  });
5205
5261
 
5206
5262
  // src/server/entities/role-permissions.ts
5207
- import { index as index9, unique } from "drizzle-orm/pg-core";
5208
- import { id as id9, timestamps as timestamps8, foreignKey as foreignKey6 } from "@spfn/core/db";
5263
+ import { index as index10, unique } from "drizzle-orm/pg-core";
5264
+ import { id as id10, timestamps as timestamps9, foreignKey as foreignKey6 } from "@spfn/core/db";
5209
5265
  var rolePermissions;
5210
5266
  var init_role_permissions = __esm({
5211
5267
  "src/server/entities/role-permissions.ts"() {
@@ -5217,7 +5273,7 @@ var init_role_permissions = __esm({
5217
5273
  "role_permissions",
5218
5274
  {
5219
5275
  // Primary key
5220
- id: id9(),
5276
+ id: id10(),
5221
5277
  // Role reference
5222
5278
  // Foreign key to roles table
5223
5279
  // Cascade delete: when role is deleted, all role-permission mappings are removed
@@ -5230,12 +5286,12 @@ var init_role_permissions = __esm({
5230
5286
  // Used for: granting permissions to roles
5231
5287
  // Example: user:delete permission → [Admin, Superadmin]
5232
5288
  permissionId: foreignKey6("permission", () => permissions.id, { onDelete: "cascade" }),
5233
- ...timestamps8()
5289
+ ...timestamps9()
5234
5290
  },
5235
5291
  (table) => [
5236
5292
  // Indexes for query performance
5237
- index9("role_permissions_role_id_idx").on(table.roleId),
5238
- index9("role_permissions_permission_id_idx").on(table.permissionId),
5293
+ index10("role_permissions_role_id_idx").on(table.roleId),
5294
+ index10("role_permissions_permission_id_idx").on(table.permissionId),
5239
5295
  // Unique constraint: one role-permission pair only
5240
5296
  unique("role_permissions_unique").on(table.roleId, table.permissionId)
5241
5297
  ]
@@ -5244,8 +5300,8 @@ var init_role_permissions = __esm({
5244
5300
  });
5245
5301
 
5246
5302
  // src/server/entities/user-permissions.ts
5247
- import { boolean as boolean5, text as text9, index as index10, unique as unique2 } from "drizzle-orm/pg-core";
5248
- import { id as id10, timestamps as timestamps9, utcTimestamp as utcTimestamp6, foreignKey as foreignKey7 } from "@spfn/core/db";
5303
+ import { boolean as boolean5, text as text10, index as index11, unique as unique2 } from "drizzle-orm/pg-core";
5304
+ import { id as id11, timestamps as timestamps10, utcTimestamp as utcTimestamp7, foreignKey as foreignKey7 } from "@spfn/core/db";
5249
5305
  var userPermissions;
5250
5306
  var init_user_permissions = __esm({
5251
5307
  "src/server/entities/user-permissions.ts"() {
@@ -5257,7 +5313,7 @@ var init_user_permissions = __esm({
5257
5313
  "user_permissions",
5258
5314
  {
5259
5315
  // Primary key
5260
- id: id10(),
5316
+ id: id11(),
5261
5317
  // User reference
5262
5318
  // Foreign key to users table
5263
5319
  // Cascade delete: when user is deleted, all overrides are removed
@@ -5280,19 +5336,19 @@ var init_user_permissions = __esm({
5280
5336
  // Reason for grant/revocation
5281
5337
  // Used for: audit trail, compliance documentation
5282
5338
  // Example: "Temporary access for project X", "Security incident - restricted"
5283
- reason: text9("reason"),
5339
+ reason: text10("reason"),
5284
5340
  // Expiration timestamp (optional)
5285
5341
  // null: Permanent override (remains until manually removed)
5286
5342
  // timestamp: Permission expires at this time (auto-revoked by background job)
5287
5343
  // Use case: Time-limited elevated access, temporary restrictions
5288
- expiresAt: utcTimestamp6("expires_at"),
5289
- ...timestamps9()
5344
+ expiresAt: utcTimestamp7("expires_at"),
5345
+ ...timestamps10()
5290
5346
  },
5291
5347
  (table) => [
5292
5348
  // Indexes for query performance
5293
- index10("user_permissions_user_id_idx").on(table.userId),
5294
- index10("user_permissions_permission_id_idx").on(table.permissionId),
5295
- index10("user_permissions_expires_at_idx").on(table.expiresAt),
5349
+ index11("user_permissions_user_id_idx").on(table.userId),
5350
+ index11("user_permissions_permission_id_idx").on(table.permissionId),
5351
+ index11("user_permissions_expires_at_idx").on(table.expiresAt),
5296
5352
  // Unique constraint: one user-permission pair only
5297
5353
  unique2("user_permissions_unique").on(table.userId, table.permissionId)
5298
5354
  ]
@@ -5301,7 +5357,7 @@ var init_user_permissions = __esm({
5301
5357
  });
5302
5358
 
5303
5359
  // src/server/entities/auth-metadata.ts
5304
- import { text as text10, timestamp } from "drizzle-orm/pg-core";
5360
+ import { text as text11, timestamp } from "drizzle-orm/pg-core";
5305
5361
  var authMetadata;
5306
5362
  var init_auth_metadata = __esm({
5307
5363
  "src/server/entities/auth-metadata.ts"() {
@@ -5311,9 +5367,9 @@ var init_auth_metadata = __esm({
5311
5367
  "auth_metadata",
5312
5368
  {
5313
5369
  // Metadata key (primary key)
5314
- key: text10("key").primaryKey(),
5370
+ key: text11("key").primaryKey(),
5315
5371
  // Metadata value
5316
- value: text10("value").notNull(),
5372
+ value: text11("value").notNull(),
5317
5373
  // Last updated timestamp
5318
5374
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
5319
5375
  }
@@ -5332,6 +5388,7 @@ var init_entities = __esm({
5332
5388
  init_user_social_accounts();
5333
5389
  init_verification_codes();
5334
5390
  init_user_invitations();
5391
+ init_account_deletion_requests();
5335
5392
  init_roles();
5336
5393
  init_permissions();
5337
5394
  init_role_permissions();
@@ -5354,8 +5411,19 @@ var init_users_repository = __esm({
5354
5411
  * ID로 사용자 조회
5355
5412
  * Read replica 사용
5356
5413
  */
5357
- async findById(id11) {
5358
- const result = await this.readDb.select().from(users).where(eq(users.id, id11)).limit(1);
5414
+ async findById(id12) {
5415
+ const result = await this.readDb.select().from(users).where(eq(users.id, id12)).limit(1);
5416
+ return result[0] ?? null;
5417
+ }
5418
+ /**
5419
+ * ID로 사용자 조회 — Write primary에서 직접 읽는다.
5420
+ *
5421
+ * 복제 지연 창에서 status 전이(예: 삭제 요청으로 pending_deletion 전환)를 놓치면
5422
+ * 안 되는 게이트(OAuth 세션 발급 등)가 사용한다. 일반 조회는 `findById`(replica)를
5423
+ * 계속 사용할 것.
5424
+ */
5425
+ async findByIdOnPrimary(id12) {
5426
+ const result = await this.db.select().from(users).where(eq(users.id, id12)).limit(1);
5359
5427
  return result[0] ?? null;
5360
5428
  }
5361
5429
  /**
@@ -5408,13 +5476,13 @@ var init_users_repository = __esm({
5408
5476
  *
5409
5477
  * roleId가 null인 유저는 role: null 반환
5410
5478
  */
5411
- async findByIdWithRole(id11) {
5479
+ async findByIdWithRole(id12) {
5412
5480
  const result = await this.readDb.select({
5413
5481
  user: users,
5414
5482
  roleName: roles.name,
5415
5483
  roleDisplayName: roles.displayName,
5416
5484
  rolePriority: roles.priority
5417
- }).from(users).leftJoin(roles, eq(users.roleId, roles.id)).where(eq(users.id, id11)).limit(1);
5485
+ }).from(users).leftJoin(roles, eq(users.roleId, roles.id)).where(eq(users.id, id12)).limit(1);
5418
5486
  const row = result[0];
5419
5487
  if (!row) {
5420
5488
  return null;
@@ -5439,15 +5507,33 @@ var init_users_repository = __esm({
5439
5507
  * 사용자 정보 업데이트
5440
5508
  * Write primary 사용
5441
5509
  */
5442
- async updateById(id11, data) {
5443
- const result = await this.db.update(users).set({ ...data, updatedAt: /* @__PURE__ */ new Date() }).where(eq(users.id, id11)).returning();
5510
+ async updateById(id12, data) {
5511
+ const result = await this.db.update(users).set({ ...data, updatedAt: /* @__PURE__ */ new Date() }).where(eq(users.id, id12)).returning();
5512
+ return result[0] ?? null;
5513
+ }
5514
+ /**
5515
+ * 계정 삭제 취소(복구) — `WHERE status = 'pending_deletion'` 조건부 UPDATE.
5516
+ *
5517
+ * cancelAccountDeletionService가 account_deletion_requests claim(조건부
5518
+ * markCancelled)에 성공한 **이후에만** 호출한다. 그 claim이 이미 purge와의
5519
+ * 경합을 해결하므로 이 조건은 방어적 이중 안전장치 — 0 row 매치(= 이미
5520
+ * status가 바뀐 상태) 시 null을 반환하며 예외를 던지지 않는다.
5521
+ * Write primary 사용
5522
+ */
5523
+ async reactivateFromPendingDeletion(id12) {
5524
+ const result = await this.db.update(users).set({ status: "active", updatedAt: /* @__PURE__ */ new Date() }).where(
5525
+ and(
5526
+ eq(users.id, id12),
5527
+ eq(users.status, "pending_deletion")
5528
+ )
5529
+ ).returning();
5444
5530
  return result[0] ?? null;
5445
5531
  }
5446
5532
  /**
5447
5533
  * 비밀번호 업데이트
5448
5534
  * Write primary 사용
5449
5535
  */
5450
- async updatePassword(id11, passwordHash, clearPasswordChangeRequired = true) {
5536
+ async updatePassword(id12, passwordHash, clearPasswordChangeRequired = true) {
5451
5537
  const updateData = {
5452
5538
  passwordHash,
5453
5539
  updatedAt: /* @__PURE__ */ new Date()
@@ -5455,26 +5541,26 @@ var init_users_repository = __esm({
5455
5541
  if (clearPasswordChangeRequired) {
5456
5542
  updateData.passwordChangeRequired = false;
5457
5543
  }
5458
- const result = await this.db.update(users).set(updateData).where(eq(users.id, id11)).returning();
5544
+ const result = await this.db.update(users).set(updateData).where(eq(users.id, id12)).returning();
5459
5545
  return result[0] ?? null;
5460
5546
  }
5461
5547
  /**
5462
5548
  * 마지막 로그인 시간 업데이트
5463
5549
  * Write primary 사용
5464
5550
  */
5465
- async updateLastLogin(id11) {
5551
+ async updateLastLogin(id12) {
5466
5552
  const result = await this.db.update(users).set({
5467
5553
  lastLoginAt: /* @__PURE__ */ new Date(),
5468
5554
  updatedAt: /* @__PURE__ */ new Date()
5469
- }).where(eq(users.id, id11)).returning();
5555
+ }).where(eq(users.id, id12)).returning();
5470
5556
  return result[0] ?? null;
5471
5557
  }
5472
5558
  /**
5473
5559
  * 사용자 삭제
5474
5560
  * Write primary 사용
5475
5561
  */
5476
- async deleteById(id11) {
5477
- const result = await this.db.delete(users).where(eq(users.id, id11)).returning();
5562
+ async deleteById(id12) {
5563
+ const result = await this.db.delete(users).where(eq(users.id, id12)).returning();
5478
5564
  return result[0] ?? null;
5479
5565
  }
5480
5566
  /**
@@ -5690,6 +5776,17 @@ var init_keys_repository = __esm({
5690
5776
  ).returning();
5691
5777
  return result[0] ?? null;
5692
5778
  }
5779
+ /**
5780
+ * 사용자의 모든 공개키 삭제 (계정 익명화 파기용)
5781
+ *
5782
+ * hard-delete는 FK cascade로 자동 처리되지만, anonymize 모드는 users row를
5783
+ * 남기므로 자식 row를 직접 지워야 한다.
5784
+ * Write primary 사용
5785
+ */
5786
+ async deleteAllByUserId(userId) {
5787
+ const result = await this.db.delete(userPublicKeys).where(eq2(userPublicKeys.userId, userId)).returning();
5788
+ return result.length;
5789
+ }
5693
5790
  /**
5694
5791
  * 마지막 사용 시간 업데이트
5695
5792
  * Write primary 사용
@@ -5727,12 +5824,12 @@ var init_keys_repository = __esm({
5727
5824
  * throttle lives in the WHERE clause — atomic, no read-then-write race. No
5728
5825
  * RETURNING (callers fire-and-forget and discard the row).
5729
5826
  */
5730
- async updateLastUsedById(id11) {
5827
+ async updateLastUsedById(id12) {
5731
5828
  const staleBefore = new Date(Date.now() - LAST_USED_THROTTLE_MS);
5732
5829
  await this.db.update(userPublicKeys).set({
5733
5830
  lastUsedAt: /* @__PURE__ */ new Date()
5734
5831
  }).where(and2(
5735
- eq2(userPublicKeys.id, id11),
5832
+ eq2(userPublicKeys.id, id12),
5736
5833
  or(
5737
5834
  isNull(userPublicKeys.lastUsedAt),
5738
5835
  lt(userPublicKeys.lastUsedAt, staleBefore)
@@ -5774,8 +5871,8 @@ var init_verification_codes_repository = __esm({
5774
5871
  * ID로 인증 코드 조회
5775
5872
  * Read replica 사용
5776
5873
  */
5777
- async findById(id11) {
5778
- const result = await this.readDb.select().from(verificationCodes).where(eq3(verificationCodes.id, id11)).limit(1);
5874
+ async findById(id12) {
5875
+ const result = await this.readDb.select().from(verificationCodes).where(eq3(verificationCodes.id, id12)).limit(1);
5779
5876
  return result[0] ?? null;
5780
5877
  }
5781
5878
  /**
@@ -5793,24 +5890,24 @@ var init_verification_codes_repository = __esm({
5793
5890
  * 인증 코드 사용 처리
5794
5891
  * Write primary 사용
5795
5892
  */
5796
- async markAsUsed(id11) {
5893
+ async markAsUsed(id12) {
5797
5894
  const result = await this.db.update(verificationCodes).set({
5798
5895
  usedAt: /* @__PURE__ */ new Date(),
5799
5896
  updatedAt: /* @__PURE__ */ new Date()
5800
- }).where(eq3(verificationCodes.id, id11)).returning();
5897
+ }).where(eq3(verificationCodes.id, id12)).returning();
5801
5898
  return result[0] ?? null;
5802
5899
  }
5803
5900
  /**
5804
5901
  * 시도 횟수 증가
5805
5902
  * Write primary 사용
5806
5903
  */
5807
- async incrementAttempts(id11) {
5808
- const code = await this.findById(id11);
5904
+ async incrementAttempts(id12) {
5905
+ const code = await this.findById(id12);
5809
5906
  if (!code) return null;
5810
5907
  const result = await this.db.update(verificationCodes).set({
5811
5908
  attempts: code.attempts + 1,
5812
5909
  updatedAt: /* @__PURE__ */ new Date()
5813
- }).where(eq3(verificationCodes.id, id11)).returning();
5910
+ }).where(eq3(verificationCodes.id, id12)).returning();
5814
5911
  return result[0] ?? null;
5815
5912
  }
5816
5913
  /**
@@ -5822,6 +5919,17 @@ var init_verification_codes_repository = __esm({
5822
5919
  const result = await this.db.delete(verificationCodes).where(lt2(verificationCodes.expiresAt, now)).returning();
5823
5920
  return result.length;
5824
5921
  }
5922
+ /**
5923
+ * Target(email/phone)의 모든 인증 코드 삭제 (계정 파기용)
5924
+ *
5925
+ * verificationCodes는 userId FK가 없고 target 텍스트로만 연결되므로, 파기 시
5926
+ * 원래 email/phone을 이 메서드로 직접 정리해야 한다.
5927
+ * Write primary 사용
5928
+ */
5929
+ async deleteByTarget(target) {
5930
+ const result = await this.db.delete(verificationCodes).where(eq3(verificationCodes.target, target)).returning();
5931
+ return result.length;
5932
+ }
5825
5933
  /**
5826
5934
  * Target의 모든 이전 코드 무효화 (새 코드 발급 시)
5827
5935
  * Write primary 사용
@@ -5856,8 +5964,8 @@ var init_roles_repository = __esm({
5856
5964
  /**
5857
5965
  * ID로 역할 조회
5858
5966
  */
5859
- async findById(id11) {
5860
- const result = await this.readDb.select().from(roles).where(eq4(roles.id, id11)).limit(1);
5967
+ async findById(id12) {
5968
+ const result = await this.readDb.select().from(roles).where(eq4(roles.id, id12)).limit(1);
5861
5969
  return result[0] ?? null;
5862
5970
  }
5863
5971
  /**
@@ -5892,15 +6000,15 @@ var init_roles_repository = __esm({
5892
6000
  /**
5893
6001
  * 역할 업데이트
5894
6002
  */
5895
- async updateById(id11, data) {
5896
- const result = await this.db.update(roles).set({ ...data, updatedAt: /* @__PURE__ */ new Date() }).where(eq4(roles.id, id11)).returning();
6003
+ async updateById(id12, data) {
6004
+ const result = await this.db.update(roles).set({ ...data, updatedAt: /* @__PURE__ */ new Date() }).where(eq4(roles.id, id12)).returning();
5897
6005
  return result[0] ?? null;
5898
6006
  }
5899
6007
  /**
5900
6008
  * 역할 삭제
5901
6009
  */
5902
- async deleteById(id11) {
5903
- const result = await this.db.delete(roles).where(eq4(roles.id, id11)).returning();
6010
+ async deleteById(id12) {
6011
+ const result = await this.db.delete(roles).where(eq4(roles.id, id12)).returning();
5904
6012
  return result[0] ?? null;
5905
6013
  }
5906
6014
  };
@@ -5920,8 +6028,8 @@ var init_permissions_repository = __esm({
5920
6028
  /**
5921
6029
  * ID로 권한 조회
5922
6030
  */
5923
- async findById(id11) {
5924
- const result = await this.readDb.select().from(permissions).where(eq5(permissions.id, id11)).limit(1);
6031
+ async findById(id12) {
6032
+ const result = await this.readDb.select().from(permissions).where(eq5(permissions.id, id12)).limit(1);
5925
6033
  return result[0] ?? null;
5926
6034
  }
5927
6035
  /**
@@ -5982,15 +6090,15 @@ var init_permissions_repository = __esm({
5982
6090
  /**
5983
6091
  * 권한 업데이트
5984
6092
  */
5985
- async updateById(id11, data) {
5986
- const result = await this.db.update(permissions).set({ ...data, updatedAt: /* @__PURE__ */ new Date() }).where(eq5(permissions.id, id11)).returning();
6093
+ async updateById(id12, data) {
6094
+ const result = await this.db.update(permissions).set({ ...data, updatedAt: /* @__PURE__ */ new Date() }).where(eq5(permissions.id, id12)).returning();
5987
6095
  return result[0] ?? null;
5988
6096
  }
5989
6097
  /**
5990
6098
  * 권한 삭제
5991
6099
  */
5992
- async deleteById(id11) {
5993
- const result = await this.db.delete(permissions).where(eq5(permissions.id, id11)).returning();
6100
+ async deleteById(id12) {
6101
+ const result = await this.db.delete(permissions).where(eq5(permissions.id, id12)).returning();
5994
6102
  return result[0] ?? null;
5995
6103
  }
5996
6104
  };
@@ -6134,8 +6242,8 @@ var init_user_permissions_repository = __esm({
6134
6242
  /**
6135
6243
  * 사용자 권한 오버라이드 업데이트
6136
6244
  */
6137
- async updateById(id11, data) {
6138
- const result = await this.db.update(userPermissions).set({ ...data, updatedAt: /* @__PURE__ */ new Date() }).where(eq7(userPermissions.id, id11)).returning();
6245
+ async updateById(id12, data) {
6246
+ const result = await this.db.update(userPermissions).set({ ...data, updatedAt: /* @__PURE__ */ new Date() }).where(eq7(userPermissions.id, id12)).returning();
6139
6247
  return result[0] ?? null;
6140
6248
  }
6141
6249
  /**
@@ -6187,8 +6295,8 @@ var init_user_profiles_repository = __esm({
6187
6295
  /**
6188
6296
  * ID로 프로필 조회
6189
6297
  */
6190
- async findById(id11) {
6191
- const result = await this.readDb.select().from(userProfiles).where(eq8(userProfiles.id, id11)).limit(1);
6298
+ async findById(id12) {
6299
+ const result = await this.readDb.select().from(userProfiles).where(eq8(userProfiles.id, id12)).limit(1);
6192
6300
  return result[0] ?? null;
6193
6301
  }
6194
6302
  /**
@@ -6218,8 +6326,8 @@ var init_user_profiles_repository = __esm({
6218
6326
  /**
6219
6327
  * 프로필 업데이트 (by ID)
6220
6328
  */
6221
- async updateById(id11, data) {
6222
- const result = await this.db.update(userProfiles).set({ ...data, updatedAt: /* @__PURE__ */ new Date() }).where(eq8(userProfiles.id, id11)).returning();
6329
+ async updateById(id12, data) {
6330
+ const result = await this.db.update(userProfiles).set({ ...data, updatedAt: /* @__PURE__ */ new Date() }).where(eq8(userProfiles.id, id12)).returning();
6223
6331
  return result[0] ?? null;
6224
6332
  }
6225
6333
  /**
@@ -6232,8 +6340,8 @@ var init_user_profiles_repository = __esm({
6232
6340
  /**
6233
6341
  * 프로필 삭제 (by ID)
6234
6342
  */
6235
- async deleteById(id11) {
6236
- const result = await this.db.delete(userProfiles).where(eq8(userProfiles.id, id11)).returning();
6343
+ async deleteById(id12) {
6344
+ const result = await this.db.delete(userProfiles).where(eq8(userProfiles.id, id12)).returning();
6237
6345
  return result[0] ?? null;
6238
6346
  }
6239
6347
  /**
@@ -6323,8 +6431,8 @@ var init_invitations_repository = __esm({
6323
6431
  /**
6324
6432
  * ID로 초대 조회
6325
6433
  */
6326
- async findById(id11) {
6327
- const result = await this.readDb.select().from(userInvitations).where(eq9(userInvitations.id, id11)).limit(1);
6434
+ async findById(id12) {
6435
+ const result = await this.readDb.select().from(userInvitations).where(eq9(userInvitations.id, id12)).limit(1);
6328
6436
  return result[0] ?? null;
6329
6437
  }
6330
6438
  /**
@@ -6371,7 +6479,7 @@ var init_invitations_repository = __esm({
6371
6479
  /**
6372
6480
  * 초대 상태 업데이트
6373
6481
  */
6374
- async updateStatus(id11, status, timestamp2) {
6482
+ async updateStatus(id12, status, timestamp2) {
6375
6483
  const updates = {
6376
6484
  status,
6377
6485
  updatedAt: /* @__PURE__ */ new Date()
@@ -6383,14 +6491,14 @@ var init_invitations_repository = __esm({
6383
6491
  updates.cancelledAt = timestamp2;
6384
6492
  }
6385
6493
  }
6386
- const result = await this.db.update(userInvitations).set(updates).where(eq9(userInvitations.id, id11)).returning();
6494
+ const result = await this.db.update(userInvitations).set(updates).where(eq9(userInvitations.id, id12)).returning();
6387
6495
  return result[0] ?? null;
6388
6496
  }
6389
6497
  /**
6390
6498
  * 초대 삭제
6391
6499
  */
6392
- async deleteById(id11) {
6393
- const result = await this.db.delete(userInvitations).where(eq9(userInvitations.id, id11)).returning();
6500
+ async deleteById(id12) {
6501
+ const result = await this.db.delete(userInvitations).where(eq9(userInvitations.id, id12)).returning();
6394
6502
  return result[0] ?? null;
6395
6503
  }
6396
6504
  /**
@@ -6488,35 +6596,35 @@ var init_invitations_repository = __esm({
6488
6596
  /**
6489
6597
  * 초대 업데이트 (일반 업데이트 - 모든 필드 가능)
6490
6598
  */
6491
- async updateById(id11, data) {
6599
+ async updateById(id12, data) {
6492
6600
  const result = await this.db.update(userInvitations).set({
6493
6601
  ...data,
6494
6602
  updatedAt: /* @__PURE__ */ new Date()
6495
- }).where(eq9(userInvitations.id, id11)).returning();
6603
+ }).where(eq9(userInvitations.id, id12)).returning();
6496
6604
  return result[0] ?? null;
6497
6605
  }
6498
6606
  /**
6499
6607
  * 초대 재전송 (status와 expiresAt 동시 업데이트)
6500
6608
  */
6501
- async resend(id11, newExpiresAt) {
6609
+ async resend(id12, newExpiresAt) {
6502
6610
  const result = await this.db.update(userInvitations).set({
6503
6611
  status: "pending",
6504
6612
  expiresAt: newExpiresAt,
6505
6613
  updatedAt: /* @__PURE__ */ new Date()
6506
- }).where(eq9(userInvitations.id, id11)).returning();
6614
+ }).where(eq9(userInvitations.id, id12)).returning();
6507
6615
  return result[0] ?? null;
6508
6616
  }
6509
6617
  /**
6510
6618
  * 초대 취소 (status, metadata 동시 업데이트)
6511
6619
  */
6512
- async cancel(id11, cancelledBy, reason, currentMetadata) {
6620
+ async cancel(id12, cancelledBy, reason, currentMetadata) {
6513
6621
  const newMetadata = currentMetadata ? { ...currentMetadata, cancelReason: reason, cancelledBy } : { cancelReason: reason, cancelledBy };
6514
6622
  const result = await this.db.update(userInvitations).set({
6515
6623
  status: "cancelled",
6516
6624
  cancelledAt: /* @__PURE__ */ new Date(),
6517
6625
  metadata: newMetadata,
6518
6626
  updatedAt: /* @__PURE__ */ new Date()
6519
- }).where(eq9(userInvitations.id, id11)).returning();
6627
+ }).where(eq9(userInvitations.id, id12)).returning();
6520
6628
  return result[0] ?? null;
6521
6629
  }
6522
6630
  };
@@ -6524,47 +6632,556 @@ var init_invitations_repository = __esm({
6524
6632
  }
6525
6633
  });
6526
6634
 
6635
+ // src/config/schema.ts
6636
+ import {
6637
+ defineEnvSchema,
6638
+ envString,
6639
+ envNumber,
6640
+ envBoolean,
6641
+ createSecureSecretParser,
6642
+ createPasswordParser as createPasswordParser2
6643
+ } from "@spfn/core/env";
6644
+ var authEnvSchema;
6645
+ var init_schema5 = __esm({
6646
+ "src/config/schema.ts"() {
6647
+ "use strict";
6648
+ authEnvSchema = defineEnvSchema({
6649
+ // ============================================================================
6650
+ // Session Configuration
6651
+ // ============================================================================
6652
+ SPFN_AUTH_SESSION_SECRET: {
6653
+ ...envString({
6654
+ description: "Session encryption secret (minimum 32 characters for AES-256)",
6655
+ required: true,
6656
+ fallbackKeys: ["SESSION_SECRET"],
6657
+ validator: createSecureSecretParser({
6658
+ minLength: 32,
6659
+ minUniqueChars: 16,
6660
+ minEntropy: 3.5
6661
+ }),
6662
+ sensitive: true,
6663
+ nextjs: true,
6664
+ // Required for Next.js RSC session validation
6665
+ examples: [
6666
+ "my-super-secret-session-key-at-least-32-chars-long",
6667
+ "use-a-cryptographically-secure-random-string-here"
6668
+ ]
6669
+ })
6670
+ },
6671
+ SPFN_AUTH_SESSION_TTL: {
6672
+ ...envString({
6673
+ description: "Session TTL (time to live) - supports duration strings like '7d', '12h', '45m'",
6674
+ default: "7d",
6675
+ required: false,
6676
+ nextjs: true,
6677
+ // May be needed for session validation in Next.js RSC
6678
+ examples: ["7d", "30d", "12h", "45m", "3600"]
6679
+ })
6680
+ },
6681
+ // ============================================================================
6682
+ // JWT Configuration
6683
+ // ============================================================================
6684
+ SPFN_AUTH_JWT_SECRET: {
6685
+ ...envString({
6686
+ description: "JWT signing secret for server-signed tokens (legacy mode)",
6687
+ default: "dev-secret-key-change-in-production",
6688
+ required: false,
6689
+ examples: [
6690
+ "your-jwt-secret-key-here",
6691
+ "use-different-from-session-secret"
6692
+ ]
6693
+ })
6694
+ },
6695
+ SPFN_AUTH_JWT_EXPIRES_IN: {
6696
+ ...envString({
6697
+ description: "JWT token expiration time (e.g., '7d', '24h', '1h')",
6698
+ default: "7d",
6699
+ required: false,
6700
+ examples: ["7d", "24h", "1h", "30m"]
6701
+ })
6702
+ },
6703
+ // ============================================================================
6704
+ // Security Configuration
6705
+ // ============================================================================
6706
+ SPFN_AUTH_COOKIE_SECURE: {
6707
+ ...envBoolean({
6708
+ description: 'Override cookie Secure flag. Defaults to NODE_ENV === "production". Set to false for HTTP-only environments (e.g. bastion over plain HTTP).',
6709
+ required: false,
6710
+ nextjs: true,
6711
+ examples: [true, false]
6712
+ })
6713
+ },
6714
+ SPFN_AUTH_BCRYPT_SALT_ROUNDS: {
6715
+ ...envNumber({
6716
+ description: "Bcrypt salt rounds (cost factor, higher = more secure but slower)",
6717
+ default: 12,
6718
+ required: false,
6719
+ examples: [10, 12, 14]
6720
+ }),
6721
+ key: "SPFN_AUTH_BCRYPT_SALT_ROUNDS"
6722
+ },
6723
+ SPFN_AUTH_VERIFICATION_TOKEN_SECRET: {
6724
+ ...envString({
6725
+ description: "Verification token secret for email verification, password reset, etc.",
6726
+ required: true,
6727
+ examples: [
6728
+ "your-verification-token-secret",
6729
+ "can-be-different-from-jwt-secret"
6730
+ ]
6731
+ })
6732
+ },
6733
+ SPFN_AUTH_TOKEN_ENCRYPTION_KEYS: {
6734
+ ...envString({
6735
+ description: "Backend-only OAuth token encryption keyring. Comma-separated <keyId>:<base64-encoded 32-byte key> entries; the first key encrypts new values and remaining keys decrypt during rotation.",
6736
+ required: false,
6737
+ sensitive: true,
6738
+ examples: [
6739
+ "v2:<base64-encoded-32-byte-key>,v1:<previous-base64-encoded-32-byte-key>"
6740
+ ]
6741
+ })
6742
+ },
6743
+ // ============================================================================
6744
+ // Admin Account Configuration
6745
+ // ============================================================================
6746
+ SPFN_AUTH_ADMIN_ACCOUNTS: {
6747
+ ...envString({
6748
+ description: "JSON array of admin accounts (recommended for multiple admins)",
6749
+ required: false,
6750
+ examples: [
6751
+ '[{"email":"admin@example.com","password":"secure-pass","role":"admin"}]',
6752
+ '[{"email":"super@example.com","password":"pass1","role":"superadmin"},{"email":"admin@example.com","password":"pass2","role":"admin"}]'
6753
+ ]
6754
+ })
6755
+ },
6756
+ SPFN_AUTH_ADMIN_EMAILS: {
6757
+ ...envString({
6758
+ description: "Comma-separated list of admin emails (legacy CSV format)",
6759
+ required: false,
6760
+ examples: [
6761
+ "admin@example.com,user@example.com",
6762
+ "super@example.com,admin@example.com,user@example.com"
6763
+ ]
6764
+ })
6765
+ },
6766
+ SPFN_AUTH_ADMIN_PASSWORDS: {
6767
+ ...envString({
6768
+ description: "Comma-separated list of admin passwords (legacy CSV format)",
6769
+ required: false,
6770
+ examples: [
6771
+ "admin-pass,user-pass",
6772
+ "super-pass,admin-pass,user-pass"
6773
+ ]
6774
+ })
6775
+ },
6776
+ SPFN_AUTH_ADMIN_ROLES: {
6777
+ ...envString({
6778
+ description: "Comma-separated list of admin roles (legacy CSV format)",
6779
+ required: false,
6780
+ examples: [
6781
+ "admin,user",
6782
+ "superadmin,admin,user"
6783
+ ]
6784
+ })
6785
+ },
6786
+ SPFN_AUTH_ADMIN_EMAIL: {
6787
+ ...envString({
6788
+ description: "Single admin email (simplest format)",
6789
+ required: false,
6790
+ examples: ["admin@example.com"]
6791
+ })
6792
+ },
6793
+ SPFN_AUTH_ADMIN_PASSWORD: {
6794
+ ...envString({
6795
+ description: "Single admin password (simplest format)",
6796
+ required: false,
6797
+ validator: createPasswordParser2({
6798
+ minLength: 8,
6799
+ requireUppercase: true,
6800
+ requireLowercase: true,
6801
+ requireNumber: true,
6802
+ requireSpecial: true
6803
+ }),
6804
+ sensitive: true,
6805
+ examples: ["SecureAdmin123!"]
6806
+ })
6807
+ },
6808
+ // ============================================================================
6809
+ // Username Configuration
6810
+ // ============================================================================
6811
+ SPFN_AUTH_RESERVED_USERNAMES: {
6812
+ ...envString({
6813
+ description: "Comma-separated list of reserved usernames that cannot be registered",
6814
+ required: false,
6815
+ default: "admin,root,system,support,help,moderator,superadmin",
6816
+ examples: [
6817
+ "admin,root,system,support,help",
6818
+ "admin,root,system,support,help,moderator,superadmin,operator"
6819
+ ]
6820
+ })
6821
+ },
6822
+ SPFN_AUTH_USERNAME_MIN_LENGTH: {
6823
+ ...envNumber({
6824
+ description: "Minimum username length",
6825
+ default: 3,
6826
+ required: false,
6827
+ examples: [2, 3, 4]
6828
+ })
6829
+ },
6830
+ SPFN_AUTH_USERNAME_MAX_LENGTH: {
6831
+ ...envNumber({
6832
+ description: "Maximum username length",
6833
+ default: 30,
6834
+ required: false,
6835
+ examples: [20, 30, 50]
6836
+ })
6837
+ },
6838
+ // ============================================================================
6839
+ // API Configuration
6840
+ // ============================================================================
6841
+ SPFN_API_URL: {
6842
+ ...envString({
6843
+ description: "Internal API URL for server-to-server communication",
6844
+ default: "http://localhost:8790",
6845
+ required: false,
6846
+ examples: [
6847
+ "https://api.example.com",
6848
+ "http://localhost:8790"
6849
+ ]
6850
+ })
6851
+ },
6852
+ NEXT_PUBLIC_SPFN_API_URL: {
6853
+ ...envString({
6854
+ description: "Public-facing API URL used for browser-facing redirects. Falls back to SPFN_API_URL if not set.",
6855
+ required: false,
6856
+ examples: [
6857
+ "https://api.example.com",
6858
+ "http://localhost:8790"
6859
+ ]
6860
+ })
6861
+ },
6862
+ SPFN_APP_URL: {
6863
+ ...envString({
6864
+ description: "Next.js application URL (internal). Used for server-to-server communication.",
6865
+ default: "http://localhost:3000",
6866
+ required: false,
6867
+ examples: [
6868
+ "https://app.example.com",
6869
+ "http://localhost:3000"
6870
+ ]
6871
+ })
6872
+ },
6873
+ NEXT_PUBLIC_SPFN_APP_URL: {
6874
+ ...envString({
6875
+ description: "Public-facing Next.js app URL for browser redirects (e.g. OAuth redirect). Falls back to SPFN_APP_URL if not set.",
6876
+ required: false,
6877
+ examples: [
6878
+ "https://app.example.com",
6879
+ "http://localhost:3000"
6880
+ ]
6881
+ })
6882
+ },
6883
+ // ============================================================================
6884
+ // OAuth Configuration - Google
6885
+ // ============================================================================
6886
+ SPFN_AUTH_GOOGLE_CLIENT_ID: {
6887
+ ...envString({
6888
+ description: "Google OAuth 2.0 Client ID. When set, Google OAuth routes are automatically enabled.",
6889
+ required: false,
6890
+ examples: ["123456789-abc123.apps.googleusercontent.com"]
6891
+ })
6892
+ },
6893
+ SPFN_AUTH_GOOGLE_CLIENT_SECRET: {
6894
+ ...envString({
6895
+ description: "Google OAuth 2.0 Client Secret",
6896
+ required: false,
6897
+ sensitive: true,
6898
+ examples: ["GOCSPX-abcdefghijklmnop"]
6899
+ })
6900
+ },
6901
+ SPFN_AUTH_GOOGLE_SCOPES: {
6902
+ ...envString({
6903
+ description: 'Comma-separated Google OAuth scopes. Defaults to "email,profile" if not set.',
6904
+ required: false,
6905
+ examples: [
6906
+ "email,profile",
6907
+ "email,profile,https://www.googleapis.com/auth/gmail.readonly",
6908
+ "email,profile,https://www.googleapis.com/auth/calendar.readonly"
6909
+ ]
6910
+ })
6911
+ },
6912
+ SPFN_AUTH_GOOGLE_REDIRECT_URI: {
6913
+ ...envString({
6914
+ description: "Google OAuth callback URL. Defaults to {NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}/_auth/oauth/google/callback \u2014 the callback must return to the web app origin that set the oauth_csrf cookie (the app rewrites /_auth/:path* to the API). Set this explicitly only when the callback should hit a different host (e.g. the API host for the direct oauthStart flow).",
6915
+ required: false,
6916
+ examples: [
6917
+ "https://app.example.com/_auth/oauth/google/callback",
6918
+ "http://localhost:3000/_auth/oauth/google/callback"
6919
+ ]
6920
+ })
6921
+ },
6922
+ // ============================================================================
6923
+ // OAuth Configuration - Kakao
6924
+ // ============================================================================
6925
+ SPFN_AUTH_KAKAO_CLIENT_ID: {
6926
+ ...envString({
6927
+ description: "Kakao Login REST API key. Used as the OAuth client_id.",
6928
+ required: false,
6929
+ examples: ["your-kakao-rest-api-key"]
6930
+ })
6931
+ },
6932
+ SPFN_AUTH_KAKAO_CLIENT_SECRET: {
6933
+ ...envString({
6934
+ description: "Kakao Login client secret. Required when the Kakao client-secret feature is enabled.",
6935
+ required: false,
6936
+ sensitive: true,
6937
+ examples: ["your-kakao-client-secret"]
6938
+ })
6939
+ },
6940
+ SPFN_AUTH_KAKAO_SCOPES: {
6941
+ ...envString({
6942
+ description: "Comma-separated Kakao consent scopes. Defaults to account_email.",
6943
+ required: false,
6944
+ examples: ["account_email"]
6945
+ })
6946
+ },
6947
+ SPFN_AUTH_KAKAO_REDIRECT_URI: {
6948
+ ...envString({
6949
+ description: "Kakao OAuth callback URL. Defaults to {NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}/_auth/oauth/kakao/callback.",
6950
+ required: false,
6951
+ examples: ["https://app.example.com/_auth/oauth/kakao/callback"]
6952
+ })
6953
+ },
6954
+ // ============================================================================
6955
+ // OAuth Configuration - Naver
6956
+ // ============================================================================
6957
+ SPFN_AUTH_NAVER_CLIENT_ID: {
6958
+ ...envString({
6959
+ description: "Naver Login OAuth client ID.",
6960
+ required: false,
6961
+ examples: ["your-naver-client-id"]
6962
+ })
6963
+ },
6964
+ SPFN_AUTH_NAVER_CLIENT_SECRET: {
6965
+ ...envString({
6966
+ description: "Naver Login OAuth client secret.",
6967
+ required: false,
6968
+ sensitive: true,
6969
+ examples: ["your-naver-client-secret"]
6970
+ })
6971
+ },
6972
+ SPFN_AUTH_NAVER_REDIRECT_URI: {
6973
+ ...envString({
6974
+ description: "Naver OAuth callback URL. Defaults to {NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}/_auth/oauth/naver/callback.",
6975
+ required: false,
6976
+ examples: ["https://app.example.com/_auth/oauth/naver/callback"]
6977
+ })
6978
+ },
6979
+ // ============================================================================
6980
+ // Native Social Login (mobile/web id_token verification)
6981
+ //
6982
+ // 네이티브 SDK가 받은 id_token을 서버가 JWKS로 검증하는 경로 전용 설정.
6983
+ // authorization code 교환을 하지 않으므로 client secret이 필요 없다.
6984
+ // audience(aud)로 허용할 client id 목록만 지정한다.
6985
+ // ============================================================================
6986
+ SPFN_AUTH_GOOGLE_NATIVE_CLIENT_IDS: {
6987
+ ...envString({
6988
+ description: "Comma-separated Google client IDs accepted as id_token audience for native sign-in (iOS, Android, web). When set, Google native sign-in is enabled. SPFN_AUTH_GOOGLE_CLIENT_ID is also accepted automatically.",
6989
+ required: false,
6990
+ examples: [
6991
+ "123-ios.apps.googleusercontent.com,123-android.apps.googleusercontent.com"
6992
+ ]
6993
+ })
6994
+ },
6995
+ SPFN_AUTH_APPLE_CLIENT_IDS: {
6996
+ ...envString({
6997
+ description: "Comma-separated Apple client IDs accepted as id_token audience for native sign-in (iOS bundle ID, web/Android Services ID). When set, Apple native sign-in is enabled.",
6998
+ required: false,
6999
+ examples: [
7000
+ "com.example.app,com.example.app.service"
7001
+ ]
7002
+ })
7003
+ },
7004
+ SPFN_AUTH_OAUTH_SUCCESS_URL: {
7005
+ ...envString({
7006
+ description: "OAuth callback page URL. This page should use OAuthCallback component to finalize session.",
7007
+ required: false,
7008
+ default: "/auth/callback",
7009
+ examples: [
7010
+ "/auth/callback",
7011
+ "https://app.example.com/auth/callback"
7012
+ ]
7013
+ })
7014
+ },
7015
+ SPFN_AUTH_OAUTH_ERROR_URL: {
7016
+ ...envString({
7017
+ description: "URL to redirect after OAuth error. Use {error} placeholder for error message.",
7018
+ required: false,
7019
+ default: "/auth/error?error={error}",
7020
+ examples: [
7021
+ "https://app.example.com/auth/error?error={error}",
7022
+ "http://localhost:3000/auth/error?error={error}"
7023
+ ]
7024
+ })
7025
+ }
7026
+ });
7027
+ }
7028
+ });
7029
+
7030
+ // src/config/index.ts
7031
+ import { createEnvRegistry } from "@spfn/core/env";
7032
+ var registry, env3;
7033
+ var init_config = __esm({
7034
+ "src/config/index.ts"() {
7035
+ "use strict";
7036
+ init_schema5();
7037
+ init_schema5();
7038
+ registry = createEnvRegistry(authEnvSchema);
7039
+ env3 = registry.validate();
7040
+ }
7041
+ });
7042
+
6527
7043
  // src/server/lib/oauth/token-cipher.ts
6528
7044
  import crypto3 from "crypto";
6529
- import { env as env3 } from "@spfn/auth/config";
6530
- function getTokenKey() {
6531
- return crypto3.createHash("sha256").update(`social-token:${env3.SPFN_AUTH_SESSION_SECRET}`).digest();
7045
+ function getTokenKeys() {
7046
+ const raw = env3.SPFN_AUTH_TOKEN_ENCRYPTION_KEYS;
7047
+ if (!raw) {
7048
+ return [];
7049
+ }
7050
+ const keys = [];
7051
+ const seen = /* @__PURE__ */ new Set();
7052
+ for (const entry of raw.split(",")) {
7053
+ const trimmed = entry.trim();
7054
+ const separator = trimmed.indexOf(":");
7055
+ if (separator <= 0 || separator === trimmed.length - 1) {
7056
+ throw new Error("Invalid SPFN_AUTH_TOKEN_ENCRYPTION_KEYS entry; expected <keyId>:<base64-key>");
7057
+ }
7058
+ const keyId = trimmed.slice(0, separator);
7059
+ const encoded = trimmed.slice(separator + 1);
7060
+ if (!KEY_ID_PATTERN.test(keyId)) {
7061
+ throw new Error("Invalid token encryption key ID; use 1-64 letters, digits, dot, underscore, or hyphen");
7062
+ }
7063
+ if (seen.has(keyId)) {
7064
+ throw new Error("Duplicate token encryption key ID");
7065
+ }
7066
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length % 4 !== 0) {
7067
+ throw new Error("Invalid token encryption key encoding; expected canonical base64");
7068
+ }
7069
+ const key = Buffer.from(encoded, "base64");
7070
+ if (key.length !== KEY_BYTES || key.toString("base64") !== encoded) {
7071
+ throw new Error("Invalid token encryption key length; expected exactly 32 bytes");
7072
+ }
7073
+ seen.add(keyId);
7074
+ keys.push({ keyId, key });
7075
+ }
7076
+ return keys;
6532
7077
  }
6533
- function isEncrypted(value) {
6534
- return value.startsWith(ENC_PREFIX);
6535
- }
6536
- function encryptToken(plain) {
6537
- if (isEncrypted(plain)) {
6538
- return plain;
6539
- }
6540
- const iv = crypto3.randomBytes(IV_BYTES);
6541
- const cipher = crypto3.createCipheriv("aes-256-gcm", getTokenKey(), iv);
6542
- const ciphertext = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
6543
- const tag = cipher.getAuthTag();
6544
- return ENC_PREFIX + Buffer.concat([iv, tag, ciphertext]).toString("base64");
6545
- }
6546
- function decryptToken(stored) {
6547
- if (!isEncrypted(stored)) {
6548
- return stored;
6549
- }
6550
- const packed = Buffer.from(stored.slice(ENC_PREFIX.length), "base64");
6551
- if (packed.length < IV_BYTES + TAG_BYTES) {
6552
- throw new Error("Malformed encrypted token: payload too short");
6553
- }
6554
- const iv = packed.subarray(0, IV_BYTES);
6555
- const tag = packed.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
6556
- const ciphertext = packed.subarray(IV_BYTES + TAG_BYTES);
6557
- const decipher = crypto3.createDecipheriv("aes-256-gcm", getTokenKey(), iv);
7078
+ function requireTokenKeys() {
7079
+ const keys = getTokenKeys();
7080
+ if (keys.length === 0) {
7081
+ throw new Error(
7082
+ "OAuth token encryption is not configured. Set SPFN_AUTH_TOKEN_ENCRYPTION_KEYS in .env.server."
7083
+ );
7084
+ }
7085
+ return keys;
7086
+ }
7087
+ function getAad(context) {
7088
+ return Buffer.from([
7089
+ "spfn-auth-social-token:v2",
7090
+ context.provider,
7091
+ context.providerUserId,
7092
+ context.tokenType
7093
+ ].join("\0"), "utf8");
7094
+ }
7095
+ function unpackCiphertext(payload, encoding) {
7096
+ const packed = Buffer.from(payload, encoding);
7097
+ if (packed.length <= IV_BYTES + TAG_BYTES) {
7098
+ throw new Error("Malformed encrypted token payload");
7099
+ }
7100
+ return {
7101
+ iv: packed.subarray(0, IV_BYTES),
7102
+ tag: packed.subarray(IV_BYTES, IV_BYTES + TAG_BYTES),
7103
+ ciphertext: packed.subarray(IV_BYTES + TAG_BYTES)
7104
+ };
7105
+ }
7106
+ function decryptAesGcm(payload, encoding, key, aad) {
7107
+ const { iv, tag, ciphertext } = unpackCiphertext(payload, encoding);
7108
+ const decipher = crypto3.createDecipheriv("aes-256-gcm", key, iv);
7109
+ if (aad) {
7110
+ decipher.setAAD(aad);
7111
+ }
6558
7112
  decipher.setAuthTag(tag);
6559
7113
  return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
6560
7114
  }
6561
- var ENC_PREFIX, IV_BYTES, TAG_BYTES;
7115
+ function getLegacyV1Key() {
7116
+ return crypto3.createHash("sha256").update(`social-token:${env3.SPFN_AUTH_SESSION_SECRET}`).digest();
7117
+ }
7118
+ function configureOAuthTokenCipher(cipher) {
7119
+ configuredCipher = cipher;
7120
+ }
7121
+ function encryptToken(value, context) {
7122
+ return configuredCipher.encrypt(value, context);
7123
+ }
7124
+ function decryptToken(value, context) {
7125
+ return configuredCipher.decrypt(value, context);
7126
+ }
7127
+ function isEncrypted(value) {
7128
+ return value.startsWith(ENCRYPTED_PREFIX);
7129
+ }
7130
+ var V1_PREFIX, V2_PREFIX, ENCRYPTED_PREFIX, IV_BYTES, TAG_BYTES, KEY_BYTES, KEY_ID_PATTERN, EnvironmentKeyringTokenCipher, configuredCipher;
6562
7131
  var init_token_cipher = __esm({
6563
7132
  "src/server/lib/oauth/token-cipher.ts"() {
6564
7133
  "use strict";
6565
- ENC_PREFIX = "enc:v1:";
7134
+ init_config();
7135
+ V1_PREFIX = "enc:v1:";
7136
+ V2_PREFIX = "enc:v2:";
7137
+ ENCRYPTED_PREFIX = "enc:";
6566
7138
  IV_BYTES = 12;
6567
7139
  TAG_BYTES = 16;
7140
+ KEY_BYTES = 32;
7141
+ KEY_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
7142
+ EnvironmentKeyringTokenCipher = class {
7143
+ async encrypt(value, context) {
7144
+ const activeKey = requireTokenKeys()[0];
7145
+ const iv = crypto3.randomBytes(IV_BYTES);
7146
+ const cipher = crypto3.createCipheriv("aes-256-gcm", activeKey.key, iv);
7147
+ cipher.setAAD(getAad(context));
7148
+ const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
7149
+ const tag = cipher.getAuthTag();
7150
+ const payload = Buffer.concat([iv, tag, ciphertext]).toString("base64url");
7151
+ return `${V2_PREFIX}${activeKey.keyId}:${payload}`;
7152
+ }
7153
+ async decrypt(value, context) {
7154
+ if (value.startsWith(V2_PREFIX)) {
7155
+ const remainder = value.slice(V2_PREFIX.length);
7156
+ const separator = remainder.indexOf(":");
7157
+ if (separator <= 0 || separator === remainder.length - 1) {
7158
+ throw new Error("Malformed v2 encrypted token");
7159
+ }
7160
+ const keyId = remainder.slice(0, separator);
7161
+ const payload = remainder.slice(separator + 1);
7162
+ const keys = requireTokenKeys();
7163
+ const selected = keys.find((key) => key.keyId === keyId);
7164
+ if (!selected) {
7165
+ throw new Error("Encrypted token references an unavailable key ID");
7166
+ }
7167
+ return {
7168
+ value: decryptAesGcm(payload, "base64url", selected.key, getAad(context)),
7169
+ needsRotation: selected.keyId !== keys[0].keyId
7170
+ };
7171
+ }
7172
+ if (value.startsWith(V1_PREFIX)) {
7173
+ return {
7174
+ value: decryptAesGcm(value.slice(V1_PREFIX.length), "base64", getLegacyV1Key()),
7175
+ needsRotation: true
7176
+ };
7177
+ }
7178
+ if (value.startsWith(ENCRYPTED_PREFIX)) {
7179
+ throw new Error("Unsupported encrypted token version");
7180
+ }
7181
+ return { value, needsRotation: true };
7182
+ }
7183
+ };
7184
+ configuredCipher = new EnvironmentKeyringTokenCipher();
6568
7185
  }
6569
7186
  });
6570
7187
 
@@ -6589,23 +7206,34 @@ var init_social_accounts_repository = __esm({
6589
7206
  if (!account) {
6590
7207
  return account;
6591
7208
  }
7209
+ const context = (tokenType) => ({
7210
+ provider: account.provider,
7211
+ providerUserId: account.providerUserId,
7212
+ tokenType
7213
+ });
7214
+ const access = account.accessToken ? await decryptToken(account.accessToken, context("access")) : null;
7215
+ const refresh = account.refreshToken ? await decryptToken(account.refreshToken, context("refresh")) : null;
6592
7216
  const heal = {};
6593
- if (account.accessToken && !isEncrypted(account.accessToken)) {
6594
- heal.accessToken = encryptToken(account.accessToken);
6595
- }
6596
- if (account.refreshToken && !isEncrypted(account.refreshToken)) {
6597
- heal.refreshToken = encryptToken(account.refreshToken);
6598
- }
6599
- if (heal.accessToken || heal.refreshToken) {
7217
+ if (access?.needsRotation || refresh?.needsRotation) {
6600
7218
  try {
6601
- await this.db.update(userSocialAccounts).set(heal).where(eq10(userSocialAccounts.id, account.id));
7219
+ if (access?.needsRotation) {
7220
+ heal.accessToken = await encryptToken(access.value, context("access"));
7221
+ }
7222
+ if (refresh?.needsRotation) {
7223
+ heal.refreshToken = await encryptToken(refresh.value, context("refresh"));
7224
+ }
7225
+ await this.db.update(userSocialAccounts).set(heal).where(and7(
7226
+ eq10(userSocialAccounts.id, account.id),
7227
+ access?.needsRotation && account.accessToken !== null ? eq10(userSocialAccounts.accessToken, account.accessToken) : void 0,
7228
+ refresh?.needsRotation && account.refreshToken !== null ? eq10(userSocialAccounts.refreshToken, account.refreshToken) : void 0
7229
+ ));
6602
7230
  } catch {
6603
7231
  }
6604
7232
  }
6605
7233
  return {
6606
7234
  ...account,
6607
- accessToken: account.accessToken ? decryptToken(account.accessToken) : account.accessToken,
6608
- refreshToken: account.refreshToken ? decryptToken(account.refreshToken) : account.refreshToken
7235
+ accessToken: access?.value ?? account.accessToken,
7236
+ refreshToken: refresh?.value ?? account.refreshToken
6609
7237
  };
6610
7238
  }
6611
7239
  /**
@@ -6647,10 +7275,15 @@ var init_social_accounts_repository = __esm({
6647
7275
  * Write primary 사용
6648
7276
  */
6649
7277
  async create(data) {
7278
+ const context = (tokenType) => ({
7279
+ provider: data.provider,
7280
+ providerUserId: data.providerUserId,
7281
+ tokenType
7282
+ });
6650
7283
  const created = await this._create(userSocialAccounts, {
6651
7284
  ...data,
6652
- accessToken: data.accessToken ? encryptToken(data.accessToken) : data.accessToken,
6653
- refreshToken: data.refreshToken ? encryptToken(data.refreshToken) : data.refreshToken,
7285
+ accessToken: data.accessToken ? await encryptToken(data.accessToken, context("access")) : data.accessToken,
7286
+ refreshToken: data.refreshToken ? await encryptToken(data.refreshToken, context("refresh")) : data.refreshToken,
6654
7287
  createdAt: /* @__PURE__ */ new Date(),
6655
7288
  updatedAt: /* @__PURE__ */ new Date()
6656
7289
  });
@@ -6660,21 +7293,34 @@ var init_social_accounts_repository = __esm({
6660
7293
  * 토큰 정보 업데이트
6661
7294
  * Write primary 사용
6662
7295
  */
6663
- async updateTokens(id11, data) {
7296
+ async updateTokens(id12, data) {
7297
+ const accounts = await this.db.select({
7298
+ provider: userSocialAccounts.provider,
7299
+ providerUserId: userSocialAccounts.providerUserId
7300
+ }).from(userSocialAccounts).where(eq10(userSocialAccounts.id, id12)).limit(1);
7301
+ const account = accounts[0];
7302
+ if (!account) {
7303
+ return null;
7304
+ }
7305
+ const context = (tokenType) => ({
7306
+ provider: account.provider,
7307
+ providerUserId: account.providerUserId,
7308
+ tokenType
7309
+ });
6664
7310
  const result = await this.db.update(userSocialAccounts).set({
6665
7311
  ...data,
6666
- accessToken: data.accessToken ? encryptToken(data.accessToken) : data.accessToken,
6667
- refreshToken: data.refreshToken ? encryptToken(data.refreshToken) : data.refreshToken,
7312
+ accessToken: data.accessToken ? await encryptToken(data.accessToken, context("access")) : data.accessToken,
7313
+ refreshToken: data.refreshToken ? await encryptToken(data.refreshToken, context("refresh")) : data.refreshToken,
6668
7314
  updatedAt: /* @__PURE__ */ new Date()
6669
- }).where(eq10(userSocialAccounts.id, id11)).returning();
7315
+ }).where(eq10(userSocialAccounts.id, id12)).returning();
6670
7316
  return this.decryptAccount(result[0] ?? null);
6671
7317
  }
6672
7318
  /**
6673
7319
  * 소셜 계정 삭제
6674
7320
  * Write primary 사용
6675
7321
  */
6676
- async deleteById(id11) {
6677
- const result = await this.db.delete(userSocialAccounts).where(eq10(userSocialAccounts.id, id11)).returning();
7322
+ async deleteById(id12) {
7323
+ const result = await this.db.delete(userSocialAccounts).where(eq10(userSocialAccounts.id, id12)).returning();
6678
7324
  return result[0] ?? null;
6679
7325
  }
6680
7326
  /**
@@ -6690,6 +7336,17 @@ var init_social_accounts_repository = __esm({
6690
7336
  ).returning();
6691
7337
  return result[0] ?? null;
6692
7338
  }
7339
+ /**
7340
+ * 사용자의 모든 소셜 계정 삭제 (계정 익명화 파기용)
7341
+ *
7342
+ * provider unique index(provider, providerUserId)를 해제해 같은 소셜 계정으로
7343
+ * 재가입할 수 있게 한다.
7344
+ * Write primary 사용
7345
+ */
7346
+ async deleteAllByUserId(userId) {
7347
+ const result = await this.db.delete(userSocialAccounts).where(eq10(userSocialAccounts.userId, userId)).returning();
7348
+ return result.length;
7349
+ }
6693
7350
  };
6694
7351
  socialAccountsRepository = new SocialAccountsRepository();
6695
7352
  }
@@ -6732,6 +7389,125 @@ var init_auth_metadata_repository = __esm({
6732
7389
  }
6733
7390
  });
6734
7391
 
7392
+ // src/server/repositories/account-deletion-requests.repository.ts
7393
+ import { eq as eq12, and as and8, lte } from "drizzle-orm";
7394
+ import { BaseRepository as BaseRepository12 } from "@spfn/core/db";
7395
+ var AccountDeletionRequestsRepository, accountDeletionRequestsRepository;
7396
+ var init_account_deletion_requests_repository = __esm({
7397
+ "src/server/repositories/account-deletion-requests.repository.ts"() {
7398
+ "use strict";
7399
+ init_account_deletion_requests();
7400
+ AccountDeletionRequestsRepository = class extends BaseRepository12 {
7401
+ /**
7402
+ * ID로 요청 조회
7403
+ * Read replica 사용
7404
+ */
7405
+ async findById(id12) {
7406
+ const result = await this.readDb.select().from(accountDeletionRequests).where(eq12(accountDeletionRequests.id, id12)).limit(1);
7407
+ return result[0] ?? null;
7408
+ }
7409
+ /**
7410
+ * User ID로 pending 요청 조회 (유저당 최대 1건, partial unique index로 보장)
7411
+ * Read replica 사용
7412
+ */
7413
+ async findPendingByUserId(userId) {
7414
+ const result = await this.readDb.select().from(accountDeletionRequests).where(
7415
+ and8(
7416
+ eq12(accountDeletionRequests.userId, userId),
7417
+ eq12(accountDeletionRequests.status, "pending")
7418
+ )
7419
+ ).limit(1);
7420
+ return result[0] ?? null;
7421
+ }
7422
+ /**
7423
+ * User ID로 pending 요청 조회 — Write primary에서 직접 읽는다.
7424
+ *
7425
+ * 로그인/OAuth/authenticate 게이트가 pending_deletion 유저의 purgeScheduledAt을
7426
+ * 표시하는 데 쓴다. 이 경로들은 복제 지연이 있으면 방금 커밋된 상태 전이를 놓칠 수
7427
+ * 있어(예: 삭제 요청 직후 OAuth 로그인 시도), replica가 아닌 primary에서 읽는다.
7428
+ */
7429
+ async findPendingByUserIdOnPrimary(userId) {
7430
+ const result = await this.db.select().from(accountDeletionRequests).where(
7431
+ and8(
7432
+ eq12(accountDeletionRequests.userId, userId),
7433
+ eq12(accountDeletionRequests.status, "pending")
7434
+ )
7435
+ ).limit(1);
7436
+ return result[0] ?? null;
7437
+ }
7438
+ /**
7439
+ * 파기 스윕 대상 조회 — status='pending' AND purgeScheduledAt <= now
7440
+ * Read replica 사용
7441
+ */
7442
+ async findDueForPurge(now) {
7443
+ return this.readDb.select().from(accountDeletionRequests).where(
7444
+ and8(
7445
+ eq12(accountDeletionRequests.status, "pending"),
7446
+ lte(accountDeletionRequests.purgeScheduledAt, now)
7447
+ )
7448
+ );
7449
+ }
7450
+ /**
7451
+ * 요청 생성
7452
+ * Write primary 사용
7453
+ */
7454
+ async create(data) {
7455
+ return await this._create(accountDeletionRequests, {
7456
+ ...data,
7457
+ createdAt: /* @__PURE__ */ new Date(),
7458
+ updatedAt: /* @__PURE__ */ new Date()
7459
+ });
7460
+ }
7461
+ /**
7462
+ * 요청 취소 (복구)
7463
+ *
7464
+ * `WHERE status = 'pending'` 조건부 UPDATE — 이미 completed(파기 완료)된 row를
7465
+ * 뒤늦게 도착한 cancel이 덮어쓰지 않도록 한다. 0 row 매치(= 이미 completed거나
7466
+ * cancelled) 시 null을 반환하니 호출자가 그 결과를 확인해야 한다.
7467
+ * Write primary 사용
7468
+ */
7469
+ async markCancelled(id12) {
7470
+ const result = await this.db.update(accountDeletionRequests).set({
7471
+ status: "cancelled",
7472
+ cancelledAt: /* @__PURE__ */ new Date(),
7473
+ updatedAt: /* @__PURE__ */ new Date()
7474
+ }).where(
7475
+ and8(
7476
+ eq12(accountDeletionRequests.id, id12),
7477
+ eq12(accountDeletionRequests.status, "pending")
7478
+ )
7479
+ ).returning();
7480
+ return result[0] ?? null;
7481
+ }
7482
+ /**
7483
+ * 요청 완료 처리 (파기 실행 직전에 호출하는 "claim" — 파기 실행 후가 아니다)
7484
+ *
7485
+ * `WHERE status = 'pending'` 조건부 UPDATE. Postgres UPDATE는 매치되는 row에 락을
7486
+ * 걸고 최신 커밋 상태로 WHERE를 재평가하므로, 이 호출 하나로 (1) 동시에 취소된
7487
+ * row를 안전하게 걸러내고 (2) row-level 락으로 동시 파기 시도를 직렬화한다.
7488
+ * 0 row 매치(= 이미 취소되었거나 다른 파기가 먼저 처리함) 시 null을 반환 — 호출자는
7489
+ * destructive DML을 실행하기 **전에** 반드시 이 결과를 확인해야 한다.
7490
+ * Write primary 사용
7491
+ */
7492
+ async markCompleted(id12, purgeStrategy) {
7493
+ const result = await this.db.update(accountDeletionRequests).set({
7494
+ status: "completed",
7495
+ completedAt: /* @__PURE__ */ new Date(),
7496
+ purgeStrategy,
7497
+ updatedAt: /* @__PURE__ */ new Date()
7498
+ }).where(
7499
+ and8(
7500
+ eq12(accountDeletionRequests.id, id12),
7501
+ eq12(accountDeletionRequests.status, "pending")
7502
+ )
7503
+ ).returning();
7504
+ return result[0] ?? null;
7505
+ }
7506
+ };
7507
+ accountDeletionRequestsRepository = new AccountDeletionRequestsRepository();
7508
+ }
7509
+ });
7510
+
6735
7511
  // src/server/repositories/index.ts
6736
7512
  var init_repositories = __esm({
6737
7513
  "src/server/repositories/index.ts"() {
@@ -6747,6 +7523,7 @@ var init_repositories = __esm({
6747
7523
  init_invitations_repository();
6748
7524
  init_social_accounts_repository();
6749
7525
  init_auth_metadata_repository();
7526
+ init_account_deletion_requests_repository();
6750
7527
  }
6751
7528
  });
6752
7529
 
@@ -6838,7 +7615,7 @@ async function removePermissionFromRole(roleId, permissionId) {
6838
7615
  }
6839
7616
  async function setRolePermissions(roleId, permissionIds) {
6840
7617
  const roleIdNum = Number(roleId);
6841
- const permissionIdNums = permissionIds.map((id11) => Number(id11));
7618
+ const permissionIdNums = permissionIds.map((id12) => Number(id12));
6842
7619
  await rolePermissionsRepository.setPermissionsForRole(roleIdNum, permissionIdNums);
6843
7620
  }
6844
7621
  async function getAllRoles(includeInactive = false) {
@@ -6858,7 +7635,7 @@ async function getRolePermissions(roleId) {
6858
7635
  }
6859
7636
  const permissionIds = mappings.map((m) => m.permissionId);
6860
7637
  const perms = await Promise.all(
6861
- permissionIds.map((id11) => permissionsRepository.findById(id11))
7638
+ permissionIds.map((id12) => permissionsRepository.findById(id12))
6862
7639
  );
6863
7640
  return perms.filter((p) => p !== null).map((p) => p.name);
6864
7641
  }
@@ -6873,7 +7650,7 @@ var init_role_service = __esm({
6873
7650
  import "@spfn/auth/config";
6874
7651
 
6875
7652
  // src/server/routes/index.ts
6876
- import { defineRouter as defineRouter5 } from "@spfn/core/route";
7653
+ import { defineRouter as defineRouter6 } from "@spfn/core/route";
6877
7654
 
6878
7655
  // src/server/routes/auth/index.ts
6879
7656
  init_schema3();
@@ -6897,6 +7674,13 @@ async function verifyPassword(password, hash2) {
6897
7674
  }
6898
7675
  return bcrypt.verify(password, hash2);
6899
7676
  }
7677
+ var dummyHashPromise = null;
7678
+ function getDummyPasswordHash() {
7679
+ if (!dummyHashPromise) {
7680
+ dummyHashPromise = hashPassword("spfn-nonexistent-account-timing-equalizer");
7681
+ }
7682
+ return dummyHashPromise;
7683
+ }
6900
7684
  var passwordValidator = createPasswordParser({
6901
7685
  minLength: 8,
6902
7686
  requireUppercase: true,
@@ -7049,19 +7833,103 @@ init_types();
7049
7833
 
7050
7834
  // src/server/services/auth.service.ts
7051
7835
  init_repositories();
7052
- import { ValidationError as ValidationError2 } from "@spfn/core/errors";
7836
+ import { ValidationError as ValidationError3 } from "@spfn/core/errors";
7053
7837
  import {
7054
- InvalidCredentialsError,
7838
+ InvalidCredentialsError as InvalidCredentialsError2,
7055
7839
  AccountDisabledError,
7840
+ AccountPendingDeletionError,
7056
7841
  AccountAlreadyExistsError,
7057
- InvalidVerificationTokenError,
7058
- VerificationTokenPurposeMismatchError,
7059
- VerificationTokenTargetMismatchError
7842
+ InvalidVerificationTokenError as InvalidVerificationTokenError2,
7843
+ VerificationTokenPurposeMismatchError as VerificationTokenPurposeMismatchError2,
7844
+ VerificationTokenTargetMismatchError as VerificationTokenTargetMismatchError2
7060
7845
  } from "@spfn/auth/errors";
7061
7846
 
7847
+ // src/server/lib/config.ts
7848
+ import { env as env4 } from "@spfn/auth/config";
7849
+ function getCookieSuffix() {
7850
+ const port = process.env.PORT;
7851
+ return port ? `_${port}` : "";
7852
+ }
7853
+ var COOKIE_NAMES = {
7854
+ /** Encrypted session data (userId, privateKey, keyId, algorithm) */
7855
+ get SESSION() {
7856
+ return `spfn_session${getCookieSuffix()}`;
7857
+ },
7858
+ /** Current key ID (for key rotation) */
7859
+ get SESSION_KEY_ID() {
7860
+ return `spfn_session_key_id${getCookieSuffix()}`;
7861
+ },
7862
+ /** Pending OAuth session (privateKey, keyId, algorithm) - temporary during OAuth flow */
7863
+ get OAUTH_PENDING() {
7864
+ return `spfn_oauth_pending${getCookieSuffix()}`;
7865
+ },
7866
+ /** OAuth CSRF nonce — double-submit against the (encrypted) state.nonce at callback */
7867
+ get OAUTH_CSRF() {
7868
+ return `spfn_oauth_csrf${getCookieSuffix()}`;
7869
+ }
7870
+ };
7871
+ function matchOAuthCsrfCookies(cookies) {
7872
+ return Object.entries(cookies).filter(([name]) => /^spfn_oauth_csrf(_\d+)?$/.test(name)).map(([name, value]) => ({ name, value }));
7873
+ }
7874
+ function parseDuration(duration) {
7875
+ if (typeof duration === "number") {
7876
+ return duration;
7877
+ }
7878
+ const match = duration.match(/^(\d+)([dhms]?)$/);
7879
+ if (!match) {
7880
+ throw new Error(`Invalid duration format: ${duration}. Use format like '30d', '12h', '45m', '3600s', or plain number.`);
7881
+ }
7882
+ const value = parseInt(match[1], 10);
7883
+ const unit = match[2] || "s";
7884
+ switch (unit) {
7885
+ case "d":
7886
+ return value * 24 * 60 * 60;
7887
+ case "h":
7888
+ return value * 60 * 60;
7889
+ case "m":
7890
+ return value * 60;
7891
+ case "s":
7892
+ return value;
7893
+ default:
7894
+ throw new Error(`Unknown duration unit: ${unit}`);
7895
+ }
7896
+ }
7897
+ var globalConfig = {
7898
+ sessionTtl: "7d"
7899
+ // Default: 7 days
7900
+ };
7901
+ function configureAuth(config2) {
7902
+ globalConfig = {
7903
+ ...globalConfig,
7904
+ ...config2
7905
+ };
7906
+ }
7907
+ function getAuthConfig() {
7908
+ return { ...globalConfig };
7909
+ }
7910
+ async function runBeforeRegister(context) {
7911
+ const { beforeRegister } = globalConfig;
7912
+ if (beforeRegister) {
7913
+ await beforeRegister(context);
7914
+ }
7915
+ }
7916
+ function getSessionTtl(override) {
7917
+ if (override !== void 0) {
7918
+ return parseDuration(override);
7919
+ }
7920
+ if (globalConfig.sessionTtl !== void 0) {
7921
+ return parseDuration(globalConfig.sessionTtl);
7922
+ }
7923
+ const envTtl = env4.SPFN_AUTH_SESSION_TTL;
7924
+ if (envTtl) {
7925
+ return parseDuration(envTtl);
7926
+ }
7927
+ return 7 * 24 * 60 * 60;
7928
+ }
7929
+
7062
7930
  // src/server/services/verification.service.ts
7063
7931
  import crypto4 from "crypto";
7064
- import { env as env4 } from "@spfn/auth/config";
7932
+ import { env as env5 } from "@spfn/auth/config";
7065
7933
  import { InvalidVerificationCodeError } from "@spfn/auth/errors";
7066
7934
  import jwt2 from "jsonwebtoken";
7067
7935
  import { sendEmail, sendSMS } from "@spfn/notification/server";
@@ -7130,7 +7998,7 @@ async function markCodeAsUsed(codeId) {
7130
7998
  await verificationCodesRepository.markAsUsed(codeId);
7131
7999
  }
7132
8000
  function createVerificationToken(payload) {
7133
- return jwt2.sign(payload, env4.SPFN_AUTH_VERIFICATION_TOKEN_SECRET, {
8001
+ return jwt2.sign(payload, env5.SPFN_AUTH_VERIFICATION_TOKEN_SECRET, {
7134
8002
  expiresIn: VERIFICATION_TOKEN_EXPIRY,
7135
8003
  issuer: "spfn-auth",
7136
8004
  audience: "spfn-client"
@@ -7138,7 +8006,7 @@ function createVerificationToken(payload) {
7138
8006
  }
7139
8007
  function validateVerificationToken(token) {
7140
8008
  try {
7141
- const decoded = jwt2.verify(token, env4.SPFN_AUTH_VERIFICATION_TOKEN_SECRET, {
8009
+ const decoded = jwt2.verify(token, env5.SPFN_AUTH_VERIFICATION_TOKEN_SECRET, {
7142
8010
  issuer: "spfn-auth",
7143
8011
  audience: "spfn-client"
7144
8012
  });
@@ -7314,7 +8182,7 @@ async function revokeKeyService(params) {
7314
8182
  init_repositories();
7315
8183
  import { ValidationError } from "@spfn/core/errors";
7316
8184
  import { ReservedUsernameError, UsernameAlreadyTakenError } from "@spfn/auth/errors";
7317
- import { env as env5 } from "@spfn/auth/config";
8185
+ import { env as env6 } from "@spfn/auth/config";
7318
8186
  async function getUserByIdService(userId) {
7319
8187
  return await usersRepository.findById(userId);
7320
8188
  }
@@ -7331,7 +8199,7 @@ async function updateUserService(userId, updates) {
7331
8199
  await usersRepository.updateById(userId, updates);
7332
8200
  }
7333
8201
  function getReservedUsernames() {
7334
- const raw = env5.SPFN_AUTH_RESERVED_USERNAMES ?? "";
8202
+ const raw = env6.SPFN_AUTH_RESERVED_USERNAMES ?? "";
7335
8203
  if (!raw) {
7336
8204
  return /* @__PURE__ */ new Set();
7337
8205
  }
@@ -7343,8 +8211,8 @@ function isReservedUsername(username) {
7343
8211
  return getReservedUsernames().has(username.toLowerCase());
7344
8212
  }
7345
8213
  function validateUsernameLength(username) {
7346
- const min = env5.SPFN_AUTH_USERNAME_MIN_LENGTH ?? 3;
7347
- const max = env5.SPFN_AUTH_USERNAME_MAX_LENGTH ?? 30;
8214
+ const min = env6.SPFN_AUTH_USERNAME_MIN_LENGTH ?? 3;
8215
+ const max = env6.SPFN_AUTH_USERNAME_MAX_LENGTH ?? 30;
7348
8216
  if (username.length < min) {
7349
8217
  throw new ValidationError({
7350
8218
  message: `Username must be at least ${min} characters`,
@@ -7381,6 +8249,48 @@ async function updateUsernameService(userId, username) {
7381
8249
  return await usersRepository.updateById(userIdNum, { username });
7382
8250
  }
7383
8251
 
8252
+ // src/server/services/account-deletion.service.ts
8253
+ init_repositories();
8254
+ import { ValidationError as ValidationError2, NotFoundError as NotFoundError2 } from "@spfn/core/errors";
8255
+ import { runInTransaction, onAfterCommit } from "@spfn/core/db";
8256
+ import { sendEmail as sendEmail2 } from "@spfn/notification/server";
8257
+ import {
8258
+ InvalidCredentialsError,
8259
+ InvalidVerificationTokenError,
8260
+ VerificationTokenPurposeMismatchError,
8261
+ VerificationTokenTargetMismatchError,
8262
+ DeletionAlreadyRequestedError,
8263
+ DeletionNotRequestedError,
8264
+ ImmediateDeletionNotAllowedError
8265
+ } from "@spfn/auth/errors";
8266
+
8267
+ // src/server/lib/deletion-config.ts
8268
+ var DEFAULT_DELETION_GRACE_PERIOD_DAYS = 30;
8269
+ var DEFAULT_DELETION_PURGE_STRATEGY = "anonymize";
8270
+ var DEFAULT_DELETION_ALLOW_SELF_IMMEDIATE = false;
8271
+ var DEFAULT_DELETION_PURGE_CRON = "0 4 * * *";
8272
+ var DEFAULT_DELETION_SEND_NOTIFICATIONS = true;
8273
+ var config = {
8274
+ gracePeriodDays: DEFAULT_DELETION_GRACE_PERIOD_DAYS,
8275
+ purgeStrategy: DEFAULT_DELETION_PURGE_STRATEGY,
8276
+ allowSelfImmediate: DEFAULT_DELETION_ALLOW_SELF_IMMEDIATE,
8277
+ purgeCron: DEFAULT_DELETION_PURGE_CRON,
8278
+ sendNotifications: DEFAULT_DELETION_SEND_NOTIFICATIONS
8279
+ };
8280
+ function configureDeletion(options) {
8281
+ config = {
8282
+ gracePeriodDays: options?.gracePeriodDays ?? DEFAULT_DELETION_GRACE_PERIOD_DAYS,
8283
+ purgeStrategy: options?.purgeStrategy ?? DEFAULT_DELETION_PURGE_STRATEGY,
8284
+ allowSelfImmediate: options?.allowSelfImmediate ?? DEFAULT_DELETION_ALLOW_SELF_IMMEDIATE,
8285
+ purgeCron: options?.purgeCron ?? DEFAULT_DELETION_PURGE_CRON,
8286
+ sendNotifications: options?.sendNotifications ?? DEFAULT_DELETION_SEND_NOTIFICATIONS,
8287
+ onBeforePurge: options?.onBeforePurge
8288
+ };
8289
+ }
8290
+ function getDeletionConfig() {
8291
+ return config;
8292
+ }
8293
+
7384
8294
  // src/server/events/index.ts
7385
8295
  init_esm();
7386
8296
  init_types();
@@ -7433,37 +8343,335 @@ var invitationAcceptedEvent = defineEvent(
7433
8343
  metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown()))
7434
8344
  })
7435
8345
  );
8346
+ var authDeletionRequestedEvent = defineEvent(
8347
+ "auth.deletion.requested",
8348
+ Type.Object({
8349
+ userId: Type.String(),
8350
+ userPublicId: Type.String(),
8351
+ purgeScheduledAt: Type.String(),
8352
+ requestedBy: Type.Union([Type.Literal("self"), Type.Literal("admin")])
8353
+ })
8354
+ );
8355
+ var authDeletionCancelledEvent = defineEvent(
8356
+ "auth.deletion.cancelled",
8357
+ Type.Object({
8358
+ userId: Type.String(),
8359
+ userPublicId: Type.String()
8360
+ })
8361
+ );
8362
+ var authDeletionCompletedEvent = defineEvent(
8363
+ "auth.deletion.completed",
8364
+ Type.Object({
8365
+ userPublicId: Type.String(),
8366
+ purgeStrategy: Type.Union([Type.Literal("anonymize"), Type.Literal("hard-delete")])
8367
+ })
8368
+ );
7436
8369
 
7437
- // src/server/services/auth.service.ts
7438
- var dummyHashPromise = null;
7439
- function getDummyHash() {
7440
- if (!dummyHashPromise) {
7441
- dummyHashPromise = hashPassword("spfn-nonexistent-account-timing-equalizer");
8370
+ // src/server/services/account-deletion.service.ts
8371
+ var POSTGRES_UNIQUE_VIOLATION = "23505";
8372
+ function isUniqueViolation(error) {
8373
+ return typeof error === "object" && error !== null && error.code === POSTGRES_UNIQUE_VIOLATION;
8374
+ }
8375
+ async function getPendingDeletionInfo(userId) {
8376
+ const request = await accountDeletionRequestsRepository.findPendingByUserIdOnPrimary(userId);
8377
+ return request ? { purgeScheduledAt: request.purgeScheduledAt } : null;
8378
+ }
8379
+ async function verifyReauthCredential(user, params) {
8380
+ if (user.passwordHash) {
8381
+ if (!params.password) {
8382
+ throw new ValidationError2({ message: "Password is required to confirm this action" });
8383
+ }
8384
+ const valid = await verifyPassword(params.password, user.passwordHash);
8385
+ if (!valid) {
8386
+ throw new InvalidCredentialsError({ message: "Incorrect password" });
8387
+ }
8388
+ return;
8389
+ }
8390
+ if (!params.verificationToken) {
8391
+ throw new ValidationError2({ message: "A verification code is required to confirm this action" });
8392
+ }
8393
+ const payload = validateVerificationToken(params.verificationToken);
8394
+ if (!payload) {
8395
+ throw new InvalidVerificationTokenError();
8396
+ }
8397
+ if (payload.purpose !== "account_deletion") {
8398
+ throw new VerificationTokenPurposeMismatchError({ expected: "account_deletion", actual: payload.purpose });
8399
+ }
8400
+ const target = user.email ?? user.phone;
8401
+ if (!target || payload.target !== target) {
8402
+ throw new VerificationTokenTargetMismatchError();
8403
+ }
8404
+ }
8405
+ async function sendDeletionEmail(to, subject, text12) {
8406
+ const result = await sendEmail2({ to, subject, text: text12 });
8407
+ if (!result.success) {
8408
+ authLogger.email.error("Failed to send account deletion email", { to, subject, error: result.error });
8409
+ }
8410
+ }
8411
+ async function notifyDeletionRequested(user, purgeScheduledAt) {
8412
+ if (!getDeletionConfig().sendNotifications || !user.email) {
8413
+ return;
8414
+ }
8415
+ await sendDeletionEmail(
8416
+ user.email,
8417
+ "Account deletion requested",
8418
+ `We received a request to delete your account. It is scheduled for permanent deletion on ${purgeScheduledAt.toISOString()}. If this wasn't you, sign in before that date to cancel it.`
8419
+ );
8420
+ }
8421
+ async function notifyDeletionCancelled(user) {
8422
+ if (!getDeletionConfig().sendNotifications || !user.email) {
8423
+ return;
8424
+ }
8425
+ await sendDeletionEmail(
8426
+ user.email,
8427
+ "Account deletion cancelled",
8428
+ "Your account deletion request was cancelled and your account is active again."
8429
+ );
8430
+ }
8431
+ async function notifyPurgeFinal(email) {
8432
+ if (!getDeletionConfig().sendNotifications) {
8433
+ return;
8434
+ }
8435
+ await sendDeletionEmail(
8436
+ email,
8437
+ "Your account has been deleted",
8438
+ "Your account and associated data have now been permanently deleted, as requested."
8439
+ );
8440
+ }
8441
+ function addDays(date, days) {
8442
+ const result = new Date(date);
8443
+ result.setDate(result.getDate() + days);
8444
+ return result;
8445
+ }
8446
+ async function requestAccountDeletionService(userId, params) {
8447
+ const { requestedBy, password, verificationToken, reason, immediate } = params;
8448
+ const user = await usersRepository.findById(userId);
8449
+ if (!user) {
8450
+ throw new NotFoundError2({ message: "User not found", resource: "User" });
8451
+ }
8452
+ if (user.status === "pending_deletion" || user.status === "deleted") {
8453
+ throw new DeletionAlreadyRequestedError();
8454
+ }
8455
+ if (requestedBy === "self") {
8456
+ await verifyReauthCredential(user, { password, verificationToken });
8457
+ }
8458
+ const config2 = getDeletionConfig();
8459
+ const wantsImmediate = immediate === true;
8460
+ if (wantsImmediate && requestedBy === "self" && !config2.allowSelfImmediate) {
8461
+ throw new ImmediateDeletionNotAllowedError();
8462
+ }
8463
+ const gracePeriodDays = wantsImmediate ? 0 : config2.gracePeriodDays;
8464
+ const requestedAt = /* @__PURE__ */ new Date();
8465
+ const purgeScheduledAt = addDays(requestedAt, gracePeriodDays);
8466
+ await usersRepository.updateById(user.id, { status: "pending_deletion" });
8467
+ let request;
8468
+ try {
8469
+ request = await accountDeletionRequestsRepository.create({
8470
+ userId: user.id,
8471
+ userPublicId: user.publicId,
8472
+ requestedAt,
8473
+ purgeScheduledAt,
8474
+ status: "pending",
8475
+ requestedBy,
8476
+ reason: reason ?? null
8477
+ });
8478
+ } catch (error) {
8479
+ if (isUniqueViolation(error)) {
8480
+ throw new DeletionAlreadyRequestedError();
8481
+ }
8482
+ throw error;
8483
+ }
8484
+ await keysRepository.revokeAllActiveByUserId(user.id, "Account deletion requested");
8485
+ onAfterCommit(() => authDeletionRequestedEvent.emit({
8486
+ userId: String(user.id),
8487
+ userPublicId: user.publicId,
8488
+ purgeScheduledAt: purgeScheduledAt.toISOString(),
8489
+ requestedBy
8490
+ }));
8491
+ onAfterCommit(() => notifyDeletionRequested(user, purgeScheduledAt));
8492
+ if (gracePeriodDays === 0) {
8493
+ await purgePendingRequest(request);
8494
+ }
8495
+ return { requestId: request.id, purgeScheduledAt };
8496
+ }
8497
+ async function cancelAccountDeletionService(params) {
8498
+ const { email, phone, password, verificationToken } = params;
8499
+ if (!email && !phone) {
8500
+ throw new ValidationError2({ message: "Either email or phone must be provided" });
8501
+ }
8502
+ const user = await usersRepository.findByEmailOrPhone(email, phone);
8503
+ if (!user) {
8504
+ if (password) {
8505
+ await verifyPassword(password, await getDummyPasswordHash());
8506
+ }
8507
+ throw new InvalidCredentialsError();
8508
+ }
8509
+ if (user.status !== "pending_deletion") {
8510
+ await verifyReauthCredential(user, { password, verificationToken });
8511
+ throw new DeletionNotRequestedError();
8512
+ }
8513
+ await verifyReauthCredential(user, { password, verificationToken });
8514
+ const pendingRequest = await accountDeletionRequestsRepository.findPendingByUserId(user.id);
8515
+ if (!pendingRequest) {
8516
+ throw new DeletionNotRequestedError();
8517
+ }
8518
+ const claimed = await accountDeletionRequestsRepository.markCancelled(pendingRequest.id);
8519
+ if (!claimed) {
8520
+ throw new DeletionNotRequestedError();
8521
+ }
8522
+ await usersRepository.reactivateFromPendingDeletion(user.id);
8523
+ onAfterCommit(() => authDeletionCancelledEvent.emit({
8524
+ userId: String(user.id),
8525
+ userPublicId: user.publicId
8526
+ }));
8527
+ onAfterCommit(() => notifyDeletionCancelled(user));
8528
+ return { userId: String(user.id) };
8529
+ }
8530
+ async function anonymizeUser(user) {
8531
+ await usersRepository.updateById(user.id, {
8532
+ email: `deleted-${user.publicId}@deleted.invalid`,
8533
+ phone: null,
8534
+ username: null,
8535
+ passwordHash: null,
8536
+ status: "deleted",
8537
+ deletedAt: /* @__PURE__ */ new Date(),
8538
+ deletedBy: "system:auth.deletion.purge"
8539
+ });
8540
+ await socialAccountsRepository.deleteAllByUserId(user.id);
8541
+ await keysRepository.deleteAllByUserId(user.id);
8542
+ await userProfilesRepository.updateByUserId(user.id, {
8543
+ displayName: null,
8544
+ firstName: null,
8545
+ lastName: null,
8546
+ avatarUrl: null,
8547
+ bio: null,
8548
+ dateOfBirth: null,
8549
+ gender: null,
8550
+ website: null,
8551
+ location: null,
8552
+ company: null,
8553
+ jobTitle: null,
8554
+ metadata: null
8555
+ });
8556
+ }
8557
+ async function purgePendingRequest(request) {
8558
+ if (request.userId === null) {
8559
+ await accountDeletionRequestsRepository.markCompleted(request.id, "hard-delete");
8560
+ return { outcome: "not-found" };
7442
8561
  }
7443
- return dummyHashPromise;
8562
+ const userId = request.userId;
8563
+ const precheckUser = await usersRepository.findById(userId);
8564
+ if (!precheckUser || precheckUser.status !== "pending_deletion") {
8565
+ return { outcome: "skipped" };
8566
+ }
8567
+ const config2 = getDeletionConfig();
8568
+ if (config2.onBeforePurge) {
8569
+ try {
8570
+ await config2.onBeforePurge({
8571
+ id: precheckUser.id,
8572
+ publicId: precheckUser.publicId,
8573
+ email: precheckUser.email,
8574
+ phone: precheckUser.phone
8575
+ });
8576
+ } catch (error) {
8577
+ authLogger.service.warn("[account-deletion] onBeforePurge threw \u2014 skipping this sweep, will retry", {
8578
+ userId: precheckUser.id,
8579
+ error: error instanceof Error ? error.message : String(error)
8580
+ });
8581
+ return { outcome: "skipped" };
8582
+ }
8583
+ }
8584
+ const purgeStrategy = config2.purgeStrategy;
8585
+ let purgedUser = null;
8586
+ await runInTransaction(async () => {
8587
+ const user = await usersRepository.findById(userId);
8588
+ if (!user || user.status !== "pending_deletion") {
8589
+ return;
8590
+ }
8591
+ const claimed = await accountDeletionRequestsRepository.markCompleted(request.id, purgeStrategy);
8592
+ if (!claimed) {
8593
+ return;
8594
+ }
8595
+ if (purgeStrategy === "hard-delete") {
8596
+ await usersRepository.deleteById(user.id);
8597
+ } else {
8598
+ await anonymizeUser(user);
8599
+ }
8600
+ if (user.email) {
8601
+ await verificationCodesRepository.deleteByTarget(user.email);
8602
+ }
8603
+ if (user.phone) {
8604
+ await verificationCodesRepository.deleteByTarget(user.phone);
8605
+ }
8606
+ purgedUser = user;
8607
+ });
8608
+ if (!purgedUser) {
8609
+ return { outcome: "skipped" };
8610
+ }
8611
+ const { email, publicId } = purgedUser;
8612
+ if (email) {
8613
+ onAfterCommit(() => notifyPurgeFinal(email));
8614
+ }
8615
+ onAfterCommit(() => authDeletionCompletedEvent.emit({
8616
+ userPublicId: publicId,
8617
+ purgeStrategy
8618
+ }));
8619
+ return { outcome: "purged" };
8620
+ }
8621
+ async function purgeUserService(userId) {
8622
+ const request = await accountDeletionRequestsRepository.findPendingByUserId(userId);
8623
+ if (!request) {
8624
+ throw new DeletionNotRequestedError();
8625
+ }
8626
+ return purgePendingRequest(request);
8627
+ }
8628
+ async function sweepDuePurges(now = /* @__PURE__ */ new Date()) {
8629
+ const dueRequests = await accountDeletionRequestsRepository.findDueForPurge(now);
8630
+ let purged = 0;
8631
+ let skipped = 0;
8632
+ for (const request of dueRequests) {
8633
+ try {
8634
+ const result = await purgePendingRequest(request);
8635
+ if (result.outcome === "purged") {
8636
+ purged++;
8637
+ } else {
8638
+ skipped++;
8639
+ }
8640
+ } catch (error) {
8641
+ skipped++;
8642
+ authLogger.service.error("[account-deletion] purge failed, will retry next sweep", {
8643
+ requestId: request.id,
8644
+ error: error instanceof Error ? error.message : String(error)
8645
+ });
8646
+ }
8647
+ }
8648
+ return { processed: dueRequests.length, purged, skipped };
7444
8649
  }
8650
+
8651
+ // src/server/services/auth.service.ts
7445
8652
  async function registerService(params) {
7446
8653
  const { email, phone, verificationToken, password, publicKey, keyId, fingerprint, algorithm, metadata } = params;
7447
8654
  const tokenPayload = validateVerificationToken(verificationToken);
7448
8655
  if (!tokenPayload) {
7449
- throw new InvalidVerificationTokenError();
8656
+ throw new InvalidVerificationTokenError2();
7450
8657
  }
7451
8658
  if (tokenPayload.purpose !== "registration") {
7452
- throw new VerificationTokenPurposeMismatchError({ expected: "registration", actual: tokenPayload.purpose });
8659
+ throw new VerificationTokenPurposeMismatchError2({ expected: "registration", actual: tokenPayload.purpose });
7453
8660
  }
7454
8661
  const providedTarget = email || phone;
7455
8662
  if (tokenPayload.target !== providedTarget) {
7456
- throw new VerificationTokenTargetMismatchError();
8663
+ throw new VerificationTokenTargetMismatchError2();
7457
8664
  }
7458
8665
  const providedTargetType = email ? "email" : "phone";
7459
8666
  if (tokenPayload.targetType !== providedTargetType) {
7460
- throw new VerificationTokenTargetMismatchError();
8667
+ throw new VerificationTokenTargetMismatchError2();
7461
8668
  }
7462
8669
  const existingUser = await usersRepository.findByEmailOrPhone(email, phone);
7463
8670
  if (existingUser) {
7464
8671
  const identifierType = email ? "email" : "phone";
7465
8672
  throw new AccountAlreadyExistsError({ identifier: email || phone, identifierType });
7466
8673
  }
8674
+ await runBeforeRegister({ channel: "credentials", email, phone, metadata });
7467
8675
  const passwordHash = await hashPassword(password);
7468
8676
  const { getRoleByName: getRoleByName3 } = await Promise.resolve().then(() => (init_role_service(), role_service_exports));
7469
8677
  const userRole = await getRoleByName3("user");
@@ -7504,17 +8712,21 @@ async function loginService(params) {
7504
8712
  const { email, phone, password, publicKey, keyId, fingerprint, oldKeyId, algorithm } = params;
7505
8713
  const user = await usersRepository.findByEmailOrPhone(email, phone);
7506
8714
  if (!email && !phone) {
7507
- throw new ValidationError2({ message: "Either email or phone must be provided" });
8715
+ throw new ValidationError3({ message: "Either email or phone must be provided" });
7508
8716
  }
7509
8717
  if (!user || !user.passwordHash) {
7510
- await verifyPassword(password, await getDummyHash());
7511
- throw new InvalidCredentialsError();
8718
+ await verifyPassword(password, await getDummyPasswordHash());
8719
+ throw new InvalidCredentialsError2();
7512
8720
  }
7513
8721
  const isValid = await verifyPassword(password, user.passwordHash);
7514
8722
  if (!isValid) {
7515
- throw new InvalidCredentialsError();
8723
+ throw new InvalidCredentialsError2();
7516
8724
  }
7517
8725
  if (user.status !== "active") {
8726
+ if (user.status === "pending_deletion") {
8727
+ const pending = await getPendingDeletionInfo(user.id);
8728
+ throw new AccountPendingDeletionError({ purgeScheduledAt: pending?.purgeScheduledAt.toISOString() });
8729
+ }
7518
8730
  throw new AccountDisabledError({ status: user.status });
7519
8731
  }
7520
8732
  if (oldKeyId) {
@@ -7563,17 +8775,17 @@ async function changePasswordService(params) {
7563
8775
  } else {
7564
8776
  const user = await usersRepository.findById(userId);
7565
8777
  if (!user) {
7566
- throw new ValidationError2({ message: "User not found" });
8778
+ throw new ValidationError3({ message: "User not found" });
7567
8779
  }
7568
8780
  passwordHash = user.passwordHash;
7569
8781
  }
7570
8782
  if (passwordHash) {
7571
8783
  if (!currentPassword) {
7572
- throw new ValidationError2({ message: "Current password is required" });
8784
+ throw new ValidationError3({ message: "Current password is required" });
7573
8785
  }
7574
8786
  const isValid = await verifyPassword(currentPassword, passwordHash);
7575
8787
  if (!isValid) {
7576
- throw new InvalidCredentialsError({ message: "Current password is incorrect" });
8788
+ throw new InvalidCredentialsError2({ message: "Current password is incorrect" });
7577
8789
  }
7578
8790
  }
7579
8791
  const newPasswordHash = await hashPassword(newPassword);
@@ -7585,82 +8797,6 @@ async function changePasswordService(params) {
7585
8797
  init_repositories();
7586
8798
  init_rbac();
7587
8799
  import { createHash } from "crypto";
7588
-
7589
- // src/server/lib/config.ts
7590
- import { env as env6 } from "@spfn/auth/config";
7591
- function getCookieSuffix() {
7592
- const port = process.env.PORT;
7593
- return port ? `_${port}` : "";
7594
- }
7595
- var COOKIE_NAMES = {
7596
- /** Encrypted session data (userId, privateKey, keyId, algorithm) */
7597
- get SESSION() {
7598
- return `spfn_session${getCookieSuffix()}`;
7599
- },
7600
- /** Current key ID (for key rotation) */
7601
- get SESSION_KEY_ID() {
7602
- return `spfn_session_key_id${getCookieSuffix()}`;
7603
- },
7604
- /** Pending OAuth session (privateKey, keyId, algorithm) - temporary during OAuth flow */
7605
- get OAUTH_PENDING() {
7606
- return `spfn_oauth_pending${getCookieSuffix()}`;
7607
- },
7608
- /** OAuth CSRF nonce — double-submit against the (encrypted) state.nonce at callback */
7609
- get OAUTH_CSRF() {
7610
- return `spfn_oauth_csrf${getCookieSuffix()}`;
7611
- }
7612
- };
7613
- function parseDuration(duration) {
7614
- if (typeof duration === "number") {
7615
- return duration;
7616
- }
7617
- const match = duration.match(/^(\d+)([dhms]?)$/);
7618
- if (!match) {
7619
- throw new Error(`Invalid duration format: ${duration}. Use format like '30d', '12h', '45m', '3600s', or plain number.`);
7620
- }
7621
- const value = parseInt(match[1], 10);
7622
- const unit = match[2] || "s";
7623
- switch (unit) {
7624
- case "d":
7625
- return value * 24 * 60 * 60;
7626
- case "h":
7627
- return value * 60 * 60;
7628
- case "m":
7629
- return value * 60;
7630
- case "s":
7631
- return value;
7632
- default:
7633
- throw new Error(`Unknown duration unit: ${unit}`);
7634
- }
7635
- }
7636
- var globalConfig = {
7637
- sessionTtl: "7d"
7638
- // Default: 7 days
7639
- };
7640
- function configureAuth(config) {
7641
- globalConfig = {
7642
- ...globalConfig,
7643
- ...config
7644
- };
7645
- }
7646
- function getAuthConfig() {
7647
- return { ...globalConfig };
7648
- }
7649
- function getSessionTtl(override) {
7650
- if (override !== void 0) {
7651
- return parseDuration(override);
7652
- }
7653
- if (globalConfig.sessionTtl !== void 0) {
7654
- return parseDuration(globalConfig.sessionTtl);
7655
- }
7656
- const envTtl = env6.SPFN_AUTH_SESSION_TTL;
7657
- if (envTtl) {
7658
- return parseDuration(envTtl);
7659
- }
7660
- return 7 * 24 * 60 * 60;
7661
- }
7662
-
7663
- // src/server/services/rbac.service.ts
7664
8800
  var RBAC_HASH_KEY = "rbac_config_hash";
7665
8801
  function computeConfigHash(allRoles, allPermissions, allMappings) {
7666
8802
  const payload = JSON.stringify({
@@ -7729,51 +8865,51 @@ async function initializeAuth(options = {}) {
7729
8865
  authLogger.service.info("\u{1F512} Built-in roles: user, admin, superadmin");
7730
8866
  }
7731
8867
  async function syncRoles(configs, existingByName) {
7732
- for (const config of configs) {
7733
- const existing = existingByName.get(config.name);
8868
+ for (const config2 of configs) {
8869
+ const existing = existingByName.get(config2.name);
7734
8870
  if (!existing) {
7735
8871
  await rolesRepository.create({
7736
- name: config.name,
7737
- displayName: config.displayName,
7738
- description: config.description || null,
7739
- priority: config.priority ?? 10,
7740
- isSystem: config.isSystem ?? false,
7741
- isBuiltin: config.isBuiltin ?? false,
8872
+ name: config2.name,
8873
+ displayName: config2.displayName,
8874
+ description: config2.description || null,
8875
+ priority: config2.priority ?? 10,
8876
+ isSystem: config2.isSystem ?? false,
8877
+ isBuiltin: config2.isBuiltin ?? false,
7742
8878
  isActive: true
7743
8879
  });
7744
- authLogger.service.info(` \u2705 Created role: ${config.name}`);
8880
+ authLogger.service.info(` \u2705 Created role: ${config2.name}`);
7745
8881
  } else {
7746
8882
  const updateData = {
7747
- displayName: config.displayName,
7748
- description: config.description || null
8883
+ displayName: config2.displayName,
8884
+ description: config2.description || null
7749
8885
  };
7750
8886
  if (!existing.isBuiltin) {
7751
- updateData.priority = config.priority ?? existing.priority;
8887
+ updateData.priority = config2.priority ?? existing.priority;
7752
8888
  }
7753
8889
  await rolesRepository.updateById(existing.id, updateData);
7754
8890
  }
7755
8891
  }
7756
8892
  }
7757
8893
  async function syncPermissions(configs, existingByName) {
7758
- for (const config of configs) {
7759
- const existing = existingByName.get(config.name);
8894
+ for (const config2 of configs) {
8895
+ const existing = existingByName.get(config2.name);
7760
8896
  if (!existing) {
7761
8897
  await permissionsRepository.create({
7762
- name: config.name,
7763
- displayName: config.displayName,
7764
- description: config.description || null,
7765
- category: config.category || null,
7766
- isSystem: config.isSystem ?? false,
7767
- isBuiltin: config.isBuiltin ?? false,
8898
+ name: config2.name,
8899
+ displayName: config2.displayName,
8900
+ description: config2.description || null,
8901
+ category: config2.category || null,
8902
+ isSystem: config2.isSystem ?? false,
8903
+ isBuiltin: config2.isBuiltin ?? false,
7768
8904
  isActive: true,
7769
8905
  metadata: null
7770
8906
  });
7771
- authLogger.service.info(` \u2705 Created permission: ${config.name}`);
8907
+ authLogger.service.info(` \u2705 Created permission: ${config2.name}`);
7772
8908
  } else {
7773
8909
  await permissionsRepository.updateById(existing.id, {
7774
- displayName: config.displayName,
7775
- description: config.description || null,
7776
- category: config.category || null
8910
+ displayName: config2.displayName,
8911
+ description: config2.description || null,
8912
+ category: config2.category || null
7777
8913
  });
7778
8914
  }
7779
8915
  }
@@ -7811,7 +8947,7 @@ async function getUserPermissions(userId) {
7811
8947
  const permIds = rolePermMappings.map((rp) => rp.permissionId);
7812
8948
  if (permIds.length > 0) {
7813
8949
  const rolePerms = await Promise.all(
7814
- permIds.map((id11) => permissionsRepository.findById(id11))
8950
+ permIds.map((id12) => permissionsRepository.findById(id12))
7815
8951
  );
7816
8952
  for (const perm of rolePerms) {
7817
8953
  if (perm && perm.isActive) {
@@ -7887,7 +9023,7 @@ init_role_service();
7887
9023
  // src/server/services/invitation.service.ts
7888
9024
  init_repositories();
7889
9025
  import crypto5 from "crypto";
7890
- import { BadRequestError, NotFoundError as NotFoundError2, ConflictError } from "@spfn/core/errors";
9026
+ import { BadRequestError, NotFoundError as NotFoundError3, ConflictError } from "@spfn/core/errors";
7891
9027
  function generateInvitationToken() {
7892
9028
  return crypto5.randomUUID();
7893
9029
  }
@@ -7912,11 +9048,11 @@ async function createInvitation(params) {
7912
9048
  }
7913
9049
  const role = await rolesRepository.findById(roleId);
7914
9050
  if (!role) {
7915
- throw new NotFoundError2({ message: `Role with id ${roleId} not found`, resource: "Role" });
9051
+ throw new NotFoundError3({ message: `Role with id ${roleId} not found`, resource: "Role" });
7916
9052
  }
7917
9053
  const inviter = await usersRepository.findById(invitedBy);
7918
9054
  if (!inviter) {
7919
- throw new NotFoundError2({ message: `User with id ${invitedBy} not found`, resource: "User" });
9055
+ throw new NotFoundError3({ message: `User with id ${invitedBy} not found`, resource: "User" });
7920
9056
  }
7921
9057
  const token = generateInvitationToken();
7922
9058
  const expiresAt = expiresAtParam ?? calculateExpiresAt(expiresInDays);
@@ -7975,8 +9111,13 @@ async function acceptInvitation(params) {
7975
9111
  const invitation = validation.invitation;
7976
9112
  const role = await rolesRepository.findById(invitation.roleId);
7977
9113
  if (!role) {
7978
- throw new NotFoundError2({ message: "Role not found", resource: "Role" });
9114
+ throw new NotFoundError3({ message: "Role not found", resource: "Role" });
7979
9115
  }
9116
+ await runBeforeRegister({
9117
+ channel: "invitation",
9118
+ email: invitation.email,
9119
+ metadata: invitation.metadata ?? void 0
9120
+ });
7980
9121
  const passwordHash = await hashPassword(password);
7981
9122
  const newUser = await usersRepository.create({
7982
9123
  email: invitation.email,
@@ -8019,20 +9160,20 @@ async function acceptInvitation(params) {
8019
9160
  async function listInvitations(params) {
8020
9161
  return await invitationsRepository.list(params);
8021
9162
  }
8022
- async function cancelInvitation(id11, cancelledBy, reason) {
8023
- const invitation = await invitationsRepository.findById(id11);
9163
+ async function cancelInvitation(id12, cancelledBy, reason) {
9164
+ const invitation = await invitationsRepository.findById(id12);
8024
9165
  if (!invitation) {
8025
- throw new NotFoundError2({ message: "Invitation not found", resource: "Invitation" });
9166
+ throw new NotFoundError3({ message: "Invitation not found", resource: "Invitation" });
8026
9167
  }
8027
9168
  if (invitation.status !== "pending") {
8028
9169
  throw new ConflictError({ message: `Cannot cancel ${invitation.status} invitation` });
8029
9170
  }
8030
- await invitationsRepository.cancel(id11, cancelledBy, reason, invitation.metadata);
9171
+ await invitationsRepository.cancel(id12, cancelledBy, reason, invitation.metadata);
8031
9172
  console.log(`[Auth] \u26A0\uFE0F Invitation cancelled: ${invitation.email} (reason: ${reason || "none"})`);
8032
9173
  }
8033
- async function deleteInvitation(id11) {
8034
- await invitationsRepository.deleteById(id11);
8035
- console.log(`[Auth] \u{1F5D1}\uFE0F Invitation deleted: ${id11}`);
9174
+ async function deleteInvitation(id12) {
9175
+ await invitationsRepository.deleteById(id12);
9176
+ console.log(`[Auth] \u{1F5D1}\uFE0F Invitation deleted: ${id12}`);
8036
9177
  }
8037
9178
  async function expireOldInvitations() {
8038
9179
  const count = await invitationsRepository.updateExpiredInvitations();
@@ -8041,16 +9182,16 @@ async function expireOldInvitations() {
8041
9182
  }
8042
9183
  return count;
8043
9184
  }
8044
- async function resendInvitation(id11, expiresInDays = 7) {
8045
- const invitation = await invitationsRepository.findById(id11);
9185
+ async function resendInvitation(id12, expiresInDays = 7) {
9186
+ const invitation = await invitationsRepository.findById(id12);
8046
9187
  if (!invitation) {
8047
- throw new NotFoundError2({ message: "Invitation not found", resource: "Invitation" });
9188
+ throw new NotFoundError3({ message: "Invitation not found", resource: "Invitation" });
8048
9189
  }
8049
9190
  if (!["pending", "expired"].includes(invitation.status)) {
8050
9191
  throw new ConflictError({ message: `Cannot resend ${invitation.status} invitation` });
8051
9192
  }
8052
9193
  const newExpiresAt = calculateExpiresAt(expiresInDays);
8053
- const updated = await invitationsRepository.resend(id11, newExpiresAt);
9194
+ const updated = await invitationsRepository.resend(id12, newExpiresAt);
8054
9195
  if (!updated) {
8055
9196
  throw new Error("Failed to update invitation");
8056
9197
  }
@@ -8089,13 +9230,13 @@ async function getAuthSessionService(userId) {
8089
9230
  // src/server/lib/one-time-token.ts
8090
9231
  import { SSETokenManager } from "@spfn/core/event/sse";
8091
9232
  var manager = null;
8092
- function initOneTimeTokenManager(config) {
9233
+ function initOneTimeTokenManager(config2) {
8093
9234
  if (manager) {
8094
9235
  manager.destroy();
8095
9236
  }
8096
9237
  manager = new SSETokenManager({
8097
- ttl: config?.ttl,
8098
- store: config?.store
9238
+ ttl: config2?.ttl,
9239
+ store: config2?.store
8099
9240
  });
8100
9241
  }
8101
9242
  function getOneTimeTokenManager() {
@@ -8205,7 +9346,8 @@ async function updateUserProfileService(userId, params) {
8205
9346
  // src/server/services/oauth.service.ts
8206
9347
  init_repositories();
8207
9348
  import { env as env11 } from "@spfn/auth/config";
8208
- import { ValidationError as ValidationError5 } from "@spfn/core/errors";
9349
+ import { ValidationError as ValidationError8 } from "@spfn/core/errors";
9350
+ import { AccountDisabledError as AccountDisabledError2, AccountPendingDeletionError as AccountPendingDeletionError2 } from "@spfn/auth/errors";
8209
9351
 
8210
9352
  // src/server/lib/oauth/google.ts
8211
9353
  import { env as env7 } from "@spfn/auth/config";
@@ -8221,7 +9363,7 @@ function getGoogleOAuthConfig() {
8221
9363
  if (!clientId || !clientSecret) {
8222
9364
  throw new Error("Google OAuth is not configured. Set SPFN_AUTH_GOOGLE_CLIENT_ID and SPFN_AUTH_GOOGLE_CLIENT_SECRET.");
8223
9365
  }
8224
- const baseUrl = env7.NEXT_PUBLIC_SPFN_API_URL || env7.SPFN_API_URL;
9366
+ const baseUrl = env7.NEXT_PUBLIC_SPFN_APP_URL || env7.SPFN_APP_URL;
8225
9367
  const redirectUri = env7.SPFN_AUTH_GOOGLE_REDIRECT_URI || `${baseUrl}/_auth/oauth/google/callback`;
8226
9368
  return {
8227
9369
  clientId,
@@ -8238,10 +9380,10 @@ function getDefaultScopes() {
8238
9380
  }
8239
9381
  function getGoogleAuthUrl(state, scopes) {
8240
9382
  const resolvedScopes = scopes ?? getDefaultScopes();
8241
- const config = getGoogleOAuthConfig();
9383
+ const config2 = getGoogleOAuthConfig();
8242
9384
  const params = new URLSearchParams({
8243
- client_id: config.clientId,
8244
- redirect_uri: config.redirectUri,
9385
+ client_id: config2.clientId,
9386
+ redirect_uri: config2.redirectUri,
8245
9387
  response_type: "code",
8246
9388
  scope: resolvedScopes.join(" "),
8247
9389
  state,
@@ -8253,16 +9395,16 @@ function getGoogleAuthUrl(state, scopes) {
8253
9395
  return `${GOOGLE_AUTH_URL}?${params.toString()}`;
8254
9396
  }
8255
9397
  async function exchangeCodeForTokens(code) {
8256
- const config = getGoogleOAuthConfig();
9398
+ const config2 = getGoogleOAuthConfig();
8257
9399
  const response = await fetch(GOOGLE_TOKEN_URL, {
8258
9400
  method: "POST",
8259
9401
  headers: {
8260
9402
  "Content-Type": "application/x-www-form-urlencoded"
8261
9403
  },
8262
9404
  body: new URLSearchParams({
8263
- client_id: config.clientId,
8264
- client_secret: config.clientSecret,
8265
- redirect_uri: config.redirectUri,
9405
+ client_id: config2.clientId,
9406
+ client_secret: config2.clientSecret,
9407
+ redirect_uri: config2.redirectUri,
8266
9408
  grant_type: "authorization_code",
8267
9409
  code
8268
9410
  })
@@ -8286,15 +9428,15 @@ async function getGoogleUserInfo(accessToken) {
8286
9428
  return response.json();
8287
9429
  }
8288
9430
  async function refreshAccessToken(refreshToken) {
8289
- const config = getGoogleOAuthConfig();
9431
+ const config2 = getGoogleOAuthConfig();
8290
9432
  const response = await fetch(GOOGLE_TOKEN_URL, {
8291
9433
  method: "POST",
8292
9434
  headers: {
8293
9435
  "Content-Type": "application/x-www-form-urlencoded"
8294
9436
  },
8295
9437
  body: new URLSearchParams({
8296
- client_id: config.clientId,
8297
- client_secret: config.clientSecret,
9438
+ client_id: config2.clientId,
9439
+ client_secret: config2.clientSecret,
8298
9440
  refresh_token: refreshToken,
8299
9441
  grant_type: "refresh_token"
8300
9442
  })
@@ -8347,15 +9489,15 @@ async function verifyOAuthState(encryptedState) {
8347
9489
  }
8348
9490
 
8349
9491
  // src/server/lib/oauth/provider.ts
8350
- var registry = /* @__PURE__ */ new Map();
9492
+ var registry2 = /* @__PURE__ */ new Map();
8351
9493
  function registerOAuthProvider(provider) {
8352
- registry.set(provider.id, provider);
9494
+ registry2.set(provider.id, provider);
8353
9495
  }
8354
- function getOAuthProvider(id11) {
8355
- return registry.get(id11);
9496
+ function getOAuthProvider(id12) {
9497
+ return registry2.get(id12);
8356
9498
  }
8357
9499
  function getRegisteredProviders() {
8358
- return [...registry.values()];
9500
+ return [...registry2.values()];
8359
9501
  }
8360
9502
 
8361
9503
  // src/server/lib/oauth/jwks-verify.ts
@@ -8399,9 +9541,12 @@ async function verifySignature(params) {
8399
9541
  }
8400
9542
  }
8401
9543
 
9544
+ // src/server/lib/oauth/index.ts
9545
+ init_token_cipher();
9546
+
8402
9547
  // src/server/lib/oauth/google-provider.ts
8403
9548
  import { env as env9 } from "@spfn/auth/config";
8404
- import { ValidationError as ValidationError3 } from "@spfn/core/errors";
9549
+ import { ValidationError as ValidationError4 } from "@spfn/core/errors";
8405
9550
  var GOOGLE_JWKS_URI = "https://www.googleapis.com/oauth2/v3/certs";
8406
9551
  var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
8407
9552
  function getGoogleNativeAudiences() {
@@ -8444,7 +9589,7 @@ var googleProvider = {
8444
9589
  async verifyNativeIdToken(idToken, options) {
8445
9590
  const audiences = getGoogleNativeAudiences();
8446
9591
  if (audiences.length === 0) {
8447
- throw new ValidationError3({
9592
+ throw new ValidationError4({
8448
9593
  message: "Google native sign-in is not configured. Set SPFN_AUTH_GOOGLE_NATIVE_CLIENT_IDS."
8449
9594
  });
8450
9595
  }
@@ -8470,7 +9615,7 @@ registerOAuthProvider(googleProvider);
8470
9615
  // src/server/lib/oauth/apple-provider.ts
8471
9616
  import { createHash as createHash2 } from "crypto";
8472
9617
  import { env as env10 } from "@spfn/auth/config";
8473
- import { ValidationError as ValidationError4 } from "@spfn/core/errors";
9618
+ import { ValidationError as ValidationError5 } from "@spfn/core/errors";
8474
9619
  var APPLE_JWKS_URI = "https://appleid.apple.com/auth/keys";
8475
9620
  var APPLE_ISSUER = "https://appleid.apple.com";
8476
9621
  function getAppleClientIds() {
@@ -8480,7 +9625,7 @@ function hashNonce(rawNonce) {
8480
9625
  return createHash2("sha256").update(rawNonce).digest("hex");
8481
9626
  }
8482
9627
  function unsupportedWebFlow() {
8483
- throw new ValidationError4({
9628
+ throw new ValidationError5({
8484
9629
  message: "Apple provider supports native id_token sign-in only. Use POST /_auth/oauth/apple/native."
8485
9630
  });
8486
9631
  }
@@ -8501,7 +9646,7 @@ var appleProvider = {
8501
9646
  async verifyNativeIdToken(idToken, options) {
8502
9647
  const audiences = getAppleClientIds();
8503
9648
  if (audiences.length === 0) {
8504
- throw new ValidationError4({
9649
+ throw new ValidationError5({
8505
9650
  message: "Apple native sign-in is not configured. Set SPFN_AUTH_APPLE_CLIENT_IDS."
8506
9651
  });
8507
9652
  }
@@ -8522,16 +9667,226 @@ var appleProvider = {
8522
9667
  };
8523
9668
  registerOAuthProvider(appleProvider);
8524
9669
 
9670
+ // src/server/lib/oauth/kakao-provider.ts
9671
+ init_config();
9672
+ import { ValidationError as ValidationError6 } from "@spfn/core/errors";
9673
+ var KAKAO_AUTH_URL = "https://kauth.kakao.com/oauth/authorize";
9674
+ var KAKAO_TOKEN_URL = "https://kauth.kakao.com/oauth/token";
9675
+ var KAKAO_USERINFO_URL = "https://kapi.kakao.com/v2/user/me";
9676
+ function getKakaoConfig() {
9677
+ const clientId = env3.SPFN_AUTH_KAKAO_CLIENT_ID;
9678
+ const clientSecret = env3.SPFN_AUTH_KAKAO_CLIENT_SECRET;
9679
+ if (!clientId) {
9680
+ throw new ValidationError6({
9681
+ message: "Kakao OAuth is not configured. Set SPFN_AUTH_KAKAO_CLIENT_ID."
9682
+ });
9683
+ }
9684
+ const baseUrl = env3.NEXT_PUBLIC_SPFN_APP_URL || env3.SPFN_APP_URL;
9685
+ return {
9686
+ clientId,
9687
+ clientSecret,
9688
+ redirectUri: env3.SPFN_AUTH_KAKAO_REDIRECT_URI || `${baseUrl}/_auth/oauth/kakao/callback`
9689
+ };
9690
+ }
9691
+ function getKakaoScopes() {
9692
+ const configured = env3.SPFN_AUTH_KAKAO_SCOPES;
9693
+ return configured ? configured.split(",").map((scope) => scope.trim()).filter(Boolean) : ["account_email"];
9694
+ }
9695
+ async function requestKakaoTokens(params) {
9696
+ const response = await fetch(KAKAO_TOKEN_URL, {
9697
+ method: "POST",
9698
+ headers: { "Content-Type": "application/x-www-form-urlencoded;charset=utf-8" },
9699
+ body: params
9700
+ });
9701
+ if (!response.ok) {
9702
+ throw new Error(`Kakao token request failed with status ${response.status}`);
9703
+ }
9704
+ const body = await response.json();
9705
+ const expiresIn = Number(body.expires_in);
9706
+ if (typeof body.access_token !== "string" || !Number.isFinite(expiresIn) || expiresIn <= 0) {
9707
+ throw new Error("Kakao token response is invalid");
9708
+ }
9709
+ return {
9710
+ accessToken: body.access_token,
9711
+ refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : void 0,
9712
+ expiresIn
9713
+ };
9714
+ }
9715
+ var kakaoProvider = {
9716
+ id: "kakao",
9717
+ isEnabled() {
9718
+ return !!env3.SPFN_AUTH_KAKAO_CLIENT_ID;
9719
+ },
9720
+ getAuthUrl(state, scopes) {
9721
+ const config2 = getKakaoConfig();
9722
+ const params = new URLSearchParams({
9723
+ response_type: "code",
9724
+ client_id: config2.clientId,
9725
+ redirect_uri: config2.redirectUri,
9726
+ state,
9727
+ scope: (scopes ?? getKakaoScopes()).join(",")
9728
+ });
9729
+ return `${KAKAO_AUTH_URL}?${params.toString()}`;
9730
+ },
9731
+ async exchangeCodeForTokens(code) {
9732
+ const config2 = getKakaoConfig();
9733
+ const params = new URLSearchParams({
9734
+ grant_type: "authorization_code",
9735
+ client_id: config2.clientId,
9736
+ redirect_uri: config2.redirectUri,
9737
+ code
9738
+ });
9739
+ if (config2.clientSecret) {
9740
+ params.set("client_secret", config2.clientSecret);
9741
+ }
9742
+ return requestKakaoTokens(params);
9743
+ },
9744
+ async getUserInfo(accessToken) {
9745
+ const response = await fetch(KAKAO_USERINFO_URL, {
9746
+ headers: { Authorization: `Bearer ${accessToken}` }
9747
+ });
9748
+ if (!response.ok) {
9749
+ throw new Error(`Kakao user-info request failed with status ${response.status}`);
9750
+ }
9751
+ const body = await response.json();
9752
+ if (typeof body.id !== "number" && typeof body.id !== "string") {
9753
+ throw new Error("Kakao user-info response is missing the provider user ID");
9754
+ }
9755
+ const account = body.kakao_account;
9756
+ const email = typeof account?.email === "string" ? account.email : null;
9757
+ return {
9758
+ providerUserId: String(body.id),
9759
+ email,
9760
+ emailVerified: email !== null && account?.is_email_valid === true && account.is_email_verified === true,
9761
+ name: typeof account?.profile?.nickname === "string" ? account.profile.nickname : void 0,
9762
+ avatar: typeof account?.profile?.profile_image_url === "string" ? account.profile.profile_image_url : void 0
9763
+ };
9764
+ },
9765
+ async refreshTokens(refreshToken) {
9766
+ const config2 = getKakaoConfig();
9767
+ const params = new URLSearchParams({
9768
+ grant_type: "refresh_token",
9769
+ client_id: config2.clientId,
9770
+ refresh_token: refreshToken
9771
+ });
9772
+ if (config2.clientSecret) {
9773
+ params.set("client_secret", config2.clientSecret);
9774
+ }
9775
+ return requestKakaoTokens(params);
9776
+ }
9777
+ };
9778
+ registerOAuthProvider(kakaoProvider);
9779
+
9780
+ // src/server/lib/oauth/naver-provider.ts
9781
+ init_config();
9782
+ import { ValidationError as ValidationError7 } from "@spfn/core/errors";
9783
+ var NAVER_AUTH_URL = "https://nid.naver.com/oauth2.0/authorize";
9784
+ var NAVER_TOKEN_URL = "https://nid.naver.com/oauth2.0/token";
9785
+ var NAVER_USERINFO_URL = "https://openapi.naver.com/v1/nid/me";
9786
+ function getNaverConfig() {
9787
+ const clientId = env3.SPFN_AUTH_NAVER_CLIENT_ID;
9788
+ const clientSecret = env3.SPFN_AUTH_NAVER_CLIENT_SECRET;
9789
+ if (!clientId || !clientSecret) {
9790
+ throw new ValidationError7({
9791
+ message: "Naver OAuth is not configured. Set SPFN_AUTH_NAVER_CLIENT_ID and SPFN_AUTH_NAVER_CLIENT_SECRET."
9792
+ });
9793
+ }
9794
+ const baseUrl = env3.NEXT_PUBLIC_SPFN_APP_URL || env3.SPFN_APP_URL;
9795
+ return {
9796
+ clientId,
9797
+ clientSecret,
9798
+ redirectUri: env3.SPFN_AUTH_NAVER_REDIRECT_URI || `${baseUrl}/_auth/oauth/naver/callback`
9799
+ };
9800
+ }
9801
+ async function requestNaverTokens(params) {
9802
+ const response = await fetch(NAVER_TOKEN_URL, {
9803
+ method: "POST",
9804
+ headers: { "Content-Type": "application/x-www-form-urlencoded;charset=utf-8" },
9805
+ body: params
9806
+ });
9807
+ if (!response.ok) {
9808
+ throw new Error(`Naver token request failed with status ${response.status}`);
9809
+ }
9810
+ const body = await response.json();
9811
+ const expiresIn = Number(body.expires_in);
9812
+ if (typeof body.access_token !== "string" || !Number.isFinite(expiresIn) || expiresIn <= 0) {
9813
+ throw new Error("Naver token response is invalid");
9814
+ }
9815
+ return {
9816
+ accessToken: body.access_token,
9817
+ refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : void 0,
9818
+ expiresIn
9819
+ };
9820
+ }
9821
+ var naverProvider = {
9822
+ id: "naver",
9823
+ isEnabled() {
9824
+ return !!(env3.SPFN_AUTH_NAVER_CLIENT_ID && env3.SPFN_AUTH_NAVER_CLIENT_SECRET);
9825
+ },
9826
+ getAuthUrl(state) {
9827
+ const config2 = getNaverConfig();
9828
+ const params = new URLSearchParams({
9829
+ response_type: "code",
9830
+ client_id: config2.clientId,
9831
+ redirect_uri: config2.redirectUri,
9832
+ state
9833
+ });
9834
+ return `${NAVER_AUTH_URL}?${params.toString()}`;
9835
+ },
9836
+ async exchangeCodeForTokens(code, options) {
9837
+ const config2 = getNaverConfig();
9838
+ return requestNaverTokens(new URLSearchParams({
9839
+ grant_type: "authorization_code",
9840
+ client_id: config2.clientId,
9841
+ client_secret: config2.clientSecret,
9842
+ redirect_uri: config2.redirectUri,
9843
+ code,
9844
+ state: options.state
9845
+ }));
9846
+ },
9847
+ async getUserInfo(accessToken) {
9848
+ const response = await fetch(NAVER_USERINFO_URL, {
9849
+ headers: { Authorization: `Bearer ${accessToken}` }
9850
+ });
9851
+ if (!response.ok) {
9852
+ throw new Error(`Naver user-info request failed with status ${response.status}`);
9853
+ }
9854
+ const body = await response.json();
9855
+ const profile = body.response;
9856
+ if (body.resultcode !== "00" || typeof profile?.id !== "string") {
9857
+ throw new Error("Naver user-info response is invalid");
9858
+ }
9859
+ return {
9860
+ providerUserId: profile.id,
9861
+ email: typeof profile.email === "string" ? profile.email : null,
9862
+ // Naver returns an email address but no independently verified-email claim.
9863
+ emailVerified: false,
9864
+ name: typeof profile.name === "string" ? profile.name : typeof profile.nickname === "string" ? profile.nickname : void 0,
9865
+ avatar: typeof profile.profile_image === "string" ? profile.profile_image : void 0
9866
+ };
9867
+ },
9868
+ async refreshTokens(refreshToken) {
9869
+ const config2 = getNaverConfig();
9870
+ return requestNaverTokens(new URLSearchParams({
9871
+ grant_type: "refresh_token",
9872
+ client_id: config2.clientId,
9873
+ client_secret: config2.clientSecret,
9874
+ refresh_token: refreshToken
9875
+ }));
9876
+ }
9877
+ };
9878
+ registerOAuthProvider(naverProvider);
9879
+
8525
9880
  // src/server/services/oauth.service.ts
8526
9881
  function requireEnabledProvider(provider) {
8527
9882
  const oauthProvider = getOAuthProvider(provider);
8528
9883
  if (!oauthProvider) {
8529
- throw new ValidationError5({
9884
+ throw new ValidationError8({
8530
9885
  message: `Unsupported OAuth provider: ${provider}. No provider is registered for this id.`
8531
9886
  });
8532
9887
  }
8533
9888
  if (!oauthProvider.isEnabled()) {
8534
- throw new ValidationError5({
9889
+ throw new ValidationError8({
8535
9890
  message: `OAuth provider '${provider}' is registered but not configured. Check its required environment variables.`
8536
9891
  });
8537
9892
  }
@@ -8539,7 +9894,7 @@ function requireEnabledProvider(provider) {
8539
9894
  }
8540
9895
  function tokenExpiryDate(expiresIn) {
8541
9896
  if (!Number.isFinite(expiresIn)) {
8542
- throw new ValidationError5({
9897
+ throw new ValidationError8({
8543
9898
  message: `Invalid token expiry returned from OAuth provider: ${expiresIn}`
8544
9899
  });
8545
9900
  }
@@ -8563,18 +9918,19 @@ async function oauthStartService(params) {
8563
9918
  async function oauthCallbackService(params) {
8564
9919
  const { provider, code, state, expectedNonce } = params;
8565
9920
  const stateData = await verifyOAuthState(state);
8566
- if (!expectedNonce || stateData.nonce !== expectedNonce) {
8567
- throw new ValidationError5({
9921
+ const nonceCandidates = typeof expectedNonce === "string" ? [expectedNonce] : expectedNonce ?? [];
9922
+ if (!stateData.nonce || !nonceCandidates.includes(stateData.nonce)) {
9923
+ throw new ValidationError8({
8568
9924
  message: "OAuth state validation failed"
8569
9925
  });
8570
9926
  }
8571
9927
  if (stateData.provider !== provider) {
8572
- throw new ValidationError5({
9928
+ throw new ValidationError8({
8573
9929
  message: "OAuth state provider mismatch"
8574
9930
  });
8575
9931
  }
8576
9932
  const oauthProvider = requireEnabledProvider(provider);
8577
- const tokens = await oauthProvider.exchangeCodeForTokens(code);
9933
+ const tokens = await oauthProvider.exchangeCodeForTokens(code, { state });
8578
9934
  const identity = await oauthProvider.getUserInfo(tokens.accessToken);
8579
9935
  const existingSocialAccount = await socialAccountsRepository.findByProviderAndProviderId(
8580
9936
  provider,
@@ -8590,10 +9946,11 @@ async function oauthCallbackService(params) {
8590
9946
  tokenExpiresAt: tokenExpiryDate(tokens.expiresIn)
8591
9947
  });
8592
9948
  } else {
8593
- const result = await createOrLinkUser(provider, identity, tokens);
9949
+ const result = await createOrLinkUser(provider, identity, tokens, stateData.metadata);
8594
9950
  userId = result.userId;
8595
9951
  isNewUser = result.isNewUser;
8596
9952
  }
9953
+ await assertActiveForOAuthSession(userId);
8597
9954
  await registerPublicKeyService({
8598
9955
  userId,
8599
9956
  keyId: stateData.keyId,
@@ -8631,13 +9988,27 @@ async function oauthCallbackService(params) {
8631
9988
  isNewUser
8632
9989
  };
8633
9990
  }
8634
- async function createOrLinkUser(provider, identity, tokens) {
9991
+ async function assertActiveForOAuthSession(userId) {
9992
+ const user = await usersRepository.findByIdOnPrimary(userId);
9993
+ if (!user) {
9994
+ throw new ValidationError8({ message: "User not found" });
9995
+ }
9996
+ if (user.status === "active") {
9997
+ return;
9998
+ }
9999
+ if (user.status === "pending_deletion") {
10000
+ const pending = await getPendingDeletionInfo(user.id);
10001
+ throw new AccountPendingDeletionError2({ purgeScheduledAt: pending?.purgeScheduledAt.toISOString() });
10002
+ }
10003
+ throw new AccountDisabledError2({ status: user.status });
10004
+ }
10005
+ async function createOrLinkUser(provider, identity, tokens, metadata) {
8635
10006
  const existingUser = identity.email ? await usersRepository.findByEmail(identity.email) : null;
8636
10007
  let userId;
8637
10008
  let isNewUser = false;
8638
10009
  if (existingUser) {
8639
10010
  if (!identity.emailVerified) {
8640
- throw new ValidationError5({
10011
+ throw new ValidationError8({
8641
10012
  message: "Cannot link to existing account with unverified email. Please verify your email with the provider first."
8642
10013
  });
8643
10014
  }
@@ -8648,6 +10019,13 @@ async function createOrLinkUser(provider, identity, tokens) {
8648
10019
  });
8649
10020
  }
8650
10021
  } else {
10022
+ await runBeforeRegister({
10023
+ channel: "oauth",
10024
+ provider,
10025
+ email: identity.email ?? void 0,
10026
+ emailVerified: identity.emailVerified,
10027
+ metadata
10028
+ });
8651
10029
  const { getRoleByName: getRoleByName3 } = await Promise.resolve().then(() => (init_role_service(), role_service_exports));
8652
10030
  const userRole = await getRoleByName3("user");
8653
10031
  if (!userRole) {
@@ -8701,7 +10079,7 @@ var TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1e3;
8701
10079
  async function getGoogleAccessToken(userId) {
8702
10080
  const account = await socialAccountsRepository.findByUserIdAndProvider(userId, "google");
8703
10081
  if (!account) {
8704
- throw new ValidationError5({
10082
+ throw new ValidationError8({
8705
10083
  message: "No Google account linked. User must sign in with Google first."
8706
10084
  });
8707
10085
  }
@@ -8710,7 +10088,7 @@ async function getGoogleAccessToken(userId) {
8710
10088
  return account.accessToken;
8711
10089
  }
8712
10090
  if (!account.refreshToken) {
8713
- throw new ValidationError5({
10091
+ throw new ValidationError8({
8714
10092
  message: "Google refresh token not available. User must re-authenticate with Google."
8715
10093
  });
8716
10094
  }
@@ -8725,12 +10103,12 @@ async function getGoogleAccessToken(userId) {
8725
10103
 
8726
10104
  // src/server/services/oauth-native.service.ts
8727
10105
  init_repositories();
8728
- import { ValidationError as ValidationError6 } from "@spfn/core/errors";
8729
- import { runInTransaction, onAfterCommit } from "@spfn/core/db";
10106
+ import { ValidationError as ValidationError9 } from "@spfn/core/errors";
10107
+ import { runInTransaction as runInTransaction2, onAfterCommit as onAfterCommit2 } from "@spfn/core/db";
8730
10108
  async function oauthNativeService(params) {
8731
10109
  const oauthProvider = getOAuthProvider(params.provider);
8732
10110
  if (!oauthProvider?.verifyNativeIdToken) {
8733
- throw new ValidationError6({
10111
+ throw new ValidationError9({
8734
10112
  message: `Provider '${params.provider}' does not support native id_token sign-in.`
8735
10113
  });
8736
10114
  }
@@ -8741,7 +10119,7 @@ async function oauthNativeService(params) {
8741
10119
  return persistNativeLogin(identity, params);
8742
10120
  }
8743
10121
  async function persistNativeLogin(identity, params) {
8744
- return runInTransaction(async () => {
10122
+ return runInTransaction2(async () => {
8745
10123
  const existing = await socialAccountsRepository.findByProviderAndProviderId(
8746
10124
  params.provider,
8747
10125
  identity.providerUserId
@@ -8751,10 +10129,11 @@ async function persistNativeLogin(identity, params) {
8751
10129
  if (existing) {
8752
10130
  userId = existing.userId;
8753
10131
  } else {
8754
- const result = await createOrLinkUser(params.provider, identity);
10132
+ const result = await createOrLinkUser(params.provider, identity, void 0, params.metadata);
8755
10133
  userId = result.userId;
8756
10134
  isNewUser = result.isNewUser;
8757
10135
  }
10136
+ await assertActiveForOAuthSession(userId);
8758
10137
  await registerPublicKeyService({
8759
10138
  userId,
8760
10139
  keyId: params.keyId,
@@ -8769,7 +10148,7 @@ async function persistNativeLogin(identity, params) {
8769
10148
  email: identity.email || void 0,
8770
10149
  metadata: params.metadata
8771
10150
  };
8772
- onAfterCommit(() => (isNewUser ? authRegisterEvent : authLoginEvent).emit(eventPayload));
10151
+ onAfterCommit2(() => (isNewUser ? authRegisterEvent : authLoginEvent).emit(eventPayload));
8773
10152
  return { userId: String(userId), keyId: params.keyId, isNewUser };
8774
10153
  }, { context: "auth:oauth-native" });
8775
10154
  }
@@ -8983,12 +10362,13 @@ import { EMAIL_PATTERN as EMAIL_PATTERN2, UUID_PATTERN } from "@spfn/auth";
8983
10362
  // src/server/middleware/authenticate.ts
8984
10363
  import { defineMiddleware } from "@spfn/core/route";
8985
10364
  import { UnauthorizedError } from "@spfn/core/errors";
8986
- import { verifyClientToken as verifyClientToken2, decodeToken as decodeToken2, authLogger as authLogger2, keysRepository as keysRepository2, usersRepository as usersRepository2, userProfilesRepository as userProfilesRepository2 } from "@spfn/auth/server";
10365
+ import { verifyClientToken as verifyClientToken2, decodeToken as decodeToken2, authLogger as authLogger2, keysRepository as keysRepository2, usersRepository as usersRepository2, userProfilesRepository as userProfilesRepository2, getPendingDeletionInfo as getPendingDeletionInfo2 } from "@spfn/auth/server";
8987
10366
  import {
8988
10367
  InvalidTokenError,
8989
10368
  TokenExpiredError,
8990
10369
  KeyExpiredError,
8991
- AccountDisabledError as AccountDisabledError2
10370
+ AccountDisabledError as AccountDisabledError3,
10371
+ AccountPendingDeletionError as AccountPendingDeletionError3
8992
10372
  } from "@spfn/auth/errors";
8993
10373
  var authenticate = defineMiddleware("auth", async (c, next) => {
8994
10374
  const authHeader = c.req.header("Authorization");
@@ -9039,7 +10419,11 @@ var authenticate = defineMiddleware("auth", async (c, next) => {
9039
10419
  }
9040
10420
  const { user, role } = result;
9041
10421
  if (user.status !== "active") {
9042
- throw new AccountDisabledError2({ status: user.status });
10422
+ if (user.status === "pending_deletion") {
10423
+ const pending = await getPendingDeletionInfo2(user.id);
10424
+ throw new AccountPendingDeletionError3({ purgeScheduledAt: pending?.purgeScheduledAt.toISOString() });
10425
+ }
10426
+ throw new AccountDisabledError3({ status: user.status });
9043
10427
  }
9044
10428
  keysRepository2.updateLastUsedById(keyRecord.id).catch((err) => authLogger2.middleware.error("Failed to update lastUsedAt", err));
9045
10429
  c.set("auth", {
@@ -9761,7 +11145,7 @@ var deleteCookie = (c, name, opt) => {
9761
11145
  // src/server/routes/oauth/index.ts
9762
11146
  init_types();
9763
11147
  import { Transactional as Transactional3 } from "@spfn/core/db";
9764
- import { ValidationError as ValidationError7 } from "@spfn/core/errors";
11148
+ import { ValidationError as ValidationError10 } from "@spfn/core/errors";
9765
11149
  import { rateLimitPolicy as rateLimitPolicy4 } from "@spfn/core/middleware";
9766
11150
  import { defineRouter as defineRouter4, route as route4 } from "@spfn/core/route";
9767
11151
  var providerParams = Type.Object({
@@ -9807,14 +11191,16 @@ var oauthGoogleCallback = route4.get("/_auth/oauth/google/callback").input({
9807
11191
  if (!query.code || !query.state) {
9808
11192
  return c.redirect(buildOAuthErrorUrl("Missing authorization code or state"));
9809
11193
  }
9810
- const expectedNonce = getCookie(c.raw, COOKIE_NAMES.OAUTH_CSRF);
9811
- deleteCookie(c.raw, COOKIE_NAMES.OAUTH_CSRF, { path: "/" });
11194
+ const csrfCookies = matchOAuthCsrfCookies(getCookie(c.raw));
11195
+ for (const cookie of csrfCookies) {
11196
+ deleteCookie(c.raw, cookie.name, { path: "/" });
11197
+ }
9812
11198
  try {
9813
11199
  const result = await oauthCallbackService({
9814
11200
  provider: "google",
9815
11201
  code: query.code,
9816
11202
  state: query.state,
9817
- expectedNonce
11203
+ expectedNonce: csrfCookies.map((cookie) => cookie.value)
9818
11204
  });
9819
11205
  return c.redirect(result.redirectUrl);
9820
11206
  } catch (err) {
@@ -9869,6 +11255,9 @@ var getGoogleOAuthUrl = route4.post("/_auth/oauth/google/url").input({
9869
11255
  returnUrl: Type.Optional(Type.String({
9870
11256
  description: "URL to redirect after OAuth success"
9871
11257
  })),
11258
+ metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
11259
+ description: "Custom signup metadata sealed into OAuth state and passed to beforeRegister"
11260
+ })),
9872
11261
  state: Type.Optional(Type.String({
9873
11262
  description: "Encrypted OAuth state (injected by interceptor)"
9874
11263
  }))
@@ -9876,10 +11265,10 @@ var getGoogleOAuthUrl = route4.post("/_auth/oauth/google/url").input({
9876
11265
  }).use([rateLimitPolicy4("oauth-start", { limit: 20, windowMs: 6e4 })]).skip(["auth"]).handler(async (c) => {
9877
11266
  const { body } = await c.data();
9878
11267
  if (!isGoogleOAuthEnabled()) {
9879
- throw new ValidationError7({ message: "Google OAuth is not configured" });
11268
+ throw new ValidationError10({ message: "Google OAuth is not configured" });
9880
11269
  }
9881
11270
  if (!body.state) {
9882
- throw new ValidationError7({
11271
+ throw new ValidationError10({
9883
11272
  message: "OAuth state is required. Ensure the OAuth interceptor is configured."
9884
11273
  });
9885
11274
  }
@@ -9940,14 +11329,16 @@ var oauthProviderCallback = route4.get("/_auth/oauth/:provider/callback").input(
9940
11329
  if (!query.code || !query.state) {
9941
11330
  return c.redirect(buildOAuthErrorUrl("Missing authorization code or state"));
9942
11331
  }
9943
- const expectedNonce = getCookie(c.raw, COOKIE_NAMES.OAUTH_CSRF);
9944
- deleteCookie(c.raw, COOKIE_NAMES.OAUTH_CSRF, { path: "/" });
11332
+ const csrfCookies = matchOAuthCsrfCookies(getCookie(c.raw));
11333
+ for (const cookie of csrfCookies) {
11334
+ deleteCookie(c.raw, cookie.name, { path: "/" });
11335
+ }
9945
11336
  try {
9946
11337
  const result = await oauthCallbackService({
9947
11338
  provider: params.provider,
9948
11339
  code: query.code,
9949
11340
  state: query.state,
9950
- expectedNonce
11341
+ expectedNonce: csrfCookies.map((cookie) => cookie.value)
9951
11342
  });
9952
11343
  return c.redirect(result.redirectUrl);
9953
11344
  } catch (err) {
@@ -9961,6 +11352,9 @@ var getProviderOAuthUrl = route4.post("/_auth/oauth/:provider/url").input({
9961
11352
  returnUrl: Type.Optional(Type.String({
9962
11353
  description: "URL to redirect after OAuth success"
9963
11354
  })),
11355
+ metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
11356
+ description: "Custom signup metadata sealed into OAuth state and passed to beforeRegister"
11357
+ })),
9964
11358
  state: Type.Optional(Type.String({
9965
11359
  description: "Encrypted OAuth state (injected by interceptor)"
9966
11360
  }))
@@ -9969,7 +11363,7 @@ var getProviderOAuthUrl = route4.post("/_auth/oauth/:provider/url").input({
9969
11363
  const { params, body } = await c.data();
9970
11364
  const provider = requireEnabledProvider(params.provider);
9971
11365
  if (!body.state) {
9972
- throw new ValidationError7({
11366
+ throw new ValidationError10({
9973
11367
  message: "OAuth state is required. Ensure the OAuth interceptor is configured."
9974
11368
  });
9975
11369
  }
@@ -10095,8 +11489,56 @@ var updateUserRole = route5.patch("/_auth/admin/users/:userId/role").input({
10095
11489
  return { userId: params.userId, roleId: body.roleId };
10096
11490
  });
10097
11491
 
11492
+ // src/server/routes/deletion/index.ts
11493
+ init_esm();
11494
+ init_schema3();
11495
+ import { Transactional as Transactional4 } from "@spfn/core/db";
11496
+ import { rateLimitPolicy as rateLimitPolicy5 } from "@spfn/core/middleware";
11497
+ import { defineRouter as defineRouter5, route as route6 } from "@spfn/core/route";
11498
+ var requestAccountDeletion = route6.post("/_auth/deletion/request").input({
11499
+ body: Type.Object({
11500
+ password: Type.Optional(Type.String({ minLength: 1, description: "Current password, if the account has one" })),
11501
+ verificationToken: Type.Optional(Type.String({ description: "Verification token (purpose: account_deletion), for passwordless/OAuth-only accounts" })),
11502
+ reason: Type.Optional(Type.String({ maxLength: 500, description: "Optional free-text reason" })),
11503
+ immediate: Type.Optional(Type.Boolean({ description: "Skip the grace period \u2014 requires deletion.allowSelfImmediate on the server" }))
11504
+ })
11505
+ }).use([rateLimitPolicy5("auth-deletion-request", { limit: 5, windowMs: 6e4 }), Transactional4()]).handler(async (c) => {
11506
+ const { body } = await c.data();
11507
+ const { userId } = getAuth(c);
11508
+ const result = await requestAccountDeletionService(Number(userId), {
11509
+ requestedBy: "self",
11510
+ password: body.password,
11511
+ verificationToken: body.verificationToken,
11512
+ reason: body.reason,
11513
+ immediate: body.immediate
11514
+ });
11515
+ return {
11516
+ purgeScheduledAt: result.purgeScheduledAt.toISOString()
11517
+ };
11518
+ });
11519
+ var cancelAccountDeletion = route6.post("/_auth/deletion/cancel").input({
11520
+ body: Type.Object({
11521
+ email: Type.Optional(EmailSchema),
11522
+ phone: Type.Optional(PhoneSchema),
11523
+ password: Type.Optional(Type.String({ minLength: 1 })),
11524
+ verificationToken: Type.Optional(Type.String({ description: "Verification token (purpose: account_deletion), for passwordless/OAuth-only accounts" }))
11525
+ }, {
11526
+ minProperties: 2,
11527
+ // email/phone + password|verificationToken
11528
+ description: "Email or phone must be provided with password or verificationToken"
11529
+ })
11530
+ }).use([rateLimitPolicy5("auth-deletion-cancel", { limit: 10, windowMs: 6e4, by: byIpAndAccount({ ipLimit: 50 }) }), Transactional4()]).skip(["auth"]).handler(async (c) => {
11531
+ const { body } = await c.data();
11532
+ await cancelAccountDeletionService(body);
11533
+ return c.noContent();
11534
+ });
11535
+ var deletionRouter = defineRouter5({
11536
+ requestAccountDeletion,
11537
+ cancelAccountDeletion
11538
+ });
11539
+
10098
11540
  // src/server/routes/index.ts
10099
- var mainAuthRouter = defineRouter5({
11541
+ var mainAuthRouter = defineRouter6({
10100
11542
  // Auth routes
10101
11543
  sendVerificationCode,
10102
11544
  verifyCode,
@@ -10108,6 +11550,9 @@ var mainAuthRouter = defineRouter5({
10108
11550
  getAuthSession,
10109
11551
  // One-Time Token routes
10110
11552
  issueOneTimeToken,
11553
+ // Account deletion routes
11554
+ requestAccountDeletion,
11555
+ cancelAccountDeletion,
10111
11556
  // OAuth routes
10112
11557
  oauthGoogleStart,
10113
11558
  oauthGoogleCallback,
@@ -10458,6 +11903,7 @@ async function ensureAdminExists() {
10458
11903
 
10459
11904
  // src/server/lifecycle.ts
10460
11905
  function createAuthLifecycle(options = {}) {
11906
+ configureDeletion(options.deletion);
10461
11907
  return {
10462
11908
  /**
10463
11909
  * Initialize auth system after database is ready
@@ -10474,15 +11920,42 @@ function createAuthLifecycle(options = {}) {
10474
11920
  }
10475
11921
  };
10476
11922
  }
11923
+
11924
+ // src/server/jobs/deletion-purge.ts
11925
+ import { job, defineJobRouter } from "@spfn/core/job";
11926
+ function createAuthDeletionPurgeJob(cronExpression = DEFAULT_DELETION_PURGE_CRON) {
11927
+ return job("auth.deletion.purge").cron(cronExpression).options({ retryLimit: 1 }).handler(async () => {
11928
+ const result = await sweepDuePurges();
11929
+ if (result.processed > 0) {
11930
+ authLogger.service.info("[auth.deletion.purge] sweep complete", result);
11931
+ }
11932
+ });
11933
+ }
11934
+ function createAuthDeletionJobRouter(options) {
11935
+ return defineJobRouter({
11936
+ deletionPurge: createAuthDeletionPurgeJob(options?.purgeCron)
11937
+ });
11938
+ }
11939
+ var authJobRouter = createAuthDeletionJobRouter();
10477
11940
  export {
11941
+ ACCOUNT_DELETION_REQUESTED_BY,
11942
+ ACCOUNT_DELETION_REQUEST_STATUSES,
11943
+ AccountDeletionRequestsRepository,
10478
11944
  AuthMetadataRepository,
10479
11945
  AuthProviderSchema,
10480
11946
  COOKIE_NAMES,
11947
+ DEFAULT_DELETION_ALLOW_SELF_IMMEDIATE,
11948
+ DEFAULT_DELETION_GRACE_PERIOD_DAYS,
11949
+ DEFAULT_DELETION_PURGE_CRON,
11950
+ DEFAULT_DELETION_PURGE_STRATEGY,
11951
+ DEFAULT_DELETION_SEND_NOTIFICATIONS,
10481
11952
  EmailSchema,
11953
+ EnvironmentKeyringTokenCipher,
10482
11954
  INVITATION_STATUSES,
10483
11955
  InvitationsRepository,
10484
11956
  KEY_ALGORITHM,
10485
11957
  KeysRepository,
11958
+ PURGE_STRATEGIES,
10486
11959
  PasswordSchema,
10487
11960
  PermissionsRepository,
10488
11961
  PhoneSchema,
@@ -10500,9 +11973,15 @@ export {
10500
11973
  VerificationCodesRepository,
10501
11974
  VerificationPurposeSchema,
10502
11975
  acceptInvitation,
11976
+ accountDeletionRequests,
11977
+ accountDeletionRequestsRepository,
10503
11978
  addPermissionToRole,
10504
11979
  appleProvider,
10505
11980
  assertCanAssignRole,
11981
+ authDeletionCancelledEvent,
11982
+ authDeletionCompletedEvent,
11983
+ authDeletionRequestedEvent,
11984
+ authJobRouter,
10506
11985
  authLogger,
10507
11986
  authLoginEvent,
10508
11987
  authMetadata,
@@ -10512,17 +11991,24 @@ export {
10512
11991
  authSchema,
10513
11992
  authenticate,
10514
11993
  buildOAuthErrorUrl,
11994
+ cancelAccountDeletionService,
10515
11995
  cancelInvitation,
10516
11996
  changePasswordService,
10517
11997
  checkUsernameAvailableService,
10518
11998
  configureAuth,
11999
+ configureDeletion,
12000
+ configureOAuthTokenCipher,
12001
+ createAuthDeletionJobRouter,
12002
+ createAuthDeletionPurgeJob,
10519
12003
  createAuthLifecycle,
10520
12004
  createInvitation,
10521
12005
  createOAuthState,
10522
12006
  createRole,
10523
12007
  decodeToken,
12008
+ decryptToken,
10524
12009
  deleteInvitation,
10525
12010
  deleteRole,
12011
+ encryptToken,
10526
12012
  exchangeCodeForTokens,
10527
12013
  expireOldInvitations,
10528
12014
  generateClientToken,
@@ -10535,6 +12021,8 @@ export {
10535
12021
  getAuth,
10536
12022
  getAuthConfig,
10537
12023
  getAuthSessionService,
12024
+ getDeletionConfig,
12025
+ getDummyPasswordHash,
10538
12026
  getEnabledOAuthProviders,
10539
12027
  getGoogleAccessToken,
10540
12028
  getGoogleAuthUrl,
@@ -10548,6 +12036,7 @@ export {
10548
12036
  getOAuthProvider,
10549
12037
  getOneTimeTokenManager,
10550
12038
  getOptionalAuth,
12039
+ getPendingDeletionInfo,
10551
12040
  getRegisteredProviders,
10552
12041
  getRole,
10553
12042
  getRoleByName,
@@ -10574,13 +12063,17 @@ export {
10574
12063
  invitationAcceptedEvent,
10575
12064
  invitationCreatedEvent,
10576
12065
  invitationsRepository,
12066
+ isEncrypted,
10577
12067
  isGoogleOAuthEnabled,
10578
12068
  isOAuthProviderEnabled,
10579
12069
  issueOneTimeTokenService,
12070
+ kakaoProvider,
10580
12071
  keysRepository,
10581
12072
  listInvitations,
10582
12073
  loginService,
10583
12074
  logoutService,
12075
+ matchOAuthCsrfCookies,
12076
+ naverProvider,
10584
12077
  oauthCallbackService,
10585
12078
  oauthNativeService,
10586
12079
  oauthStartService,
@@ -10589,11 +12082,13 @@ export {
10589
12082
  parseDuration,
10590
12083
  permissions,
10591
12084
  permissionsRepository,
12085
+ purgeUserService,
10592
12086
  refreshAccessToken,
10593
12087
  registerOAuthProvider,
10594
12088
  registerPublicKeyService,
10595
12089
  registerService,
10596
12090
  removePermissionFromRole,
12091
+ requestAccountDeletionService,
10597
12092
  requireAnyPermission,
10598
12093
  requireEnabledProvider,
10599
12094
  requirePermissions,
@@ -10606,12 +12101,14 @@ export {
10606
12101
  roles,
10607
12102
  rolesRepository,
10608
12103
  rotateKeyService,
12104
+ runBeforeRegister,
10609
12105
  sealSession,
10610
12106
  sendVerificationCodeService,
10611
12107
  setRolePermissions,
10612
12108
  shouldRefreshSession,
10613
12109
  shouldRotateKey,
10614
12110
  socialAccountsRepository,
12111
+ sweepDuePurges,
10615
12112
  unsealSession,
10616
12113
  updateLastLoginService,
10617
12114
  updateLocaleService,