@taskforcehq/taskforce 0.3.301 → 0.3.302

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/dist/Taskforce.module.css +26 -2
  2. package/dist/TaskforceCore.filter-persistence.test.js +1 -1
  3. package/dist/TaskforceCore.js +13 -24
  4. package/dist/TaskforceCore.test.js +167 -152
  5. package/dist/components/features/TaskSettings.js +14 -82
  6. package/dist/components/features/TaskSettings.test.js +33 -70
  7. package/dist/components/features/settings/TaxonomyLibraryManager.js +8 -2
  8. package/dist/components/features/settings/TaxonomyLibraryManager.test.js +1 -1
  9. package/dist/components/task/TaskActionHeader.js +6 -3
  10. package/dist/components/task/TaskCard.js +6 -3
  11. package/dist/components/task/TaskForm.js +2 -2
  12. package/dist/components/task/TaskForm.test.js +41 -2
  13. package/dist/components/ui/ReferenceBadgeButton.js +1 -1
  14. package/dist/components/views/PlansPage.js +9 -8
  15. package/dist/components/views/StandaloneLayout.js +466 -204
  16. package/dist/components/views/panels/PlanningDrawer.js +18 -1
  17. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +43 -0
  18. package/dist/components/views/standalone/modals/AccountHubModal.js +22 -0
  19. package/dist/components/views/standalone/modals/CreateWorkspaceConfirmModal.d.ts +9 -0
  20. package/dist/components/views/standalone/modals/CreateWorkspaceConfirmModal.js +8 -0
  21. package/dist/components/views/standalone/modals/EditProfileModal.d.ts +26 -0
  22. package/dist/components/views/standalone/modals/EditProfileModal.js +10 -0
  23. package/dist/components/views/standalone/modals/HelpModal.d.ts +9 -0
  24. package/dist/components/views/standalone/modals/HelpModal.js +8 -0
  25. package/dist/components/views/standalone/modals/ScheduleWarningModal.d.ts +13 -0
  26. package/dist/components/views/standalone/modals/ScheduleWarningModal.js +6 -0
  27. package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +61 -0
  28. package/dist/components/views/standalone/modals/SyncStatusModal.js +38 -0
  29. package/dist/components/views/standalone/modals/TeamManagementModal.d.ts +64 -0
  30. package/dist/components/views/standalone/modals/TeamManagementModal.js +10 -0
  31. package/dist/components/views/standalone/modals/types.d.ts +2 -0
  32. package/dist/components/views/standalone/modals/types.js +1 -0
  33. package/dist/components/views/teamManagementAccess.d.ts +1 -1
  34. package/dist/components/views/teamManagementAccess.js +2 -2
  35. package/dist/components/views/teamManagementAccess.test.js +3 -3
  36. package/dist/config/deployment.js +2 -2
  37. package/dist/config/deployment.test.js +5 -3
  38. package/dist/core/AiProfileService.js +7 -2
  39. package/dist/core/AiProfiles.test.js +19 -0
  40. package/dist/core/SyncReconciliationService.d.ts +2 -0
  41. package/dist/core/SyncReconciliationService.js +3 -0
  42. package/dist/core/TaskAuditTimeline.test.js +40 -0
  43. package/dist/core/Taskforce.d.ts +49 -0
  44. package/dist/core/Taskforce.ids.test.js +133 -0
  45. package/dist/core/Taskforce.js +241 -78
  46. package/dist/core/UserProfileAvatarDrafts.test.d.ts +1 -0
  47. package/dist/core/UserProfileAvatarDrafts.test.js +102 -0
  48. package/dist/core/taskReferencePolicy.d.ts +28 -0
  49. package/dist/core/taskReferencePolicy.js +189 -0
  50. package/dist/core/types.d.ts +2 -0
  51. package/dist/hooks/tasks/useTaskFormActions.js +3 -0
  52. package/dist/hooks/useTaskData.js +3 -2
  53. package/dist/hooks/useTaskData.test.js +66 -0
  54. package/dist/hooks/useTaskforce.d.ts +5 -0
  55. package/dist/hooks/useTaskforce.js +63 -15
  56. package/dist/hooks/useTaskforce.sync-behavior.test.js +185 -6
  57. package/dist/mcp/aiProfileBootstrap.d.ts +29 -0
  58. package/dist/mcp/aiProfileBootstrap.js +80 -0
  59. package/dist/mcp/aiProfileBootstrap.test.d.ts +1 -0
  60. package/dist/mcp/aiProfileBootstrap.test.js +68 -0
  61. package/dist/mcp/clientIntegrations.d.ts +2 -8
  62. package/dist/mcp/clientIntegrations.js +21 -40
  63. package/dist/mcp/clientIntegrations.test.js +28 -2
  64. package/dist/mcp/clientProfileDefaults.d.ts +12 -0
  65. package/dist/mcp/clientProfileDefaults.js +37 -0
  66. package/dist/mcp/runtime.d.ts +10 -7
  67. package/dist/mcp/runtime.js +41 -19
  68. package/dist/mcp/runtime.test.js +49 -2
  69. package/dist/migrations/taskSchemaMigrations.js +53 -1
  70. package/dist/qaOpen.test.d.ts +1 -0
  71. package/dist/qaOpen.test.js +56 -0
  72. package/dist/server/index.cookieProxy.test.js +1 -0
  73. package/dist/server/index.d.ts +1 -0
  74. package/dist/server/index.js +15 -1
  75. package/dist/server/index.test.js +14 -1
  76. package/dist/server/routes/admin.js +25 -22
  77. package/dist/server/routes/auth.d.ts +5 -0
  78. package/dist/server/routes/auth.js +350 -16
  79. package/dist/server/routes/billing.js +11 -1
  80. package/dist/server/routes/billing.test.js +32 -2
  81. package/dist/server/routes/documents.js +6 -1
  82. package/dist/server/routes/shared.d.ts +1 -1
  83. package/dist/server/routes/shared.js +11 -3
  84. package/dist/server/routes/sync.integration.test.js +130 -0
  85. package/dist/server/routes/sync.js +226 -1924
  86. package/dist/server/routes/tasks.js +18 -4
  87. package/dist/server/routes.js +26 -19
  88. package/dist/server/routes.test.js +352 -6
  89. package/dist/shared/runtimeContract.d.ts +1 -1
  90. package/dist/sync/collaborationSyncPayload.d.ts +17 -0
  91. package/dist/sync/collaborationSyncPayload.js +131 -0
  92. package/dist/sync/collaborationSyncPayload.test.d.ts +1 -0
  93. package/dist/sync/collaborationSyncPayload.test.js +104 -0
  94. package/dist/sync/collaborationSyncState.d.ts +15 -0
  95. package/dist/sync/collaborationSyncState.js +60 -0
  96. package/dist/sync/contentSyncPayload.d.ts +45 -0
  97. package/dist/sync/contentSyncPayload.js +104 -0
  98. package/dist/sync/contentSyncPayload.test.d.ts +1 -0
  99. package/dist/sync/contentSyncPayload.test.js +104 -0
  100. package/dist/sync/contentSyncState.d.ts +88 -0
  101. package/dist/sync/contentSyncState.js +743 -0
  102. package/dist/sync/planningSyncPayload.d.ts +9 -0
  103. package/dist/sync/planningSyncPayload.js +82 -0
  104. package/dist/sync/planningSyncPayload.test.d.ts +1 -0
  105. package/dist/sync/planningSyncPayload.test.js +68 -0
  106. package/dist/sync/syncApplyHandlers.d.ts +83 -0
  107. package/dist/sync/syncApplyHandlers.js +445 -0
  108. package/dist/sync/taskSyncPayload.d.ts +33 -0
  109. package/dist/sync/taskSyncPayload.js +126 -0
  110. package/dist/sync/taskSyncPayload.test.d.ts +1 -0
  111. package/dist/sync/taskSyncPayload.test.js +54 -0
  112. package/dist/sync/workspacePullFeed.d.ts +28 -0
  113. package/dist/sync/workspacePullFeed.js +301 -0
  114. package/dist/sync/workspacePullFeed.test.d.ts +1 -0
  115. package/dist/sync/workspacePullFeed.test.js +166 -0
  116. package/dist/sync/workspaceSyncModel.d.ts +4 -1
  117. package/dist/sync/workspaceSyncModel.js +9 -2
  118. package/dist/sync/workspaceSyncModel.test.js +29 -0
  119. package/dist/test/setup.js +1 -1
  120. package/dist/types.d.ts +1 -0
  121. package/dist/ui/assets/{AgentsModule-Bdq3T1Ts.js → AgentsModule-JG5jc3he.js} +1 -1
  122. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-jKUVo-PJ.js → AnnotatedAttachmentWorkspace-Dvi02Y7q.js} +1 -1
  123. package/dist/ui/assets/{DocumentWorkspace-DuQa-uIC.js → DocumentWorkspace-iys3zfca.js} +2 -2
  124. package/dist/ui/assets/{InitiativesModule-C8Pi2VTk.js → InitiativesModule-DJvLJI3A.js} +1 -1
  125. package/dist/ui/assets/PlansPage-BMPlpFpw.js +1 -0
  126. package/dist/ui/assets/TaskSettings-Bqd4cCzW.js +8 -0
  127. package/dist/ui/assets/{TaskSettings-BwB9NtmJ.css → TaskSettings-CnIBL_Eb.css} +1 -1
  128. package/dist/ui/assets/{WorkflowsModule-b6TLuHwJ.js → WorkflowsModule-BguHeQaV.js} +1 -1
  129. package/dist/ui/assets/index-MXeWoebC.css +1 -0
  130. package/dist/ui/assets/index-YJ4k5QNa.js +6 -0
  131. package/dist/ui/assets/{vendor-icons-QZyhEwgT.js → vendor-icons-C8EED3Oh.js} +1 -1
  132. package/dist/ui/index.html +3 -3
  133. package/dist/utils/billingStatusCache.d.ts +21 -0
  134. package/dist/utils/billingStatusCache.js +70 -0
  135. package/dist/utils/taskActivity.js +17 -4
  136. package/dist/utils/taskActivity.test.js +27 -0
  137. package/dist/utils/taskNormalization.js +10 -2
  138. package/dist/utils/taskReferences.d.ts +11 -1
  139. package/dist/utils/taskReferences.js +33 -1
  140. package/package.json +2 -1
  141. package/scripts/qa-open-lib.mjs +175 -0
  142. package/scripts/qa-open-lib.mjs.d.ts +49 -0
  143. package/scripts/qa-open.mjs +352 -0
  144. package/dist/ui/assets/PlansPage-B6BDECSi.js +0 -1
  145. package/dist/ui/assets/TaskSettings-DUJgRTPU.js +0 -8
  146. package/dist/ui/assets/index-BiiF1cLQ.css +0 -1
  147. package/dist/ui/assets/index-xBWLPOEb.js +0 -6
@@ -21,6 +21,7 @@
21
21
  * POST /api/taskforce/auth/logout
22
22
  * GET /api/taskforce/account/access-status
23
23
  * GET /api/taskforce/account/entitlements
24
+ * GET /api/taskforce/account/team-management-access
24
25
  * GET /api/taskforce/account/plan-preview
25
26
  * GET /api/taskforce/auth/workspace-members
26
27
  * GET /api/taskforce/workspace/assignee-options
@@ -33,15 +34,45 @@
33
34
  * DELETE /api/taskforce/admin/users/:id
34
35
  * GET /api/taskforce/admin/workspace-audit-logs
35
36
  */
37
+ import * as fs from 'fs';
38
+ import * as path from 'path';
39
+ import { randomUUID } from 'crypto';
36
40
  import { parseJsonBody } from '../routes.js';
37
41
  import { hasValidSession, createClearSessionCookie, createSessionTokenCookie, resolveRequestAccess, resolveSessionCookieAttributesForRequest, } from '../auth.js';
38
42
  import { resolveEffectiveEmailConfig, sendEmail } from '../email.js';
39
43
  import { resolveDeploymentConfig } from '../../config/deployment.js';
40
44
  import { resolveRuntimeModeContract } from '../../shared/runtimeContract.js';
41
- import { resolveErrorStatusCode } from './shared.js';
45
+ import { requestHeader, resolveErrorStatusCode } from './shared.js';
46
+ import { normalizeTaskDataRelativePath } from '../../storage/objectStorageClient.js';
42
47
  // === SECTION: Registration ===
