@taskforcehq/taskforce 0.3.304 → 0.3.305
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/README.md +140 -247
- package/dist/TaskforceCore.js +165 -70
- package/dist/TaskforceCore.test.js +799 -90
- package/dist/compat/workspaceSyncCompat.js +6 -0
- package/dist/components/features/DocumentIndex.d.ts +1 -1
- package/dist/components/features/DocumentIndex.js +1 -1
- package/dist/components/features/DocumentViewer.d.ts +1 -1
- package/dist/components/features/DocumentViewer.js +32 -9
- package/dist/components/features/DocumentWorkspace.d.ts +5 -22
- package/dist/components/features/DocumentWorkspace.js +12 -155
- package/dist/components/features/DocumentWorkspace.test.js +193 -6
- package/dist/components/features/TaskSettings.js +29 -3
- package/dist/components/features/TaskSettings.test.js +42 -1
- package/dist/components/features/documentWorkspaceModel.d.ts +24 -0
- package/dist/components/features/documentWorkspaceModel.js +57 -0
- package/dist/components/features/useDocumentWorkspaceController.d.ts +34 -0
- package/dist/components/features/useDocumentWorkspaceController.js +113 -0
- package/dist/components/task/TaskForm.d.ts +8 -8
- package/dist/components/task/TaskForm.js +358 -221
- package/dist/components/task/TaskForm.test.js +456 -381
- package/dist/components/task/TaskKanban.js +2 -1
- package/dist/components/views/PlanComparisonPage.js +5 -8
- package/dist/components/views/PlanComparisonPage.test.js +51 -41
- package/dist/components/views/PlansPage.js +126 -84
- package/dist/components/views/PlansPage.test.js +308 -39
- package/dist/components/views/StandaloneLayout.js +28 -22
- package/dist/components/views/panels/FilterToolbar.test.js +6 -4
- package/dist/components/views/standalone/modals/AccountHubModal.d.ts +2 -14
- package/dist/components/views/standalone/modals/AccountHubModal.js +12 -14
- package/dist/components/views/standalone/modals/AccountHubModal.test.js +24 -1
- package/dist/config/envSchema.js +1 -1
- package/dist/core/AiProfileService.d.ts +6 -1
- package/dist/core/AiProfileService.js +161 -16
- package/dist/core/AiProfileService.test.js +3 -1
- package/dist/core/AiProfiles.test.js +200 -0
- package/dist/core/AuthTokenService.test.d.ts +1 -0
- package/dist/core/AuthTokenService.test.js +78 -0
- package/dist/core/EntitlementsPolicy.test.js +75 -13
- package/dist/core/PlanEntitlementService.d.ts +7 -1
- package/dist/core/PlanEntitlementService.js +117 -42
- package/dist/core/PlanVersionPolicy.test.js +19 -26
- package/dist/core/SignupMonetizationSettings.test.js +46 -21
- package/dist/core/SystemAdmin.test.js +212 -56
- package/dist/core/TaskTaxonomyValidation.test.js +53 -0
- package/dist/core/Taskforce.d.ts +3 -0
- package/dist/core/Taskforce.js +50 -10
- package/dist/core/WorkspacePlanMode.test.js +12 -12
- package/dist/core/shared.d.ts +2 -0
- package/dist/core/shared.js +11 -0
- package/dist/hooks/sync/auth.js +3 -1
- package/dist/hooks/sync/transfers.d.ts +2 -1
- package/dist/hooks/sync/transfers.js +8 -4
- package/dist/hooks/useSyncOrchestrator.d.ts +7 -4
- package/dist/hooks/useSyncOrchestrator.js +75 -7
- package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +80 -10
- package/dist/hooks/useTaskMutations.d.ts +2 -1
- package/dist/hooks/useTaskMutations.js +12 -1
- package/dist/hooks/useTaskMutations.test.js +30 -1
- package/dist/hooks/useTaskforce.d.ts +5 -2
- package/dist/hooks/useTaskforce.js +17 -5
- package/dist/hooks/useTaskforce.sync-behavior.test.js +78 -0
- package/dist/hooks/useWorkspaceSyncController.d.ts +3 -1
- package/dist/hooks/useWorkspaceSyncController.js +11 -1
- package/dist/hooks/useWorkspaceSyncController.test.js +1 -0
- package/dist/mcp/adminWorkspaceRegistrar.d.ts +2 -0
- package/dist/mcp/adminWorkspaceRegistrar.js +154 -0
- package/dist/mcp/collaborationRegistrar.d.ts +2 -0
- package/dist/mcp/collaborationRegistrar.js +71 -0
- package/dist/mcp/documentAssetRegistrar.d.ts +2 -0
- package/dist/mcp/documentAssetRegistrar.js +346 -0
- package/dist/mcp/runtime.js +2362 -3530
- package/dist/mcp/runtime.test.js +159 -0
- package/dist/mcp/taskPlanningRegistrar.d.ts +2 -0
- package/dist/mcp/taskPlanningRegistrar.js +418 -0
- package/dist/mcp/toolCatalog.d.ts +35 -0
- package/dist/mcp/toolCatalog.js +3 -0
- package/dist/server/routes/admin.d.ts +1 -5
- package/dist/server/routes/admin.js +138 -61
- package/dist/server/routes/annotatedAttachments.d.ts +1 -1
- package/dist/server/routes/annotatedAttachments.js +1 -1
- package/dist/server/routes/auth.d.ts +1 -4
- package/dist/server/routes/auth.js +23 -63
- package/dist/server/routes/authSupport.d.ts +30 -0
- package/dist/server/routes/authSupport.js +81 -0
- package/dist/server/routes/billing.d.ts +1 -1
- package/dist/server/routes/billing.js +104 -188
- package/dist/server/routes/billing.test.js +214 -86
- package/dist/server/routes/documentReviews.d.ts +1 -1
- package/dist/server/routes/documentReviews.js +1 -1
- package/dist/server/routes/documents.d.ts +4 -2
- package/dist/server/routes/documents.js +73 -7
- package/dist/server/routes/primitives.d.ts +11 -0
- package/dist/server/routes/primitives.js +73 -0
- package/dist/server/routes/resources.d.ts +1 -1
- package/dist/server/routes/resources.js +1 -1
- package/dist/server/routes/shared.d.ts +1 -1
- package/dist/server/routes/sync.d.ts +1 -1
- package/dist/server/routes/sync.js +1 -1
- package/dist/server/routes/tasks.d.ts +1 -1
- package/dist/server/routes/tasks.js +1 -1
- package/dist/server/routes/workspaces.d.ts +1 -1
- package/dist/server/routes/workspaces.js +1 -1
- package/dist/server/routes.d.ts +2 -9
- package/dist/server/routes.js +76 -162
- package/dist/server/routes.test.js +672 -30
- package/dist/sync/contentSyncState.js +16 -7
- package/dist/sync/syncApplyHandlers.test.js +2 -0
- package/dist/sync/syncService.d.ts +1 -0
- package/dist/sync/syncService.js +8 -0
- package/dist/sync/syncService.test.js +14 -0
- package/dist/sync/workspaceSyncModel.d.ts +3 -0
- package/dist/sync/workspaceSyncModel.js +12 -3
- package/dist/sync/workspaceSyncModel.test.js +34 -0
- package/dist/sync/workspaceSyncState.d.ts +2 -0
- package/dist/sync/workspaceSyncState.js +1 -0
- package/dist/ui/assets/{AgentsModule-BLWVbuKP.js → AgentsModule-N2MnQBZk.js} +1 -1
- package/dist/ui/assets/{AnnotatedAttachmentWorkspace-BlS-cqnl.js → AnnotatedAttachmentWorkspace-BG6P_GVW.js} +1 -1
- package/dist/ui/assets/DocumentWorkspace-B3nV_Gc5.css +1 -0
- package/dist/ui/assets/DocumentWorkspace-DRbDMwh8.js +252 -0
- package/dist/ui/assets/{InitiativesModule-BjR6iBf9.js → InitiativesModule-Dhm7M8e7.js} +1 -1
- package/dist/ui/assets/PlansPage-Cq0zXj3I.js +1 -0
- package/dist/ui/assets/TaskSettings-ln0aF7xc.js +9 -0
- package/dist/ui/assets/{WorkflowsModule-DnFqdUME.js → WorkflowsModule-BcS4afRn.js} +1 -1
- package/dist/ui/assets/{index-Ii0B0rTO.css → index-C2-OXZV7.css} +1 -1
- package/dist/ui/assets/index-DcSI5F86.js +6 -0
- package/dist/ui/assets/{vendor-icons-I7ZwG1z_.js → vendor-icons-BSUaRwF8.js} +1 -1
- package/dist/ui/index.html +3 -3
- package/dist/utils/commercialLifecyclePresentation.d.ts +19 -0
- package/dist/utils/commercialLifecyclePresentation.js +196 -0
- package/package.json +8 -1
- package/scripts/check-cloud-mcp.mjs +83 -0
- package/scripts/playwright-auth.sh +5 -1
- package/scripts/run-billing-performance-smoke.sh +24 -0
- package/dist/ui/assets/DocumentWorkspace-BH_6DF6x.js +0 -250
- package/dist/ui/assets/DocumentWorkspace-C_8T8oz-.css +0 -1
- package/dist/ui/assets/PlansPage-CSDQ4sLa.js +0 -1
- package/dist/ui/assets/TaskSettings-CEDy34Jo.js +0 -9
- package/dist/ui/assets/index-DXUdtH1B.js +0 -6
package/dist/config/envSchema.js
CHANGED
|
@@ -26,7 +26,7 @@ const SPECS = [
|
|
|
26
26
|
{ key: 'TASKFORCE_BOOTSTRAP_ADMIN_EMAIL', description: 'Bootstrap admin email used by login fallback.', category: 'Auth', targets: ['server', 'all'] },
|
|
27
27
|
{ key: 'TASKFORCE_BOOTSTRAP_ADMIN_PASSWORD', description: 'Bootstrap admin password used by login fallback.', category: 'Auth', secret: true, targets: ['server', 'all'] },
|
|
28
28
|
{ key: 'TASKFORCE_AUTH_PUBLIC_BASE_URL', description: 'Public auth URL used in email links.', category: 'Auth', example: 'https://performance-app.taskforcehq.ai', targets: ['server', 'all'] },
|
|
29
|
-
{ key: 'TASKFORCE_AUTH_EXPOSE_DEV_TOKENS', description: '
|
|
29
|
+
{ key: 'TASKFORCE_AUTH_EXPOSE_DEV_TOKENS', description: 'Explicitly expose auth tokens in non-production responses for test automation.', category: 'Auth', defaultValue: 'false', targets: ['server', 'all'] },
|
|
30
30
|
{ key: 'TASKFORCE_CLOUD_PROXY_BASE_URL', description: 'Optional upstream cloud base URL for local proxying of auth/sync API calls.', category: 'Auth', example: 'https://performance-app.taskforcehq.ai', targets: ['server', 'all'] },
|
|
31
31
|
{ key: 'TASKFORCE_CF_ACCESS_CLIENT_ID', description: 'Optional Cloudflare Access service token client id for local cloud proxy.', category: 'Cloudflare', secret: true, targets: ['server', 'all'] },
|
|
32
32
|
{ key: 'TASKFORCE_CF_ACCESS_CLIENT_SECRET', description: 'Optional Cloudflare Access service token client secret for local cloud proxy.', category: 'Cloudflare', secret: true, targets: ['server', 'all'] },
|
|
@@ -50,7 +50,12 @@ export interface AiProfileServiceDeps {
|
|
|
50
50
|
aiProfileRowToRecord: (row: Record<string, unknown>) => AiProfileRecordLike;
|
|
51
51
|
generateEntityId: (prefix: string) => string;
|
|
52
52
|
issueProfileToken: () => string;
|
|
53
|
-
|
|
53
|
+
countAiProfileUsage: (workspaceId: string) => {
|
|
54
|
+
used: number;
|
|
55
|
+
limit: number | null;
|
|
56
|
+
remaining: number | null;
|
|
57
|
+
};
|
|
58
|
+
createRuleError: (message: string, statusCode?: number, code?: string, details?: unknown) => Error;
|
|
54
59
|
}
|
|
55
60
|
export declare function listAiProfilesService(deps: AiProfileServiceDeps, workspaceId: string): AiProfileRecordLike[];
|
|
56
61
|
export declare function getAiProfileByTokenService(deps: AiProfileServiceDeps, input: {
|
|
@@ -23,6 +23,112 @@ function normalizeAiProfileSurfaceTypeInput(deps, value) {
|
|
|
23
23
|
}
|
|
24
24
|
return normalized;
|
|
25
25
|
}
|
|
26
|
+
function inferAiProfileProviderModelDefaults(name) {
|
|
27
|
+
const normalized = String(name || '').trim().toLowerCase();
|
|
28
|
+
if (!normalized)
|
|
29
|
+
return {};
|
|
30
|
+
if (normalized.includes('claude code'))
|
|
31
|
+
return { provider: 'Anthropic', model: 'Claude Code' };
|
|
32
|
+
if (normalized.includes('claude'))
|
|
33
|
+
return { provider: 'Anthropic', model: 'Claude' };
|
|
34
|
+
if (normalized.includes('chatgpt'))
|
|
35
|
+
return { provider: 'OpenAI', model: 'ChatGPT' };
|
|
36
|
+
if (normalized.includes('codex'))
|
|
37
|
+
return { provider: 'OpenAI', model: 'Codex' };
|
|
38
|
+
if (normalized.includes('gemini'))
|
|
39
|
+
return { provider: 'Google', model: 'Gemini' };
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
function inferAiProfileMetadataDefaults(name, surfaceType) {
|
|
43
|
+
const inferredProviderModel = inferAiProfileProviderModelDefaults(name);
|
|
44
|
+
switch (surfaceType) {
|
|
45
|
+
case 'agent':
|
|
46
|
+
return {
|
|
47
|
+
description: `${name} AI collaborator registered in Taskforce.`,
|
|
48
|
+
role: 'AI Collaborator',
|
|
49
|
+
...inferredProviderModel
|
|
50
|
+
};
|
|
51
|
+
case 'coding_tool':
|
|
52
|
+
return {
|
|
53
|
+
description: `${name} coding agent connected through MCP.`,
|
|
54
|
+
role: 'Coding Agent',
|
|
55
|
+
...inferredProviderModel
|
|
56
|
+
};
|
|
57
|
+
case 'chat_app':
|
|
58
|
+
return {
|
|
59
|
+
description: `${name} conversational AI connected through Taskforce.`,
|
|
60
|
+
role: 'AI Assistant',
|
|
61
|
+
...inferredProviderModel
|
|
62
|
+
};
|
|
63
|
+
case 'ide':
|
|
64
|
+
return {
|
|
65
|
+
description: `${name} IDE-connected AI assistant.`,
|
|
66
|
+
role: 'Coding Assistant',
|
|
67
|
+
...inferredProviderModel
|
|
68
|
+
};
|
|
69
|
+
case 'other':
|
|
70
|
+
return {
|
|
71
|
+
description: `${name} AI profile connected through Taskforce.`,
|
|
72
|
+
role: 'AI Assistant',
|
|
73
|
+
...inferredProviderModel
|
|
74
|
+
};
|
|
75
|
+
default:
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function resolveOptionalProfileField(inputValue, existingValue, fallbackValue) {
|
|
80
|
+
const explicitValue = normalizeOptionalProfileField(inputValue);
|
|
81
|
+
if (explicitValue !== undefined)
|
|
82
|
+
return explicitValue;
|
|
83
|
+
const preservedValue = normalizeOptionalProfileField(existingValue);
|
|
84
|
+
if (preservedValue !== undefined)
|
|
85
|
+
return preservedValue;
|
|
86
|
+
return normalizeOptionalProfileField(fallbackValue);
|
|
87
|
+
}
|
|
88
|
+
function resolveAiProfileMetadataFields(deps, input, existing) {
|
|
89
|
+
const surfaceTypeInput = normalizeAiProfileSurfaceTypeInput(deps, input.surfaceType);
|
|
90
|
+
const effectiveSurfaceType = surfaceTypeInput === undefined
|
|
91
|
+
? existing?.surfaceType ?? null
|
|
92
|
+
: surfaceTypeInput;
|
|
93
|
+
const inferredDefaults = inferAiProfileMetadataDefaults(input.name, effectiveSurfaceType);
|
|
94
|
+
return {
|
|
95
|
+
description: resolveOptionalProfileField(input.description, existing?.description, inferredDefaults.description) ?? null,
|
|
96
|
+
role: resolveOptionalProfileField(input.role, existing?.role, inferredDefaults.role) ?? null,
|
|
97
|
+
provider: resolveOptionalProfileField(input.provider, existing?.provider, inferredDefaults.provider) ?? null,
|
|
98
|
+
model: resolveOptionalProfileField(input.model, existing?.model, inferredDefaults.model) ?? null,
|
|
99
|
+
surfaceType: effectiveSurfaceType,
|
|
100
|
+
surfaceTypeProvided: surfaceTypeInput !== undefined,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function applyAiProfileMetadataDefaults(record) {
|
|
104
|
+
const inferredDefaults = inferAiProfileMetadataDefaults(record.name, record.surfaceType);
|
|
105
|
+
return {
|
|
106
|
+
...record,
|
|
107
|
+
description: resolveOptionalProfileField(undefined, record.description, inferredDefaults.description) ?? null,
|
|
108
|
+
role: resolveOptionalProfileField(undefined, record.role, inferredDefaults.role) ?? null,
|
|
109
|
+
provider: resolveOptionalProfileField(undefined, record.provider, inferredDefaults.provider) ?? null,
|
|
110
|
+
model: resolveOptionalProfileField(undefined, record.model, inferredDefaults.model) ?? null,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function hydrateAiProfileRecord(deps, row) {
|
|
114
|
+
const record = deps.aiProfileRowToRecord(row);
|
|
115
|
+
const hydrated = applyAiProfileMetadataDefaults(record);
|
|
116
|
+
const metadataChanged = record.description !== hydrated.description
|
|
117
|
+
|| record.role !== hydrated.role
|
|
118
|
+
|| record.provider !== hydrated.provider
|
|
119
|
+
|| record.model !== hydrated.model;
|
|
120
|
+
if (metadataChanged) {
|
|
121
|
+
deps.db.prepare(`
|
|
122
|
+
UPDATE ai_profiles
|
|
123
|
+
SET description = ?,
|
|
124
|
+
role = ?,
|
|
125
|
+
provider = ?,
|
|
126
|
+
model = ?
|
|
127
|
+
WHERE tenant_id = ? AND id = ?
|
|
128
|
+
`).run(hydrated.description ?? null, hydrated.role ?? null, hydrated.provider ?? null, hydrated.model ?? null, deps.tenantId, record.id);
|
|
129
|
+
}
|
|
130
|
+
return hydrated;
|
|
131
|
+
}
|
|
26
132
|
export function listAiProfilesService(deps, workspaceId) {
|
|
27
133
|
const normalizedWorkspaceId = deps.normalizeWorkspaceId(workspaceId);
|
|
28
134
|
const rows = deps.db.prepare(`
|
|
@@ -48,7 +154,7 @@ export function listAiProfilesService(deps, workspaceId) {
|
|
|
48
154
|
AND workspace_id = ?
|
|
49
155
|
ORDER BY COALESCE(updated_at, created_at) DESC, LOWER(name) ASC
|
|
50
156
|
`).all(deps.tenantId, normalizedWorkspaceId);
|
|
51
|
-
return rows.map((row) => deps
|
|
157
|
+
return rows.map((row) => hydrateAiProfileRecord(deps, row));
|
|
52
158
|
}
|
|
53
159
|
export function getAiProfileByTokenService(deps, input) {
|
|
54
160
|
const normalizedWorkspaceId = deps.normalizeWorkspaceId(input.workspaceId);
|
|
@@ -63,7 +169,7 @@ export function getAiProfileByTokenService(deps, input) {
|
|
|
63
169
|
AND profile_token = ?
|
|
64
170
|
LIMIT 1
|
|
65
171
|
`).get(deps.tenantId, normalizedWorkspaceId, profileToken);
|
|
66
|
-
return row ? deps
|
|
172
|
+
return row ? hydrateAiProfileRecord(deps, row) : null;
|
|
67
173
|
}
|
|
68
174
|
export function upsertAiProfileForSyncService(deps, profile) {
|
|
69
175
|
const normalizedWorkspaceId = deps.normalizeWorkspaceId(profile.workspaceId);
|
|
@@ -77,7 +183,6 @@ export function upsertAiProfileForSyncService(deps, profile) {
|
|
|
77
183
|
const normalizedLastActiveAt = typeof profile.lastActiveAt === 'string' && profile.lastActiveAt.trim().length > 0
|
|
78
184
|
? profile.lastActiveAt.trim()
|
|
79
185
|
: null;
|
|
80
|
-
const normalizedSurfaceType = normalizeAiProfileSurfaceTypeInput(deps, profile.surfaceType);
|
|
81
186
|
const existingRow = deps.db.prepare(`
|
|
82
187
|
SELECT *
|
|
83
188
|
FROM ai_profiles
|
|
@@ -87,9 +192,18 @@ export function upsertAiProfileForSyncService(deps, profile) {
|
|
|
87
192
|
if (existingRow) {
|
|
88
193
|
const existingUpdatedAt = String(existingRow.updated_at || existingRow.created_at || '').trim();
|
|
89
194
|
if (existingUpdatedAt && existingUpdatedAt > normalizedUpdatedAt) {
|
|
90
|
-
return deps
|
|
195
|
+
return hydrateAiProfileRecord(deps, existingRow);
|
|
91
196
|
}
|
|
92
197
|
}
|
|
198
|
+
const existingProfile = existingRow ? deps.aiProfileRowToRecord(existingRow) : null;
|
|
199
|
+
const resolvedMetadata = resolveAiProfileMetadataFields(deps, {
|
|
200
|
+
name: profile.name,
|
|
201
|
+
description: profile.description,
|
|
202
|
+
role: profile.role,
|
|
203
|
+
provider: profile.provider,
|
|
204
|
+
model: profile.model,
|
|
205
|
+
surfaceType: profile.surfaceType,
|
|
206
|
+
}, existingProfile);
|
|
93
207
|
deps.db.prepare(`
|
|
94
208
|
INSERT INTO ai_profiles (
|
|
95
209
|
id, tenant_id, workspace_id, profile_token, name, username, icon, color,
|
|
@@ -111,7 +225,7 @@ export function upsertAiProfileForSyncService(deps, profile) {
|
|
|
111
225
|
created_at = excluded.created_at,
|
|
112
226
|
updated_at = excluded.updated_at,
|
|
113
227
|
last_active_at = excluded.last_active_at
|
|
114
|
-
`).run(profileId, deps.tenantId, normalizedWorkspaceId, String(profile.profileToken || '').trim(), deps.normalizeAiProfileName(profile.name), deps.normalizeAiProfileUsername(profile.username, profile.name), deps.normalizeAiProfileIcon(profile.icon), deps.normalizeAiProfileColor(profile.color),
|
|
228
|
+
`).run(profileId, deps.tenantId, normalizedWorkspaceId, String(profile.profileToken || '').trim(), deps.normalizeAiProfileName(profile.name), deps.normalizeAiProfileUsername(profile.username, profile.name), deps.normalizeAiProfileIcon(profile.icon), deps.normalizeAiProfileColor(profile.color), resolvedMetadata.description, resolvedMetadata.role, resolvedMetadata.provider, resolvedMetadata.model, resolvedMetadata.surfaceType, profile.providerMetadata && typeof profile.providerMetadata === 'object' && !Array.isArray(profile.providerMetadata)
|
|
115
229
|
? JSON.stringify(profile.providerMetadata)
|
|
116
230
|
: null, normalizedCreatedAt, normalizedUpdatedAt, normalizedLastActiveAt);
|
|
117
231
|
const refreshed = deps.db.prepare(`
|
|
@@ -123,7 +237,7 @@ export function upsertAiProfileForSyncService(deps, profile) {
|
|
|
123
237
|
if (!refreshed) {
|
|
124
238
|
throw deps.createRuleError('Failed to upsert AI profile during sync', 500, 'AI_PROFILE_SYNC_UPSERT_FAILED');
|
|
125
239
|
}
|
|
126
|
-
return deps
|
|
240
|
+
return hydrateAiProfileRecord(deps, refreshed);
|
|
127
241
|
}
|
|
128
242
|
export function mergeAiProfilesService(deps, input) {
|
|
129
243
|
const normalizedWorkspaceId = deps.normalizeWorkspaceId(input.workspaceId);
|
|
@@ -165,7 +279,7 @@ export function mergeAiProfilesService(deps, input) {
|
|
|
165
279
|
const refreshed = deps.db.prepare(`
|
|
166
280
|
SELECT * FROM ai_profiles WHERE tenant_id = ? AND id = ? LIMIT 1
|
|
167
281
|
`).get(deps.tenantId, keepId);
|
|
168
|
-
return deps
|
|
282
|
+
return hydrateAiProfileRecord(deps, refreshed);
|
|
169
283
|
}
|
|
170
284
|
export function resolveAiProfileService(deps, input) {
|
|
171
285
|
const normalizedWorkspaceId = deps.normalizeWorkspaceId(input.workspaceId);
|
|
@@ -197,7 +311,14 @@ export function resolveAiProfileService(deps, input) {
|
|
|
197
311
|
const nextRole = normalizeOptionalProfileField(input.role);
|
|
198
312
|
const nextProvider = normalizeOptionalProfileField(input.provider);
|
|
199
313
|
const nextModel = normalizeOptionalProfileField(input.model);
|
|
200
|
-
const
|
|
314
|
+
const resolvedMetadata = resolveAiProfileMetadataFields(deps, {
|
|
315
|
+
name: nextName,
|
|
316
|
+
description: nextDescription,
|
|
317
|
+
role: nextRole,
|
|
318
|
+
provider: nextProvider,
|
|
319
|
+
model: nextModel,
|
|
320
|
+
surfaceType: input.surfaceType,
|
|
321
|
+
}, existingProfile);
|
|
201
322
|
deps.db.prepare(`
|
|
202
323
|
UPDATE ai_profiles
|
|
203
324
|
SET name = ?,
|
|
@@ -215,7 +336,7 @@ export function resolveAiProfileService(deps, input) {
|
|
|
215
336
|
last_active_at = ?,
|
|
216
337
|
updated_at = ?
|
|
217
338
|
WHERE tenant_id = ? AND id = ?
|
|
218
|
-
`).run(nextName, nextUsername, nextIcon, nextColor,
|
|
339
|
+
`).run(nextName, nextUsername, nextIcon, nextColor, resolvedMetadata.description, resolvedMetadata.role, resolvedMetadata.provider, resolvedMetadata.model, resolvedMetadata.surfaceTypeProvided ? 1 : 0, resolvedMetadata.surfaceType, now, now, deps.tenantId, String(existingRow.id || '').trim());
|
|
219
340
|
const refreshed = deps.db.prepare(`
|
|
220
341
|
SELECT *
|
|
221
342
|
FROM ai_profiles
|
|
@@ -227,7 +348,7 @@ export function resolveAiProfileService(deps, input) {
|
|
|
227
348
|
}
|
|
228
349
|
return {
|
|
229
350
|
created: false,
|
|
230
|
-
profile: deps
|
|
351
|
+
profile: hydrateAiProfileRecord(deps, refreshed)
|
|
231
352
|
};
|
|
232
353
|
}
|
|
233
354
|
const exactMatches = deps.db.prepare(`
|
|
@@ -251,7 +372,15 @@ export function resolveAiProfileService(deps, input) {
|
|
|
251
372
|
const nextRole = normalizeOptionalProfileField(input.role);
|
|
252
373
|
const nextProvider = normalizeOptionalProfileField(input.provider);
|
|
253
374
|
const nextModel = normalizeOptionalProfileField(input.model);
|
|
254
|
-
const
|
|
375
|
+
const existingProfile = deps.aiProfileRowToRecord(exactMatches[0]);
|
|
376
|
+
const resolvedMetadata = resolveAiProfileMetadataFields(deps, {
|
|
377
|
+
name: nextName,
|
|
378
|
+
description: nextDescription,
|
|
379
|
+
role: nextRole,
|
|
380
|
+
provider: nextProvider,
|
|
381
|
+
model: nextModel,
|
|
382
|
+
surfaceType: input.surfaceType,
|
|
383
|
+
}, existingProfile);
|
|
255
384
|
deps.db.prepare(`
|
|
256
385
|
UPDATE ai_profiles
|
|
257
386
|
SET name = ?,
|
|
@@ -269,7 +398,7 @@ export function resolveAiProfileService(deps, input) {
|
|
|
269
398
|
ELSE surface_type
|
|
270
399
|
END
|
|
271
400
|
WHERE tenant_id = ? AND id = ?
|
|
272
|
-
`).run(nextName, nextUsername, nextIcon, nextColor,
|
|
401
|
+
`).run(nextName, nextUsername, nextIcon, nextColor, resolvedMetadata.description, resolvedMetadata.role, resolvedMetadata.provider, resolvedMetadata.model, now, now, resolvedMetadata.surfaceTypeProvided ? 1 : 0, resolvedMetadata.surfaceType, deps.tenantId, canonicalId);
|
|
273
402
|
const reused = deps.db.prepare(`
|
|
274
403
|
SELECT *
|
|
275
404
|
FROM ai_profiles
|
|
@@ -281,7 +410,7 @@ export function resolveAiProfileService(deps, input) {
|
|
|
281
410
|
}
|
|
282
411
|
return {
|
|
283
412
|
created: false,
|
|
284
|
-
profile: deps
|
|
413
|
+
profile: hydrateAiProfileRecord(deps, reused),
|
|
285
414
|
...(exactMatches.length > 1 ? {
|
|
286
415
|
duplicateWarning: {
|
|
287
416
|
existingProfileId: canonicalId,
|
|
@@ -290,6 +419,15 @@ export function resolveAiProfileService(deps, input) {
|
|
|
290
419
|
} : {})
|
|
291
420
|
};
|
|
292
421
|
}
|
|
422
|
+
const aiProfileUsage = deps.countAiProfileUsage(normalizedWorkspaceId);
|
|
423
|
+
if (aiProfileUsage.limit !== null && aiProfileUsage.used >= aiProfileUsage.limit) {
|
|
424
|
+
throw deps.createRuleError('No AI seats are available for this workspace. Remove an existing AI profile or upgrade your plan.', 403, 'AI_PROFILE_LIMIT_REACHED', {
|
|
425
|
+
workspaceId: normalizedWorkspaceId,
|
|
426
|
+
limit: aiProfileUsage.limit,
|
|
427
|
+
used: aiProfileUsage.used,
|
|
428
|
+
remaining: aiProfileUsage.remaining
|
|
429
|
+
});
|
|
430
|
+
}
|
|
293
431
|
const id = deps.generateEntityId('ai-profile');
|
|
294
432
|
const issuedProfileToken = deps.issueProfileToken();
|
|
295
433
|
const username = deps.normalizeAiProfileUsername(undefined, name);
|
|
@@ -299,7 +437,14 @@ export function resolveAiProfileService(deps, input) {
|
|
|
299
437
|
const role = normalizeOptionalProfileField(input.role);
|
|
300
438
|
const provider = normalizeOptionalProfileField(input.provider);
|
|
301
439
|
const model = normalizeOptionalProfileField(input.model);
|
|
302
|
-
const
|
|
440
|
+
const resolvedMetadata = resolveAiProfileMetadataFields(deps, {
|
|
441
|
+
name,
|
|
442
|
+
description,
|
|
443
|
+
role,
|
|
444
|
+
provider,
|
|
445
|
+
model,
|
|
446
|
+
surfaceType: input.surfaceType,
|
|
447
|
+
}, null);
|
|
303
448
|
const providerMetadata = input.providerMetadata && typeof input.providerMetadata === 'object'
|
|
304
449
|
? input.providerMetadata
|
|
305
450
|
: null;
|
|
@@ -308,7 +453,7 @@ export function resolveAiProfileService(deps, input) {
|
|
|
308
453
|
id, tenant_id, workspace_id, profile_token, name, username, icon, color,
|
|
309
454
|
description, role, provider, model, surface_type, provider_metadata_json, created_at, updated_at, last_active_at
|
|
310
455
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
311
|
-
`).run(id, deps.tenantId, normalizedWorkspaceId, issuedProfileToken, name, username, icon, color, description
|
|
456
|
+
`).run(id, deps.tenantId, normalizedWorkspaceId, issuedProfileToken, name, username, icon, color, resolvedMetadata.description, resolvedMetadata.role, resolvedMetadata.provider, resolvedMetadata.model, resolvedMetadata.surfaceType, providerMetadata ? JSON.stringify(providerMetadata) : null, now, now, now);
|
|
312
457
|
const created = deps.db.prepare(`
|
|
313
458
|
SELECT *
|
|
314
459
|
FROM ai_profiles
|
|
@@ -320,6 +465,6 @@ export function resolveAiProfileService(deps, input) {
|
|
|
320
465
|
}
|
|
321
466
|
return {
|
|
322
467
|
created: true,
|
|
323
|
-
profile: deps
|
|
468
|
+
profile: hydrateAiProfileRecord(deps, created),
|
|
324
469
|
};
|
|
325
470
|
}
|
|
@@ -56,10 +56,12 @@ function createDeps(overrides) {
|
|
|
56
56
|
}),
|
|
57
57
|
generateEntityId: (prefix) => `${prefix}-1`,
|
|
58
58
|
issueProfileToken: () => 'tfp_test',
|
|
59
|
-
|
|
59
|
+
countAiProfileUsage: () => ({ used: 0, limit: null, remaining: null }),
|
|
60
|
+
createRuleError: (message, statusCode = 400, code = 'TASK_RULE_VIOLATION', details) => {
|
|
60
61
|
const error = new Error(message);
|
|
61
62
|
error.statusCode = statusCode;
|
|
62
63
|
error.code = code;
|
|
64
|
+
error.details = details;
|
|
63
65
|
return error;
|
|
64
66
|
},
|
|
65
67
|
...overrides
|
|
@@ -10,6 +10,28 @@ function createProjectRoot(prefix = 'taskforce-ai-profiles-') {
|
|
|
10
10
|
return root;
|
|
11
11
|
}
|
|
12
12
|
const createdRoots = [];
|
|
13
|
+
function enableAiProfileSeatLimit(core, workspaceId, userId, limit) {
|
|
14
|
+
core.upsertPlanCatalog({ planId: 'free', displayName: 'Free', status: 'active' });
|
|
15
|
+
core.ensureBootstrapUserAccess({
|
|
16
|
+
userId,
|
|
17
|
+
workspaceId,
|
|
18
|
+
role: 'owner',
|
|
19
|
+
email: `${userId}@example.com`
|
|
20
|
+
});
|
|
21
|
+
core.updatePlanVersionFeatures({
|
|
22
|
+
planVersionId: 'free-v1',
|
|
23
|
+
features: [
|
|
24
|
+
{ featureKey: 'collaboration.ai_profiles', access: 'limited', config: { maxAiProfiles: limit } }
|
|
25
|
+
]
|
|
26
|
+
});
|
|
27
|
+
core.updateAccountEntitlement({
|
|
28
|
+
accountId: userId,
|
|
29
|
+
planVersionId: 'free-v1',
|
|
30
|
+
entitlementState: 'active',
|
|
31
|
+
actorUserId: 'system-admin',
|
|
32
|
+
actorRole: 'system_admin'
|
|
33
|
+
});
|
|
34
|
+
}
|
|
13
35
|
afterEach(() => {
|
|
14
36
|
while (createdRoots.length > 0) {
|
|
15
37
|
const root = createdRoots.pop();
|
|
@@ -103,6 +125,125 @@ describe('TaskforceCore ai profiles', () => {
|
|
|
103
125
|
expect(second.profile.surfaceType).toBe('coding_tool');
|
|
104
126
|
expect(second.profile.color).toBe('#0ea5e9');
|
|
105
127
|
});
|
|
128
|
+
it('applies default metadata for named agent profiles when optional fields are omitted', () => {
|
|
129
|
+
const projectRoot = createProjectRoot();
|
|
130
|
+
createdRoots.push(projectRoot);
|
|
131
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
132
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
133
|
+
const resolved = core.resolveAiProfile({
|
|
134
|
+
workspaceId: 'workspace-1',
|
|
135
|
+
name: 'John Drake',
|
|
136
|
+
surfaceType: 'agent'
|
|
137
|
+
});
|
|
138
|
+
expect(resolved.profile.description).toBe('John Drake AI collaborator registered in Taskforce.');
|
|
139
|
+
expect(resolved.profile.role).toBe('AI Collaborator');
|
|
140
|
+
expect(resolved.profile.provider).toBeNull();
|
|
141
|
+
expect(resolved.profile.model).toBeNull();
|
|
142
|
+
expect(resolved.profile.surfaceType).toBe('agent');
|
|
143
|
+
});
|
|
144
|
+
it('self-heals sparse named agent profiles on re-resolve', () => {
|
|
145
|
+
const projectRoot = createProjectRoot();
|
|
146
|
+
createdRoots.push(projectRoot);
|
|
147
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
148
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
149
|
+
const first = core.resolveAiProfile({
|
|
150
|
+
workspaceId: 'workspace-1',
|
|
151
|
+
name: 'John Drake'
|
|
152
|
+
});
|
|
153
|
+
expect(first.profile.description).toBeNull();
|
|
154
|
+
expect(first.profile.role).toBeNull();
|
|
155
|
+
const second = core.resolveAiProfile({
|
|
156
|
+
workspaceId: 'workspace-1',
|
|
157
|
+
name: 'John Drake',
|
|
158
|
+
surfaceType: 'agent',
|
|
159
|
+
profileToken: first.profile.profileToken
|
|
160
|
+
});
|
|
161
|
+
expect(second.profile.id).toBe(first.profile.id);
|
|
162
|
+
expect(second.profile.description).toBe('John Drake AI collaborator registered in Taskforce.');
|
|
163
|
+
expect(second.profile.role).toBe('AI Collaborator');
|
|
164
|
+
expect(second.profile.provider).toBeNull();
|
|
165
|
+
expect(second.profile.model).toBeNull();
|
|
166
|
+
expect(second.profile.surfaceType).toBe('agent');
|
|
167
|
+
});
|
|
168
|
+
it('durably repairs sparse named agent profiles when listing profiles', () => {
|
|
169
|
+
const projectRoot = createProjectRoot();
|
|
170
|
+
createdRoots.push(projectRoot);
|
|
171
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
172
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
173
|
+
const first = core.resolveAiProfile({
|
|
174
|
+
workspaceId: 'workspace-1',
|
|
175
|
+
name: 'John Drake'
|
|
176
|
+
});
|
|
177
|
+
expect(first.profile.description).toBeNull();
|
|
178
|
+
expect(first.profile.role).toBeNull();
|
|
179
|
+
core.resolveAiProfile({
|
|
180
|
+
workspaceId: 'workspace-1',
|
|
181
|
+
name: 'John Drake',
|
|
182
|
+
surfaceType: 'agent',
|
|
183
|
+
profileToken: first.profile.profileToken
|
|
184
|
+
});
|
|
185
|
+
core.db.prepare(`
|
|
186
|
+
UPDATE ai_profiles
|
|
187
|
+
SET description = NULL, role = NULL, provider = NULL, model = NULL
|
|
188
|
+
WHERE tenant_id = ? AND workspace_id = ? AND id = ?
|
|
189
|
+
`).run('default', 'workspace-1', first.profile.id);
|
|
190
|
+
const listed = core.listAiProfiles('workspace-1')[0];
|
|
191
|
+
expect(listed.description).toBe('John Drake AI collaborator registered in Taskforce.');
|
|
192
|
+
expect(listed.role).toBe('AI Collaborator');
|
|
193
|
+
expect(listed.provider).toBeNull();
|
|
194
|
+
expect(listed.model).toBeNull();
|
|
195
|
+
const repaired = core.db.prepare(`
|
|
196
|
+
SELECT description, role, provider, model
|
|
197
|
+
FROM ai_profiles
|
|
198
|
+
WHERE tenant_id = ? AND workspace_id = ? AND id = ?
|
|
199
|
+
LIMIT 1
|
|
200
|
+
`).get('default', 'workspace-1', first.profile.id);
|
|
201
|
+
expect(repaired).toEqual({
|
|
202
|
+
description: 'John Drake AI collaborator registered in Taskforce.',
|
|
203
|
+
role: 'AI Collaborator',
|
|
204
|
+
provider: null,
|
|
205
|
+
model: null
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
it('durably repairs sparse coding-tool profiles when resolving by token lookup', () => {
|
|
209
|
+
const projectRoot = createProjectRoot();
|
|
210
|
+
createdRoots.push(projectRoot);
|
|
211
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
212
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
213
|
+
const resolved = core.resolveAiProfile({
|
|
214
|
+
workspaceId: 'workspace-1',
|
|
215
|
+
name: 'Codex',
|
|
216
|
+
surfaceType: 'coding_tool'
|
|
217
|
+
});
|
|
218
|
+
core.db.prepare(`
|
|
219
|
+
UPDATE ai_profiles
|
|
220
|
+
SET description = NULL, role = NULL, provider = NULL, model = NULL
|
|
221
|
+
WHERE tenant_id = ? AND workspace_id = ? AND id = ?
|
|
222
|
+
`).run('default', 'workspace-1', resolved.profile.id);
|
|
223
|
+
const fetched = core.getAiProfileByToken({
|
|
224
|
+
workspaceId: 'workspace-1',
|
|
225
|
+
profileToken: resolved.profile.profileToken
|
|
226
|
+
});
|
|
227
|
+
expect(fetched).toEqual(expect.objectContaining({
|
|
228
|
+
description: 'Codex coding agent connected through MCP.',
|
|
229
|
+
role: 'Coding Agent',
|
|
230
|
+
provider: 'OpenAI',
|
|
231
|
+
model: 'Codex',
|
|
232
|
+
surfaceType: 'coding_tool'
|
|
233
|
+
}));
|
|
234
|
+
const repaired = core.db.prepare(`
|
|
235
|
+
SELECT description, role, provider, model
|
|
236
|
+
FROM ai_profiles
|
|
237
|
+
WHERE tenant_id = ? AND workspace_id = ? AND id = ?
|
|
238
|
+
LIMIT 1
|
|
239
|
+
`).get('default', 'workspace-1', resolved.profile.id);
|
|
240
|
+
expect(repaired).toEqual({
|
|
241
|
+
description: 'Codex coding agent connected through MCP.',
|
|
242
|
+
role: 'Coding Agent',
|
|
243
|
+
provider: 'OpenAI',
|
|
244
|
+
model: 'Codex'
|
|
245
|
+
});
|
|
246
|
+
});
|
|
106
247
|
it('ignores empty-string optional ai profile updates instead of clearing stored values', () => {
|
|
107
248
|
const projectRoot = createProjectRoot();
|
|
108
249
|
createdRoots.push(projectRoot);
|
|
@@ -368,6 +509,65 @@ describe('TaskforceCore ai profiles', () => {
|
|
|
368
509
|
expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
|
|
369
510
|
expect(core.listAiProfiles('workspace-2')).toHaveLength(1);
|
|
370
511
|
});
|
|
512
|
+
it('allows token-based re-resolve even when the ai profile limit is full', () => {
|
|
513
|
+
const projectRoot = createProjectRoot();
|
|
514
|
+
createdRoots.push(projectRoot);
|
|
515
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
516
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
517
|
+
enableAiProfileSeatLimit(core, 'workspace-1', 'owner-u1', 1);
|
|
518
|
+
const first = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex' });
|
|
519
|
+
const second = core.resolveAiProfile({
|
|
520
|
+
workspaceId: 'workspace-1',
|
|
521
|
+
name: 'Codex Prime',
|
|
522
|
+
icon: 'Sparkles',
|
|
523
|
+
profileToken: first.profile.profileToken
|
|
524
|
+
});
|
|
525
|
+
expect(second.created).toBe(false);
|
|
526
|
+
expect(second.profile.id).toBe(first.profile.id);
|
|
527
|
+
expect(second.profile.name).toBe('Codex Prime');
|
|
528
|
+
expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
|
|
529
|
+
});
|
|
530
|
+
it('reuses an exact-match ai profile even when the ai profile limit is full', () => {
|
|
531
|
+
const projectRoot = createProjectRoot();
|
|
532
|
+
createdRoots.push(projectRoot);
|
|
533
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
534
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
535
|
+
enableAiProfileSeatLimit(core, 'workspace-1', 'owner-u1', 1);
|
|
536
|
+
const first = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Automation Codex' });
|
|
537
|
+
const second = core.resolveAiProfile({
|
|
538
|
+
workspaceId: 'workspace-1',
|
|
539
|
+
name: 'Automation Codex',
|
|
540
|
+
role: 'Assistant'
|
|
541
|
+
});
|
|
542
|
+
expect(second.created).toBe(false);
|
|
543
|
+
expect(second.profile.id).toBe(first.profile.id);
|
|
544
|
+
expect(second.profile.role).toBe('Assistant');
|
|
545
|
+
expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
|
|
546
|
+
});
|
|
547
|
+
it('blocks net-new ai profile creation when the ai profile limit is full', () => {
|
|
548
|
+
const projectRoot = createProjectRoot();
|
|
549
|
+
createdRoots.push(projectRoot);
|
|
550
|
+
const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
|
|
551
|
+
core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
|
|
552
|
+
enableAiProfileSeatLimit(core, 'workspace-1', 'owner-u1', 1);
|
|
553
|
+
const first = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex' });
|
|
554
|
+
expect(first.created).toBe(true);
|
|
555
|
+
try {
|
|
556
|
+
core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude' });
|
|
557
|
+
throw new Error('Expected AI profile creation to be blocked');
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
expect(error.message).toContain('No AI seats are available for this workspace');
|
|
561
|
+
expect(error.code).toBe('AI_PROFILE_LIMIT_REACHED');
|
|
562
|
+
expect(error.statusCode).toBe(403);
|
|
563
|
+
expect(error.details).toEqual({
|
|
564
|
+
workspaceId: 'workspace-1',
|
|
565
|
+
limit: 1,
|
|
566
|
+
used: 1,
|
|
567
|
+
remaining: 0
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
});
|
|
371
571
|
it('merges two duplicate profiles, reassigning task assignees and comment authors', () => {
|
|
372
572
|
const projectRoot = createProjectRoot();
|
|
373
573
|
createdRoots.push(projectRoot);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { TaskforceCore } from './Taskforce';
|
|
6
|
+
function makeCore() {
|
|
7
|
+
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'taskforce-auth-token-test-'));
|
|
8
|
+
const core = new TaskforceCore({
|
|
9
|
+
projectRoot,
|
|
10
|
+
storagePath: path.join(projectRoot, '.taskforce')
|
|
11
|
+
});
|
|
12
|
+
core.updateGlobalSettings({
|
|
13
|
+
billing: {
|
|
14
|
+
onboardingPolicy: {
|
|
15
|
+
mode: 'require_plan_selection',
|
|
16
|
+
planVersionId: null
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
core.upsertPlanCatalog({ planId: 'starter', displayName: 'Starter', status: 'active' });
|
|
21
|
+
return {
|
|
22
|
+
core,
|
|
23
|
+
cleanup: () => {
|
|
24
|
+
core.close();
|
|
25
|
+
fs.rmSync(projectRoot, { recursive: true, force: true });
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
describe('Auth token lifecycle', () => {
|
|
30
|
+
it('keeps previously issued verification tokens valid until they are used or expire', () => {
|
|
31
|
+
const { core, cleanup } = makeCore();
|
|
32
|
+
try {
|
|
33
|
+
core.registerPasswordUser({
|
|
34
|
+
email: 'verify@example.com',
|
|
35
|
+
password: 'password123',
|
|
36
|
+
displayName: 'Verify User',
|
|
37
|
+
betaAccess: true
|
|
38
|
+
});
|
|
39
|
+
const first = String(core.requestEmailVerification({ email: 'verify@example.com' }).token || '');
|
|
40
|
+
const second = String(core.requestEmailVerification({ email: 'verify@example.com' }).token || '');
|
|
41
|
+
expect(first).toMatch(/^[a-f0-9]{48}$/);
|
|
42
|
+
expect(second).toMatch(/^[a-f0-9]{48}$/);
|
|
43
|
+
expect(first).not.toBe(second);
|
|
44
|
+
expect(core.inspectOneTimeAuthToken(first, 'email_verification').state).toBe('valid');
|
|
45
|
+
expect(core.inspectOneTimeAuthToken(second, 'email_verification').state).toBe('valid');
|
|
46
|
+
expect(core.verifyEmailWithToken(first)).toBe(true);
|
|
47
|
+
expect(core.inspectOneTimeAuthToken(first, 'email_verification').state).toBe('used');
|
|
48
|
+
expect(core.inspectOneTimeAuthToken(second, 'email_verification').state).toBe('valid');
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
cleanup();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
it('keeps previously issued password reset tokens valid until they are used or expire', () => {
|
|
55
|
+
const { core, cleanup } = makeCore();
|
|
56
|
+
try {
|
|
57
|
+
core.registerPasswordUser({
|
|
58
|
+
email: 'reset@example.com',
|
|
59
|
+
password: 'password123',
|
|
60
|
+
displayName: 'Reset User',
|
|
61
|
+
betaAccess: true
|
|
62
|
+
});
|
|
63
|
+
const first = String(core.requestPasswordReset({ email: 'reset@example.com' }).token || '');
|
|
64
|
+
const second = String(core.requestPasswordReset({ email: 'reset@example.com' }).token || '');
|
|
65
|
+
expect(first).toMatch(/^[a-f0-9]{48}$/);
|
|
66
|
+
expect(second).toMatch(/^[a-f0-9]{48}$/);
|
|
67
|
+
expect(first).not.toBe(second);
|
|
68
|
+
expect(core.inspectOneTimeAuthToken(first, 'password_reset').state).toBe('valid');
|
|
69
|
+
expect(core.inspectOneTimeAuthToken(second, 'password_reset').state).toBe('valid');
|
|
70
|
+
expect(core.resetPasswordWithToken({ token: first, password: 'UpdatedPass456!' })).toBe(true);
|
|
71
|
+
expect(core.inspectOneTimeAuthToken(first, 'password_reset').state).toBe('used');
|
|
72
|
+
expect(core.inspectOneTimeAuthToken(second, 'password_reset').state).toBe('valid');
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
cleanup();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|