feat(member-work-sync): require report tokens for reports

This commit is contained in:
777genius 2026-04-29 13:26:01 +03:00
parent c39167ece7
commit b346a1146c
17 changed files with 453 additions and 10 deletions

View file

@ -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.`);
}

View file

@ -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() } : {}),

View file

@ -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,

View file

@ -47,6 +47,7 @@ export function registerWorkSyncTools(server: Pick<FastMCP, 'addTool'>) {
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<FastMCP, 'addTool'>) {
from,
state,
agendaFingerprint,
reportToken,
taskIds,
note,
leaseTtlMs,
@ -71,6 +73,7 @@ export function registerWorkSyncTools(server: Pick<FastMCP, 'addTool'>) {
...(from ? { from } : {}),
state,
agendaFingerprint,
reportToken,
...(taskIds ? { taskIds } : {}),
...(note ? { note } : {}),
...(leaseTtlMs ? { leaseTtlMs } : {}),

View file

@ -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,

View file

@ -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;

View file

@ -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<MemberWorkSyncStatus> {
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'],
};
}

View file

@ -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 {

View file

@ -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<MemberWorkSyncReportTokenVerification>;
}
export interface MemberWorkSyncLoggerPort {
debug(message: string, metadata?: Record<string, unknown>): void;
warn(message: string, metadata?: Record<string, unknown>): void;
@ -50,6 +79,7 @@ export interface MemberWorkSyncUseCaseDeps {
agendaSource: MemberWorkSyncAgendaSourcePort;
statusStore: MemberWorkSyncStatusStorePort;
reportStore?: MemberWorkSyncReportStorePort;
reportToken?: MemberWorkSyncReportTokenPort;
logger?: MemberWorkSyncLoggerPort;
}

View file

@ -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 ?? []) {

View file

@ -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);

View file

@ -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<string, Promise<string>>();
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<MemberWorkSyncReportTokenVerification> {
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<string> {
const secret = await this.getSecret(teamName);
return createHmac('sha256', secret).update(encodedPayload).digest('base64url');
}
private async getSecret(teamName: string): Promise<string> {
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<string> {
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;
}
}

View file

@ -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');
}
}

View file

@ -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 } : {}),

View file

@ -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');
});
});

View file

@ -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);
});
});

View file

@ -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' });
});
});