fix(provenance): address #143 re-review — shared resolver + decoupled badge

Architecture & design:
- Arch A: introduce resolveProvenance() as the single source of truth for
  deriving a write's actor/aiChatId from the SIGNED identity, and wire it into
  BOTH transport seams — the REST jwt.strategy and the collab
  authentication.extension. Previously the collab seam derived actor from the
  token claim alone and ignored user.isAgent, so a flagged service account's
  page-content edits over the websocket persisted as lastUpdatedSource='user',
  drifting from REST. The seams now share one resolver and can't diverge.
- Arch B: drop AiAgentBadge's page-history coupling. The generic ui/ badge no
  longer imports historyAtoms; it exposes an onActivate callback fired after the
  deep-link, and the history row passes onActivate to close its own modal.

Suggestions/warnings:
- S1: soften the jwt.strategy provenance comment (applies to every REST write).
- S2/suggestion-3: drop the redundant comment-list-item null-aiChatId test
  (covered by ai-agent-badge.test.tsx).
- S3: de-duplicate jwt.strategy.spec test #3 (the no-claim→'user' half
  duplicated test #2); keep only the signed actor='agent' claim assertion.
- W2: add keyboard-activation tests for the badge (Enter/Space, unrelated key).
- W3: flip the design doc status to "реализовано (#143)".

Tests:
- new auth-provenance.decorator.spec.ts unit-tests resolveProvenance +
  agentSourceFields.
- new collab-seam test: is_agent user with no claim → actor='agent'
  (Arch A regression guard).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-24 00:27:03 +03:00
parent 1d54f8ed1c
commit 7705d44fc6
11 changed files with 239 additions and 82 deletions

View File

@@ -208,4 +208,19 @@ describe('AuthenticationExtension.onAuthenticate', () => {
expect(ctx.actor).toBe('user');
expect(ctx.aiChatId).toBeNull();
});
it('is_agent user with NO claim → actor=agent (collab seam consults the signed identity)', async () => {
// Arch A regression guard: a flagged service account editing page CONTENT
// over the collab websocket carries a plain COLLAB token (no actor claim).
// Before the shared resolveProvenance() wiring this seam derived actor from
// the claim alone, so such edits persisted as lastUpdatedSource='user' —
// drifting from the REST seam. The seam must now stamp 'agent' from the
// is_agent flag, matching jwt.strategy.
userRepo.findById.mockResolvedValue(buildUser({ isAgent: true }));
const ctx = await ext.onAuthenticate(buildData() as any);
expect(ctx.actor).toBe('agent');
// No internal ai_chats row for an MCP/service-account collab edit → null.
expect(ctx.aiChatId).toBeNull();
});
});

View File

@@ -15,6 +15,7 @@ import { SpaceRole } from '../../common/helpers/types/permission';
import { isUserDisabled } from '../../common/helpers';
import { getPageId } from '../collaboration.util';
import { JwtCollabPayload, JwtType } from '../../core/auth/dto/jwt-payload';
import { resolveProvenance } from '../../common/decorators/auth-provenance.decorator';
@Injectable()
export class AuthenticationExtension implements Extension {
@@ -103,13 +104,17 @@ export class AuthenticationExtension implements Extension {
this.logger.debug(`Authenticated user ${user.id} on page ${pageId}`);
// Carry the signed agent-edit provenance claim into the hocuspocus
// connection context (§6.6 / §15 C2). The human collab path omits these
// claims, so it resolves to actor='user' / aiChatId=null.
// Carry the agent-edit provenance into the hocuspocus connection context
// (§6.6 / §15 C2), derived via the SAME resolver as the REST seam so the two
// can't drift. An is_agent service account (e.g. the MCP bot) is attributed
// 'agent' here too, so its page-content edits over collab persist as
// lastUpdatedSource='agent' (#143 review Arch A) — not just its REST writes.
// The human collab path carries no claim and is not flagged → actor='user'.
const provenance = resolveProvenance(user, jwtPayload);
return {
user,
actor: jwtPayload.actor ?? 'user',
aiChatId: jwtPayload.aiChatId ?? null,
actor: provenance.actor,
aiChatId: provenance.aiChatId,
};
}
}