@taskforcehq/taskforce 0.3.305 → 0.3.306
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/Taskforce.module.css +295 -89
- package/dist/TaskforceCore.js +215 -43
- package/dist/TaskforceCore.test.js +433 -28
- package/dist/assets/fonts/CourierPrime-Bold.woff2 +0 -0
- package/dist/assets/fonts/CourierPrime-BoldItalic.woff2 +0 -0
- package/dist/assets/fonts/CourierPrime-Italic.woff2 +0 -0
- package/dist/assets/fonts/CourierPrime-Regular.woff2 +0 -0
- package/dist/assets/fonts/Noir_medium.woff2 +0 -0
- package/dist/assets/fonts/Noir_regular.woff2 +0 -0
- package/dist/assets/fonts/RussoOne-Regular.woff2 +0 -0
- package/dist/assets/fonts/SpecialElite-Regular.woff2 +0 -0
- package/dist/compat/workspaceSyncCompat.js +46 -2
- package/dist/compat/workspaceSyncCompat.test.js +38 -1
- package/dist/components/context/ContextAttachmentManager.d.ts +14 -0
- package/dist/components/context/ContextAttachmentManager.js +549 -0
- package/dist/components/features/AgentsModule.d.ts +10 -1
- package/dist/components/features/AgentsModule.js +260 -15
- package/dist/components/features/AgentsModule.test.js +255 -5
- package/dist/components/features/DocumentViewer.d.ts +5 -1
- package/dist/components/features/DocumentViewer.js +60 -3
- package/dist/components/features/DocumentWorkspace.js +1 -1
- package/dist/components/features/DocumentWorkspace.test.js +33 -0
- package/dist/components/features/documentWorkspaceModel.d.ts +1 -1
- package/dist/components/task/TaskCard.js +19 -28
- package/dist/components/task/TaskCard.test.js +38 -3
- package/dist/components/task/TaskContextUpload.js +4 -526
- package/dist/components/task/TaskForm.js +85 -90
- package/dist/components/task/TaskForm.test.js +110 -8
- package/dist/components/task/TaskKanban.d.ts +2 -1
- package/dist/components/task/TaskKanban.js +94 -13
- package/dist/components/task/TaskKanban.test.js +36 -0
- package/dist/components/ui/EditableAvatarButton.d.ts +18 -0
- package/dist/components/ui/EditableAvatarButton.js +18 -0
- package/dist/components/views/PlanComparisonPage.d.ts +9 -1
- package/dist/components/views/PlanComparisonPage.js +70 -14
- package/dist/components/views/PlanComparisonPage.test.js +238 -0
- package/dist/components/views/PlansPage.js +127 -36
- package/dist/components/views/PlansPage.test.js +294 -12
- package/dist/components/views/StandaloneLayout.js +283 -533
- package/dist/components/views/panels/PlanningDrawer.d.ts +8 -1
- package/dist/components/views/panels/PlanningDrawer.js +18 -11
- package/dist/components/views/panels/PlanningDrawer.test.d.ts +1 -0
- package/dist/components/views/panels/PlanningDrawer.test.js +141 -0
- package/dist/components/views/planningScope.d.ts +20 -0
- package/dist/components/views/planningScope.js +25 -0
- package/dist/components/views/planningScope.test.d.ts +1 -0
- package/dist/components/views/planningScope.test.js +60 -0
- package/dist/components/views/standalone/modals/AccountHubModal.d.ts +26 -1
- package/dist/components/views/standalone/modals/AccountHubModal.js +76 -2
- package/dist/components/views/standalone/modals/AccountHubModal.test.js +20 -3
- package/dist/components/views/standalone/modals/AvatarImageManagerModal.d.ts +20 -0
- package/dist/components/views/standalone/modals/AvatarImageManagerModal.js +127 -0
- package/dist/components/views/standalone/modals/EditProfileModal.d.ts +2 -9
- package/dist/components/views/standalone/modals/EditProfileModal.js +3 -2
- package/dist/components/views/standalone/modals/SyncEnableWarningModal.d.ts +9 -0
- package/dist/components/views/standalone/modals/SyncEnableWarningModal.js +6 -0
- package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.d.ts +1 -0
- package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.js +24 -0
- package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +2 -1
- package/dist/components/views/standalone/modals/SyncStatusModal.js +2 -2
- package/dist/core/AiProfileService.d.ts +39 -2
- package/dist/core/AiProfileService.js +639 -73
- package/dist/core/AiProfileService.test.js +19 -1
- package/dist/core/AiProfiles.test.js +566 -7
- package/dist/core/AttachmentLinkService.js +57 -15
- package/dist/core/AuthIdentityService.d.ts +107 -0
- package/dist/core/AuthIdentityService.js +284 -0
- package/dist/core/AuthTokenService.d.ts +4 -0
- package/dist/core/AuthTokenService.js +34 -6
- package/dist/core/AuthTokenService.test.js +3 -3
- package/dist/core/EntitlementsPolicy.test.js +2 -2
- package/dist/core/GlobalSettingsService.js +115 -6
- package/dist/core/PlanEntitlementService.d.ts +23 -2
- package/dist/core/PlanEntitlementService.js +343 -57
- package/dist/core/PlanFeatureCatalog.test.d.ts +1 -0
- package/dist/core/PlanFeatureCatalog.test.js +84 -0
- package/dist/core/PlanVersionPolicy.test.js +35 -2
- package/dist/core/PlanningEntities.test.js +52 -0
- package/dist/core/SignupMonetizationSettings.test.js +199 -27
- package/dist/core/SyncReconciliationService.js +2 -65
- package/dist/core/SystemAdmin.test.js +42 -42
- package/dist/core/TaskAttachmentsCanonical.test.js +51 -1
- package/dist/core/Taskforce.d.ts +225 -14
- package/dist/core/Taskforce.js +765 -192
- package/dist/core/Taskforce.listTasksSlim.test.d.ts +1 -0
- package/dist/core/Taskforce.listTasksSlim.test.js +91 -0
- package/dist/core/UserProfileAvatarDrafts.test.js +30 -3
- package/dist/core/WorkspaceLifecycleService.js +0 -5
- package/dist/core/WorkspacePermissions.test.js +25 -1
- package/dist/core/WorkspacePlanMode.test.js +117 -16
- package/dist/core/shared.d.ts +1 -1
- package/dist/core/shared.js +5 -1
- package/dist/core/types.d.ts +68 -2
- package/dist/hooks/auth/useTaskforceAuthBootstrap.js +26 -1
- package/dist/hooks/sync/bootstrap.js +2 -0
- package/dist/hooks/sync/controlPlane.d.ts +37 -0
- package/dist/hooks/sync/controlPlane.js +78 -0
- package/dist/hooks/sync/controlPlane.test.d.ts +1 -0
- package/dist/hooks/sync/controlPlane.test.js +121 -0
- package/dist/hooks/sync/lifecyclePolicy.d.ts +45 -0
- package/dist/hooks/sync/lifecyclePolicy.js +93 -0
- package/dist/hooks/sync/lifecyclePolicy.test.d.ts +1 -0
- package/dist/hooks/sync/lifecyclePolicy.test.js +124 -0
- package/dist/hooks/sync/orchestratorShared.d.ts +10 -1
- package/dist/hooks/sync/orchestratorShared.js +68 -47
- package/dist/hooks/sync/orchestratorShared.test.js +50 -1
- package/dist/hooks/sync/pullLifecycle.d.ts +31 -0
- package/dist/hooks/sync/pullLifecycle.js +24 -0
- package/dist/hooks/sync/pullLifecycle.test.d.ts +1 -0
- package/dist/hooks/sync/pullLifecycle.test.js +80 -0
- package/dist/hooks/sync/recovery.d.ts +16 -2
- package/dist/hooks/sync/recovery.js +171 -53
- package/dist/hooks/sync/recovery.test.d.ts +1 -0
- package/dist/hooks/sync/recovery.test.js +292 -0
- package/dist/hooks/sync/transfers.d.ts +15 -3
- package/dist/hooks/sync/transfers.js +179 -62
- package/dist/hooks/sync/transfers.test.d.ts +1 -0
- package/dist/hooks/sync/transfers.test.js +396 -0
- package/dist/hooks/sync/useRecentSyncEvents.d.ts +21 -0
- package/dist/hooks/sync/useRecentSyncEvents.js +92 -0
- package/dist/hooks/sync/useRecentSyncEvents.test.d.ts +1 -0
- package/dist/hooks/sync/useRecentSyncEvents.test.js +124 -0
- package/dist/hooks/sync/useSyncStatusActions.d.ts +30 -0
- package/dist/hooks/sync/useSyncStatusActions.js +88 -0
- package/dist/hooks/sync/useSyncStatusActions.test.d.ts +1 -0
- package/dist/hooks/sync/useSyncStatusActions.test.js +133 -0
- package/dist/hooks/sync/useSyncStatusControls.d.ts +25 -0
- package/dist/hooks/sync/useSyncStatusControls.js +60 -0
- package/dist/hooks/sync/useSyncStatusControls.test.d.ts +1 -0
- package/dist/hooks/sync/useSyncStatusControls.test.js +88 -0
- package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +12 -0
- package/dist/hooks/useSyncOrchestrator.d.ts +9 -2
- package/dist/hooks/useSyncOrchestrator.js +359 -178
- package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +407 -0
- package/dist/hooks/useTaskData.js +8 -3
- package/dist/hooks/useTaskData.test.js +45 -0
- package/dist/hooks/useTaskforce.d.ts +60 -2
- package/dist/hooks/useTaskforce.js +549 -240
- package/dist/hooks/useTaskforce.runtime-routing.test.d.ts +1 -0
- package/dist/hooks/useTaskforce.runtime-routing.test.js +152 -0
- package/dist/hooks/useTaskforce.sync-behavior.test.js +2380 -212
- package/dist/hooks/useWorkspaceSyncController.d.ts +1 -0
- package/dist/hooks/useWorkspaceSyncController.js +6 -1
- package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +16 -0
- package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +316 -35
- package/dist/localization/locales/en-US.d.ts +0 -1
- package/dist/localization/locales/en-US.js +0 -1
- package/dist/localization/locales/es-419.js +0 -1
- package/dist/localization/locales/pt-BR.js +0 -1
- package/dist/mcp/documentAssetRegistrar.js +3 -3
- package/dist/mcp/runtime.d.ts +2 -0
- package/dist/mcp/runtime.js +131 -64
- package/dist/mcp/runtime.test.js +291 -12
- package/dist/migrations/taskSchemaMigrations.d.ts +1 -1
- package/dist/migrations/taskSchemaMigrations.js +171 -61
- package/dist/migrations/taskSchemaMigrations.test.js +15 -0
- package/dist/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
- package/dist/resources/templates/workflows/collaborate.yaml +1 -1
- package/dist/resources/templates/workflows/evaluate.yaml +2 -2
- package/dist/resources/templates/workflows/plan.yaml +1 -1
- package/dist/resources/templates/workflows/review.yaml +2 -2
- package/dist/server/annotatedAttachmentsRoutes.test.js +3 -3
- package/dist/server/auth/providers/apple.d.ts +33 -0
- package/dist/server/auth/providers/apple.js +82 -0
- package/dist/server/auth/providers/appleClientSecret.d.ts +37 -0
- package/dist/server/auth/providers/appleClientSecret.js +65 -0
- package/dist/server/auth/providers/github.d.ts +26 -0
- package/dist/server/auth/providers/github.js +86 -0
- package/dist/server/auth/providers/google.d.ts +21 -0
- package/dist/server/auth/providers/google.js +56 -0
- package/dist/server/auth/providers/types.d.ts +21 -0
- package/dist/server/auth/providers/types.js +1 -0
- package/dist/server/auth.d.ts +2 -0
- package/dist/server/auth.js +6 -3
- package/dist/server/documentReviewRoutes.test.js +36 -3
- package/dist/server/index.cookieProxy.test.js +1 -0
- package/dist/server/index.d.ts +12 -0
- package/dist/server/index.js +120 -9
- package/dist/server/index.rateLimit.test.js +3 -0
- package/dist/server/index.test.js +106 -3
- package/dist/server/routes/admin.d.ts +3 -0
- package/dist/server/routes/admin.js +272 -19
- package/dist/server/routes/auth.d.ts +15 -2
- package/dist/server/routes/auth.js +994 -57
- package/dist/server/routes/authSupport.d.ts +1 -1
- package/dist/server/routes/authSupport.js +1 -1
- package/dist/server/routes/billing.js +361 -35
- package/dist/server/routes/billing.test.js +592 -12
- package/dist/server/routes/documents.d.ts +1 -0
- package/dist/server/routes/documents.js +172 -70
- package/dist/server/routes/shared.d.ts +28 -2
- package/dist/server/routes/shared.js +69 -4
- package/dist/server/routes/sync.integration.test.js +437 -39
- package/dist/server/routes/sync.js +61 -605
- package/dist/server/routes/syncAuxRoutes.d.ts +15 -0
- package/dist/server/routes/syncAuxRoutes.js +91 -0
- package/dist/server/routes/syncAuxRoutes.test.d.ts +1 -0
- package/dist/server/routes/syncAuxRoutes.test.js +158 -0
- package/dist/server/routes/syncPullApplyRoutes.d.ts +36 -0
- package/dist/server/routes/syncPullApplyRoutes.js +204 -0
- package/dist/server/routes/syncPushRoutes.d.ts +24 -0
- package/dist/server/routes/syncPushRoutes.js +212 -0
- package/dist/server/routes/syncRouteGuards.d.ts +36 -0
- package/dist/server/routes/syncRouteGuards.js +67 -0
- package/dist/server/routes/syncRouteGuards.test.d.ts +1 -0
- package/dist/server/routes/syncRouteGuards.test.js +94 -0
- package/dist/server/routes/syncRouteTypes.d.ts +13 -0
- package/dist/server/routes/syncRouteTypes.js +1 -0
- package/dist/server/routes/syncSnapshotRoutes.d.ts +66 -0
- package/dist/server/routes/syncSnapshotRoutes.js +180 -0
- package/dist/server/routes/tasks.js +43 -1
- package/dist/server/routes/workspaces.js +36 -5
- package/dist/server/routes.js +60 -3
- package/dist/server/routes.test.js +1824 -171
- package/dist/services/workflowExportSnapshots.test.js +2 -0
- package/dist/shared/aiProfileSeatScope.d.ts +5 -0
- package/dist/shared/aiProfileSeatScope.js +21 -0
- package/dist/shared/passwordPolicy.d.ts +13 -0
- package/dist/shared/passwordPolicy.js +84 -0
- package/dist/shared/passwordPolicy.test.d.ts +1 -0
- package/dist/shared/passwordPolicy.test.js +34 -0
- package/dist/storage/workspaceAssetStore.d.ts +15 -3
- package/dist/storage/workspaceAssetStore.js +132 -44
- package/dist/storage/workspaceAssetStore.test.js +93 -6
- package/dist/styles/fonts.css +70 -0
- package/dist/sync/collaborationSyncPayload.d.ts +6 -1
- package/dist/sync/collaborationSyncPayload.js +17 -11
- package/dist/sync/collaborationSyncPayload.test.js +13 -1
- package/dist/sync/contentSyncState.d.ts +4 -8
- package/dist/sync/contentSyncState.js +17 -70
- package/dist/sync/syncApplyHandlers.d.ts +7 -0
- package/dist/sync/syncApplyHandlers.js +28 -13
- package/dist/sync/syncApplyHandlers.test.js +70 -3
- package/dist/sync/syncFieldSemantics.d.ts +12 -0
- package/dist/sync/syncFieldSemantics.js +55 -0
- package/dist/sync/syncResourceAdapters.d.ts +44 -0
- package/dist/sync/syncResourceAdapters.js +77 -0
- package/dist/sync/syncService.d.ts +5 -0
- package/dist/sync/syncService.js +52 -0
- package/dist/sync/syncService.test.js +57 -3
- package/dist/sync/taskSyncChangeKey.d.ts +2 -0
- package/dist/sync/taskSyncChangeKey.js +143 -0
- package/dist/sync/taskSyncPayload.js +3 -8
- package/dist/sync/workspacePullFeed.js +4 -3
- package/dist/sync/workspacePullFeed.test.js +43 -0
- package/dist/sync/workspaceRepair.js +5 -2
- package/dist/sync/workspaceSyncModel.d.ts +45 -0
- package/dist/sync/workspaceSyncModel.js +183 -28
- package/dist/sync/workspaceSyncModel.test.js +291 -12
- package/dist/sync/workspaceSyncState.d.ts +17 -0
- package/dist/sync/workspaceSyncState.js +2 -1
- package/dist/sync/workspaceSyncSurface.js +3 -8
- package/dist/types.d.ts +17 -0
- package/dist/ui/assets/AgentsModule-DrLawwNl.js +1 -0
- package/dist/ui/assets/{AnnotatedAttachmentWorkspace-BG6P_GVW.js → AnnotatedAttachmentWorkspace-Z9_whf7F.js} +1 -1
- package/dist/ui/assets/CourierPrime-Bold-BuLj_dpw.woff2 +0 -0
- package/dist/ui/assets/CourierPrime-BoldItalic-BrK2pUkZ.woff2 +0 -0
- package/dist/ui/assets/CourierPrime-Italic-DFUZK5Ey.woff2 +0 -0
- package/dist/ui/assets/CourierPrime-Regular-BsKphzVf.woff2 +0 -0
- package/dist/ui/assets/DocumentWorkspace-B03MaSkw.js +252 -0
- package/dist/ui/assets/DocumentWorkspace-Dhdso07p.css +1 -0
- package/dist/ui/assets/{InitiativesModule-Dhm7M8e7.js → InitiativesModule-9E6ParrY.js} +1 -1
- package/dist/ui/assets/Noir_medium-bxQwKGzB.woff2 +0 -0
- package/dist/ui/assets/Noir_regular-ojf2kxlG.woff2 +0 -0
- package/dist/ui/assets/PlansPage-CvfnMiWq.css +1 -0
- package/dist/ui/assets/PlansPage-DRXscYxH.js +1 -0
- package/dist/ui/assets/RussoOne-Regular-Cu_qq_qC.woff2 +0 -0
- package/dist/ui/assets/SpecialElite-Regular-Di6gSvAS.woff2 +0 -0
- package/dist/ui/assets/{TaskSettings-ln0aF7xc.js → TaskSettings-DZX4jk7e.js} +1 -1
- package/dist/ui/assets/{WorkflowsModule-BcS4afRn.js → WorkflowsModule-BA7jcEpH.js} +1 -1
- package/dist/ui/assets/index-9eT8e0Lu.css +1 -0
- package/dist/ui/assets/index-C6k75jr8.js +6 -0
- package/dist/ui/assets/{vendor-icons-BSUaRwF8.js → vendor-icons-DuEd_65c.js} +1 -1
- package/dist/ui/fonts/CourierPrime-Bold.ttf +0 -0
- package/dist/ui/fonts/CourierPrime-Bold.woff2 +0 -0
- package/dist/ui/fonts/CourierPrime-BoldItalic.ttf +0 -0
- package/dist/ui/fonts/CourierPrime-BoldItalic.woff2 +0 -0
- package/dist/ui/fonts/CourierPrime-Italic.ttf +0 -0
- package/dist/ui/fonts/CourierPrime-Italic.woff2 +0 -0
- package/dist/ui/fonts/CourierPrime-Regular.ttf +0 -0
- package/dist/ui/fonts/CourierPrime-Regular.woff2 +0 -0
- package/dist/ui/fonts/Noir_medium.otf +0 -0
- package/dist/ui/fonts/Noir_medium.woff2 +0 -0
- package/dist/ui/fonts/Noir_regular.otf +0 -0
- package/dist/ui/fonts/Noir_regular.woff2 +0 -0
- package/dist/ui/fonts/RussoOne-Regular.woff2 +0 -0
- package/dist/ui/fonts/SpecialElite-Regular.ttf +0 -0
- package/dist/ui/fonts/SpecialElite-Regular.woff2 +0 -0
- package/dist/ui/index.html +3 -3
- package/dist/utils/accountProfileSummaryCache.d.ts +5 -0
- package/dist/utils/assignees.d.ts +2 -0
- package/dist/utils/assignees.js +2 -0
- package/dist/utils/avatarUpload.d.ts +27 -0
- package/dist/utils/avatarUpload.js +132 -0
- package/dist/utils/avatarUpload.test.d.ts +1 -0
- package/dist/utils/avatarUpload.test.js +40 -0
- package/dist/utils/bootstrapTrace.d.ts +7 -0
- package/dist/utils/bootstrapTrace.js +29 -0
- package/dist/utils/commercialLifecyclePresentation.js +3 -0
- package/dist/utils/contextAssetEvents.d.ts +2 -0
- package/dist/utils/syncEventDetails.d.ts +3 -0
- package/dist/utils/syncEventDetails.js +64 -0
- package/dist/utils/syncEventDetails.test.d.ts +1 -0
- package/dist/utils/syncEventDetails.test.js +23 -0
- package/dist/utils/syncEventPresentation.d.ts +15 -0
- package/dist/utils/syncEventPresentation.js +129 -0
- package/dist/utils/syncEventPresentation.test.d.ts +1 -0
- package/dist/utils/syncEventPresentation.test.js +64 -0
- package/dist/utils/syncStatusPresentation.d.ts +72 -0
- package/dist/utils/syncStatusPresentation.js +217 -0
- package/dist/utils/syncStatusPresentation.test.d.ts +1 -0
- package/dist/utils/syncStatusPresentation.test.js +164 -0
- package/dist/utils/taskActivity.d.ts +2 -0
- package/dist/utils/taskActivity.js +228 -22
- package/dist/utils/taskActivity.test.js +119 -8
- package/dist/utils/workspaceSyncPresentation.d.ts +4 -0
- package/dist/utils/workspaceSyncPresentation.js +68 -2
- package/dist/utils/workspaceSyncPresentation.test.js +36 -1
- package/package.json +3 -2
- package/scripts/export-sqlite-to-postgres.mjs +7 -9
- package/scripts/run-e2e-realtime.sh +17 -3
- package/scripts/run-smoke.sh +17 -5
- package/scripts/run-soak.sh +17 -5
- package/src/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
- package/src/resources/templates/workflows/collaborate.yaml +1 -1
- package/src/resources/templates/workflows/evaluate.yaml +2 -2
- package/src/resources/templates/workflows/plan.yaml +1 -1
- package/src/resources/templates/workflows/review.yaml +2 -2
- package/dist/ui/assets/AgentsModule-N2MnQBZk.js +0 -1
- package/dist/ui/assets/DocumentWorkspace-B3nV_Gc5.css +0 -1
- package/dist/ui/assets/DocumentWorkspace-DRbDMwh8.js +0 -252
- package/dist/ui/assets/PlansPage-C2dd2n1X.css +0 -1
- package/dist/ui/assets/PlansPage-Cq0zXj3I.js +0 -1
- package/dist/ui/assets/RussoOne-Regular-C3BxZIj7.ttf +0 -0
- package/dist/ui/assets/index-C2-OXZV7.css +0 -1
- package/dist/ui/assets/index-DcSI5F86.js +0 -6
- package/scripts/migrate-planning-model.ts +0 -233
|
@@ -6,9 +6,20 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Routes registered here:
|
|
8
8
|
* GET /api/taskforce/auth/runtime-config
|
|
9
|
+
* GET /api/taskforce/auth/providers
|
|
10
|
+
* GET /api/taskforce/auth/oauth/:provider/start
|
|
11
|
+
* GET /api/taskforce/auth/oauth/:provider/callback
|
|
12
|
+
* POST /api/taskforce/auth/oauth/apple/callback
|
|
13
|
+
* GET /api/taskforce/account/login-methods
|
|
14
|
+
* DELETE /api/taskforce/account/login-methods/:provider
|
|
15
|
+
* POST /api/taskforce/account/login-methods/password
|
|
9
16
|
* GET /api/taskforce/auth/session
|
|
10
17
|
* GET /api/taskforce/auth/admin-session
|
|
11
18
|
* POST /api/taskforce/auth/profile
|
|
19
|
+
* POST /api/taskforce/auth/profile/avatar/init
|
|
20
|
+
* POST /api/taskforce/auth/profile/avatar/upload
|
|
21
|
+
* POST /api/taskforce/auth/profile/avatar/finalize
|
|
22
|
+
* POST /api/taskforce/auth/profile/avatar/discard
|
|
12
23
|
* POST /api/taskforce/auth/login
|
|
13
24
|
* POST /api/taskforce/auth/register
|
|
14
25
|
* POST /api/taskforce/auth/verify-email/request
|
|
@@ -26,6 +37,7 @@
|
|
|
26
37
|
* GET /api/taskforce/account/plan-preview
|
|
27
38
|
* GET /api/taskforce/auth/workspace-members
|
|
28
39
|
* GET /api/taskforce/workspace/assignee-options
|
|
40
|
+
* POST /api/taskforce/workspace/ai-profiles/avatar
|
|
29
41
|
* GET /api/taskforce/admin/users
|
|
30
42
|
* POST /api/taskforce/admin/users/invite
|
|
31
43
|
* PATCH /api/taskforce/admin/users/:id/role
|
|
@@ -38,17 +50,20 @@
|
|
|
38
50
|
import * as fs from 'fs';
|
|
39
51
|
import * as path from 'path';
|
|
40
52
|
import { randomUUID } from 'crypto';
|
|
41
|
-
import { parseJsonBody } from './primitives.js';
|
|
53
|
+
import { parseJsonBody, parseFormBody } from './primitives.js';
|
|
54
|
+
import { buildGoogleAuthUrl, exchangeGoogleCode } from '../auth/providers/google.js';
|
|
55
|
+
import { buildAppleAuthUrl, exchangeAppleCode } from '../auth/providers/apple.js';
|
|
56
|
+
import { buildGithubAuthUrl, exchangeGithubCode } from '../auth/providers/github.js';
|
|
42
57
|
import { hasValidSession, createClearSessionCookie, createSessionTokenCookie, resolveRequestAccess, resolveSessionCookieAttributesForRequest, } from '../auth.js';
|
|
43
58
|
import { sendEmail } from '../email.js';
|
|
44
59
|
import { resolveDeploymentConfig } from '../../config/deployment.js';
|
|
45
60
|
import { resolveRuntimeModeContract } from '../../shared/runtimeContract.js';
|
|
46
|
-
import { requestHeader, resolveErrorStatusCode } from './shared.js';
|
|
61
|
+
import { getRouteTimingNow, logSlowBootstrapRoute, requestBootstrapTraceId, requestHeader, resolveErrorStatusCode, serializeJsonWithTiming, setServerTimingHeader } from './shared.js';
|
|
47
62
|
import { buildAuthFlowBehavior, buildSiteBehavior, getRouteEmailConfig, resolvePublicAuthBaseUrl, resolveRuntimeConfigBaseUrl, shouldExposeDevAuthTokens } from './authSupport.js';
|
|
48
63
|
import { normalizeTaskDataRelativePath } from '../../storage/objectStorageClient.js';
|
|
49
64
|
// === SECTION: Registration ===
|
|
50
65
|
export function registerAuthRoutes(deps) {
|
|
51
|
-
const { addRoute, core, context, requestWorkspaceId, requestUserId, ensureAppAccess, ensureAuthenticatedUser, ensureWorkspaceAdminRole, ensureCloudPlanData, requireCloudRuntime, auditAuthAction, resolveAdminRequestActor, resolveEffectiveObjectStorageConfig, getObjectStorageClient, } = deps;
|
|
66
|
+
const { addRoute, core, context, requestWorkspaceId, requestUserId, resolveAuthenticatedUserId, ensureAppAccess, ensureAuthenticatedUser, ensureWorkspaceAdminRole, ensureCloudPlanData, requireCloudRuntime, auditAuthAction, resolveAdminRequestActor, resolveEffectiveObjectStorageConfig, getObjectStorageClient, } = deps;
|
|
52
67
|
const normalizeTeamWorkspaceRole = (value) => {
|
|
53
68
|
const role = String(value || '').trim().toLowerCase();
|
|
54
69
|
if (role === 'owner' || role === 'admin' || role === 'member' || role === 'read-only')
|
|
@@ -103,7 +118,7 @@ export function registerAuthRoutes(deps) {
|
|
|
103
118
|
return null;
|
|
104
119
|
};
|
|
105
120
|
const buildAccountProfileSummary = (req) => {
|
|
106
|
-
const userId =
|
|
121
|
+
const userId = resolveAuthenticatedUserId(req);
|
|
107
122
|
const workspaceId = requestWorkspaceId(req);
|
|
108
123
|
const access = core.getAccountAccessStatus(userId);
|
|
109
124
|
const resolvedAccess = core.resolveUserAccess(userId, workspaceId, 'member');
|
|
@@ -125,8 +140,12 @@ export function registerAuthRoutes(deps) {
|
|
|
125
140
|
catch {
|
|
126
141
|
billingRow = undefined;
|
|
127
142
|
}
|
|
128
|
-
const
|
|
143
|
+
const allowBillingPlanFallback = access.gate === 'plan_selection_required' || access.gate === 'checkout_pending';
|
|
144
|
+
const planId = String(access.entitlement?.planId
|
|
145
|
+
|| (allowBillingPlanFallback ? billingRow?.tier : '')
|
|
146
|
+
|| '').trim() || null;
|
|
129
147
|
const planVersionId = String(access.entitlement?.planVersionId || '').trim() || null;
|
|
148
|
+
const entitlementState = access.entitlement?.state || null;
|
|
130
149
|
let planRow;
|
|
131
150
|
if (planId) {
|
|
132
151
|
try {
|
|
@@ -151,6 +170,7 @@ export function registerAuthRoutes(deps) {
|
|
|
151
170
|
teamPlanMode = 'personal';
|
|
152
171
|
}
|
|
153
172
|
}
|
|
173
|
+
const aiProfileSeatUsage = core.countAiProfileUsage(workspaceId);
|
|
154
174
|
const billingConflictCode = resolveBillingConflictCodeForUser(userId, access.gate);
|
|
155
175
|
return {
|
|
156
176
|
stripeStatus: billingRow?.stripe_status || null,
|
|
@@ -161,7 +181,7 @@ export function registerAuthRoutes(deps) {
|
|
|
161
181
|
trialEnd: billingRow?.trial_end || null,
|
|
162
182
|
currentPeriodEnd: billingRow?.current_period_end || null,
|
|
163
183
|
effectiveUntil: billingRow?.effective_until || null,
|
|
164
|
-
entitlementState
|
|
184
|
+
entitlementState,
|
|
165
185
|
planVersionId,
|
|
166
186
|
planId,
|
|
167
187
|
planName: planRow?.display_name ? String(planRow.display_name) : planId,
|
|
@@ -173,7 +193,8 @@ export function registerAuthRoutes(deps) {
|
|
|
173
193
|
workspaceRole,
|
|
174
194
|
canManageWorkspace,
|
|
175
195
|
teamPlanMode,
|
|
176
|
-
teamManagementAllowed: canManageWorkspace && teamPlanMode === 'team'
|
|
196
|
+
teamManagementAllowed: canManageWorkspace && teamPlanMode === 'team',
|
|
197
|
+
aiProfileSeatUsage
|
|
177
198
|
};
|
|
178
199
|
};
|
|
179
200
|
// === SECTION: Email delivery helpers ===
|
|
@@ -187,7 +208,7 @@ export function registerAuthRoutes(deps) {
|
|
|
187
208
|
const access = resolvedWorkspaceId
|
|
188
209
|
? core.resolveUserAccess(userId, resolvedWorkspaceId, fallbackRole)
|
|
189
210
|
: null;
|
|
190
|
-
return createSessionTokenCookie(`
|
|
211
|
+
return createSessionTokenCookie(`usr:${userId}`, authConfig, {
|
|
191
212
|
role: (access?.role || fallbackRole),
|
|
192
213
|
workspaceId: access?.workspaceId || '',
|
|
193
214
|
userId,
|
|
@@ -241,6 +262,29 @@ export function registerAuthRoutes(deps) {
|
|
|
241
262
|
await deleteStoredAvatarObject(draft.storageKey);
|
|
242
263
|
}
|
|
243
264
|
};
|
|
265
|
+
const parseBinaryBody = async (req, maxBytes) => {
|
|
266
|
+
const safeMaxBytes = Number.isFinite(maxBytes) && maxBytes > 0
|
|
267
|
+
? Math.floor(maxBytes)
|
|
268
|
+
: MAX_AVATAR_UPLOAD_BYTES;
|
|
269
|
+
return new Promise((resolve, reject) => {
|
|
270
|
+
const chunks = [];
|
|
271
|
+
let receivedBytes = 0;
|
|
272
|
+
req.on('data', (chunk) => {
|
|
273
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
274
|
+
receivedBytes += buffer.length;
|
|
275
|
+
if (receivedBytes > safeMaxBytes) {
|
|
276
|
+
req.pause();
|
|
277
|
+
reject(Object.assign(new Error(`Payload too large (max ${safeMaxBytes} bytes)`), { statusCode: 413 }));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
chunks.push(buffer);
|
|
281
|
+
});
|
|
282
|
+
req.on('end', () => {
|
|
283
|
+
resolve(Buffer.concat(chunks));
|
|
284
|
+
});
|
|
285
|
+
req.on('error', reject);
|
|
286
|
+
});
|
|
287
|
+
};
|
|
244
288
|
const logAuthLink = (kind, email, token, req) => {
|
|
245
289
|
const base = resolvePublicAuthBaseUrl(req);
|
|
246
290
|
const urlPath = kind === 'verify-email'
|
|
@@ -339,11 +383,650 @@ export function registerAuthRoutes(deps) {
|
|
|
339
383
|
});
|
|
340
384
|
res.end(`window.__TASKFORCE_SITE_CONFIG__ = ${payload};`);
|
|
341
385
|
});
|
|
386
|
+
// GET /api/taskforce/auth/providers - Public: list enabled OAuth login providers (no secrets)
|
|
387
|
+
addRoute('GET', '/api/taskforce/auth/providers', async (_req, res) => {
|
|
388
|
+
const providers = core.getGlobalSettings().oauthProviders || {};
|
|
389
|
+
const enabled = [];
|
|
390
|
+
if (providers.google?.enabled === true)
|
|
391
|
+
enabled.push('google');
|
|
392
|
+
if (providers.github?.enabled === true)
|
|
393
|
+
enabled.push('github');
|
|
394
|
+
if (providers.apple?.enabled === true)
|
|
395
|
+
enabled.push('apple');
|
|
396
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
397
|
+
res.end(JSON.stringify({ providers: enabled }));
|
|
398
|
+
});
|
|
399
|
+
// === SECTION: OAuth 2.0 / OpenID Connect ===
|
|
400
|
+
const OAUTH_RETURN_TO_PATTERN = /^\/[A-Za-z0-9_\-./?=&%]*$/;
|
|
401
|
+
/**
|
|
402
|
+
* Validates a `return_to` value and defaults to '/' on failure.
|
|
403
|
+
* Prevents open-redirect attacks by only allowing same-site relative paths.
|
|
404
|
+
*/
|
|
405
|
+
const sanitizeReturnTo = (raw) => {
|
|
406
|
+
const value = String(raw || '').trim();
|
|
407
|
+
if (!value || !OAUTH_RETURN_TO_PATTERN.test(value))
|
|
408
|
+
return '/';
|
|
409
|
+
return value;
|
|
410
|
+
};
|
|
411
|
+
/**
|
|
412
|
+
* Provider-agnostic session cookie builder for OAuth-authenticated users.
|
|
413
|
+
* Resolves workspace/role and mints the signed session token.
|
|
414
|
+
*/
|
|
415
|
+
const buildOAuthSessionCookie = (req, authConfig, userId, identityId) => {
|
|
416
|
+
const resolvedWorkspaceId = core.resolvePreferredUserWorkspaceId(userId, {
|
|
417
|
+
preferredWorkspaceId: null,
|
|
418
|
+
persistedWorkspaceId: null
|
|
419
|
+
});
|
|
420
|
+
const fallbackRole = String(authConfig.defaultRole || 'member').trim() || 'member';
|
|
421
|
+
const access = resolvedWorkspaceId
|
|
422
|
+
? core.resolveUserAccess(userId, resolvedWorkspaceId, fallbackRole)
|
|
423
|
+
: null;
|
|
424
|
+
return createSessionTokenCookie(`usr:${userId}`, authConfig, {
|
|
425
|
+
role: (access?.role || fallbackRole),
|
|
426
|
+
workspaceId: access?.workspaceId || '',
|
|
427
|
+
userId,
|
|
428
|
+
identityId,
|
|
429
|
+
...resolveSessionCookieAttributesForRequest(req, authConfig),
|
|
430
|
+
});
|
|
431
|
+
};
|
|
432
|
+
/**
|
|
433
|
+
* Handles the post-exchange OAuth profile: find or create the user,
|
|
434
|
+
* link the provider identity if needed, and return { userId, identityId }.
|
|
435
|
+
*
|
|
436
|
+
* Resolution order:
|
|
437
|
+
* 1. Existing identity matching (provider, providerSubject) → login.
|
|
438
|
+
* 2. Existing user with same email (provider must have verified it) → link + login.
|
|
439
|
+
* 3. Self-registration allowed → register new user → login.
|
|
440
|
+
* 4. Otherwise → reject.
|
|
441
|
+
*/
|
|
442
|
+
const resolveOAuthUser = (profile) => {
|
|
443
|
+
// Case 1: known identity
|
|
444
|
+
const found = core.findUserByOAuthProvider(profile.provider, profile.providerSubject);
|
|
445
|
+
if (found) {
|
|
446
|
+
// Update last_login_at snapshot
|
|
447
|
+
try {
|
|
448
|
+
const db = core.getDatabaseAdapter();
|
|
449
|
+
const tenantId = core.getTenantId();
|
|
450
|
+
db.prepare(`
|
|
451
|
+
UPDATE user_auth_identities
|
|
452
|
+
SET last_login_at = ?, avatar_url_snapshot = COALESCE(?, avatar_url_snapshot),
|
|
453
|
+
updated_at = ?
|
|
454
|
+
WHERE tenant_id = ? AND id = ?
|
|
455
|
+
`).run(new Date().toISOString(), profile.avatarUrl ?? null, new Date().toISOString(), tenantId, found.identityId);
|
|
456
|
+
}
|
|
457
|
+
catch { /* non-fatal */ }
|
|
458
|
+
return { userId: found.userId, identityId: found.identityId, isNew: false };
|
|
459
|
+
}
|
|
460
|
+
// Case 2: email match (provider must have verified it)
|
|
461
|
+
if (profile.email && profile.emailVerified) {
|
|
462
|
+
const byEmail = core.findUserByEmail(profile.email);
|
|
463
|
+
if (byEmail) {
|
|
464
|
+
try {
|
|
465
|
+
const identityId = core.linkOAuthProviderToUser({
|
|
466
|
+
userId: byEmail.userId,
|
|
467
|
+
provider: profile.provider,
|
|
468
|
+
providerSubject: profile.providerSubject,
|
|
469
|
+
providerEmail: profile.email,
|
|
470
|
+
providerEmailVerified: profile.emailVerified,
|
|
471
|
+
displayNameSnapshot: profile.displayName,
|
|
472
|
+
avatarUrlSnapshot: profile.avatarUrl,
|
|
473
|
+
});
|
|
474
|
+
return { userId: byEmail.userId, identityId, isNew: false };
|
|
475
|
+
}
|
|
476
|
+
catch (e) {
|
|
477
|
+
if (e?.code !== 'IDENTITY_ALREADY_EXISTS')
|
|
478
|
+
throw e;
|
|
479
|
+
// Race condition: already linked — re-query
|
|
480
|
+
const refound = core.findUserByOAuthProvider(profile.provider, profile.providerSubject);
|
|
481
|
+
if (refound)
|
|
482
|
+
return { ...refound, isNew: false };
|
|
483
|
+
throw e;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
// Case 3: self-registration
|
|
488
|
+
const authBehavior = buildAuthFlowBehavior(core);
|
|
489
|
+
if (!authBehavior.allowSelfRegistration) {
|
|
490
|
+
throw Object.assign(new Error('Self-registration is currently disabled.'), {
|
|
491
|
+
code: 'REGISTRATION_DISABLED',
|
|
492
|
+
statusCode: 403
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
const created = core.registerOAuthUser({
|
|
496
|
+
email: profile.email,
|
|
497
|
+
emailVerified: profile.emailVerified,
|
|
498
|
+
provider: profile.provider,
|
|
499
|
+
providerSubject: profile.providerSubject,
|
|
500
|
+
displayName: profile.displayName,
|
|
501
|
+
avatarUrl: profile.avatarUrl,
|
|
502
|
+
betaAccess: authBehavior.defaultNewUserBetaAccess,
|
|
503
|
+
});
|
|
504
|
+
return { userId: created.userId, identityId: created.identityId, isNew: true };
|
|
505
|
+
};
|
|
506
|
+
// GET /api/taskforce/auth/oauth/:provider/start
|
|
507
|
+
// Initiates the OAuth authorisation redirect for Google, GitHub, or Apple.
|
|
508
|
+
addRoute('GET', '/api/taskforce/auth/oauth/:provider/start', async (req, res, params) => {
|
|
509
|
+
if (!requireCloudRuntime(res, 'OAuth Login'))
|
|
510
|
+
return;
|
|
511
|
+
const authConfig = context.authConfig;
|
|
512
|
+
if (!authConfig?.enabled) {
|
|
513
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
514
|
+
res.end(JSON.stringify({ success: false, error: 'Auth is not enabled.' }));
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const provider = String(params.provider || '').toLowerCase();
|
|
518
|
+
if (provider !== 'google' && provider !== 'github' && provider !== 'apple') {
|
|
519
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
520
|
+
res.end(JSON.stringify({ success: false, error: 'Unknown OAuth provider.' }));
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
const oauthProviders = core.getGlobalSettings().oauthProviders || {};
|
|
524
|
+
const providerConfig = oauthProviders[provider];
|
|
525
|
+
if (!providerConfig?.enabled) {
|
|
526
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
527
|
+
res.end(JSON.stringify({ success: false, error: `${provider} login is not enabled.` }));
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const queryString = (req.url || '').split('?')[1] || '';
|
|
531
|
+
const queryParams = new URLSearchParams(queryString);
|
|
532
|
+
const returnTo = sanitizeReturnTo(queryParams.get('return_to'));
|
|
533
|
+
const clientIp = requestHeader(req, 'x-forwarded-for', '') || requestHeader(req, 'x-real-ip', '') || null;
|
|
534
|
+
const rawInviteToken = String(queryParams.get('invite_token') || '').trim();
|
|
535
|
+
const rawFlowHint = String(queryParams.get('flow') || '').trim().toLowerCase();
|
|
536
|
+
// Determine flow mode: link > invite_claim > login
|
|
537
|
+
let flowMode = 'login';
|
|
538
|
+
let validatedInviteToken = null;
|
|
539
|
+
let linkUserId = null;
|
|
540
|
+
if (rawFlowHint === 'link') {
|
|
541
|
+
// Provider-link flow: require an authenticated session
|
|
542
|
+
if (!authConfig || !hasValidSession(req, authConfig)) {
|
|
543
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
544
|
+
res.end(JSON.stringify({ success: false, error: 'Sign in required to link a provider.', code: 'AUTH_REQUIRED' }));
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const access = resolveRequestAccess(req, authConfig);
|
|
548
|
+
linkUserId = access?.userId || null;
|
|
549
|
+
if (!linkUserId) {
|
|
550
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
551
|
+
res.end(JSON.stringify({ success: false, error: 'Unable to resolve authenticated user.', code: 'AUTH_REQUIRED' }));
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
flowMode = 'link';
|
|
555
|
+
}
|
|
556
|
+
else if (rawInviteToken) {
|
|
557
|
+
// Invite-claim flow
|
|
558
|
+
const inviteDetails = core.inspectInviteAcceptance(rawInviteToken);
|
|
559
|
+
if (inviteDetails.state !== 'valid') {
|
|
560
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
561
|
+
res.end(JSON.stringify({ success: false, error: 'Invite token is invalid or expired.', code: 'INVITE_TOKEN_INVALID' }));
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (inviteDetails.inviteeState !== 'pending_setup') {
|
|
565
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
566
|
+
res.end(JSON.stringify({ success: false, error: 'This invite has already been claimed.', code: 'INVITE_ALREADY_CLAIMED' }));
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
flowMode = 'invite_claim';
|
|
570
|
+
validatedInviteToken = rawInviteToken;
|
|
571
|
+
}
|
|
572
|
+
const { state, codeChallenge, nonce } = core.createOAuthLoginState({
|
|
573
|
+
provider,
|
|
574
|
+
flowMode,
|
|
575
|
+
userId: linkUserId,
|
|
576
|
+
returnTo,
|
|
577
|
+
inviteToken: validatedInviteToken,
|
|
578
|
+
createdIp: clientIp,
|
|
579
|
+
});
|
|
580
|
+
const baseUrl = resolvePublicAuthBaseUrl(req);
|
|
581
|
+
const redirectUri = `${baseUrl}/api/taskforce/auth/oauth/${provider}/callback`;
|
|
582
|
+
let authUrl;
|
|
583
|
+
if (provider === 'google') {
|
|
584
|
+
const cfg = providerConfig;
|
|
585
|
+
if (!cfg.clientId) {
|
|
586
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
587
|
+
res.end(JSON.stringify({ success: false, error: 'Google OAuth is not fully configured.' }));
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
authUrl = buildGoogleAuthUrl({
|
|
591
|
+
clientId: cfg.clientId,
|
|
592
|
+
redirectUri,
|
|
593
|
+
state,
|
|
594
|
+
codeChallenge,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
else if (provider === 'github') {
|
|
598
|
+
const cfg = providerConfig;
|
|
599
|
+
if (!cfg.clientId) {
|
|
600
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
601
|
+
res.end(JSON.stringify({ success: false, error: 'GitHub OAuth is not fully configured.' }));
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
authUrl = buildGithubAuthUrl({
|
|
605
|
+
clientId: cfg.clientId,
|
|
606
|
+
redirectUri,
|
|
607
|
+
state,
|
|
608
|
+
codeChallenge,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
// Apple
|
|
613
|
+
const cfg = providerConfig;
|
|
614
|
+
if (!cfg.servicesId) {
|
|
615
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
616
|
+
res.end(JSON.stringify({ success: false, error: 'Apple Sign In is not fully configured.' }));
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
authUrl = buildAppleAuthUrl({
|
|
620
|
+
servicesId: cfg.servicesId,
|
|
621
|
+
redirectUri,
|
|
622
|
+
state,
|
|
623
|
+
nonce,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
res.writeHead(302, { Location: authUrl });
|
|
627
|
+
res.end();
|
|
628
|
+
});
|
|
629
|
+
// GET /api/taskforce/auth/oauth/:provider/callback
|
|
630
|
+
// Handles the OAuth callback for Google and GitHub (GET redirect with `code` + `state`).
|
|
631
|
+
addRoute('GET', '/api/taskforce/auth/oauth/:provider/callback', async (req, res, params) => {
|
|
632
|
+
if (!requireCloudRuntime(res, 'OAuth Login'))
|
|
633
|
+
return;
|
|
634
|
+
const authConfig = context.authConfig;
|
|
635
|
+
if (!authConfig?.enabled) {
|
|
636
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
637
|
+
res.end(JSON.stringify({ success: false, error: 'Auth is not enabled.' }));
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
const provider = String(params.provider || '').toLowerCase();
|
|
641
|
+
if (provider !== 'google' && provider !== 'github') {
|
|
642
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
643
|
+
res.end(JSON.stringify({ success: false, error: 'Unknown OAuth provider.' }));
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const queryString = (req.url || '').split('?')[1] || '';
|
|
647
|
+
const queryParams = new URLSearchParams(queryString);
|
|
648
|
+
const stateParam = String(queryParams.get('state') || '').trim();
|
|
649
|
+
const codeParam = String(queryParams.get('code') || '').trim();
|
|
650
|
+
const errorParam = String(queryParams.get('error') || '').trim();
|
|
651
|
+
const baseUrl = resolvePublicAuthBaseUrl(req);
|
|
652
|
+
const redirectUri = `${baseUrl}/api/taskforce/auth/oauth/${provider}/callback`;
|
|
653
|
+
if (errorParam) {
|
|
654
|
+
auditAuthAction(req, 'auth-oauth-callback-denied', { provider, error: errorParam });
|
|
655
|
+
res.writeHead(302, { Location: `/login?error=oauth_denied&provider=${encodeURIComponent(provider)}` });
|
|
656
|
+
res.end();
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
if (!stateParam || !codeParam) {
|
|
660
|
+
res.writeHead(302, { Location: `/login?error=oauth_invalid&provider=${encodeURIComponent(provider)}` });
|
|
661
|
+
res.end();
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
// Consume state (single-use, validates expiry)
|
|
665
|
+
const stateRecord = core.consumeOAuthLoginState(stateParam);
|
|
666
|
+
if (!stateRecord) {
|
|
667
|
+
auditAuthAction(req, 'auth-oauth-state-invalid', { provider, stateProvided: true });
|
|
668
|
+
res.writeHead(302, { Location: `/login?error=oauth_state_invalid&provider=${encodeURIComponent(provider)}` });
|
|
669
|
+
res.end();
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (stateRecord.provider !== provider) {
|
|
673
|
+
auditAuthAction(req, 'auth-oauth-state-provider-mismatch', { provider, stateProvider: stateRecord.provider });
|
|
674
|
+
res.writeHead(302, { Location: `/login?error=oauth_state_invalid&provider=${encodeURIComponent(provider)}` });
|
|
675
|
+
res.end();
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const codeVerifier = stateRecord.pkceVerifier || '';
|
|
679
|
+
const returnTo = sanitizeReturnTo(stateRecord.returnTo);
|
|
680
|
+
const oauthProviders = core.getGlobalSettings().oauthProviders || {};
|
|
681
|
+
try {
|
|
682
|
+
let profile;
|
|
683
|
+
if (provider === 'google') {
|
|
684
|
+
const cfg = (oauthProviders.google || {});
|
|
685
|
+
if (!cfg.clientId || !cfg.clientSecret) {
|
|
686
|
+
throw Object.assign(new Error('Google OAuth is not configured.'), { statusCode: 500 });
|
|
687
|
+
}
|
|
688
|
+
profile = await exchangeGoogleCode({
|
|
689
|
+
code: codeParam,
|
|
690
|
+
codeVerifier,
|
|
691
|
+
redirectUri,
|
|
692
|
+
clientId: cfg.clientId,
|
|
693
|
+
clientSecret: cfg.clientSecret,
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
const cfg = (oauthProviders.github || {});
|
|
698
|
+
if (!cfg.clientId || !cfg.clientSecret) {
|
|
699
|
+
throw Object.assign(new Error('GitHub OAuth is not configured.'), { statusCode: 500 });
|
|
700
|
+
}
|
|
701
|
+
profile = await exchangeGithubCode({
|
|
702
|
+
code: codeParam,
|
|
703
|
+
codeVerifier,
|
|
704
|
+
redirectUri,
|
|
705
|
+
clientId: cfg.clientId,
|
|
706
|
+
clientSecret: cfg.clientSecret,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
let userId;
|
|
710
|
+
let identityId;
|
|
711
|
+
let isNew = false;
|
|
712
|
+
if (stateRecord.flowMode === 'link') {
|
|
713
|
+
if (!stateRecord.userId) {
|
|
714
|
+
throw Object.assign(new Error('User ID missing from OAuth link state.'), { code: 'AUTH_REQUIRED', statusCode: 401 });
|
|
715
|
+
}
|
|
716
|
+
identityId = core.linkOAuthProviderToUser({
|
|
717
|
+
userId: stateRecord.userId,
|
|
718
|
+
provider: profile.provider,
|
|
719
|
+
providerSubject: profile.providerSubject,
|
|
720
|
+
providerEmail: profile.email,
|
|
721
|
+
providerEmailVerified: profile.emailVerified,
|
|
722
|
+
displayNameSnapshot: profile.displayName,
|
|
723
|
+
avatarUrlSnapshot: profile.avatarUrl,
|
|
724
|
+
});
|
|
725
|
+
userId = stateRecord.userId;
|
|
726
|
+
isNew = false;
|
|
727
|
+
}
|
|
728
|
+
else if (stateRecord.flowMode === 'invite_claim') {
|
|
729
|
+
if (!stateRecord.inviteToken) {
|
|
730
|
+
throw Object.assign(new Error('Invite token missing from OAuth state.'), { code: 'INVITE_TOKEN_MISSING', statusCode: 400 });
|
|
731
|
+
}
|
|
732
|
+
const claimed = core.claimInviteWithOAuthProvider({
|
|
733
|
+
token: stateRecord.inviteToken,
|
|
734
|
+
provider: profile.provider,
|
|
735
|
+
providerSubject: profile.providerSubject,
|
|
736
|
+
providerEmail: profile.email,
|
|
737
|
+
providerEmailVerified: profile.emailVerified,
|
|
738
|
+
displayNameSnapshot: profile.displayName,
|
|
739
|
+
avatarUrlSnapshot: profile.avatarUrl,
|
|
740
|
+
});
|
|
741
|
+
userId = claimed.userId;
|
|
742
|
+
identityId = claimed.identityId;
|
|
743
|
+
isNew = true;
|
|
744
|
+
}
|
|
745
|
+
else {
|
|
746
|
+
({ userId, identityId, isNew } = resolveOAuthUser(profile));
|
|
747
|
+
}
|
|
748
|
+
const cookie = buildOAuthSessionCookie(req, authConfig, userId, identityId);
|
|
749
|
+
res.setHeader('Set-Cookie', cookie);
|
|
750
|
+
auditAuthAction(req, 'auth-oauth-login-success', {
|
|
751
|
+
provider,
|
|
752
|
+
userId,
|
|
753
|
+
isNew,
|
|
754
|
+
flowMode: stateRecord.flowMode,
|
|
755
|
+
email: profile.email || null,
|
|
756
|
+
});
|
|
757
|
+
res.writeHead(302, { Location: returnTo });
|
|
758
|
+
res.end();
|
|
759
|
+
}
|
|
760
|
+
catch (err) {
|
|
761
|
+
const code = err?.code || 'OAUTH_ERROR';
|
|
762
|
+
auditAuthAction(req, 'auth-oauth-callback-error', { provider, code, message: String(err?.message || '') });
|
|
763
|
+
if (code === 'REGISTRATION_DISABLED') {
|
|
764
|
+
res.writeHead(302, { Location: `/login?error=registration_disabled&provider=${encodeURIComponent(provider)}` });
|
|
765
|
+
}
|
|
766
|
+
else if (code === 'IDENTITY_ALREADY_EXISTS') {
|
|
767
|
+
res.writeHead(302, { Location: `${returnTo}?error=provider_already_linked&provider=${encodeURIComponent(provider)}` });
|
|
768
|
+
}
|
|
769
|
+
else if (code === 'INVITE_TOKEN_EXPIRED' || code === 'INVITE_TOKEN_REVOKED' || code === 'INVITE_TOKEN_USED' || code === 'INVITE_TOKEN_INVALID') {
|
|
770
|
+
res.writeHead(302, { Location: `/login?error=invite_invalid&provider=${encodeURIComponent(provider)}` });
|
|
771
|
+
}
|
|
772
|
+
else {
|
|
773
|
+
res.writeHead(302, { Location: `/login?error=oauth_failed&provider=${encodeURIComponent(provider)}` });
|
|
774
|
+
}
|
|
775
|
+
res.end();
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
// POST /api/taskforce/auth/oauth/apple/callback
|
|
779
|
+
// Apple Sign In sends `code`, `state`, `id_token` and optional `user` JSON via form_post.
|
|
780
|
+
addRoute('POST', '/api/taskforce/auth/oauth/apple/callback', async (req, res) => {
|
|
781
|
+
if (!requireCloudRuntime(res, 'Apple Sign In'))
|
|
782
|
+
return;
|
|
783
|
+
const authConfig = context.authConfig;
|
|
784
|
+
if (!authConfig?.enabled) {
|
|
785
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
786
|
+
res.end(JSON.stringify({ success: false, error: 'Auth is not enabled.' }));
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
let formData;
|
|
790
|
+
try {
|
|
791
|
+
formData = await parseFormBody(req);
|
|
792
|
+
}
|
|
793
|
+
catch {
|
|
794
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
795
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid form body.' }));
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
const stateParam = String(formData.state || '').trim();
|
|
799
|
+
const codeParam = String(formData.code || '').trim();
|
|
800
|
+
const errorParam = String(formData.error || '').trim();
|
|
801
|
+
const userJson = formData.user ? String(formData.user).trim() : null;
|
|
802
|
+
if (errorParam) {
|
|
803
|
+
auditAuthAction(req, 'auth-oauth-callback-denied', { provider: 'apple', error: errorParam });
|
|
804
|
+
res.writeHead(302, { Location: '/login?error=oauth_denied&provider=apple' });
|
|
805
|
+
res.end();
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (!stateParam || !codeParam) {
|
|
809
|
+
res.writeHead(302, { Location: '/login?error=oauth_invalid&provider=apple' });
|
|
810
|
+
res.end();
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
const stateRecord = core.consumeOAuthLoginState(stateParam);
|
|
814
|
+
if (!stateRecord || stateRecord.provider !== 'apple') {
|
|
815
|
+
auditAuthAction(req, 'auth-oauth-state-invalid', { provider: 'apple', stateProvided: true });
|
|
816
|
+
res.writeHead(302, { Location: '/login?error=oauth_state_invalid&provider=apple' });
|
|
817
|
+
res.end();
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
const returnTo = sanitizeReturnTo(stateRecord.returnTo);
|
|
821
|
+
const oauthProviders = core.getGlobalSettings().oauthProviders || {};
|
|
822
|
+
const appleCfg = (oauthProviders.apple || {});
|
|
823
|
+
if (!appleCfg.servicesId || !appleCfg.teamId || !appleCfg.keyId || !appleCfg.privateKeyPem) {
|
|
824
|
+
auditAuthAction(req, 'auth-oauth-callback-error', { provider: 'apple', code: 'APPLE_NOT_CONFIGURED' });
|
|
825
|
+
res.writeHead(302, { Location: '/login?error=oauth_failed&provider=apple' });
|
|
826
|
+
res.end();
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
const baseUrl = resolvePublicAuthBaseUrl(req);
|
|
830
|
+
const redirectUri = `${baseUrl}/api/taskforce/auth/oauth/apple/callback`;
|
|
831
|
+
try {
|
|
832
|
+
const profile = await exchangeAppleCode({
|
|
833
|
+
code: codeParam,
|
|
834
|
+
redirectUri,
|
|
835
|
+
servicesId: appleCfg.servicesId,
|
|
836
|
+
teamId: appleCfg.teamId,
|
|
837
|
+
keyId: appleCfg.keyId,
|
|
838
|
+
privateKeyPem: appleCfg.privateKeyPem,
|
|
839
|
+
userJson,
|
|
840
|
+
});
|
|
841
|
+
let userId;
|
|
842
|
+
let identityId;
|
|
843
|
+
let isNew = false;
|
|
844
|
+
if (stateRecord.flowMode === 'link') {
|
|
845
|
+
if (!stateRecord.userId) {
|
|
846
|
+
throw Object.assign(new Error('User ID missing from OAuth link state.'), { code: 'AUTH_REQUIRED', statusCode: 401 });
|
|
847
|
+
}
|
|
848
|
+
identityId = core.linkOAuthProviderToUser({
|
|
849
|
+
userId: stateRecord.userId,
|
|
850
|
+
provider: 'apple',
|
|
851
|
+
providerSubject: profile.providerSubject,
|
|
852
|
+
providerEmail: profile.email,
|
|
853
|
+
providerEmailVerified: profile.emailVerified,
|
|
854
|
+
displayNameSnapshot: profile.displayName,
|
|
855
|
+
avatarUrlSnapshot: profile.avatarUrl,
|
|
856
|
+
});
|
|
857
|
+
userId = stateRecord.userId;
|
|
858
|
+
isNew = false;
|
|
859
|
+
}
|
|
860
|
+
else if (stateRecord.flowMode === 'invite_claim') {
|
|
861
|
+
if (!stateRecord.inviteToken) {
|
|
862
|
+
throw Object.assign(new Error('Invite token missing from OAuth state.'), { code: 'INVITE_TOKEN_MISSING', statusCode: 400 });
|
|
863
|
+
}
|
|
864
|
+
const claimed = core.claimInviteWithOAuthProvider({
|
|
865
|
+
token: stateRecord.inviteToken,
|
|
866
|
+
provider: 'apple',
|
|
867
|
+
providerSubject: profile.providerSubject,
|
|
868
|
+
providerEmail: profile.email,
|
|
869
|
+
providerEmailVerified: profile.emailVerified,
|
|
870
|
+
displayNameSnapshot: profile.displayName,
|
|
871
|
+
avatarUrlSnapshot: profile.avatarUrl,
|
|
872
|
+
});
|
|
873
|
+
userId = claimed.userId;
|
|
874
|
+
identityId = claimed.identityId;
|
|
875
|
+
isNew = true;
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
({ userId, identityId, isNew } = resolveOAuthUser(profile));
|
|
879
|
+
}
|
|
880
|
+
const cookie = buildOAuthSessionCookie(req, authConfig, userId, identityId);
|
|
881
|
+
res.setHeader('Set-Cookie', cookie);
|
|
882
|
+
auditAuthAction(req, 'auth-oauth-login-success', {
|
|
883
|
+
provider: 'apple',
|
|
884
|
+
userId,
|
|
885
|
+
isNew,
|
|
886
|
+
flowMode: stateRecord.flowMode,
|
|
887
|
+
email: profile.email || null,
|
|
888
|
+
});
|
|
889
|
+
res.writeHead(302, { Location: returnTo });
|
|
890
|
+
res.end();
|
|
891
|
+
}
|
|
892
|
+
catch (err) {
|
|
893
|
+
const code = err?.code || 'OAUTH_ERROR';
|
|
894
|
+
auditAuthAction(req, 'auth-oauth-callback-error', { provider: 'apple', code, message: String(err?.message || '') });
|
|
895
|
+
if (code === 'REGISTRATION_DISABLED') {
|
|
896
|
+
res.writeHead(302, { Location: '/login?error=registration_disabled&provider=apple' });
|
|
897
|
+
}
|
|
898
|
+
else if (code === 'IDENTITY_ALREADY_EXISTS') {
|
|
899
|
+
res.writeHead(302, { Location: `${returnTo}?error=provider_already_linked&provider=apple` });
|
|
900
|
+
}
|
|
901
|
+
else if (code === 'INVITE_TOKEN_EXPIRED' || code === 'INVITE_TOKEN_REVOKED' || code === 'INVITE_TOKEN_USED' || code === 'INVITE_TOKEN_INVALID') {
|
|
902
|
+
res.writeHead(302, { Location: '/login?error=invite_invalid&provider=apple' });
|
|
903
|
+
}
|
|
904
|
+
else {
|
|
905
|
+
res.writeHead(302, { Location: '/login?error=oauth_failed&provider=apple' });
|
|
906
|
+
}
|
|
907
|
+
res.end();
|
|
908
|
+
}
|
|
909
|
+
});
|
|
910
|
+
// GET /api/taskforce/account/login-methods
|
|
911
|
+
// Returns the list of active login identities for the authenticated user.
|
|
912
|
+
addRoute('GET', '/api/taskforce/account/login-methods', async (req, res) => {
|
|
913
|
+
if (!requireCloudRuntime(res, 'Login methods'))
|
|
914
|
+
return;
|
|
915
|
+
const authConfig = context.authConfig;
|
|
916
|
+
if (!authConfig?.enabled || !hasValidSession(req, authConfig)) {
|
|
917
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
918
|
+
res.end(JSON.stringify({ success: false, error: 'Authentication required.' }));
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
const access = resolveRequestAccess(req, authConfig);
|
|
922
|
+
const userId = access?.userId || '';
|
|
923
|
+
if (!userId) {
|
|
924
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
925
|
+
res.end(JSON.stringify({ success: false, error: 'Unable to resolve user.' }));
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
const identities = core.listLoginMethodsForUser(userId);
|
|
929
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
930
|
+
res.end(JSON.stringify({
|
|
931
|
+
success: true,
|
|
932
|
+
methods: identities.map(id => ({
|
|
933
|
+
provider: id.provider,
|
|
934
|
+
providerEmail: id.providerEmail || null,
|
|
935
|
+
displayNameSnapshot: id.displayNameSnapshot || null,
|
|
936
|
+
avatarUrlSnapshot: id.avatarUrlSnapshot || null,
|
|
937
|
+
linkedAt: id.linkedAt || id.createdAt,
|
|
938
|
+
lastLoginAt: id.lastLoginAt || null,
|
|
939
|
+
}))
|
|
940
|
+
}));
|
|
941
|
+
});
|
|
942
|
+
// DELETE /api/taskforce/account/login-methods/:provider
|
|
943
|
+
// Unlinks a provider identity from the authenticated user.
|
|
944
|
+
// Rejects with 409 if it would be the last active login method.
|
|
945
|
+
addRoute('DELETE', '/api/taskforce/account/login-methods/:provider', async (req, res, params) => {
|
|
946
|
+
if (!requireCloudRuntime(res, 'Login methods'))
|
|
947
|
+
return;
|
|
948
|
+
const authConfig = context.authConfig;
|
|
949
|
+
if (!authConfig?.enabled || !hasValidSession(req, authConfig)) {
|
|
950
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
951
|
+
res.end(JSON.stringify({ success: false, error: 'Authentication required.' }));
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
const access = resolveRequestAccess(req, authConfig);
|
|
955
|
+
const userId = access?.userId || '';
|
|
956
|
+
if (!userId) {
|
|
957
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
958
|
+
res.end(JSON.stringify({ success: false, error: 'Unable to resolve user.' }));
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
const provider = String(params.provider || '').toLowerCase();
|
|
962
|
+
if (!provider) {
|
|
963
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
964
|
+
res.end(JSON.stringify({ success: false, error: 'Provider is required.' }));
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
try {
|
|
968
|
+
core.unlinkLoginMethod(userId, provider);
|
|
969
|
+
auditAuthAction(req, 'account-login-method-unlinked', { userId, provider });
|
|
970
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
971
|
+
res.end(JSON.stringify({ success: true }));
|
|
972
|
+
}
|
|
973
|
+
catch (err) {
|
|
974
|
+
const code = err?.code || 'UNLINK_ERROR';
|
|
975
|
+
const status = code === 'LAST_IDENTITY_REQUIRED' ? 409 : resolveErrorStatusCode(err);
|
|
976
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
977
|
+
res.end(JSON.stringify({ success: false, error: err?.message || 'Unlink failed.', code }));
|
|
978
|
+
}
|
|
979
|
+
});
|
|
980
|
+
// POST /api/taskforce/account/login-methods/password
|
|
981
|
+
// Adds (or replaces) a password identity for the authenticated user.
|
|
982
|
+
// Social-only accounts use this to add a password login method.
|
|
983
|
+
addRoute('POST', '/api/taskforce/account/login-methods/password', async (req, res) => {
|
|
984
|
+
if (!requireCloudRuntime(res, 'Login methods'))
|
|
985
|
+
return;
|
|
986
|
+
const authConfig = context.authConfig;
|
|
987
|
+
if (!authConfig?.enabled || !hasValidSession(req, authConfig)) {
|
|
988
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
989
|
+
res.end(JSON.stringify({ success: false, error: 'Authentication required.' }));
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
const access = resolveRequestAccess(req, authConfig);
|
|
993
|
+
const userId = access?.userId || '';
|
|
994
|
+
if (!userId) {
|
|
995
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
996
|
+
res.end(JSON.stringify({ success: false, error: 'Unable to resolve user.' }));
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
let body;
|
|
1000
|
+
try {
|
|
1001
|
+
body = await parseJsonBody(req);
|
|
1002
|
+
}
|
|
1003
|
+
catch {
|
|
1004
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1005
|
+
res.end(JSON.stringify({ success: false, error: 'Invalid JSON body.' }));
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
const password = String(body?.password || '');
|
|
1009
|
+
try {
|
|
1010
|
+
core.addPasswordToAccount(userId, password);
|
|
1011
|
+
auditAuthAction(req, 'account-password-added', { userId });
|
|
1012
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1013
|
+
res.end(JSON.stringify({ success: true }));
|
|
1014
|
+
}
|
|
1015
|
+
catch (err) {
|
|
1016
|
+
const code = err?.code || 'ADD_PASSWORD_ERROR';
|
|
1017
|
+
const status = resolveErrorStatusCode(err);
|
|
1018
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
1019
|
+
res.end(JSON.stringify({ success: false, error: err?.message || 'Failed to add password.', code }));
|
|
1020
|
+
}
|
|
1021
|
+
});
|
|
342
1022
|
// GET /api/taskforce/auth/session - Return current auth/session state
|
|
343
1023
|
addRoute('GET', '/api/taskforce/auth/session', async (req, res) => {
|
|
1024
|
+
const routeStartedAt = getRouteTimingNow();
|
|
1025
|
+
const traceId = requestBootstrapTraceId(req);
|
|
344
1026
|
const authConfig = context.authConfig;
|
|
345
1027
|
const access = authConfig ? resolveRequestAccess(req, authConfig) : null;
|
|
346
1028
|
const authenticated = Boolean(authConfig && hasValidSession(req, authConfig));
|
|
1029
|
+
const authResolvedAt = getRouteTimingNow();
|
|
347
1030
|
let role = access?.role || authConfig?.defaultRole || 'member';
|
|
348
1031
|
let workspaceId = String(access?.workspaceId || authConfig?.defaultWorkspaceId || '').trim();
|
|
349
1032
|
let userId = access?.userId || 'anonymous';
|
|
@@ -351,11 +1034,16 @@ export function registerAuthRoutes(deps) {
|
|
|
351
1034
|
let displayName = null;
|
|
352
1035
|
let avatarUrl = null;
|
|
353
1036
|
let betaAccess = true;
|
|
1037
|
+
let betaAccessResolvedAt = authResolvedAt;
|
|
1038
|
+
let userAccessResolvedAt = authResolvedAt;
|
|
1039
|
+
let identityResolvedAt = authResolvedAt;
|
|
354
1040
|
if (authenticated && access?.userId) {
|
|
355
1041
|
// Keep the session endpoint fast on reload; avatar draft cleanup is best-effort housekeeping.
|
|
356
1042
|
void cleanupStaleAvatarDrafts(access.userId).catch(() => { });
|
|
357
1043
|
betaAccess = core.getUserBetaAccess(access.userId);
|
|
1044
|
+
betaAccessResolvedAt = getRouteTimingNow();
|
|
358
1045
|
const resolved = core.resolveUserAccess(access.userId, workspaceId, role);
|
|
1046
|
+
userAccessResolvedAt = getRouteTimingNow();
|
|
359
1047
|
if (resolved) {
|
|
360
1048
|
role = resolved.role;
|
|
361
1049
|
workspaceId = resolved.workspaceId;
|
|
@@ -370,9 +1058,9 @@ export function registerAuthRoutes(deps) {
|
|
|
370
1058
|
email = identity?.email || null;
|
|
371
1059
|
displayName = identity?.displayName || null;
|
|
372
1060
|
avatarUrl = identity?.avatarUrl || null;
|
|
1061
|
+
identityResolvedAt = getRouteTimingNow();
|
|
373
1062
|
}
|
|
374
|
-
|
|
375
|
-
res.end(JSON.stringify({
|
|
1063
|
+
const payload = {
|
|
376
1064
|
authenticated,
|
|
377
1065
|
userId,
|
|
378
1066
|
workspaceId,
|
|
@@ -381,7 +1069,34 @@ export function registerAuthRoutes(deps) {
|
|
|
381
1069
|
displayName,
|
|
382
1070
|
avatarUrl,
|
|
383
1071
|
betaAccess
|
|
384
|
-
}
|
|
1072
|
+
};
|
|
1073
|
+
const serialized = serializeJsonWithTiming(payload, identityResolvedAt);
|
|
1074
|
+
const totalDurationMs = getRouteTimingNow() - routeStartedAt;
|
|
1075
|
+
setServerTimingHeader(res, [
|
|
1076
|
+
{ name: 'auth-session', durationMs: totalDurationMs },
|
|
1077
|
+
{ name: 'auth-session-cookie', durationMs: authResolvedAt - routeStartedAt },
|
|
1078
|
+
{ name: 'auth-session-beta-access', durationMs: betaAccessResolvedAt - authResolvedAt },
|
|
1079
|
+
{ name: 'auth-session-user-access', durationMs: userAccessResolvedAt - betaAccessResolvedAt },
|
|
1080
|
+
{ name: 'auth-session-identity', durationMs: identityResolvedAt - userAccessResolvedAt },
|
|
1081
|
+
{ name: 'auth-session-json', durationMs: serialized.durationMs },
|
|
1082
|
+
]);
|
|
1083
|
+
logSlowBootstrapRoute({
|
|
1084
|
+
route: '/api/taskforce/auth/session',
|
|
1085
|
+
workspaceId: workspaceId || access?.workspaceId || authConfig?.defaultWorkspaceId || 'default',
|
|
1086
|
+
traceId,
|
|
1087
|
+
durationMs: totalDurationMs,
|
|
1088
|
+
details: {
|
|
1089
|
+
authenticated,
|
|
1090
|
+
payloadBytes: serialized.byteLength,
|
|
1091
|
+
cookieMs: authResolvedAt - routeStartedAt,
|
|
1092
|
+
betaAccessMs: betaAccessResolvedAt - authResolvedAt,
|
|
1093
|
+
userAccessMs: userAccessResolvedAt - betaAccessResolvedAt,
|
|
1094
|
+
identityMs: identityResolvedAt - userAccessResolvedAt,
|
|
1095
|
+
serializeMs: serialized.durationMs,
|
|
1096
|
+
}
|
|
1097
|
+
});
|
|
1098
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1099
|
+
res.end(serialized.body);
|
|
385
1100
|
});
|
|
386
1101
|
// GET /api/taskforce/auth/admin-session - Return global admin auth/session state
|
|
387
1102
|
addRoute('GET', '/api/taskforce/auth/admin-session', async (req, res) => {
|
|
@@ -537,6 +1252,63 @@ export function registerAuthRoutes(deps) {
|
|
|
537
1252
|
path: `/api/taskforce/context/${encodeURIComponent(relativePath)}`
|
|
538
1253
|
}));
|
|
539
1254
|
});
|
|
1255
|
+
addRoute('POST', '/api/taskforce/auth/profile/avatar/upload', async (req, res) => {
|
|
1256
|
+
if (!requireCloudRuntime(res, 'Authentication'))
|
|
1257
|
+
return;
|
|
1258
|
+
const authConfig = context.authConfig;
|
|
1259
|
+
if (!authConfig) {
|
|
1260
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1261
|
+
res.end(JSON.stringify({ success: false, error: 'Auth context unavailable.' }));
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
const access = resolveRequestAccess(req, authConfig);
|
|
1265
|
+
const userId = String(access.userId || '').trim();
|
|
1266
|
+
if (!access.authenticated || !userId || userId === 'anonymous') {
|
|
1267
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1268
|
+
res.end(JSON.stringify({ success: false, error: 'Authentication required.' }));
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
const draftId = String(requestHeader(req, 'x-taskforce-avatar-draft-id', '') || '').trim();
|
|
1272
|
+
if (!draftId) {
|
|
1273
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1274
|
+
res.end(JSON.stringify({ success: false, error: 'Avatar draft id is required.', code: 'AVATAR_DRAFT_REQUIRED' }));
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
await cleanupStaleAvatarDrafts(userId);
|
|
1278
|
+
const draft = core.getUserAvatarUploadDraft(userId, draftId);
|
|
1279
|
+
if (!draft || draft.discardedAt) {
|
|
1280
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1281
|
+
res.end(JSON.stringify({ success: false, error: 'Avatar draft is invalid or expired.', code: 'AVATAR_DRAFT_INVALID' }));
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
const objectStorageClient = getObjectStorageClient();
|
|
1285
|
+
const effectiveObjectStorage = resolveEffectiveObjectStorageConfig();
|
|
1286
|
+
if (!objectStorageClient || effectiveObjectStorage.provider !== 'r2') {
|
|
1287
|
+
res.writeHead(501, { 'Content-Type': 'application/json' });
|
|
1288
|
+
res.end(JSON.stringify({ success: false, error: 'Avatar uploads require object storage.' }));
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
try {
|
|
1292
|
+
const body = await parseBinaryBody(req, MAX_AVATAR_UPLOAD_BYTES);
|
|
1293
|
+
if (body.length === 0) {
|
|
1294
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1295
|
+
res.end(JSON.stringify({ success: false, error: 'Avatar upload body is empty.', code: 'AVATAR_UPLOAD_EMPTY' }));
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
await objectStorageClient.putObject(draft.storageKey, body, draft.mimeType || 'application/octet-stream');
|
|
1299
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1300
|
+
res.end(JSON.stringify({ success: true, draftId, relativePath: draft.storageKey }));
|
|
1301
|
+
}
|
|
1302
|
+
catch (error) {
|
|
1303
|
+
const statusCode = resolveErrorStatusCode(error, 400);
|
|
1304
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
1305
|
+
res.end(JSON.stringify({
|
|
1306
|
+
success: false,
|
|
1307
|
+
error: String(error?.message || 'Failed to upload avatar.'),
|
|
1308
|
+
code: error?.code
|
|
1309
|
+
}));
|
|
1310
|
+
}
|
|
1311
|
+
});
|
|
540
1312
|
addRoute('POST', '/api/taskforce/auth/profile/avatar/finalize', async (req, res) => {
|
|
541
1313
|
if (!requireCloudRuntime(res, 'Authentication'))
|
|
542
1314
|
return;
|
|
@@ -708,10 +1480,11 @@ export function registerAuthRoutes(deps) {
|
|
|
708
1480
|
};
|
|
709
1481
|
}
|
|
710
1482
|
}
|
|
711
|
-
const cookie = createSessionTokenCookie(`
|
|
1483
|
+
const cookie = createSessionTokenCookie(`usr:${access.userId}`, authConfig, {
|
|
712
1484
|
role: access.role,
|
|
713
1485
|
workspaceId: access.workspaceId,
|
|
714
1486
|
userId: access.userId,
|
|
1487
|
+
identityId: authResult.access?.identityId,
|
|
715
1488
|
...resolveSessionCookieAttributesForRequest(req, authConfig)
|
|
716
1489
|
});
|
|
717
1490
|
auditAuthAction(req, 'auth-login-success', {
|
|
@@ -1016,7 +1789,7 @@ export function registerAuthRoutes(deps) {
|
|
|
1016
1789
|
const password = String(body?.password || '');
|
|
1017
1790
|
if (!token || !password) {
|
|
1018
1791
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1019
|
-
res.end(JSON.stringify({ success: false, error: 'Token and password are required.' }));
|
|
1792
|
+
res.end(JSON.stringify({ success: false, error: 'Token and password are required.', code: 'TOKEN_PASSWORD_REQUIRED' }));
|
|
1020
1793
|
return;
|
|
1021
1794
|
}
|
|
1022
1795
|
try {
|
|
@@ -1041,7 +1814,7 @@ export function registerAuthRoutes(deps) {
|
|
|
1041
1814
|
statusCode: resolveErrorStatusCode(error, 400)
|
|
1042
1815
|
});
|
|
1043
1816
|
res.writeHead(resolveErrorStatusCode(error, 400), { 'Content-Type': 'application/json' });
|
|
1044
|
-
res.end(JSON.stringify({ success: false, error: String(error?.message || 'Unable to reset password') }));
|
|
1817
|
+
res.end(JSON.stringify({ success: false, error: String(error?.message || 'Unable to reset password'), code: error?.code }));
|
|
1045
1818
|
}
|
|
1046
1819
|
});
|
|
1047
1820
|
// === SECTION: Invite flows ===
|
|
@@ -1065,6 +1838,18 @@ export function registerAuthRoutes(deps) {
|
|
|
1065
1838
|
res.end(JSON.stringify({ success: false, state: details.state, error: 'Invalid or expired invite token.' }));
|
|
1066
1839
|
return;
|
|
1067
1840
|
}
|
|
1841
|
+
// Augment availableMethods with enabled OAuth providers for pending_setup invitees
|
|
1842
|
+
const oauthProviders = core.getGlobalSettings().oauthProviders || {};
|
|
1843
|
+
const enabledOAuthProviders = [];
|
|
1844
|
+
if (oauthProviders.google?.enabled)
|
|
1845
|
+
enabledOAuthProviders.push('google');
|
|
1846
|
+
if (oauthProviders.github?.enabled)
|
|
1847
|
+
enabledOAuthProviders.push('github');
|
|
1848
|
+
if (oauthProviders.apple?.enabled)
|
|
1849
|
+
enabledOAuthProviders.push('apple');
|
|
1850
|
+
const augmentedMethods = details.inviteeState === 'pending_setup'
|
|
1851
|
+
? [...(details.availableMethods || []), ...enabledOAuthProviders.filter(p => !(details.availableMethods || []).includes(p))]
|
|
1852
|
+
: (details.availableMethods || []);
|
|
1068
1853
|
auditAuthAction(req, 'auth-invite-opened', {
|
|
1069
1854
|
userId: details.userId,
|
|
1070
1855
|
email: details.email,
|
|
@@ -1078,6 +1863,8 @@ export function registerAuthRoutes(deps) {
|
|
|
1078
1863
|
state: details.state,
|
|
1079
1864
|
email: details.email,
|
|
1080
1865
|
workspaceId: details.workspaceId,
|
|
1866
|
+
inviteeState: details.inviteeState,
|
|
1867
|
+
availableMethods: augmentedMethods,
|
|
1081
1868
|
inviteeKind: details.inviteeKind,
|
|
1082
1869
|
passwordRequired: details.passwordRequired === true
|
|
1083
1870
|
}));
|
|
@@ -1107,10 +1894,11 @@ export function registerAuthRoutes(deps) {
|
|
|
1107
1894
|
}
|
|
1108
1895
|
try {
|
|
1109
1896
|
const accepted = core.acceptInviteWithToken({ token, password });
|
|
1110
|
-
const cookie = createSessionTokenCookie(`
|
|
1897
|
+
const cookie = createSessionTokenCookie(`usr:${accepted.userId}`, authConfig, {
|
|
1111
1898
|
role: accepted.role,
|
|
1112
1899
|
workspaceId: accepted.workspaceId,
|
|
1113
1900
|
userId: accepted.userId,
|
|
1901
|
+
identityId: accepted.identityId,
|
|
1114
1902
|
...resolveSessionCookieAttributesForRequest(req, authConfig)
|
|
1115
1903
|
});
|
|
1116
1904
|
res.setHeader('Set-Cookie', cookie);
|
|
@@ -1163,11 +1951,12 @@ export function registerAuthRoutes(deps) {
|
|
|
1163
1951
|
}
|
|
1164
1952
|
try {
|
|
1165
1953
|
const accepted = core.joinInviteWithAuthenticatedUser({ token, userId: access.userId });
|
|
1166
|
-
const sessionId = String(access.sessionId || `
|
|
1954
|
+
const sessionId = String(access.sessionId || `usr:${accepted.userId}`).trim() || `usr:${accepted.userId}`;
|
|
1167
1955
|
const cookie = createSessionTokenCookie(sessionId, authConfig, {
|
|
1168
1956
|
role: accepted.role,
|
|
1169
1957
|
workspaceId: accepted.workspaceId,
|
|
1170
1958
|
userId: accepted.userId,
|
|
1959
|
+
identityId: access.identityId || undefined,
|
|
1171
1960
|
...resolveSessionCookieAttributesForRequest(req, authConfig)
|
|
1172
1961
|
});
|
|
1173
1962
|
res.setHeader('Set-Cookie', cookie);
|
|
@@ -1236,7 +2025,7 @@ export function registerAuthRoutes(deps) {
|
|
|
1236
2025
|
return;
|
|
1237
2026
|
}
|
|
1238
2027
|
try {
|
|
1239
|
-
const userId =
|
|
2028
|
+
const userId = resolveAuthenticatedUserId(req);
|
|
1240
2029
|
const payload = core.getAccountAccessStatus(userId);
|
|
1241
2030
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1242
2031
|
res.end(JSON.stringify(payload));
|
|
@@ -1359,6 +2148,31 @@ export function registerAuthRoutes(deps) {
|
|
|
1359
2148
|
res.end(JSON.stringify({ error: String(error?.message || 'Unable to build plan preview'), code: error?.code, details: error?.details }));
|
|
1360
2149
|
}
|
|
1361
2150
|
});
|
|
2151
|
+
// GET /api/taskforce/account/ai-profile-seat-usage - Read cloud-authoritative AI seat usage for the current workspace.
|
|
2152
|
+
addRoute('GET', '/api/taskforce/account/ai-profile-seat-usage', async (req, res) => {
|
|
2153
|
+
if (!ensureCloudPlanData(req, res))
|
|
2154
|
+
return;
|
|
2155
|
+
if (!ensureAuthenticatedUser(req)) {
|
|
2156
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
2157
|
+
res.end(JSON.stringify({ error: 'Authentication required.' }));
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
try {
|
|
2161
|
+
const wid = requestWorkspaceId(req);
|
|
2162
|
+
const aiProfileSeatUsage = core.countAiProfileUsage(wid);
|
|
2163
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2164
|
+
res.end(JSON.stringify({ aiProfileSeatUsage }));
|
|
2165
|
+
}
|
|
2166
|
+
catch (error) {
|
|
2167
|
+
const statusCode = resolveErrorStatusCode(error, 400);
|
|
2168
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
|
2169
|
+
res.end(JSON.stringify({
|
|
2170
|
+
error: String(error?.message || 'Unable to build AI profile seat usage'),
|
|
2171
|
+
code: error?.code,
|
|
2172
|
+
details: error?.details
|
|
2173
|
+
}));
|
|
2174
|
+
}
|
|
2175
|
+
});
|
|
1362
2176
|
// === SECTION: Workspace team management (workspace admin) ===
|
|
1363
2177
|
// GET /api/taskforce/auth/workspace-members - Auth-backed workspace member list for local runtime identity hydration.
|
|
1364
2178
|
addRoute('GET', '/api/taskforce/auth/workspace-members', async (req, res) => {
|
|
@@ -1383,12 +2197,17 @@ export function registerAuthRoutes(deps) {
|
|
|
1383
2197
|
res.end(JSON.stringify({ error: 'Authentication required.' }));
|
|
1384
2198
|
return;
|
|
1385
2199
|
}
|
|
2200
|
+
const startedAt = getRouteTimingNow();
|
|
1386
2201
|
const wid = requestWorkspaceId(req);
|
|
2202
|
+
const traceId = requestBootstrapTraceId(req);
|
|
2203
|
+
const adminRoleStartedAt = getRouteTimingNow();
|
|
1387
2204
|
const includeProfileToken = context.authConfig?.enabled !== true || ensureWorkspaceAdminRole(req);
|
|
1388
|
-
const
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
.
|
|
2205
|
+
const adminRoleResolvedAt = getRouteTimingNow();
|
|
2206
|
+
const usersStartedAt = getRouteTimingNow();
|
|
2207
|
+
const activeWorkspaceUsers = core.listWorkspaceUsers(wid)
|
|
2208
|
+
.filter((user) => String(user.status || '').trim().toLowerCase() === 'active' && user.disabled !== true);
|
|
2209
|
+
const usersLoadedAt = getRouteTimingNow();
|
|
2210
|
+
const workspaceUsers = activeWorkspaceUsers.map((user) => ({
|
|
1392
2211
|
value: user.userId,
|
|
1393
2212
|
label: String(user.displayName || user.email || user.userId).trim() || user.userId,
|
|
1394
2213
|
icon: 'User',
|
|
@@ -1397,11 +2216,15 @@ export function registerAuthRoutes(deps) {
|
|
|
1397
2216
|
userId: user.userId,
|
|
1398
2217
|
email: user.email,
|
|
1399
2218
|
displayName: user.displayName ?? null,
|
|
2219
|
+
avatarUrl: user.avatarUrl ?? null,
|
|
1400
2220
|
status: user.status,
|
|
1401
2221
|
disabled: user.disabled === true
|
|
1402
2222
|
}));
|
|
2223
|
+
const usersMappedAt = getRouteTimingNow();
|
|
2224
|
+
const aiProfileDiagnostics = { metadataBackfillCount: 0 };
|
|
2225
|
+
const aiProfilesStartedAt = getRouteTimingNow();
|
|
1403
2226
|
const aiProfiles = core
|
|
1404
|
-
.listAiProfiles(wid)
|
|
2227
|
+
.listAiProfiles(wid, { diagnostics: aiProfileDiagnostics })
|
|
1405
2228
|
.map((profile) => ({
|
|
1406
2229
|
value: profile.id,
|
|
1407
2230
|
label: profile.name,
|
|
@@ -1409,19 +2232,90 @@ export function registerAuthRoutes(deps) {
|
|
|
1409
2232
|
username: profile.username,
|
|
1410
2233
|
icon: profile.icon,
|
|
1411
2234
|
color: profile.color,
|
|
2235
|
+
avatarUrl: profile.avatarUrl ?? null,
|
|
2236
|
+
avatarSourceUrl: profile.avatarSourceUrl ?? null,
|
|
1412
2237
|
kind: 'agent',
|
|
1413
2238
|
description: profile.description ?? null,
|
|
1414
2239
|
role: profile.role ?? null,
|
|
1415
2240
|
provider: profile.provider ?? null,
|
|
1416
2241
|
model: profile.model ?? null,
|
|
1417
2242
|
surfaceType: profile.surfaceType ?? null,
|
|
2243
|
+
seatScope: profile.seatScope ?? null,
|
|
2244
|
+
archivedAt: profile.archivedAt ?? null,
|
|
1418
2245
|
createdAt: profile.createdAt,
|
|
1419
2246
|
updatedAt: profile.updatedAt,
|
|
1420
2247
|
lastActiveAt: profile.lastActiveAt ?? null
|
|
1421
2248
|
}));
|
|
2249
|
+
const aiProfilesLoadedAt = getRouteTimingNow();
|
|
2250
|
+
const seatUsageStartedAt = getRouteTimingNow();
|
|
2251
|
+
const aiProfileSeatUsage = core.countAiProfileUsage(wid);
|
|
2252
|
+
const seatUsageLoadedAt = getRouteTimingNow();
|
|
1422
2253
|
const assignees = [...workspaceUsers, ...aiProfiles];
|
|
2254
|
+
const serializeStartedAt = getRouteTimingNow();
|
|
2255
|
+
const serialized = serializeJsonWithTiming({ assignees, aiProfileSeatUsage }, serializeStartedAt);
|
|
2256
|
+
const totalDurationMs = Math.max(0, getRouteTimingNow() - startedAt);
|
|
2257
|
+
setServerTimingHeader(res, [
|
|
2258
|
+
{ name: 'assignee-options', durationMs: totalDurationMs },
|
|
2259
|
+
{ name: 'assignee-options-admin-role', durationMs: adminRoleResolvedAt - adminRoleStartedAt },
|
|
2260
|
+
{ name: 'assignee-options-users', durationMs: usersLoadedAt - usersStartedAt },
|
|
2261
|
+
{ name: 'assignee-options-users-map', durationMs: usersMappedAt - usersLoadedAt },
|
|
2262
|
+
{ name: 'assignee-options-ai-profiles', durationMs: aiProfilesLoadedAt - aiProfilesStartedAt },
|
|
2263
|
+
{ name: 'assignee-options-seat-usage', durationMs: seatUsageLoadedAt - seatUsageStartedAt },
|
|
2264
|
+
{ name: 'assignee-options-json', durationMs: serialized.durationMs },
|
|
2265
|
+
]);
|
|
2266
|
+
logSlowBootstrapRoute({
|
|
2267
|
+
route: '/api/taskforce/workspace/assignee-options',
|
|
2268
|
+
workspaceId: wid,
|
|
2269
|
+
traceId,
|
|
2270
|
+
durationMs: totalDurationMs,
|
|
2271
|
+
details: {
|
|
2272
|
+
humanAssigneeCount: workspaceUsers.length,
|
|
2273
|
+
workspaceUserCount: activeWorkspaceUsers.length,
|
|
2274
|
+
aiProfileCount: aiProfiles.length,
|
|
2275
|
+
metadataBackfillCount: aiProfileDiagnostics.metadataBackfillCount,
|
|
2276
|
+
responseBytes: serialized.byteLength,
|
|
2277
|
+
adminRoleMs: Math.max(0, adminRoleResolvedAt - adminRoleStartedAt),
|
|
2278
|
+
listWorkspaceUsersMs: Math.max(0, usersLoadedAt - usersStartedAt),
|
|
2279
|
+
workspaceUsersMapMs: Math.max(0, usersMappedAt - usersLoadedAt),
|
|
2280
|
+
listAiProfilesMs: Math.max(0, aiProfilesLoadedAt - aiProfilesStartedAt),
|
|
2281
|
+
countAiProfileUsageMs: Math.max(0, seatUsageLoadedAt - seatUsageStartedAt),
|
|
2282
|
+
jsonSerializeMs: serialized.durationMs,
|
|
2283
|
+
},
|
|
2284
|
+
});
|
|
1423
2285
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1424
|
-
res.end(
|
|
2286
|
+
res.end(serialized.body);
|
|
2287
|
+
});
|
|
2288
|
+
// POST /api/taskforce/workspace/ai-profiles/avatar - Assign or clear an AI profile avatar image.
|
|
2289
|
+
addRoute('POST', '/api/taskforce/workspace/ai-profiles/avatar', async (req, res) => {
|
|
2290
|
+
if (!ensureWorkspaceAdminRole(req)) {
|
|
2291
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2292
|
+
res.end(JSON.stringify({ error: 'Forbidden: owner or admin role required.' }));
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
const body = await parseJsonBody(req);
|
|
2296
|
+
const wid = requestWorkspaceId(req);
|
|
2297
|
+
const profileId = String(body?.profileId || '').trim();
|
|
2298
|
+
const avatarUrl = body?.avatarUrl === null ? null : String(body?.avatarUrl || '').trim();
|
|
2299
|
+
const avatarSourceUrl = body?.avatarSourceUrl === undefined
|
|
2300
|
+
? undefined
|
|
2301
|
+
: body?.avatarSourceUrl === null
|
|
2302
|
+
? null
|
|
2303
|
+
: String(body?.avatarSourceUrl || '').trim();
|
|
2304
|
+
if (!profileId) {
|
|
2305
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2306
|
+
res.end(JSON.stringify({ error: 'profileId is required.' }));
|
|
2307
|
+
return;
|
|
2308
|
+
}
|
|
2309
|
+
try {
|
|
2310
|
+
const profile = core.updateAiProfileAvatar({ workspaceId: wid, profileId, avatarUrl, avatarSourceUrl });
|
|
2311
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2312
|
+
res.end(JSON.stringify({ success: true, profile }));
|
|
2313
|
+
}
|
|
2314
|
+
catch (err) {
|
|
2315
|
+
const status = err?.statusCode ?? 500;
|
|
2316
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
2317
|
+
res.end(JSON.stringify({ error: err?.message ?? 'Failed to update AI profile avatar.' }));
|
|
2318
|
+
}
|
|
1425
2319
|
});
|
|
1426
2320
|
// POST /api/taskforce/workspace/ai-profiles/merge - Merge a duplicate AI profile into another, reassigning all references.
|
|
1427
2321
|
addRoute('POST', '/api/taskforce/workspace/ai-profiles/merge', async (req, res) => {
|
|
@@ -1450,6 +2344,41 @@ export function registerAuthRoutes(deps) {
|
|
|
1450
2344
|
res.end(JSON.stringify({ error: err?.message ?? 'Merge failed.' }));
|
|
1451
2345
|
}
|
|
1452
2346
|
});
|
|
2347
|
+
// POST /api/taskforce/workspace/ai-profiles/archive - Remove an AI profile from the active roster without deleting its history.
|
|
2348
|
+
addRoute('POST', '/api/taskforce/workspace/ai-profiles/archive', async (req, res) => {
|
|
2349
|
+
if (!ensureWorkspaceAdminRole(req)) {
|
|
2350
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
2351
|
+
res.end(JSON.stringify({ error: 'Forbidden: owner or admin role required.' }));
|
|
2352
|
+
return;
|
|
2353
|
+
}
|
|
2354
|
+
const body = await parseJsonBody(req);
|
|
2355
|
+
const wid = requestWorkspaceId(req);
|
|
2356
|
+
const profileId = String(body.profileId || '').trim();
|
|
2357
|
+
const reason = String(body.reason || '').trim() || 'manual_archive';
|
|
2358
|
+
if (!profileId) {
|
|
2359
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
2360
|
+
res.end(JSON.stringify({ error: 'profileId is required.' }));
|
|
2361
|
+
return;
|
|
2362
|
+
}
|
|
2363
|
+
try {
|
|
2364
|
+
const profile = core.archiveAiProfile({
|
|
2365
|
+
workspaceId: wid,
|
|
2366
|
+
profileId,
|
|
2367
|
+
archivedBy: requestUserId(req),
|
|
2368
|
+
reason
|
|
2369
|
+
});
|
|
2370
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
2371
|
+
res.end(JSON.stringify({ profile }));
|
|
2372
|
+
}
|
|
2373
|
+
catch (err) {
|
|
2374
|
+
const status = err?.statusCode ?? 500;
|
|
2375
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
2376
|
+
res.end(JSON.stringify({
|
|
2377
|
+
error: err?.message ?? 'Failed to archive AI profile.',
|
|
2378
|
+
details: err?.details ?? null
|
|
2379
|
+
}));
|
|
2380
|
+
}
|
|
2381
|
+
});
|
|
1453
2382
|
// GET /api/taskforce/admin/users - List workspace users for team management.
|
|
1454
2383
|
addRoute('GET', '/api/taskforce/admin/users', async (req, res) => {
|
|
1455
2384
|
if (!ensureWorkspaceAdminRole(req)) {
|
|
@@ -1458,8 +2387,9 @@ export function registerAuthRoutes(deps) {
|
|
|
1458
2387
|
return;
|
|
1459
2388
|
}
|
|
1460
2389
|
const wid = requestWorkspaceId(req);
|
|
2390
|
+
const actorUserId = resolveAuthenticatedUserId(req);
|
|
1461
2391
|
const gate = core.evaluateFeatureAccess({
|
|
1462
|
-
userId:
|
|
2392
|
+
userId: actorUserId,
|
|
1463
2393
|
workspaceId: wid,
|
|
1464
2394
|
featureKey: 'collaboration.team_management'
|
|
1465
2395
|
});
|
|
@@ -1474,13 +2404,14 @@ export function registerAuthRoutes(deps) {
|
|
|
1474
2404
|
});
|
|
1475
2405
|
// POST /api/taskforce/admin/users/invite - Invite a user into a workspace.
|
|
1476
2406
|
addRoute('POST', '/api/taskforce/admin/users/invite', async (req, res) => {
|
|
1477
|
-
|
|
2407
|
+
const body = await parseJsonBody(req);
|
|
2408
|
+
const wid = String(body?.workspaceId || requestWorkspaceId(req)).trim() || 'default';
|
|
2409
|
+
if (!ensureWorkspaceAdminRole(req, wid)) {
|
|
1478
2410
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1479
2411
|
res.end(JSON.stringify({ error: 'Forbidden: owner or admin role required.' }));
|
|
1480
2412
|
return;
|
|
1481
2413
|
}
|
|
1482
|
-
const
|
|
1483
|
-
const wid = String(body?.workspaceId || requestWorkspaceId(req)).trim() || 'default';
|
|
2414
|
+
const actorUserId = resolveAuthenticatedUserId(req);
|
|
1484
2415
|
const email = String(body?.email || '').trim().toLowerCase();
|
|
1485
2416
|
const role = String(body?.role || 'member').trim().toLowerCase();
|
|
1486
2417
|
const permissionMode = String(body?.permissionMode || 'read-write').trim().toLowerCase();
|
|
@@ -1490,7 +2421,7 @@ export function registerAuthRoutes(deps) {
|
|
|
1490
2421
|
return;
|
|
1491
2422
|
}
|
|
1492
2423
|
const inviteGate = core.evaluateFeatureAccess({
|
|
1493
|
-
userId:
|
|
2424
|
+
userId: actorUserId,
|
|
1494
2425
|
workspaceId: wid,
|
|
1495
2426
|
featureKey: 'collaboration.invites'
|
|
1496
2427
|
});
|
|
@@ -1513,8 +2444,8 @@ export function registerAuthRoutes(deps) {
|
|
|
1513
2444
|
let inviteEmailError = null;
|
|
1514
2445
|
if (inviteToken.token) {
|
|
1515
2446
|
const workspace = core.getWorkspaceProfile(wid);
|
|
1516
|
-
const actorIdentity = core.resolveUserIdentity(
|
|
1517
|
-
const inviterEmailOrLabel = String(actorIdentity?.email ||
|
|
2447
|
+
const actorIdentity = core.resolveUserIdentity(actorUserId);
|
|
2448
|
+
const inviterEmailOrLabel = String(actorIdentity?.email || actorUserId || 'Taskforce').trim();
|
|
1518
2449
|
const delivery = await deliverInviteEmail(email, inviteToken.token, req, {
|
|
1519
2450
|
workspaceName: String(workspace?.name || wid),
|
|
1520
2451
|
inviterEmailOrLabel
|
|
@@ -1529,7 +2460,7 @@ export function registerAuthRoutes(deps) {
|
|
|
1529
2460
|
role,
|
|
1530
2461
|
permissionMode: role === 'member' ? (permissionMode === 'read-only' ? 'read-only' : 'read-write') : 'read-write',
|
|
1531
2462
|
inviteEmailSent
|
|
1532
|
-
});
|
|
2463
|
+
}, wid);
|
|
1533
2464
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1534
2465
|
res.end(JSON.stringify({
|
|
1535
2466
|
success: true,
|
|
@@ -1546,13 +2477,14 @@ export function registerAuthRoutes(deps) {
|
|
|
1546
2477
|
});
|
|
1547
2478
|
// PATCH /api/taskforce/admin/users/:id/role - Update workspace role for a user.
|
|
1548
2479
|
addRoute('PATCH', '/api/taskforce/admin/users/:id/role', async (req, res, params) => {
|
|
1549
|
-
|
|
2480
|
+
const body = await parseJsonBody(req);
|
|
2481
|
+
const wid = String(body?.workspaceId || requestWorkspaceId(req)).trim() || 'default';
|
|
2482
|
+
if (!ensureWorkspaceAdminRole(req, wid)) {
|
|
1550
2483
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1551
2484
|
res.end(JSON.stringify({ error: 'Forbidden: owner or admin role required.' }));
|
|
1552
2485
|
return;
|
|
1553
2486
|
}
|
|
1554
|
-
const
|
|
1555
|
-
const wid = String(body?.workspaceId || requestWorkspaceId(req)).trim() || 'default';
|
|
2487
|
+
const actorUserId = resolveAuthenticatedUserId(req);
|
|
1556
2488
|
const role = String(body?.role || '').trim().toLowerCase();
|
|
1557
2489
|
if (role !== 'owner' && role !== 'admin' && role !== 'member') {
|
|
1558
2490
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
@@ -1560,7 +2492,7 @@ export function registerAuthRoutes(deps) {
|
|
|
1560
2492
|
return;
|
|
1561
2493
|
}
|
|
1562
2494
|
const gate = core.evaluateFeatureAccess({
|
|
1563
|
-
userId:
|
|
2495
|
+
userId: actorUserId,
|
|
1564
2496
|
workspaceId: wid,
|
|
1565
2497
|
featureKey: 'collaboration.team_management'
|
|
1566
2498
|
});
|
|
@@ -1576,7 +2508,7 @@ export function registerAuthRoutes(deps) {
|
|
|
1576
2508
|
res.end(JSON.stringify({ error: 'User membership not found.' }));
|
|
1577
2509
|
return;
|
|
1578
2510
|
}
|
|
1579
|
-
auditAuthAction(req, 'update-workspace-user-role', { workspaceId: wid, targetUserId: params.id, role });
|
|
2511
|
+
auditAuthAction(req, 'update-workspace-user-role', { workspaceId: wid, targetUserId: params.id, role }, wid);
|
|
1580
2512
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1581
2513
|
res.end(JSON.stringify({ success: true }));
|
|
1582
2514
|
}
|
|
@@ -1588,13 +2520,14 @@ export function registerAuthRoutes(deps) {
|
|
|
1588
2520
|
});
|
|
1589
2521
|
// PATCH /api/taskforce/admin/users/:id/permission-mode - Update member permission mode.
|
|
1590
2522
|
addRoute('PATCH', '/api/taskforce/admin/users/:id/permission-mode', async (req, res, params) => {
|
|
1591
|
-
|
|
2523
|
+
const body = await parseJsonBody(req);
|
|
2524
|
+
const wid = String(body?.workspaceId || requestWorkspaceId(req)).trim() || 'default';
|
|
2525
|
+
if (!ensureWorkspaceAdminRole(req, wid)) {
|
|
1592
2526
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1593
2527
|
res.end(JSON.stringify({ error: 'Forbidden: owner or admin role required.' }));
|
|
1594
2528
|
return;
|
|
1595
2529
|
}
|
|
1596
|
-
const
|
|
1597
|
-
const wid = String(body?.workspaceId || requestWorkspaceId(req)).trim() || 'default';
|
|
2530
|
+
const actorUserId = resolveAuthenticatedUserId(req);
|
|
1598
2531
|
const mode = String(body?.mode || '').trim().toLowerCase();
|
|
1599
2532
|
if (mode !== 'read-only' && mode !== 'read-write') {
|
|
1600
2533
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
@@ -1602,7 +2535,7 @@ export function registerAuthRoutes(deps) {
|
|
|
1602
2535
|
return;
|
|
1603
2536
|
}
|
|
1604
2537
|
const gate = core.evaluateFeatureAccess({
|
|
1605
|
-
userId:
|
|
2538
|
+
userId: actorUserId,
|
|
1606
2539
|
workspaceId: wid,
|
|
1607
2540
|
featureKey: 'collaboration.team_management'
|
|
1608
2541
|
});
|
|
@@ -1618,7 +2551,7 @@ export function registerAuthRoutes(deps) {
|
|
|
1618
2551
|
res.end(JSON.stringify({ error: 'User membership not found.' }));
|
|
1619
2552
|
return;
|
|
1620
2553
|
}
|
|
1621
|
-
auditAuthAction(req, 'update-workspace-user-permission-mode', { workspaceId: wid, targetUserId: params.id, mode });
|
|
2554
|
+
auditAuthAction(req, 'update-workspace-user-permission-mode', { workspaceId: wid, targetUserId: params.id, mode }, wid);
|
|
1622
2555
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1623
2556
|
res.end(JSON.stringify({ success: true }));
|
|
1624
2557
|
}
|
|
@@ -1630,16 +2563,17 @@ export function registerAuthRoutes(deps) {
|
|
|
1630
2563
|
});
|
|
1631
2564
|
// PATCH /api/taskforce/admin/users/:id/disable - Disable or reactivate a workspace user.
|
|
1632
2565
|
addRoute('PATCH', '/api/taskforce/admin/users/:id/disable', async (req, res, params) => {
|
|
1633
|
-
|
|
2566
|
+
const body = await parseJsonBody(req);
|
|
2567
|
+
const wid = String(body?.workspaceId || requestWorkspaceId(req)).trim() || 'default';
|
|
2568
|
+
if (!ensureWorkspaceAdminRole(req, wid)) {
|
|
1634
2569
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1635
2570
|
res.end(JSON.stringify({ error: 'Forbidden: owner or admin role required.' }));
|
|
1636
2571
|
return;
|
|
1637
2572
|
}
|
|
1638
|
-
const
|
|
1639
|
-
const wid = String(body?.workspaceId || requestWorkspaceId(req)).trim() || 'default';
|
|
2573
|
+
const actorUserId = resolveAuthenticatedUserId(req);
|
|
1640
2574
|
const disabled = body?.disabled === true;
|
|
1641
2575
|
const gate = core.evaluateFeatureAccess({
|
|
1642
|
-
userId:
|
|
2576
|
+
userId: actorUserId,
|
|
1643
2577
|
workspaceId: wid,
|
|
1644
2578
|
featureKey: 'collaboration.team_management'
|
|
1645
2579
|
});
|
|
@@ -1655,7 +2589,7 @@ export function registerAuthRoutes(deps) {
|
|
|
1655
2589
|
res.end(JSON.stringify({ error: 'User membership not found.' }));
|
|
1656
2590
|
return;
|
|
1657
2591
|
}
|
|
1658
|
-
auditAuthAction(req, 'set-workspace-user-disabled', { workspaceId: wid, targetUserId: params.id, disabled });
|
|
2592
|
+
auditAuthAction(req, 'set-workspace-user-disabled', { workspaceId: wid, targetUserId: params.id, disabled }, wid);
|
|
1659
2593
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1660
2594
|
res.end(JSON.stringify({ success: true }));
|
|
1661
2595
|
}
|
|
@@ -1667,15 +2601,16 @@ export function registerAuthRoutes(deps) {
|
|
|
1667
2601
|
});
|
|
1668
2602
|
// POST /api/taskforce/admin/users/:id/invite/revoke - Revoke invite acceptance token.
|
|
1669
2603
|
addRoute('POST', '/api/taskforce/admin/users/:id/invite/revoke', async (req, res, params) => {
|
|
1670
|
-
|
|
2604
|
+
const body = await parseJsonBody(req);
|
|
2605
|
+
const wid = String(body?.workspaceId || requestWorkspaceId(req)).trim() || 'default';
|
|
2606
|
+
if (!ensureWorkspaceAdminRole(req, wid)) {
|
|
1671
2607
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1672
2608
|
res.end(JSON.stringify({ error: 'Forbidden: owner or admin role required.' }));
|
|
1673
2609
|
return;
|
|
1674
2610
|
}
|
|
1675
|
-
const
|
|
1676
|
-
const wid = String(body?.workspaceId || requestWorkspaceId(req)).trim() || 'default';
|
|
2611
|
+
const actorUserId = resolveAuthenticatedUserId(req);
|
|
1677
2612
|
const gate = core.evaluateFeatureAccess({
|
|
1678
|
-
userId:
|
|
2613
|
+
userId: actorUserId,
|
|
1679
2614
|
workspaceId: wid,
|
|
1680
2615
|
featureKey: 'collaboration.team_management'
|
|
1681
2616
|
});
|
|
@@ -1685,21 +2620,22 @@ export function registerAuthRoutes(deps) {
|
|
|
1685
2620
|
return;
|
|
1686
2621
|
}
|
|
1687
2622
|
const revoked = core.revokeInviteAcceptanceForUser(params.id);
|
|
1688
|
-
auditAuthAction(req, 'revoke-invite', { workspaceId: wid, targetUserId: params.id, revoked: revoked.revoked });
|
|
2623
|
+
auditAuthAction(req, 'revoke-invite', { workspaceId: wid, targetUserId: params.id, revoked: revoked.revoked }, wid);
|
|
1689
2624
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1690
2625
|
res.end(JSON.stringify({ success: true, revoked: revoked.revoked }));
|
|
1691
2626
|
});
|
|
1692
2627
|
// DELETE /api/taskforce/admin/users/:id - Remove workspace membership.
|
|
1693
2628
|
addRoute('DELETE', '/api/taskforce/admin/users/:id', async (req, res, params) => {
|
|
1694
|
-
|
|
2629
|
+
const reqUrl = new URL(req.url || '/api/taskforce/admin/users/:id', 'http://localhost');
|
|
2630
|
+
const wid = String(reqUrl.searchParams.get('workspaceId') || requestWorkspaceId(req)).trim() || 'default';
|
|
2631
|
+
if (!ensureWorkspaceAdminRole(req, wid)) {
|
|
1695
2632
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
1696
2633
|
res.end(JSON.stringify({ error: 'Forbidden: owner or admin role required.' }));
|
|
1697
2634
|
return;
|
|
1698
2635
|
}
|
|
1699
|
-
const
|
|
1700
|
-
const wid = String(reqUrl.searchParams.get('workspaceId') || requestWorkspaceId(req)).trim() || 'default';
|
|
2636
|
+
const actorUserId = resolveAuthenticatedUserId(req);
|
|
1701
2637
|
const gate = core.evaluateFeatureAccess({
|
|
1702
|
-
userId:
|
|
2638
|
+
userId: actorUserId,
|
|
1703
2639
|
workspaceId: wid,
|
|
1704
2640
|
featureKey: 'collaboration.team_management'
|
|
1705
2641
|
});
|
|
@@ -1715,7 +2651,7 @@ export function registerAuthRoutes(deps) {
|
|
|
1715
2651
|
res.end(JSON.stringify({ error: 'User membership not found.' }));
|
|
1716
2652
|
return;
|
|
1717
2653
|
}
|
|
1718
|
-
auditAuthAction(req, 'remove-workspace-user', { workspaceId: wid, targetUserId: params.id });
|
|
2654
|
+
auditAuthAction(req, 'remove-workspace-user', { workspaceId: wid, targetUserId: params.id }, wid);
|
|
1719
2655
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1720
2656
|
res.end(JSON.stringify({ success: true }));
|
|
1721
2657
|
}
|
|
@@ -1733,8 +2669,9 @@ export function registerAuthRoutes(deps) {
|
|
|
1733
2669
|
return;
|
|
1734
2670
|
}
|
|
1735
2671
|
const wid = requestWorkspaceId(req);
|
|
2672
|
+
const actorUserId = resolveAuthenticatedUserId(req);
|
|
1736
2673
|
const gate = core.evaluateFeatureAccess({
|
|
1737
|
-
userId:
|
|
2674
|
+
userId: actorUserId,
|
|
1738
2675
|
workspaceId: wid,
|
|
1739
2676
|
featureKey: 'collaboration.team_management'
|
|
1740
2677
|
});
|