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.
|
||||
- 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.`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() } : {}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ?? []) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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,
|
||||
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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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