feat(iios): message senderName + dev user directory

- Messages now carry senderName (resolved actorId → sourceHandle.externalId) so
  clients render usernames instead of raw actor cuids.
- GET /v1/dev/users exposes the dev directory (DEV_USERS keys) so a host app can
  validate add-by-username; real apps validate against their user store / MDM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 17:11:20 +05:30
parent 31f7682a46
commit 1af10d0f4a
2 changed files with 31 additions and 7 deletions
@@ -13,6 +13,7 @@ export interface MessageDto {
id: string;
threadId: string;
senderActorId: string;
senderName: string;
content: string;
contentRef?: string;
parentInteractionId?: string;
@@ -198,7 +199,7 @@ export class MessageService {
// Idempotency: a repeat key returns the existing message (no re-increment).
const existing = await this.prisma.iiosInteraction.findUnique({
where: { scopeId_idempotencyKey: { scopeId: thread.scopeId, idempotencyKey } },
include: { parts: { orderBy: { partIndex: 'asc' } } },
include: { parts: { orderBy: { partIndex: 'asc' } }, actor: { include: { sourceHandle: true } } },
});
if (existing) return this.toDto(existing, threadId);
@@ -260,7 +261,7 @@ export class MessageService {
return tx.iiosInteraction.findUniqueOrThrow({
where: { id: created.id },
include: { parts: { orderBy: { partIndex: 'asc' } } },
include: { parts: { orderBy: { partIndex: 'asc' } }, actor: { include: { sourceHandle: true } } },
});
});
@@ -269,7 +270,7 @@ export class MessageService {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
const winner = await this.prisma.iiosInteraction.findUniqueOrThrow({
where: { scopeId_idempotencyKey: { scopeId: thread.scopeId, idempotencyKey } },
include: { parts: { orderBy: { partIndex: 'asc' } } },
include: { parts: { orderBy: { partIndex: 'asc' } }, actor: { include: { sourceHandle: true } } },
});
return this.toDto(winner, threadId);
}
@@ -341,7 +342,7 @@ export class MessageService {
async getMessageById(id: string, principal?: MessagePrincipal): Promise<MessageDto | null> {
const i = await this.prisma.iiosInteraction.findUnique({
where: { id },
include: { parts: { orderBy: { partIndex: 'asc' } } },
include: { parts: { orderBy: { partIndex: 'asc' } }, actor: { include: { sourceHandle: true } } },
});
if (!i || !i.threadId) return null;
if (principal) await this.actors.assertOwns(principal, i.scopeId); // tenant fence (KG-02) when called on behalf of a caller
@@ -352,7 +353,7 @@ export class MessageService {
const interactions = await this.prisma.iiosInteraction.findMany({
where: { threadId },
orderBy: { occurredAt: 'asc' },
include: { parts: { orderBy: { partIndex: 'asc' } } },
include: { parts: { orderBy: { partIndex: 'asc' } }, actor: { include: { sourceHandle: true } } },
});
return interactions.map((i) => this.toDto(i, threadId));
}
@@ -360,7 +361,15 @@ export class MessageService {
// ─── helpers ────────────────────────────────────────────────────
private toDto(
interaction: { id: string; actorId: string | null; parentInteractionId?: string | null; traceId: string | null; occurredAt: Date; parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null }> },
interaction: {
id: string;
actorId: string | null;
actor?: { displayName: string | null; sourceHandle: { externalId: string } | null } | null;
parentInteractionId?: string | null;
traceId: string | null;
occurredAt: Date;
parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null }>;
},
threadId: string,
): MessageDto {
const text = interaction.parts.find((p) => p.kind === 'TEXT');
@@ -369,6 +378,7 @@ export class MessageService {
id: interaction.id,
threadId,
senderActorId: interaction.actorId ?? '',
senderName: interaction.actor?.sourceHandle?.externalId ?? interaction.actor?.displayName ?? 'unknown',
content: text?.bodyText ?? '',
contentRef: file?.contentRef ?? undefined,
parentInteractionId: interaction.parentInteractionId ?? undefined,