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]) {
|
async insertIfAbsent(input: Parameters<MemberWorkSyncInboxNudgePort['insertIfAbsent']>[0]) {
|
||||||
const existing = await this.inboxReader.getMessagesFor(input.teamName, input.memberName);
|
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 };
|
return { inserted: false, messageId: input.messageId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,6 +34,7 @@ export class TeamInboxMemberWorkSyncNudgeSink implements MemberWorkSyncInboxNudg
|
||||||
workSyncIntent: input.payload.workSyncIntent,
|
workSyncIntent: input.payload.workSyncIntent,
|
||||||
workSyncIntentKey: input.payload.workSyncIntentKey,
|
workSyncIntentKey: input.payload.workSyncIntentKey,
|
||||||
workSyncReviewRequestEventIds: input.payload.workSyncReviewRequestEventIds,
|
workSyncReviewRequestEventIds: input.payload.workSyncReviewRequestEventIds,
|
||||||
|
workSyncPayloadHash: input.payloadHash,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,8 @@ export class TeamInboxReader {
|
||||||
(id): id is string => typeof id === 'string' && id.length > 0
|
(id): id is string => typeof id === 'string' && id.length > 0
|
||||||
)
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
workSyncPayloadHash:
|
||||||
|
typeof row.workSyncPayloadHash === 'string' ? row.workSyncPayloadHash : undefined,
|
||||||
slashCommand:
|
slashCommand:
|
||||||
row.slashCommand &&
|
row.slashCommand &&
|
||||||
typeof row.slashCommand === 'object' &&
|
typeof row.slashCommand === 'object' &&
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ export class TeamInboxWriter {
|
||||||
...(request.workSyncReviewRequestEventIds?.length
|
...(request.workSyncReviewRequestEventIds?.length
|
||||||
? { workSyncReviewRequestEventIds: request.workSyncReviewRequestEventIds }
|
? { workSyncReviewRequestEventIds: request.workSyncReviewRequestEventIds }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(request.workSyncPayloadHash ? { workSyncPayloadHash: request.workSyncPayloadHash } : {}),
|
||||||
...(request.slashCommand && { slashCommand: request.slashCommand }),
|
...(request.slashCommand && { slashCommand: request.slashCommand }),
|
||||||
...(request.commandOutput && { commandOutput: request.commandOutput }),
|
...(request.commandOutput && { commandOutput: request.commandOutput }),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -670,6 +670,8 @@ export interface InboxMessage {
|
||||||
workSyncIntentKey?: string;
|
workSyncIntentKey?: string;
|
||||||
/** Concrete review_requested event IDs covered by this nudge. */
|
/** Concrete review_requested event IDs covered by this nudge. */
|
||||||
workSyncReviewRequestEventIds?: string[];
|
workSyncReviewRequestEventIds?: string[];
|
||||||
|
/** Durable hash for idempotent hidden member-work-sync automation rows. */
|
||||||
|
workSyncPayloadHash?: string;
|
||||||
/** Structured slash-command metadata for sent command rows. */
|
/** Structured slash-command metadata for sent command rows. */
|
||||||
slashCommand?: SlashCommandMeta;
|
slashCommand?: SlashCommandMeta;
|
||||||
/** Structured command-output metadata for session-derived result rows. */
|
/** Structured command-output metadata for session-derived result rows. */
|
||||||
|
|
@ -717,6 +719,7 @@ export interface SendMessageRequest {
|
||||||
workSyncIntent?: InboxMessage['workSyncIntent'];
|
workSyncIntent?: InboxMessage['workSyncIntent'];
|
||||||
workSyncIntentKey?: string;
|
workSyncIntentKey?: string;
|
||||||
workSyncReviewRequestEventIds?: string[];
|
workSyncReviewRequestEventIds?: string[];
|
||||||
|
workSyncPayloadHash?: string;
|
||||||
slashCommand?: SlashCommandMeta;
|
slashCommand?: SlashCommandMeta;
|
||||||
commandOutput?: CommandOutputMeta;
|
commandOutput?: CommandOutputMeta;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,9 @@ describe('TeamInboxMemberWorkSyncNudgeSink', () => {
|
||||||
it('returns inserted=false when the inbox already contains the stable messageId', async () => {
|
it('returns inserted=false when the inbox already contains the stable messageId', async () => {
|
||||||
const input = makeInput();
|
const input = makeInput();
|
||||||
const inboxReader = {
|
const inboxReader = {
|
||||||
getMessagesFor: vi.fn(async () => [{ messageId: input.messageId }]),
|
getMessagesFor: vi.fn(async () => [
|
||||||
|
{ messageId: input.messageId, workSyncPayloadHash: input.payloadHash },
|
||||||
|
]),
|
||||||
};
|
};
|
||||||
const inboxWriter = {
|
const inboxWriter = {
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
|
|
@ -47,6 +49,52 @@ describe('TeamInboxMemberWorkSyncNudgeSink', () => {
|
||||||
expect(inboxWriter.sendMessage).not.toHaveBeenCalled();
|
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 () => {
|
it('writes a system notification inbox message for a new nudge', async () => {
|
||||||
const input = makeInput();
|
const input = makeInput();
|
||||||
const inboxReader = {
|
const inboxReader = {
|
||||||
|
|
@ -77,6 +125,7 @@ describe('TeamInboxMemberWorkSyncNudgeSink', () => {
|
||||||
workSyncIntent: 'agenda_sync',
|
workSyncIntent: 'agenda_sync',
|
||||||
workSyncIntentKey: undefined,
|
workSyncIntentKey: undefined,
|
||||||
workSyncReviewRequestEventIds: 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 () => {
|
it('preserves agent error semantic kind from the team lead inbox', async () => {
|
||||||
hoisted.files.set(
|
hoisted.files.set(
|
||||||
'/mock/teams/my-team/inboxes/team-lead.json',
|
'/mock/teams/my-team/inboxes/team-lead.json',
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,25 @@ describe('TeamInboxWriter', () => {
|
||||||
expect(persisted[0].source).toBe('system_notification');
|
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 () => {
|
it('preserves provided message identity fields for dedup across live and persisted rows', async () => {
|
||||||
const result = await writer.sendMessage('my-team', {
|
const result = await writer.sendMessage('my-team', {
|
||||||
member: 'alice',
|
member: 'alice',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue