feat(member-work-sync): require report tokens for reports
This commit is contained in:
parent
c39167ece7
commit
b346a1146c
17 changed files with 453 additions and 10 deletions
|
|
@ -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.
|
- 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.
|
- 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.
|
- 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 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.
|
- 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.
|
- 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.
|
- 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.`);
|
Failure to follow this protocol means the task board will show incorrect status.`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ function compactReportBody(context, memberName, flags = {}) {
|
||||||
memberName,
|
memberName,
|
||||||
state: flags.state,
|
state: flags.state,
|
||||||
agendaFingerprint: flags.agendaFingerprint || flags['agenda-fingerprint'],
|
agendaFingerprint: flags.agendaFingerprint || flags['agenda-fingerprint'],
|
||||||
|
reportToken: flags.reportToken || flags['report-token'],
|
||||||
...(Array.isArray(flags.taskIds) ? { taskIds: flags.taskIds } : {}),
|
...(Array.isArray(flags.taskIds) ? { taskIds: flags.taskIds } : {}),
|
||||||
...(Array.isArray(flags['task-ids']) ? { taskIds: flags['task-ids'] } : {}),
|
...(Array.isArray(flags['task-ids']) ? { taskIds: flags['task-ids'] } : {}),
|
||||||
...(typeof flags.note === 'string' && flags.note.trim() ? { note: flags.note.trim() } : {}),
|
...(typeof flags.note === 'string' && flags.note.trim() ? { note: flags.note.trim() } : {}),
|
||||||
|
|
|
||||||
|
|
@ -2272,6 +2272,8 @@ describe('agent-teams-controller API', () => {
|
||||||
items: [],
|
items: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
},
|
},
|
||||||
|
reportToken: 'wrs:v1.test.token',
|
||||||
|
reportTokenExpiresAt: '2026-04-29T00:15:00.000Z',
|
||||||
evaluatedAt: '2026-04-29T00:00:00.000Z',
|
evaluatedAt: '2026-04-29T00:00:00.000Z',
|
||||||
diagnostics: ['no_current_report'],
|
diagnostics: ['no_current_report'],
|
||||||
},
|
},
|
||||||
|
|
@ -2293,6 +2295,7 @@ describe('agent-teams-controller API', () => {
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
state: 'still_working',
|
state: 'still_working',
|
||||||
agendaFingerprint: 'agenda:v1:abc',
|
agendaFingerprint: 'agenda:v1:abc',
|
||||||
|
reportToken: 'wrs:v1.test.token',
|
||||||
taskIds: ['task-1'],
|
taskIds: ['task-1'],
|
||||||
note: 'Continuing work',
|
note: 'Continuing work',
|
||||||
leaseTtlMs: 120000,
|
leaseTtlMs: 120000,
|
||||||
|
|
@ -2314,6 +2317,7 @@ describe('agent-teams-controller API', () => {
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
state: 'still_working',
|
state: 'still_working',
|
||||||
agendaFingerprint: 'agenda:v1:abc',
|
agendaFingerprint: 'agenda:v1:abc',
|
||||||
|
reportToken: 'wrs:v1.test.token',
|
||||||
taskIds: ['task-1'],
|
taskIds: ['task-1'],
|
||||||
note: 'Continuing work',
|
note: 'Continuing work',
|
||||||
leaseTtlMs: 120000,
|
leaseTtlMs: 120000,
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ export function registerWorkSyncTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
from: z.string().min(1).optional(),
|
from: z.string().min(1).optional(),
|
||||||
state: reportStateSchema,
|
state: reportStateSchema,
|
||||||
agendaFingerprint: z.string().min(1),
|
agendaFingerprint: z.string().min(1),
|
||||||
|
reportToken: z.string().min(1),
|
||||||
taskIds: z.array(z.string().min(1)).optional(),
|
taskIds: z.array(z.string().min(1)).optional(),
|
||||||
note: z.string().optional(),
|
note: z.string().optional(),
|
||||||
leaseTtlMs: z.number().int().min(60000).max(3600000).optional(),
|
leaseTtlMs: z.number().int().min(60000).max(3600000).optional(),
|
||||||
|
|
@ -60,6 +61,7 @@ export function registerWorkSyncTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
from,
|
from,
|
||||||
state,
|
state,
|
||||||
agendaFingerprint,
|
agendaFingerprint,
|
||||||
|
reportToken,
|
||||||
taskIds,
|
taskIds,
|
||||||
note,
|
note,
|
||||||
leaseTtlMs,
|
leaseTtlMs,
|
||||||
|
|
@ -71,6 +73,7 @@ export function registerWorkSyncTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...(from ? { from } : {}),
|
...(from ? { from } : {}),
|
||||||
state,
|
state,
|
||||||
agendaFingerprint,
|
agendaFingerprint,
|
||||||
|
reportToken,
|
||||||
...(taskIds ? { taskIds } : {}),
|
...(taskIds ? { taskIds } : {}),
|
||||||
...(note ? { note } : {}),
|
...(note ? { note } : {}),
|
||||||
...(leaseTtlMs ? { leaseTtlMs } : {}),
|
...(leaseTtlMs ? { leaseTtlMs } : {}),
|
||||||
|
|
|
||||||
|
|
@ -425,6 +425,8 @@ describe('agent-teams-mcp tools', () => {
|
||||||
items: [],
|
items: [],
|
||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
},
|
},
|
||||||
|
reportToken: 'wrs:v1.test.token',
|
||||||
|
reportTokenExpiresAt: '2026-04-29T00:15:00.000Z',
|
||||||
evaluatedAt: '2026-04-29T00:00:00.000Z',
|
evaluatedAt: '2026-04-29T00:00:00.000Z',
|
||||||
diagnostics: ['no_current_report'],
|
diagnostics: ['no_current_report'],
|
||||||
},
|
},
|
||||||
|
|
@ -455,6 +457,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
memberName: 'alice',
|
memberName: 'alice',
|
||||||
state: 'still_working',
|
state: 'still_working',
|
||||||
agendaFingerprint: 'agenda:v1:abc',
|
agendaFingerprint: 'agenda:v1:abc',
|
||||||
|
reportToken: 'wrs:v1.test.token',
|
||||||
taskIds: ['task-1'],
|
taskIds: ['task-1'],
|
||||||
note: 'Still working',
|
note: 'Still working',
|
||||||
leaseTtlMs: 120000,
|
leaseTtlMs: 120000,
|
||||||
|
|
@ -476,6 +479,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
memberName: 'alice',
|
memberName: 'alice',
|
||||||
state: 'still_working',
|
state: 'still_working',
|
||||||
agendaFingerprint: 'agenda:v1:abc',
|
agendaFingerprint: 'agenda:v1:abc',
|
||||||
|
reportToken: 'wrs:v1.test.token',
|
||||||
taskIds: ['task-1'],
|
taskIds: ['task-1'],
|
||||||
note: 'Still working',
|
note: 'Still working',
|
||||||
leaseTtlMs: 120000,
|
leaseTtlMs: 120000,
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,8 @@ export interface MemberWorkSyncStatus {
|
||||||
state: MemberWorkSyncStatusState;
|
state: MemberWorkSyncStatusState;
|
||||||
agenda: MemberWorkSyncAgenda;
|
agenda: MemberWorkSyncAgenda;
|
||||||
report?: MemberWorkSyncReport;
|
report?: MemberWorkSyncReport;
|
||||||
|
reportToken?: string;
|
||||||
|
reportTokenExpiresAt?: string;
|
||||||
evaluatedAt: string;
|
evaluatedAt: string;
|
||||||
diagnostics: string[];
|
diagnostics: string[];
|
||||||
providerId?: MemberWorkSyncProviderId;
|
providerId?: MemberWorkSyncProviderId;
|
||||||
|
|
@ -82,6 +84,7 @@ export interface MemberWorkSyncReportRequest {
|
||||||
memberName: string;
|
memberName: string;
|
||||||
state: MemberWorkSyncReportState;
|
state: MemberWorkSyncReportState;
|
||||||
agendaFingerprint: string;
|
agendaFingerprint: string;
|
||||||
|
reportToken?: string;
|
||||||
taskIds?: string[];
|
taskIds?: string[];
|
||||||
note?: string;
|
note?: string;
|
||||||
reportedAt?: string;
|
reportedAt?: string;
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ export class MemberWorkSyncReconciler {
|
||||||
inactive: source.inactive,
|
inactive: source.inactive,
|
||||||
});
|
});
|
||||||
|
|
||||||
const status: MemberWorkSyncStatus = {
|
const status = await attachMemberWorkSyncReportToken(this.deps, {
|
||||||
teamName: agenda.teamName,
|
teamName: agenda.teamName,
|
||||||
memberName: agenda.memberName,
|
memberName: agenda.memberName,
|
||||||
state: decision.state,
|
state: decision.state,
|
||||||
|
|
@ -51,9 +51,32 @@ export class MemberWorkSyncReconciler {
|
||||||
evaluatedAt: nowIso,
|
evaluatedAt: nowIso,
|
||||||
diagnostics: [...agenda.diagnostics, ...decision.diagnostics],
|
diagnostics: [...agenda.diagnostics, ...decision.diagnostics],
|
||||||
...(source.providerId ? { providerId: source.providerId } : {}),
|
...(source.providerId ? { providerId: source.providerId } : {}),
|
||||||
};
|
});
|
||||||
|
|
||||||
await this.deps.statusStore.write(status);
|
await this.deps.statusStore.write(status);
|
||||||
return 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'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,21 @@ import type {
|
||||||
MemberWorkSyncReportResult,
|
MemberWorkSyncReportResult,
|
||||||
} from '../../contracts';
|
} from '../../contracts';
|
||||||
import { validateMemberWorkSyncReport } from '../domain';
|
import { validateMemberWorkSyncReport } from '../domain';
|
||||||
import { finalizeMemberWorkSyncAgenda, MemberWorkSyncReconciler } from './MemberWorkSyncReconciler';
|
import {
|
||||||
|
attachMemberWorkSyncReportToken,
|
||||||
|
finalizeMemberWorkSyncAgenda,
|
||||||
|
MemberWorkSyncReconciler,
|
||||||
|
} from './MemberWorkSyncReconciler';
|
||||||
import type { MemberWorkSyncUseCaseDeps } from './ports';
|
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 {
|
export class MemberWorkSyncReporter {
|
||||||
private readonly reconciler: MemberWorkSyncReconciler;
|
private readonly reconciler: MemberWorkSyncReconciler;
|
||||||
|
|
||||||
|
|
@ -20,16 +32,28 @@ export class MemberWorkSyncReporter {
|
||||||
const nowIso = (
|
const nowIso = (
|
||||||
request.reportedAt ? new Date(request.reportedAt) : this.deps.clock.now()
|
request.reportedAt ? new Date(request.reportedAt) : this.deps.clock.now()
|
||||||
).toISOString();
|
).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({
|
const validation = validateMemberWorkSyncReport({
|
||||||
request,
|
request,
|
||||||
agenda,
|
agenda,
|
||||||
nowIso,
|
nowIso,
|
||||||
activeMemberNames: source.activeMemberNames,
|
activeMemberNames: source.activeMemberNames,
|
||||||
|
tokenValidation,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!validation.ok) {
|
if (!validation.ok) {
|
||||||
const status = await this.reconciler.execute(request);
|
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 {
|
return {
|
||||||
accepted: false,
|
accepted: false,
|
||||||
code: validation.code,
|
code: validation.code,
|
||||||
|
|
@ -51,7 +75,7 @@ export class MemberWorkSyncReporter {
|
||||||
accepted: true,
|
accepted: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const status = {
|
const status = await attachMemberWorkSyncReportToken(this.deps, {
|
||||||
teamName: agenda.teamName,
|
teamName: agenda.teamName,
|
||||||
memberName: agenda.memberName,
|
memberName: agenda.memberName,
|
||||||
state:
|
state:
|
||||||
|
|
@ -65,7 +89,7 @@ export class MemberWorkSyncReporter {
|
||||||
evaluatedAt: nowIso,
|
evaluatedAt: nowIso,
|
||||||
diagnostics: [...agenda.diagnostics, 'report_accepted'],
|
diagnostics: [...agenda.diagnostics, 'report_accepted'],
|
||||||
...(source.providerId ? { providerId: source.providerId } : {}),
|
...(source.providerId ? { providerId: source.providerId } : {}),
|
||||||
};
|
});
|
||||||
|
|
||||||
await this.deps.statusStore.write(status);
|
await this.deps.statusStore.write(status);
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,35 @@ export interface MemberWorkSyncHashPort {
|
||||||
sha256Hex(value: string): string;
|
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 {
|
export interface MemberWorkSyncLoggerPort {
|
||||||
debug(message: string, metadata?: Record<string, unknown>): void;
|
debug(message: string, metadata?: Record<string, unknown>): void;
|
||||||
warn(message: string, metadata?: Record<string, unknown>): void;
|
warn(message: string, metadata?: Record<string, unknown>): void;
|
||||||
|
|
@ -50,6 +79,7 @@ export interface MemberWorkSyncUseCaseDeps {
|
||||||
agendaSource: MemberWorkSyncAgendaSourcePort;
|
agendaSource: MemberWorkSyncAgendaSourcePort;
|
||||||
statusStore: MemberWorkSyncStatusStorePort;
|
statusStore: MemberWorkSyncStatusStorePort;
|
||||||
reportStore?: MemberWorkSyncReportStorePort;
|
reportStore?: MemberWorkSyncReportStorePort;
|
||||||
|
reportToken?: MemberWorkSyncReportTokenPort;
|
||||||
logger?: MemberWorkSyncLoggerPort;
|
logger?: MemberWorkSyncLoggerPort;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ export interface MemberWorkSyncReportValidation {
|
||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MemberWorkSyncReportTokenValidation =
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; reason: 'missing' | 'expired' | 'invalid' };
|
||||||
|
|
||||||
const DEFAULT_STILL_WORKING_LEASE_MS = 15 * 60 * 1000;
|
const DEFAULT_STILL_WORKING_LEASE_MS = 15 * 60 * 1000;
|
||||||
const DEFAULT_BLOCKED_LEASE_MS = 30 * 60 * 1000;
|
const DEFAULT_BLOCKED_LEASE_MS = 30 * 60 * 1000;
|
||||||
const MIN_LEASE_MS = 60_000;
|
const MIN_LEASE_MS = 60_000;
|
||||||
|
|
@ -47,6 +51,7 @@ export function validateMemberWorkSyncReport(input: {
|
||||||
agenda: MemberWorkSyncAgenda;
|
agenda: MemberWorkSyncAgenda;
|
||||||
nowIso: string;
|
nowIso: string;
|
||||||
activeMemberNames: string[];
|
activeMemberNames: string[];
|
||||||
|
tokenValidation: MemberWorkSyncReportTokenValidation;
|
||||||
}): MemberWorkSyncReportValidation {
|
}): MemberWorkSyncReportValidation {
|
||||||
const memberName = normalizeMemberName(input.request.memberName);
|
const memberName = normalizeMemberName(input.request.memberName);
|
||||||
const activeMemberNames = new Set(input.activeMemberNames.map(normalizeMemberName));
|
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.',
|
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));
|
const agendaTaskIds = new Set(input.agenda.items.map((item) => item.taskId));
|
||||||
for (const taskId of input.request.taskIds ?? []) {
|
for (const taskId of input.request.taskIds ?? []) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import type {
|
||||||
} from '../../contracts';
|
} from '../../contracts';
|
||||||
import { MemberWorkSyncDiagnosticsReader, MemberWorkSyncReporter } from '../../core/application';
|
import { MemberWorkSyncDiagnosticsReader, MemberWorkSyncReporter } from '../../core/application';
|
||||||
import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource';
|
import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource';
|
||||||
|
import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter';
|
||||||
import { JsonMemberWorkSyncStore } from '../infrastructure/JsonMemberWorkSyncStore';
|
import { JsonMemberWorkSyncStore } from '../infrastructure/JsonMemberWorkSyncStore';
|
||||||
import { MemberWorkSyncStorePaths } from '../infrastructure/MemberWorkSyncStorePaths';
|
import { MemberWorkSyncStorePaths } from '../infrastructure/MemberWorkSyncStorePaths';
|
||||||
import { NodeHashAdapter } from '../infrastructure/NodeHashAdapter';
|
import { NodeHashAdapter } from '../infrastructure/NodeHashAdapter';
|
||||||
|
|
@ -40,13 +41,16 @@ export function createMemberWorkSyncFeature(deps: {
|
||||||
hash,
|
hash,
|
||||||
clock,
|
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 = {
|
const useCaseDeps = {
|
||||||
clock,
|
clock,
|
||||||
hash,
|
hash,
|
||||||
agendaSource,
|
agendaSource,
|
||||||
statusStore: store,
|
statusStore: store,
|
||||||
reportStore: store,
|
reportStore: store,
|
||||||
|
reportToken,
|
||||||
logger: deps.logger,
|
logger: deps.logger,
|
||||||
};
|
};
|
||||||
const diagnosticsReader = new MemberWorkSyncDiagnosticsReader(useCaseDeps);
|
const diagnosticsReader = new MemberWorkSyncDiagnosticsReader(useCaseDeps);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,4 +14,8 @@ export class MemberWorkSyncStorePaths {
|
||||||
getPendingReportsPath(teamName: string): string {
|
getPendingReportsPath(teamName: string): string {
|
||||||
return join(this.getTeamDir(teamName), 'pending-reports.jsonl');
|
return join(this.getTeamDir(teamName), 'pending-reports.jsonl');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getReportTokenSecretPath(teamName: string): string {
|
||||||
|
return join(this.getTeamDir(teamName), 'report-token-secret.json');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -813,6 +813,9 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
|
||||||
memberName,
|
memberName,
|
||||||
state,
|
state,
|
||||||
agendaFingerprint,
|
agendaFingerprint,
|
||||||
|
...(typeof payload.reportToken === 'string'
|
||||||
|
? { reportToken: payload.reportToken }
|
||||||
|
: {}),
|
||||||
...(taskIds ? { taskIds } : {}),
|
...(taskIds ? { taskIds } : {}),
|
||||||
...(typeof payload.note === 'string' ? { note: payload.note } : {}),
|
...(typeof payload.note === 'string' ? { note: payload.note } : {}),
|
||||||
...(typeof payload.reportedAt === 'string' ? { reportedAt: payload.reportedAt } : {}),
|
...(typeof payload.reportedAt === 'string' ? { reportedAt: payload.reportedAt } : {}),
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
|
|
||||||
const nowIso = '2026-04-29T00:00:00.000Z';
|
const nowIso = '2026-04-29T00:00:00.000Z';
|
||||||
const hash = (value: string) => `h${value.length}`;
|
const hash = (value: string) => `h${value.length}`;
|
||||||
|
const validToken = { ok: true } as const;
|
||||||
|
|
||||||
function agendaWithWork() {
|
function agendaWithWork() {
|
||||||
return buildActionableWorkAgenda({
|
return buildActionableWorkAgenda({
|
||||||
|
|
@ -32,6 +33,7 @@ describe('validateMemberWorkSyncReport', () => {
|
||||||
agenda,
|
agenda,
|
||||||
nowIso,
|
nowIso,
|
||||||
activeMemberNames: ['bob'],
|
activeMemberNames: ['bob'],
|
||||||
|
tokenValidation: validToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
|
|
@ -50,6 +52,7 @@ describe('validateMemberWorkSyncReport', () => {
|
||||||
agenda,
|
agenda,
|
||||||
nowIso,
|
nowIso,
|
||||||
activeMemberNames: ['bob'],
|
activeMemberNames: ['bob'],
|
||||||
|
tokenValidation: validToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
|
|
@ -70,6 +73,7 @@ describe('validateMemberWorkSyncReport', () => {
|
||||||
agenda,
|
agenda,
|
||||||
nowIso,
|
nowIso,
|
||||||
activeMemberNames: ['bob'],
|
activeMemberNames: ['bob'],
|
||||||
|
tokenValidation: validToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toMatchObject({ ok: false, code: 'blocked_without_evidence' });
|
expect(result).toMatchObject({ ok: false, code: 'blocked_without_evidence' });
|
||||||
|
|
@ -87,6 +91,7 @@ describe('validateMemberWorkSyncReport', () => {
|
||||||
agenda,
|
agenda,
|
||||||
nowIso,
|
nowIso,
|
||||||
activeMemberNames: ['bob'],
|
activeMemberNames: ['bob'],
|
||||||
|
tokenValidation: validToken,
|
||||||
});
|
});
|
||||||
const foreign = validateMemberWorkSyncReport({
|
const foreign = validateMemberWorkSyncReport({
|
||||||
request: {
|
request: {
|
||||||
|
|
@ -99,6 +104,7 @@ describe('validateMemberWorkSyncReport', () => {
|
||||||
agenda,
|
agenda,
|
||||||
nowIso,
|
nowIso,
|
||||||
activeMemberNames: ['bob'],
|
activeMemberNames: ['bob'],
|
||||||
|
tokenValidation: validToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(stale.code).toBe('stale_fingerprint');
|
expect(stale.code).toBe('stale_fingerprint');
|
||||||
|
|
@ -117,6 +123,7 @@ describe('validateMemberWorkSyncReport', () => {
|
||||||
agenda,
|
agenda,
|
||||||
nowIso,
|
nowIso,
|
||||||
activeMemberNames: ['bob'],
|
activeMemberNames: ['bob'],
|
||||||
|
tokenValidation: validToken,
|
||||||
});
|
});
|
||||||
const inactive = validateMemberWorkSyncReport({
|
const inactive = validateMemberWorkSyncReport({
|
||||||
request: {
|
request: {
|
||||||
|
|
@ -128,9 +135,41 @@ describe('validateMemberWorkSyncReport', () => {
|
||||||
agenda,
|
agenda,
|
||||||
nowIso,
|
nowIso,
|
||||||
activeMemberNames: [],
|
activeMemberNames: [],
|
||||||
|
tokenValidation: validToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(reserved.code).toBe('reserved_or_invalid_member');
|
expect(reserved.code).toBe('reserved_or_invalid_member');
|
||||||
expect(inactive.code).toBe('member_inactive');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,16 @@ function createDeps(options?: {
|
||||||
},
|
},
|
||||||
statusStore: store,
|
statusStore: store,
|
||||||
reportStore: 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 };
|
return { clock, deps, source, store };
|
||||||
}
|
}
|
||||||
|
|
@ -116,6 +126,7 @@ describe('MemberWorkSync use cases', () => {
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
state: 'still_working',
|
state: 'still_working',
|
||||||
agendaFingerprint: current.agenda.fingerprint,
|
agendaFingerprint: current.agenda.fingerprint,
|
||||||
|
reportToken: current.reportToken,
|
||||||
taskIds: ['task-1'],
|
taskIds: ['task-1'],
|
||||||
leaseTtlMs: 120_000,
|
leaseTtlMs: 120_000,
|
||||||
source: 'test',
|
source: 'test',
|
||||||
|
|
@ -163,10 +174,31 @@ describe('MemberWorkSync use cases', () => {
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
state: 'caught_up',
|
state: 'caught_up',
|
||||||
agendaFingerprint: current.agenda.fingerprint,
|
agendaFingerprint: current.agenda.fingerprint,
|
||||||
|
reportToken: current.reportToken,
|
||||||
source: 'test',
|
source: 'test',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.accepted).toBe(true);
|
expect(result.accepted).toBe(true);
|
||||||
expect(result.status.state).toBe('caught_up');
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue