@spfn/auth 0.3.0-beta.7 → 0.3.0-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -4473,7 +4473,7 @@ var init_types = __esm({
4473
4473
 
4474
4474
  // src/server/routes/schema.ts
4475
4475
  import { EMAIL_PATTERN, PHONE_PATTERN } from "@spfn/auth";
4476
- var EmailSchema, PhoneSchema, DeviceNameSchema, PlatformSchema, PasswordSchema, TargetTypeSchema, VERIFICATION_TARGET_TYPES, VerificationPurposeSchema, VERIFICATION_PURPOSES;
4476
+ var EmailSchema, PhoneSchema, DeviceNameSchema, PlatformSchema, PublicKeySchema, KeyIdSchema, FingerprintSchema, UserCodeSchema, DeviceAuthPollResponseSchema, PasswordSchema, TargetTypeSchema, VERIFICATION_TARGET_TYPES, VerificationPurposeSchema, VERIFICATION_PURPOSES;
4477
4477
  var init_schema3 = __esm({
4478
4478
  "src/server/routes/schema.ts"() {
4479
4479
  "use strict";
@@ -4495,6 +4495,39 @@ var init_schema3 = __esm({
4495
4495
  KEY_PLATFORM.map((platform) => Type.Literal(platform)),
4496
4496
  { description: "Platform the key lives on" }
4497
4497
  );
4498
+ PublicKeySchema = Type.String({
4499
+ maxLength: 2048,
4500
+ description: "Client public key, SPKI DER in base64"
4501
+ });
4502
+ KeyIdSchema = Type.String({
4503
+ maxLength: 64,
4504
+ description: "Key identifier"
4505
+ });
4506
+ FingerprintSchema = Type.String({
4507
+ maxLength: 128,
4508
+ description: "SHA-256 hex fingerprint of the public key"
4509
+ });
4510
+ UserCodeSchema = Type.String({
4511
+ minLength: 8,
4512
+ maxLength: 16,
4513
+ description: "Device user code as displayed, e.g. WXYZ-2345. Dashes, spaces and case are ignored."
4514
+ });
4515
+ DeviceAuthPollResponseSchema = Type.Union([
4516
+ Type.Object({
4517
+ status: Type.Literal("pending"),
4518
+ intervalMillis: Type.Integer({ description: "Milliseconds to wait before polling again" })
4519
+ }),
4520
+ Type.Object({
4521
+ status: Type.Literal("approved"),
4522
+ userId: Type.String(),
4523
+ publicId: Type.String(),
4524
+ email: Type.Optional(Type.String()),
4525
+ phone: Type.Optional(Type.String()),
4526
+ passwordChangeRequired: Type.Boolean()
4527
+ })
4528
+ ], {
4529
+ description: "Pending, or the login the approval produced"
4530
+ });
4498
4531
  PasswordSchema = Type.String({
4499
4532
  minLength: 8,
4500
4533
  maxLength: 72,
@@ -5028,9 +5061,72 @@ var init_signup_link_tokens = __esm({
5028
5061
  }
5029
5062
  });
5030
5063
 
5064
+ // src/server/entities/device-authorizations.ts
5065
+ import { text as text8, uniqueIndex as uniqueIndex3 } from "drizzle-orm/pg-core";
5066
+ import { id as id8, timestamps as timestamps7, enumText as enumText5, utcTimestamp as utcTimestamp6, optionalForeignKey } from "@spfn/core/db";
5067
+ var DEVICE_AUTH_STATUSES, deviceAuthorizations;
5068
+ var init_device_authorizations = __esm({
5069
+ "src/server/entities/device-authorizations.ts"() {
5070
+ "use strict";
5071
+ init_types();
5072
+ init_users();
5073
+ init_schema4();
5074
+ DEVICE_AUTH_STATUSES = ["pending", "approved", "denied", "consumed"];
5075
+ deviceAuthorizations = authSchema.table(
5076
+ "device_authorizations",
5077
+ {
5078
+ id: id8(),
5079
+ // SHA-256 of the device code, hex
5080
+ // The code itself (32 random bytes, base64url) is returned to the waiting
5081
+ // device once and never stored: it is the only thing that device holds,
5082
+ // so a dump of this table must not let its reader finish someone's login
5083
+ deviceCodeHash: text8("device_code_hash").notNull().unique(),
5084
+ // The code a person reads off the waiting device's screen
5085
+ // Stored normalized — uppercase, no dash — because that is the only form
5086
+ // a lookup can match; the dash is put back for display
5087
+ // Plaintext on purpose: it authorizes nothing without an approver who is
5088
+ // already authenticated, and it has to be found by exact match
5089
+ userCode: text8("user_code").notNull(),
5090
+ // Key material the waiting device generated, same shapes as user_public_keys
5091
+ // Parked, not registered: it can sign nothing until a poll moves it over
5092
+ publicKey: text8("public_key").notNull(),
5093
+ keyId: text8("key_id").notNull(),
5094
+ fingerprint: text8("fingerprint").notNull(),
5095
+ algorithm: enumText5("algorithm", KEY_ALGORITHM).notNull().default("ES256"),
5096
+ // Device labels the waiting device supplied
5097
+ // null: it sent none
5098
+ // Shown to the approver so they can tell whether the device asking is the
5099
+ // one in front of them — display only, so a client that lies gains nothing
5100
+ // but a wrong line on the approval screen
5101
+ deviceName: text8("device_name"),
5102
+ platform: enumText5("platform", KEY_PLATFORM),
5103
+ status: enumText5("status", DEVICE_AUTH_STATUSES).notNull().default("pending"),
5104
+ // Who approved, written at approve time from the approver's session
5105
+ // null while pending, and on every record that was denied or expired
5106
+ // Never taken from a request body — that would be the whole authorization
5107
+ userId: optionalForeignKey("user", () => users.id, { onDelete: "cascade" }),
5108
+ // Expiry — 10 minutes from creation by default
5109
+ // Judged on read; no job clears the row
5110
+ expiresAt: utcTimestamp6("expires_at").notNull(),
5111
+ // Set when the approver said yes
5112
+ approvedAt: utcTimestamp6("approved_at"),
5113
+ // Set when the poll that registered the key won the race
5114
+ // Non-null means the record is spent, whatever else it says
5115
+ consumedAt: utcTimestamp6("consumed_at"),
5116
+ ...timestamps7()
5117
+ },
5118
+ (table) => [
5119
+ // Lookup path for info/approve/deny
5120
+ // Unique so a typed code can never address two records
5121
+ uniqueIndex3("device_authorization_user_code_idx").on(table.userCode)
5122
+ ]
5123
+ );
5124
+ }
5125
+ });
5126
+
5031
5127
  // src/server/entities/user-invitations.ts
5032
- import { text as text8, index as index8 } from "drizzle-orm/pg-core";
5033
- import { id as id8, timestamps as timestamps7, enumText as enumText5, utcTimestamp as utcTimestamp6, typedJsonb as typedJsonb2, foreignKey as foreignKey5 } from "@spfn/core/db";
5128
+ import { text as text9, index as index8 } from "drizzle-orm/pg-core";
5129
+ import { id as id9, timestamps as timestamps8, enumText as enumText6, utcTimestamp as utcTimestamp7, typedJsonb as typedJsonb2, foreignKey as foreignKey5 } from "@spfn/core/db";
5034
5130
  var userInvitations;
5035
5131
  var init_user_invitations = __esm({
5036
5132
  "src/server/entities/user-invitations.ts"() {
@@ -5043,14 +5139,14 @@ var init_user_invitations = __esm({
5043
5139
  "user_invitations",
5044
5140
  {
5045
5141
  // Primary key
5046
- id: id8(),
5142
+ id: id9(),
5047
5143
  // Target email address for the invitation
5048
5144
  // Will become the user's email upon acceptance
5049
- email: text8("email").notNull(),
5145
+ email: text9("email").notNull(),
5050
5146
  // Unique invitation token (UUID v4)
5051
5147
  // Used in invitation URL: /auth/invite/{token}
5052
5148
  // Single-use token that expires after acceptance
5053
- token: text8("token").notNull().unique(),
5149
+ token: text9("token").notNull().unique(),
5054
5150
  // Role to be assigned when invitation is accepted
5055
5151
  // Foreign key to roles table
5056
5152
  roleId: foreignKey5("role", () => roles.id),
@@ -5063,19 +5159,19 @@ var init_user_invitations = __esm({
5063
5159
  // - accepted: User accepted and account created
5064
5160
  // - expired: Invitation expired (automatic)
5065
5161
  // - cancelled: Invitation cancelled by admin
5066
- status: enumText5("status", INVITATION_STATUSES).default("pending").notNull(),
5162
+ status: enumText6("status", INVITATION_STATUSES).default("pending").notNull(),
5067
5163
  // Expiration timestamp (default: 7 days from creation)
5068
5164
  // Invitation cannot be accepted after this time
5069
5165
  // Background job should update status to 'expired'
5070
- expiresAt: utcTimestamp6("expires_at").notNull(),
5166
+ expiresAt: utcTimestamp7("expires_at").notNull(),
5071
5167
  // Timestamp when invitation was accepted
5072
5168
  // null = not yet accepted
5073
5169
  // Used for: audit trail, analytics
5074
- acceptedAt: utcTimestamp6("accepted_at"),
5170
+ acceptedAt: utcTimestamp7("accepted_at"),
5075
5171
  // Timestamp when invitation was cancelled
5076
5172
  // null = not cancelled
5077
5173
  // Used for: audit trail
5078
- cancelledAt: utcTimestamp6("cancelled_at"),
5174
+ cancelledAt: utcTimestamp7("cancelled_at"),
5079
5175
  // Additional metadata (JSONB)
5080
5176
  // Use cases:
5081
5177
  // - Custom welcome message
@@ -5084,7 +5180,7 @@ var init_user_invitations = __esm({
5084
5180
  // - Custom fields for app-specific data
5085
5181
  // Example: { message: "Welcome!", department: "Engineering" }
5086
5182
  metadata: typedJsonb2("metadata"),
5087
- ...timestamps7()
5183
+ ...timestamps8()
5088
5184
  },
5089
5185
  (table) => [
5090
5186
  // Indexes for query optimization
@@ -5101,9 +5197,9 @@ var init_user_invitations = __esm({
5101
5197
  });
5102
5198
 
5103
5199
  // src/server/entities/account-deletion-requests.ts
5104
- import { text as text9, index as index9, uniqueIndex as uniqueIndex3 } from "drizzle-orm/pg-core";
5200
+ import { text as text10, index as index9, uniqueIndex as uniqueIndex4 } from "drizzle-orm/pg-core";
5105
5201
  import { sql as sql2 } from "drizzle-orm";
5106
- import { id as id9, timestamps as timestamps8, enumText as enumText6, utcTimestamp as utcTimestamp7, optionalForeignKey } from "@spfn/core/db";
5202
+ import { id as id10, timestamps as timestamps9, enumText as enumText7, utcTimestamp as utcTimestamp8, optionalForeignKey as optionalForeignKey2 } from "@spfn/core/db";
5107
5203
  var accountDeletionRequests;
5108
5204
  var init_account_deletion_requests = __esm({
5109
5205
  "src/server/entities/account-deletion-requests.ts"() {
@@ -5114,32 +5210,32 @@ var init_account_deletion_requests = __esm({
5114
5210
  accountDeletionRequests = authSchema.table(
5115
5211
  "account_deletion_requests",
5116
5212
  {
5117
- id: id9(),
5213
+ id: id10(),
5118
5214
  // Foreign key to users table. `set null` (optionalForeignKey default) so this
5119
5215
  // row survives a hard-delete purge of the user it refers to.
5120
- userId: optionalForeignKey("user", () => users.id),
5216
+ userId: optionalForeignKey2("user", () => users.id),
5121
5217
  // Snapshot of the user's public UUID at request time — stays readable even
5122
5218
  // after userId is nulled out or the account is anonymized.
5123
- userPublicId: text9("user_public_id").notNull(),
5219
+ userPublicId: text10("user_public_id").notNull(),
5124
5220
  // When the deletion was requested
5125
- requestedAt: utcTimestamp7("requested_at").notNull().defaultNow(),
5221
+ requestedAt: utcTimestamp8("requested_at").notNull().defaultNow(),
5126
5222
  // When the purge job is allowed to run (requestedAt + grace period; equals
5127
5223
  // requestedAt itself for immediate/zero-grace deletions)
5128
- purgeScheduledAt: utcTimestamp7("purge_scheduled_at").notNull(),
5224
+ purgeScheduledAt: utcTimestamp8("purge_scheduled_at").notNull(),
5129
5225
  // Request lifecycle status
5130
5226
  // - pending: awaiting purgeScheduledAt (or immediate purge)
5131
5227
  // - cancelled: recovered before purge
5132
5228
  // - completed: purge ran
5133
- status: enumText6("status", ACCOUNT_DELETION_REQUEST_STATUSES).default("pending").notNull(),
5229
+ status: enumText7("status", ACCOUNT_DELETION_REQUEST_STATUSES).default("pending").notNull(),
5134
5230
  // Who initiated the request
5135
- requestedBy: enumText6("requested_by", ACCOUNT_DELETION_REQUESTED_BY).default("self").notNull(),
5231
+ requestedBy: enumText7("requested_by", ACCOUNT_DELETION_REQUESTED_BY).default("self").notNull(),
5136
5232
  // Optional free-text reason (self-service UI, admin note, DSR reference, ...)
5137
- reason: text9("reason"),
5138
- cancelledAt: utcTimestamp7("cancelled_at"),
5139
- completedAt: utcTimestamp7("completed_at"),
5233
+ reason: text10("reason"),
5234
+ cancelledAt: utcTimestamp8("cancelled_at"),
5235
+ completedAt: utcTimestamp8("completed_at"),
5140
5236
  // Purge strategy actually executed (set on completion; null while pending)
5141
- purgeStrategy: enumText6("purge_strategy", PURGE_STRATEGIES),
5142
- ...timestamps8()
5237
+ purgeStrategy: enumText7("purge_strategy", PURGE_STRATEGIES),
5238
+ ...timestamps9()
5143
5239
  },
5144
5240
  (table) => [
5145
5241
  index9("account_deletion_requests_user_id_idx").on(table.userId),
@@ -5147,7 +5243,7 @@ var init_account_deletion_requests = __esm({
5147
5243
  index9("account_deletion_requests_purge_scheduled_at_idx").on(table.purgeScheduledAt),
5148
5244
  index9("account_deletion_requests_user_public_id_idx").on(table.userPublicId),
5149
5245
  // Partial unique index: at most one pending request per user at a time.
5150
- uniqueIndex3("account_deletion_requests_user_pending_unique_idx").on(table.userId).where(sql2`${table.status} = 'pending'`)
5246
+ uniqueIndex4("account_deletion_requests_user_pending_unique_idx").on(table.userId).where(sql2`${table.status} = 'pending'`)
5151
5247
  ]
5152
5248
  );
5153
5249
  }
@@ -5299,8 +5395,8 @@ var init_rbac = __esm({
5299
5395
  });
5300
5396
 
5301
5397
  // src/server/entities/permissions.ts
5302
- import { text as text10, boolean as boolean4, index as index10 } from "drizzle-orm/pg-core";
5303
- import { id as id10, timestamps as timestamps9, enumText as enumText7, typedJsonb as typedJsonb3 } from "@spfn/core/db";
5398
+ import { text as text11, boolean as boolean4, index as index10 } from "drizzle-orm/pg-core";
5399
+ import { id as id11, timestamps as timestamps10, enumText as enumText8, typedJsonb as typedJsonb3 } from "@spfn/core/db";
5304
5400
  var permissions;
5305
5401
  var init_permissions = __esm({
5306
5402
  "src/server/entities/permissions.ts"() {
@@ -5311,7 +5407,7 @@ var init_permissions = __esm({
5311
5407
  "permissions",
5312
5408
  {
5313
5409
  // Primary key
5314
- id: id10(),
5410
+ id: id11(),
5315
5411
  // Permission identifier
5316
5412
  // Format: resource:action or namespace:resource:action
5317
5413
  // Examples:
@@ -5319,20 +5415,20 @@ var init_permissions = __esm({
5319
5415
  // - Namespaced: 'auth:user:delete', 'cms:post:publish'
5320
5416
  // Must be unique across all permissions
5321
5417
  // Used in: permission checks, role assignments, API guards
5322
- name: text10("name").notNull().unique(),
5418
+ name: text11("name").notNull().unique(),
5323
5419
  // Display name for UI
5324
5420
  // Human-readable name shown in admin panels
5325
5421
  // Example: "Delete Users", "Publish Posts"
5326
- displayName: text10("display_name").notNull(),
5422
+ displayName: text11("display_name").notNull(),
5327
5423
  // Permission description
5328
5424
  // Detailed explanation of what this permission allows
5329
5425
  // Example: "Allows deletion of user accounts from the system"
5330
- description: text10("description"),
5426
+ description: text11("description"),
5331
5427
  // Category for grouping
5332
5428
  // Used for: organizing permissions in UI, filtering
5333
5429
  // Built-in categories: auth, user, rbac, system
5334
5430
  // Custom categories: any app-specific category
5335
- category: enumText7("category", PERMISSION_CATEGORIES),
5431
+ category: enumText8("category", PERMISSION_CATEGORIES),
5336
5432
  // Built-in permission flag
5337
5433
  // true: Core package permissions (auth:*, user:*, rbac:*)
5338
5434
  // - Cannot be deleted or modified
@@ -5363,7 +5459,7 @@ var init_permissions = __esm({
5363
5459
  // - Audit: { createdBy: 123, source: 'migration', version: '1.0.0' }
5364
5460
  // Example: { icon: 'trash', color: 'red', requiresMfa: true }
5365
5461
  metadata: typedJsonb3("metadata"),
5366
- ...timestamps9()
5462
+ ...timestamps10()
5367
5463
  },
5368
5464
  (table) => [
5369
5465
  index10("permissions_name_idx").on(table.name),
@@ -5378,7 +5474,7 @@ var init_permissions = __esm({
5378
5474
 
5379
5475
  // src/server/entities/role-permissions.ts
5380
5476
  import { index as index11, unique } from "drizzle-orm/pg-core";
5381
- import { id as id11, timestamps as timestamps10, foreignKey as foreignKey6 } from "@spfn/core/db";
5477
+ import { id as id12, timestamps as timestamps11, foreignKey as foreignKey6 } from "@spfn/core/db";
5382
5478
  var rolePermissions;
5383
5479
  var init_role_permissions = __esm({
5384
5480
  "src/server/entities/role-permissions.ts"() {
@@ -5390,7 +5486,7 @@ var init_role_permissions = __esm({
5390
5486
  "role_permissions",
5391
5487
  {
5392
5488
  // Primary key
5393
- id: id11(),
5489
+ id: id12(),
5394
5490
  // Role reference
5395
5491
  // Foreign key to roles table
5396
5492
  // Cascade delete: when role is deleted, all role-permission mappings are removed
@@ -5403,7 +5499,7 @@ var init_role_permissions = __esm({
5403
5499
  // Used for: granting permissions to roles
5404
5500
  // Example: user:delete permission → [Admin, Superadmin]
5405
5501
  permissionId: foreignKey6("permission", () => permissions.id, { onDelete: "cascade" }),
5406
- ...timestamps10()
5502
+ ...timestamps11()
5407
5503
  },
5408
5504
  (table) => [
5409
5505
  // Indexes for query performance
@@ -5417,8 +5513,8 @@ var init_role_permissions = __esm({
5417
5513
  });
5418
5514
 
5419
5515
  // src/server/entities/user-permissions.ts
5420
- import { boolean as boolean5, text as text11, index as index12, unique as unique2 } from "drizzle-orm/pg-core";
5421
- import { id as id12, timestamps as timestamps11, utcTimestamp as utcTimestamp8, foreignKey as foreignKey7 } from "@spfn/core/db";
5516
+ import { boolean as boolean5, text as text12, index as index12, unique as unique2 } from "drizzle-orm/pg-core";
5517
+ import { id as id13, timestamps as timestamps12, utcTimestamp as utcTimestamp9, foreignKey as foreignKey7 } from "@spfn/core/db";
5422
5518
  var userPermissions;
5423
5519
  var init_user_permissions = __esm({
5424
5520
  "src/server/entities/user-permissions.ts"() {
@@ -5430,7 +5526,7 @@ var init_user_permissions = __esm({
5430
5526
  "user_permissions",
5431
5527
  {
5432
5528
  // Primary key
5433
- id: id12(),
5529
+ id: id13(),
5434
5530
  // User reference
5435
5531
  // Foreign key to users table
5436
5532
  // Cascade delete: when user is deleted, all overrides are removed
@@ -5453,13 +5549,13 @@ var init_user_permissions = __esm({
5453
5549
  // Reason for grant/revocation
5454
5550
  // Used for: audit trail, compliance documentation
5455
5551
  // Example: "Temporary access for project X", "Security incident - restricted"
5456
- reason: text11("reason"),
5552
+ reason: text12("reason"),
5457
5553
  // Expiration timestamp (optional)
5458
5554
  // null: Permanent override (remains until manually removed)
5459
5555
  // timestamp: Permission expires at this time (auto-revoked by background job)
5460
5556
  // Use case: Time-limited elevated access, temporary restrictions
5461
- expiresAt: utcTimestamp8("expires_at"),
5462
- ...timestamps11()
5557
+ expiresAt: utcTimestamp9("expires_at"),
5558
+ ...timestamps12()
5463
5559
  },
5464
5560
  (table) => [
5465
5561
  // Indexes for query performance
@@ -5475,7 +5571,7 @@ var init_user_permissions = __esm({
5475
5571
 
5476
5572
  // src/server/entities/auth-metadata.ts
5477
5573
  import { sql as sql3 } from "drizzle-orm";
5478
- import { text as text12, timestamp } from "drizzle-orm/pg-core";
5574
+ import { text as text13, timestamp } from "drizzle-orm/pg-core";
5479
5575
  var authMetadata;
5480
5576
  var init_auth_metadata = __esm({
5481
5577
  "src/server/entities/auth-metadata.ts"() {
@@ -5485,9 +5581,9 @@ var init_auth_metadata = __esm({
5485
5581
  "auth_metadata",
5486
5582
  {
5487
5583
  // Metadata key (primary key)
5488
- key: text12("key").primaryKey(),
5584
+ key: text13("key").primaryKey(),
5489
5585
  // Metadata value
5490
- value: text12("value").notNull(),
5586
+ value: text13("value").notNull(),
5491
5587
  // Last updated timestamp — stamped by the database on insert and on update
5492
5588
  updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => sql3`now()`)
5493
5589
  }
@@ -5496,8 +5592,8 @@ var init_auth_metadata = __esm({
5496
5592
  });
5497
5593
 
5498
5594
  // src/server/entities/ops-tokens.ts
5499
- import { text as text13 } from "drizzle-orm/pg-core";
5500
- import { id as id13, timestamps as timestamps12, utcTimestamp as utcTimestamp9 } from "@spfn/core/db";
5595
+ import { text as text14 } from "drizzle-orm/pg-core";
5596
+ import { id as id14, timestamps as timestamps13, utcTimestamp as utcTimestamp10 } from "@spfn/core/db";
5501
5597
  var opsTokens;
5502
5598
  var init_ops_tokens = __esm({
5503
5599
  "src/server/entities/ops-tokens.ts"() {
@@ -5506,22 +5602,22 @@ var init_ops_tokens = __esm({
5506
5602
  opsTokens = authSchema.table(
5507
5603
  "ops_tokens",
5508
5604
  {
5509
- id: id13(),
5605
+ id: id14(),
5510
5606
  // Operator-facing label ("ci-deploy", "rayim-laptop")
5511
- name: text13("name").notNull(),
5607
+ name: text14("name").notNull(),
5512
5608
  // SHA-256 hex of the token secret. Lookup key — the secret never lands
5513
5609
  // here, and the unique constraint doubles as the lookup index.
5514
- tokenHash: text13("token_hash").notNull().unique(),
5610
+ tokenHash: text14("token_hash").notNull().unique(),
5515
5611
  // Granted scopes as permission strings ('waitlist:read', ...).
5516
5612
  // '*' grants every scope.
5517
- scopes: text13("scopes").array().notNull(),
5613
+ scopes: text14("scopes").array().notNull(),
5518
5614
  // null = the token does not expire
5519
- expiresAt: utcTimestamp9("expires_at"),
5615
+ expiresAt: utcTimestamp10("expires_at"),
5520
5616
  // null = active; a timestamp revokes the token permanently
5521
- revokedAt: utcTimestamp9("revoked_at"),
5617
+ revokedAt: utcTimestamp10("revoked_at"),
5522
5618
  // Last successful verification, updated fire-and-forget
5523
- lastUsedAt: utcTimestamp9("last_used_at"),
5524
- ...timestamps12()
5619
+ lastUsedAt: utcTimestamp10("last_used_at"),
5620
+ ...timestamps13()
5525
5621
  }
5526
5622
  );
5527
5623
  }
@@ -5538,6 +5634,7 @@ var init_entities = __esm({
5538
5634
  init_user_social_accounts();
5539
5635
  init_verification_codes();
5540
5636
  init_signup_link_tokens();
5637
+ init_device_authorizations();
5541
5638
  init_user_invitations();
5542
5639
  init_account_deletion_requests();
5543
5640
  init_roles();
@@ -5564,8 +5661,8 @@ var init_users_repository = __esm({
5564
5661
  * ID로 사용자 조회
5565
5662
  * Read replica 사용
5566
5663
  */
5567
- async findById(id14) {
5568
- const result = await this.readDb.select().from(users).where(eq(users.id, id14)).limit(1);
5664
+ async findById(id15) {
5665
+ const result = await this.readDb.select().from(users).where(eq(users.id, id15)).limit(1);
5569
5666
  return result[0] ?? null;
5570
5667
  }
5571
5668
  /**
@@ -5575,8 +5672,8 @@ var init_users_repository = __esm({
5575
5672
  * 안 되는 게이트(OAuth 세션 발급 등)가 사용한다. 일반 조회는 `findById`(replica)를
5576
5673
  * 계속 사용할 것.
5577
5674
  */
5578
- async findByIdOnPrimary(id14) {
5579
- const result = await this.db.select().from(users).where(eq(users.id, id14)).limit(1);
5675
+ async findByIdOnPrimary(id15) {
5676
+ const result = await this.db.select().from(users).where(eq(users.id, id15)).limit(1);
5580
5677
  return result[0] ?? null;
5581
5678
  }
5582
5679
  /**
@@ -5647,13 +5744,13 @@ var init_users_repository = __esm({
5647
5744
  *
5648
5745
  * roleId가 null인 유저는 role: null 반환
5649
5746
  */
5650
- async findByIdWithRole(id14) {
5747
+ async findByIdWithRole(id15) {
5651
5748
  const result = await this.readDb.select({
5652
5749
  user: users,
5653
5750
  roleName: roles.name,
5654
5751
  roleDisplayName: roles.displayName,
5655
5752
  rolePriority: roles.priority
5656
- }).from(users).leftJoin(roles, eq(users.roleId, roles.id)).where(eq(users.id, id14)).limit(1);
5753
+ }).from(users).leftJoin(roles, eq(users.roleId, roles.id)).where(eq(users.id, id15)).limit(1);
5657
5754
  const row = result[0];
5658
5755
  if (!row) {
5659
5756
  return null;
@@ -5728,9 +5825,9 @@ var init_users_repository = __esm({
5728
5825
  * 사용자 정보 업데이트
5729
5826
  * Write primary 사용
5730
5827
  */
5731
- async updateById(id14, data) {
5828
+ async updateById(id15, data) {
5732
5829
  const patch = "email" in data ? { ...data, email: normalizeOptionalEmail(data.email) } : data;
5733
- const result = await this.db.update(users).set(patch).where(eq(users.id, id14)).returning();
5830
+ const result = await this.db.update(users).set(patch).where(eq(users.id, id15)).returning();
5734
5831
  return result[0] ?? null;
5735
5832
  }
5736
5833
  /**
@@ -5742,10 +5839,10 @@ var init_users_repository = __esm({
5742
5839
  * status가 바뀐 상태) 시 null을 반환하며 예외를 던지지 않는다.
5743
5840
  * Write primary 사용
5744
5841
  */
5745
- async reactivateFromPendingDeletion(id14) {
5842
+ async reactivateFromPendingDeletion(id15) {
5746
5843
  const result = await this.db.update(users).set({ status: "active" }).where(
5747
5844
  and(
5748
- eq(users.id, id14),
5845
+ eq(users.id, id15),
5749
5846
  eq(users.status, "pending_deletion")
5750
5847
  )
5751
5848
  ).returning();
@@ -5755,32 +5852,32 @@ var init_users_repository = __esm({
5755
5852
  * 비밀번호 업데이트
5756
5853
  * Write primary 사용
5757
5854
  */
5758
- async updatePassword(id14, passwordHash, clearPasswordChangeRequired = true) {
5855
+ async updatePassword(id15, passwordHash, clearPasswordChangeRequired = true) {
5759
5856
  const updateData = {
5760
5857
  passwordHash
5761
5858
  };
5762
5859
  if (clearPasswordChangeRequired) {
5763
5860
  updateData.passwordChangeRequired = false;
5764
5861
  }
5765
- const result = await this.db.update(users).set(updateData).where(eq(users.id, id14)).returning();
5862
+ const result = await this.db.update(users).set(updateData).where(eq(users.id, id15)).returning();
5766
5863
  return result[0] ?? null;
5767
5864
  }
5768
5865
  /**
5769
5866
  * 마지막 로그인 시간 업데이트
5770
5867
  * Write primary 사용
5771
5868
  */
5772
- async updateLastLogin(id14) {
5869
+ async updateLastLogin(id15) {
5773
5870
  const result = await this.db.update(users).set({
5774
5871
  lastLoginAt: /* @__PURE__ */ new Date()
5775
- }).where(eq(users.id, id14)).returning();
5872
+ }).where(eq(users.id, id15)).returning();
5776
5873
  return result[0] ?? null;
5777
5874
  }
5778
5875
  /**
5779
5876
  * 사용자 삭제
5780
5877
  * Write primary 사용
5781
5878
  */
5782
- async deleteById(id14) {
5783
- const result = await this.db.delete(users).where(eq(users.id, id14)).returning();
5879
+ async deleteById(id15) {
5880
+ const result = await this.db.delete(users).where(eq(users.id, id15)).returning();
5784
5881
  return result[0] ?? null;
5785
5882
  }
5786
5883
  /**
@@ -6128,14 +6225,14 @@ var init_keys_repository = __esm({
6128
6225
  * stored, so it answers "since when has this device been on this release"
6129
6226
  * rather than "when was it last seen", which lastUsedAt already answers.
6130
6227
  */
6131
- async updateLastUsedById(id14, identity) {
6228
+ async updateLastUsedById(id15, identity) {
6132
6229
  const staleBefore = new Date(Date.now() - LAST_USED_THROTTLE_MS);
6133
6230
  const lastUsedIsStale = or(
6134
6231
  isNull(userPublicKeys.lastUsedAt),
6135
6232
  lt(userPublicKeys.lastUsedAt, staleBefore)
6136
6233
  );
6137
6234
  if (!identity) {
6138
- await this.db.update(userPublicKeys).set({ lastUsedAt: /* @__PURE__ */ new Date() }).where(and2(eq2(userPublicKeys.id, id14), lastUsedIsStale));
6235
+ await this.db.update(userPublicKeys).set({ lastUsedAt: /* @__PURE__ */ new Date() }).where(and2(eq2(userPublicKeys.id, id15), lastUsedIsStale));
6139
6236
  return;
6140
6237
  }
6141
6238
  const identityChanged = sql5`(
@@ -6152,7 +6249,7 @@ var init_keys_repository = __esm({
6152
6249
  clientContractVersion: identity.contractVersion,
6153
6250
  clientSeenAt: sql5`CASE WHEN ${identityChanged} THEN ${nowParam} ELSE ${userPublicKeys.clientSeenAt} END`
6154
6251
  }).where(and2(
6155
- eq2(userPublicKeys.id, id14),
6252
+ eq2(userPublicKeys.id, id15),
6156
6253
  or(lastUsedIsStale, identityChanged)
6157
6254
  ));
6158
6255
  }
@@ -6191,8 +6288,8 @@ var init_verification_codes_repository = __esm({
6191
6288
  * ID로 인증 코드 조회
6192
6289
  * Read replica 사용
6193
6290
  */
6194
- async findById(id14) {
6195
- const result = await this.readDb.select().from(verificationCodes).where(eq3(verificationCodes.id, id14)).limit(1);
6291
+ async findById(id15) {
6292
+ const result = await this.readDb.select().from(verificationCodes).where(eq3(verificationCodes.id, id15)).limit(1);
6196
6293
  return result[0] ?? null;
6197
6294
  }
6198
6295
  /**
@@ -6206,22 +6303,22 @@ var init_verification_codes_repository = __esm({
6206
6303
  * 인증 코드 사용 처리
6207
6304
  * Write primary 사용
6208
6305
  */
6209
- async markAsUsed(id14) {
6306
+ async markAsUsed(id15) {
6210
6307
  const result = await this.db.update(verificationCodes).set({
6211
6308
  usedAt: /* @__PURE__ */ new Date()
6212
- }).where(eq3(verificationCodes.id, id14)).returning();
6309
+ }).where(eq3(verificationCodes.id, id15)).returning();
6213
6310
  return result[0] ?? null;
6214
6311
  }
6215
6312
  /**
6216
6313
  * 시도 횟수 증가
6217
6314
  * Write primary 사용
6218
6315
  */
6219
- async incrementAttempts(id14) {
6220
- const code = await this.findById(id14);
6316
+ async incrementAttempts(id15) {
6317
+ const code = await this.findById(id15);
6221
6318
  if (!code) return null;
6222
6319
  const result = await this.db.update(verificationCodes).set({
6223
6320
  attempts: code.attempts + 1
6224
- }).where(eq3(verificationCodes.id, id14)).returning();
6321
+ }).where(eq3(verificationCodes.id, id15)).returning();
6225
6322
  return result[0] ?? null;
6226
6323
  }
6227
6324
  /**
@@ -6309,14 +6406,14 @@ var init_signup_link_tokens_repository = __esm({
6309
6406
  *
6310
6407
  * @returns the updated row, or null if another request claimed it first
6311
6408
  */
6312
- async claimLink(id14, setupSecretHash, setupExpiresAt) {
6409
+ async claimLink(id15, setupSecretHash, setupExpiresAt) {
6313
6410
  const result = await this.db.update(signupLinkTokens).set({
6314
6411
  consumedAt: /* @__PURE__ */ new Date(),
6315
6412
  setupSecretHash,
6316
6413
  setupExpiresAt
6317
6414
  }).where(
6318
6415
  and4(
6319
- eq4(signupLinkTokens.id, id14),
6416
+ eq4(signupLinkTokens.id, id15),
6320
6417
  isNull3(signupLinkTokens.consumedAt),
6321
6418
  isNull3(signupLinkTokens.supersededAt)
6322
6419
  )
@@ -6329,10 +6426,10 @@ var init_signup_link_tokens_repository = __esm({
6329
6426
  *
6330
6427
  * @returns the updated row, or null if another request completed it first
6331
6428
  */
6332
- async claimSetupSession(id14) {
6429
+ async claimSetupSession(id15) {
6333
6430
  const result = await this.db.update(signupLinkTokens).set({ completedAt: /* @__PURE__ */ new Date() }).where(
6334
6431
  and4(
6335
- eq4(signupLinkTokens.id, id14),
6432
+ eq4(signupLinkTokens.id, id15),
6336
6433
  isNull3(signupLinkTokens.completedAt),
6337
6434
  isNull3(signupLinkTokens.supersededAt)
6338
6435
  )
@@ -6374,27 +6471,172 @@ var init_signup_link_tokens_repository = __esm({
6374
6471
  }
6375
6472
  });
6376
6473
 
6377
- // src/server/repositories/roles.repository.ts
6474
+ // src/server/repositories/device-authorizations.repository.ts
6378
6475
  import { BaseRepository as BaseRepository5 } from "@spfn/core/db";
6379
- import { eq as eq5, asc } from "drizzle-orm";
6476
+ import { eq as eq5, and as and5, gt as gt2, inArray, sql as sql6 } from "drizzle-orm";
6477
+ var notExpired, DeviceAuthorizationsRepository, deviceAuthorizationsRepository;
6478
+ var init_device_authorizations_repository = __esm({
6479
+ "src/server/repositories/device-authorizations.repository.ts"() {
6480
+ "use strict";
6481
+ init_device_authorizations();
6482
+ notExpired = () => gt2(deviceAuthorizations.expiresAt, sql6`now()`);
6483
+ DeviceAuthorizationsRepository = class extends BaseRepository5 {
6484
+ /**
6485
+ * Insert a pending authorization, unless one of its codes is already taken.
6486
+ *
6487
+ * `onConflictDoNothing` rather than letting the unique index raise: the start
6488
+ * route runs inside a transaction, and a raised unique violation aborts it,
6489
+ * so the retry that a generated-code collision calls for could not run — every
6490
+ * statement after it would fail with "current transaction is aborted" instead.
6491
+ * An empty result is the collision signal, and the caller draws a fresh code.
6492
+ *
6493
+ * Write primary.
6494
+ *
6495
+ * @returns the inserted row, or null if either code collided
6496
+ */
6497
+ async create(data) {
6498
+ const result = await this.db.insert(deviceAuthorizations).values(data).onConflictDoNothing().returning();
6499
+ return result[0] ?? null;
6500
+ }
6501
+ /**
6502
+ * Find a record by its normalized user code, in any state.
6503
+ *
6504
+ * Deliberately unfiltered: which refusal a caller is owed — expired, already
6505
+ * handled, unknown — is the service's decision, and a row filtered out here
6506
+ * would be indistinguishable from a code that was never issued.
6507
+ *
6508
+ * Read replica.
6509
+ */
6510
+ async findByUserCode(userCode) {
6511
+ const result = await this.readDb.select().from(deviceAuthorizations).where(eq5(deviceAuthorizations.userCode, userCode)).limit(1);
6512
+ return result[0] ?? null;
6513
+ }
6514
+ /**
6515
+ * Find a record by the hash of a device code, in any state.
6516
+ * Unfiltered for the same reason as `findByUserCode`.
6517
+ *
6518
+ * Read replica.
6519
+ */
6520
+ async findByDeviceCodeHash(deviceCodeHash) {
6521
+ const result = await this.readDb.select().from(deviceAuthorizations).where(eq5(deviceAuthorizations.deviceCodeHash, deviceCodeHash)).limit(1);
6522
+ return result[0] ?? null;
6523
+ }
6524
+ /**
6525
+ * Bind the approving user and move the record to `approved`, but only from
6526
+ * `pending`.
6527
+ *
6528
+ * `userId` comes from the approver's authenticated session — never from a
6529
+ * request body — so this is the point where the record gains an owner.
6530
+ *
6531
+ * @returns the updated row, or null if it was no longer pending, or expired
6532
+ */
6533
+ async approve(id15, userId) {
6534
+ const result = await this.db.update(deviceAuthorizations).set({ status: "approved", userId, approvedAt: /* @__PURE__ */ new Date() }).where(
6535
+ and5(
6536
+ eq5(deviceAuthorizations.id, id15),
6537
+ eq5(deviceAuthorizations.status, "pending"),
6538
+ notExpired()
6539
+ )
6540
+ ).returning();
6541
+ return result[0] ?? null;
6542
+ }
6543
+ /**
6544
+ * Move the record to `denied`, but only from `pending`.
6545
+ *
6546
+ * No user is bound: a refusal should leave no record of who was asked.
6547
+ *
6548
+ * @returns the updated row, or null if it was no longer pending, or expired
6549
+ */
6550
+ async deny(id15) {
6551
+ const result = await this.db.update(deviceAuthorizations).set({ status: "denied" }).where(
6552
+ and5(
6553
+ eq5(deviceAuthorizations.id, id15),
6554
+ eq5(deviceAuthorizations.status, "pending"),
6555
+ notExpired()
6556
+ )
6557
+ ).returning();
6558
+ return result[0] ?? null;
6559
+ }
6560
+ /**
6561
+ * Refuse every authorization a user still has in flight.
6562
+ *
6563
+ * This is the device-code half of a global revocation. Revoking
6564
+ * `user_public_keys` alone leaves an approved-but-uncollected record behind,
6565
+ * and the next poll on it registers a brand-new active key — so "sign every
6566
+ * device out" would hand one straight back to whoever was still waiting,
6567
+ * which is exactly the person a revoke-all is usually aimed at.
6568
+ *
6569
+ * `denied` rather than a status of its own: a swept record owes its holder
6570
+ * the answer a refused one owes, on all four operations — the waiting device
6571
+ * is told no and stops polling, and info/approve/deny say the request was
6572
+ * already answered. Adding a fourth live status would add a migration and a
6573
+ * case-table row to record a distinction nothing acts on.
6574
+ *
6575
+ * Expired rows are swept too. A global revoke is not the place to reason
6576
+ * about which dead rows were about to die anyway.
6577
+ *
6578
+ * `pending` alongside `approved` even though a pending row carries no
6579
+ * `userId` today, so only approved rows can match: the state list says which
6580
+ * states this is meant to close, and a later change that binds the user
6581
+ * earlier should not silently reopen the hole.
6582
+ *
6583
+ * @returns the rows this call refused
6584
+ */
6585
+ async denyAllActiveByUserId(userId) {
6586
+ return await this.db.update(deviceAuthorizations).set({ status: "denied" }).where(
6587
+ and5(
6588
+ eq5(deviceAuthorizations.userId, userId),
6589
+ inArray(deviceAuthorizations.status, ["pending", "approved"])
6590
+ )
6591
+ ).returning();
6592
+ }
6593
+ /**
6594
+ * Spend an approved record, but only from `approved`, and address it by the
6595
+ * device code hash the caller actually presented.
6596
+ *
6597
+ * This is the one-shot: the winner of two concurrent polls registers the key,
6598
+ * and the loser sees no approved record and is answered as if the code were
6599
+ * unknown. Matching on the hash rather than on an id read a moment ago keeps
6600
+ * the whole decision in one statement.
6601
+ *
6602
+ * @returns the spent row, or null if it was not approved (any more), or expired
6603
+ */
6604
+ async consumeApproved(deviceCodeHash) {
6605
+ const result = await this.db.update(deviceAuthorizations).set({ status: "consumed", consumedAt: /* @__PURE__ */ new Date() }).where(
6606
+ and5(
6607
+ eq5(deviceAuthorizations.deviceCodeHash, deviceCodeHash),
6608
+ eq5(deviceAuthorizations.status, "approved"),
6609
+ notExpired()
6610
+ )
6611
+ ).returning();
6612
+ return result[0] ?? null;
6613
+ }
6614
+ };
6615
+ deviceAuthorizationsRepository = new DeviceAuthorizationsRepository();
6616
+ }
6617
+ });
6618
+
6619
+ // src/server/repositories/roles.repository.ts
6620
+ import { BaseRepository as BaseRepository6 } from "@spfn/core/db";
6621
+ import { eq as eq6, asc } from "drizzle-orm";
6380
6622
  var RolesRepository, rolesRepository;
6381
6623
  var init_roles_repository = __esm({
6382
6624
  "src/server/repositories/roles.repository.ts"() {
6383
6625
  "use strict";
6384
6626
  init_roles();
6385
- RolesRepository = class extends BaseRepository5 {
6627
+ RolesRepository = class extends BaseRepository6 {
6386
6628
  /**
6387
6629
  * ID로 역할 조회
6388
6630
  */
6389
- async findById(id14) {
6390
- const result = await this.readDb.select().from(roles).where(eq5(roles.id, id14)).limit(1);
6631
+ async findById(id15) {
6632
+ const result = await this.readDb.select().from(roles).where(eq6(roles.id, id15)).limit(1);
6391
6633
  return result[0] ?? null;
6392
6634
  }
6393
6635
  /**
6394
6636
  * Name으로 역할 조회
6395
6637
  */
6396
6638
  async findByName(name) {
6397
- const result = await this.readDb.select().from(roles).where(eq5(roles.name, name)).limit(1);
6639
+ const result = await this.readDb.select().from(roles).where(eq6(roles.name, name)).limit(1);
6398
6640
  return result[0] ?? null;
6399
6641
  }
6400
6642
  /**
@@ -6407,7 +6649,7 @@ var init_roles_repository = __esm({
6407
6649
  * 활성 역할만 조회
6408
6650
  */
6409
6651
  async findActive() {
6410
- return this.readDb.select().from(roles).where(eq5(roles.isActive, true)).orderBy(asc(roles.priority));
6652
+ return this.readDb.select().from(roles).where(eq6(roles.isActive, true)).orderBy(asc(roles.priority));
6411
6653
  }
6412
6654
  /**
6413
6655
  * 역할 생성
@@ -6418,15 +6660,15 @@ var init_roles_repository = __esm({
6418
6660
  /**
6419
6661
  * 역할 업데이트
6420
6662
  */
6421
- async updateById(id14, data) {
6422
- const result = await this.db.update(roles).set(data).where(eq5(roles.id, id14)).returning();
6663
+ async updateById(id15, data) {
6664
+ const result = await this.db.update(roles).set(data).where(eq6(roles.id, id15)).returning();
6423
6665
  return result[0] ?? null;
6424
6666
  }
6425
6667
  /**
6426
6668
  * 역할 삭제
6427
6669
  */
6428
- async deleteById(id14) {
6429
- const result = await this.db.delete(roles).where(eq5(roles.id, id14)).returning();
6670
+ async deleteById(id15) {
6671
+ const result = await this.db.delete(roles).where(eq6(roles.id, id15)).returning();
6430
6672
  return result[0] ?? null;
6431
6673
  }
6432
6674
  };
@@ -6435,26 +6677,26 @@ var init_roles_repository = __esm({
6435
6677
  });
6436
6678
 
6437
6679
  // src/server/repositories/permissions.repository.ts
6438
- import { BaseRepository as BaseRepository6 } from "@spfn/core/db";
6439
- import { asc as asc2, eq as eq6, inArray } from "drizzle-orm";
6680
+ import { BaseRepository as BaseRepository7 } from "@spfn/core/db";
6681
+ import { asc as asc2, eq as eq7, inArray as inArray2 } from "drizzle-orm";
6440
6682
  var PermissionsRepository, permissionsRepository;
6441
6683
  var init_permissions_repository = __esm({
6442
6684
  "src/server/repositories/permissions.repository.ts"() {
6443
6685
  "use strict";
6444
6686
  init_permissions();
6445
- PermissionsRepository = class extends BaseRepository6 {
6687
+ PermissionsRepository = class extends BaseRepository7 {
6446
6688
  /**
6447
6689
  * ID로 권한 조회
6448
6690
  */
6449
- async findById(id14) {
6450
- const result = await this.readDb.select().from(permissions).where(eq6(permissions.id, id14)).limit(1);
6691
+ async findById(id15) {
6692
+ const result = await this.readDb.select().from(permissions).where(eq7(permissions.id, id15)).limit(1);
6451
6693
  return result[0] ?? null;
6452
6694
  }
6453
6695
  /**
6454
6696
  * Name으로 권한 조회
6455
6697
  */
6456
6698
  async findByName(name) {
6457
- const result = await this.readDb.select().from(permissions).where(eq6(permissions.name, name)).limit(1);
6699
+ const result = await this.readDb.select().from(permissions).where(eq7(permissions.name, name)).limit(1);
6458
6700
  return result[0] ?? null;
6459
6701
  }
6460
6702
  /**
@@ -6462,7 +6704,7 @@ var init_permissions_repository = __esm({
6462
6704
  */
6463
6705
  async findByNames(names) {
6464
6706
  if (names.length === 0) return [];
6465
- return this.readDb.select().from(permissions).where(inArray(permissions.name, names));
6707
+ return this.readDb.select().from(permissions).where(inArray2(permissions.name, names));
6466
6708
  }
6467
6709
  /**
6468
6710
  * 모든 권한 조회
@@ -6474,13 +6716,13 @@ var init_permissions_repository = __esm({
6474
6716
  * 활성 권한만 조회
6475
6717
  */
6476
6718
  async findActive() {
6477
- return this.readDb.select().from(permissions).where(eq6(permissions.isActive, true)).orderBy(asc2(permissions.name));
6719
+ return this.readDb.select().from(permissions).where(eq7(permissions.isActive, true)).orderBy(asc2(permissions.name));
6478
6720
  }
6479
6721
  /**
6480
6722
  * 카테고리별 권한 조회
6481
6723
  */
6482
6724
  async findByCategory(category) {
6483
- return this.readDb.select().from(permissions).where(eq6(permissions.category, category)).orderBy(asc2(permissions.name));
6725
+ return this.readDb.select().from(permissions).where(eq7(permissions.category, category)).orderBy(asc2(permissions.name));
6484
6726
  }
6485
6727
  /**
6486
6728
  * 권한 생성
@@ -6498,15 +6740,15 @@ var init_permissions_repository = __esm({
6498
6740
  /**
6499
6741
  * 권한 업데이트
6500
6742
  */
6501
- async updateById(id14, data) {
6502
- const result = await this.db.update(permissions).set(data).where(eq6(permissions.id, id14)).returning();
6743
+ async updateById(id15, data) {
6744
+ const result = await this.db.update(permissions).set(data).where(eq7(permissions.id, id15)).returning();
6503
6745
  return result[0] ?? null;
6504
6746
  }
6505
6747
  /**
6506
6748
  * 권한 삭제
6507
6749
  */
6508
- async deleteById(id14) {
6509
- const result = await this.db.delete(permissions).where(eq6(permissions.id, id14)).returning();
6750
+ async deleteById(id15) {
6751
+ const result = await this.db.delete(permissions).where(eq7(permissions.id, id15)).returning();
6510
6752
  return result[0] ?? null;
6511
6753
  }
6512
6754
  };
@@ -6515,25 +6757,25 @@ var init_permissions_repository = __esm({
6515
6757
  });
6516
6758
 
6517
6759
  // src/server/repositories/role-permissions.repository.ts
6518
- import { BaseRepository as BaseRepository7 } from "@spfn/core/db";
6519
- import { and as and5, eq as eq7 } from "drizzle-orm";
6760
+ import { BaseRepository as BaseRepository8 } from "@spfn/core/db";
6761
+ import { and as and6, eq as eq8 } from "drizzle-orm";
6520
6762
  var RolePermissionsRepository, rolePermissionsRepository;
6521
6763
  var init_role_permissions_repository = __esm({
6522
6764
  "src/server/repositories/role-permissions.repository.ts"() {
6523
6765
  "use strict";
6524
6766
  init_role_permissions();
6525
- RolePermissionsRepository = class extends BaseRepository7 {
6767
+ RolePermissionsRepository = class extends BaseRepository8 {
6526
6768
  /**
6527
6769
  * 역할 ID로 모든 권한 조회
6528
6770
  */
6529
6771
  async findByRoleId(roleId) {
6530
- return this.readDb.select().from(rolePermissions).where(eq7(rolePermissions.roleId, roleId));
6772
+ return this.readDb.select().from(rolePermissions).where(eq8(rolePermissions.roleId, roleId));
6531
6773
  }
6532
6774
  /**
6533
6775
  * 권한 ID로 모든 역할 조회
6534
6776
  */
6535
6777
  async findByPermissionId(permissionId) {
6536
- return this.readDb.select().from(rolePermissions).where(eq7(rolePermissions.permissionId, permissionId));
6778
+ return this.readDb.select().from(rolePermissions).where(eq8(rolePermissions.permissionId, permissionId));
6537
6779
  }
6538
6780
  /**
6539
6781
  * 역할-권한 매핑 생성
@@ -6553,9 +6795,9 @@ var init_role_permissions_repository = __esm({
6553
6795
  */
6554
6796
  async deleteByRoleIdAndPermissionId(roleId, permissionId) {
6555
6797
  const result = await this.db.delete(rolePermissions).where(
6556
- and5(
6557
- eq7(rolePermissions.roleId, roleId),
6558
- eq7(rolePermissions.permissionId, permissionId)
6798
+ and6(
6799
+ eq8(rolePermissions.roleId, roleId),
6800
+ eq8(rolePermissions.permissionId, permissionId)
6559
6801
  )
6560
6802
  ).returning();
6561
6803
  return result[0] ?? null;
@@ -6564,7 +6806,7 @@ var init_role_permissions_repository = __esm({
6564
6806
  * 역할의 모든 권한 매핑 삭제
6565
6807
  */
6566
6808
  async deleteByRoleId(roleId) {
6567
- const result = await this.db.delete(rolePermissions).where(eq7(rolePermissions.roleId, roleId)).returning();
6809
+ const result = await this.db.delete(rolePermissions).where(eq8(rolePermissions.roleId, roleId)).returning();
6568
6810
  return result.length;
6569
6811
  }
6570
6812
  /**
@@ -6585,19 +6827,19 @@ var init_role_permissions_repository = __esm({
6585
6827
  });
6586
6828
 
6587
6829
  // src/server/repositories/user-permissions.repository.ts
6588
- import { BaseRepository as BaseRepository8 } from "@spfn/core/db";
6589
- import { eq as eq8, and as and6, or as or2, isNull as isNull4, isNotNull, lt as lt3, gt as gt2 } from "drizzle-orm";
6830
+ import { BaseRepository as BaseRepository9 } from "@spfn/core/db";
6831
+ import { eq as eq9, and as and7, or as or2, isNull as isNull4, isNotNull, lt as lt3, gt as gt3 } from "drizzle-orm";
6590
6832
  var UserPermissionsRepository, userPermissionsRepository;
6591
6833
  var init_user_permissions_repository = __esm({
6592
6834
  "src/server/repositories/user-permissions.repository.ts"() {
6593
6835
  "use strict";
6594
6836
  init_user_permissions();
6595
- UserPermissionsRepository = class extends BaseRepository8 {
6837
+ UserPermissionsRepository = class extends BaseRepository9 {
6596
6838
  /**
6597
6839
  * 사용자 ID로 모든 권한 오버라이드 조회
6598
6840
  */
6599
6841
  async findByUserId(userId) {
6600
- return this.readDb.select().from(userPermissions).where(eq8(userPermissions.userId, userId));
6842
+ return this.readDb.select().from(userPermissions).where(eq9(userPermissions.userId, userId));
6601
6843
  }
6602
6844
  /**
6603
6845
  * 사용자 ID로 유효한 권한 오버라이드만 조회
@@ -6606,11 +6848,11 @@ var init_user_permissions_repository = __esm({
6606
6848
  async findValidByUserId(userId) {
6607
6849
  const now = /* @__PURE__ */ new Date();
6608
6850
  return this.readDb.select().from(userPermissions).where(
6609
- and6(
6610
- eq8(userPermissions.userId, userId),
6851
+ and7(
6852
+ eq9(userPermissions.userId, userId),
6611
6853
  or2(
6612
6854
  isNull4(userPermissions.expiresAt),
6613
- gt2(userPermissions.expiresAt, now)
6855
+ gt3(userPermissions.expiresAt, now)
6614
6856
  )
6615
6857
  )
6616
6858
  );
@@ -6620,9 +6862,9 @@ var init_user_permissions_repository = __esm({
6620
6862
  */
6621
6863
  async findByUserIdAndPermissionId(userId, permissionId) {
6622
6864
  const result = await this.readDb.select().from(userPermissions).where(
6623
- and6(
6624
- eq8(userPermissions.userId, userId),
6625
- eq8(userPermissions.permissionId, permissionId)
6865
+ and7(
6866
+ eq9(userPermissions.userId, userId),
6867
+ eq9(userPermissions.permissionId, permissionId)
6626
6868
  )
6627
6869
  ).limit(1);
6628
6870
  return result[0] ?? null;
@@ -6636,8 +6878,8 @@ var init_user_permissions_repository = __esm({
6636
6878
  /**
6637
6879
  * 사용자 권한 오버라이드 업데이트
6638
6880
  */
6639
- async updateById(id14, data) {
6640
- const result = await this.db.update(userPermissions).set(data).where(eq8(userPermissions.id, id14)).returning();
6881
+ async updateById(id15, data) {
6882
+ const result = await this.db.update(userPermissions).set(data).where(eq9(userPermissions.id, id15)).returning();
6641
6883
  return result[0] ?? null;
6642
6884
  }
6643
6885
  /**
@@ -6645,9 +6887,9 @@ var init_user_permissions_repository = __esm({
6645
6887
  */
6646
6888
  async deleteByUserIdAndPermissionId(userId, permissionId) {
6647
6889
  const result = await this.db.delete(userPermissions).where(
6648
- and6(
6649
- eq8(userPermissions.userId, userId),
6650
- eq8(userPermissions.permissionId, permissionId)
6890
+ and7(
6891
+ eq9(userPermissions.userId, userId),
6892
+ eq9(userPermissions.permissionId, permissionId)
6651
6893
  )
6652
6894
  ).returning();
6653
6895
  return result[0] ?? null;
@@ -6656,7 +6898,7 @@ var init_user_permissions_repository = __esm({
6656
6898
  * 사용자의 모든 권한 오버라이드 삭제
6657
6899
  */
6658
6900
  async deleteByUserId(userId) {
6659
- const result = await this.db.delete(userPermissions).where(eq8(userPermissions.userId, userId)).returning();
6901
+ const result = await this.db.delete(userPermissions).where(eq9(userPermissions.userId, userId)).returning();
6660
6902
  return result.length;
6661
6903
  }
6662
6904
  /**
@@ -6665,7 +6907,7 @@ var init_user_permissions_repository = __esm({
6665
6907
  async deleteExpired() {
6666
6908
  const now = /* @__PURE__ */ new Date();
6667
6909
  const result = await this.db.delete(userPermissions).where(
6668
- and6(
6910
+ and7(
6669
6911
  isNotNull(userPermissions.expiresAt),
6670
6912
  lt3(userPermissions.expiresAt, now)
6671
6913
  )
@@ -6678,33 +6920,33 @@ var init_user_permissions_repository = __esm({
6678
6920
  });
6679
6921
 
6680
6922
  // src/server/repositories/user-profiles.repository.ts
6681
- import { BaseRepository as BaseRepository9 } from "@spfn/core/db";
6682
- import { eq as eq9 } from "drizzle-orm";
6923
+ import { BaseRepository as BaseRepository10 } from "@spfn/core/db";
6924
+ import { eq as eq10 } from "drizzle-orm";
6683
6925
  var UserProfilesRepository, userProfilesRepository;
6684
6926
  var init_user_profiles_repository = __esm({
6685
6927
  "src/server/repositories/user-profiles.repository.ts"() {
6686
6928
  "use strict";
6687
6929
  init_user_profiles();
6688
- UserProfilesRepository = class extends BaseRepository9 {
6930
+ UserProfilesRepository = class extends BaseRepository10 {
6689
6931
  /**
6690
6932
  * ID로 프로필 조회
6691
6933
  */
6692
- async findById(id14) {
6693
- const result = await this.readDb.select().from(userProfiles).where(eq9(userProfiles.id, id14)).limit(1);
6934
+ async findById(id15) {
6935
+ const result = await this.readDb.select().from(userProfiles).where(eq10(userProfiles.id, id15)).limit(1);
6694
6936
  return result[0] ?? null;
6695
6937
  }
6696
6938
  /**
6697
6939
  * User ID로 locale만 조회 (경량)
6698
6940
  */
6699
6941
  async findLocaleByUserId(userId) {
6700
- const result = await this.readDb.select({ locale: userProfiles.locale }).from(userProfiles).where(eq9(userProfiles.userId, userId)).limit(1);
6942
+ const result = await this.readDb.select({ locale: userProfiles.locale }).from(userProfiles).where(eq10(userProfiles.userId, userId)).limit(1);
6701
6943
  return result[0]?.locale || "en";
6702
6944
  }
6703
6945
  /**
6704
6946
  * User ID로 프로필 조회
6705
6947
  */
6706
6948
  async findByUserId(userId) {
6707
- const result = await this.readDb.select().from(userProfiles).where(eq9(userProfiles.userId, userId)).limit(1);
6949
+ const result = await this.readDb.select().from(userProfiles).where(eq10(userProfiles.userId, userId)).limit(1);
6708
6950
  return result[0] ?? null;
6709
6951
  }
6710
6952
  /**
@@ -6716,29 +6958,29 @@ var init_user_profiles_repository = __esm({
6716
6958
  /**
6717
6959
  * 프로필 업데이트 (by ID)
6718
6960
  */
6719
- async updateById(id14, data) {
6720
- const result = await this.db.update(userProfiles).set(data).where(eq9(userProfiles.id, id14)).returning();
6961
+ async updateById(id15, data) {
6962
+ const result = await this.db.update(userProfiles).set(data).where(eq10(userProfiles.id, id15)).returning();
6721
6963
  return result[0] ?? null;
6722
6964
  }
6723
6965
  /**
6724
6966
  * 프로필 업데이트 (by User ID)
6725
6967
  */
6726
6968
  async updateByUserId(userId, data) {
6727
- const result = await this.db.update(userProfiles).set(data).where(eq9(userProfiles.userId, userId)).returning();
6969
+ const result = await this.db.update(userProfiles).set(data).where(eq10(userProfiles.userId, userId)).returning();
6728
6970
  return result[0] ?? null;
6729
6971
  }
6730
6972
  /**
6731
6973
  * 프로필 삭제 (by ID)
6732
6974
  */
6733
- async deleteById(id14) {
6734
- const result = await this.db.delete(userProfiles).where(eq9(userProfiles.id, id14)).returning();
6975
+ async deleteById(id15) {
6976
+ const result = await this.db.delete(userProfiles).where(eq10(userProfiles.id, id15)).returning();
6735
6977
  return result[0] ?? null;
6736
6978
  }
6737
6979
  /**
6738
6980
  * 프로필 삭제 (by User ID)
6739
6981
  */
6740
6982
  async deleteByUserId(userId) {
6741
- const result = await this.db.delete(userProfiles).where(eq9(userProfiles.userId, userId)).returning();
6983
+ const result = await this.db.delete(userProfiles).where(eq10(userProfiles.userId, userId)).returning();
6742
6984
  return result[0] ?? null;
6743
6985
  }
6744
6986
  /**
@@ -6780,7 +7022,7 @@ var init_user_profiles_repository = __esm({
6780
7022
  metadata: userProfiles.metadata,
6781
7023
  createdAt: userProfiles.createdAt,
6782
7024
  updatedAt: userProfiles.updatedAt
6783
- }).from(userProfiles).where(eq9(userProfiles.userId, userId)).limit(1).then((rows) => rows[0] ?? null);
7025
+ }).from(userProfiles).where(eq10(userProfiles.userId, userId)).limit(1).then((rows) => rows[0] ?? null);
6784
7026
  if (!profile) {
6785
7027
  return null;
6786
7028
  }
@@ -6808,8 +7050,8 @@ var init_user_profiles_repository = __esm({
6808
7050
  });
6809
7051
 
6810
7052
  // src/server/repositories/invitations.repository.ts
6811
- import { eq as eq10, and as and7, lt as lt4, desc as desc2, sql as sql6 } from "drizzle-orm";
6812
- import { BaseRepository as BaseRepository10 } from "@spfn/core/db";
7053
+ import { eq as eq11, and as and8, lt as lt4, desc as desc2, sql as sql7 } from "drizzle-orm";
7054
+ import { BaseRepository as BaseRepository11 } from "@spfn/core/db";
6813
7055
  var InvitationsRepository, invitationsRepository;
6814
7056
  var init_invitations_repository = __esm({
6815
7057
  "src/server/repositories/invitations.repository.ts"() {
@@ -6818,19 +7060,19 @@ var init_invitations_repository = __esm({
6818
7060
  init_roles();
6819
7061
  init_user_invitations();
6820
7062
  init_email();
6821
- InvitationsRepository = class extends BaseRepository10 {
7063
+ InvitationsRepository = class extends BaseRepository11 {
6822
7064
  /**
6823
7065
  * ID로 초대 조회
6824
7066
  */
6825
- async findById(id14) {
6826
- const result = await this.readDb.select().from(userInvitations).where(eq10(userInvitations.id, id14)).limit(1);
7067
+ async findById(id15) {
7068
+ const result = await this.readDb.select().from(userInvitations).where(eq11(userInvitations.id, id15)).limit(1);
6827
7069
  return result[0] ?? null;
6828
7070
  }
6829
7071
  /**
6830
7072
  * Token으로 초대 조회
6831
7073
  */
6832
7074
  async findByToken(token) {
6833
- const result = await this.readDb.select().from(userInvitations).where(eq10(userInvitations.token, token)).limit(1);
7075
+ const result = await this.readDb.select().from(userInvitations).where(eq11(userInvitations.token, token)).limit(1);
6834
7076
  return result[0] ?? null;
6835
7077
  }
6836
7078
  /**
@@ -6838,9 +7080,9 @@ var init_invitations_repository = __esm({
6838
7080
  */
6839
7081
  async findPendingByEmail(email) {
6840
7082
  const result = await this.readDb.select().from(userInvitations).where(
6841
- and7(
6842
- eq10(userInvitations.email, normalizeEmail(email)),
6843
- eq10(userInvitations.status, "pending")
7083
+ and8(
7084
+ eq11(userInvitations.email, normalizeEmail(email)),
7085
+ eq11(userInvitations.status, "pending")
6844
7086
  )
6845
7087
  ).limit(1);
6846
7088
  return result[0] ?? null;
@@ -6849,13 +7091,13 @@ var init_invitations_repository = __esm({
6849
7091
  * 초대자 ID로 모든 초대 조회
6850
7092
  */
6851
7093
  async findByInvitedBy(invitedBy) {
6852
- return this.readDb.select().from(userInvitations).where(eq10(userInvitations.invitedBy, invitedBy));
7094
+ return this.readDb.select().from(userInvitations).where(eq11(userInvitations.invitedBy, invitedBy));
6853
7095
  }
6854
7096
  /**
6855
7097
  * 상태별 초대 조회
6856
7098
  */
6857
7099
  async findByStatus(status) {
6858
- return this.readDb.select().from(userInvitations).where(eq10(userInvitations.status, status));
7100
+ return this.readDb.select().from(userInvitations).where(eq11(userInvitations.status, status));
6859
7101
  }
6860
7102
  /**
6861
7103
  * 초대 생성
@@ -6866,7 +7108,7 @@ var init_invitations_repository = __esm({
6866
7108
  /**
6867
7109
  * 초대 상태 업데이트
6868
7110
  */
6869
- async updateStatus(id14, status, timestamp2) {
7111
+ async updateStatus(id15, status, timestamp2) {
6870
7112
  const updates = {
6871
7113
  status
6872
7114
  };
@@ -6877,14 +7119,14 @@ var init_invitations_repository = __esm({
6877
7119
  updates.cancelledAt = timestamp2;
6878
7120
  }
6879
7121
  }
6880
- const result = await this.db.update(userInvitations).set(updates).where(eq10(userInvitations.id, id14)).returning();
7122
+ const result = await this.db.update(userInvitations).set(updates).where(eq11(userInvitations.id, id15)).returning();
6881
7123
  return result[0] ?? null;
6882
7124
  }
6883
7125
  /**
6884
7126
  * 초대 삭제
6885
7127
  */
6886
- async deleteById(id14) {
6887
- const result = await this.db.delete(userInvitations).where(eq10(userInvitations.id, id14)).returning();
7128
+ async deleteById(id15) {
7129
+ const result = await this.db.delete(userInvitations).where(eq11(userInvitations.id, id15)).returning();
6888
7130
  return result[0] ?? null;
6889
7131
  }
6890
7132
  /**
@@ -6893,8 +7135,8 @@ var init_invitations_repository = __esm({
6893
7135
  async updateExpiredInvitations() {
6894
7136
  const now = /* @__PURE__ */ new Date();
6895
7137
  const result = await this.db.update(userInvitations).set({ status: "expired" }).where(
6896
- and7(
6897
- eq10(userInvitations.status, "pending"),
7138
+ and8(
7139
+ eq11(userInvitations.status, "pending"),
6898
7140
  lt4(userInvitations.expiresAt, now)
6899
7141
  )
6900
7142
  ).returning();
@@ -6926,7 +7168,7 @@ var init_invitations_repository = __esm({
6926
7168
  id: users.id,
6927
7169
  email: users.email
6928
7170
  }
6929
- }).from(userInvitations).innerJoin(roles, eq10(userInvitations.roleId, roles.id)).innerJoin(users, eq10(userInvitations.invitedBy, users.id)).where(eq10(userInvitations.token, token)).limit(1);
7171
+ }).from(userInvitations).innerJoin(roles, eq11(userInvitations.roleId, roles.id)).innerJoin(users, eq11(userInvitations.invitedBy, users.id)).where(eq11(userInvitations.token, token)).limit(1);
6930
7172
  return result[0] ?? null;
6931
7173
  }
6932
7174
  /**
@@ -6937,13 +7179,13 @@ var init_invitations_repository = __esm({
6937
7179
  const offset = (page - 1) * limit;
6938
7180
  const conditions = [];
6939
7181
  if (status) {
6940
- conditions.push(eq10(userInvitations.status, status));
7182
+ conditions.push(eq11(userInvitations.status, status));
6941
7183
  }
6942
7184
  if (invitedBy) {
6943
- conditions.push(eq10(userInvitations.invitedBy, invitedBy));
7185
+ conditions.push(eq11(userInvitations.invitedBy, invitedBy));
6944
7186
  }
6945
- const whereClause = conditions.length > 0 ? and7(...conditions) : void 0;
6946
- const countResult = await this.readDb.select({ count: sql6`count(*)` }).from(userInvitations).where(whereClause);
7187
+ const whereClause = conditions.length > 0 ? and8(...conditions) : void 0;
7188
+ const countResult = await this.readDb.select({ count: sql7`count(*)` }).from(userInvitations).where(whereClause);
6947
7189
  const total = Number(countResult[0]?.count || 0);
6948
7190
  const results = await this.readDb.select({
6949
7191
  id: userInvitations.id,
@@ -6967,7 +7209,7 @@ var init_invitations_repository = __esm({
6967
7209
  id: users.id,
6968
7210
  email: users.email
6969
7211
  }
6970
- }).from(userInvitations).innerJoin(roles, eq10(userInvitations.roleId, roles.id)).innerJoin(users, eq10(userInvitations.invitedBy, users.id)).where(whereClause).orderBy(desc2(userInvitations.createdAt)).limit(limit).offset(offset);
7212
+ }).from(userInvitations).innerJoin(roles, eq11(userInvitations.roleId, roles.id)).innerJoin(users, eq11(userInvitations.invitedBy, users.id)).where(whereClause).orderBy(desc2(userInvitations.createdAt)).limit(limit).offset(offset);
6971
7213
  return {
6972
7214
  invitations: results,
6973
7215
  total,
@@ -6979,31 +7221,31 @@ var init_invitations_repository = __esm({
6979
7221
  /**
6980
7222
  * 초대 업데이트 (일반 업데이트 - 모든 필드 가능)
6981
7223
  */
6982
- async updateById(id14, data) {
7224
+ async updateById(id15, data) {
6983
7225
  const patch = "email" in data && typeof data.email === "string" ? { ...data, email: normalizeEmail(data.email) } : data;
6984
- const result = await this.db.update(userInvitations).set(patch).where(eq10(userInvitations.id, id14)).returning();
7226
+ const result = await this.db.update(userInvitations).set(patch).where(eq11(userInvitations.id, id15)).returning();
6985
7227
  return result[0] ?? null;
6986
7228
  }
6987
7229
  /**
6988
7230
  * 초대 재전송 (status와 expiresAt 동시 업데이트)
6989
7231
  */
6990
- async resend(id14, newExpiresAt) {
7232
+ async resend(id15, newExpiresAt) {
6991
7233
  const result = await this.db.update(userInvitations).set({
6992
7234
  status: "pending",
6993
7235
  expiresAt: newExpiresAt
6994
- }).where(eq10(userInvitations.id, id14)).returning();
7236
+ }).where(eq11(userInvitations.id, id15)).returning();
6995
7237
  return result[0] ?? null;
6996
7238
  }
6997
7239
  /**
6998
7240
  * 초대 취소 (status, metadata 동시 업데이트)
6999
7241
  */
7000
- async cancel(id14, cancelledBy, reason, currentMetadata) {
7242
+ async cancel(id15, cancelledBy, reason, currentMetadata) {
7001
7243
  const newMetadata = currentMetadata ? { ...currentMetadata, cancelReason: reason, cancelledBy } : { cancelReason: reason, cancelledBy };
7002
7244
  const result = await this.db.update(userInvitations).set({
7003
7245
  status: "cancelled",
7004
7246
  cancelledAt: /* @__PURE__ */ new Date(),
7005
7247
  metadata: newMetadata
7006
- }).where(eq10(userInvitations.id, id14)).returning();
7248
+ }).where(eq11(userInvitations.id, id15)).returning();
7007
7249
  return result[0] ?? null;
7008
7250
  }
7009
7251
  };
@@ -7090,6 +7332,15 @@ var init_schema5 = __esm({
7090
7332
  examples: [true, false]
7091
7333
  })
7092
7334
  },
7335
+ SPFN_AUTH_CSRF: {
7336
+ ...envString({
7337
+ description: 'CSRF protection for cookie-session mutations in the Next.js proxy: off | warn | enforce. Unset behaves as "warn" (log what would be refused, allow it through). configureAuth({ csrf: { mode } }) takes precedence.',
7338
+ required: false,
7339
+ nextjs: true,
7340
+ // The check runs in the Next.js proxy
7341
+ examples: ["enforce", "warn", "off"]
7342
+ })
7343
+ },
7093
7344
  SPFN_AUTH_BCRYPT_SALT_ROUNDS: {
7094
7345
  ...envNumber({
7095
7346
  description: "Bcrypt salt rounds (cost factor, higher = more secure but slower)",
@@ -7317,7 +7568,7 @@ var init_schema5 = __esm({
7317
7568
  },
7318
7569
  SPFN_AUTH_GOOGLE_REDIRECT_URI: {
7319
7570
  ...envString({
7320
- 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).",
7571
+ description: "Google OAuth callback URL. Defaults to {NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}/_auth/oauth/google/callback. The override must stay on the web app origin at this exact path \u2014 the CSRF cookie for the callback is host-only and the app rewrites /_auth/:path* to the API, so a callback that lands anywhere else is refused for CSRF. Checked at boot: a value off the web app origin or off the callback path refuses to start. The one case for an override elsewhere is the direct POST /_auth/oauth/start flow on a split deployment (no Next.js interceptor, so its CSRF cookie is on the API host), which also needs SPFN_AUTH_OAUTH_CALLBACK_ORIGIN_CHECK=off.",
7321
7572
  required: false,
7322
7573
  examples: [
7323
7574
  "https://app.example.com/_auth/oauth/google/callback",
@@ -7360,7 +7611,7 @@ var init_schema5 = __esm({
7360
7611
  },
7361
7612
  SPFN_AUTH_KAKAO_REDIRECT_URI: {
7362
7613
  ...envString({
7363
- description: "Kakao OAuth callback URL. Defaults to {NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}/_auth/oauth/kakao/callback.",
7614
+ description: "Kakao OAuth callback URL. Defaults to {NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}/_auth/oauth/kakao/callback. The override must stay on the web app origin at this exact path \u2014 the CSRF cookie for the callback is host-only and the app rewrites /_auth/:path* to the API, so a callback that lands anywhere else is refused for CSRF. Checked at boot: a value off the web app origin or off the callback path refuses to start. The one case for an override elsewhere is the direct POST /_auth/oauth/start flow on a split deployment (no Next.js interceptor, so its CSRF cookie is on the API host), which also needs SPFN_AUTH_OAUTH_CALLBACK_ORIGIN_CHECK=off.",
7364
7615
  required: false,
7365
7616
  examples: ["https://app.example.com/_auth/oauth/kakao/callback"]
7366
7617
  })
@@ -7385,7 +7636,7 @@ var init_schema5 = __esm({
7385
7636
  },
7386
7637
  SPFN_AUTH_NAVER_REDIRECT_URI: {
7387
7638
  ...envString({
7388
- description: "Naver OAuth callback URL. Defaults to {NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}/_auth/oauth/naver/callback.",
7639
+ description: "Naver OAuth callback URL. Defaults to {NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}/_auth/oauth/naver/callback. The override must stay on the web app origin at this exact path \u2014 the CSRF cookie for the callback is host-only and the app rewrites /_auth/:path* to the API, so a callback that lands anywhere else is refused for CSRF. Checked at boot: a value off the web app origin or off the callback path refuses to start. The one case for an override elsewhere is the direct POST /_auth/oauth/start flow on a split deployment (no Next.js interceptor, so its CSRF cookie is on the API host), which also needs SPFN_AUTH_OAUTH_CALLBACK_ORIGIN_CHECK=off.",
7389
7640
  required: false,
7390
7641
  examples: ["https://app.example.com/_auth/oauth/naver/callback"]
7391
7642
  })
@@ -7417,11 +7668,18 @@ var init_schema5 = __esm({
7417
7668
  },
7418
7669
  SPFN_AUTH_GITHUB_REDIRECT_URI: {
7419
7670
  ...envString({
7420
- description: "GitHub OAuth callback URL. Defaults to {NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}/_auth/oauth/github/callback.",
7671
+ description: "GitHub OAuth callback URL. Defaults to {NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}/_auth/oauth/github/callback. The override must stay on the web app origin at this exact path \u2014 the CSRF cookie for the callback is host-only and the app rewrites /_auth/:path* to the API, so a callback that lands anywhere else is refused for CSRF. Checked at boot: a value off the web app origin or off the callback path refuses to start. The one case for an override elsewhere is the direct POST /_auth/oauth/start flow on a split deployment (no Next.js interceptor, so its CSRF cookie is on the API host), which also needs SPFN_AUTH_OAUTH_CALLBACK_ORIGIN_CHECK=off.",
7421
7672
  required: false,
7422
7673
  examples: ["https://app.example.com/_auth/oauth/github/callback"]
7423
7674
  })
7424
7675
  },
7676
+ SPFN_AUTH_OAUTH_CALLBACK_ORIGIN_CHECK: {
7677
+ ...envString({
7678
+ description: 'Boot-time check of the four SPFN_AUTH_<PROVIDER>_REDIRECT_URI overrides: each one that is set must sit on the web app origin ({NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}) at /_auth/oauth/<provider>/callback, because the callback CSRF cookie is host-only. "off" is the only value that disables the check \u2014 unset, "on" and anything else all run it.',
7679
+ required: false,
7680
+ examples: ["off"]
7681
+ })
7682
+ },
7425
7683
  // ============================================================================
7426
7684
  // Native Social Login (mobile/web id_token verification)
7427
7685
  //
@@ -7650,15 +7908,15 @@ var init_token_cipher = __esm({
7650
7908
  });
7651
7909
 
7652
7910
  // src/server/repositories/social-accounts.repository.ts
7653
- import { eq as eq11, and as and8 } from "drizzle-orm";
7654
- import { BaseRepository as BaseRepository11 } from "@spfn/core/db";
7911
+ import { eq as eq12, and as and9 } from "drizzle-orm";
7912
+ import { BaseRepository as BaseRepository12 } from "@spfn/core/db";
7655
7913
  var SocialAccountsRepository, socialAccountsRepository;
7656
7914
  var init_social_accounts_repository = __esm({
7657
7915
  "src/server/repositories/social-accounts.repository.ts"() {
7658
7916
  "use strict";
7659
7917
  init_entities();
7660
7918
  init_token_cipher();
7661
- SocialAccountsRepository = class extends BaseRepository11 {
7919
+ SocialAccountsRepository = class extends BaseRepository12 {
7662
7920
  /**
7663
7921
  * 저장 row 의 토큰을 평문으로 복호화해 반환한다.
7664
7922
  *
@@ -7686,10 +7944,10 @@ var init_social_accounts_repository = __esm({
7686
7944
  if (refresh?.needsRotation) {
7687
7945
  heal.refreshToken = await encryptToken(refresh.value, context("refresh"));
7688
7946
  }
7689
- await this.db.update(userSocialAccounts).set(heal).where(and8(
7690
- eq11(userSocialAccounts.id, account.id),
7691
- access?.needsRotation && account.accessToken !== null ? eq11(userSocialAccounts.accessToken, account.accessToken) : void 0,
7692
- refresh?.needsRotation && account.refreshToken !== null ? eq11(userSocialAccounts.refreshToken, account.refreshToken) : void 0
7947
+ await this.db.update(userSocialAccounts).set(heal).where(and9(
7948
+ eq12(userSocialAccounts.id, account.id),
7949
+ access?.needsRotation && account.accessToken !== null ? eq12(userSocialAccounts.accessToken, account.accessToken) : void 0,
7950
+ refresh?.needsRotation && account.refreshToken !== null ? eq12(userSocialAccounts.refreshToken, account.refreshToken) : void 0
7693
7951
  ));
7694
7952
  } catch {
7695
7953
  }
@@ -7706,9 +7964,9 @@ var init_social_accounts_repository = __esm({
7706
7964
  */
7707
7965
  async findByProviderAndProviderId(provider, providerUserId) {
7708
7966
  const result = await this.readDb.select().from(userSocialAccounts).where(
7709
- and8(
7710
- eq11(userSocialAccounts.provider, provider),
7711
- eq11(userSocialAccounts.providerUserId, providerUserId)
7967
+ and9(
7968
+ eq12(userSocialAccounts.provider, provider),
7969
+ eq12(userSocialAccounts.providerUserId, providerUserId)
7712
7970
  )
7713
7971
  ).limit(1);
7714
7972
  return this.decryptAccount(result[0] ?? null);
@@ -7718,7 +7976,7 @@ var init_social_accounts_repository = __esm({
7718
7976
  * Read replica 사용
7719
7977
  */
7720
7978
  async findByUserId(userId) {
7721
- const result = await this.readDb.select().from(userSocialAccounts).where(eq11(userSocialAccounts.userId, userId));
7979
+ const result = await this.readDb.select().from(userSocialAccounts).where(eq12(userSocialAccounts.userId, userId));
7722
7980
  return Promise.all(result.map((account) => this.decryptAccount(account)));
7723
7981
  }
7724
7982
  /**
@@ -7727,9 +7985,9 @@ var init_social_accounts_repository = __esm({
7727
7985
  */
7728
7986
  async findByUserIdAndProvider(userId, provider) {
7729
7987
  const result = await this.readDb.select().from(userSocialAccounts).where(
7730
- and8(
7731
- eq11(userSocialAccounts.userId, userId),
7732
- eq11(userSocialAccounts.provider, provider)
7988
+ and9(
7989
+ eq12(userSocialAccounts.userId, userId),
7990
+ eq12(userSocialAccounts.provider, provider)
7733
7991
  )
7734
7992
  ).limit(1);
7735
7993
  return this.decryptAccount(result[0] ?? null);
@@ -7755,11 +8013,11 @@ var init_social_accounts_repository = __esm({
7755
8013
  * 토큰 정보 업데이트
7756
8014
  * Write primary 사용
7757
8015
  */
7758
- async updateTokens(id14, data) {
8016
+ async updateTokens(id15, data) {
7759
8017
  const accounts = await this.db.select({
7760
8018
  provider: userSocialAccounts.provider,
7761
8019
  providerUserId: userSocialAccounts.providerUserId
7762
- }).from(userSocialAccounts).where(eq11(userSocialAccounts.id, id14)).limit(1);
8020
+ }).from(userSocialAccounts).where(eq12(userSocialAccounts.id, id15)).limit(1);
7763
8021
  const account = accounts[0];
7764
8022
  if (!account) {
7765
8023
  return null;
@@ -7773,15 +8031,15 @@ var init_social_accounts_repository = __esm({
7773
8031
  ...data,
7774
8032
  accessToken: data.accessToken ? await encryptToken(data.accessToken, context("access")) : data.accessToken,
7775
8033
  refreshToken: data.refreshToken ? await encryptToken(data.refreshToken, context("refresh")) : data.refreshToken
7776
- }).where(eq11(userSocialAccounts.id, id14)).returning();
8034
+ }).where(eq12(userSocialAccounts.id, id15)).returning();
7777
8035
  return this.decryptAccount(result[0] ?? null);
7778
8036
  }
7779
8037
  /**
7780
8038
  * 소셜 계정 삭제
7781
8039
  * Write primary 사용
7782
8040
  */
7783
- async deleteById(id14) {
7784
- const result = await this.db.delete(userSocialAccounts).where(eq11(userSocialAccounts.id, id14)).returning();
8041
+ async deleteById(id15) {
8042
+ const result = await this.db.delete(userSocialAccounts).where(eq12(userSocialAccounts.id, id15)).returning();
7785
8043
  return result[0] ?? null;
7786
8044
  }
7787
8045
  /**
@@ -7790,9 +8048,9 @@ var init_social_accounts_repository = __esm({
7790
8048
  */
7791
8049
  async deleteByUserIdAndProvider(userId, provider) {
7792
8050
  const result = await this.db.delete(userSocialAccounts).where(
7793
- and8(
7794
- eq11(userSocialAccounts.userId, userId),
7795
- eq11(userSocialAccounts.provider, provider)
8051
+ and9(
8052
+ eq12(userSocialAccounts.userId, userId),
8053
+ eq12(userSocialAccounts.provider, provider)
7796
8054
  )
7797
8055
  ).returning();
7798
8056
  return result[0] ?? null;
@@ -7805,7 +8063,7 @@ var init_social_accounts_repository = __esm({
7805
8063
  * Write primary 사용
7806
8064
  */
7807
8065
  async deleteAllByUserId(userId) {
7808
- const result = await this.db.delete(userSocialAccounts).where(eq11(userSocialAccounts.userId, userId)).returning();
8066
+ const result = await this.db.delete(userSocialAccounts).where(eq12(userSocialAccounts.userId, userId)).returning();
7809
8067
  return result.length;
7810
8068
  }
7811
8069
  };
@@ -7814,19 +8072,19 @@ var init_social_accounts_repository = __esm({
7814
8072
  });
7815
8073
 
7816
8074
  // src/server/repositories/auth-metadata.repository.ts
7817
- import { BaseRepository as BaseRepository12 } from "@spfn/core/db";
7818
- import { eq as eq12 } from "drizzle-orm";
8075
+ import { BaseRepository as BaseRepository13 } from "@spfn/core/db";
8076
+ import { eq as eq13 } from "drizzle-orm";
7819
8077
  var AuthMetadataRepository, authMetadataRepository;
7820
8078
  var init_auth_metadata_repository = __esm({
7821
8079
  "src/server/repositories/auth-metadata.repository.ts"() {
7822
8080
  "use strict";
7823
8081
  init_auth_metadata();
7824
- AuthMetadataRepository = class extends BaseRepository12 {
8082
+ AuthMetadataRepository = class extends BaseRepository13 {
7825
8083
  /**
7826
8084
  * 키로 값 조회
7827
8085
  */
7828
8086
  async get(key) {
7829
- const result = await this.readDb.select().from(authMetadata).where(eq12(authMetadata.key, key)).limit(1);
8087
+ const result = await this.readDb.select().from(authMetadata).where(eq13(authMetadata.key, key)).limit(1);
7830
8088
  return result[0]?.value ?? null;
7831
8089
  }
7832
8090
  /**
@@ -7849,20 +8107,20 @@ var init_auth_metadata_repository = __esm({
7849
8107
  });
7850
8108
 
7851
8109
  // src/server/repositories/account-deletion-requests.repository.ts
7852
- import { eq as eq13, and as and9, lte } from "drizzle-orm";
7853
- import { BaseRepository as BaseRepository13 } from "@spfn/core/db";
8110
+ import { eq as eq14, and as and10, lte } from "drizzle-orm";
8111
+ import { BaseRepository as BaseRepository14 } from "@spfn/core/db";
7854
8112
  var AccountDeletionRequestsRepository, accountDeletionRequestsRepository;
7855
8113
  var init_account_deletion_requests_repository = __esm({
7856
8114
  "src/server/repositories/account-deletion-requests.repository.ts"() {
7857
8115
  "use strict";
7858
8116
  init_account_deletion_requests();
7859
- AccountDeletionRequestsRepository = class extends BaseRepository13 {
8117
+ AccountDeletionRequestsRepository = class extends BaseRepository14 {
7860
8118
  /**
7861
8119
  * ID로 요청 조회
7862
8120
  * Read replica 사용
7863
8121
  */
7864
- async findById(id14) {
7865
- const result = await this.readDb.select().from(accountDeletionRequests).where(eq13(accountDeletionRequests.id, id14)).limit(1);
8122
+ async findById(id15) {
8123
+ const result = await this.readDb.select().from(accountDeletionRequests).where(eq14(accountDeletionRequests.id, id15)).limit(1);
7866
8124
  return result[0] ?? null;
7867
8125
  }
7868
8126
  /**
@@ -7871,9 +8129,9 @@ var init_account_deletion_requests_repository = __esm({
7871
8129
  */
7872
8130
  async findPendingByUserId(userId) {
7873
8131
  const result = await this.readDb.select().from(accountDeletionRequests).where(
7874
- and9(
7875
- eq13(accountDeletionRequests.userId, userId),
7876
- eq13(accountDeletionRequests.status, "pending")
8132
+ and10(
8133
+ eq14(accountDeletionRequests.userId, userId),
8134
+ eq14(accountDeletionRequests.status, "pending")
7877
8135
  )
7878
8136
  ).limit(1);
7879
8137
  return result[0] ?? null;
@@ -7887,9 +8145,9 @@ var init_account_deletion_requests_repository = __esm({
7887
8145
  */
7888
8146
  async findPendingByUserIdOnPrimary(userId) {
7889
8147
  const result = await this.db.select().from(accountDeletionRequests).where(
7890
- and9(
7891
- eq13(accountDeletionRequests.userId, userId),
7892
- eq13(accountDeletionRequests.status, "pending")
8148
+ and10(
8149
+ eq14(accountDeletionRequests.userId, userId),
8150
+ eq14(accountDeletionRequests.status, "pending")
7893
8151
  )
7894
8152
  ).limit(1);
7895
8153
  return result[0] ?? null;
@@ -7900,8 +8158,8 @@ var init_account_deletion_requests_repository = __esm({
7900
8158
  */
7901
8159
  async findDueForPurge(now) {
7902
8160
  return this.readDb.select().from(accountDeletionRequests).where(
7903
- and9(
7904
- eq13(accountDeletionRequests.status, "pending"),
8161
+ and10(
8162
+ eq14(accountDeletionRequests.status, "pending"),
7905
8163
  lte(accountDeletionRequests.purgeScheduledAt, now)
7906
8164
  )
7907
8165
  );
@@ -7921,14 +8179,14 @@ var init_account_deletion_requests_repository = __esm({
7921
8179
  * cancelled) 시 null을 반환하니 호출자가 그 결과를 확인해야 한다.
7922
8180
  * Write primary 사용
7923
8181
  */
7924
- async markCancelled(id14) {
8182
+ async markCancelled(id15) {
7925
8183
  const result = await this.db.update(accountDeletionRequests).set({
7926
8184
  status: "cancelled",
7927
8185
  cancelledAt: /* @__PURE__ */ new Date()
7928
8186
  }).where(
7929
- and9(
7930
- eq13(accountDeletionRequests.id, id14),
7931
- eq13(accountDeletionRequests.status, "pending")
8187
+ and10(
8188
+ eq14(accountDeletionRequests.id, id15),
8189
+ eq14(accountDeletionRequests.status, "pending")
7932
8190
  )
7933
8191
  ).returning();
7934
8192
  return result[0] ?? null;
@@ -7943,15 +8201,15 @@ var init_account_deletion_requests_repository = __esm({
7943
8201
  * destructive DML을 실행하기 **전에** 반드시 이 결과를 확인해야 한다.
7944
8202
  * Write primary 사용
7945
8203
  */
7946
- async markCompleted(id14, purgeStrategy) {
8204
+ async markCompleted(id15, purgeStrategy) {
7947
8205
  const result = await this.db.update(accountDeletionRequests).set({
7948
8206
  status: "completed",
7949
8207
  completedAt: /* @__PURE__ */ new Date(),
7950
8208
  purgeStrategy
7951
8209
  }).where(
7952
- and9(
7953
- eq13(accountDeletionRequests.id, id14),
7954
- eq13(accountDeletionRequests.status, "pending")
8210
+ and10(
8211
+ eq14(accountDeletionRequests.id, id15),
8212
+ eq14(accountDeletionRequests.status, "pending")
7955
8213
  )
7956
8214
  ).returning();
7957
8215
  return result[0] ?? null;
@@ -7962,14 +8220,14 @@ var init_account_deletion_requests_repository = __esm({
7962
8220
  });
7963
8221
 
7964
8222
  // src/server/repositories/ops-tokens.repository.ts
7965
- import { and as and10, desc as desc3, eq as eq14, isNull as isNull5 } from "drizzle-orm";
7966
- import { BaseRepository as BaseRepository14 } from "@spfn/core/db";
8223
+ import { and as and11, desc as desc3, eq as eq15, isNull as isNull5 } from "drizzle-orm";
8224
+ import { BaseRepository as BaseRepository15 } from "@spfn/core/db";
7967
8225
  var OpsTokensRepository, opsTokensRepository;
7968
8226
  var init_ops_tokens_repository = __esm({
7969
8227
  "src/server/repositories/ops-tokens.repository.ts"() {
7970
8228
  "use strict";
7971
8229
  init_ops_tokens();
7972
- OpsTokensRepository = class extends BaseRepository14 {
8230
+ OpsTokensRepository = class extends BaseRepository15 {
7973
8231
  /**
7974
8232
  * Lookup by the secret's hash — the verification path.
7975
8233
  *
@@ -7979,7 +8237,7 @@ var init_ops_tokens_repository = __esm({
7979
8237
  * and revocation is documented as taking effect immediately.
7980
8238
  */
7981
8239
  async findByTokenHash(tokenHash) {
7982
- const result = await this.db.select().from(opsTokens).where(eq14(opsTokens.tokenHash, tokenHash)).limit(1);
8240
+ const result = await this.db.select().from(opsTokens).where(eq15(opsTokens.tokenHash, tokenHash)).limit(1);
7983
8241
  return result[0] ?? null;
7984
8242
  }
7985
8243
  async create(data) {
@@ -7994,13 +8252,13 @@ var init_ops_tokens_repository = __esm({
7994
8252
  * token is already revoked — the first revocation's timestamp is never
7995
8253
  * overwritten.
7996
8254
  */
7997
- async revokeById(id14) {
7998
- const result = await this.db.update(opsTokens).set({ revokedAt: /* @__PURE__ */ new Date() }).where(and10(eq14(opsTokens.id, id14), isNull5(opsTokens.revokedAt))).returning();
8255
+ async revokeById(id15) {
8256
+ const result = await this.db.update(opsTokens).set({ revokedAt: /* @__PURE__ */ new Date() }).where(and11(eq15(opsTokens.id, id15), isNull5(opsTokens.revokedAt))).returning();
7999
8257
  return result[0] ?? null;
8000
8258
  }
8001
8259
  /** Fire-and-forget from the verification path. */
8002
- async updateLastUsedById(id14) {
8003
- await this.db.update(opsTokens).set({ lastUsedAt: /* @__PURE__ */ new Date() }).where(eq14(opsTokens.id, id14));
8260
+ async updateLastUsedById(id15) {
8261
+ await this.db.update(opsTokens).set({ lastUsedAt: /* @__PURE__ */ new Date() }).where(eq15(opsTokens.id, id15));
8004
8262
  }
8005
8263
  };
8006
8264
  opsTokensRepository = new OpsTokensRepository();
@@ -8015,6 +8273,7 @@ var init_repositories = __esm({
8015
8273
  init_keys_repository();
8016
8274
  init_verification_codes_repository();
8017
8275
  init_signup_link_tokens_repository();
8276
+ init_device_authorizations_repository();
8018
8277
  init_roles_repository();
8019
8278
  init_permissions_repository();
8020
8279
  init_role_permissions_repository();
@@ -8116,7 +8375,7 @@ async function removePermissionFromRole(roleId, permissionId) {
8116
8375
  }
8117
8376
  async function setRolePermissions(roleId, permissionIds) {
8118
8377
  const roleIdNum = Number(roleId);
8119
- const permissionIdNums = permissionIds.map((id14) => Number(id14));
8378
+ const permissionIdNums = permissionIds.map((id15) => Number(id15));
8120
8379
  await rolePermissionsRepository.setPermissionsForRole(roleIdNum, permissionIdNums);
8121
8380
  }
8122
8381
  async function getAllRoles(includeInactive = false) {
@@ -8136,7 +8395,7 @@ async function getRolePermissions(roleId) {
8136
8395
  }
8137
8396
  const permissionIds = mappings.map((m) => m.permissionId);
8138
8397
  const perms = await Promise.all(
8139
- permissionIds.map((id14) => permissionsRepository.findById(id14))
8398
+ permissionIds.map((id15) => permissionsRepository.findById(id15))
8140
8399
  );
8141
8400
  return perms.filter((p) => p !== null).map((p) => p.name);
8142
8401
  }
@@ -8222,6 +8481,7 @@ function validatePasswordStrength(password) {
8222
8481
  import jwt from "jsonwebtoken";
8223
8482
  import crypto2 from "crypto";
8224
8483
  import { env as env2 } from "@spfn/auth/config";
8484
+ import { KeyAlgorithmMismatchError } from "@spfn/auth/errors";
8225
8485
  var PUBLIC_KEY_CACHE_MAX = 1e3;
8226
8486
  var publicKeyCache = /* @__PURE__ */ new Map();
8227
8487
  function getPublicKeyObject(publicKeyB64) {
@@ -8302,6 +8562,39 @@ function verifyKeyFingerprint(publicKeyB64, expectedFingerprint) {
8302
8562
  return false;
8303
8563
  }
8304
8564
  }
8565
+ function readPublicKey(publicKeyB64) {
8566
+ try {
8567
+ return getPublicKeyObject(publicKeyB64);
8568
+ } catch {
8569
+ return null;
8570
+ }
8571
+ }
8572
+ function describeKeyType(key) {
8573
+ if (!key) {
8574
+ return "not a readable SPKI public key";
8575
+ }
8576
+ if (key.asymmetricKeyType === "ec") {
8577
+ return `a ${key.asymmetricKeyDetails?.namedCurve ?? "unknown-curve"} EC key`;
8578
+ }
8579
+ return `a ${key.asymmetricKeyType ?? "unrecognised"} key`;
8580
+ }
8581
+ function keyMatchesAlgorithm(key, algorithm) {
8582
+ if (!key) {
8583
+ return false;
8584
+ }
8585
+ if (algorithm === "ES256") {
8586
+ return key.asymmetricKeyType === "ec" && key.asymmetricKeyDetails?.namedCurve === "prime256v1";
8587
+ }
8588
+ return key.asymmetricKeyType === "rsa";
8589
+ }
8590
+ function assertKeyMatchesAlgorithm(publicKeyB64, algorithm) {
8591
+ const key = readPublicKey(publicKeyB64);
8592
+ if (!keyMatchesAlgorithm(key, algorithm)) {
8593
+ throw new KeyAlgorithmMismatchError({
8594
+ message: `Public key is ${describeKeyType(key)} but the declared algorithm is ${algorithm}`
8595
+ });
8596
+ }
8597
+ }
8305
8598
 
8306
8599
  // src/server/helpers/context.ts
8307
8600
  function getAuth(c) {
@@ -8353,6 +8646,27 @@ import {
8353
8646
  // src/server/lib/config.ts
8354
8647
  init_email();
8355
8648
  import { env as env4 } from "@spfn/auth/config";
8649
+
8650
+ // src/server/logger.ts
8651
+ import { logger as rootLogger } from "@spfn/core/logger";
8652
+ var authLogger = {
8653
+ plugin: rootLogger.child("@spfn/auth:plugin"),
8654
+ middleware: rootLogger.child("@spfn/auth:middleware"),
8655
+ interceptor: {
8656
+ general: rootLogger.child("@spfn/auth:interceptor:general"),
8657
+ login: rootLogger.child("@spfn/auth:interceptor:login"),
8658
+ keyRotation: rootLogger.child("@spfn/auth:interceptor:key-rotation"),
8659
+ oauth: rootLogger.child("@spfn/auth:interceptor:oauth"),
8660
+ csrf: rootLogger.child("@spfn/auth:interceptor:csrf")
8661
+ },
8662
+ session: rootLogger.child("@spfn/auth:session"),
8663
+ service: rootLogger.child("@spfn/auth:service"),
8664
+ setup: rootLogger.child("@spfn/auth:setup"),
8665
+ email: rootLogger.child("@spfn/auth:email"),
8666
+ sms: rootLogger.child("@spfn/auth:sms")
8667
+ };
8668
+
8669
+ // src/server/lib/config.ts
8356
8670
  function getCookieSuffix() {
8357
8671
  const port = process.env.SPFN_PORT;
8358
8672
  return port ? `_${port}` : "";
@@ -8377,6 +8691,10 @@ var COOKIE_NAMES = {
8377
8691
  /** Password-setup session for verified-email signup — temporary, single-purpose */
8378
8692
  get SIGNUP_SETUP() {
8379
8693
  return `spfn_signup_setup${getCookieSuffix()}`;
8694
+ },
8695
+ /** CSRF token — the only cookie here the browser can read */
8696
+ get CSRF() {
8697
+ return `spfn_csrf${getCookieSuffix()}`;
8380
8698
  }
8381
8699
  };
8382
8700
  function matchOAuthCsrfCookies(cookies) {
@@ -8409,10 +8727,10 @@ var globalConfig = {
8409
8727
  sessionTtl: "7d"
8410
8728
  // Default: 7 days
8411
8729
  };
8412
- function configureAuth(config2) {
8730
+ function configureAuth(config3) {
8413
8731
  globalConfig = {
8414
8732
  ...globalConfig,
8415
- ...config2
8733
+ ...config3
8416
8734
  };
8417
8735
  }
8418
8736
  function getAuthConfig() {
@@ -8437,6 +8755,28 @@ function getSessionTtl(override) {
8437
8755
  }
8438
8756
  return 7 * 24 * 60 * 60;
8439
8757
  }
8758
+ var CSRF_MODES = ["off", "warn", "enforce"];
8759
+ var unrecognizedCsrfModeReported = false;
8760
+ function getCsrfMode() {
8761
+ const configured2 = globalConfig.csrf?.mode ?? env4.SPFN_AUTH_CSRF;
8762
+ if (!configured2) {
8763
+ return "warn";
8764
+ }
8765
+ const normalized = String(configured2).trim().toLowerCase();
8766
+ if (!CSRF_MODES.includes(normalized)) {
8767
+ if (!unrecognizedCsrfModeReported) {
8768
+ unrecognizedCsrfModeReported = true;
8769
+ authLogger.interceptor.csrf.error(
8770
+ `Unrecognized CSRF mode "${configured2}" \u2014 expected off | warn | enforce. Enforcing.`
8771
+ );
8772
+ }
8773
+ return "enforce";
8774
+ }
8775
+ return normalized;
8776
+ }
8777
+ function getCsrfExemptPaths() {
8778
+ return globalConfig.csrf?.exemptPaths ?? [];
8779
+ }
8440
8780
 
8441
8781
  // src/server/services/verification.service.ts
8442
8782
  import crypto4 from "crypto";
@@ -8444,26 +8784,6 @@ import { env as env5 } from "@spfn/auth/config";
8444
8784
  import { InvalidVerificationCodeError } from "@spfn/auth/errors";
8445
8785
  import jwt2 from "jsonwebtoken";
8446
8786
  import { sendEmail, sendSMS } from "@spfn/notification/server";
8447
-
8448
- // src/server/logger.ts
8449
- import { logger as rootLogger } from "@spfn/core/logger";
8450
- var authLogger = {
8451
- plugin: rootLogger.child("@spfn/auth:plugin"),
8452
- middleware: rootLogger.child("@spfn/auth:middleware"),
8453
- interceptor: {
8454
- general: rootLogger.child("@spfn/auth:interceptor:general"),
8455
- login: rootLogger.child("@spfn/auth:interceptor:login"),
8456
- keyRotation: rootLogger.child("@spfn/auth:interceptor:key-rotation"),
8457
- oauth: rootLogger.child("@spfn/auth:interceptor:oauth")
8458
- },
8459
- session: rootLogger.child("@spfn/auth:session"),
8460
- service: rootLogger.child("@spfn/auth:service"),
8461
- setup: rootLogger.child("@spfn/auth:setup"),
8462
- email: rootLogger.child("@spfn/auth:email"),
8463
- sms: rootLogger.child("@spfn/auth:sms")
8464
- };
8465
-
8466
- // src/server/services/verification.service.ts
8467
8787
  init_email();
8468
8788
  init_repositories();
8469
8789
  var ACCOUNT_EXISTS_NOTICE_DEDUPE_MINUTES = 60;
@@ -8646,6 +8966,7 @@ var KEY_TTL_DAYS = 90;
8646
8966
  init_repositories();
8647
8967
  import { InvalidKeyFingerprintError, KeyIdAlreadyRegisteredError } from "@spfn/auth/errors";
8648
8968
  var KEY_FINGERPRINT_PREFIX_LENGTH = 8;
8969
+ var DEFAULT_KEY_ALGORITHM = "ES256";
8649
8970
  function getKeyExpiryDate() {
8650
8971
  const expiresAt = /* @__PURE__ */ new Date();
8651
8972
  expiresAt.setDate(expiresAt.getDate() + KEY_TTL_DAYS);
@@ -8655,7 +8976,7 @@ function isExpired(expiresAt) {
8655
8976
  return expiresAt !== null && /* @__PURE__ */ new Date() > expiresAt;
8656
8977
  }
8657
8978
  async function registerPublicKeyService(params) {
8658
- const { userId, keyId, publicKey, fingerprint, algorithm = "ES256", deviceName, platform } = params;
8979
+ const { userId, keyId, publicKey, fingerprint, algorithm = DEFAULT_KEY_ALGORITHM, deviceName, platform } = params;
8659
8980
  const existing = await keysRepository.findByKeyId(keyId);
8660
8981
  if (existing) {
8661
8982
  if (existing.userId === userId && existing.isActive) {
@@ -8670,6 +8991,7 @@ async function registerPublicKeyService(params) {
8670
8991
  if (!isValidFingerprint) {
8671
8992
  throw new InvalidKeyFingerprintError();
8672
8993
  }
8994
+ assertKeyMatchesAlgorithm(publicKey, algorithm);
8673
8995
  await keysRepository.create({
8674
8996
  userId,
8675
8997
  keyId,
@@ -8683,11 +9005,12 @@ async function registerPublicKeyService(params) {
8683
9005
  });
8684
9006
  }
8685
9007
  async function rotateKeyService(params) {
8686
- const { userId, oldKeyId, newKeyId, newPublicKey, fingerprint, algorithm = "ES256" } = params;
9008
+ const { userId, oldKeyId, newKeyId, newPublicKey, fingerprint, algorithm = DEFAULT_KEY_ALGORITHM } = params;
8687
9009
  const isValidFingerprint = verifyKeyFingerprint(newPublicKey, fingerprint);
8688
9010
  if (!isValidFingerprint) {
8689
9011
  throw new InvalidKeyFingerprintError();
8690
9012
  }
9013
+ assertKeyMatchesAlgorithm(newPublicKey, algorithm);
8691
9014
  const replaced = await keysRepository.findByKeyIdAndUserId(oldKeyId, userId);
8692
9015
  await keysRepository.revokeByKeyIdAndUserId(
8693
9016
  oldKeyId,
@@ -8734,6 +9057,7 @@ async function listKeysService(params) {
8734
9057
  async function revokeAllKeysService(params) {
8735
9058
  const { userId, currentKeyId, includeCurrent = false, reason } = params;
8736
9059
  const revoked = includeCurrent ? await keysRepository.revokeAllActiveByUserId(userId, reason) : await keysRepository.revokeAllActiveByUserIdExcept(userId, currentKeyId, reason);
9060
+ await deviceAuthorizationsRepository.denyAllActiveByUserId(userId);
8737
9061
  return { revokedCount: revoked.length, currentKeyRevoked: includeCurrent };
8738
9062
  }
8739
9063
 
@@ -8859,11 +9183,17 @@ var AuthProviderSchema = Type.Union([
8859
9183
  Type.Literal("phone"),
8860
9184
  ...SOCIAL_PROVIDERS.map((p) => Type.Literal(p))
8861
9185
  ]);
9186
+ var AuthLoginProviderSchema = Type.Union([
9187
+ Type.Literal("email"),
9188
+ Type.Literal("phone"),
9189
+ Type.Literal("device"),
9190
+ ...SOCIAL_PROVIDERS.map((p) => Type.Literal(p))
9191
+ ]);
8862
9192
  var authLoginEvent = defineEvent(
8863
9193
  "auth.login",
8864
9194
  Type.Object({
8865
9195
  userId: Type.String(),
8866
- provider: AuthProviderSchema,
9196
+ provider: AuthLoginProviderSchema,
8867
9197
  email: Type.Optional(Type.String()),
8868
9198
  phone: Type.Optional(Type.String())
8869
9199
  })
@@ -8971,8 +9301,8 @@ async function verifyReauthCredential(user, params) {
8971
9301
  throw new VerificationTokenTargetMismatchError();
8972
9302
  }
8973
9303
  }
8974
- async function sendDeletionEmail(to, subject, text14) {
8975
- const result = await sendEmail2({ to, subject, text: text14 });
9304
+ async function sendDeletionEmail(to, subject, text15) {
9305
+ const result = await sendEmail2({ to, subject, text: text15 });
8976
9306
  if (!result.success) {
8977
9307
  authLogger.email.error("Failed to send account deletion email", { to, subject, error: result.error });
8978
9308
  }
@@ -9024,12 +9354,12 @@ async function requestAccountDeletionService(userId, params) {
9024
9354
  if (requestedBy === "self") {
9025
9355
  await verifyReauthCredential(user, { password, verificationToken });
9026
9356
  }
9027
- const config2 = getDeletionConfig();
9357
+ const config3 = getDeletionConfig();
9028
9358
  const wantsImmediate = immediate === true;
9029
- if (wantsImmediate && requestedBy === "self" && !config2.allowSelfImmediate) {
9359
+ if (wantsImmediate && requestedBy === "self" && !config3.allowSelfImmediate) {
9030
9360
  throw new ImmediateDeletionNotAllowedError();
9031
9361
  }
9032
- const gracePeriodDays = wantsImmediate ? 0 : config2.gracePeriodDays;
9362
+ const gracePeriodDays = wantsImmediate ? 0 : config3.gracePeriodDays;
9033
9363
  const requestedAt = /* @__PURE__ */ new Date();
9034
9364
  const purgeScheduledAt = addDays(requestedAt, gracePeriodDays);
9035
9365
  await usersRepository.updateById(user.id, { status: "pending_deletion" });
@@ -9051,6 +9381,7 @@ async function requestAccountDeletionService(userId, params) {
9051
9381
  throw error;
9052
9382
  }
9053
9383
  await keysRepository.revokeAllActiveByUserId(user.id, "Account deletion requested");
9384
+ await deviceAuthorizationsRepository.denyAllActiveByUserId(user.id);
9054
9385
  onAfterCommit(() => authDeletionRequestedEvent.emit({
9055
9386
  userId: String(user.id),
9056
9387
  userPublicId: user.publicId,
@@ -9133,10 +9464,10 @@ async function purgePendingRequest(request) {
9133
9464
  if (!precheckUser || precheckUser.status !== "pending_deletion") {
9134
9465
  return { outcome: "skipped" };
9135
9466
  }
9136
- const config2 = getDeletionConfig();
9137
- if (config2.onBeforePurge) {
9467
+ const config3 = getDeletionConfig();
9468
+ if (config3.onBeforePurge) {
9138
9469
  try {
9139
- await config2.onBeforePurge({
9470
+ await config3.onBeforePurge({
9140
9471
  id: precheckUser.id,
9141
9472
  publicId: precheckUser.publicId,
9142
9473
  email: precheckUser.email,
@@ -9150,7 +9481,7 @@ async function purgePendingRequest(request) {
9150
9481
  return { outcome: "skipped" };
9151
9482
  }
9152
9483
  }
9153
- const purgeStrategy = config2.purgeStrategy;
9484
+ const purgeStrategy = config3.purgeStrategy;
9154
9485
  let purgedUser = null;
9155
9486
  await runInTransaction(async () => {
9156
9487
  const user = await usersRepository.findById(userId);
@@ -9371,6 +9702,7 @@ async function changePasswordService(params) {
9371
9702
  }
9372
9703
  const newPasswordHash = await hashPassword(newPassword);
9373
9704
  await usersRepository.updatePassword(userId, newPasswordHash, true);
9705
+ await deviceAuthorizationsRepository.denyAllActiveByUserId(userId);
9374
9706
  await keysRepository.revokeAllActiveByUserId(userId, "Revoked by password change");
9375
9707
  }
9376
9708
 
@@ -9513,23 +9845,252 @@ async function completeSignupService(params) {
9513
9845
  authLogger.service.warn("Signup setup session refused", { reason: "lost the claim race" });
9514
9846
  throw new InvalidSignupSetupSessionError();
9515
9847
  }
9516
- return await createVerifiedAccount({
9517
- email: claimed.email,
9518
- password: params.password,
9519
- publicKey: params.publicKey,
9520
- keyId: params.keyId,
9521
- fingerprint: params.fingerprint,
9522
- algorithm: params.algorithm,
9523
- deviceName: params.deviceName,
9524
- platform: params.platform,
9525
- metadata: params.metadata
9848
+ return await createVerifiedAccount({
9849
+ email: claimed.email,
9850
+ password: params.password,
9851
+ publicKey: params.publicKey,
9852
+ keyId: params.keyId,
9853
+ fingerprint: params.fingerprint,
9854
+ algorithm: params.algorithm,
9855
+ deviceName: params.deviceName,
9856
+ platform: params.platform,
9857
+ metadata: params.metadata
9858
+ });
9859
+ }
9860
+
9861
+ // src/server/services/device-auth.service.ts
9862
+ init_repositories();
9863
+ import {
9864
+ AccountDisabledError as AccountDisabledError2,
9865
+ AccountPendingDeletionError as AccountPendingDeletionError2,
9866
+ DeviceAuthNotFoundError,
9867
+ DeviceAuthExpiredError,
9868
+ DeviceAuthAlreadyHandledError,
9869
+ DeviceAuthDeniedError,
9870
+ InvalidKeyFingerprintError as InvalidKeyFingerprintError2
9871
+ } from "@spfn/auth/errors";
9872
+ import { onAfterCommit as onAfterCommit2 } from "@spfn/core/db";
9873
+
9874
+ // src/server/lib/device-auth-config.ts
9875
+ var DEFAULT_DEVICE_AUTH_TTL_MS = 10 * 60 * 1e3;
9876
+ var DEFAULT_DEVICE_AUTH_INTERVAL_MS = 5 * 1e3;
9877
+ var config2 = {
9878
+ ttlMs: DEFAULT_DEVICE_AUTH_TTL_MS,
9879
+ intervalMs: DEFAULT_DEVICE_AUTH_INTERVAL_MS
9880
+ };
9881
+ function configureDeviceAuth(options) {
9882
+ const ttlMs = options?.ttlMs ?? DEFAULT_DEVICE_AUTH_TTL_MS;
9883
+ const intervalMs = options?.intervalMs ?? DEFAULT_DEVICE_AUTH_INTERVAL_MS;
9884
+ assertWholeMillis("ttlMs", ttlMs);
9885
+ assertWholeMillis("intervalMs", intervalMs);
9886
+ config2 = { ttlMs, intervalMs };
9887
+ }
9888
+ function assertWholeMillis(name, value) {
9889
+ if (!Number.isInteger(value) || value <= 0) {
9890
+ throw new Error(
9891
+ `deviceAuth.${name} must be a positive whole number of milliseconds, received ${value}.`
9892
+ );
9893
+ }
9894
+ }
9895
+ function getDeviceAuthConfig() {
9896
+ return config2;
9897
+ }
9898
+
9899
+ // src/server/lib/device-code.ts
9900
+ import { createHash, randomBytes, randomInt } from "crypto";
9901
+ var USER_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
9902
+ var USER_CODE_LENGTH = 8;
9903
+ var USER_CODE_GROUP_SIZE = 4;
9904
+ var DEVICE_CODE_BYTES = 32;
9905
+ function generateUserCode() {
9906
+ let code = "";
9907
+ for (let position = 0; position < USER_CODE_LENGTH; position++) {
9908
+ code += USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)];
9909
+ }
9910
+ return code;
9911
+ }
9912
+ function formatUserCode(userCode) {
9913
+ return `${userCode.slice(0, USER_CODE_GROUP_SIZE)}-${userCode.slice(USER_CODE_GROUP_SIZE)}`;
9914
+ }
9915
+ function normalizeUserCode(input) {
9916
+ return input.replace(/[^A-Za-z0-9]/g, "").toUpperCase();
9917
+ }
9918
+ function generateDeviceCode() {
9919
+ return randomBytes(DEVICE_CODE_BYTES).toString("base64url");
9920
+ }
9921
+ function hashDeviceCode(deviceCode) {
9922
+ return createHash("sha256").update(deviceCode).digest("hex");
9923
+ }
9924
+
9925
+ // src/server/services/device-auth.service.ts
9926
+ var USER_CODE_ATTEMPTS = 3;
9927
+ function assertActionable(record) {
9928
+ if (!record || record.status === "consumed") {
9929
+ throw new DeviceAuthNotFoundError();
9930
+ }
9931
+ if (record.expiresAt.getTime() < Date.now()) {
9932
+ throw new DeviceAuthExpiredError();
9933
+ }
9934
+ return record;
9935
+ }
9936
+ function refuseMissedTransition(record, from, moved) {
9937
+ if (!record || record.status === "consumed") {
9938
+ throw new DeviceAuthNotFoundError();
9939
+ }
9940
+ if (record.status === from) {
9941
+ throw new DeviceAuthExpiredError();
9942
+ }
9943
+ throw moved();
9944
+ }
9945
+ function describeDevice(record) {
9946
+ return {
9947
+ deviceName: record.deviceName ?? void 0,
9948
+ platform: record.platform ?? void 0,
9949
+ fingerprintPrefix: record.fingerprint.slice(0, KEY_FINGERPRINT_PREFIX_LENGTH),
9950
+ requestedAtMillis: record.createdAt.getTime(),
9951
+ expiresAtMillis: record.expiresAt.getTime()
9952
+ };
9953
+ }
9954
+ async function startDeviceAuthService(params) {
9955
+ if (!verifyKeyFingerprint(params.publicKey, params.fingerprint)) {
9956
+ throw new InvalidKeyFingerprintError2();
9957
+ }
9958
+ assertKeyMatchesAlgorithm(params.publicKey, params.algorithm ?? DEFAULT_KEY_ALGORITHM);
9959
+ const { ttlMs, intervalMs } = getDeviceAuthConfig();
9960
+ const expiresAt = new Date(Date.now() + ttlMs);
9961
+ for (let attempt = 0; attempt < USER_CODE_ATTEMPTS; attempt++) {
9962
+ const deviceCode = generateDeviceCode();
9963
+ const userCode = generateUserCode();
9964
+ const record = await deviceAuthorizationsRepository.create({
9965
+ deviceCodeHash: hashDeviceCode(deviceCode),
9966
+ userCode,
9967
+ publicKey: params.publicKey,
9968
+ keyId: params.keyId,
9969
+ fingerprint: params.fingerprint,
9970
+ algorithm: params.algorithm,
9971
+ deviceName: params.deviceName,
9972
+ platform: params.platform,
9973
+ expiresAt
9974
+ });
9975
+ if (record) {
9976
+ return {
9977
+ deviceCode,
9978
+ userCode: formatUserCode(userCode),
9979
+ expiresAtMillis: expiresAt.getTime(),
9980
+ intervalMillis: intervalMs
9981
+ };
9982
+ }
9983
+ }
9984
+ throw new Error(
9985
+ `Could not allocate a unique device user code in ${USER_CODE_ATTEMPTS} attempts. Check the code generator and the user_code unique index.`
9986
+ );
9987
+ }
9988
+ async function getDeviceAuthInfoService(params) {
9989
+ const record = assertActionable(
9990
+ await deviceAuthorizationsRepository.findByUserCode(normalizeUserCode(params.userCode))
9991
+ );
9992
+ if (record.status !== "pending") {
9993
+ throw new DeviceAuthAlreadyHandledError();
9994
+ }
9995
+ return describeDevice(record);
9996
+ }
9997
+ async function approveDeviceAuthService(params) {
9998
+ const userCode = normalizeUserCode(params.userCode);
9999
+ const record = assertActionable(
10000
+ await deviceAuthorizationsRepository.findByUserCode(userCode)
10001
+ );
10002
+ const approved = await deviceAuthorizationsRepository.approve(record.id, params.userId);
10003
+ if (!approved) {
10004
+ refuseMissedTransition(
10005
+ await deviceAuthorizationsRepository.findByUserCode(userCode),
10006
+ "pending",
10007
+ () => new DeviceAuthAlreadyHandledError()
10008
+ );
10009
+ }
10010
+ return describeDevice(approved);
10011
+ }
10012
+ async function denyDeviceAuthService(params) {
10013
+ const userCode = normalizeUserCode(params.userCode);
10014
+ const record = assertActionable(
10015
+ await deviceAuthorizationsRepository.findByUserCode(userCode)
10016
+ );
10017
+ const denied = await deviceAuthorizationsRepository.deny(record.id);
10018
+ if (!denied) {
10019
+ refuseMissedTransition(
10020
+ await deviceAuthorizationsRepository.findByUserCode(userCode),
10021
+ "pending",
10022
+ () => new DeviceAuthAlreadyHandledError()
10023
+ );
10024
+ }
10025
+ }
10026
+ async function pollDeviceAuthService(params) {
10027
+ const deviceCodeHash = hashDeviceCode(params.deviceCode);
10028
+ const record = assertActionable(
10029
+ await deviceAuthorizationsRepository.findByDeviceCodeHash(deviceCodeHash)
10030
+ );
10031
+ if (record.status === "denied") {
10032
+ throw new DeviceAuthDeniedError();
10033
+ }
10034
+ if (record.status === "pending") {
10035
+ return { status: "pending", intervalMillis: getDeviceAuthConfig().intervalMs };
10036
+ }
10037
+ const consumed = await deviceAuthorizationsRepository.consumeApproved(deviceCodeHash);
10038
+ if (!consumed) {
10039
+ refuseMissedTransition(
10040
+ await deviceAuthorizationsRepository.findByDeviceCodeHash(deviceCodeHash),
10041
+ "approved",
10042
+ () => new DeviceAuthNotFoundError()
10043
+ );
10044
+ }
10045
+ return { status: "approved", ...await completeDeviceLogin(consumed) };
10046
+ }
10047
+ async function completeDeviceLogin(record) {
10048
+ if (record.userId === null) {
10049
+ throw new DeviceAuthNotFoundError();
10050
+ }
10051
+ const user = await usersRepository.findById(record.userId);
10052
+ if (!user) {
10053
+ throw new DeviceAuthNotFoundError();
10054
+ }
10055
+ if (user.status !== "active") {
10056
+ if (user.status === "pending_deletion") {
10057
+ const pending = await getPendingDeletionInfo(user.id);
10058
+ throw new AccountPendingDeletionError2({
10059
+ purgeScheduledAt: pending?.purgeScheduledAt.toISOString()
10060
+ });
10061
+ }
10062
+ throw new AccountDisabledError2({ status: user.status });
10063
+ }
10064
+ await registerPublicKeyService({
10065
+ userId: user.id,
10066
+ keyId: record.keyId,
10067
+ publicKey: record.publicKey,
10068
+ fingerprint: record.fingerprint,
10069
+ algorithm: record.algorithm,
10070
+ deviceName: record.deviceName ?? void 0,
10071
+ platform: record.platform ?? void 0
9526
10072
  });
10073
+ await updateLastLoginService(user.id);
10074
+ const result = {
10075
+ userId: String(user.id),
10076
+ publicId: user.publicId,
10077
+ email: user.email || void 0,
10078
+ phone: user.phone || void 0,
10079
+ passwordChangeRequired: user.passwordChangeRequired
10080
+ };
10081
+ onAfterCommit2(() => authLoginEvent.emit({
10082
+ userId: result.userId,
10083
+ provider: "device",
10084
+ email: result.email,
10085
+ phone: result.phone
10086
+ }));
10087
+ return result;
9527
10088
  }
9528
10089
 
9529
10090
  // src/server/services/rbac.service.ts
9530
10091
  init_repositories();
9531
10092
  init_rbac();
9532
- import { createHash } from "crypto";
10093
+ import { createHash as createHash2 } from "crypto";
9533
10094
  var RBAC_HASH_KEY = "rbac_config_hash";
9534
10095
  function computeConfigHash(allRoles, allPermissions, allMappings) {
9535
10096
  const payload = JSON.stringify({
@@ -9540,7 +10101,7 @@ function computeConfigHash(allRoles, allPermissions, allMappings) {
9540
10101
  return acc;
9541
10102
  }, {})
9542
10103
  });
9543
- return createHash("sha256").update(payload).digest("hex");
10104
+ return createHash2("sha256").update(payload).digest("hex");
9544
10105
  }
9545
10106
  function collectMappings(options) {
9546
10107
  const allMappings = { ...BUILTIN_ROLE_PERMISSIONS };
@@ -9598,51 +10159,51 @@ async function initializeAuth(options = {}) {
9598
10159
  authLogger.service.info("\u{1F512} Built-in roles: user, admin, superadmin");
9599
10160
  }
9600
10161
  async function syncRoles(configs, existingByName) {
9601
- for (const config2 of configs) {
9602
- const existing = existingByName.get(config2.name);
10162
+ for (const config3 of configs) {
10163
+ const existing = existingByName.get(config3.name);
9603
10164
  if (!existing) {
9604
10165
  await rolesRepository.create({
9605
- name: config2.name,
9606
- displayName: config2.displayName,
9607
- description: config2.description || null,
9608
- priority: config2.priority ?? 10,
9609
- isSystem: config2.isSystem ?? false,
9610
- isBuiltin: config2.isBuiltin ?? false,
10166
+ name: config3.name,
10167
+ displayName: config3.displayName,
10168
+ description: config3.description || null,
10169
+ priority: config3.priority ?? 10,
10170
+ isSystem: config3.isSystem ?? false,
10171
+ isBuiltin: config3.isBuiltin ?? false,
9611
10172
  isActive: true
9612
10173
  });
9613
- authLogger.service.info(` \u2705 Created role: ${config2.name}`);
10174
+ authLogger.service.info(` \u2705 Created role: ${config3.name}`);
9614
10175
  } else {
9615
10176
  const updateData = {
9616
- displayName: config2.displayName,
9617
- description: config2.description || null
10177
+ displayName: config3.displayName,
10178
+ description: config3.description || null
9618
10179
  };
9619
10180
  if (!existing.isBuiltin) {
9620
- updateData.priority = config2.priority ?? existing.priority;
10181
+ updateData.priority = config3.priority ?? existing.priority;
9621
10182
  }
9622
10183
  await rolesRepository.updateById(existing.id, updateData);
9623
10184
  }
9624
10185
  }
9625
10186
  }
9626
10187
  async function syncPermissions(configs, existingByName) {
9627
- for (const config2 of configs) {
9628
- const existing = existingByName.get(config2.name);
10188
+ for (const config3 of configs) {
10189
+ const existing = existingByName.get(config3.name);
9629
10190
  if (!existing) {
9630
10191
  await permissionsRepository.create({
9631
- name: config2.name,
9632
- displayName: config2.displayName,
9633
- description: config2.description || null,
9634
- category: config2.category || null,
9635
- isSystem: config2.isSystem ?? false,
9636
- isBuiltin: config2.isBuiltin ?? false,
10192
+ name: config3.name,
10193
+ displayName: config3.displayName,
10194
+ description: config3.description || null,
10195
+ category: config3.category || null,
10196
+ isSystem: config3.isSystem ?? false,
10197
+ isBuiltin: config3.isBuiltin ?? false,
9637
10198
  isActive: true,
9638
10199
  metadata: null
9639
10200
  });
9640
- authLogger.service.info(` \u2705 Created permission: ${config2.name}`);
10201
+ authLogger.service.info(` \u2705 Created permission: ${config3.name}`);
9641
10202
  } else {
9642
10203
  await permissionsRepository.updateById(existing.id, {
9643
- displayName: config2.displayName,
9644
- description: config2.description || null,
9645
- category: config2.category || null
10204
+ displayName: config3.displayName,
10205
+ description: config3.description || null,
10206
+ category: config3.category || null
9646
10207
  });
9647
10208
  }
9648
10209
  }
@@ -9703,7 +10264,7 @@ async function getUserPermissions(userId) {
9703
10264
  const permIds = rolePermMappings.map((rp) => rp.permissionId);
9704
10265
  if (permIds.length > 0) {
9705
10266
  const rolePerms = await Promise.all(
9706
- permIds.map((id14) => permissionsRepository.findById(id14))
10267
+ permIds.map((id15) => permissionsRepository.findById(id15))
9707
10268
  );
9708
10269
  for (const perm of rolePerms) {
9709
10270
  if (perm && perm.isActive) {
@@ -9860,6 +10421,7 @@ async function validateInvitation(token) {
9860
10421
  }
9861
10422
  async function acceptInvitation(params) {
9862
10423
  const { token, password, publicKey, keyId, fingerprint, algorithm } = params;
10424
+ assertKeyMatchesAlgorithm(publicKey, algorithm);
9863
10425
  const validation = await validateInvitation(token);
9864
10426
  if (!validation.valid || !validation.invitation) {
9865
10427
  throw new BadRequestError({ message: validation.error || "Invalid invitation" });
@@ -9916,20 +10478,20 @@ async function acceptInvitation(params) {
9916
10478
  async function listInvitations(params) {
9917
10479
  return await invitationsRepository.list(params);
9918
10480
  }
9919
- async function cancelInvitation(id14, cancelledBy, reason) {
9920
- const invitation = await invitationsRepository.findById(id14);
10481
+ async function cancelInvitation(id15, cancelledBy, reason) {
10482
+ const invitation = await invitationsRepository.findById(id15);
9921
10483
  if (!invitation) {
9922
10484
  throw new NotFoundError3({ message: "Invitation not found", resource: "Invitation" });
9923
10485
  }
9924
10486
  if (invitation.status !== "pending") {
9925
10487
  throw new ConflictError({ message: `Cannot cancel ${invitation.status} invitation` });
9926
10488
  }
9927
- await invitationsRepository.cancel(id14, cancelledBy, reason, invitation.metadata);
10489
+ await invitationsRepository.cancel(id15, cancelledBy, reason, invitation.metadata);
9928
10490
  console.log(`[Auth] \u26A0\uFE0F Invitation cancelled: ${invitation.email} (reason: ${reason || "none"})`);
9929
10491
  }
9930
- async function deleteInvitation(id14) {
9931
- await invitationsRepository.deleteById(id14);
9932
- console.log(`[Auth] \u{1F5D1}\uFE0F Invitation deleted: ${id14}`);
10492
+ async function deleteInvitation(id15) {
10493
+ await invitationsRepository.deleteById(id15);
10494
+ console.log(`[Auth] \u{1F5D1}\uFE0F Invitation deleted: ${id15}`);
9933
10495
  }
9934
10496
  async function expireOldInvitations() {
9935
10497
  const count = await invitationsRepository.updateExpiredInvitations();
@@ -9938,8 +10500,8 @@ async function expireOldInvitations() {
9938
10500
  }
9939
10501
  return count;
9940
10502
  }
9941
- async function resendInvitation(id14, expiresInDays = 7) {
9942
- const invitation = await invitationsRepository.findById(id14);
10503
+ async function resendInvitation(id15, expiresInDays = 7) {
10504
+ const invitation = await invitationsRepository.findById(id15);
9943
10505
  if (!invitation) {
9944
10506
  throw new NotFoundError3({ message: "Invitation not found", resource: "Invitation" });
9945
10507
  }
@@ -9947,7 +10509,7 @@ async function resendInvitation(id14, expiresInDays = 7) {
9947
10509
  throw new ConflictError({ message: `Cannot resend ${invitation.status} invitation` });
9948
10510
  }
9949
10511
  const newExpiresAt = calculateExpiresAt(expiresInDays);
9950
- const updated = await invitationsRepository.resend(id14, newExpiresAt);
10512
+ const updated = await invitationsRepository.resend(id15, newExpiresAt);
9951
10513
  if (!updated) {
9952
10514
  throw new Error("Failed to update invitation");
9953
10515
  }
@@ -9986,13 +10548,13 @@ async function getAuthSessionService(userId) {
9986
10548
  // src/server/lib/one-time-token.ts
9987
10549
  import { SSETokenManager } from "@spfn/core/event/sse";
9988
10550
  var manager = null;
9989
- function initOneTimeTokenManager(config2) {
10551
+ function initOneTimeTokenManager(config3) {
9990
10552
  if (manager) {
9991
10553
  manager.destroy();
9992
10554
  }
9993
10555
  manager = new SSETokenManager({
9994
- ttl: config2?.ttl,
9995
- store: config2?.store
10556
+ ttl: config3?.ttl,
10557
+ store: config3?.store
9996
10558
  });
9997
10559
  }
9998
10560
  function getOneTimeTokenManager() {
@@ -10104,8 +10666,8 @@ init_repositories();
10104
10666
  import { env as env12 } from "@spfn/auth/config";
10105
10667
  import { ValidationError as ValidationError8 } from "@spfn/core/errors";
10106
10668
  import {
10107
- AccountDisabledError as AccountDisabledError2,
10108
- AccountPendingDeletionError as AccountPendingDeletionError2,
10669
+ AccountDisabledError as AccountDisabledError3,
10670
+ AccountPendingDeletionError as AccountPendingDeletionError3,
10109
10671
  UnverifiedEmailLinkError
10110
10672
  } from "@spfn/auth/errors";
10111
10673
 
@@ -10140,10 +10702,10 @@ function getDefaultScopes() {
10140
10702
  }
10141
10703
  function getGoogleAuthUrl(state, scopes) {
10142
10704
  const resolvedScopes = scopes ?? getDefaultScopes();
10143
- const config2 = getGoogleOAuthConfig();
10705
+ const config3 = getGoogleOAuthConfig();
10144
10706
  const params = new URLSearchParams({
10145
- client_id: config2.clientId,
10146
- redirect_uri: config2.redirectUri,
10707
+ client_id: config3.clientId,
10708
+ redirect_uri: config3.redirectUri,
10147
10709
  response_type: "code",
10148
10710
  scope: resolvedScopes.join(" "),
10149
10711
  state,
@@ -10155,16 +10717,16 @@ function getGoogleAuthUrl(state, scopes) {
10155
10717
  return `${GOOGLE_AUTH_URL}?${params.toString()}`;
10156
10718
  }
10157
10719
  async function exchangeCodeForTokens(code) {
10158
- const config2 = getGoogleOAuthConfig();
10720
+ const config3 = getGoogleOAuthConfig();
10159
10721
  const response = await fetch(GOOGLE_TOKEN_URL, {
10160
10722
  method: "POST",
10161
10723
  headers: {
10162
10724
  "Content-Type": "application/x-www-form-urlencoded"
10163
10725
  },
10164
10726
  body: new URLSearchParams({
10165
- client_id: config2.clientId,
10166
- client_secret: config2.clientSecret,
10167
- redirect_uri: config2.redirectUri,
10727
+ client_id: config3.clientId,
10728
+ client_secret: config3.clientSecret,
10729
+ redirect_uri: config3.redirectUri,
10168
10730
  grant_type: "authorization_code",
10169
10731
  code
10170
10732
  })
@@ -10188,15 +10750,15 @@ async function getGoogleUserInfo(accessToken) {
10188
10750
  return response.json();
10189
10751
  }
10190
10752
  async function refreshAccessToken(refreshToken) {
10191
- const config2 = getGoogleOAuthConfig();
10753
+ const config3 = getGoogleOAuthConfig();
10192
10754
  const response = await fetch(GOOGLE_TOKEN_URL, {
10193
10755
  method: "POST",
10194
10756
  headers: {
10195
10757
  "Content-Type": "application/x-www-form-urlencoded"
10196
10758
  },
10197
10759
  body: new URLSearchParams({
10198
- client_id: config2.clientId,
10199
- client_secret: config2.clientSecret,
10760
+ client_id: config3.clientId,
10761
+ client_secret: config3.clientSecret,
10200
10762
  refresh_token: refreshToken,
10201
10763
  grant_type: "refresh_token"
10202
10764
  })
@@ -10261,8 +10823,8 @@ var registry2 = /* @__PURE__ */ new Map();
10261
10823
  function registerOAuthProvider(provider) {
10262
10824
  registry2.set(provider.id, provider);
10263
10825
  }
10264
- function getOAuthProvider(id14) {
10265
- return registry2.get(id14);
10826
+ function getOAuthProvider(id15) {
10827
+ return registry2.get(id15);
10266
10828
  }
10267
10829
  function getRegisteredProviders() {
10268
10830
  return [...registry2.values()];
@@ -10383,7 +10945,7 @@ var googleProvider = {
10383
10945
  registerOAuthProvider(googleProvider);
10384
10946
 
10385
10947
  // src/server/lib/oauth/apple-provider.ts
10386
- import { createHash as createHash2 } from "crypto";
10948
+ import { createHash as createHash3 } from "crypto";
10387
10949
  import { env as env11 } from "@spfn/auth/config";
10388
10950
  import { ValidationError as ValidationError4 } from "@spfn/core/errors";
10389
10951
  import { NativeSignInUnsupportedError as NativeSignInUnsupportedError2 } from "@spfn/auth/errors";
@@ -10393,7 +10955,7 @@ function getAppleClientIds() {
10393
10955
  return (env11.SPFN_AUTH_APPLE_CLIENT_IDS || "").split(",").map((s) => s.trim()).filter(Boolean);
10394
10956
  }
10395
10957
  function hashNonce(rawNonce) {
10396
- return createHash2("sha256").update(rawNonce).digest("hex");
10958
+ return createHash3("sha256").update(rawNonce).digest("hex");
10397
10959
  }
10398
10960
  function unsupportedWebFlow() {
10399
10961
  throw new ValidationError4({
@@ -10516,21 +11078,21 @@ var githubProvider = {
10516
11078
  return !!(env3.SPFN_AUTH_GITHUB_CLIENT_ID && env3.SPFN_AUTH_GITHUB_CLIENT_SECRET);
10517
11079
  },
10518
11080
  getAuthUrl(state, scopes) {
10519
- const config2 = getGithubConfig();
11081
+ const config3 = getGithubConfig();
10520
11082
  const params = new URLSearchParams({
10521
- client_id: config2.clientId,
10522
- redirect_uri: config2.redirectUri,
11083
+ client_id: config3.clientId,
11084
+ redirect_uri: config3.redirectUri,
10523
11085
  state,
10524
11086
  scope: (scopes ?? getGithubScopes()).join(" ")
10525
11087
  });
10526
11088
  return `${GITHUB_AUTH_URL}?${params.toString()}`;
10527
11089
  },
10528
11090
  async exchangeCodeForTokens(code) {
10529
- const config2 = getGithubConfig();
11091
+ const config3 = getGithubConfig();
10530
11092
  return requestGithubTokens(new URLSearchParams({
10531
- client_id: config2.clientId,
10532
- client_secret: config2.clientSecret,
10533
- redirect_uri: config2.redirectUri,
11093
+ client_id: config3.clientId,
11094
+ client_secret: config3.clientSecret,
11095
+ redirect_uri: config3.redirectUri,
10534
11096
  code
10535
11097
  }));
10536
11098
  },
@@ -10560,11 +11122,11 @@ var githubProvider = {
10560
11122
  };
10561
11123
  },
10562
11124
  async refreshTokens(refreshToken) {
10563
- const config2 = getGithubConfig();
11125
+ const config3 = getGithubConfig();
10564
11126
  return requestGithubTokens(new URLSearchParams({
10565
11127
  grant_type: "refresh_token",
10566
- client_id: config2.clientId,
10567
- client_secret: config2.clientSecret,
11128
+ client_id: config3.clientId,
11129
+ client_secret: config3.clientSecret,
10568
11130
  refresh_token: refreshToken
10569
11131
  }));
10570
11132
  }
@@ -10687,26 +11249,26 @@ var kakaoProvider = {
10687
11249
  return !!env3.SPFN_AUTH_KAKAO_CLIENT_ID;
10688
11250
  },
10689
11251
  getAuthUrl(state, scopes) {
10690
- const config2 = getKakaoConfig();
11252
+ const config3 = getKakaoConfig();
10691
11253
  const params = new URLSearchParams({
10692
11254
  response_type: "code",
10693
- client_id: config2.clientId,
10694
- redirect_uri: config2.redirectUri,
11255
+ client_id: config3.clientId,
11256
+ redirect_uri: config3.redirectUri,
10695
11257
  state,
10696
11258
  scope: (scopes ?? getKakaoScopes()).join(",")
10697
11259
  });
10698
11260
  return `${KAKAO_AUTH_URL}?${params.toString()}`;
10699
11261
  },
10700
11262
  async exchangeCodeForTokens(code) {
10701
- const config2 = getKakaoConfig();
11263
+ const config3 = getKakaoConfig();
10702
11264
  const params = new URLSearchParams({
10703
11265
  grant_type: "authorization_code",
10704
- client_id: config2.clientId,
10705
- redirect_uri: config2.redirectUri,
11266
+ client_id: config3.clientId,
11267
+ redirect_uri: config3.redirectUri,
10706
11268
  code
10707
11269
  });
10708
- if (config2.clientSecret) {
10709
- params.set("client_secret", config2.clientSecret);
11270
+ if (config3.clientSecret) {
11271
+ params.set("client_secret", config3.clientSecret);
10710
11272
  }
10711
11273
  return requestKakaoTokens(params);
10712
11274
  },
@@ -10748,14 +11310,14 @@ var kakaoProvider = {
10748
11310
  return options.accessToken ? withKakaoVerifiedEmail(identity, options.accessToken) : identity;
10749
11311
  },
10750
11312
  async refreshTokens(refreshToken) {
10751
- const config2 = getKakaoConfig();
11313
+ const config3 = getKakaoConfig();
10752
11314
  const params = new URLSearchParams({
10753
11315
  grant_type: "refresh_token",
10754
- client_id: config2.clientId,
11316
+ client_id: config3.clientId,
10755
11317
  refresh_token: refreshToken
10756
11318
  });
10757
- if (config2.clientSecret) {
10758
- params.set("client_secret", config2.clientSecret);
11319
+ if (config3.clientSecret) {
11320
+ params.set("client_secret", config3.clientSecret);
10759
11321
  }
10760
11322
  return requestKakaoTokens(params);
10761
11323
  },
@@ -10792,7 +11354,7 @@ registerOAuthProvider(kakaoProvider);
10792
11354
  init_config();
10793
11355
  import { ValidationError as ValidationError7 } from "@spfn/core/errors";
10794
11356
  import { NativeSignInUnsupportedError as NativeSignInUnsupportedError4 } from "@spfn/auth/errors";
10795
- import { createDecipheriv, createHash as createHash3, createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
11357
+ import { createDecipheriv, createHash as createHash4, createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
10796
11358
  var NAVER_AUTH_URL = "https://nid.naver.com/oauth2.0/authorize";
10797
11359
  var NAVER_TOKEN_URL = "https://nid.naver.com/oauth2.0/token";
10798
11360
  var NAVER_USERINFO_URL = "https://openapi.naver.com/v1/nid/me";
@@ -10842,7 +11404,7 @@ async function requestNaverTokens(params) {
10842
11404
  };
10843
11405
  }
10844
11406
  function deriveNaverUnlinkKey(clientSecret) {
10845
- return createHash3("md5").update(clientSecret).digest().subarray(0, 16);
11407
+ return createHash4("md5").update(clientSecret).digest().subarray(0, 16);
10846
11408
  }
10847
11409
  function decodeBase64Url(value) {
10848
11410
  return Buffer.from(value.replace(/-/g, "+").replace(/_/g, "/"), "base64");
@@ -10913,22 +11475,22 @@ var naverProvider = {
10913
11475
  return !!(env3.SPFN_AUTH_NAVER_CLIENT_ID && env3.SPFN_AUTH_NAVER_CLIENT_SECRET);
10914
11476
  },
10915
11477
  getAuthUrl(state) {
10916
- const config2 = getNaverConfig();
11478
+ const config3 = getNaverConfig();
10917
11479
  const params = new URLSearchParams({
10918
11480
  response_type: "code",
10919
- client_id: config2.clientId,
10920
- redirect_uri: config2.redirectUri,
11481
+ client_id: config3.clientId,
11482
+ redirect_uri: config3.redirectUri,
10921
11483
  state
10922
11484
  });
10923
11485
  return `${NAVER_AUTH_URL}?${params.toString()}`;
10924
11486
  },
10925
11487
  async exchangeCodeForTokens(code, options) {
10926
- const config2 = getNaverConfig();
11488
+ const config3 = getNaverConfig();
10927
11489
  return requestNaverTokens(new URLSearchParams({
10928
11490
  grant_type: "authorization_code",
10929
- client_id: config2.clientId,
10930
- client_secret: config2.clientSecret,
10931
- redirect_uri: config2.redirectUri,
11491
+ client_id: config3.clientId,
11492
+ client_secret: config3.clientSecret,
11493
+ redirect_uri: config3.redirectUri,
10932
11494
  code,
10933
11495
  state: options.state
10934
11496
  }));
@@ -10969,11 +11531,11 @@ var naverProvider = {
10969
11531
  return options.accessToken ? withNaverProfile(identity, options.accessToken) : identity;
10970
11532
  },
10971
11533
  async refreshTokens(refreshToken) {
10972
- const config2 = getNaverConfig();
11534
+ const config3 = getNaverConfig();
10973
11535
  return requestNaverTokens(new URLSearchParams({
10974
11536
  grant_type: "refresh_token",
10975
- client_id: config2.clientId,
10976
- client_secret: config2.clientSecret,
11537
+ client_id: config3.clientId,
11538
+ client_secret: config3.clientSecret,
10977
11539
  refresh_token: refreshToken
10978
11540
  }));
10979
11541
  },
@@ -11142,9 +11704,9 @@ async function assertActiveForOAuthSession(userId) {
11142
11704
  }
11143
11705
  if (user.status === "pending_deletion") {
11144
11706
  const pending = await getPendingDeletionInfo(user.id);
11145
- throw new AccountPendingDeletionError2({ purgeScheduledAt: pending?.purgeScheduledAt.toISOString() });
11707
+ throw new AccountPendingDeletionError3({ purgeScheduledAt: pending?.purgeScheduledAt.toISOString() });
11146
11708
  }
11147
- throw new AccountDisabledError2({ status: user.status });
11709
+ throw new AccountDisabledError3({ status: user.status });
11148
11710
  }
11149
11711
  async function backfillVerifiedEmail(userId, identity) {
11150
11712
  if (!identity.email || !identity.emailVerified) {
@@ -11287,9 +11849,9 @@ async function oauthUnlinkNotifyService(provider, notification) {
11287
11849
  }
11288
11850
 
11289
11851
  // src/server/services/oauth-native.service.ts
11290
- import { runInTransaction as runInTransaction2, onAfterCommit as onAfterCommit2 } from "@spfn/core/db";
11852
+ import { runInTransaction as runInTransaction2, onAfterCommit as onAfterCommit3 } from "@spfn/core/db";
11291
11853
  import {
11292
- InvalidKeyFingerprintError as InvalidKeyFingerprintError2,
11854
+ InvalidKeyFingerprintError as InvalidKeyFingerprintError3,
11293
11855
  NativeSignInUnsupportedError as NativeSignInUnsupportedError5,
11294
11856
  NonceKeyBindingError
11295
11857
  } from "@spfn/auth/errors";
@@ -11316,7 +11878,7 @@ function assertNonceBindsPublicKey(params) {
11316
11878
  throw new NonceKeyBindingError();
11317
11879
  }
11318
11880
  if (!verifyKeyFingerprint(params.publicKey, params.fingerprint)) {
11319
- throw new InvalidKeyFingerprintError2();
11881
+ throw new InvalidKeyFingerprintError3();
11320
11882
  }
11321
11883
  }
11322
11884
  async function persistNativeLogin(identity, params) {
@@ -11352,23 +11914,23 @@ async function persistNativeLogin(identity, params) {
11352
11914
  email: identity.email || void 0,
11353
11915
  metadata: params.metadata
11354
11916
  };
11355
- onAfterCommit2(() => (isNewUser ? authRegisterEvent : authLoginEvent).emit(eventPayload));
11917
+ onAfterCommit3(() => (isNewUser ? authRegisterEvent : authLoginEvent).emit(eventPayload));
11356
11918
  return { userId: String(userId), keyId: params.keyId, isNewUser };
11357
11919
  }, { context: "auth:oauth-native" });
11358
11920
  }
11359
11921
 
11360
11922
  // src/server/services/ops-token.service.ts
11361
11923
  init_ops_tokens_repository();
11362
- import { createHash as createHash4, randomBytes } from "crypto";
11924
+ import { createHash as createHash5, randomBytes as randomBytes2 } from "crypto";
11363
11925
  var OPS_TOKEN_PREFIX = "spfn_ops_";
11364
11926
  function hashOpsToken(token) {
11365
- return createHash4("sha256").update(token).digest("hex");
11927
+ return createHash5("sha256").update(token).digest("hex");
11366
11928
  }
11367
11929
  async function issueOpsTokenService(name, scopes, expiresAt) {
11368
11930
  if (scopes.length === 0) {
11369
11931
  throw new Error("An ops token needs at least one scope ('*' grants all).");
11370
11932
  }
11371
- const token = OPS_TOKEN_PREFIX + randomBytes(32).toString("hex");
11933
+ const token = OPS_TOKEN_PREFIX + randomBytes2(32).toString("hex");
11372
11934
  const record = await opsTokensRepository.create({
11373
11935
  name,
11374
11936
  tokenHash: hashOpsToken(token),
@@ -11398,8 +11960,8 @@ async function verifyOpsTokenService(token) {
11398
11960
  scopes: record.scopes
11399
11961
  };
11400
11962
  }
11401
- async function revokeOpsTokenService(id14) {
11402
- return await opsTokensRepository.revokeById(id14);
11963
+ async function revokeOpsTokenService(id15) {
11964
+ return await opsTokensRepository.revokeById(id15);
11403
11965
  }
11404
11966
  async function listOpsTokensService() {
11405
11967
  return await opsTokensRepository.list();
@@ -11412,7 +11974,7 @@ import { rateLimitPolicy } from "@spfn/core/middleware";
11412
11974
 
11413
11975
  // src/server/lib/rate-limit-keys.ts
11414
11976
  init_email();
11415
- import { createHash as createHash5 } from "crypto";
11977
+ import { createHash as createHash6 } from "crypto";
11416
11978
  import { getClientIp } from "@spfn/core/middleware";
11417
11979
  async function readJsonBody(c) {
11418
11980
  try {
@@ -11448,11 +12010,20 @@ function byIpAndAccount(options = {}) {
11448
12010
  ];
11449
12011
  };
11450
12012
  }
12013
+ function byIpAndCaller(options = {}) {
12014
+ return async (c) => {
12015
+ const auth = getOptionalAuth(c);
12016
+ return [
12017
+ { key: `ip:${getClientIp(c)}`, limit: options.ipLimit },
12018
+ auth ? `caller:${auth.userId}` : void 0
12019
+ ];
12020
+ };
12021
+ }
11451
12022
  function idTokenKey(body) {
11452
12023
  if (typeof body.idToken !== "string" || !body.idToken) {
11453
12024
  return void 0;
11454
12025
  }
11455
- return `tok:${createHash5("sha256").update(body.idToken).digest("hex")}`;
12026
+ return `tok:${createHash6("sha256").update(body.idToken).digest("hex")}`;
11456
12027
  }
11457
12028
  function byIpAndIdToken(options = {}) {
11458
12029
  return async (c) => {
@@ -11610,6 +12181,69 @@ var login = route.post("/_auth/login").input({
11610
12181
  const { body } = await c.data();
11611
12182
  return await loginService(body);
11612
12183
  });
12184
+ var startDeviceAuth = route.post("/_auth/device/start").input({
12185
+ // Bounded, unlike the interceptor bodies the authenticated enrolment
12186
+ // routes use: this is the one place key material arrives from a caller
12187
+ // with nothing to authenticate and is stored before anyone has agreed to
12188
+ // it. Validation refuses an oversize payload before it reaches a row.
12189
+ body: Type.Object({
12190
+ publicKey: PublicKeySchema,
12191
+ keyId: KeyIdSchema,
12192
+ fingerprint: FingerprintSchema,
12193
+ algorithm: Type.Optional(Type.Union(
12194
+ KEY_ALGORITHM.map((algo) => Type.Literal(algo)),
12195
+ { description: "Signature algorithm" }
12196
+ )),
12197
+ deviceName: Type.Optional(DeviceNameSchema),
12198
+ platform: Type.Optional(PlatformSchema)
12199
+ })
12200
+ }).use([rateLimitPolicy("auth-device-start", { limit: 10, windowMs: 6e4 }), Transactional()]).skip(["auth"]).handler(async (c) => {
12201
+ const { body } = await c.data();
12202
+ return await startDeviceAuthService(body);
12203
+ });
12204
+ var pollDeviceAuth = route.post("/_auth/device/poll").input({
12205
+ body: Type.Object({
12206
+ deviceCode: Type.String({ description: "Device code returned by /_auth/device/start" })
12207
+ })
12208
+ }).use([rateLimitPolicy("auth-device-poll", { limit: 30, windowMs: 6e4 }), Transactional()]).skip(["auth"]).handler(async (c) => {
12209
+ const { body } = await c.data();
12210
+ return await pollDeviceAuthService(body);
12211
+ });
12212
+ var getDeviceAuthInfo = route.post("/_auth/device/info").input({
12213
+ body: Type.Object({
12214
+ userCode: UserCodeSchema
12215
+ })
12216
+ }).use([
12217
+ rateLimitPolicy("auth-device-info", { limit: 10, windowMs: 6e4, by: byIpAndCaller({ ipLimit: 50 }) }),
12218
+ Transactional()
12219
+ ]).handler(async (c) => {
12220
+ const { body } = await c.data();
12221
+ return await getDeviceAuthInfoService(body);
12222
+ });
12223
+ var approveDeviceAuth = route.post("/_auth/device/approve").input({
12224
+ body: Type.Object({
12225
+ userCode: UserCodeSchema
12226
+ })
12227
+ }).use([
12228
+ rateLimitPolicy("auth-device-approve", { limit: 10, windowMs: 6e4, by: byIpAndCaller({ ipLimit: 50 }) }),
12229
+ Transactional()
12230
+ ]).handler(async (c) => {
12231
+ const { body } = await c.data();
12232
+ const { userId } = getAuth(c);
12233
+ return await approveDeviceAuthService({ userCode: body.userCode, userId: Number(userId) });
12234
+ });
12235
+ var denyDeviceAuth = route.post("/_auth/device/deny").input({
12236
+ body: Type.Object({
12237
+ userCode: UserCodeSchema
12238
+ })
12239
+ }).use([
12240
+ rateLimitPolicy("auth-device-deny", { limit: 10, windowMs: 6e4, by: byIpAndCaller({ ipLimit: 50 }) }),
12241
+ Transactional()
12242
+ ]).handler(async (c) => {
12243
+ const { body } = await c.data();
12244
+ await denyDeviceAuthService(body);
12245
+ return c.noContent();
12246
+ });
11613
12247
  var logout = route.post("/_auth/logout").handler(async (c) => {
11614
12248
  const auth = getAuth(c);
11615
12249
  if (!auth) {
@@ -11718,6 +12352,11 @@ var authRouter = defineRouter({
11718
12352
  verifyCode,
11719
12353
  register,
11720
12354
  login,
12355
+ startDeviceAuth,
12356
+ pollDeviceAuth,
12357
+ getDeviceAuthInfo,
12358
+ approveDeviceAuth,
12359
+ denyDeviceAuth,
11721
12360
  logout,
11722
12361
  rotateKey,
11723
12362
  listKeys,
@@ -11732,8 +12371,8 @@ var authRouter = defineRouter({
11732
12371
  import { EMAIL_PATTERN as EMAIL_PATTERN2, UUID_PATTERN } from "@spfn/auth";
11733
12372
 
11734
12373
  // src/server/middleware/authenticate.ts
11735
- import { defineMiddleware } from "@spfn/core/route";
11736
- import { UnauthorizedError as UnauthorizedError2 } from "@spfn/core/errors";
12374
+ import { defineMiddleware as defineMiddleware2 } from "@spfn/core/route";
12375
+ import { UnauthorizedError as UnauthorizedError3 } from "@spfn/core/errors";
11737
12376
  import { verifyClientToken as verifyClientToken2, decodeToken as decodeToken2, authLogger as authLogger3, keysRepository as keysRepository3, usersRepository as usersRepository3, userProfilesRepository as userProfilesRepository3 } from "@spfn/auth/server";
11738
12377
  import {
11739
12378
  InvalidTokenError,
@@ -11742,7 +12381,7 @@ import {
11742
12381
  } from "@spfn/auth/errors";
11743
12382
 
11744
12383
  // src/server/client-proof/refusal.ts
11745
- import { randomBytes as randomBytes2 } from "crypto";
12384
+ import { randomBytes as randomBytes3 } from "crypto";
11746
12385
 
11747
12386
  // src/server/client-proof/canonical-json.ts
11748
12387
  var CanonicalJsonError = class extends Error {
@@ -11755,13 +12394,13 @@ var CanonicalJsonError = class extends Error {
11755
12394
  var INT64_MIN = -(2n ** 63n);
11756
12395
  var INT64_MAX = 2n ** 63n - 1n;
11757
12396
  function parseCanonicalJson(bytes) {
11758
- let text14;
12397
+ let text15;
11759
12398
  try {
11760
- text14 = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
12399
+ text15 = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
11761
12400
  } catch {
11762
12401
  throw new CanonicalJsonError("INVALID_UTF8");
11763
12402
  }
11764
- const parser = new Parser(text14);
12403
+ const parser = new Parser(text15);
11765
12404
  const value = parser.parseValue();
11766
12405
  parser.skipWhitespace();
11767
12406
  if (!parser.atEnd()) {
@@ -11782,8 +12421,8 @@ function isCanonicalBytes(bytes, value) {
11782
12421
  return true;
11783
12422
  }
11784
12423
  var Parser = class {
11785
- constructor(text14) {
11786
- this.text = text14;
12424
+ constructor(text15) {
12425
+ this.text = text15;
11787
12426
  }
11788
12427
  pos = 0;
11789
12428
  atEnd() {
@@ -12100,7 +12739,7 @@ var HTTP_STATUS = {
12100
12739
  CONTRACT_UNSUPPORTED: 409
12101
12740
  };
12102
12741
  function newHexId() {
12103
- return randomBytes2(16).toString("hex");
12742
+ return randomBytes3(16).toString("hex");
12104
12743
  }
12105
12744
  var ClientProofRefusal = class _ClientProofRefusal {
12106
12745
  constructor(code, message) {
@@ -12202,7 +12841,7 @@ function contractViolation(message) {
12202
12841
  }
12203
12842
 
12204
12843
  // src/server/client-proof/contract-bundle.ts
12205
- import { createHash as createHash7 } from "crypto";
12844
+ import { createHash as createHash8 } from "crypto";
12206
12845
  import {
12207
12846
  CORE_TIME_OPERATION_ID as CORE_TIME_OPERATION_ID2,
12208
12847
  ServerTimeResponseSchema
@@ -12210,7 +12849,7 @@ import {
12210
12849
  init_types();
12211
12850
 
12212
12851
  // src/server/client-proof/proof.ts
12213
- import { createHash as createHash6, createPrivateKey, createPublicKey, sign, verify as verify2 } from "crypto";
12852
+ import { createHash as createHash7, createPrivateKey, createPublicKey, sign, verify as verify2 } from "crypto";
12214
12853
  var CLIENT_PROOF_PROFILE = "clientProofV1";
12215
12854
  var ABSENT_BODY_SHA256 = "0".repeat(64);
12216
12855
  var DEFAULT_REPLAY_WINDOW_MILLIS = 3e5;
@@ -12280,7 +12919,7 @@ function verifyClientProof(input, presentedProof, publicKey) {
12280
12919
  );
12281
12920
  }
12282
12921
  function sha256Hex(bytes) {
12283
- return createHash6("sha256").update(bytes).digest("hex");
12922
+ return createHash7("sha256").update(bytes).digest("hex");
12284
12923
  }
12285
12924
 
12286
12925
  // src/server/client-proof/admission.ts
@@ -12372,8 +13011,8 @@ var CORE_PREREQUISITE_OPERATIONS = [
12372
13011
 
12373
13012
  // src/server/client-proof/contract-bundle.ts
12374
13013
  init_wire_headers();
12375
- var CONTRACT_VERSION = "0.9.0";
12376
- var CONTRACT_SUPPORTED_RANGE = ">=0.9.0 <0.10.0";
13014
+ var CONTRACT_VERSION = "0.10.1";
13015
+ var CONTRACT_SUPPORTED_RANGE = ">=0.10.0 <0.11.0";
12377
13016
  function required(name, type) {
12378
13017
  return { name, type, optional: false };
12379
13018
  }
@@ -12544,7 +13183,7 @@ var CONTRACT_TYPES = [
12544
13183
  fields: [
12545
13184
  required("keyId", "string"),
12546
13185
  optional("deviceName", "string"),
12547
- optional("platform", "string"),
13186
+ optional("platform", "KeyPlatform"),
12548
13187
  required("algorithm", "KeyAlgorithm"),
12549
13188
  required("fingerprintPrefix", "string"),
12550
13189
  required("createdAtMillis", "integer"),
@@ -12586,10 +13225,94 @@ var CONTRACT_TYPES = [
12586
13225
  required("revokedCount", "integer"),
12587
13226
  required("currentKeyRevoked", "boolean")
12588
13227
  ]
13228
+ },
13229
+ {
13230
+ name: "StartDeviceAuthRequest",
13231
+ fields: [
13232
+ required("publicKey", "string"),
13233
+ required("keyId", "string"),
13234
+ required("fingerprint", "string"),
13235
+ optional("algorithm", "KeyAlgorithm"),
13236
+ optional("deviceName", "string"),
13237
+ optional("platform", "KeyPlatform")
13238
+ ]
13239
+ },
13240
+ {
13241
+ name: "StartDeviceAuthResponse",
13242
+ fields: [
13243
+ required("deviceCode", "string"),
13244
+ required("userCode", "string"),
13245
+ required("expiresAtMillis", "integer"),
13246
+ required("intervalMillis", "integer")
13247
+ ]
13248
+ },
13249
+ {
13250
+ name: "PollDeviceAuthRequest",
13251
+ fields: [
13252
+ required("deviceCode", "string")
13253
+ ]
13254
+ },
13255
+ /**
13256
+ * The poll union, flattened into the one shape this grammar can carry.
13257
+ *
13258
+ * `status` is the discriminant and the only required field; everything else
13259
+ * belongs to one branch and is therefore optional. `intervalMillis` is the
13260
+ * pending branch, and the five after it are the approved branch — the same
13261
+ * fields `LoginResponse` carries, because an approved poll is the login the
13262
+ * approval produced. `deviceAuthorization.pollStatusRule` states the pairing
13263
+ * the grammar cannot.
13264
+ */
13265
+ {
13266
+ name: "PollDeviceAuthResponse",
13267
+ fields: [
13268
+ required("status", "DeviceAuthPollStatus"),
13269
+ optional("intervalMillis", "integer"),
13270
+ optional("userId", "string"),
13271
+ optional("publicId", "string"),
13272
+ optional("email", "string"),
13273
+ optional("phone", "string"),
13274
+ optional("passwordChangeRequired", "boolean")
13275
+ ]
13276
+ },
13277
+ /**
13278
+ * Info, approve and deny each declare their own request type although all
13279
+ * three carry nothing but `userCode`. An operation's request shape is its
13280
+ * own: a field added to one of them later must not appear on the other two
13281
+ * by accident, which is what a shared type would do.
13282
+ */
13283
+ {
13284
+ name: "DeviceAuthInfoRequest",
13285
+ fields: [
13286
+ required("userCode", "string")
13287
+ ]
13288
+ },
13289
+ {
13290
+ name: "DeviceAuthInfoResponse",
13291
+ fields: [
13292
+ optional("deviceName", "string"),
13293
+ optional("platform", "KeyPlatform"),
13294
+ required("fingerprintPrefix", "string"),
13295
+ required("requestedAtMillis", "integer"),
13296
+ required("expiresAtMillis", "integer")
13297
+ ]
13298
+ },
13299
+ {
13300
+ name: "ApproveDeviceAuthRequest",
13301
+ fields: [
13302
+ required("userCode", "string")
13303
+ ]
13304
+ },
13305
+ {
13306
+ name: "DenyDeviceAuthRequest",
13307
+ fields: [
13308
+ required("userCode", "string")
13309
+ ]
12589
13310
  }
12590
13311
  ];
12591
13312
  var CONTRACT_ENUMS = [
12592
- { name: "KeyAlgorithm", values: [...KEY_ALGORITHM] }
13313
+ { name: "KeyAlgorithm", values: [...KEY_ALGORITHM] },
13314
+ { name: "KeyPlatform", values: [...KEY_PLATFORM] },
13315
+ { name: "DeviceAuthPollStatus", values: ["pending", "approved"] }
12593
13316
  ];
12594
13317
  var BUNDLE_FILENAME = "spfn-mobile-contract.json";
12595
13318
  var BUNDLE_REPO_PATH = `contracts/mobile/${BUNDLE_FILENAME}`;
@@ -12623,7 +13346,7 @@ import {
12623
13346
  userProfilesRepository as userProfilesRepository2,
12624
13347
  getPendingDeletionInfo as getPendingDeletionInfo2
12625
13348
  } from "@spfn/auth/server";
12626
- import { AccountDisabledError as AccountDisabledError3, AccountPendingDeletionError as AccountPendingDeletionError3 } from "@spfn/auth/errors";
13349
+ import { AccountDisabledError as AccountDisabledError4, AccountPendingDeletionError as AccountPendingDeletionError4 } from "@spfn/auth/errors";
12627
13350
 
12628
13351
  // src/server/client-proof/refusal-response.ts
12629
13352
  function clientProofRefusalResponse(c, refusal) {
@@ -12718,9 +13441,9 @@ async function resolveAuthenticatedUser(userId) {
12718
13441
  if (user.status !== "active") {
12719
13442
  if (user.status === "pending_deletion") {
12720
13443
  const pending = await getPendingDeletionInfo2(user.id);
12721
- throw new AccountPendingDeletionError3({ purgeScheduledAt: pending?.purgeScheduledAt.toISOString() });
13444
+ throw new AccountPendingDeletionError4({ purgeScheduledAt: pending?.purgeScheduledAt.toISOString() });
12722
13445
  }
12723
- throw new AccountDisabledError3({ status: user.status });
13446
+ throw new AccountDisabledError4({ status: user.status });
12724
13447
  }
12725
13448
  return { user, role: role?.name ?? null, locale };
12726
13449
  }
@@ -12739,6 +13462,9 @@ var ClientProofRefusalError = class extends SerializableError {
12739
13462
  function refusalError(refusal) {
12740
13463
  return new ClientProofRefusalError(refusal);
12741
13464
  }
13465
+ function clientProofRefusalOf(err) {
13466
+ return err instanceof ClientProofRefusalError ? err.refusal : null;
13467
+ }
12742
13468
  async function runAuthProfile(c) {
12743
13469
  try {
12744
13470
  const verifier = selectAuthProfile(c);
@@ -12884,8 +13610,172 @@ function registerAuthProfile(profileId, verifier) {
12884
13610
  AUTH_PROFILE_VERIFIERS.set(profileId, { verify: verifier.verify.bind(verifier) });
12885
13611
  }
12886
13612
 
13613
+ // src/server/middleware/machine-principals.ts
13614
+ import { decodeProtectedHeader } from "jose";
13615
+ import { defineMiddleware } from "@spfn/core/route";
13616
+ import { ForbiddenError as ForbiddenError2, UnauthorizedError as UnauthorizedError2 } from "@spfn/core/errors";
13617
+ function getMachinePrincipal(c) {
13618
+ return c.get("machinePrincipal") ?? null;
13619
+ }
13620
+ var TOKEN_PREFIX_VERIFIERS = [];
13621
+ var KID_PREFIX_VERIFIERS = [];
13622
+ var REGISTERED_IDS = /* @__PURE__ */ new Set();
13623
+ function readDiscriminator(match) {
13624
+ const tokenPrefix = match?.tokenPrefix;
13625
+ const kidPrefix = match?.kidPrefix;
13626
+ if (tokenPrefix !== void 0 && kidPrefix !== void 0) {
13627
+ return null;
13628
+ }
13629
+ if (typeof tokenPrefix === "string" && tokenPrefix.length > 0) {
13630
+ return { kind: "tokenPrefix", prefix: tokenPrefix };
13631
+ }
13632
+ if (typeof kidPrefix === "string" && kidPrefix.length > 0) {
13633
+ return { kind: "kidPrefix", prefix: kidPrefix };
13634
+ }
13635
+ return null;
13636
+ }
13637
+ function prefixesCollide(a, b) {
13638
+ return a.startsWith(b) || b.startsWith(a);
13639
+ }
13640
+ function registerMachineVerifier(reg) {
13641
+ if (typeof reg?.id !== "string" || reg.id.length === 0) {
13642
+ throw new Error("registerMachineVerifier: id must be a non-empty string");
13643
+ }
13644
+ if (typeof reg.verify !== "function") {
13645
+ throw new Error(`registerMachineVerifier: '${reg.id}' needs a verifier with a callable verify(token, c)`);
13646
+ }
13647
+ const discriminator = readDiscriminator(reg.match);
13648
+ if (discriminator === null) {
13649
+ throw new Error(`registerMachineVerifier: '${reg.id}' needs exactly one non-empty discriminator \u2014 { tokenPrefix } or { kidPrefix }`);
13650
+ }
13651
+ if (REGISTERED_IDS.has(reg.id)) {
13652
+ throw new Error(`registerMachineVerifier: '${reg.id}' is already registered`);
13653
+ }
13654
+ const peers = discriminator.kind === "tokenPrefix" ? TOKEN_PREFIX_VERIFIERS : KID_PREFIX_VERIFIERS;
13655
+ const shadowed = peers.find((peer) => prefixesCollide(peer.prefix, discriminator.prefix));
13656
+ if (shadowed !== void 0) {
13657
+ throw new Error(
13658
+ `registerMachineVerifier: '${reg.id}' ${discriminator.kind} '${discriminator.prefix}' collides with '${shadowed.id}' \u2014 one prefix would shadow the other`
13659
+ );
13660
+ }
13661
+ peers.push({ id: reg.id, prefix: discriminator.prefix, verify: reg.verify.bind(reg) });
13662
+ REGISTERED_IDS.add(reg.id);
13663
+ }
13664
+ function findMachineVerifier(token) {
13665
+ const byTokenPrefix = TOKEN_PREFIX_VERIFIERS.find((entry) => token.startsWith(entry.prefix));
13666
+ if (byTokenPrefix !== void 0) {
13667
+ return byTokenPrefix;
13668
+ }
13669
+ if (KID_PREFIX_VERIFIERS.length === 0) {
13670
+ return null;
13671
+ }
13672
+ const kid = readProtectedKid(token);
13673
+ if (kid === null) {
13674
+ return null;
13675
+ }
13676
+ return KID_PREFIX_VERIFIERS.find((entry) => kid.startsWith(entry.prefix)) ?? null;
13677
+ }
13678
+ function matchesMachineDiscriminator(token) {
13679
+ return findMachineVerifier(token) !== null;
13680
+ }
13681
+ function readProtectedKid(token) {
13682
+ try {
13683
+ const { kid } = decodeProtectedHeader(token);
13684
+ return typeof kid === "string" ? kid : null;
13685
+ } catch {
13686
+ return null;
13687
+ }
13688
+ }
13689
+ var MACHINE_REFUSAL_MESSAGE = "Machine authentication required: Authorization: Bearer <token>";
13690
+ function machineRefusal() {
13691
+ return new UnauthorizedError2({ message: MACHINE_REFUSAL_MESSAGE });
13692
+ }
13693
+ var machineAuth = defineMiddleware("machineAuth", async (c, next) => {
13694
+ const refused = profileChannelRefusal(c);
13695
+ if (refused !== null) {
13696
+ return refused;
13697
+ }
13698
+ const token = extractBearer(c.req.header("Authorization"));
13699
+ if (token === null) {
13700
+ throw machineRefusal();
13701
+ }
13702
+ const entry = findMachineVerifier(token);
13703
+ if (entry === null) {
13704
+ throw machineRefusal();
13705
+ }
13706
+ const principal = await verifiedPrincipal(entry, token, c);
13707
+ if (principal === null) {
13708
+ throw machineRefusal();
13709
+ }
13710
+ c.set("machinePrincipal", principal);
13711
+ await next();
13712
+ return void 0;
13713
+ }, { skips: ["auth"] });
13714
+ function profileChannelRefusal(c) {
13715
+ try {
13716
+ selectAuthProfile(c);
13717
+ return null;
13718
+ } catch (err) {
13719
+ const refusal = clientProofRefusalOf(err);
13720
+ if (refusal === null) {
13721
+ throw err;
13722
+ }
13723
+ return clientProofRefusalResponse(c, refusal);
13724
+ }
13725
+ }
13726
+ async function verifiedPrincipal(entry, token, c) {
13727
+ try {
13728
+ const principal = copyPrincipal(await entry.verify(token, c), entry.id);
13729
+ if (principal === null) {
13730
+ authLogger.middleware.error(`machine verifier '${entry.id}' resolved no principal`);
13731
+ }
13732
+ return principal;
13733
+ } catch (err) {
13734
+ authLogger.middleware.error(`machine verifier '${entry.id}' refused or failed`, err);
13735
+ return null;
13736
+ }
13737
+ }
13738
+ function copyPrincipal(resolved, scheme) {
13739
+ const subjectType = resolved?.subjectType;
13740
+ const subjectId = resolved?.subjectId;
13741
+ const scopes = resolved?.scopes;
13742
+ const claims = resolved?.claims;
13743
+ if (typeof subjectType !== "string" || subjectType.length === 0 || typeof subjectId !== "string" || subjectId.length === 0 || !Array.isArray(scopes)) {
13744
+ return null;
13745
+ }
13746
+ return {
13747
+ subjectType,
13748
+ subjectId,
13749
+ scopes: [...scopes],
13750
+ claims: claims === void 0 ? void 0 : structuredClone(claims),
13751
+ scheme
13752
+ };
13753
+ }
13754
+ var requireMachineScope = defineMiddleware(
13755
+ "machineScope",
13756
+ (...scopes) => async (c, next) => {
13757
+ const principal = getMachinePrincipal(c);
13758
+ if (!principal) {
13759
+ throw machineRefusal();
13760
+ }
13761
+ const granted = new Set(principal.scopes);
13762
+ const missing = scopes.filter((scope) => !granted.has(scope));
13763
+ if (missing.length > 0) {
13764
+ throw new ForbiddenError2({ message: `Machine principal lacks scope: ${missing.join(", ")}` });
13765
+ }
13766
+ await next();
13767
+ }
13768
+ );
13769
+ function extractBearer(header) {
13770
+ if (!header || !header.startsWith("Bearer ")) {
13771
+ return null;
13772
+ }
13773
+ return header.substring(7);
13774
+ }
13775
+
12887
13776
  // src/server/middleware/authenticate.ts
12888
- var authenticate = defineMiddleware("auth", async (c, next) => {
13777
+ var INVALID_TOKEN_MESSAGE = "Invalid token: missing keyId";
13778
+ var authenticate = defineMiddleware2("auth", async (c, next) => {
12889
13779
  const profile = await runAuthProfile(c);
12890
13780
  if (profile.kind === "refused") {
12891
13781
  return profile.response;
@@ -12901,17 +13791,21 @@ var authenticate = defineMiddleware("auth", async (c, next) => {
12901
13791
  headers: c.req.header(),
12902
13792
  path: c.req.path
12903
13793
  });
12904
- throw new UnauthorizedError2({ message: "Authentication header missing or invalid: Bearer {token}" });
13794
+ throw new UnauthorizedError3({ message: "Authentication header missing or invalid: Bearer {token}" });
12905
13795
  }
12906
13796
  const token = authHeader.substring(7);
13797
+ if (matchesMachineDiscriminator(token)) {
13798
+ authLogger3.middleware.warn("Machine credential presented to the user path \u2014 refused", { path: c.req.path });
13799
+ throw new UnauthorizedError3({ message: INVALID_TOKEN_MESSAGE });
13800
+ }
12907
13801
  const decoded = decodeToken2(token);
12908
13802
  if (!decoded || !decoded.keyId) {
12909
- throw new UnauthorizedError2({ message: "Invalid token: missing keyId" });
13803
+ throw new UnauthorizedError3({ message: INVALID_TOKEN_MESSAGE });
12910
13804
  }
12911
13805
  const keyId = decoded.keyId;
12912
13806
  const keyRecord = await keysRepository3.findActiveByKeyId(keyId);
12913
13807
  if (!keyRecord) {
12914
- throw new UnauthorizedError2({ message: "Invalid or revoked key" });
13808
+ throw new UnauthorizedError3({ message: "Invalid or revoked key" });
12915
13809
  }
12916
13810
  if (keyRecord.expiresAt && /* @__PURE__ */ new Date() > keyRecord.expiresAt) {
12917
13811
  throw new KeyExpiredError();
@@ -12932,7 +13826,7 @@ var authenticate = defineMiddleware("auth", async (c, next) => {
12932
13826
  throw new InvalidTokenError({ message: "Invalid token signature" });
12933
13827
  }
12934
13828
  }
12935
- throw new UnauthorizedError2({ message: "Authentication failed" });
13829
+ throw new UnauthorizedError3({ message: "Authentication failed" });
12936
13830
  }
12937
13831
  const { user, role, locale } = await resolveAuthenticatedUser(keyRecord.userId);
12938
13832
  keysRepository3.updateLastUsedById(keyRecord.id, readContextClientIdentity(c)).catch((err) => authLogger3.middleware.error("Failed to update lastUsedAt", err));
@@ -12958,7 +13852,7 @@ var authenticate = defineMiddleware("auth", async (c, next) => {
12958
13852
  await next();
12959
13853
  return void 0;
12960
13854
  });
12961
- var optionalAuth = defineMiddleware("optionalAuth", async (c, next) => {
13855
+ var optionalAuth = defineMiddleware2("optionalAuth", async (c, next) => {
12962
13856
  const profile = await runAuthProfile(c);
12963
13857
  if (profile.kind === "refused") {
12964
13858
  return profile.response;
@@ -12974,6 +13868,10 @@ var optionalAuth = defineMiddleware("optionalAuth", async (c, next) => {
12974
13868
  return void 0;
12975
13869
  }
12976
13870
  const token = authHeader.substring(7);
13871
+ if (matchesMachineDiscriminator(token)) {
13872
+ authLogger3.middleware.warn("Machine credential presented to the user path \u2014 refused", { path: c.req.path });
13873
+ throw new UnauthorizedError3({ message: INVALID_TOKEN_MESSAGE });
13874
+ }
12977
13875
  try {
12978
13876
  const decoded = decodeToken2(token);
12979
13877
  if (!decoded || !decoded.keyId) {
@@ -13020,11 +13918,11 @@ var optionalAuth = defineMiddleware("optionalAuth", async (c, next) => {
13020
13918
  }, { skips: ["auth"] });
13021
13919
 
13022
13920
  // src/server/middleware/require-permission.ts
13023
- import { defineMiddleware as defineMiddleware2 } from "@spfn/core/route";
13024
- import { ForbiddenError as ForbiddenError2 } from "@spfn/core/errors";
13921
+ import { defineMiddleware as defineMiddleware3 } from "@spfn/core/route";
13922
+ import { ForbiddenError as ForbiddenError3 } from "@spfn/core/errors";
13025
13923
  import { InsufficientPermissionsError } from "@spfn/auth/errors";
13026
13924
  import { getAuth as getAuth2, hasAllPermissions as hasAllPermissions2, hasAnyPermission as hasAnyPermission2, authLogger as authLogger4 } from "@spfn/auth/server";
13027
- var requirePermissions = defineMiddleware2(
13925
+ var requirePermissions = defineMiddleware3(
13028
13926
  "permission",
13029
13927
  (...permissionNames) => async (c, next) => {
13030
13928
  const auth = getAuth2(c);
@@ -13033,7 +13931,7 @@ var requirePermissions = defineMiddleware2(
13033
13931
  permissions: permissionNames,
13034
13932
  path: c.req.path
13035
13933
  });
13036
- throw new ForbiddenError2({ message: "Authentication required" });
13934
+ throw new ForbiddenError3({ message: "Authentication required" });
13037
13935
  }
13038
13936
  const { userId } = auth;
13039
13937
  const allowed = await hasAllPermissions2(userId, permissionNames);
@@ -13052,7 +13950,7 @@ var requirePermissions = defineMiddleware2(
13052
13950
  await next();
13053
13951
  }
13054
13952
  );
13055
- var requireAnyPermission = defineMiddleware2(
13953
+ var requireAnyPermission = defineMiddleware3(
13056
13954
  "anyPermission",
13057
13955
  (...permissionNames) => async (c, next) => {
13058
13956
  const auth = getAuth2(c);
@@ -13061,7 +13959,7 @@ var requireAnyPermission = defineMiddleware2(
13061
13959
  permissions: permissionNames,
13062
13960
  path: c.req.path
13063
13961
  });
13064
- throw new ForbiddenError2({ message: "Authentication required" });
13962
+ throw new ForbiddenError3({ message: "Authentication required" });
13065
13963
  }
13066
13964
  const { userId } = auth;
13067
13965
  const allowed = await hasAnyPermission2(userId, permissionNames);
@@ -13082,11 +13980,11 @@ var requireAnyPermission = defineMiddleware2(
13082
13980
  );
13083
13981
 
13084
13982
  // src/server/middleware/require-role.ts
13085
- import { defineMiddleware as defineMiddleware3 } from "@spfn/core/route";
13983
+ import { defineMiddleware as defineMiddleware4 } from "@spfn/core/route";
13086
13984
  import { getAuth as getAuth3, authLogger as authLogger5 } from "@spfn/auth/server";
13087
- import { ForbiddenError as ForbiddenError3 } from "@spfn/core/errors";
13985
+ import { ForbiddenError as ForbiddenError4 } from "@spfn/core/errors";
13088
13986
  import { InsufficientRoleError } from "@spfn/auth/errors";
13089
- var requireRole = defineMiddleware3(
13987
+ var requireRole = defineMiddleware4(
13090
13988
  "role",
13091
13989
  (...roleNames) => async (c, next) => {
13092
13990
  const auth = getAuth3(c);
@@ -13095,7 +13993,7 @@ var requireRole = defineMiddleware3(
13095
13993
  roles: roleNames,
13096
13994
  path: c.req.path
13097
13995
  });
13098
- throw new ForbiddenError3({ message: "Authentication required" });
13996
+ throw new ForbiddenError4({ message: "Authentication required" });
13099
13997
  }
13100
13998
  const { userId, role: userRole } = auth;
13101
13999
  if (!userRole || !roleNames.includes(userRole)) {
@@ -13117,11 +14015,11 @@ var requireRole = defineMiddleware3(
13117
14015
  );
13118
14016
 
13119
14017
  // src/server/middleware/role-guard.ts
13120
- import { defineMiddleware as defineMiddleware4 } from "@spfn/core/route";
14018
+ import { defineMiddleware as defineMiddleware5 } from "@spfn/core/route";
13121
14019
  import { getAuth as getAuth4, authLogger as authLogger6 } from "@spfn/auth/server";
13122
- import { ForbiddenError as ForbiddenError4 } from "@spfn/core/errors";
14020
+ import { ForbiddenError as ForbiddenError5 } from "@spfn/core/errors";
13123
14021
  import { InsufficientRoleError as InsufficientRoleError2 } from "@spfn/auth/errors";
13124
- var roleGuard = defineMiddleware4(
14022
+ var roleGuard = defineMiddleware5(
13125
14023
  "roleGuard",
13126
14024
  (options) => async (c, next) => {
13127
14025
  const { allow, deny } = options;
@@ -13133,7 +14031,7 @@ var roleGuard = defineMiddleware4(
13133
14031
  authLogger6.middleware.warn("Role guard failed: not authenticated", {
13134
14032
  path: c.req.path
13135
14033
  });
13136
- throw new ForbiddenError4({ message: "Authentication required" });
14034
+ throw new ForbiddenError5({ message: "Authentication required" });
13137
14035
  }
13138
14036
  const { userId, role: userRole } = auth;
13139
14037
  if (deny && deny.length > 0) {
@@ -13169,28 +14067,28 @@ var roleGuard = defineMiddleware4(
13169
14067
  );
13170
14068
 
13171
14069
  // src/server/middleware/one-time-token-auth.ts
13172
- import { defineMiddleware as defineMiddleware5 } from "@spfn/core/route";
13173
- import { UnauthorizedError as UnauthorizedError3 } from "@spfn/core/errors";
14070
+ import { defineMiddleware as defineMiddleware6 } from "@spfn/core/route";
14071
+ import { UnauthorizedError as UnauthorizedError4 } from "@spfn/core/errors";
13174
14072
  import { usersRepository as usersRepository4, userProfilesRepository as userProfilesRepository4 } from "@spfn/auth/server";
13175
- var oneTimeTokenAuth = defineMiddleware5("oneTimeTokenAuth", async (c, next) => {
14073
+ var oneTimeTokenAuth = defineMiddleware6("oneTimeTokenAuth", async (c, next) => {
13176
14074
  const token = c.req.query("token") ?? extractOTTHeader(c.req.header("Authorization"));
13177
14075
  if (!token) {
13178
- throw new UnauthorizedError3({ message: "One-time token required: ?token=xxx or Authorization: OTT xxx" });
14076
+ throw new UnauthorizedError4({ message: "One-time token required: ?token=xxx or Authorization: OTT xxx" });
13179
14077
  }
13180
14078
  const userId = await verifyOneTimeTokenService(token);
13181
14079
  if (!userId) {
13182
- throw new UnauthorizedError3({ message: "Invalid or expired one-time token" });
14080
+ throw new UnauthorizedError4({ message: "Invalid or expired one-time token" });
13183
14081
  }
13184
14082
  const [result, locale] = await Promise.all([
13185
14083
  usersRepository4.findByIdWithRole(Number(userId)),
13186
14084
  userProfilesRepository4.findLocaleByUserId(Number(userId))
13187
14085
  ]);
13188
14086
  if (!result) {
13189
- throw new UnauthorizedError3({ message: "User not found" });
14087
+ throw new UnauthorizedError4({ message: "User not found" });
13190
14088
  }
13191
14089
  const { user, role } = result;
13192
14090
  if (user.status !== "active") {
13193
- throw new UnauthorizedError3({ message: "Account is not active" });
14091
+ throw new UnauthorizedError4({ message: "Account is not active" });
13194
14092
  }
13195
14093
  c.set("auth", {
13196
14094
  user,
@@ -13211,39 +14109,39 @@ function extractOTTHeader(header) {
13211
14109
  }
13212
14110
 
13213
14111
  // src/server/middleware/ops-token-auth.ts
13214
- import { defineMiddleware as defineMiddleware6 } from "@spfn/core/route";
13215
- import { ForbiddenError as ForbiddenError5, UnauthorizedError as UnauthorizedError4 } from "@spfn/core/errors";
14112
+ import { defineMiddleware as defineMiddleware7 } from "@spfn/core/route";
14113
+ import { ForbiddenError as ForbiddenError6, UnauthorizedError as UnauthorizedError5 } from "@spfn/core/errors";
13216
14114
  function getOpsToken(c) {
13217
14115
  return c.get("opsToken") ?? null;
13218
14116
  }
13219
- var opsTokenAuth = defineMiddleware6("opsTokenAuth", async (c, next) => {
13220
- const token = extractBearer(c.req.header("Authorization"));
14117
+ var opsTokenAuth = defineMiddleware7("opsTokenAuth", async (c, next) => {
14118
+ const token = extractBearer2(c.req.header("Authorization"));
13221
14119
  if (!token) {
13222
- throw new UnauthorizedError4({ message: "Ops token required: Authorization: Bearer <token>" });
14120
+ throw new UnauthorizedError5({ message: "Ops token required: Authorization: Bearer <token>" });
13223
14121
  }
13224
14122
  const verified = await verifyOpsTokenService(token);
13225
14123
  if (!verified) {
13226
- throw new UnauthorizedError4({ message: "Invalid ops token" });
14124
+ throw new UnauthorizedError5({ message: "Invalid ops token" });
13227
14125
  }
13228
14126
  c.set("opsToken", verified);
13229
14127
  await next();
13230
14128
  }, { skips: ["auth"] });
13231
- var requireOpsScope = defineMiddleware6(
14129
+ var requireOpsScope = defineMiddleware7(
13232
14130
  "opsScope",
13233
14131
  (...scopes) => async (c, next) => {
13234
14132
  const token = getOpsToken(c);
13235
14133
  if (!token) {
13236
- throw new UnauthorizedError4({ message: "Ops token required: Authorization: Bearer <token>" });
14134
+ throw new UnauthorizedError5({ message: "Ops token required: Authorization: Bearer <token>" });
13237
14135
  }
13238
14136
  const granted = new Set(token.scopes);
13239
14137
  const missing = scopes.filter((scope) => !granted.has(scope) && !granted.has("*"));
13240
14138
  if (missing.length > 0) {
13241
- throw new ForbiddenError5({ message: `Ops token lacks scope: ${missing.join(", ")}` });
14139
+ throw new ForbiddenError6({ message: `Ops token lacks scope: ${missing.join(", ")}` });
13242
14140
  }
13243
14141
  await next();
13244
14142
  }
13245
14143
  );
13246
- function extractBearer(header) {
14144
+ function extractBearer2(header) {
13247
14145
  if (!header || !header.startsWith("Bearer ")) {
13248
14146
  return null;
13249
14147
  }
@@ -14038,7 +14936,7 @@ var oauthRouter = defineRouter4({
14038
14936
 
14039
14937
  // src/server/routes/admin/index.ts
14040
14938
  init_esm();
14041
- import { ForbiddenError as ForbiddenError6 } from "@spfn/core/errors";
14939
+ import { ForbiddenError as ForbiddenError7 } from "@spfn/core/errors";
14042
14940
  import { route as route5 } from "@spfn/core/route";
14043
14941
  var listRoles = route5.get("/_auth/admin/roles").input({
14044
14942
  query: Type.Object({
@@ -14108,11 +15006,11 @@ var updateUserRole = route5.patch("/_auth/admin/users/:userId/role").input({
14108
15006
  const { params, body } = await c.data();
14109
15007
  const auth = getAuth(c);
14110
15008
  if (params.userId === Number(auth.userId)) {
14111
- throw new ForbiddenError6({ message: "Cannot change your own role" });
15009
+ throw new ForbiddenError7({ message: "Cannot change your own role" });
14112
15010
  }
14113
15011
  const targetRole = await getUserRole(params.userId);
14114
15012
  if (targetRole === "superadmin") {
14115
- throw new ForbiddenError6({ message: "Cannot modify superadmin role" });
15013
+ throw new ForbiddenError7({ message: "Cannot modify superadmin role" });
14116
15014
  }
14117
15015
  await assertCanAssignRole(auth.userId, body.roleId);
14118
15016
  await updateUserService(params.userId, { roleId: body.roleId });
@@ -14236,6 +15134,12 @@ var mainAuthRouter = defineRouter6({
14236
15134
  confirmSignupLink,
14237
15135
  completeSignup,
14238
15136
  login,
15137
+ // Device-code login routes
15138
+ startDeviceAuth,
15139
+ pollDeviceAuth,
15140
+ getDeviceAuthInfo,
15141
+ approveDeviceAuth,
15142
+ denyDeviceAuth,
14239
15143
  logout,
14240
15144
  rotateKey,
14241
15145
  listKeys,
@@ -14479,15 +15383,64 @@ async function shouldRefreshSession(jwt4, thresholdHours = 24) {
14479
15383
  return hoursRemaining < thresholdHours;
14480
15384
  }
14481
15385
 
14482
- // src/server/setup.ts
15386
+ // src/server/lib/csrf.ts
14483
15387
  import { env as env14 } from "@spfn/auth/config";
15388
+ var CSRF_HEADER = "x-spfn-csrf";
15389
+ var CSRF_SUBKEY_LABEL = "spfn-auth-csrf-token-v1";
15390
+ var MAX_CANDIDATES = 32;
15391
+ function sessionSecret() {
15392
+ const secret = env14.SPFN_AUTH_SESSION_SECRET;
15393
+ if (!secret) {
15394
+ throw new Error(
15395
+ "SPFN_AUTH_SESSION_SECRET is required for CSRF protection. Set it (sessions need it anyway), or set SPFN_AUTH_CSRF=off."
15396
+ );
15397
+ }
15398
+ return secret;
15399
+ }
15400
+ async function hmacSha256(key, message) {
15401
+ const cryptoKey = await crypto.subtle.importKey(
15402
+ "raw",
15403
+ key.buffer,
15404
+ { name: "HMAC", hash: "SHA-256" },
15405
+ false,
15406
+ ["sign"]
15407
+ );
15408
+ const signature = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(message));
15409
+ return new Uint8Array(signature);
15410
+ }
15411
+ function toHex(bytes) {
15412
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
15413
+ }
15414
+ async function deriveCsrfToken(keyId) {
15415
+ const subkey = await hmacSha256(new TextEncoder().encode(sessionSecret()), CSRF_SUBKEY_LABEL);
15416
+ return toHex(await hmacSha256(subkey, keyId));
15417
+ }
15418
+ function timingSafeEqualString(a, b) {
15419
+ if (a.length !== b.length) {
15420
+ return false;
15421
+ }
15422
+ let difference = 0;
15423
+ for (let i = 0; i < a.length; i++) {
15424
+ difference |= a.charCodeAt(i) ^ b.charCodeAt(i);
15425
+ }
15426
+ return difference === 0;
15427
+ }
15428
+ function matchesCsrfToken(expected, presented) {
15429
+ if (!presented) {
15430
+ return false;
15431
+ }
15432
+ return presented.split(",", MAX_CANDIDATES).some((candidate) => timingSafeEqualString(expected, candidate.trim()));
15433
+ }
15434
+
15435
+ // src/server/setup.ts
15436
+ import { env as env15 } from "@spfn/auth/config";
14484
15437
  import { getRoleByName as getRoleByName2 } from "@spfn/auth/server";
14485
15438
  init_repositories();
14486
15439
  function parseAdminAccounts() {
14487
15440
  const accounts = [];
14488
- if (env14.SPFN_AUTH_ADMIN_ACCOUNTS) {
15441
+ if (env15.SPFN_AUTH_ADMIN_ACCOUNTS) {
14489
15442
  try {
14490
- const accountsJson = env14.SPFN_AUTH_ADMIN_ACCOUNTS;
15443
+ const accountsJson = env15.SPFN_AUTH_ADMIN_ACCOUNTS;
14491
15444
  const parsed = JSON.parse(accountsJson);
14492
15445
  if (!Array.isArray(parsed)) {
14493
15446
  authLogger.setup.error("\u274C SPFN_AUTH_ADMIN_ACCOUNTS must be an array");
@@ -14514,11 +15467,11 @@ function parseAdminAccounts() {
14514
15467
  return accounts;
14515
15468
  }
14516
15469
  }
14517
- const adminEmails = env14.SPFN_AUTH_ADMIN_EMAILS;
15470
+ const adminEmails = env15.SPFN_AUTH_ADMIN_EMAILS;
14518
15471
  if (adminEmails) {
14519
15472
  const emails = adminEmails.split(",").map((s) => s.trim());
14520
- const passwords = (env14.SPFN_AUTH_ADMIN_PASSWORDS || "").split(",").map((s) => s.trim());
14521
- const roles2 = (env14.SPFN_AUTH_ADMIN_ROLES || "").split(",").map((s) => s.trim());
15473
+ const passwords = (env15.SPFN_AUTH_ADMIN_PASSWORDS || "").split(",").map((s) => s.trim());
15474
+ const roles2 = (env15.SPFN_AUTH_ADMIN_ROLES || "").split(",").map((s) => s.trim());
14522
15475
  if (passwords.length !== emails.length) {
14523
15476
  authLogger.setup.error("\u274C SPFN_AUTH_ADMIN_EMAILS and SPFN_AUTH_ADMIN_PASSWORDS length mismatch");
14524
15477
  return accounts;
@@ -14540,8 +15493,8 @@ function parseAdminAccounts() {
14540
15493
  }
14541
15494
  return accounts;
14542
15495
  }
14543
- const adminEmail = env14.SPFN_AUTH_ADMIN_EMAIL;
14544
- const adminPassword = env14.SPFN_AUTH_ADMIN_PASSWORD;
15496
+ const adminEmail = env15.SPFN_AUTH_ADMIN_EMAIL;
15497
+ const adminPassword = env15.SPFN_AUTH_ADMIN_PASSWORD;
14545
15498
  if (adminEmail && adminPassword) {
14546
15499
  accounts.push({
14547
15500
  email: adminEmail,
@@ -14602,19 +15555,88 @@ async function ensureAdminExists() {
14602
15555
  }
14603
15556
  }
14604
15557
 
15558
+ // src/server/lib/oauth/redirect-uri-check.ts
15559
+ var PROVIDERS = ["google", "kakao", "naver", "github"];
15560
+ var OPT_OUT_VAR = "SPFN_AUTH_OAUTH_CALLBACK_ORIGIN_CHECK";
15561
+ function redirectUriVar(provider) {
15562
+ return `SPFN_AUTH_${provider.toUpperCase()}_REDIRECT_URI`;
15563
+ }
15564
+ function resolveWebAppOrigin(env16) {
15565
+ const configured2 = env16.NEXT_PUBLIC_SPFN_APP_URL || env16.SPFN_APP_URL;
15566
+ if (!configured2) {
15567
+ return null;
15568
+ }
15569
+ try {
15570
+ return new URL(configured2).origin;
15571
+ } catch {
15572
+ return null;
15573
+ }
15574
+ }
15575
+ function conformanceDetail(value, webAppOrigin, callbackPath) {
15576
+ let url;
15577
+ try {
15578
+ url = new URL(value);
15579
+ } catch {
15580
+ return " The value is not a URL.";
15581
+ }
15582
+ if (url.origin !== webAppOrigin || url.pathname !== callbackPath) {
15583
+ return "";
15584
+ }
15585
+ if (url.search !== "" || url.hash !== "") {
15586
+ return " The value carries a query string or fragment; the callback URL is the path alone.";
15587
+ }
15588
+ return null;
15589
+ }
15590
+ function providerRefusal(provider, env16, webAppOrigin) {
15591
+ const variable = redirectUriVar(provider);
15592
+ const value = env16[variable];
15593
+ if (!value) {
15594
+ return null;
15595
+ }
15596
+ const callbackPath = `/_auth/oauth/${provider}/callback`;
15597
+ const detail = conformanceDetail(value, webAppOrigin, callbackPath);
15598
+ if (detail === null) {
15599
+ return null;
15600
+ }
15601
+ return `${variable} must be on the web app origin (${webAppOrigin}) at ${callbackPath}: the callback's CSRF cookie is host-only and /_auth/* is forwarded to the API by the app's rewrite. Unset it to use the default, fix the origin, or set ${OPT_OUT_VAR}=off for a deployment that deliberately terminates the callback elsewhere.${detail}`;
15602
+ }
15603
+ function assertOAuthRedirectUris(env16 = process.env) {
15604
+ const examined = PROVIDERS.map(redirectUriVar).join(", ");
15605
+ if (env16[OPT_OUT_VAR] === "off") {
15606
+ authLogger.service.info(
15607
+ `${OPT_OUT_VAR}=off: the OAuth callback origin check is disabled. Not examined: ${examined}.`
15608
+ );
15609
+ return;
15610
+ }
15611
+ const webAppOrigin = resolveWebAppOrigin(env16);
15612
+ if (!webAppOrigin) {
15613
+ authLogger.service.info(
15614
+ `The OAuth callback origin check was skipped: neither NEXT_PUBLIC_SPFN_APP_URL nor SPFN_APP_URL resolves to a parseable URL, which the startup env validation reports on its own. Not examined: ${examined}.`
15615
+ );
15616
+ return;
15617
+ }
15618
+ const refusals = PROVIDERS.map((provider) => providerRefusal(provider, env16, webAppOrigin)).filter((refusal) => refusal !== null);
15619
+ if (refusals.length > 0) {
15620
+ throw new Error(refusals.join("\n"));
15621
+ }
15622
+ }
15623
+
14605
15624
  // src/server/lifecycle.ts
14606
15625
  function createAuthLifecycle(options = {}) {
14607
15626
  configureDeletion(options.deletion);
15627
+ configureDeviceAuth(options.deviceAuth);
14608
15628
  return {
14609
15629
  /**
14610
15630
  * Initialize auth system after database is ready
14611
15631
  *
14612
15632
  * Performs:
15633
+ * 0. Refuses boot on an OAuth redirect URI override off the web app origin
14613
15634
  * 1. Ensures admin account exists (creates if missing)
14614
15635
  * 2. Initializes RBAC system with built-in + custom roles/permissions
14615
15636
  * 3. Initializes one-time token manager
14616
15637
  */
14617
15638
  afterInfrastructure: async () => {
15639
+ assertOAuthRedirectUris();
14618
15640
  await initializeAuth(options);
14619
15641
  try {
14620
15642
  await normalizeStoredEmails();
@@ -14653,20 +15675,28 @@ export {
14653
15675
  AuthMetadataRepository,
14654
15676
  AuthProviderSchema,
14655
15677
  COOKIE_NAMES,
15678
+ CSRF_HEADER,
14656
15679
  DEFAULT_DELETION_ALLOW_SELF_IMMEDIATE,
14657
15680
  DEFAULT_DELETION_GRACE_PERIOD_DAYS,
14658
15681
  DEFAULT_DELETION_PURGE_CRON,
14659
15682
  DEFAULT_DELETION_PURGE_STRATEGY,
14660
15683
  DEFAULT_DELETION_SEND_NOTIFICATIONS,
15684
+ DEFAULT_DEVICE_AUTH_INTERVAL_MS,
15685
+ DEFAULT_DEVICE_AUTH_TTL_MS,
15686
+ DEVICE_AUTH_STATUSES,
15687
+ DeviceAuthPollResponseSchema,
15688
+ DeviceAuthorizationsRepository,
14661
15689
  DeviceNameSchema,
14662
15690
  EmailSchema,
14663
15691
  EnvironmentKeyringTokenCipher,
15692
+ FingerprintSchema,
14664
15693
  INVITATION_STATUSES,
14665
15694
  InvitationsRepository,
14666
15695
  KEY_ALGORITHM,
14667
15696
  KEY_DEVICE_NAME_MAX_LENGTH,
14668
15697
  KEY_FINGERPRINT_PREFIX_LENGTH,
14669
15698
  KEY_PLATFORM,
15699
+ KeyIdSchema,
14670
15700
  KeysRepository,
14671
15701
  OpsTokensRepository,
14672
15702
  PURGE_STRATEGIES,
@@ -14674,14 +15704,18 @@ export {
14674
15704
  PermissionsRepository,
14675
15705
  PhoneSchema,
14676
15706
  PlatformSchema,
15707
+ PublicKeySchema,
14677
15708
  RolePermissionsRepository,
14678
15709
  RolesRepository,
14679
15710
  SOCIAL_PROVIDERS,
14680
15711
  SignupLinkTokensRepository,
14681
15712
  SocialAccountsRepository,
14682
15713
  TargetTypeSchema,
15714
+ USER_CODE_ALPHABET,
15715
+ USER_CODE_LENGTH,
14683
15716
  USER_STATUSES,
14684
15717
  UnlinkNotifyRejection,
15718
+ UserCodeSchema,
14685
15719
  UserPermissionsRepository,
14686
15720
  UserProfilesRepository,
14687
15721
  UsersRepository,
@@ -14694,7 +15728,9 @@ export {
14694
15728
  accountDeletionRequestsRepository,
14695
15729
  addPermissionToRole,
14696
15730
  appleProvider,
15731
+ approveDeviceAuthService,
14697
15732
  assertCanAssignRole,
15733
+ assertKeyMatchesAlgorithm,
14698
15734
  authDeletionCancelledEvent,
14699
15735
  authDeletionCompletedEvent,
14700
15736
  authDeletionRequestedEvent,
@@ -14715,6 +15751,7 @@ export {
14715
15751
  completeSignupService,
14716
15752
  configureAuth,
14717
15753
  configureDeletion,
15754
+ configureDeviceAuth,
14718
15755
  configureOAuthTokenCipher,
14719
15756
  confirmSignupLinkService,
14720
15757
  createAuthDeletionJobRouter,
@@ -14727,20 +15764,31 @@ export {
14727
15764
  decryptToken,
14728
15765
  deleteInvitation,
14729
15766
  deleteRole,
15767
+ denyDeviceAuthService,
15768
+ deriveCsrfToken,
15769
+ deviceAuthorizations,
15770
+ deviceAuthorizationsRepository,
14730
15771
  encryptToken,
14731
15772
  exchangeCodeForTokens,
14732
15773
  expireOldInvitations,
15774
+ formatUserCode,
14733
15775
  generateClientToken,
15776
+ generateDeviceCode,
14734
15777
  generateKeyPair,
14735
15778
  generateKeyPairES256,
14736
15779
  generateKeyPairRS256,
14737
15780
  generateOAuthNonce,
14738
15781
  generateToken,
15782
+ generateUserCode,
14739
15783
  getAllRoles,
14740
15784
  getAuth,
14741
15785
  getAuthConfig,
14742
15786
  getAuthSessionService,
15787
+ getCsrfExemptPaths,
15788
+ getCsrfMode,
14743
15789
  getDeletionConfig,
15790
+ getDeviceAuthConfig,
15791
+ getDeviceAuthInfoService,
14744
15792
  getDummyPasswordHash,
14745
15793
  getEnabledOAuthProviders,
14746
15794
  getGoogleAccessToken,
@@ -14752,6 +15800,7 @@ export {
14752
15800
  getKeyId,
14753
15801
  getKeySize,
14754
15802
  getLocale,
15803
+ getMachinePrincipal,
14755
15804
  getOAuthProvider,
14756
15805
  getOneTimeTokenManager,
14757
15806
  getOpsToken,
@@ -14778,6 +15827,7 @@ export {
14778
15827
  hasAnyRole,
14779
15828
  hasPermission,
14780
15829
  hasRole,
15830
+ hashDeviceCode,
14781
15831
  hashPassword,
14782
15832
  initOneTimeTokenManager,
14783
15833
  initializeAuth,
@@ -14797,11 +15847,14 @@ export {
14797
15847
  listOpsTokensService,
14798
15848
  loginService,
14799
15849
  logoutService,
15850
+ machineAuth,
14800
15851
  matchOAuthCsrfCookies,
15852
+ matchesCsrfToken,
14801
15853
  naverProvider,
14802
15854
  normalizeEmail,
14803
15855
  normalizeOptionalEmail,
14804
15856
  normalizeStoredEmails,
15857
+ normalizeUserCode,
14805
15858
  oauthCallbackService,
14806
15859
  oauthNativeService,
14807
15860
  oauthStartService,
@@ -14815,9 +15868,11 @@ export {
14815
15868
  parseDuration,
14816
15869
  permissions,
14817
15870
  permissionsRepository,
15871
+ pollDeviceAuthService,
14818
15872
  purgeUserService,
14819
15873
  refreshAccessToken,
14820
15874
  registerAuthProfile,
15875
+ registerMachineVerifier,
14821
15876
  registerOAuthProvider,
14822
15877
  registerPublicKeyService,
14823
15878
  registerService,
@@ -14826,6 +15881,7 @@ export {
14826
15881
  requestSignupLinkService,
14827
15882
  requireAnyPermission,
14828
15883
  requireEnabledProvider,
15884
+ requireMachineScope,
14829
15885
  requireOpsScope,
14830
15886
  requirePermissions,
14831
15887
  requireRole,
@@ -14851,6 +15907,7 @@ export {
14851
15907
  signupLinkTokens,
14852
15908
  signupLinkTokensRepository,
14853
15909
  socialAccountsRepository,
15910
+ startDeviceAuthService,
14854
15911
  sweepDuePurges,
14855
15912
  unsealSession,
14856
15913
  updateLastLoginService,