diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index dcb860af..56551a28 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -785,9 +785,9 @@ function buildMemberTaskProtocol(teamName, messagingProtocol = createMemberMessa - member_work_sync_status and member_work_sync_report are only for reporting whether you have seen the current actionable-work agenda. They do NOT start, complete, approve, or comment on tasks. - Never use member_work_sync_report instead of task_start, task_complete, review_approve, review_request_changes, task_set_clarification, or task_add_comment. - When you are about to stop, wait, or go idle because you believe your current work queue is handled, first call member_work_sync_status for yourself. - - If the returned agenda has actionable items and you are actively continuing work on them, call member_work_sync_report with state "still_working" and that exact agendaFingerprint. - - If you are blocked, report "blocked" only when the board already has blocker or clarification evidence for the listed task. - - If the returned agenda is empty, report "caught_up" with that exact agendaFingerprint. + - If the returned agenda has actionable items and you are actively continuing work on them, call member_work_sync_report with state "still_working", that exact agendaFingerprint, and the returned reportToken. + - If you are blocked, report "blocked" only when the board already has blocker or clarification evidence for the listed task, and include the returned reportToken. + - If the returned agenda is empty, report "caught_up" with that exact agendaFingerprint and the returned reportToken. - Do not report more than once for the same agendaFingerprint unless your state changed. Failure to follow this protocol means the task board will show incorrect status.`); } diff --git a/agent-teams-controller/src/internal/workSync.js b/agent-teams-controller/src/internal/workSync.js index 8a39fde9..43c38c90 100644 --- a/agent-teams-controller/src/internal/workSync.js +++ b/agent-teams-controller/src/internal/workSync.js @@ -92,6 +92,7 @@ function compactReportBody(context, memberName, flags = {}) { memberName, state: flags.state, agendaFingerprint: flags.agendaFingerprint || flags['agenda-fingerprint'], + reportToken: flags.reportToken || flags['report-token'], ...(Array.isArray(flags.taskIds) ? { taskIds: flags.taskIds } : {}), ...(Array.isArray(flags['task-ids']) ? { taskIds: flags['task-ids'] } : {}), ...(typeof flags.note === 'string' && flags.note.trim() ? { note: flags.note.trim() } : {}), diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index 4c45e659..b3240e15 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -2272,6 +2272,8 @@ describe('agent-teams-controller API', () => { items: [], diagnostics: [], }, + reportToken: 'wrs:v1.test.token', + reportTokenExpiresAt: '2026-04-29T00:15:00.000Z', evaluatedAt: '2026-04-29T00:00:00.000Z', diagnostics: ['no_current_report'], }, @@ -2293,6 +2295,7 @@ describe('agent-teams-controller API', () => { memberName: 'bob', state: 'still_working', agendaFingerprint: 'agenda:v1:abc', + reportToken: 'wrs:v1.test.token', taskIds: ['task-1'], note: 'Continuing work', leaseTtlMs: 120000, @@ -2314,6 +2317,7 @@ describe('agent-teams-controller API', () => { memberName: 'bob', state: 'still_working', agendaFingerprint: 'agenda:v1:abc', + reportToken: 'wrs:v1.test.token', taskIds: ['task-1'], note: 'Continuing work', leaseTtlMs: 120000, diff --git a/mcp-server/src/tools/workSyncTools.ts b/mcp-server/src/tools/workSyncTools.ts index 2f085a57..b3b8d122 100644 --- a/mcp-server/src/tools/workSyncTools.ts +++ b/mcp-server/src/tools/workSyncTools.ts @@ -47,6 +47,7 @@ export function registerWorkSyncTools(server: Pick) { from: z.string().min(1).optional(), state: reportStateSchema, agendaFingerprint: z.string().min(1), + reportToken: z.string().min(1), taskIds: z.array(z.string().min(1)).optional(), note: z.string().optional(), leaseTtlMs: z.number().int().min(60000).max(3600000).optional(), @@ -60,6 +61,7 @@ export function registerWorkSyncTools(server: Pick) { from, state, agendaFingerprint, + reportToken, taskIds, note, leaseTtlMs, @@ -71,6 +73,7 @@ export function registerWorkSyncTools(server: Pick) { ...(from ? { from } : {}), state, agendaFingerprint, + reportToken, ...(taskIds ? { taskIds } : {}), ...(note ? { note } : {}), ...(leaseTtlMs ? { leaseTtlMs } : {}), diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index 50929a3f..df98328f 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -425,6 +425,8 @@ describe('agent-teams-mcp tools', () => { items: [], diagnostics: [], }, + reportToken: 'wrs:v1.test.token', + reportTokenExpiresAt: '2026-04-29T00:15:00.000Z', evaluatedAt: '2026-04-29T00:00:00.000Z', diagnostics: ['no_current_report'], }, @@ -455,6 +457,7 @@ describe('agent-teams-mcp tools', () => { memberName: 'alice', state: 'still_working', agendaFingerprint: 'agenda:v1:abc', + reportToken: 'wrs:v1.test.token', taskIds: ['task-1'], note: 'Still working', leaseTtlMs: 120000, @@ -476,6 +479,7 @@ describe('agent-teams-mcp tools', () => { memberName: 'alice', state: 'still_working', agendaFingerprint: 'agenda:v1:abc', + reportToken: 'wrs:v1.test.token', taskIds: ['task-1'], note: 'Still working', leaseTtlMs: 120000, diff --git a/src/features/member-work-sync/contracts/types.ts b/src/features/member-work-sync/contracts/types.ts index 46e49c6e..8c2528f4 100644 --- a/src/features/member-work-sync/contracts/types.ts +++ b/src/features/member-work-sync/contracts/types.ts @@ -72,6 +72,8 @@ export interface MemberWorkSyncStatus { state: MemberWorkSyncStatusState; agenda: MemberWorkSyncAgenda; report?: MemberWorkSyncReport; + reportToken?: string; + reportTokenExpiresAt?: string; evaluatedAt: string; diagnostics: string[]; providerId?: MemberWorkSyncProviderId; @@ -82,6 +84,7 @@ export interface MemberWorkSyncReportRequest { memberName: string; state: MemberWorkSyncReportState; agendaFingerprint: string; + reportToken?: string; taskIds?: string[]; note?: string; reportedAt?: string; diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts b/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts index 29ed8ad3..9ef7519f 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncReconciler.ts @@ -42,7 +42,7 @@ export class MemberWorkSyncReconciler { inactive: source.inactive, }); - const status: MemberWorkSyncStatus = { + const status = await attachMemberWorkSyncReportToken(this.deps, { teamName: agenda.teamName, memberName: agenda.memberName, state: decision.state, @@ -51,9 +51,32 @@ export class MemberWorkSyncReconciler { evaluatedAt: nowIso, diagnostics: [...agenda.diagnostics, ...decision.diagnostics], ...(source.providerId ? { providerId: source.providerId } : {}), - }; + }); await this.deps.statusStore.write(status); return status; } } + +export async function attachMemberWorkSyncReportToken( + deps: MemberWorkSyncUseCaseDeps, + status: MemberWorkSyncStatus +): Promise { + if (!deps.reportToken) { + return status; + } + + const issued = await deps.reportToken.create({ + teamName: status.teamName, + memberName: status.memberName, + agendaFingerprint: status.agenda.fingerprint, + issuedAt: status.evaluatedAt, + }); + + return { + ...status, + reportToken: issued.token, + reportTokenExpiresAt: issued.expiresAt, + diagnostics: [...status.diagnostics, 'report_token_issued'], + }; +} diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts b/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts index b5e00406..06c733ae 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncReporter.ts @@ -4,9 +4,21 @@ import type { MemberWorkSyncReportResult, } from '../../contracts'; import { validateMemberWorkSyncReport } from '../domain'; -import { finalizeMemberWorkSyncAgenda, MemberWorkSyncReconciler } from './MemberWorkSyncReconciler'; +import { + attachMemberWorkSyncReportToken, + finalizeMemberWorkSyncAgenda, + MemberWorkSyncReconciler, +} from './MemberWorkSyncReconciler'; import type { MemberWorkSyncUseCaseDeps } from './ports'; +const TERMINAL_REPORT_REJECTION_CODES = new Set([ + 'reserved_or_invalid_member', + 'identity_mismatch', + 'member_inactive', + 'identity_untrusted', + 'invalid_report_token', +]); + export class MemberWorkSyncReporter { private readonly reconciler: MemberWorkSyncReconciler; @@ -20,16 +32,28 @@ export class MemberWorkSyncReporter { const nowIso = ( request.reportedAt ? new Date(request.reportedAt) : this.deps.clock.now() ).toISOString(); + const tokenValidation = this.deps.reportToken + ? await this.deps.reportToken.verify({ + token: request.reportToken, + teamName: agenda.teamName, + memberName: agenda.memberName, + agendaFingerprint: agenda.fingerprint, + nowIso, + }) + : ({ ok: false, reason: 'missing' } as const); const validation = validateMemberWorkSyncReport({ request, agenda, nowIso, activeMemberNames: source.activeMemberNames, + tokenValidation, }); if (!validation.ok) { const status = await this.reconciler.execute(request); - await this.deps.reportStore?.appendPendingReport?.(request, validation.code); + if (!TERMINAL_REPORT_REJECTION_CODES.has(validation.code)) { + await this.deps.reportStore?.appendPendingReport?.(request, validation.code); + } return { accepted: false, code: validation.code, @@ -51,7 +75,7 @@ export class MemberWorkSyncReporter { accepted: true, }; - const status = { + const status = await attachMemberWorkSyncReportToken(this.deps, { teamName: agenda.teamName, memberName: agenda.memberName, state: @@ -65,7 +89,7 @@ export class MemberWorkSyncReporter { evaluatedAt: nowIso, diagnostics: [...agenda.diagnostics, 'report_accepted'], ...(source.providerId ? { providerId: source.providerId } : {}), - }; + }); await this.deps.statusStore.write(status); return { diff --git a/src/features/member-work-sync/core/application/ports.ts b/src/features/member-work-sync/core/application/ports.ts index fb098c20..1df714db 100644 --- a/src/features/member-work-sync/core/application/ports.ts +++ b/src/features/member-work-sync/core/application/ports.ts @@ -14,6 +14,35 @@ export interface MemberWorkSyncHashPort { sha256Hex(value: string): string; } +export interface MemberWorkSyncReportTokenCreateInput { + teamName: string; + memberName: string; + agendaFingerprint: string; + issuedAt: string; +} + +export interface MemberWorkSyncReportTokenVerifyInput { + token?: string; + teamName: string; + memberName: string; + agendaFingerprint: string; + nowIso: string; +} + +export type MemberWorkSyncReportTokenVerification = + | { ok: true } + | { ok: false; reason: 'missing' | 'expired' | 'invalid' }; + +export interface MemberWorkSyncReportTokenPort { + create(input: MemberWorkSyncReportTokenCreateInput): Promise<{ + token: string; + expiresAt: string; + }>; + verify( + input: MemberWorkSyncReportTokenVerifyInput + ): Promise; +} + export interface MemberWorkSyncLoggerPort { debug(message: string, metadata?: Record): void; warn(message: string, metadata?: Record): void; @@ -50,6 +79,7 @@ export interface MemberWorkSyncUseCaseDeps { agendaSource: MemberWorkSyncAgendaSourcePort; statusStore: MemberWorkSyncStatusStorePort; reportStore?: MemberWorkSyncReportStorePort; + reportToken?: MemberWorkSyncReportTokenPort; logger?: MemberWorkSyncLoggerPort; } diff --git a/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts b/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts index dcf5efc2..29b34c2a 100644 --- a/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts +++ b/src/features/member-work-sync/core/domain/MemberWorkSyncReportValidator.ts @@ -12,6 +12,10 @@ export interface MemberWorkSyncReportValidation { expiresAt?: string; } +export type MemberWorkSyncReportTokenValidation = + | { ok: true } + | { ok: false; reason: 'missing' | 'expired' | 'invalid' }; + const DEFAULT_STILL_WORKING_LEASE_MS = 15 * 60 * 1000; const DEFAULT_BLOCKED_LEASE_MS = 30 * 60 * 1000; const MIN_LEASE_MS = 60_000; @@ -47,6 +51,7 @@ export function validateMemberWorkSyncReport(input: { agenda: MemberWorkSyncAgenda; nowIso: string; activeMemberNames: string[]; + tokenValidation: MemberWorkSyncReportTokenValidation; }): MemberWorkSyncReportValidation { const memberName = normalizeMemberName(input.request.memberName); const activeMemberNames = new Set(input.activeMemberNames.map(normalizeMemberName)); @@ -71,6 +76,20 @@ export function validateMemberWorkSyncReport(input: { message: 'Report fingerprint is stale. Read current member work sync status and retry.', }; } + if (!input.tokenValidation.ok) { + return input.tokenValidation.reason === 'missing' + ? { + ok: false, + code: 'identity_untrusted', + message: 'Report token is required. Read current member work sync status and retry.', + } + : { + ok: false, + code: 'invalid_report_token', + message: + 'Report token is invalid or expired. Read current member work sync status and retry.', + }; + } const agendaTaskIds = new Set(input.agenda.items.map((item) => item.taskId)); for (const taskId of input.request.taskIds ?? []) { diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index 4c3d6188..d667cf1b 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -6,6 +6,7 @@ import type { } from '../../contracts'; import { MemberWorkSyncDiagnosticsReader, MemberWorkSyncReporter } from '../../core/application'; import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource'; +import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter'; import { JsonMemberWorkSyncStore } from '../infrastructure/JsonMemberWorkSyncStore'; import { MemberWorkSyncStorePaths } from '../infrastructure/MemberWorkSyncStorePaths'; import { NodeHashAdapter } from '../infrastructure/NodeHashAdapter'; @@ -40,13 +41,16 @@ export function createMemberWorkSyncFeature(deps: { hash, clock, }); - const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(deps.teamsBasePath)); + const storePaths = new MemberWorkSyncStorePaths(deps.teamsBasePath); + const store = new JsonMemberWorkSyncStore(storePaths); + const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths); const useCaseDeps = { clock, hash, agendaSource, statusStore: store, reportStore: store, + reportToken, logger: deps.logger, }; const diagnosticsReader = new MemberWorkSyncDiagnosticsReader(useCaseDeps); diff --git a/src/features/member-work-sync/main/infrastructure/HmacMemberWorkSyncReportTokenAdapter.ts b/src/features/member-work-sync/main/infrastructure/HmacMemberWorkSyncReportTokenAdapter.ts new file mode 100644 index 00000000..ad211162 --- /dev/null +++ b/src/features/member-work-sync/main/infrastructure/HmacMemberWorkSyncReportTokenAdapter.ts @@ -0,0 +1,171 @@ +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; +import { mkdir, readFile } from 'node:fs/promises'; + +import { atomicWriteAsync } from '@main/utils/atomicWrite'; + +import type { + MemberWorkSyncReportTokenCreateInput, + MemberWorkSyncReportTokenPort, + MemberWorkSyncReportTokenVerifyInput, + MemberWorkSyncReportTokenVerification, +} from '../../core/application'; +import type { MemberWorkSyncStorePaths } from './MemberWorkSyncStorePaths'; + +const TOKEN_PREFIX = 'wrs:v1'; +const TOKEN_TTL_MS = 15 * 60 * 1000; + +interface SecretFile { + schemaVersion: 1; + secret: string; +} + +interface TokenPayload { + version: 1; + teamName: string; + memberName: string; + agendaFingerprint: string; + expiresAt: string; +} + +function base64UrlEncode(value: string): string { + return Buffer.from(value, 'utf8').toString('base64url'); +} + +function base64UrlDecode(value: string): string { + return Buffer.from(value, 'base64url').toString('utf8'); +} + +function isSecretFile(value: unknown): value is SecretFile { + return ( + value != null && + typeof value === 'object' && + (value as SecretFile).schemaVersion === 1 && + typeof (value as SecretFile).secret === 'string' && + (value as SecretFile).secret.length >= 32 + ); +} + +function isTokenPayload(value: unknown): value is TokenPayload { + return ( + value != null && + typeof value === 'object' && + (value as TokenPayload).version === 1 && + typeof (value as TokenPayload).teamName === 'string' && + typeof (value as TokenPayload).memberName === 'string' && + typeof (value as TokenPayload).agendaFingerprint === 'string' && + typeof (value as TokenPayload).expiresAt === 'string' + ); +} + +function safeEqual(left: string, right: string): boolean { + const leftBytes = Buffer.from(left); + const rightBytes = Buffer.from(right); + return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes); +} + +export class HmacMemberWorkSyncReportTokenAdapter implements MemberWorkSyncReportTokenPort { + private readonly secretCache = new Map>(); + + constructor(private readonly paths: MemberWorkSyncStorePaths) {} + + async create(input: MemberWorkSyncReportTokenCreateInput): Promise<{ + token: string; + expiresAt: string; + }> { + const expiresAt = new Date(Date.parse(input.issuedAt) + TOKEN_TTL_MS).toISOString(); + const payload: TokenPayload = { + version: 1, + teamName: input.teamName, + memberName: input.memberName, + agendaFingerprint: input.agendaFingerprint, + expiresAt, + }; + const encodedPayload = base64UrlEncode(JSON.stringify(payload)); + const signature = await this.sign(input.teamName, encodedPayload); + return { + token: `${TOKEN_PREFIX}.${encodedPayload}.${signature}`, + expiresAt, + }; + } + + async verify( + input: MemberWorkSyncReportTokenVerifyInput + ): Promise { + if (!input.token) { + return { ok: false, reason: 'missing' }; + } + + const [prefix, encodedPayload, signature, extra] = input.token.split('.'); + if (prefix !== TOKEN_PREFIX || !encodedPayload || !signature || extra) { + return { ok: false, reason: 'invalid' }; + } + + const expectedSignature = await this.sign(input.teamName, encodedPayload); + if (!safeEqual(signature, expectedSignature)) { + return { ok: false, reason: 'invalid' }; + } + + let payload: unknown; + try { + payload = JSON.parse(base64UrlDecode(encodedPayload)); + } catch { + return { ok: false, reason: 'invalid' }; + } + if (!isTokenPayload(payload)) { + return { ok: false, reason: 'invalid' }; + } + if ( + payload.teamName !== input.teamName || + payload.memberName !== input.memberName || + payload.agendaFingerprint !== input.agendaFingerprint + ) { + return { ok: false, reason: 'invalid' }; + } + if (Date.parse(payload.expiresAt) <= Date.parse(input.nowIso)) { + return { ok: false, reason: 'expired' }; + } + + return { ok: true }; + } + + private async sign(teamName: string, encodedPayload: string): Promise { + const secret = await this.getSecret(teamName); + return createHmac('sha256', secret).update(encodedPayload).digest('base64url'); + } + + private async getSecret(teamName: string): Promise { + const existing = this.secretCache.get(teamName); + if (existing) { + return existing; + } + + const next = this.loadOrCreateSecret(teamName); + this.secretCache.set(teamName, next); + return next; + } + + private async loadOrCreateSecret(teamName: string): Promise { + try { + const raw = await readFile(this.paths.getReportTokenSecretPath(teamName), 'utf8'); + const parsed = JSON.parse(raw); + if (isSecretFile(parsed)) { + return parsed.secret; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + + const secretFile: SecretFile = { + schemaVersion: 1, + secret: randomBytes(32).toString('base64url'), + }; + await mkdir(this.paths.getTeamDir(teamName), { recursive: true }); + await atomicWriteAsync( + this.paths.getReportTokenSecretPath(teamName), + JSON.stringify(secretFile, null, 2) + ); + return secretFile.secret; + } +} diff --git a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts index f82f7b4f..79811045 100644 --- a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts +++ b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts @@ -14,4 +14,8 @@ export class MemberWorkSyncStorePaths { getPendingReportsPath(teamName: string): string { return join(this.getTeamDir(teamName), 'pending-reports.jsonl'); } + + getReportTokenSecretPath(teamName: string): string { + return join(this.getTeamDir(teamName), 'report-token-secret.json'); + } } diff --git a/src/main/http/teams.ts b/src/main/http/teams.ts index 4d67535f..2a93459c 100644 --- a/src/main/http/teams.ts +++ b/src/main/http/teams.ts @@ -813,6 +813,9 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices) memberName, state, agendaFingerprint, + ...(typeof payload.reportToken === 'string' + ? { reportToken: payload.reportToken } + : {}), ...(taskIds ? { taskIds } : {}), ...(typeof payload.note === 'string' ? { note: payload.note } : {}), ...(typeof payload.reportedAt === 'string' ? { reportedAt: payload.reportedAt } : {}), diff --git a/test/features/member-work-sync/core/MemberWorkSyncReportValidator.test.ts b/test/features/member-work-sync/core/MemberWorkSyncReportValidator.test.ts index 8a7a902a..8116b071 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncReportValidator.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncReportValidator.test.ts @@ -7,6 +7,7 @@ import { const nowIso = '2026-04-29T00:00:00.000Z'; const hash = (value: string) => `h${value.length}`; +const validToken = { ok: true } as const; function agendaWithWork() { return buildActionableWorkAgenda({ @@ -32,6 +33,7 @@ describe('validateMemberWorkSyncReport', () => { agenda, nowIso, activeMemberNames: ['bob'], + tokenValidation: validToken, }); expect(result.ok).toBe(true); @@ -50,6 +52,7 @@ describe('validateMemberWorkSyncReport', () => { agenda, nowIso, activeMemberNames: ['bob'], + tokenValidation: validToken, }); expect(result).toMatchObject({ @@ -70,6 +73,7 @@ describe('validateMemberWorkSyncReport', () => { agenda, nowIso, activeMemberNames: ['bob'], + tokenValidation: validToken, }); expect(result).toMatchObject({ ok: false, code: 'blocked_without_evidence' }); @@ -87,6 +91,7 @@ describe('validateMemberWorkSyncReport', () => { agenda, nowIso, activeMemberNames: ['bob'], + tokenValidation: validToken, }); const foreign = validateMemberWorkSyncReport({ request: { @@ -99,6 +104,7 @@ describe('validateMemberWorkSyncReport', () => { agenda, nowIso, activeMemberNames: ['bob'], + tokenValidation: validToken, }); expect(stale.code).toBe('stale_fingerprint'); @@ -117,6 +123,7 @@ describe('validateMemberWorkSyncReport', () => { agenda, nowIso, activeMemberNames: ['bob'], + tokenValidation: validToken, }); const inactive = validateMemberWorkSyncReport({ request: { @@ -128,9 +135,41 @@ describe('validateMemberWorkSyncReport', () => { agenda, nowIso, activeMemberNames: [], + tokenValidation: validToken, }); expect(reserved.code).toBe('reserved_or_invalid_member'); expect(inactive.code).toBe('member_inactive'); }); + + it('rejects missing or invalid report tokens for otherwise current reports', () => { + const agenda = agendaWithWork(); + const missing = validateMemberWorkSyncReport({ + request: { + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: agenda.fingerprint, + }, + agenda, + nowIso, + activeMemberNames: ['bob'], + tokenValidation: { ok: false, reason: 'missing' }, + }); + const invalid = validateMemberWorkSyncReport({ + request: { + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: agenda.fingerprint, + }, + agenda, + nowIso, + activeMemberNames: ['bob'], + tokenValidation: { ok: false, reason: 'invalid' }, + }); + + expect(missing.code).toBe('identity_untrusted'); + expect(invalid.code).toBe('invalid_report_token'); + }); }); diff --git a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts index 3d41a6f8..afa598e9 100644 --- a/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts +++ b/test/features/member-work-sync/core/MemberWorkSyncUseCases.test.ts @@ -87,6 +87,16 @@ function createDeps(options?: { }, statusStore: store, reportStore: store, + reportToken: { + create: async (input) => ({ + token: `token:${input.teamName}:${input.memberName}:${input.agendaFingerprint}`, + expiresAt: '2026-04-29T00:15:00.000Z', + }), + verify: async (input) => + input.token === `token:${input.teamName}:${input.memberName}:${input.agendaFingerprint}` + ? { ok: true } + : { ok: false, reason: input.token ? 'invalid' : 'missing' }, + }, }; return { clock, deps, source, store }; } @@ -116,6 +126,7 @@ describe('MemberWorkSync use cases', () => { memberName: 'bob', state: 'still_working', agendaFingerprint: current.agenda.fingerprint, + reportToken: current.reportToken, taskIds: ['task-1'], leaseTtlMs: 120_000, source: 'test', @@ -163,10 +174,31 @@ describe('MemberWorkSync use cases', () => { memberName: 'bob', state: 'caught_up', agendaFingerprint: current.agenda.fingerprint, + reportToken: current.reportToken, source: 'test', }); expect(result.accepted).toBe(true); expect(result.status.state).toBe('caught_up'); }); + + it('rejects invalid report tokens without recording replayable intents', async () => { + const { deps, store } = createDeps(); + const reader = new MemberWorkSyncDiagnosticsReader(deps); + const reporter = new MemberWorkSyncReporter(deps); + const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); + + const result = await reporter.execute({ + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: current.agenda.fingerprint, + reportToken: 'token:team-a:alice:wrong', + source: 'test', + }); + + expect(result.accepted).toBe(false); + expect(result.code).toBe('invalid_report_token'); + expect(store.pendingReports).toHaveLength(0); + }); }); diff --git a/test/features/member-work-sync/main/HmacMemberWorkSyncReportTokenAdapter.test.ts b/test/features/member-work-sync/main/HmacMemberWorkSyncReportTokenAdapter.test.ts new file mode 100644 index 00000000..443e1a67 --- /dev/null +++ b/test/features/member-work-sync/main/HmacMemberWorkSyncReportTokenAdapter.test.ts @@ -0,0 +1,79 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { HmacMemberWorkSyncReportTokenAdapter } from '@features/member-work-sync/main/infrastructure/HmacMemberWorkSyncReportTokenAdapter'; +import { MemberWorkSyncStorePaths } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths'; + +describe('HmacMemberWorkSyncReportTokenAdapter', () => { + let root: string; + let adapter: HmacMemberWorkSyncReportTokenAdapter; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'member-work-sync-token-')); + adapter = new HmacMemberWorkSyncReportTokenAdapter(new MemberWorkSyncStorePaths(root)); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it('creates a token bound to team, member, fingerprint, and expiry', async () => { + const issued = await adapter.create({ + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + issuedAt: '2026-04-29T00:00:00.000Z', + }); + + expect(issued.expiresAt).toBe('2026-04-29T00:15:00.000Z'); + await expect( + adapter.verify({ + token: issued.token, + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + nowIso: '2026-04-29T00:14:59.000Z', + }) + ).resolves.toEqual({ ok: true }); + }); + + it('rejects copied, stale, and expired tokens', async () => { + const issued = await adapter.create({ + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + issuedAt: '2026-04-29T00:00:00.000Z', + }); + + await expect( + adapter.verify({ + token: issued.token, + teamName: 'team-a', + memberName: 'alice', + agendaFingerprint: 'agenda:v1:abc', + nowIso: '2026-04-29T00:01:00.000Z', + }) + ).resolves.toEqual({ ok: false, reason: 'invalid' }); + await expect( + adapter.verify({ + token: issued.token, + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:new', + nowIso: '2026-04-29T00:01:00.000Z', + }) + ).resolves.toEqual({ ok: false, reason: 'invalid' }); + await expect( + adapter.verify({ + token: issued.token, + teamName: 'team-a', + memberName: 'bob', + agendaFingerprint: 'agenda:v1:abc', + nowIso: '2026-04-29T00:15:00.000Z', + }) + ).resolves.toEqual({ ok: false, reason: 'expired' }); + }); +});