Compare commits

...

1 Commits

Author SHA1 Message Date
Jason Woltje
5a11e3c121 feat(mosaic-as): agent registration endpoint + scoped/revocable tokens (US-007)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
POST /bridge/v1/agents mints/ensures @agent-<alias>-<host> and returns a
scoped, revocable per-agent bridge token. Adds POST /bridge/v1/agents/revoke
(manual revoke from day one) and GET /bridge/v1/agents (reconciliation source
that never advertises revoked/phantom agents).

Persistence: per-agent token sha256 hashes stored in Matrix account_data on the
AS sender user (org.uscllc.mosaic_as.agents) — no new infra, survives restart.
Tokens are magt_-prefixed high-entropy random; plaintext is never persisted and
returned exactly once. Per-agent tokens are scoped: usable only to act as their
own agent on /bridge/v1/messages|typing; host bridgeTokens stay unscoped.
Registration/revoke/list are host-token-only.

Independent opus security review: PASS (no critical/high). Remediated the one
MEDIUM (agent-slug collision: distinct alias/host pairs joining to the same
Matrix id now rejected instead of silently overwriting) + regression test.

Closes #540

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 19:55:49 -05:00
7 changed files with 601 additions and 4 deletions

View File

@@ -3,6 +3,8 @@ import { describe, expect, it, vi } from 'vitest';
import { AppserviceDaemon } from '../server.js';
import type { DaemonConfig, DaemonRequest } from '../server.js';
const AGENTS_TYPE = 'org.uscllc.mosaic_as.agents';
const cfg: DaemonConfig = {
homeserverUrl: 'https://hs.example',
domain: 'hs.example',
@@ -228,6 +230,149 @@ describe('AppserviceDaemon routing', () => {
expect(bad.status).toBe(400);
});
// A daemon whose fetch mock backs account_data with a mutable in-test object,
// so register/verify/revoke round-trip through the (faked) homeserver.
const makeAgentDaemon = () => {
const accountData: { value: Record<string, unknown> | null } = { value: null };
const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => {
const url = new URL(String(input));
const path = url.pathname;
if (path.includes(`/account_data/${AGENTS_TYPE}`)) {
if (init?.method === 'PUT') {
accountData.value = JSON.parse(String(init.body)) as Record<string, unknown>;
return jsonResponse(200, {});
}
if (accountData.value === null) {
return jsonResponse(404, { errcode: 'M_NOT_FOUND', error: 'not found' });
}
return jsonResponse(200, accountData.value);
}
if (path.endsWith('/register')) return jsonResponse(200, { user_id: 'whatever' });
if (path.includes('/send/m.room.message/')) return jsonResponse(200, { event_id: '$sent' });
return jsonResponse(200, {});
});
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
return { daemon, fetchMock };
};
const registerAgent = async (
daemon: AppserviceDaemon,
body: Record<string, unknown> = { alias: 'pi0', host: 'web1' },
) =>
daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/agents',
authorizationHeader: 'Bearer bridge-secret',
body,
}),
);
it('host token registers an agent and returns agent_user_id + bridge_token', async () => {
const { daemon, fetchMock } = makeAgentDaemon();
const res = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
expect(res.status).toBe(200);
expect(res.body.agent_user_id).toBe('@agent-pi0-web1:hs.example');
expect(String(res.body.bridge_token).startsWith('magt_')).toBe(true);
const registerCall = fetchMock.mock.calls
.map((c) => new URL(String(c[0])))
.find((u) => u.pathname.endsWith('/register'));
expect(registerCall).toBeDefined();
});
it('register requires a HOST token (agent token and no token are 403)', async () => {
const { daemon } = makeAgentDaemon();
const minted = await registerAgent(daemon);
const agentToken = String(minted.body.bridge_token);
const asAgent = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/agents',
authorizationHeader: `Bearer ${agentToken}`,
body: { alias: 'pi1', host: 'web2' },
}),
);
expect(asAgent.status).toBe(403);
const noAuth = await daemon.handle(
request({ method: 'POST', path: '/bridge/v1/agents', body: { alias: 'pi1', host: 'web2' } }),
);
expect(noAuth.status).toBe(403);
});
it('agent-scoped token may send as itself but not as another agent', async () => {
const { daemon } = makeAgentDaemon();
const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
const agentToken = String(minted.body.bridge_token);
const self = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/messages',
authorizationHeader: `Bearer ${agentToken}`,
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' },
}),
);
expect(self.status).toBe(200);
const other = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/messages',
authorizationHeader: `Bearer ${agentToken}`,
body: { room_id: '!r:hs.example', agent: 'pi9-web9', body: 'hi' },
}),
);
expect(other.status).toBe(403);
expect(other.body.error).toBe('token not scoped to this agent');
});
it('revoked agent token is rejected on messages', async () => {
const { daemon } = makeAgentDaemon();
const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
const agentToken = String(minted.body.bridge_token);
const revoke = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/agents/revoke',
authorizationHeader: 'Bearer bridge-secret',
body: { agent_user_id: '@agent-pi0-web1:hs.example' },
}),
);
expect(revoke.status).toBe(200);
expect(revoke.body.revoked).toBe(1);
const afterRevoke = await daemon.handle(
request({
method: 'POST',
path: '/bridge/v1/messages',
authorizationHeader: `Bearer ${agentToken}`,
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' },
}),
);
expect(afterRevoke.status).toBe(403);
});
it('GET /bridge/v1/agents lists registered agents (host only)', async () => {
const { daemon } = makeAgentDaemon();
await registerAgent(daemon, { alias: 'pi0', host: 'web1', display_name: 'Pi Zero' });
const res = await daemon.handle(
request({
method: 'GET',
path: '/bridge/v1/agents',
authorizationHeader: 'Bearer bridge-secret',
}),
);
expect(res.status).toBe(200);
const agents = res.body.agents as Array<Record<string, unknown>>;
expect(agents).toHaveLength(1);
expect(agents[0]?.agent_user_id).toBe('@agent-pi0-web1:hs.example');
expect(agents[0]?.display_name).toBe('Pi Zero');
});
it('empty bridge token list denies everything', async () => {
const daemon = new AppserviceDaemon({ ...cfg, bridgeTokens: [] }, undefined, () => {});
const res = await daemon.handle(

View File

@@ -1,11 +1,14 @@
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import {
AgentTokenStore,
AppserviceIntent,
TransactionHandler,
validateBridgeMessage,
validateBridgeTyping,
validateProvisionRoom,
validateRegisterAgent,
validateRevokeAgent,
} from '@mosaicstack/appservice';
import type { AppserviceConfig, MatrixEvent } from '@mosaicstack/appservice';
@@ -37,6 +40,13 @@ const safeEqual = (a: string, b: string): boolean => timingSafeEqual(digest(a),
const TXN_PATH = /^\/_matrix\/app\/v1\/transactions\/([^/]+)$/;
/**
* Resolved identity for an authenticated /bridge/v1/* caller. Host principals
* (the agent-comms host daemons) are unrestricted; agent principals are scoped
* to a single virtual user and may only act as themselves.
*/
export type BridgePrincipal = { kind: 'host' } | { kind: 'agent'; agentUserId: string } | null;
/**
* HTTP-framework-agnostic request router for the mosaic-as daemon: the
* Application Service transactions endpoint (Synapse-facing) plus the
@@ -46,6 +56,7 @@ const TXN_PATH = /^\/_matrix\/app\/v1\/transactions\/([^/]+)$/;
export class AppserviceDaemon {
readonly intent: AppserviceIntent;
private readonly transactions: TransactionHandler;
private readonly agents: AgentTokenStore;
constructor(
private readonly cfg: DaemonConfig,
@@ -53,6 +64,7 @@ export class AppserviceDaemon {
private readonly log: (line: string) => void = (line) => console.log(line),
) {
this.intent = new AppserviceIntent(cfg, fetchImpl);
this.agents = new AgentTokenStore(this.intent);
this.transactions = new TransactionHandler({
hsToken: cfg.hsToken,
onEvent: (event) => this.onEvent(event),
@@ -69,10 +81,20 @@ export class AppserviceDaemon {
}
}
private bridgeAuthorized(authorizationHeader: string | undefined): boolean {
if (!authorizationHeader?.startsWith('Bearer ')) return false;
/** Resolve the calling principal, or null when unauthorized. Fail-closed:
* host tokens win (timing-safe compare); otherwise a magt_* bearer is looked
* up in the agent token store; anything else is rejected. */
private async bridgeAuthorized(
authorizationHeader: string | undefined,
): Promise<BridgePrincipal> {
if (!authorizationHeader?.startsWith('Bearer ')) return null;
const presented = authorizationHeader.slice('Bearer '.length);
return this.cfg.bridgeTokens.some((token) => safeEqual(presented, token));
if (this.cfg.bridgeTokens.some((token) => safeEqual(presented, token))) {
return { kind: 'host' };
}
const agentUserId = await this.agents.verifyToken(presented);
if (agentUserId) return { kind: 'agent', agentUserId };
return null;
}
async handle(req: DaemonRequest): Promise<DaemonResponse> {
@@ -89,12 +111,60 @@ export class AppserviceDaemon {
}
if (req.path.startsWith('/bridge/v1/')) {
if (!this.bridgeAuthorized(req.authorizationHeader)) {
const principal = await this.bridgeAuthorized(req.authorizationHeader);
if (!principal) {
return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'bad bridge token' } };
}
try {
if (req.method === 'POST' && req.path === '/bridge/v1/agents') {
if (principal.kind !== 'host') {
return {
status: 403,
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot register agents' },
};
}
validateRegisterAgent(req.body);
const { agentUserId, token } = await this.agents.register({
alias: req.body.alias,
host: req.body.host,
displayName: req.body.display_name,
});
this.log(`registered agent ${agentUserId}`);
return { status: 200, body: { agent_user_id: agentUserId, bridge_token: token } };
}
if (req.method === 'POST' && req.path === '/bridge/v1/agents/revoke') {
if (principal.kind !== 'host') {
return {
status: 403,
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot revoke agents' },
};
}
validateRevokeAgent(req.body);
const revoked = await this.agents.revoke(req.body.agent_user_id);
this.log(`revoked ${revoked} token(s) for ${req.body.agent_user_id}`);
return { status: 200, body: { revoked } };
}
if (req.method === 'GET' && req.path === '/bridge/v1/agents') {
if (principal.kind !== 'host') {
return {
status: 403,
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot list agents' },
};
}
const agents = await this.agents.list();
return { status: 200, body: { agents } };
}
if (req.method === 'POST' && req.path === '/bridge/v1/messages') {
validateBridgeMessage(req.body);
if (
principal.kind === 'agent' &&
this.intent.agentUserId(req.body.agent) !== principal.agentUserId
) {
return {
status: 403,
body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' },
};
}
const eventId = await this.intent.sendAsAgent({
roomId: req.body.room_id,
agent: req.body.agent,
@@ -107,6 +177,15 @@ export class AppserviceDaemon {
}
if (req.method === 'POST' && req.path === '/bridge/v1/typing') {
validateBridgeTyping(req.body);
if (
principal.kind === 'agent' &&
this.intent.agentUserId(req.body.agent) !== principal.agentUserId
) {
return {
status: 403,
body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' },
};
}
await this.intent.setTyping(req.body.room_id, req.body.agent, req.body.typing);
return { status: 200, body: {} };
}

View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest';
import { AGENTS_ACCOUNT_DATA_TYPE, AgentTokenStore } from '../agent-store.js';
import type { AppserviceIntent } from '../intent.js';
/** Fake intent: in-memory account_data, no-op user provisioning. Only the
* surface AgentTokenStore touches is implemented. */
const makeFakeIntent = () => {
const store: Record<string, Record<string, unknown>> = {};
const fake = {
domain: 'hs.example',
getSenderAccountData: async (type: string): Promise<Record<string, unknown> | null> =>
store[type] ?? null,
setSenderAccountData: async (type: string, content: Record<string, unknown>): Promise<void> => {
store[type] = structuredClone(content);
},
ensureRegistered: async (agent: string): Promise<string> => `@agent-${agent}:hs.example`,
setDisplayName: async (): Promise<void> => {},
};
return { intent: fake as unknown as AppserviceIntent, store };
};
describe('AgentTokenStore', () => {
it('mints a magt_ token and stores only its sha256 (never plaintext)', async () => {
const { intent, store } = makeFakeIntent();
const s = new AgentTokenStore(intent);
const { agentUserId, token } = await s.register({ alias: 'pi0', host: 'web1' });
expect(agentUserId).toBe('@agent-pi0-web1:hs.example');
expect(token.startsWith('magt_')).toBe(true);
const raw = JSON.stringify(store[AGENTS_ACCOUNT_DATA_TYPE]);
expect(raw).not.toContain(token);
// The stored hash is sha256hex(token), 64 hex chars.
const { createHash } = await import('node:crypto');
const hash = createHash('sha256').update(token).digest('hex');
expect(raw).toContain(hash);
});
it('verifyToken returns the agentUserId for a fresh token, null otherwise', async () => {
const { intent } = makeFakeIntent();
const s = new AgentTokenStore(intent);
const { agentUserId, token } = await s.register({ alias: 'pi0', host: 'web1' });
expect(await s.verifyToken(token)).toBe(agentUserId);
expect(await s.verifyToken('magt_garbage')).toBeNull();
expect(await s.verifyToken('not-a-token')).toBeNull();
expect(await s.verifyToken('')).toBeNull();
});
it('revoke invalidates tokens, returns count, and hides agent from list', async () => {
const { intent } = makeFakeIntent();
const s = new AgentTokenStore(intent);
const { agentUserId, token } = await s.register({ alias: 'pi0', host: 'web1' });
expect((await s.list()).map((a) => a.agent_user_id)).toContain(agentUserId);
const count = await s.revoke(agentUserId);
expect(count).toBe(1);
expect(await s.verifyToken(token)).toBeNull();
expect((await s.list()).map((a) => a.agent_user_id)).not.toContain(agentUserId);
// Idempotent on unknown / already-revoked.
expect(await s.revoke(agentUserId)).toBe(0);
expect(await s.revoke('@agent-nope:hs.example')).toBe(0);
});
it('re-register after revoke yields a working token and the agent reappears', async () => {
const { intent } = makeFakeIntent();
const s = new AgentTokenStore(intent);
const { agentUserId, token: t1 } = await s.register({ alias: 'pi0', host: 'web1' });
await s.revoke(agentUserId);
const { token: t2 } = await s.register({ alias: 'pi0', host: 'web1' });
expect(await s.verifyToken(t1)).toBeNull();
expect(await s.verifyToken(t2)).toBe(agentUserId);
expect((await s.list()).map((a) => a.agent_user_id)).toContain(agentUserId);
});
it('agent A token never verifies as agent B', async () => {
const { intent } = makeFakeIntent();
const s = new AgentTokenStore(intent);
const a = await s.register({ alias: 'pi0', host: 'web1' });
const b = await s.register({ alias: 'pi1', host: 'web2' });
expect(await s.verifyToken(a.token)).toBe(a.agentUserId);
expect(await s.verifyToken(b.token)).toBe(b.agentUserId);
expect(a.agentUserId).not.toBe(b.agentUserId);
});
it('rejects an ambiguous re-registration that collides on one Matrix id', async () => {
const { intent } = makeFakeIntent();
const s = new AgentTokenStore(intent);
// alias="a-b",host="c" and alias="a",host="b-c" both -> @agent-a-b-c.
const first = await s.register({ alias: 'a-b', host: 'c' });
expect(first.agentUserId).toBe('@agent-a-b-c:hs.example');
await expect(s.register({ alias: 'a', host: 'b-c' })).rejects.toThrow(/collision/);
// The original registration is untouched: still one active token, correct pair.
expect(await s.verifyToken(first.token)).toBe(first.agentUserId);
const summary = (await s.list()).find((x) => x.agent_user_id === first.agentUserId);
expect(summary?.alias).toBe('a-b');
expect(summary?.host).toBe('c');
expect(summary?.active_token_count).toBe(1);
});
it('display_name is stored and surfaced in list', async () => {
const { intent } = makeFakeIntent();
const s = new AgentTokenStore(intent);
await s.register({ alias: 'pi0', host: 'web1', displayName: 'Pi Zero' });
const summary = (await s.list())[0];
expect(summary?.display_name).toBe('Pi Zero');
expect(summary?.active_token_count).toBe(1);
});
});

View File

@@ -0,0 +1,63 @@
/** DTOs for agent registration + scoped/revocable bridge tokens (US-007). */
export interface RegisterAgentDto {
/** Agent alias slug, e.g. "pi0". Combined with host into the agent slug. */
alias: string;
/** Host slug, e.g. "web1". Combined with alias into the agent slug. */
host: string;
display_name?: string;
}
export interface RevokeAgentDto {
agent_user_id: string;
}
export interface RegisterAgentResponse {
agent_user_id: string;
bridge_token: string;
}
export interface AgentSummary {
agent_user_id: string;
alias: string;
host: string;
display_name?: string;
created_at: string;
active_token_count: number;
}
const SLUG_RE = /^[a-z0-9][a-z0-9_.-]*$/;
/** Combined agent slug, e.g. alias="pi0", host="web1" -> "pi0-web1". */
export function agentSlug(alias: string, host: string): string {
return `${alias}-${host}`;
}
const assertSlug = (value: unknown, field: string): void => {
if (typeof value !== 'string' || value.length === 0 || !SLUG_RE.test(value)) {
throw new Error(`${field} must match [a-z0-9][a-z0-9_.-]* (lowercase, non-empty)`);
}
};
export function validateRegisterAgent(input: unknown): asserts input is RegisterAgentDto {
const o = input as Partial<RegisterAgentDto> | null | undefined;
if (!o || typeof o !== 'object') throw new Error('payload must be an object');
assertSlug(o.alias, 'alias');
assertSlug(o.host, 'host');
if (o.display_name !== undefined) {
if (typeof o.display_name !== 'string' || o.display_name.length === 0) {
throw new Error('display_name must be a non-empty string');
}
if (o.display_name.length > 100) {
throw new Error('display_name must be at most 100 chars');
}
}
}
export function validateRevokeAgent(input: unknown): asserts input is RevokeAgentDto {
const o = input as Partial<RevokeAgentDto> | null | undefined;
if (!o || typeof o !== 'object') throw new Error('payload must be an object');
if (typeof o.agent_user_id !== 'string' || !o.agent_user_id.startsWith('@')) {
throw new Error('agent_user_id must be a Matrix user id');
}
}

View File

@@ -0,0 +1,160 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { agentSlug } from './agent-registry.dto.js';
import type { AgentSummary } from './agent-registry.dto.js';
import type { AppserviceIntent } from './intent.js';
/** account_data type holding the agent registry on the AS sender user. */
export const AGENTS_ACCOUNT_DATA_TYPE = 'org.uscllc.mosaic_as.agents';
const TOKEN_PREFIX = 'magt_';
interface StoredAgent {
alias: string;
host: string;
display_name?: string;
created_at: string;
/** sha256hex of each active token. Plaintext tokens are NEVER stored. */
token_hashes: string[];
revoked_at?: string;
}
interface AgentRegistry {
agents: Record<string, StoredAgent>;
}
const sha256hex = (value: string): string => createHash('sha256').update(value).digest('hex');
const mintToken = (): string => `${TOKEN_PREFIX}${randomBytes(32).toString('base64url')}`;
/**
* Persists scoped/revocable bridge tokens for agent virtual users in Matrix
* account_data on the AS sender user (no new infra; survives restart).
*
* Tokens are stored only as sha256 hashes (the high-entropy `magt_` token makes
* plain sha256 safe — no salt/KDF needed since brute force is infeasible).
*
* KNOWN v1 LIMIT: Synapse caps a single account_data object (default
* max_account_data_size, ~100KB). Each agent + hash entry is small, so this
* supports thousands of agents, but a very large fleet would eventually need a
* dedicated store. Revoked agents with no active tokens are pruned of hashes
* (kept as tombstones) to bound growth.
*/
export class AgentTokenStore {
constructor(private readonly intent: AppserviceIntent) {}
/** Read the registry fresh from account_data (low-frequency ops favor
* correctness over caching; verifyToken/list also read fresh). */
private async read(): Promise<AgentRegistry> {
const data = await this.intent.getSenderAccountData(AGENTS_ACCOUNT_DATA_TYPE);
const agents = data?.agents;
if (agents && typeof agents === 'object') {
return { agents: agents as Record<string, StoredAgent> };
}
return { agents: {} };
}
private async write(registry: AgentRegistry): Promise<void> {
await this.intent.setSenderAccountData(AGENTS_ACCOUNT_DATA_TYPE, {
agents: registry.agents,
});
}
/** Ensure the virtual user exists, mint a fresh token, store its hash, and
* return the plaintext token ONCE. Clears any prior revocation. */
async register(opts: {
alias: string;
host: string;
displayName?: string;
}): Promise<{ agentUserId: string; token: string }> {
const slug = agentSlug(opts.alias, opts.host);
const agentUserId = await this.intent.ensureRegistered(slug);
if (opts.displayName !== undefined) {
await this.intent.setDisplayName(slug, opts.displayName);
}
const token = mintToken();
const hash = sha256hex(token);
const registry = await this.read();
const existing = registry.agents[agentUserId];
if (existing) {
// The agent slug `<alias>-<host>` joins with a `-`, which is also a legal
// slug char, so distinct pairs can collide on one Matrix id (e.g.
// a/b-c and a-b/c both -> @agent-a-b-c). They ARE the same Matrix user,
// but silently overwriting the stored alias/host of a different pair
// would conflate two logical agents into one token bucket. Reject the
// ambiguous re-registration instead of overwriting.
if (existing.alias !== opts.alias || existing.host !== opts.host) {
throw new Error(
`agent id collision: ${agentUserId} already registered as ` +
`${existing.alias}/${existing.host}, refusing ${opts.alias}/${opts.host}`,
);
}
if (opts.displayName !== undefined) existing.display_name = opts.displayName;
existing.token_hashes = [...existing.token_hashes, hash];
delete existing.revoked_at;
} else {
registry.agents[agentUserId] = {
alias: opts.alias,
host: opts.host,
...(opts.displayName !== undefined ? { display_name: opts.displayName } : {}),
created_at: new Date().toISOString(),
token_hashes: [hash],
};
}
await this.write(registry);
return { agentUserId, token };
}
/** Return the agentUserId bound to an active (non-revoked) token, else null.
* Constant-time hash comparison; no early-out on match. */
async verifyToken(token: string): Promise<string | null> {
if (!token.startsWith(TOKEN_PREFIX)) return null;
const presented = Buffer.from(sha256hex(token), 'hex');
const registry = await this.read();
let matched: string | null = null;
for (const [agentUserId, agent] of Object.entries(registry.agents)) {
if (agent.revoked_at) continue;
for (const stored of agent.token_hashes) {
const candidate = Buffer.from(stored, 'hex');
if (candidate.length === presented.length && timingSafeEqual(candidate, presented)) {
// No early break: keep scanning so timing does not reveal match position.
matched = agentUserId;
}
}
}
return matched;
}
/** Revoke all active tokens for an agent. Idempotent; returns count revoked. */
async revoke(agentUserId: string): Promise<number> {
const registry = await this.read();
const agent = registry.agents[agentUserId];
if (!agent) return 0;
const count = agent.token_hashes.length;
agent.token_hashes = [];
agent.revoked_at = new Date().toISOString();
await this.write(registry);
return count;
}
/** List agents with at least one active token (never advertise revoked/phantom). */
async list(): Promise<AgentSummary[]> {
const registry = await this.read();
const out: AgentSummary[] = [];
for (const [agentUserId, agent] of Object.entries(registry.agents)) {
if (agent.revoked_at || agent.token_hashes.length === 0) continue;
out.push({
agent_user_id: agentUserId,
alias: agent.alias,
host: agent.host,
...(agent.display_name !== undefined ? { display_name: agent.display_name } : {}),
created_at: agent.created_at,
active_token_count: agent.token_hashes.length,
});
}
return out;
}
}

View File

@@ -10,6 +10,14 @@ export {
validateProvisionRoom,
} from './bridge.dto.js';
export type { BridgeMessageDto, BridgeTypingDto, ProvisionRoomDto } from './bridge.dto.js';
export { agentSlug, validateRegisterAgent, validateRevokeAgent } from './agent-registry.dto.js';
export type {
RegisterAgentDto,
RevokeAgentDto,
RegisterAgentResponse,
AgentSummary,
} from './agent-registry.dto.js';
export { AgentTokenStore, AGENTS_ACCOUNT_DATA_TYPE } from './agent-store.js';
export type {
AppserviceConfig,
EventHandler,

View File

@@ -233,4 +233,30 @@ export class AppserviceIntent {
body: { displayname: displayName },
});
}
/** Read an account_data object on the AS sender user. Returns null when the
* key has never been written (M_NOT_FOUND), so callers can treat that as an
* empty store; any other error propagates. */
async getSenderAccountData(type: string): Promise<Record<string, unknown> | null> {
const user = encodeURIComponent(this.senderUserId);
const key = encodeURIComponent(type);
try {
return await this.request('GET', `/_matrix/client/v3/user/${user}/account_data/${key}`, {
userId: this.senderUserId,
});
} catch (err) {
if (err instanceof MatrixApiError && err.errcode === 'M_NOT_FOUND') return null;
throw err;
}
}
/** Write an account_data object on the AS sender user. */
async setSenderAccountData(type: string, content: Record<string, unknown>): Promise<void> {
const user = encodeURIComponent(this.senderUserId);
const key = encodeURIComponent(type);
await this.request('PUT', `/_matrix/client/v3/user/${user}/account_data/${key}`, {
userId: this.senderUserId,
body: content,
});
}
}