feat(iios): generic opaque metadata + manual ticket assign (glue prereqs)

Generic kernel primitives so app backends (e.g. a CRM support glue) can link
their domain to interactions WITHOUT the kernel learning any app vocabulary:

- thread: accept an opaque `metadata` bag on create (merged with membership),
  echo it in listThreads, and filter listThreads by `?metadata[key]=value`
  (equality on the opaque bag — the kernel never interprets the keys).
- support: accept opaque `metadata` on createTicket/escalate (thread bag
  inherited); add SupportService.assignTo — a policy-gated manual assignment
  of a ticket to a target actor (POST /v1/support/tickets/:id/assignee).
- SDK @insignia/iios-kernel-client 0.1.2: createThread(metadata),
  listThreads({metadata}) filter, createTicket(metadata), escalate(metadata),
  assignTicket; ThreadSummary/Ticket expose the opaque bag.

Generic-safety gate holds: no crm/customer/lead in kernel code (opaque values
only). Tests: +2 (metadata create/filter, ticket metadata + assignTo); 31 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 18:39:30 +05:30
parent 8c70b6d31f
commit b918a21083
10 changed files with 162 additions and 28 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@insignia/iios-kernel-client", "name": "@insignia/iios-kernel-client",
"version": "0.1.1", "version": "0.1.2",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.js", "module": "dist/index.js",
+18 -8
View File
@@ -77,15 +77,21 @@ export class RestClient {
return ((await r.json()) as { users: string[] }).users; return ((await r.json()) as { users: string[] }).users;
} }
/** Server-authoritative conversation list (works cross-device, shows unread + members). */ /**
async listThreads(): Promise<ThreadSummary[]> { * Server-authoritative conversation list (works cross-device, shows unread + members).
const r = await fetch(this.url('/v1/threads'), { headers: this.headers() }); * `filter.metadata` narrows to threads whose opaque attribute bag matches every key/value.
*/
async listThreads(filter?: { metadata?: Record<string, string> }): Promise<ThreadSummary[]> {
const qs = filter?.metadata
? '?' + Object.entries(filter.metadata).map(([k, v]) => `metadata[${encodeURIComponent(k)}]=${encodeURIComponent(v)}`).join('&')
: '';
const r = await fetch(this.url(`/v1/threads${qs}`), { headers: this.headers() });
if (!r.ok) throw new Error(`listThreads ${r.status}`); if (!r.ok) throw new Error(`listThreads ${r.status}`);
return (await r.json()) as ThreadSummary[]; return (await r.json()) as ThreadSummary[];
} }
/** Create a thread with generic app attributes (membership hint, creator role, subject/name). */ /** Create a thread with generic app attributes (membership/creator role/subject + an opaque metadata bag). */
async createThread(opts: { membership?: string; creatorRole?: string; subject?: string }): Promise<{ threadId: string }> { async createThread(opts: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }): Promise<{ threadId: string }> {
return this.post<{ threadId: string }>('/v1/threads', opts); return this.post<{ threadId: string }>('/v1/threads', opts);
} }
@@ -109,11 +115,15 @@ export class RestClient {
} }
// ─── support ────────────────────────────────────────────────── // ─── support ──────────────────────────────────────────────────
async createTicket(body: { subject: string; priority?: string; threadId?: string }): Promise<Ticket> { async createTicket(body: { subject: string; priority?: string; threadId?: string; metadata?: Record<string, unknown> }): Promise<Ticket> {
return this.post<Ticket>('/v1/support/tickets', body); return this.post<Ticket>('/v1/support/tickets', body);
} }
async escalate(threadId: string, subject?: string): Promise<Ticket> { async escalate(threadId: string, subject?: string, metadata?: Record<string, unknown>): Promise<Ticket> {
return this.post<Ticket>('/v1/support/escalate', { threadId, subject }); return this.post<Ticket>('/v1/support/escalate', { threadId, subject, metadata });
}
/** Manually assign a ticket to a specific user (generic assignment override). */
async assignTicket(id: string, userId: string): Promise<Ticket> {
return this.post<Ticket>(`/v1/support/tickets/${id}/assignee`, { userId });
} }
async listTickets(scope: 'mine' | 'assigned' = 'mine'): Promise<Ticket[]> { async listTickets(scope: 'mine' | 'assigned' = 'mine'): Promise<Ticket[]> {
const r = await fetch(this.url(`/v1/support/tickets?scope=${scope}`), { headers: this.headers() }); const r = await fetch(this.url(`/v1/support/tickets?scope=${scope}`), { headers: this.headers() });
+4
View File
@@ -69,6 +69,8 @@ export interface ThreadSummary {
threadId: string; threadId: string;
subject: string | null; subject: string | null;
membership?: string; // 'dm' | 'group' (opaque app attribute) membership?: string; // 'dm' | 'group' (opaque app attribute)
/** The thread's opaque, app-supplied attribute bag — echoed verbatim; the kernel never interprets it. */
metadata?: Record<string, unknown> | null;
participants: string[]; // member usernames participants: string[]; // member usernames
participantCount: number; participantCount: number;
unread: number; unread: number;
@@ -126,6 +128,8 @@ export interface Ticket {
assignedActorId?: string | null; assignedActorId?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
/** Opaque, app-supplied attribute bag on the ticket — echoed verbatim; the kernel never interprets it. */
metadata?: Record<string, unknown> | null;
threadLinks?: Array<{ threadId: string; relationKind: string }>; threadLinks?: Array<{ threadId: string; relationKind: string }>;
} }
@@ -50,6 +50,8 @@ export interface ThreadSummary {
threadId: string; threadId: string;
subject: string | null; subject: string | null;
membership?: string; membership?: string;
/** The thread's opaque, app-supplied attribute bag — echoed back verbatim; the kernel never interprets it. */
metadata?: Record<string, unknown> | null;
participants: string[]; participants: string[];
participantCount: number; participantCount: number;
unread: number; unread: number;
@@ -85,19 +87,21 @@ export class MessageService {
* for a group. Opening an existing thread is a GOVERNED join: only an existing member may * for a group. Opening an existing thread is a GOVERNED join: only an existing member may
* re-open it (policy `iios.thread.join`) — new members enter via addParticipant. * re-open it (policy `iios.thread.join`) — new members enter via addParticipant.
*/ */
async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string; subject?: string }): Promise<OpenThreadResult> { async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }): Promise<OpenThreadResult> {
if (!threadId) { if (!threadId) {
await decideOrThrow(this.ports, { action: 'iios.thread.create', scope: principal }); await decideOrThrow(this.ports, { action: 'iios.thread.create', scope: principal });
const scope = await this.actors.resolveScope(principal); const scope = await this.actors.resolveScope(principal);
const actor = await this.actors.resolveActor(scope.id, principal); const actor = await this.actors.resolveActor(scope.id, principal);
// `membership`/`creatorRole`/`subject` are generic, app-supplied thread attributes — the // `membership`/`creatorRole`/`subject`/`metadata` are generic, app-supplied thread attributes —
// kernel stores/echoes them but never branches on their chat meaning (that lives in policy + app). // the kernel stores/echoes them as an opaque bag but never branches on their meaning (that lives
// in policy + the app). `membership` is folded into the same bag for back-compat.
const merged = { ...(opts?.metadata ?? {}), ...(opts?.membership ? { membership: opts.membership } : {}) };
const thread = await this.prisma.iiosThread.create({ const thread = await this.prisma.iiosThread.create({
data: { data: {
scopeId: scope.id, scopeId: scope.id,
createdByActorId: actor.id, createdByActorId: actor.id,
subject: opts?.subject?.trim() || undefined, subject: opts?.subject?.trim() || undefined,
metadata: opts?.membership ? ({ membership: opts.membership } as Prisma.InputJsonValue) : undefined, metadata: Object.keys(merged).length > 0 ? (merged as Prisma.InputJsonValue) : undefined,
}, },
}); });
await this.actors.ensureParticipant(thread.id, actor.id, opts?.creatorRole ?? 'MEMBER'); await this.actors.ensureParticipant(thread.id, actor.id, opts?.creatorRole ?? 'MEMBER');
@@ -167,8 +171,12 @@ export class MessageService {
return { threadId, muted }; return { threadId, muted };
} }
/** Generic "my threads": every thread the caller participates in, with last message + unread. */ /**
async listThreads(principal: MessagePrincipal): Promise<ThreadSummary[]> { * Generic "my threads": every thread the caller participates in, with last message + unread.
* An optional `filter.metadata` narrows to threads whose opaque attribute bag matches ALL of the
* given key/values (an equality match on the JSON bag — the kernel does not interpret the keys).
*/
async listThreads(principal: MessagePrincipal, filter?: { metadata?: Record<string, string> }): Promise<ThreadSummary[]> {
const scope = await this.actors.findScope(principal); const scope = await this.actors.findScope(principal);
if (!scope) return []; if (!scope) return [];
const actor = await this.actors.resolveActor(scope.id, principal); const actor = await this.actors.resolveActor(scope.id, principal);
@@ -177,7 +185,7 @@ export class MessageService {
if (threadIds.length === 0) return []; if (threadIds.length === 0) return [];
const mutedBy = new Map(memberships.map((m) => [m.threadId, m.muted])); const mutedBy = new Map(memberships.map((m) => [m.threadId, m.muted]));
const [threads, unreads, allParts] = await Promise.all([ const [allThreads, unreads, allParts] = await Promise.all([
this.prisma.iiosThread.findMany({ where: { id: { in: threadIds } } }), this.prisma.iiosThread.findMany({ where: { id: { in: threadIds } } }),
this.prisma.iiosUnreadCounter.findMany({ where: { threadId: { in: threadIds }, actorId: actor.id } }), this.prisma.iiosUnreadCounter.findMany({ where: { threadId: { in: threadIds }, actorId: actor.id } }),
this.prisma.iiosThreadParticipant.findMany({ this.prisma.iiosThreadParticipant.findMany({
@@ -185,6 +193,14 @@ export class MessageService {
include: { actor: { include: { sourceHandle: true } } }, include: { actor: { include: { sourceHandle: true } } },
}), }),
]); ]);
// Opaque equality filter on the metadata bag (every requested key must match).
const metaFilter = filter?.metadata;
const threads = metaFilter
? allThreads.filter((t) => {
const bag = (t.metadata as Record<string, unknown> | null) ?? {};
return Object.entries(metaFilter).every(([k, v]) => bag[k] === v);
})
: allThreads;
const unreadBy = new Map(unreads.map((u) => [u.threadId, u.unreadCount])); const unreadBy = new Map(unreads.map((u) => [u.threadId, u.unreadCount]));
const membersBy = new Map<string, string[]>(); const membersBy = new Map<string, string[]>();
for (const p of allParts) { for (const p of allParts) {
@@ -204,6 +220,7 @@ export class MessageService {
threadId: t.id, threadId: t.id,
subject: t.subject, subject: t.subject,
membership: (t.metadata as { membership?: string } | null)?.membership, membership: (t.metadata as { membership?: string } | null)?.membership,
metadata: (t.metadata as Record<string, unknown> | null) ?? null,
participants: members, participants: members,
participantCount: members.length, participantCount: members.length,
unread: unreadBy.get(t.id) ?? 0, unread: unreadBy.get(t.id) ?? 0,
@@ -143,6 +143,23 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
expect((await s.listThreads(bob))[0]?.unread).toBe(1); expect((await s.listThreads(bob))[0]?.unread).toBe(1);
}); });
it('opaque metadata: stored on create, echoed in listThreads, and a metadata filter narrows the list', async () => {
const s = svc();
// Two threads with different opaque app attributes — the kernel never interprets these values.
await s.openThread(null, alice, { metadata: { source: 'crm-support', crmCustomerId: 'cust_1' } });
await s.openThread(null, alice, { metadata: { source: 'other' } });
const all = await s.listThreads(alice);
expect(all).toHaveLength(2);
const support = all.find((t) => (t.metadata as { source?: string } | null)?.source === 'crm-support');
expect(support?.metadata).toMatchObject({ source: 'crm-support', crmCustomerId: 'cust_1' });
// Equality filter on the opaque bag returns only the matching thread.
const filtered = await s.listThreads(alice, { metadata: { source: 'crm-support' } });
expect(filtered).toHaveLength(1);
expect(filtered[0]?.metadata).toMatchObject({ crmCustomerId: 'cust_1' });
});
it('a reply stores + returns parentInteractionId; a cross-thread parent is ignored', async () => { it('a reply stores + returns parentInteractionId; a cross-thread parent is ignored', async () => {
const s = gov(); const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'dm' }); const { threadId } = await s.openThread(null, alice, { membership: 'dm' });
@@ -5,6 +5,7 @@ import { AssignmentService } from './assignment.service';
import { SessionVerifier } from '../platform/session.verifier'; import { SessionVerifier } from '../platform/session.verifier';
import type { MessagePrincipal } from '../identity/actor.resolver'; import type { MessagePrincipal } from '../identity/actor.resolver';
import { import {
AssignTicketDto,
AvailabilityDto, AvailabilityDto,
CallbackDto, CallbackDto,
CreateQueueDto, CreateQueueDto,
@@ -32,7 +33,7 @@ export class SupportController {
@Post('escalate') @Post('escalate')
async escalate(@Body() body: EscalateDto, @Headers('authorization') auth?: string) { async escalate(@Body() body: EscalateDto, @Headers('authorization') auth?: string) {
return this.support.escalate(body.threadId, this.principal(auth), body.subject); return this.support.escalate(body.threadId, this.principal(auth), body.subject, body.metadata);
} }
@Get('tickets') @Get('tickets')
@@ -45,6 +46,12 @@ export class SupportController {
return this.support.transition(id, this.principal(auth), body.state as IiosTicketState, body.reason); return this.support.transition(id, this.principal(auth), body.state as IiosTicketState, body.reason);
} }
/** Manually assign a ticket to a specific actor (by userId) — generic assignment override. */
@Post('tickets/:id/assignee')
async assign(@Param('id') id: string, @Body() body: AssignTicketDto, @Headers('authorization') auth?: string) {
return this.support.assignTo(id, this.principal(auth), body.userId);
}
@Post('callbacks') @Post('callbacks')
async callback( async callback(
@Body() body: CallbackDto, @Body() body: CallbackDto,
@@ -1,4 +1,4 @@
import { IsIn, IsOptional, IsString } from 'class-validator'; import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
const PRIORITIES = ['P0', 'P1', 'P2', 'P3', 'P4'] as const; const PRIORITIES = ['P0', 'P1', 'P2', 'P3', 'P4'] as const;
const STATES = ['NEW', 'OPEN', 'PENDING_CUSTOMER', 'PENDING_INTERNAL', 'RESOLVED', 'CLOSED', 'CANCELLED'] as const; const STATES = ['NEW', 'OPEN', 'PENDING_CUSTOMER', 'PENDING_INTERNAL', 'RESOLVED', 'CLOSED', 'CANCELLED'] as const;
@@ -7,11 +7,18 @@ export class CreateTicketDto {
@IsString() subject!: string; @IsString() subject!: string;
@IsOptional() @IsIn(PRIORITIES) priority?: (typeof PRIORITIES)[number]; @IsOptional() @IsIn(PRIORITIES) priority?: (typeof PRIORITIES)[number];
@IsOptional() @IsString() threadId?: string; @IsOptional() @IsString() threadId?: string;
/** Opaque, app-supplied attribute bag — stored on the ticket, never interpreted by the kernel. */
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
} }
export class EscalateDto { export class EscalateDto {
@IsString() threadId!: string; @IsString() threadId!: string;
@IsOptional() @IsString() subject?: string; @IsOptional() @IsString() subject?: string;
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
}
export class AssignTicketDto {
@IsString() userId!: string;
} }
export class PatchTicketDto { export class PatchTicketDto {
@@ -35,7 +35,7 @@ export class SupportService {
async createTicket( async createTicket(
principal: MessagePrincipal, principal: MessagePrincipal,
input: { subject: string; priority?: IiosTicketPriority; threadId?: string; queueId?: string }, input: { subject: string; priority?: IiosTicketPriority; threadId?: string; queueId?: string; metadata?: Record<string, unknown> },
idempotencyKey?: string, idempotencyKey?: string,
) { ) {
await decideOrThrow(this.ports, { action: 'iios.support.ticket.create', scope: principal }); await decideOrThrow(this.ports, { action: 'iios.support.ticket.create', scope: principal });
@@ -63,6 +63,8 @@ export class SupportService {
subject: input.subject, subject: input.subject,
priority: input.priority ?? 'P3', priority: input.priority ?? 'P3',
traceId, traceId,
// Opaque, app-supplied attribute bag (e.g. a caller's external ref) — stored, never interpreted.
metadata: input.metadata ? (input.metadata as Prisma.InputJsonValue) : undefined,
}, },
}); });
await tx.iiosTicketStateHistory.create({ await tx.iiosTicketStateHistory.create({
@@ -99,11 +101,17 @@ export class SupportService {
return this.idempotency.run({ scopeId: scope.id, commandName: 'support.ticket.create', key: idempotencyKey, request: input }, body); return this.idempotency.run({ scopeId: scope.id, commandName: 'support.ticket.create', key: idempotencyKey, request: input }, body);
} }
/** Escalate a chat thread to support: create a ticket linked to that thread. */ /** Escalate a chat thread to support: create a ticket linked to that thread. `metadata` is opaque. */
async escalate(threadId: string, principal: MessagePrincipal, subject?: string) { async escalate(threadId: string, principal: MessagePrincipal, subject?: string, metadata?: Record<string, unknown>) {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } }); const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found'); if (!thread) throw new NotFoundException('thread not found');
return this.createTicket(principal, { subject: subject ?? thread.subject ?? 'Support request', threadId }); // Inherit the thread's opaque bag so the ticket carries the same app context, unless overridden.
const inherited = { ...((thread.metadata as Record<string, unknown> | null) ?? {}), ...(metadata ?? {}) };
return this.createTicket(principal, {
subject: subject ?? thread.subject ?? 'Support request',
threadId,
metadata: Object.keys(inherited).length > 0 ? inherited : undefined,
});
} }
async linkThread(ticketId: string, threadId: string, relationKind = 'PRIMARY'): Promise<void> { async linkThread(ticketId: string, threadId: string, relationKind = 'PRIMARY'): Promise<void> {
@@ -120,6 +128,53 @@ export class SupportService {
.catch(() => undefined); .catch(() => undefined);
} }
/**
* Manually assign a ticket to a specific actor (by userId) — a generic override of the
* event-driven auto-assignment. The target is resolved-or-created as an actor in the ticket's
* scope (so you can assign someone who hasn't logged in yet) and joined to the linked thread(s)
* so they can reply over the socket. Fail-closed via policy `iios.support.ticket.assign`.
*/
async assignTo(ticketId: string, principal: MessagePrincipal, targetUserId: string) {
const ticket = await this.prisma.iiosTicket.findUnique({ where: { id: ticketId }, include: { threadLinks: true } });
if (!ticket) throw new NotFoundException('ticket not found');
await decideOrThrow(this.ports, { action: 'iios.support.ticket.assign', ticketId, scopeId: ticket.scopeId, targetUserId });
const target = await this.actors.resolveActor(ticket.scopeId, {
userId: targetUserId,
appId: principal.appId,
orgId: principal.orgId,
tenantId: principal.tenantId,
displayName: targetUserId,
});
const toState: IiosTicketState = ticket.state === 'NEW' ? 'OPEN' : ticket.state;
const event: CloudEvent = {
specversion: '1.0',
id: `evt_assign_${ticketId}_${target.id}`,
type: IIOS_EVENTS.ticketStateChanged,
source: `iios/support/${ticket.scopeId}`,
subject: `ticket/${ticketId}`,
time: new Date().toISOString(),
datacontenttype: 'application/json',
insignia: { scopeSnapshotId: ticket.scopeId, correlationId: ticket.traceId ?? undefined, idempotencyKey: `assign:${ticketId}:${target.id}`, dataClass: 'internal' },
data: { ticketId, fromState: ticket.state, toState, assignedActorId: target.id },
};
await this.prisma.$transaction([
this.prisma.iiosTicket.update({ where: { id: ticketId }, data: { assignedActorId: target.id, state: toState } }),
this.prisma.iiosTicketStateHistory.create({ data: { ticketId, fromState: ticket.state, toState, actorId: target.id, reasonCode: 'assigned' } }),
this.prisma.iiosOutboxEvent.create({
data: {
aggregateType: 'ticket',
aggregateId: ticketId,
eventType: IIOS_EVENTS.ticketStateChanged,
cloudEvent: event as unknown as Prisma.InputJsonValue,
partitionKey: `${ticket.scopeId}:${ticketId}`,
},
}),
]);
for (const link of ticket.threadLinks) await this.actors.ensureParticipant(link.threadId, target.id);
return this.prisma.iiosTicket.findUnique({ where: { id: ticketId } });
}
async transition(ticketId: string, principal: MessagePrincipal, toState: IiosTicketState, reason?: string) { async transition(ticketId: string, principal: MessagePrincipal, toState: IiosTicketState, reason?: string) {
const ticket = await this.prisma.iiosTicket.findUnique({ where: { id: ticketId } }); const ticket = await this.prisma.iiosTicket.findUnique({ where: { id: ticketId } });
if (!ticket) throw new NotFoundException('ticket not found'); if (!ticket) throw new NotFoundException('ticket not found');
@@ -57,6 +57,19 @@ describe('SupportService (P4)', () => {
expect(links[0]?.threadId).toBe(threadId); expect(links[0]?.threadId).toBe(threadId);
}); });
it('stores an opaque metadata bag on the ticket; assignTo assigns to a target actor and opens it', async () => {
const t = await support().createTicket(cust, { subject: 'help', metadata: { crmCustomerId: 'cust_1', source: 'crm-support' } });
expect(t.metadata).toMatchObject({ crmCustomerId: 'cust_1', source: 'crm-support' });
expect(t.state).toBe('NEW');
const assigned = await support().assignTo(t.id, cust, 'agent_1');
expect(assigned?.state).toBe('OPEN');
const handle = await prisma.iiosSourceHandle.findFirstOrThrow({ where: { externalId: 'agent_1' } });
const actor = await prisma.iiosActorRef.findFirstOrThrow({ where: { sourceHandleId: handle.id } });
expect(assigned?.assignedActorId).toBe(actor.id);
expect(await prisma.iiosTicketStateHistory.count({ where: { ticketId: t.id, reasonCode: 'assigned' } })).toBe(1);
});
it('transitions NEW→OPEN→RESOLVED→CLOSED with history + rejects illegal moves', async () => { it('transitions NEW→OPEN→RESOLVED→CLOSED with history + rejects illegal moves', async () => {
const t = await support().createTicket(cust, { subject: 's' }); const t = await support().createTicket(cust, { subject: 's' });
await support().transition(t.id, cust, 'OPEN'); await support().transition(t.id, cust, 'OPEN');
@@ -23,10 +23,14 @@ export class ThreadsController {
private readonly session: SessionVerifier, private readonly session: SessionVerifier,
) {} ) {}
/** Generic "my threads" — every thread the caller participates in (last message + unread). */ /**
* Generic "my threads" — every thread the caller participates in (last message + unread).
* `?metadata[key]=value` narrows to threads whose opaque attribute bag matches (equality on each key).
*/
@Get() @Get()
async listThreads(@Headers('authorization') auth?: string) { async listThreads(@Headers('authorization') auth?: string, @Query('metadata') metadata?: Record<string, string>) {
return this.messages.listThreads(this.principal(auth)); const metaFilter = metadata && typeof metadata === 'object' ? metadata : undefined;
return this.messages.listThreads(this.principal(auth), metaFilter ? { metadata: metaFilter } : undefined);
} }
/** Generic "my annotated messages" (e.g. ?type=save for a personal bookmarks list). */ /** Generic "my annotated messages" (e.g. ?type=save for a personal bookmarks list). */
@@ -35,11 +39,11 @@ export class ThreadsController {
return this.messages.listMyAnnotated(this.principal(auth), type); return this.messages.listMyAnnotated(this.principal(auth), type);
} }
/** Create a thread; `membership`/`creatorRole`/`subject` are opaque, app-supplied attributes the kernel stores but never interprets. */ /** Create a thread; `membership`/`creatorRole`/`subject`/`metadata` are opaque, app-supplied attributes the kernel stores but never interprets. */
@Post() @Post()
@HttpCode(201) @HttpCode(201)
async createThread(@Body() body: { membership?: string; creatorRole?: string; subject?: string }, @Headers('authorization') auth?: string) { async createThread(@Body() body: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }, @Headers('authorization') auth?: string) {
return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole, subject: body?.subject }); return this.messages.openThread(null, this.principal(auth), { membership: body?.membership, creatorRole: body?.creatorRole, subject: body?.subject, metadata: body?.metadata });
} }
/** Governed membership: add a user (by userId) to a thread — policy enforces DM cap / roles. */ /** Governed membership: add a user (by userId) to a thread — policy enforces DM cap / roles. */