43
48
  export function registerAuthRoutes(deps) {
44
- const { addRoute, core, context, requestWorkspaceId, requestUserId, ensureAppAccess, ensureAuthenticatedUser, ensureWorkspaceAdminRole, ensureCloudPlanData, requireCloudRuntime, auditAuthAction, resolveAdminRequestActor, shouldExposeDevAuthTokens, resolveRuntimeConfigBaseUrl, resolvePublicAuthBaseUrl, } = deps;
49
+ const { addRoute, core, context, requestWorkspaceId, requestUserId, ensureAppAccess, ensureAuthenticatedUser, ensureWorkspaceAdminRole, ensureCloudPlanData, requireCloudRuntime, auditAuthAction, resolveAdminRequestActor, shouldExposeDevAuthTokens, resolveRuntimeConfigBaseUrl, resolvePublicAuthBaseUrl, resolveEffectiveObjectStorageConfig, getObjectStorageClient, } = deps;
50
+ const normalizeTeamWorkspaceRole = (value) => {
51
+ const role = String(value || '').trim().toLowerCase();
52
+ if (role === 'owner' || role === 'admin' || role === 'member' || role === 'read-only')
53
+ return role;
54
+ return null;
55
+ };
56
+ const resolveBooleanSetting = (value, fallback) => {
57
+ if (typeof value === 'boolean')
58
+ return value;
59
+ if (typeof value === 'number')
60
+ return value !== 0;
61
+ const normalized = String(value || '').trim().toLowerCase();
62
+ if (!normalized)
63
+ return fallback;
64
+ if (['1', 'true', 'yes', 'on'].includes(normalized))
65
+ return true;
66
+ if (['0', 'false', 'no', 'off'].includes(normalized))
67
+ return false;
68
+ return fallback;
69
+ };
70
+ const resolveTeamPlanModeFromEntitlements = (payload) => {
71
+ const state = String(payload?.state || '').trim().toLowerCase();
72
+ const teamAllowed = payload?.features?.['collaboration.team_management']?.allowed === true;
73
+ const stateAllows = state === 'active' || state === 'trialing' || state === 'grace';
74
+ return teamAllowed && stateAllows ? 'team' : 'personal';
75
+ };
45
76
  // === SECTION: Email delivery helpers ===
46
77
  const getEmailConfig = () => {
47
78
  if (context.authConfig?.runtimeMode !== 'cloud') {
@@ -57,10 +88,10 @@ export function registerAuthRoutes(deps) {
57
88
  const authRateLimitWindowMsRaw = Number(authSettings.authRateLimitWindowMs);
58
89
  const signupSettings = core.getSignupMonetizationSettings();
59
90
  return {
60
- allowSelfRegistration: authSettings.allowSelfRegistration !== false,
61
- requireVerifiedEmail: authSettings.requireVerifiedEmail !== false,
62
- defaultNewUserBetaAccess: authSettings.defaultNewUserBetaAccess === true,
63
- authRateLimitEnabled: authSettings.authRateLimitEnabled !== false,
91
+ allowSelfRegistration: resolveBooleanSetting(authSettings.allowSelfRegistration, true),
92
+ requireVerifiedEmail: resolveBooleanSetting(authSettings.requireVerifiedEmail, true),
93
+ defaultNewUserBetaAccess: resolveBooleanSetting(authSettings.defaultNewUserBetaAccess, false),
94
+ authRateLimitEnabled: resolveBooleanSetting(authSettings.authRateLimitEnabled, true),
64
95
  authRateLimitMaxRequests: Number.isFinite(authRateLimitMaxRequestsRaw) && authRateLimitMaxRequestsRaw >= 1
65
96
  ? Math.floor(authRateLimitMaxRequestsRaw)
66
97
  : 10,
@@ -75,10 +106,57 @@ export function registerAuthRoutes(deps) {
75
106
  const globalSettings = core.getGlobalSettings();
76
107
  const siteSettings = globalSettings.site || {};
77
108
  return {
78
- showRunTaskforceLocally: siteSettings.showRunTaskforceLocally !== false,
79
- pricingPageEnabled: siteSettings.pricingPageEnabled !== false
109
+ showRunTaskforceLocally: resolveBooleanSetting(siteSettings.showRunTaskforceLocally, true),
110
+ pricingPageEnabled: resolveBooleanSetting(siteSettings.pricingPageEnabled, true)
80
111
  };
81
112
  };
113
+ const AVATAR_DRAFT_RETENTION_MS = 24 * 60 * 60 * 1000;
114
+ const MAX_AVATAR_UPLOAD_BYTES = 5 * 1024 * 1024;
115
+ const AVATAR_MIME_EXTENSION = {
116
+ 'image/png': '.png',
117
+ 'image/jpeg': '.jpg',
118
+ 'image/webp': '.webp',
119
+ 'image/gif': '.gif'
120
+ };
121
+ const buildUserAvatarStoragePath = (userIdRaw, draftIdRaw, originalNameRaw, mimeTypeRaw) => {
122
+ const userId = String(userIdRaw || '').trim().replace(/[^a-zA-Z0-9._-]/g, '-') || 'user';
123
+ const draftId = String(draftIdRaw || '').trim().replace(/[^a-zA-Z0-9._-]/g, '-') || randomUUID().replace(/-/g, '');
124
+ const originalName = String(originalNameRaw || '').trim();
125
+ const mimeType = String(mimeTypeRaw || '').trim().toLowerCase();
126
+ const originalExt = originalName ? path.extname(originalName).toLowerCase() : '';
127
+ const ext = originalExt || AVATAR_MIME_EXTENSION[mimeType] || '.bin';
128
+ const stem = (originalName ? path.basename(originalName, originalExt) : 'avatar')
129
+ .replace(/[^a-zA-Z0-9._-]/g, '-')
130
+ .replace(/-+/g, '-')
131
+ .replace(/^-+|-+$/g, '')
132
+ .slice(0, 64) || 'avatar';
133
+ return `users/${userId}/profile/avatar/${draftId}-${stem}${ext}`;
134
+ };
135
+ const deleteStoredAvatarObject = async (relativePathRaw) => {
136
+ const relativePath = normalizeTaskDataRelativePath(String(relativePathRaw || '').trim());
137
+ if (!relativePath)
138
+ return;
139
+ const objectStorageClient = getObjectStorageClient();
140
+ const effectiveObjectStorage = resolveEffectiveObjectStorageConfig();
141
+ if (objectStorageClient && effectiveObjectStorage.provider === 'r2') {
142
+ await objectStorageClient.deleteObject(relativePath).catch(() => { });
143
+ return;
144
+ }
145
+ const { basePath } = core.getPaths();
146
+ const resolvedPath = path.resolve(basePath, relativePath);
147
+ if (!resolvedPath.startsWith(path.resolve(basePath)))
148
+ return;
149
+ if (fs.existsSync(resolvedPath)) {
150
+ fs.rmSync(resolvedPath, { force: true });
151
+ }
152
+ };
153
+ const cleanupStaleAvatarDrafts = async (userId) => {
154
+ const cutoff = new Date(Date.now() - AVATAR_DRAFT_RETENTION_MS).toISOString();
155
+ const staleDrafts = core.cleanupStaleUserAvatarUploadDrafts(userId, cutoff);
156
+ for (const draft of staleDrafts) {
157
+ await deleteStoredAvatarObject(draft.storageKey);
158
+ }
159
+ };
82
160
  const logAuthLink = (kind, email, token, req) => {
83
161
  const base = resolvePublicAuthBaseUrl(req);
84
162
  const urlPath = kind === 'verify-email'
@@ -187,8 +265,10 @@ export function registerAuthRoutes(deps) {
187
265
  let userId = access?.userId || 'anonymous';
188
266
  let email = null;
189
267
  let displayName = null;
268
+ let avatarUrl = null;
190
269
  let betaAccess = true;
191
270
  if (authenticated && access?.userId) {
271
+ await cleanupStaleAvatarDrafts(access.userId);
192
272
  betaAccess = core.getUserBetaAccess(access.userId);
193
273
  const resolved = core.resolveUserAccess(access.userId, workspaceId, role);
194
274
  if (resolved) {
@@ -204,6 +284,7 @@ export function registerAuthRoutes(deps) {
204
284
  const identity = core.resolveUserIdentity(userId);
205
285
  email = identity?.email || null;
206
286
  displayName = identity?.displayName || null;
287
+ avatarUrl = identity?.avatarUrl || null;
207
288
  }
208
289
  res.writeHead(200, { 'Content-Type': 'application/json' });
209
290
  res.end(JSON.stringify({
@@ -213,6 +294,7 @@ export function registerAuthRoutes(deps) {
213
294
  role,
214
295
  email,
215
296
  displayName,
297
+ avatarUrl,
216
298
  betaAccess
217
299
  }));
218
300
  });
@@ -229,28 +311,67 @@ export function registerAuthRoutes(deps) {
229
311
  addRoute('POST', '/api/taskforce/auth/profile', async (req, res) => {
230
312
  if (!requireCloudRuntime(res, 'Authentication'))
231
313
  return;
232
- if (!ensureAuthenticatedUser(req)) {
233
- res.writeHead(401, { 'Content-Type': 'application/json' });
234
- res.end(JSON.stringify({ success: false, error: 'Authentication required.' }));
314
+ const authConfig = context.authConfig;
315
+ if (!authConfig) {
316
+ res.writeHead(500, { 'Content-Type': 'application/json' });
317
+ res.end(JSON.stringify({ success: false, error: 'Auth context unavailable.' }));
235
318
  return;
236
319
  }
237
- const userId = String(requestUserId(req) || '').trim();
238
- if (!userId || userId === 'anonymous') {
320
+ const access = resolveRequestAccess(req, authConfig);
321
+ const userId = String(access.userId || '').trim();
322
+ if (!access.authenticated || !userId || userId === 'anonymous') {
239
323
  res.writeHead(401, { 'Content-Type': 'application/json' });
240
324
  res.end(JSON.stringify({ success: false, error: 'Authentication required.' }));
241
325
  return;
242
326
  }
243
327
  const body = await parseJsonBody(req);
244
328
  try {
245
- const updated = core.updateUserDisplayName(userId, String(body?.displayName || ''));
329
+ await cleanupStaleAvatarDrafts(userId);
330
+ const avatarDraftId = typeof body?.avatarDraftId === 'string' ? body.avatarDraftId.trim() : '';
331
+ const clearAvatar = body?.clearAvatar === true;
332
+ let avatarUrl;
333
+ if (avatarDraftId) {
334
+ const draft = core.getUserAvatarUploadDraft(userId, avatarDraftId);
335
+ if (!draft || draft.discardedAt || !draft.finalizedAt) {
336
+ res.writeHead(400, { 'Content-Type': 'application/json' });
337
+ res.end(JSON.stringify({ success: false, error: 'Avatar draft is invalid or expired.', code: 'AVATAR_DRAFT_INVALID' }));
338
+ return;
339
+ }
340
+ avatarUrl = `/api/taskforce/context/${encodeURIComponent(draft.storageKey)}`;
341
+ }
342
+ else if (clearAvatar) {
343
+ avatarUrl = null;
344
+ }
345
+ const previousIdentity = core.resolveUserIdentity(userId);
346
+ const updated = core.updateUserProfile(userId, {
347
+ displayName: String(body?.displayName || ''),
348
+ ...((avatarDraftId || clearAvatar) ? { avatarUrl: avatarUrl || null } : {})
349
+ });
246
350
  if (!updated) {
247
351
  res.writeHead(404, { 'Content-Type': 'application/json' });
248
352
  res.end(JSON.stringify({ success: false, error: 'User not found.', code: 'USER_NOT_FOUND' }));
249
353
  return;
250
354
  }
355
+ if (avatarDraftId) {
356
+ core.markUserAvatarUploadDraftConsumed(userId, avatarDraftId);
357
+ }
358
+ if (avatarDraftId || clearAvatar) {
359
+ const previousAvatarUrl = String(previousIdentity?.avatarUrl || '').trim();
360
+ const nextAvatarUrl = String(updated.avatarUrl || '').trim();
361
+ const currentDraft = avatarDraftId ? core.getUserAvatarUploadDraft(userId, avatarDraftId) : null;
362
+ const currentStorageKey = String(currentDraft?.storageKey || '').trim();
363
+ if (previousAvatarUrl.startsWith('/api/taskforce/context/')
364
+ && previousAvatarUrl !== nextAvatarUrl) {
365
+ const previousStorageKey = normalizeTaskDataRelativePath(decodeURIComponent(previousAvatarUrl.slice('/api/taskforce/context/'.length)));
366
+ if (previousStorageKey && previousStorageKey !== currentStorageKey) {
367
+ await deleteStoredAvatarObject(previousStorageKey);
368
+ }
369
+ }
370
+ }
251
371
  auditAuthAction(req, 'auth-profile-updated', {
252
372
  userId,
253
- displayName: updated.displayName || null
373
+ displayName: updated.displayName || null,
374
+ avatarUrl: updated.avatarUrl || null
254
375
  });
255
376
  res.writeHead(200, { 'Content-Type': 'application/json' });
256
377
  res.end(JSON.stringify({
@@ -258,7 +379,8 @@ export function registerAuthRoutes(deps) {
258
379
  profile: {
259
380
  userId: updated.userId,
260
381
  email: updated.email,
261
- displayName: updated.displayName || null
382
+ displayName: updated.displayName || null,
383
+ avatarUrl: updated.avatarUrl || null
262
384
  }
263
385
  }));
264
386
  }
@@ -268,6 +390,161 @@ export function registerAuthRoutes(deps) {
268
390
  res.end(JSON.stringify({ success: false, error: String(error?.message || 'Unable to update profile.'), code: error?.code }));
269
391
  }
270
392
  });
393
+ addRoute('POST', '/api/taskforce/auth/profile/avatar/init', async (req, res) => {
394
+ if (!requireCloudRuntime(res, 'Authentication'))
395
+ return;
396
+ const authConfig = context.authConfig;
397
+ if (!authConfig) {
398
+ res.writeHead(500, { 'Content-Type': 'application/json' });
399
+ res.end(JSON.stringify({ success: false, error: 'Auth context unavailable.' }));
400
+ return;
401
+ }
402
+ const access = resolveRequestAccess(req, authConfig);
403
+ const userId = String(access.userId || '').trim();
404
+ if (!access.authenticated || !userId || userId === 'anonymous') {
405
+ res.writeHead(401, { 'Content-Type': 'application/json' });
406
+ res.end(JSON.stringify({ success: false, error: 'Authentication required.' }));
407
+ return;
408
+ }
409
+ const objectStorageClient = getObjectStorageClient();
410
+ const effectiveObjectStorage = resolveEffectiveObjectStorageConfig();
411
+ if (!objectStorageClient || effectiveObjectStorage.provider !== 'r2' || !effectiveObjectStorage.r2) {
412
+ res.writeHead(501, { 'Content-Type': 'application/json' });
413
+ res.end(JSON.stringify({ success: false, error: 'Avatar uploads require object storage.' }));
414
+ return;
415
+ }
416
+ const body = await parseJsonBody(req);
417
+ const originalName = String(body?.originalName || '').trim();
418
+ const mimeType = String(body?.mimeType || '').trim().toLowerCase();
419
+ const size = Number(body?.size || 0);
420
+ if (!originalName || !mimeType.startsWith('image/') || !(mimeType in AVATAR_MIME_EXTENSION)) {
421
+ res.writeHead(400, { 'Content-Type': 'application/json' });
422
+ res.end(JSON.stringify({ success: false, error: 'A supported image file is required.', code: 'AVATAR_FILE_INVALID' }));
423
+ return;
424
+ }
425
+ if (!Number.isFinite(size) || size <= 0 || size > MAX_AVATAR_UPLOAD_BYTES) {
426
+ res.writeHead(400, { 'Content-Type': 'application/json' });
427
+ res.end(JSON.stringify({ success: false, error: 'Avatar must be 5 MB or smaller.', code: 'AVATAR_SIZE_INVALID' }));
428
+ return;
429
+ }
430
+ await cleanupStaleAvatarDrafts(userId);
431
+ const draftId = `avatar-draft-${randomUUID().replace(/-/g, '')}`;
432
+ const relativePath = buildUserAvatarStoragePath(userId, draftId, originalName, mimeType);
433
+ core.createUserAvatarUploadDraft({
434
+ userId,
435
+ draftId,
436
+ storageKey: relativePath,
437
+ mimeType,
438
+ originalFilename: originalName,
439
+ sizeBytes: size,
440
+ storageProvider: 'r2'
441
+ });
442
+ const uploadUrl = objectStorageClient.createSignedPutUrl(relativePath, effectiveObjectStorage.r2.signedUploadTtlSeconds, mimeType);
443
+ res.writeHead(200, { 'Content-Type': 'application/json' });
444
+ res.end(JSON.stringify({
445
+ success: true,
446
+ mode: 'direct',
447
+ draftId,
448
+ uploadUrl,
449
+ method: 'PUT',
450
+ headers: { 'Content-Type': mimeType },
451
+ relativePath,
452
+ path: `/api/taskforce/context/${encodeURIComponent(relativePath)}`
453
+ }));
454
+ });
455
+ addRoute('POST', '/api/taskforce/auth/profile/avatar/finalize', async (req, res) => {
456
+ if (!requireCloudRuntime(res, 'Authentication'))
457
+ return;
458
+ const authConfig = context.authConfig;
459
+ if (!authConfig) {
460
+ res.writeHead(500, { 'Content-Type': 'application/json' });
461
+ res.end(JSON.stringify({ success: false, error: 'Auth context unavailable.' }));
462
+ return;
463
+ }
464
+ const access = resolveRequestAccess(req, authConfig);
465
+ const userId = String(access.userId || '').trim();
466
+ if (!access.authenticated || !userId || userId === 'anonymous') {
467
+ res.writeHead(401, { 'Content-Type': 'application/json' });
468
+ res.end(JSON.stringify({ success: false, error: 'Authentication required.' }));
469
+ return;
470
+ }
471
+ const body = await parseJsonBody(req);
472
+ const draftId = String(body?.draftId || '').trim();
473
+ const relativePath = normalizeTaskDataRelativePath(String(body?.relativePath || '').trim());
474
+ if (!draftId || !relativePath) {
475
+ res.writeHead(400, { 'Content-Type': 'application/json' });
476
+ res.end(JSON.stringify({ success: false, error: 'draftId and relativePath are required.' }));
477
+ return;
478
+ }
479
+ await cleanupStaleAvatarDrafts(userId);
480
+ const draft = core.getUserAvatarUploadDraft(userId, draftId);
481
+ if (!draft || draft.discardedAt || draft.storageKey !== relativePath) {
482
+ res.writeHead(400, { 'Content-Type': 'application/json' });
483
+ res.end(JSON.stringify({ success: false, error: 'Avatar draft is invalid or expired.', code: 'AVATAR_DRAFT_INVALID' }));
484
+ return;
485
+ }
486
+ const objectStorageClient = getObjectStorageClient();
487
+ if (objectStorageClient) {
488
+ const uploaded = await objectStorageClient.getObject(relativePath);
489
+ if (!uploaded) {
490
+ res.writeHead(404, { 'Content-Type': 'application/json' });
491
+ res.end(JSON.stringify({ success: false, error: 'Uploaded avatar not found.', code: 'AVATAR_UPLOAD_MISSING' }));
492
+ return;
493
+ }
494
+ }
495
+ else {
496
+ const { basePath } = core.getPaths();
497
+ const resolvedPath = path.resolve(basePath, relativePath);
498
+ if (!resolvedPath.startsWith(path.resolve(basePath)) || !fs.existsSync(resolvedPath)) {
499
+ res.writeHead(404, { 'Content-Type': 'application/json' });
500
+ res.end(JSON.stringify({ success: false, error: 'Uploaded avatar not found.', code: 'AVATAR_UPLOAD_MISSING' }));
501
+ return;
502
+ }
503
+ }
504
+ const finalized = core.markUserAvatarUploadDraftFinalized(userId, draftId);
505
+ res.writeHead(200, { 'Content-Type': 'application/json' });
506
+ res.end(JSON.stringify({
507
+ success: true,
508
+ draftId,
509
+ avatarUrl: `/api/taskforce/context/${encodeURIComponent(relativePath)}`,
510
+ profile: finalized
511
+ }));
512
+ });
513
+ addRoute('POST', '/api/taskforce/auth/profile/avatar/discard', async (req, res) => {
514
+ if (!requireCloudRuntime(res, 'Authentication'))
515
+ return;
516
+ const authConfig = context.authConfig;
517
+ if (!authConfig) {
518
+ res.writeHead(500, { 'Content-Type': 'application/json' });
519
+ res.end(JSON.stringify({ success: false, error: 'Auth context unavailable.' }));
520
+ return;
521
+ }
522
+ const access = resolveRequestAccess(req, authConfig);
523
+ const userId = String(access.userId || '').trim();
524
+ if (!access.authenticated || !userId || userId === 'anonymous') {
525
+ res.writeHead(401, { 'Content-Type': 'application/json' });
526
+ res.end(JSON.stringify({ success: false, error: 'Authentication required.' }));
527
+ return;
528
+ }
529
+ const body = await parseJsonBody(req);
530
+ const draftId = String(body?.draftId || '').trim();
531
+ if (!draftId) {
532
+ res.writeHead(400, { 'Content-Type': 'application/json' });
533
+ res.end(JSON.stringify({ success: false, error: 'draftId is required.' }));
534
+ return;
535
+ }
536
+ await cleanupStaleAvatarDrafts(userId);
537
+ const draft = core.getUserAvatarUploadDraft(userId, draftId);
538
+ if (!draft) {
539
+ res.writeHead(200, { 'Content-Type': 'application/json' });
540
+ res.end(JSON.stringify({ success: true, discarded: false }));
541
+ return;
542
+ }
543
+ core.discardUserAvatarUploadDraft(userId, draftId);
544
+ await deleteStoredAvatarObject(draft.storageKey);
545
+ res.writeHead(200, { 'Content-Type': 'application/json' });
546
+ res.end(JSON.stringify({ success: true, discarded: true }));
547
+ });
271
548
  // === SECTION: Login and register ===
272
549
  // POST /api/taskforce/auth/login - Exchange email/password for secure session cookie
273
550
  addRoute('POST', '/api/taskforce/auth/login', async (req, res) => {
@@ -857,6 +1134,63 @@ export function registerAuthRoutes(deps) {
857
1134
  res.end(JSON.stringify({ error: String(error?.message || 'Unable to resolve entitlements'), code: error?.code, details: error?.details }));
858
1135
  }
859
1136
  });
1137
+ // GET /api/taskforce/account/team-management-access - Resolve authoritative team-management access for the active workspace.
1138
+ addRoute('GET', '/api/taskforce/account/team-management-access', async (req, res) => {
1139
+ if (!ensureAuthenticatedUser(req)) {
1140
+ res.writeHead(401, { 'Content-Type': 'application/json' });
1141
+ res.end(JSON.stringify({ error: 'Authentication required.' }));
1142
+ return;
1143
+ }
1144
+ const wid = requestWorkspaceId(req);
1145
+ const userId = requestUserId(req);
1146
+ const hintedRole = normalizeTeamWorkspaceRole(requestHeader(req, 'x-taskforce-workspace-role', ''));
1147
+ const resolvedAccess = core.resolveUserAccess(userId, wid, 'member');
1148
+ const role = normalizeTeamWorkspaceRole(resolvedAccess?.role || hintedRole || null);
1149
+ const canManageWorkspace = role === 'owner' || role === 'admin';
1150
+ if (!canManageWorkspace) {
1151
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1152
+ res.end(JSON.stringify({
1153
+ workspaceId: wid,
1154
+ role,
1155
+ canManageWorkspace: false,
1156
+ teamPlanMode: 'unknown',
1157
+ allowed: false
1158
+ }));
1159
+ return;
1160
+ }
1161
+ try {
1162
+ const entitlements = core.getAccountEntitlementsPayload({ userId, workspaceId: wid });
1163
+ const teamPlanMode = resolveTeamPlanModeFromEntitlements(entitlements);
1164
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1165
+ res.end(JSON.stringify({
1166
+ workspaceId: wid,
1167
+ role,
1168
+ canManageWorkspace: true,
1169
+ teamPlanMode,
1170
+ allowed: teamPlanMode === 'team'
1171
+ }));
1172
+ }
1173
+ catch (error) {
1174
+ if (error?.code === 'ENTITLEMENT_NOT_FOUND') {
1175
+ const access = core.getAccountAccessStatus(userId);
1176
+ const teamPlanMode = access.gate === 'plan_selection_required' ? 'personal' : 'unknown';
1177
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1178
+ res.end(JSON.stringify({
1179
+ workspaceId: wid,
1180
+ role,
1181
+ canManageWorkspace: true,
1182
+ teamPlanMode,
1183
+ allowed: false,
1184
+ gate: access.gate,
1185
+ planSelectionRequired: access.planSelectionRequired
1186
+ }));
1187
+ return;
1188
+ }
1189
+ const statusCode = resolveErrorStatusCode(error, 400);
1190
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
1191
+ res.end(JSON.stringify({ error: String(error?.message || 'Unable to resolve team management access'), code: error?.code, details: error?.details }));
1192
+ }
1193
+ });
860
1194
  // GET /api/taskforce/account/plan-preview - Read-only payload for upgrade/support CTAs.
861
1195
  addRoute('GET', '/api/taskforce/account/plan-preview', async (req, res) => {
862
1196
  if (!ensureCloudPlanData(req, res))
@@ -6,6 +6,7 @@ const BILLING_CONFIG_KEY_PLAN = 'billing.config.v1';
6
6
  const PRICE_METADATA_PLAN_VERSION = 'taskforce_plan_version_id';
7
7
  const BILLING_DIAGNOSTIC_RETENTION_DAYS = 30;
8
8
  const BILLING_DIAGNOSTIC_MAX_PAYLOAD_BYTES = 8 * 1024;
9
+ const BILLING_STATUS_RECONCILIATION_TTL_MS = 30_000;
9
10
  const TASKFORCE_PROXY_ORIGIN_HOST_HEADER = 'x-taskforce-origin-host';
10
11
  const TASKFORCE_PROXY_ORIGIN_PROTO_HEADER = 'x-taskforce-origin-proto';
11
12
  const TASKFORCE_PROXY_ORIGIN_BASE_URL_HEADER = 'x-taskforce-origin-base-url';
@@ -623,12 +624,18 @@ function getBillingMirrorRow(core, userIdRaw) {
623
624
  const db = core.getDatabaseAdapter();
624
625
  const tenantId = core.getTenantId();
625
626
  return db.prepare(`
626
- SELECT stripe_status, stripe_customer_id, stripe_subscription_id, stripe_price_id, billing_interval, trial_end, current_period_end, effective_until, entitlement_state, tier
627
+ SELECT stripe_status, stripe_customer_id, stripe_subscription_id, stripe_price_id, billing_interval, trial_end, current_period_end, effective_until, entitlement_state, tier, updated_at
627
628
  FROM user_billing
628
629
  WHERE tenant_id = ? AND user_id = ?
629
630
  LIMIT 1
630
631
  `).get(tenantId, userId);
631
632
  }
633
+ function isBillingMirrorFresh(row) {
634
+ const updatedAt = Date.parse(String(row?.updated_at || ''));
635
+ if (!Number.isFinite(updatedAt))
636
+ return false;
637
+ return (Date.now() - updatedAt) < BILLING_STATUS_RECONCILIATION_TTL_MS;
638
+ }
632
639
  function isStripeManagedSubscriptionStatus(statusRaw) {
633
640
  const status = String(statusRaw || '').trim().toLowerCase();
634
641
  return status === 'trialing'
@@ -708,6 +715,9 @@ async function reconcileBillingMirrorIfNeeded(core, userIdRaw) {
708
715
  if (!userId)
709
716
  return { row: null, conflictCode: null };
710
717
  let row = getBillingMirrorRow(core, userId);
718
+ if (isBillingMirrorFresh(row)) {
719
+ return { row, conflictCode: null };
720
+ }
711
721
  if (String(row?.stripe_subscription_id || '').trim()) {
712
722
  try {
713
723
  await reconcileBillingFromStripe(core, userId, row?.stripe_subscription_id);
@@ -1435,6 +1435,7 @@ describe('Billing Webhook Routes', () => {
1435
1435
  });
1436
1436
  it('reconciles stale entitlement from Stripe when billing status is requested', async () => {
1437
1437
  const now = new Date().toISOString();
1438
+ const stale = new Date(Date.now() - 60_000).toISOString();
1438
1439
  db.prepare(`
1439
1440
  INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
1440
1441
  VALUES ('pro', 'default', 'Pro Plan', 'active', ?, ?)
@@ -1451,12 +1452,12 @@ describe('Billing Webhook Routes', () => {
1451
1452
  INSERT INTO user_billing (
1452
1453
  user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status, tier, billing_interval, entitlement_state, created_at, updated_at
1453
1454
  ) VALUES ('u1', 'default', 'cus_123', 'sub_123', 'price_old', 'active', 'starter', 'month', 'active', ?, ?)
1454
- `).run(now, now);
1455
+ `).run(stale, stale);
1455
1456
  db.prepare(`
1456
1457
  INSERT INTO account_entitlements (
1457
1458
  account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
1458
1459
  ) VALUES ('u1', 'default', 'starter-v1', 'active', ?, ?, ?)
1459
- `).run(now, now, now);
1460
+ `).run(stale, stale, stale);
1460
1461
  const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
1461
1462
  const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
1462
1463
  const res = createMockRes();
@@ -1471,6 +1472,35 @@ describe('Billing Webhook Routes', () => {
1471
1472
  `).get();
1472
1473
  expect(entitlement?.plan_version_id).toBe('pro-v1');
1473
1474
  });
1475
+ it('skips Stripe reconciliation for fresh billing mirror rows when billing status is requested', async () => {
1476
+ const now = new Date().toISOString();
1477
+ db.prepare(`
1478
+ INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
1479
+ VALUES ('pro', 'default', 'Pro Plan', 'active', ?, ?)
1480
+ `).run(now, now);
1481
+ db.prepare(`
1482
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1483
+ VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
1484
+ `).run(now, now);
1485
+ db.prepare(`
1486
+ INSERT INTO user_billing (
1487
+ user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status, tier, billing_interval, entitlement_state, created_at, updated_at
1488
+ ) VALUES ('u1', 'default', 'cus_123', 'sub_123', 'price_pro_month', 'active', 'pro', 'month', 'active', ?, ?)
1489
+ `).run(now, now);
1490
+ db.prepare(`
1491
+ INSERT INTO account_entitlements (
1492
+ account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
1493
+ ) VALUES ('u1', 'default', 'pro-v1', 'active', ?, ?, ?)
1494
+ `).run(now, now, now);
1495
+ const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
1496
+ const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
1497
+ const res = createMockRes();
1498
+ await statusRoute(req, res, {});
1499
+ expect(res.statusCode).toBe(200);
1500
+ expect(mockSubscriptionRetrieve).not.toHaveBeenCalled();
1501
+ expect(mockCustomersList).not.toHaveBeenCalled();
1502
+ expect(mockSubscriptionList).not.toHaveBeenCalled();
1503
+ });
1474
1504
  it('repairs billing status from an active Stripe subscription discovered by account email', async () => {
1475
1505
  const now = new Date().toISOString();
1476
1506
  db.prepare(`
@@ -24,9 +24,11 @@
24
24
  import * as fs from 'fs';
25
25
  import * as path from 'path';
26
26
  import { normalizeTaskDataRelativePath } from '../../storage/objectStorageClient.js';
27
+ import { shouldUseProvisionalTaskReferences } from '../../utils/taskReferences.js';
27
28
  import { purgeExpiredDocuments } from '../../storage/documentPurge.js';
28
29
  import { parsePlan } from '../../utils/planParser.js';
29
30
  import { getCanonicalDocumentName, getReadableDocumentName, isGeneratedDocumentName } from '../../utils/documentNames.js';
31
+ import { requestRuntimeMode } from './shared.js';
30
32
  import { parseJsonBody } from '../routes.js';
31
33
  export function registerDocumentRoutes(deps) {
32
34
  const { addRoute, core, context, requestWorkspaceId, ensureAdminRole, resolveEffectiveObjectStorageConfig, getObjectStorageClient, resolveStorageWorkspaceId, ensureContextUploadAllowed, parseStorageFolderSegments, buildCanonicalWorkspaceAssetRelativePath, inferAssetKindFromPath, appendStorageTelemetry, scanAndRecordDocument, buildTaskAttachmentPayload, workspaceAssetStore, taskAssetStore, normalizeDocumentPathInput, getAssetMetadata, getCanonicalAssetMetadata, documentRetentionDays, appendDocumentPurgeHistory, markAssetDeleted, isAssetDeleted, clearAssetDeleted, ensureWithinDir, sanitizeDownloadFilename, contentTypeForExt, inferInlineDocumentFromPath, resolveCanonicalDocumentRead, inferWorkspaceIdFromStoragePath, inferTaskIdFromStoragePath, } = deps;
@@ -1097,7 +1099,10 @@ export function registerDocumentRoutes(deps) {
1097
1099
  type: node.type || 'feature',
1098
1100
  priority: typeof node.priority === 'number' ? node.priority : 2,
1099
1101
  status: 'task',
1100
- }, { workspaceId });
1102
+ }, {
1103
+ workspaceId,
1104
+ provisionalLocalReference: shouldUseProvisionalTaskReferences(requestRuntimeMode(req))
1105
+ });
1101
1106
  created.push(taskItem.id);
1102
1107
  totalCreated++;
1103
1108
  }
@@ -30,6 +30,6 @@ export declare function makeResolveAdminRequestActor(core: TaskforceCore, contex
30
30
  systemRole: "system_admin" | "user";
31
31
  };
32
32
  export declare function makeAuditAdminAction(core: TaskforceCore, context: RouteContext): (req: IncomingMessage, action: string, details: Record<string, unknown>) => void;
33
- export declare function makeAuditAuthAction(core: TaskforceCore, requestWorkspaceId: (req: IncomingMessage) => string): (req: IncomingMessage, action: string, details: Record<string, unknown>) => void;
33
+ export declare function makeAuditAuthAction(core: TaskforceCore, requestWorkspaceId: (req: IncomingMessage) => string, context: RouteContext): (req: IncomingMessage, action: string, details: Record<string, unknown>) => void;
34
34
  export declare function makeRequireCloudRuntime(context: RouteContext): (res: ServerResponse, feature: string) => boolean;
35
35
  export declare function makeEnsureCloudPlanData(context: RouteContext): (req: IncomingMessage, res: ServerResponse) => boolean;
@@ -257,13 +257,21 @@ export function makeAuditAdminAction(core, context) {
257
257
  console.info(`[Taskforce Admin Audit] ${JSON.stringify(payload)}`);
258
258
  };
259
259
  }
260
- export function makeAuditAuthAction(core, requestWorkspaceId) {
260
+ export function makeAuditAuthAction(core, requestWorkspaceId, context) {
261
261
  return function auditAuthAction(req, action, details) {
262
+ const authConfig = context.authConfig;
263
+ const resolvedAccess = authConfig?.enabled ? resolveRequestAccess(req, authConfig) : null;
264
+ const actorUserId = resolvedAccess?.authenticated && resolvedAccess.userId && resolvedAccess.userId !== 'anonymous'
265
+ ? resolvedAccess.userId
266
+ : requestUserId(req);
267
+ const actorRole = resolvedAccess?.authenticated
268
+ ? String(resolvedAccess.role || '').trim().toLowerCase() || requestRole(req)
269
+ : requestRole(req);
262
270
  const payload = {
263
271
  ts: new Date().toISOString(),
264
272
  action,
265
- actorUserId: requestUserId(req),
266
- actorRole: requestRole(req),
273
+ actorUserId,
274
+ actorRole,
267
275
  workspaceId: requestWorkspaceId(req),
268
276
  details
269
277
  };