feat(api+web): unread counts per topic + PATCH /read mark-as-read
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

PATCH /v1/topics/:name/read upserts topic_member.last_read_at for the
api key's issuing member. The chat panel calls it on mount and on
every inbound SSE message (5s debounce so we don't hammer it).

GET /v1/topics now returns unread per topic — counts messages newer
than last_read_at and not authored by the viewer. Mesh detail page
shows a clay-rounded badge next to each topic name with the count
(99+ ceiling).

AuthedApiKey gains issuedByMemberId so endpoints can attribute
side-effects to the minting member. Required because external api
keys aren't tied to a specific peer member; only dashboard- and
CLI-minted keys carry one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 19:06:01 +01:00
parent 7e71a61db4
commit a80eb6fcca
4 changed files with 175 additions and 7 deletions

View File

@@ -22,6 +22,13 @@ export type ApiKeyCapability = "send" | "read" | "state_write" | "admin";
export interface AuthedApiKey {
id: string;
meshId: string;
/**
* The mesh member that minted this key. Dashboard keys carry the
* owner's member id; CLI-minted keys carry the issuing peer. Endpoints
* that attribute a side-effect to a member (e.g. PATCH /read,
* presence ping) read this field instead of the api key id.
*/
issuedByMemberId: string | null;
capabilities: ApiKeyCapability[];
topicScopes: string[] | null;
}
@@ -37,6 +44,7 @@ async function verifyBearer(secret: string): Promise<AuthedApiKey | null> {
secretHash: meshApiKey.secretHash,
capabilities: meshApiKey.capabilities,
topicScopes: meshApiKey.topicScopes,
issuedByMemberId: meshApiKey.issuedByMemberId,
revokedAt: meshApiKey.revokedAt,
expiresAt: meshApiKey.expiresAt,
})
@@ -58,6 +66,7 @@ async function verifyBearer(secret: string): Promise<AuthedApiKey | null> {
return {
id: c.id,
meshId: c.meshId,
issuedByMemberId: c.issuedByMemberId ?? null,
capabilities: (c.capabilities ?? []) as ApiKeyCapability[],
topicScopes: c.topicScopes ?? null,
};