fix(member-work-sync): validate inbox nudge payload hash
This commit is contained in:
parent
3d207451cb
commit
75a3938f84
7 changed files with 110 additions and 2 deletions
|
|
@ -11,7 +11,11 @@ export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudg
|
|||
|
||||
async insertIfAbsent(input: Parameters<MemberWorkSyncInboxNudgePort['insertIfAbsent']>[0]) {
|
||||
const existing = await this.inboxReader.getMessagesFor(input.teamName, input.memberName);
|
||||
if (existing.some((message) => message.messageId === input.messageId)) {
|
||||
const existingMessage = existing.find((message) => message.messageId === input.messageId);
|
||||
if (existingMessage) {
|
||||
if (existingMessage.workSyncPayloadHash !== input.payloadHash) {
|
||||
return { inserted: false, messageId: input.messageId, conflict: true };
|
||||
}
|
||||
return { inserted: false, messageId: input.messageId };
|
||||
}
|
||||
|
||||
|
|
@ -30,6 +34,7 @@ export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudg
|
|||
workSyncIntent: input.payload.workSyncIntent,
|
||||
workSyncIntentKey: input.payload.workSyncIntentKey,
|
||||
workSyncReviewRequestEventIds: input.payload.workSyncReviewRequestEventIds,
|
||||
workSyncPayloadHash: input.payloadHash,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -159,6 +159,8 @@ export class TeamInboxReader {
|
|||
(id): id is string => typeof id === 'string' && id.length > 0
|
||||
)
|
||||
: undefined,
|
||||
workSyncPayloadHash:
|
||||
typeof row.workSyncPayloadHash === 'string' ? row.workSyncPayloadHash : undefined,
|
||||
slashCommand:
|
||||
row.slashCommand &&
|
||||
typeof row.slashCommand === 'object' &&
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ export class TeamInboxWriter {
|
|||
...(request.workSyncReviewRequestEventIds?.length
|
||||
? { workSyncReviewRequestEventIds: request.workSyncReviewRequestEventIds }
|
||||
: {}),
|
||||
...(request.workSyncPayloadHash ? { workSyncPayloadHash: request.workSyncPayloadHash } : {}),
|
||||
...(request.slashCommand && { slashCommand: request.slashCommand }),
|
||||
...(request.commandOutput && { commandOutput: request.commandOutput }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -670,6 +670,8 @@ export interface InboxMessage {
|
|||
workSyncIntentKey?: string;
|
||||
/** Concrete review_requested event IDs covered by this nudge. */
|
||||
workSyncReviewRequestEventIds?: string[];
|
||||
/** Durable hash for idempotent hidden member-work-sync automation rows. */
|
||||
workSyncPayloadHash?: string;
|
||||
/** Structured slash-command metadata for sent command rows. */
|
||||
slashCommand?: SlashCommandMeta;
|
||||
/** Structured command-output metadata for session-derived result rows. */
|
||||
|
|
@ -717,6 +719,7 @@ export interface SendMessageRequest {
|
|||
workSyncIntent?: InboxMessage['workSyncIntent'];
|
||||
workSyncIntentKey?: string;
|
||||
workSyncReviewRequestEventIds?: string[];
|
||||
workSyncPayloadHash?: string;
|
||||
slashCommand?: SlashCommandMeta;
|
||||
commandOutput?: CommandOutputMeta;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ describe('TeamInboxMemberWorkSyncNudgeSink', () => {
|
|||
it('returns inserted=false when the inbox already contains the stable messageId', async () => {
|
||||
const input = makeInput();
|
||||
const inboxReader = {
|
||||
getMessagesFor: vi.fn(async () => [{ messageId: input.messageId }]),
|
||||
getMessagesFor: vi.fn(async () => [
|
||||
{ messageId: input.messageId, workSyncPayloadHash: input.payloadHash },
|
||||
]),
|
||||
};
|
||||
const inboxWriter = {
|
||||
sendMessage: vi.fn(),
|
||||
|
|
@ -47,6 +49,52 @@ describe('TeamInboxMemberWorkSyncNudgeSink', () => {
|
|||
expect(inboxWriter.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed when the existing stable messageId has a different payload hash', async () => {
|
||||
const input = makeInput();
|
||||
const inboxReader = {
|
||||
getMessagesFor: vi.fn(async () => [
|
||||
{ messageId: input.messageId, workSyncPayloadHash: 'different-payload-hash' },
|
||||
]),
|
||||
};
|
||||
const inboxWriter = {
|
||||
sendMessage: vi.fn(),
|
||||
};
|
||||
const sink = new TeamInboxMemberWorkSyncNudgeSink(inboxReader as never, inboxWriter as never);
|
||||
|
||||
await expect(sink.insertIfAbsent(input)).resolves.toEqual({
|
||||
inserted: false,
|
||||
messageId: input.messageId,
|
||||
conflict: true,
|
||||
});
|
||||
|
||||
expect(inboxWriter.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats legacy work-sync rows without payload hash as conflicts', async () => {
|
||||
const input = makeInput();
|
||||
const inboxReader = {
|
||||
getMessagesFor: vi.fn(async () => [
|
||||
{
|
||||
messageId: input.messageId,
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
workSyncIntent: input.payload.workSyncIntent,
|
||||
},
|
||||
]),
|
||||
};
|
||||
const inboxWriter = {
|
||||
sendMessage: vi.fn(),
|
||||
};
|
||||
const sink = new TeamInboxMemberWorkSyncNudgeSink(inboxReader as never, inboxWriter as never);
|
||||
|
||||
await expect(sink.insertIfAbsent(input)).resolves.toEqual({
|
||||
inserted: false,
|
||||
messageId: input.messageId,
|
||||
conflict: true,
|
||||
});
|
||||
|
||||
expect(inboxWriter.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes a system notification inbox message for a new nudge', async () => {
|
||||
const input = makeInput();
|
||||
const inboxReader = {
|
||||
|
|
@ -77,6 +125,7 @@ describe('TeamInboxMemberWorkSyncNudgeSink', () => {
|
|||
workSyncIntent: 'agenda_sync',
|
||||
workSyncIntentKey: undefined,
|
||||
workSyncReviewRequestEventIds: undefined,
|
||||
workSyncPayloadHash: input.payloadHash,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -177,6 +177,35 @@ describe('TeamInboxReader', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('preserves member-work-sync payload hash without changing visible message fields', async () => {
|
||||
hoisted.files.set(
|
||||
'/mock/teams/my-team/inboxes/alice.json',
|
||||
JSON.stringify([
|
||||
{
|
||||
from: 'system',
|
||||
to: 'alice',
|
||||
text: 'Please reconcile current work.',
|
||||
timestamp: '2026-01-01T02:30:00.000Z',
|
||||
read: false,
|
||||
messageId: 'member-work-sync:my-team:alice:agenda',
|
||||
source: 'system_notification',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
workSyncIntent: 'agenda_sync',
|
||||
workSyncPayloadHash: 'sha256:work-sync',
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
const messages = await reader.getMessagesFor('my-team', 'alice');
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toMatchObject({
|
||||
messageId: 'member-work-sync:my-team:alice:agenda',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
workSyncIntent: 'agenda_sync',
|
||||
workSyncPayloadHash: 'sha256:work-sync',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves agent error semantic kind from the team lead inbox', async () => {
|
||||
hoisted.files.set(
|
||||
'/mock/teams/my-team/inboxes/team-lead.json',
|
||||
|
|
|
|||
|
|
@ -157,6 +157,25 @@ describe('TeamInboxWriter', () => {
|
|||
expect(persisted[0].source).toBe('system_notification');
|
||||
});
|
||||
|
||||
it('persists member-work-sync payload hash when provided', async () => {
|
||||
await writer.sendMessage('my-team', {
|
||||
member: 'alice',
|
||||
text: 'sync your work state',
|
||||
source: 'system_notification',
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
workSyncIntent: 'agenda_sync',
|
||||
workSyncPayloadHash: 'sha256:work-sync',
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(hoisted.files.get(inboxPath) ?? '[]') as Record<string, unknown>[];
|
||||
expect(persisted).toHaveLength(1);
|
||||
expect(persisted[0]).toMatchObject({
|
||||
messageKind: 'member_work_sync_nudge',
|
||||
workSyncIntent: 'agenda_sync',
|
||||
workSyncPayloadHash: 'sha256:work-sync',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves provided message identity fields for dedup across live and persisted rows', async () => {
|
||||
const result = await writer.sendMessage('my-team', {
|
||||
member: 'alice',
|
||||
|
|
|
|||
Loading…
Reference in a new issue