@cats-inc/cats-platform 0.1.10 → 0.1.21

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.
Files changed (163) hide show
  1. package/.env.example +7 -10
  2. package/build/desktop/bootstrapPage.d.ts.map +1 -1
  3. package/build/desktop/bootstrapPage.js +162 -55
  4. package/build/desktop/bootstrapPage.js.map +1 -1
  5. package/build/desktop/cliInventoryProbe.d.ts +18 -0
  6. package/build/desktop/cliInventoryProbe.d.ts.map +1 -1
  7. package/build/desktop/cliInventoryProbe.js +42 -0
  8. package/build/desktop/cliInventoryProbe.js.map +1 -1
  9. package/build/desktop/contracts.d.ts +9 -0
  10. package/build/desktop/contracts.d.ts.map +1 -1
  11. package/build/desktop/main.js +101 -20
  12. package/build/desktop/main.js.map +1 -1
  13. package/build/desktop/readiness.d.ts.map +1 -1
  14. package/build/desktop/readiness.js +35 -2
  15. package/build/desktop/readiness.js.map +1 -1
  16. package/build/desktop/tray.d.ts +1 -1
  17. package/build/desktop/tray.d.ts.map +1 -1
  18. package/build/desktop/tray.js +2 -7
  19. package/build/desktop/tray.js.map +1 -1
  20. package/build/desktop/trayMenu.d.ts +23 -2
  21. package/build/desktop/trayMenu.d.ts.map +1 -1
  22. package/build/desktop/trayMenu.js +24 -30
  23. package/build/desktop/trayMenu.js.map +1 -1
  24. package/build/desktop/updateDialog.d.ts +40 -0
  25. package/build/desktop/updateDialog.d.ts.map +1 -0
  26. package/build/desktop/updateDialog.js +178 -0
  27. package/build/desktop/updateDialog.js.map +1 -0
  28. package/build/desktop/updateNotifications.js +2 -2
  29. package/build/desktop/updateNotifications.js.map +1 -1
  30. package/build/desktop/updaterAdapter.d.ts +1 -0
  31. package/build/desktop/updaterAdapter.d.ts.map +1 -1
  32. package/build/desktop/updaterAdapter.js +13 -1
  33. package/build/desktop/updaterAdapter.js.map +1 -1
  34. package/build/renderer/assets/{ActiveSessionPermissionChip-Dl2UOFgh.js → ActiveSessionPermissionChip-DImsuR9T.js} +1 -1
  35. package/build/renderer/assets/{App-CPqGDXVi.js → App-BfOvMP2m.js} +1 -1
  36. package/build/renderer/assets/{App-ChvP0G9X.js → App-DKOSn0o_.js} +1 -1
  37. package/build/renderer/assets/{App-C57f4p8k.js → App-DWd7I3Z2.js} +1 -1
  38. package/build/renderer/assets/{WorkspaceAppRoutes-DYg2vnjz.js → WorkspaceAppRoutes-CrbTA9En.js} +1 -1
  39. package/build/renderer/assets/app-BTww3Hl4.js +43 -0
  40. package/build/renderer/assets/{app-XnXwkcD2.css → app-CPxWQwLA.css} +1 -1
  41. package/build/renderer/assets/conversationSidebarRecentEntries-QuaOPxCb.js +6 -0
  42. package/build/renderer/assets/{useDraftSessionChips-By3XXabI.js → useDraftSessionChips-DCIId0eY.js} +1 -1
  43. package/build/renderer/index.html +2 -2
  44. package/build/server/app/renderer/auth/api.d.ts +28 -2
  45. package/build/server/app/renderer/auth/api.d.ts.map +1 -1
  46. package/build/server/app/renderer/auth/api.js +29 -4
  47. package/build/server/app/renderer/auth/api.js.map +1 -1
  48. package/build/server/app/renderer/settings/settingsDesktopUpdateLabels.d.ts +11 -4
  49. package/build/server/app/renderer/settings/settingsDesktopUpdateLabels.d.ts.map +1 -1
  50. package/build/server/app/renderer/settings/settingsDesktopUpdateLabels.js +25 -5
  51. package/build/server/app/renderer/settings/settingsDesktopUpdateLabels.js.map +1 -1
  52. package/build/server/app/server/authGatePolicy.js +1 -2
  53. package/build/server/app/server/authGatePolicy.js.map +1 -1
  54. package/build/server/app/server/authRoutes.d.ts +14 -1
  55. package/build/server/app/server/authRoutes.d.ts.map +1 -1
  56. package/build/server/app/server/authRoutes.js +395 -76
  57. package/build/server/app/server/authRoutes.js.map +1 -1
  58. package/build/server/app/server/contracts.d.ts +4 -1
  59. package/build/server/app/server/contracts.d.ts.map +1 -1
  60. package/build/server/app/server/dependencies.d.ts.map +1 -1
  61. package/build/server/app/server/dependencies.js +6 -1
  62. package/build/server/app/server/dependencies.js.map +1 -1
  63. package/build/server/app/server/platformSetupRoutes.d.ts +2 -1
  64. package/build/server/app/server/platformSetupRoutes.d.ts.map +1 -1
  65. package/build/server/app/server/platformSetupRoutes.js +129 -64
  66. package/build/server/app/server/platformSetupRoutes.js.map +1 -1
  67. package/build/server/app/server/requestRouter.d.ts.map +1 -1
  68. package/build/server/app/server/requestRouter.js +3 -0
  69. package/build/server/app/server/requestRouter.js.map +1 -1
  70. package/build/server/app/server/runtimeSurfaceProxy.d.ts.map +1 -1
  71. package/build/server/app/server/runtimeSurfaceProxy.js +12 -17
  72. package/build/server/app/server/runtimeSurfaceProxy.js.map +1 -1
  73. package/build/server/config.d.ts +1 -3
  74. package/build/server/config.d.ts.map +1 -1
  75. package/build/server/config.js +2 -13
  76. package/build/server/config.js.map +1 -1
  77. package/build/server/design/components/providerModelFieldsSupport.d.ts.map +1 -1
  78. package/build/server/design/components/providerModelFieldsSupport.js +4 -1
  79. package/build/server/design/components/providerModelFieldsSupport.js.map +1 -1
  80. package/build/server/design/components/useProviderTargetReconciliation.d.ts.map +1 -1
  81. package/build/server/design/components/useProviderTargetReconciliation.js +3 -1
  82. package/build/server/design/components/useProviderTargetReconciliation.js.map +1 -1
  83. package/build/server/platform/auth/actionGrant.d.ts +61 -0
  84. package/build/server/platform/auth/actionGrant.d.ts.map +1 -0
  85. package/build/server/platform/auth/actionGrant.js +108 -0
  86. package/build/server/platform/auth/actionGrant.js.map +1 -0
  87. package/build/server/platform/auth/adminCredentials.d.ts +37 -0
  88. package/build/server/platform/auth/adminCredentials.d.ts.map +1 -0
  89. package/build/server/platform/auth/adminCredentials.js +63 -0
  90. package/build/server/platform/auth/adminCredentials.js.map +1 -0
  91. package/build/server/platform/auth/bootstrap.d.ts +14 -0
  92. package/build/server/platform/auth/bootstrap.d.ts.map +1 -1
  93. package/build/server/platform/auth/bootstrap.js +29 -4
  94. package/build/server/platform/auth/bootstrap.js.map +1 -1
  95. package/build/server/platform/auth/config.d.ts.map +1 -1
  96. package/build/server/platform/auth/config.js +1 -1
  97. package/build/server/platform/auth/config.js.map +1 -1
  98. package/build/server/platform/auth/errorCodes.d.ts +2 -0
  99. package/build/server/platform/auth/errorCodes.d.ts.map +1 -1
  100. package/build/server/platform/auth/errorCodes.js +2 -0
  101. package/build/server/platform/auth/errorCodes.js.map +1 -1
  102. package/build/server/platform/auth/googleAccount.d.ts +40 -13
  103. package/build/server/platform/auth/googleAccount.d.ts.map +1 -1
  104. package/build/server/platform/auth/googleAccount.js +89 -70
  105. package/build/server/platform/auth/googleAccount.js.map +1 -1
  106. package/build/server/platform/auth/index.d.ts +4 -0
  107. package/build/server/platform/auth/index.d.ts.map +1 -1
  108. package/build/server/platform/auth/index.js +4 -0
  109. package/build/server/platform/auth/index.js.map +1 -1
  110. package/build/server/platform/auth/localLogin.d.ts +10 -0
  111. package/build/server/platform/auth/localLogin.d.ts.map +1 -1
  112. package/build/server/platform/auth/localLogin.js +17 -0
  113. package/build/server/platform/auth/localLogin.js.map +1 -1
  114. package/build/server/platform/auth/loginMethods.d.ts +18 -0
  115. package/build/server/platform/auth/loginMethods.d.ts.map +1 -0
  116. package/build/server/platform/auth/loginMethods.js +19 -0
  117. package/build/server/platform/auth/loginMethods.js.map +1 -0
  118. package/build/server/platform/auth/securityEvents.d.ts +31 -0
  119. package/build/server/platform/auth/securityEvents.d.ts.map +1 -0
  120. package/build/server/platform/auth/securityEvents.js +45 -0
  121. package/build/server/platform/auth/securityEvents.js.map +1 -0
  122. package/build/server/products/chat/api/contracts.d.ts +0 -8
  123. package/build/server/products/chat/api/contracts.d.ts.map +1 -1
  124. package/build/server/products/chat/api/setupRoutes.d.ts.map +1 -1
  125. package/build/server/products/chat/api/setupRoutes.js +2 -84
  126. package/build/server/products/chat/api/setupRoutes.js.map +1 -1
  127. package/build/server/products/shared/api/workspaceContracts.d.ts +0 -8
  128. package/build/server/products/shared/api/workspaceContracts.d.ts.map +1 -1
  129. package/build/server/runtime/client.d.ts +11 -0
  130. package/build/server/runtime/client.d.ts.map +1 -1
  131. package/build/server/runtime/client.js +23 -1
  132. package/build/server/runtime/client.js.map +1 -1
  133. package/build/server/shared/i18n/catalogs/en.d.ts.map +1 -1
  134. package/build/server/shared/i18n/catalogs/en.js +40 -0
  135. package/build/server/shared/i18n/catalogs/en.js.map +1 -1
  136. package/build/server/shared/i18n/catalogs/zh-TW.d.ts.map +1 -1
  137. package/build/server/shared/i18n/catalogs/zh-TW.js +39 -0
  138. package/build/server/shared/i18n/catalogs/zh-TW.js.map +1 -1
  139. package/build/server/shared/i18n/index.d.ts +39 -0
  140. package/build/server/shared/i18n/index.d.ts.map +1 -1
  141. package/build/server/shared/i18n/messageKeys.d.ts +39 -0
  142. package/build/server/shared/i18n/messageKeys.d.ts.map +1 -1
  143. package/build/server/shared/i18n/messageKeys.js +39 -0
  144. package/build/server/shared/i18n/messageKeys.js.map +1 -1
  145. package/build/server/shared/providerCatalog.d.ts +3 -2
  146. package/build/server/shared/providerCatalog.d.ts.map +1 -1
  147. package/build/server/shared/providerCatalog.js +9 -1
  148. package/build/server/shared/providerCatalog.js.map +1 -1
  149. package/build/server/shared/providerCatalogData.d.ts +11 -1
  150. package/build/server/shared/providerCatalogData.d.ts.map +1 -1
  151. package/build/server/shared/providerCatalogData.js +9 -0
  152. package/build/server/shared/providerCatalogData.js.map +1 -1
  153. package/build/server/shared/providerCatalogInstances.d.ts.map +1 -1
  154. package/build/server/shared/providerCatalogInstances.js +3 -0
  155. package/build/server/shared/providerCatalogInstances.js.map +1 -1
  156. package/package.json +1 -1
  157. package/scripts/README.md +30 -0
  158. package/scripts/linux/node-cli-common.sh +2 -75
  159. package/scripts/macos/node-cli-common.sh +2 -75
  160. package/scripts/windows/Check-WindowsSetupReadiness.ps1 +14 -443
  161. package/scripts/windows/Remove-MergedBranches.ps1 +229 -0
  162. package/build/renderer/assets/app-DVN9Ghrk.js +0 -43
  163. package/build/renderer/assets/conversationSidebarRecentEntries-XZVCnMLw.js +0 -6
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { generateSessionTokenMaterial, clearAllLoginThrottleState, collectLoginThrottleAlerts, createEmptyPlatformAuthState, createFirstAdminLocalAuthState, issueBrowserSession, isSessionChainActive, PlatformBrowserHandoffCapacityError, PLATFORM_BROWSER_HANDOFF_EXCHANGE_PATH, PLATFORM_BROWSER_HANDOFF_TTL_MS, normalizePlatformBrowserHandoffReturnTo, normalizeAccountIdentifier, authorizePlatformAuthRepairBootstrap, createFirstAdminGoogleAuthState, createLoginThrottleSubject, evaluateLoginThrottle, recordFailedLogin, recordSuccessfulLogin, resolveBrowserPrincipalFromToken, revokeSession, revokeSessionFamily, AUTH_SESSION_COOKIE_NAME, clearAuthSessionCookie, serializeAuthSessionCookie, summarizePlatformPrincipal, evaluatePreAuthOriginGate, validateCatsCsrfToken as validateCatsSessionCsrfToken, touchSession, createGoogleBrowserSessionForLinkedIdentity, linkGoogleIdentityToAccount, validateGoogleGisCsrfToken, verifyPlatformGoogleIdentityToken, verifyPlatformLocalPasswordCredential, } from '../../platform/auth/index.js';
2
+ import { generateSessionTokenMaterial, clearAllLoginThrottleState, collectLoginThrottleAlerts, createEmptyPlatformAuthState, createFirstAdminLocalAuthState, issueBrowserSession, isSessionChainActive, PlatformBrowserHandoffCapacityError, PLATFORM_BROWSER_HANDOFF_EXCHANGE_PATH, PLATFORM_BROWSER_HANDOFF_TTL_MS, normalizePlatformBrowserHandoffReturnTo, normalizeAccountIdentifier, authorizePlatformAuthRepairBootstrap, createLoginThrottleSubject, createPlatformAuthSecurityEvent, isPlatformAuthActionGrantPurpose, summarizePlatformLoginMethods, unlinkGoogleIdentityFromAccount, validatePlatformAdminCredentials, describePlatformAdminCredentialRejection, PLATFORM_AUTH_ACTION_HEADER, PLATFORM_AUTH_ERROR_CODES, PlatformAdminCredentialError, PlatformFirstAdminExistsError, evaluateLoginThrottle, recordFailedLogin, recordSuccessfulLogin, resolveBrowserPrincipalFromToken, revokeSession, revokeSessionFamily, AUTH_SESSION_COOKIE_NAME, clearAuthSessionCookie, serializeAuthSessionCookie, summarizePlatformPrincipal, evaluatePreAuthOriginGate, validateCatsCsrfToken as validateCatsSessionCsrfToken, touchSession, createGoogleBrowserSessionForLinkedIdentity, linkGoogleIdentityToAccount, validateGoogleGisCsrfToken, verifyPlatformGoogleIdentityToken, verifyPlatformLocalPasswordCredential, verifyPlatformLocalPasswordForAccount, } from '../../platform/auth/index.js';
3
3
  import { readJsonBody, sendJson, sendMethodNotAllowed, } from '../../shared/http.js';
4
4
  import { readGoogleCredentialRequestPayload } from './googleAuthRequest.js';
5
5
  import { sendPlatformAuthError } from './authErrorResponses.js';
@@ -57,7 +57,7 @@ export async function routePlatformAuthApi(context) {
57
57
  await handleGoogleLogin(context);
58
58
  return true;
59
59
  }
60
- if (context.url.pathname === '/api/auth/google/setup') {
60
+ if (context.url.pathname === '/api/auth/reauth') {
61
61
  if (context.method !== 'POST') {
62
62
  sendMethodNotAllowed(context.response, ['POST']);
63
63
  return true;
@@ -65,7 +65,7 @@ export async function routePlatformAuthApi(context) {
65
65
  if (!enforcePreAuthOriginGate(context)) {
66
66
  return true;
67
67
  }
68
- await handleGoogleSetup(context);
68
+ await handleLocalReauthentication(context);
69
69
  return true;
70
70
  }
71
71
  if (context.url.pathname === '/api/auth/google/link') {
@@ -73,9 +73,23 @@ export async function routePlatformAuthApi(context) {
73
73
  sendMethodNotAllowed(context.response, ['POST']);
74
74
  return true;
75
75
  }
76
+ if (!enforcePreAuthOriginGate(context)) {
77
+ return true;
78
+ }
76
79
  await handleGoogleLink(context);
77
80
  return true;
78
81
  }
82
+ if (context.url.pathname === '/api/auth/google/unlink') {
83
+ if (context.method !== 'POST') {
84
+ sendMethodNotAllowed(context.response, ['POST']);
85
+ return true;
86
+ }
87
+ if (!enforcePreAuthOriginGate(context)) {
88
+ return true;
89
+ }
90
+ await handleGoogleUnlink(context);
91
+ return true;
92
+ }
79
93
  if (context.url.pathname === '/api/auth/repair/first-admin') {
80
94
  if (context.method !== 'POST') {
81
95
  sendMethodNotAllowed(context.response, ['POST']);
@@ -369,10 +383,17 @@ async function handleRepairFirstAdmin(context) {
369
383
  sendAuthError(context.response, 400, 'E_FORBIDDEN', 'Invalid auth repair request body.');
370
384
  return;
371
385
  }
372
- if (typeof body.identifier !== 'string' || typeof body.password !== 'string') {
373
- sendAuthError(context.response, 400, 'E_FORBIDDEN', 'Identifier and password are required.');
386
+ // PLAN-104 Task 1.6: repair uses the same Admin credential validator as
387
+ // setup, without weakening the recovery-token/origin boundary below.
388
+ const credentialValidation = validatePlatformAdminCredentials({
389
+ identifier: body.identifier,
390
+ password: body.password,
391
+ });
392
+ if (!credentialValidation.ok) {
393
+ sendAuthError(context.response, 400, 'E_FORBIDDEN', describePlatformAdminCredentialRejection(credentialValidation.reason));
374
394
  return;
375
395
  }
396
+ const credentials = credentialValidation.credentials;
376
397
  const now = context.dependencies.now?.() ?? new Date();
377
398
  const authorization = authorizePlatformAuthRepairBootstrap({
378
399
  remoteAddress: readRemoteAddress(context.request),
@@ -387,26 +408,47 @@ async function handleRepairFirstAdmin(context) {
387
408
  sendAuthError(context.response, 403, 'E_FORBIDDEN', repairAuthorizationMessage(authorization.reason));
388
409
  return;
389
410
  }
390
- const created = await createFirstAdminLocalAuthState({
391
- state: createEmptyPlatformAuthState(now),
392
- displayName: typeof body.displayName === 'string' && body.displayName.trim()
393
- ? body.displayName
394
- : 'Owner',
395
- identifier: body.identifier,
396
- password: body.password,
397
- sessionSecret,
398
- sessionTtlMs: context.dependencies.auth.sessionTtlMs,
399
- now,
400
- });
401
- await context.dependencies.authStore.writeState(created.state);
411
+ let created;
412
+ try {
413
+ created = await createFirstAdminLocalAuthState({
414
+ state: createEmptyPlatformAuthState(now),
415
+ displayName: typeof body.displayName === 'string' && body.displayName.trim()
416
+ ? body.displayName
417
+ : 'Owner',
418
+ identifier: credentials.identifier,
419
+ password: credentials.password,
420
+ sessionSecret,
421
+ sessionTtlMs: context.dependencies.auth.sessionTtlMs,
422
+ now,
423
+ });
424
+ }
425
+ catch (error) {
426
+ if (error instanceof PlatformAdminCredentialError) {
427
+ sendAuthError(context.response, 400, 'E_FORBIDDEN', error.message);
428
+ return;
429
+ }
430
+ if (error instanceof PlatformFirstAdminExistsError) {
431
+ sendAuthError(context.response, 409, 'E_FORBIDDEN', 'Auth repair is not required.');
432
+ return;
433
+ }
434
+ throw error;
435
+ }
436
+ const repairedState = await context.dependencies.authStore.writeState(created.state);
402
437
  if (authorization.mode === 'recovery_token') {
403
438
  await context.dependencies.setAuthRecoveryTokenState?.(authorization.consumedTokenState);
404
439
  }
440
+ await reportAuthSecurityEvent(context, {
441
+ kind: 'first_admin_created',
442
+ outcome: 'success',
443
+ accountId: created.account.id,
444
+ sessionId: created.session.session.id,
445
+ reason: 'repair',
446
+ });
405
447
  sendJson(context.response, 200, buildAuthStatusPayload(context.dependencies.auth, {
406
448
  account: created.account,
407
449
  membership: created.membership,
408
450
  session: created.session.session,
409
- }, created.session.csrfToken), {
451
+ }, created.session.csrfToken, summarizePlatformLoginMethods(repairedState, created.account.id)), {
410
452
  'Set-Cookie': serializeAuthSessionCookie(created.session.token, context.dependencies.auth.sessionTtlMs),
411
453
  });
412
454
  }
@@ -476,7 +518,7 @@ async function handleAuthStatus(context) {
476
518
  now: context.dependencies.now?.() ?? new Date(),
477
519
  remoteAddress: readRemoteAddress(context.request),
478
520
  });
479
- await context.dependencies.authStore.updateState((state) => ({
521
+ const nextState = await context.dependencies.authStore.updateState((state) => ({
480
522
  ...state,
481
523
  sessions: state.sessions.map((session) => session.id === resolved.session.id
482
524
  ? {
@@ -491,7 +533,7 @@ async function handleAuthStatus(context) {
491
533
  ...touched,
492
534
  csrfTokenHash: csrf.tokenHash,
493
535
  },
494
- }, csrf.token));
536
+ }, csrf.token, summarizePlatformLoginMethods(nextState, resolved.account.id)));
495
537
  }
496
538
  async function handleLocalLogin(context) {
497
539
  const sessionSecret = context.dependencies.auth.sessionSecret;
@@ -546,7 +588,7 @@ async function handleLocalLogin(context) {
546
588
  ttlMs: context.dependencies.auth.sessionTtlMs,
547
589
  now,
548
590
  });
549
- await context.dependencies.authStore.updateState((current) => {
591
+ const nextState = await context.dependencies.authStore.updateState((current) => {
550
592
  const cleared = recordSuccessfulLogin(current, {
551
593
  subject: throttleSubject,
552
594
  now,
@@ -560,7 +602,7 @@ async function handleLocalLogin(context) {
560
602
  account: credential.account,
561
603
  membership: credential.membership,
562
604
  session: issued.session,
563
- }, issued.csrfToken), {
605
+ }, issued.csrfToken, summarizePlatformLoginMethods(nextState, credential.account.id)), {
564
606
  'Set-Cookie': serializeAuthSessionCookie(issued.token, context.dependencies.auth.sessionTtlMs),
565
607
  });
566
608
  }
@@ -605,7 +647,7 @@ async function handleGoogleLogin(context) {
605
647
  sendAuthError(context.response, 401, 'E_UNAUTHENTICATED', 'Google account is not linked.');
606
648
  return;
607
649
  }
608
- await context.dependencies.authStore.writeState(recordSuccessfulLogin(issued.state, {
650
+ const nextState = await context.dependencies.authStore.writeState(recordSuccessfulLogin(issued.state, {
609
651
  subject: throttleSubject,
610
652
  now,
611
653
  }));
@@ -613,25 +655,74 @@ async function handleGoogleLogin(context) {
613
655
  account: issued.account,
614
656
  membership: issued.membership,
615
657
  session: issued.session.session,
616
- }, issued.session.csrfToken), {
658
+ }, issued.session.csrfToken, summarizePlatformLoginMethods(nextState, issued.account.id)), {
617
659
  'Set-Cookie': serializeAuthSessionCookie(issued.session.token, context.dependencies.auth.sessionTtlMs),
618
660
  });
619
661
  }
620
- async function handleGoogleSetup(context) {
662
+ /**
663
+ * `POST /api/auth/reauth` — local-password step-up.
664
+ *
665
+ * ADR-111 section 3 requires the sensitive-action proof to be server-owned.
666
+ * The renderer's password modal is presentation; this route is the boundary.
667
+ */
668
+ async function handleLocalReauthentication(context) {
621
669
  const sessionSecret = context.dependencies.auth.sessionSecret;
622
- if (!sessionSecret) {
623
- sendAuthError(context.response, 503, 'E_FORBIDDEN', 'Google setup is not configured.');
670
+ const actionGrantStore = context.dependencies.actionGrantStore;
671
+ if (!sessionSecret || !actionGrantStore) {
672
+ sendAuthError(context.response, 503, 'E_FORBIDDEN', 'Reauthentication is not configured.');
624
673
  return;
625
674
  }
626
- const identity = await readVerifiedBrowserGoogleIdentity(context);
627
- if (!identity) {
675
+ const resolved = await resolveBrowserPrincipal(context);
676
+ if (!resolved) {
677
+ sendAuthError(context.response, 401, 'E_UNAUTHENTICATED', 'Authentication is required.');
678
+ return;
679
+ }
680
+ if (!enforceCatsCsrfToken(context, resolved.session)) {
681
+ return;
682
+ }
683
+ let body;
684
+ try {
685
+ body = await readJsonBody(context.request);
686
+ }
687
+ catch {
688
+ sendAuthError(context.response, 400, 'E_FORBIDDEN', 'Invalid reauthentication request body.');
689
+ return;
690
+ }
691
+ if (!isPlatformAuthActionGrantPurpose(body.purpose)) {
692
+ sendAuthError(context.response, 400, 'E_FORBIDDEN', 'A supported action purpose is required.');
693
+ return;
694
+ }
695
+ const purpose = body.purpose;
696
+ if (typeof body.password !== 'string' || !body.password) {
697
+ await reportAuthSecurityEvent(context, {
698
+ kind: 'step_up_failed',
699
+ outcome: 'failure',
700
+ accountId: resolved.account.id,
701
+ sessionId: resolved.session.id,
702
+ reason: 'password_required',
703
+ });
704
+ sendAuthError(context.response, 400, 'E_FORBIDDEN', 'Password is required.');
628
705
  return;
629
706
  }
630
- const state = await context.dependencies.authStore.readState();
631
707
  const now = context.dependencies.now?.() ?? new Date();
708
+ const state = await context.dependencies.authStore.readState();
709
+ const localIdentity = state.identities.find((identity) => identity.provider === 'local_password' && identity.accountId === resolved.account.id) ?? null;
710
+ if (!localIdentity) {
711
+ await reportAuthSecurityEvent(context, {
712
+ kind: 'step_up_failed',
713
+ outcome: 'failure',
714
+ accountId: resolved.account.id,
715
+ sessionId: resolved.session.id,
716
+ reason: 'local_password_missing',
717
+ });
718
+ sendAuthError(context.response, 403, 'E_REAUTH_REQUIRED', 'Local password is not available.');
719
+ return;
720
+ }
721
+ // SPEC-113 requirement 17: failed step-up feeds the same composite and
722
+ // aggregate throttle policy that guards ordinary local login.
632
723
  const throttleSubject = createLoginThrottleSubject({
633
- provider: 'google',
634
- accountKey: identity.providerSubject,
724
+ provider: 'local_password',
725
+ accountKey: localIdentity.providerSubject,
635
726
  remoteAddress: readRemoteAddress(context.request),
636
727
  });
637
728
  const throttle = evaluateLoginThrottle(state, {
@@ -646,31 +737,43 @@ async function handleGoogleSetup(context) {
646
737
  if (throttle.delayMs > 0) {
647
738
  await sleep(throttle.delayMs, context.dependencies.sleep);
648
739
  }
649
- let created;
650
- try {
651
- created = createFirstAdminGoogleAuthState({
652
- state,
653
- identity,
654
- sessionSecret,
655
- sessionTtlMs: context.dependencies.auth.sessionTtlMs,
656
- now,
740
+ const credential = await verifyPlatformLocalPasswordForAccount(state, {
741
+ accountId: resolved.account.id,
742
+ password: body.password,
743
+ });
744
+ if (!credential) {
745
+ await recordFailedLoginWithAlerts(context, throttleSubject, now);
746
+ await reportAuthSecurityEvent(context, {
747
+ kind: 'step_up_failed',
748
+ outcome: 'failure',
749
+ accountId: resolved.account.id,
750
+ sessionId: resolved.session.id,
751
+ reason: 'invalid_password',
657
752
  });
658
- }
659
- catch {
660
- sendAuthError(context.response, 409, 'E_FORBIDDEN', 'First admin already exists.');
753
+ sendAuthError(context.response, 401, 'E_UNAUTHENTICATED', 'Invalid credentials.');
661
754
  return;
662
755
  }
663
- await context.dependencies.authStore.writeState(recordSuccessfulLogin(created.state, {
664
- subject: throttleSubject,
756
+ await context.dependencies.authStore.updateState((current) => recordSuccessfulLogin(current, { subject: throttleSubject, now }));
757
+ const grant = actionGrantStore.issue({
758
+ accountId: resolved.account.id,
759
+ sessionId: resolved.session.id,
760
+ purpose,
761
+ sessionSecret,
665
762
  now,
666
- }));
667
- sendJson(context.response, 200, buildAuthStatusPayload(context.dependencies.auth, {
668
- account: created.account,
669
- membership: created.membership,
670
- session: created.session.session,
671
- }, created.session.csrfToken), {
672
- 'Set-Cookie': serializeAuthSessionCookie(created.session.token, context.dependencies.auth.sessionTtlMs),
673
763
  });
764
+ await reportAuthSecurityEvent(context, {
765
+ kind: 'step_up_succeeded',
766
+ outcome: 'success',
767
+ accountId: resolved.account.id,
768
+ sessionId: resolved.session.id,
769
+ reason: purpose,
770
+ });
771
+ const payload = {
772
+ purpose,
773
+ actionToken: grant.token,
774
+ expiresAt: grant.expiresAt,
775
+ };
776
+ sendJson(context.response, 200, payload);
674
777
  }
675
778
  async function handleGoogleLink(context) {
676
779
  const sessionSecret = context.dependencies.auth.sessionSecret;
@@ -686,50 +789,263 @@ async function handleGoogleLink(context) {
686
789
  if (!enforceCatsCsrfToken(context, resolved.session)) {
687
790
  return;
688
791
  }
792
+ if (!await enforceActionGrant(context, resolved, 'link_google')) {
793
+ return;
794
+ }
795
+ // The grant is spent from here on, whatever the outcome below
796
+ // (SPEC-113 requirement 33).
689
797
  const identity = await readVerifiedBrowserGoogleIdentity(context);
690
798
  if (!identity) {
799
+ await reportAuthSecurityEvent(context, {
800
+ kind: 'google_link_failed',
801
+ outcome: 'failure',
802
+ accountId: resolved.account.id,
803
+ sessionId: resolved.session.id,
804
+ reason: 'google_credential_rejected',
805
+ });
691
806
  return;
692
807
  }
693
808
  const now = context.dependencies.now?.() ?? new Date();
694
- let linked;
695
- try {
696
- linked = linkGoogleIdentityToAccount({
697
- state: await context.dependencies.authStore.readState(),
809
+ const csrf = generateSessionTokenMaterial(sessionSecret);
810
+ const touched = touchSession(resolved.session, {
811
+ now,
812
+ remoteAddress: readRemoteAddress(context.request),
813
+ });
814
+ let rejection = null;
815
+ const nextState = await context.dependencies.authStore.updateState((state) => {
816
+ const outcome = linkGoogleIdentityToAccount({
817
+ state,
698
818
  accountId: resolved.account.id,
699
819
  identity,
700
820
  now,
701
821
  });
822
+ if (!outcome.ok) {
823
+ rejection = outcome.reason;
824
+ return state;
825
+ }
826
+ return {
827
+ ...outcome.result.state,
828
+ sessions: outcome.result.state.sessions.map((session) => session.id === resolved.session.id
829
+ ? { ...touched, csrfTokenHash: csrf.tokenHash }
830
+ : session),
831
+ };
832
+ });
833
+ if (rejection !== null) {
834
+ await reportAuthSecurityEvent(context, {
835
+ kind: 'google_link_failed',
836
+ outcome: 'failure',
837
+ accountId: resolved.account.id,
838
+ sessionId: resolved.session.id,
839
+ reason: rejection,
840
+ });
841
+ const mapped = mapGoogleLinkRejection(rejection);
842
+ sendAuthError(context.response, mapped.status, mapped.code, mapped.message);
843
+ return;
702
844
  }
703
- catch {
704
- sendAuthError(context.response, 409, 'E_FORBIDDEN', 'Google account is already linked.');
845
+ await reportAuthSecurityEvent(context, {
846
+ kind: 'google_link_succeeded',
847
+ outcome: 'success',
848
+ accountId: resolved.account.id,
849
+ sessionId: resolved.session.id,
850
+ reason: null,
851
+ });
852
+ const account = nextState.accounts.find((candidate) => candidate.id === resolved.account.id) ?? resolved.account;
853
+ sendJson(context.response, 200, buildAuthStatusPayload(context.dependencies.auth, {
854
+ account,
855
+ membership: resolved.membership,
856
+ session: { ...touched, csrfTokenHash: csrf.tokenHash },
857
+ }, csrf.token, summarizePlatformLoginMethods(nextState, resolved.account.id)));
858
+ }
859
+ async function handleGoogleUnlink(context) {
860
+ const sessionSecret = context.dependencies.auth.sessionSecret;
861
+ if (!sessionSecret) {
862
+ sendAuthError(context.response, 503, 'E_FORBIDDEN', 'Google unlink is not configured.');
863
+ return;
864
+ }
865
+ const resolved = await resolveBrowserPrincipal(context);
866
+ if (!resolved) {
867
+ sendAuthError(context.response, 401, 'E_UNAUTHENTICATED', 'Authentication is required.');
705
868
  return;
706
869
  }
707
- if (!linked) {
708
- sendAuthError(context.response, 403, 'E_FORBIDDEN', 'Google account cannot be linked.');
870
+ if (!enforceCatsCsrfToken(context, resolved.session)) {
709
871
  return;
710
872
  }
873
+ if (!await enforceActionGrant(context, resolved, 'unlink_google')) {
874
+ return;
875
+ }
876
+ const now = context.dependencies.now?.() ?? new Date();
711
877
  const csrf = generateSessionTokenMaterial(sessionSecret);
712
878
  const touched = touchSession(resolved.session, {
713
879
  now,
714
880
  remoteAddress: readRemoteAddress(context.request),
715
881
  });
716
- await context.dependencies.authStore.writeState({
717
- ...linked.state,
718
- sessions: linked.state.sessions.map((session) => session.id === resolved.session.id
719
- ? {
720
- ...touched,
721
- csrfTokenHash: csrf.tokenHash,
722
- }
723
- : session),
882
+ let rejection = null;
883
+ let revokedSessionIds = [];
884
+ const nextState = await context.dependencies.authStore.updateState((state) => {
885
+ const outcome = unlinkGoogleIdentityFromAccount({
886
+ state,
887
+ accountId: resolved.account.id,
888
+ keepSessionId: resolved.session.id,
889
+ now,
890
+ });
891
+ if (!outcome.ok) {
892
+ rejection = outcome.reason;
893
+ return state;
894
+ }
895
+ revokedSessionIds = outcome.result.revokedSessionIds;
896
+ return {
897
+ ...outcome.result.state,
898
+ sessions: outcome.result.state.sessions.map((session) => session.id === resolved.session.id
899
+ ? { ...touched, csrfTokenHash: csrf.tokenHash }
900
+ : session),
901
+ };
902
+ });
903
+ if (rejection !== null) {
904
+ await reportAuthSecurityEvent(context, {
905
+ kind: 'google_unlink_failed',
906
+ outcome: 'failure',
907
+ accountId: resolved.account.id,
908
+ sessionId: resolved.session.id,
909
+ reason: rejection,
910
+ });
911
+ const mapped = mapGoogleUnlinkRejection(rejection);
912
+ sendAuthError(context.response, mapped.status, mapped.code, mapped.message);
913
+ return;
914
+ }
915
+ // Sessions that just lost their cookie must not keep a usable step-up
916
+ // capability either.
917
+ for (const sessionId of revokedSessionIds) {
918
+ context.dependencies.actionGrantStore?.revokeForSession(sessionId);
919
+ }
920
+ await reportAuthSecurityEvent(context, {
921
+ kind: 'google_unlink_succeeded',
922
+ outcome: 'success',
923
+ accountId: resolved.account.id,
924
+ sessionId: resolved.session.id,
925
+ reason: null,
724
926
  });
927
+ const account = nextState.accounts.find((candidate) => candidate.id === resolved.account.id) ?? resolved.account;
725
928
  sendJson(context.response, 200, buildAuthStatusPayload(context.dependencies.auth, {
726
- account: linked.account,
727
- membership: linked.membership,
728
- session: {
729
- ...touched,
730
- csrfTokenHash: csrf.tokenHash,
731
- },
732
- }, csrf.token));
929
+ account,
930
+ membership: resolved.membership,
931
+ session: { ...touched, csrfTokenHash: csrf.tokenHash },
932
+ }, csrf.token, summarizePlatformLoginMethods(nextState, resolved.account.id)));
933
+ }
934
+ /**
935
+ * Consumes the one-time action grant carried by `X-Cats-Auth-Action`.
936
+ * SPEC-113 requirement 21 rejects a token supplied through the URL, so the
937
+ * header is the only accepted transport.
938
+ */
939
+ async function enforceActionGrant(context, resolved, purpose) {
940
+ const sessionSecret = context.dependencies.auth.sessionSecret;
941
+ const actionGrantStore = context.dependencies.actionGrantStore;
942
+ if (!sessionSecret || !actionGrantStore) {
943
+ sendAuthError(context.response, 503, 'E_FORBIDDEN', 'Sensitive actions are not configured.');
944
+ return false;
945
+ }
946
+ if (context.url.searchParams.has('actionToken')) {
947
+ sendAuthError(context.response, 403, 'E_REAUTH_REQUIRED', 'Action token must be sent as a header.');
948
+ return false;
949
+ }
950
+ const header = context.request.headers[PLATFORM_AUTH_ACTION_HEADER];
951
+ const token = typeof header === 'string' ? header : '';
952
+ const outcome = actionGrantStore.consume({
953
+ token,
954
+ accountId: resolved.account.id,
955
+ sessionId: resolved.session.id,
956
+ purpose,
957
+ sessionSecret,
958
+ now: context.dependencies.now?.() ?? new Date(),
959
+ });
960
+ if (!outcome.ok) {
961
+ await reportAuthSecurityEvent(context, {
962
+ kind: 'step_up_failed',
963
+ outcome: 'failure',
964
+ accountId: resolved.account.id,
965
+ sessionId: resolved.session.id,
966
+ reason: outcome.reason,
967
+ });
968
+ sendAuthError(context.response, 403, 'E_REAUTH_REQUIRED', actionGrantRejectionMessage(outcome.reason));
969
+ return false;
970
+ }
971
+ return true;
972
+ }
973
+ function actionGrantRejectionMessage(reason) {
974
+ switch (reason) {
975
+ case 'missing_token':
976
+ return 'Password confirmation is required.';
977
+ case 'unknown_or_expired':
978
+ return 'Password confirmation has expired. Confirm your password again.';
979
+ case 'purpose_mismatch':
980
+ case 'account_mismatch':
981
+ case 'session_mismatch':
982
+ return 'Password confirmation is not valid for this action.';
983
+ }
984
+ }
985
+ function mapGoogleLinkRejection(reason) {
986
+ switch (reason) {
987
+ case 'account_not_found':
988
+ return {
989
+ status: 403,
990
+ code: PLATFORM_AUTH_ERROR_CODES.forbidden,
991
+ message: 'Google account cannot be linked.',
992
+ };
993
+ case 'email_mismatch':
994
+ return {
995
+ status: 409,
996
+ code: PLATFORM_AUTH_ERROR_CODES.identityConflict,
997
+ message: 'The Google account email does not match this Cats account.',
998
+ };
999
+ case 'subject_owned_by_other_account':
1000
+ return {
1001
+ status: 409,
1002
+ code: PLATFORM_AUTH_ERROR_CODES.identityConflict,
1003
+ message: 'That Google account is already linked to another Cats account.',
1004
+ };
1005
+ case 'account_has_other_google_identity':
1006
+ return {
1007
+ status: 409,
1008
+ code: PLATFORM_AUTH_ERROR_CODES.identityConflict,
1009
+ message: 'This Cats account already has a different Google account linked.',
1010
+ };
1011
+ }
1012
+ }
1013
+ function mapGoogleUnlinkRejection(reason) {
1014
+ switch (reason) {
1015
+ case 'account_not_found':
1016
+ return {
1017
+ status: 403,
1018
+ code: PLATFORM_AUTH_ERROR_CODES.forbidden,
1019
+ message: 'Google account cannot be unlinked.',
1020
+ };
1021
+ case 'google_not_linked':
1022
+ return {
1023
+ status: 409,
1024
+ code: PLATFORM_AUTH_ERROR_CODES.identityConflict,
1025
+ message: 'No Google account is linked.',
1026
+ };
1027
+ case 'local_fallback_missing':
1028
+ return {
1029
+ status: 409,
1030
+ code: PLATFORM_AUTH_ERROR_CODES.identityConflict,
1031
+ message: 'Set a local password before unlinking Google.',
1032
+ };
1033
+ }
1034
+ }
1035
+ async function reportAuthSecurityEvent(context, input) {
1036
+ const reporter = context.dependencies.reportAuthSecurityEvent;
1037
+ if (!reporter) {
1038
+ return;
1039
+ }
1040
+ try {
1041
+ await reporter(createPlatformAuthSecurityEvent({
1042
+ ...input,
1043
+ now: context.dependencies.now?.() ?? new Date(),
1044
+ }));
1045
+ }
1046
+ catch (error) {
1047
+ console.warn('[cats-platform] auth security event reporter failed', error);
1048
+ }
733
1049
  }
734
1050
  async function readVerifiedBrowserGoogleIdentity(context) {
735
1051
  const googleClientId = context.dependencies.auth.google.clientId;
@@ -829,7 +1145,7 @@ async function resolveBrowserPrincipal(context) {
829
1145
  now: context.dependencies.now?.() ?? new Date(),
830
1146
  });
831
1147
  }
832
- function buildAuthStatusPayload(auth, resolved, csrfToken) {
1148
+ function buildAuthStatusPayload(auth, resolved, csrfToken, loginMethods = null) {
833
1149
  return {
834
1150
  authenticated: resolved !== null,
835
1151
  principal: resolved ? summarizePrincipal(resolved) : null,
@@ -840,6 +1156,9 @@ function buildAuthStatusPayload(auth, resolved, csrfToken) {
840
1156
  clientId: auth.google.clientId,
841
1157
  },
842
1158
  },
1159
+ // Requirement 11: an unauthenticated response never discloses whether an
1160
+ // account or provider identity exists.
1161
+ loginMethods: resolved ? loginMethods : null,
843
1162
  };
844
1163
  }
845
1164
  function summarizePrincipal(resolved) {