clavarium 0.2.367-next.1 → 0.2.369-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.367-next.1",
3
+ "version": "0.2.369-next.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "clavarium",
9
- "version": "0.2.367-next.1",
9
+ "version": "0.2.369-next.1",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clavarium",
3
- "version": "0.2.367-next.1",
3
+ "version": "0.2.369-next.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A conversation is a VM with a terminal in it. Everything else is a folder.",
package/server/api.mjs CHANGED
@@ -700,6 +700,8 @@ export async function handleApi(request, response, conversations, headers, resou
700
700
  if (access !== 'write') gitKeeper?.forgetAttachment(name, conversation);
701
701
  await resources.attach(conversation, name, access);
702
702
  retell(conversation);
703
+ /* Newly attached: its links looked at (and its mounts), not waited for. */
704
+ if (access !== 'none') void health?.check?.(conversation, { asked: true })?.catch?.(() => {});
703
705
  };
704
706
  const handSecret = async (conversation, name, attached) => {
705
707
  if (!conversations.list().some((c) => c.name === conversation)) throw new Refusal(404, 'No such conversation.');
@@ -868,6 +870,13 @@ export async function handleApi(request, response, conversations, headers, resou
868
870
  if (!conversations.list().some((c) => c.name === conversation)) throw new Refusal(404, 'No such conversation.');
869
871
  return send(200, health.markSeen(conversation)), true;
870
872
  }
873
+ /* "Check shared folders": each mounted and readable (put back when loose), each link the share cannot serve (health.mjs). */
874
+ const foldersOf = /^\/api\/conversations\/([^/]+)\/folders\/check$/.exec(pathname);
875
+ if (foldersOf && health) {
876
+ const conversation = decodeURIComponent(foldersOf[1]);
877
+ if (!conversations.list().some((c) => c.name === conversation)) throw new Refusal(404, 'No such conversation.');
878
+ return send(200, await health.checkFolders(conversation)), true;
879
+ }
871
880
  const machine = /^\/api\/conversations\/([^/]+)\/(restart|machine|health)$/.exec(pathname);
872
881
  if (machine) {
873
882
  const conversation = decodeURIComponent(machine[1]);
@@ -17,6 +17,20 @@
17
17
  * A conversation archived takes its lines with it (devices.forget). Its
18
18
  * terminal ("x") is never given to a key.
19
19
  *
20
+ * a room, given ONE BY ONE the same way:
21
+ * read read its thread, as the page reads it (names, never ids;
22
+ * secrets masked) room:<id> r
23
+ * talk and post in it as the person does -- to its host, or to the AIs
24
+ * named -- signed with the key's name and look room:<id> rw
25
+ *
26
+ * a voice box, given ONE BY ONE:
27
+ * state online, where it speaks, when it last heard, its volume --
28
+ * nothing it heard, never audio box:<id> r
29
+ * speak and make it say a short text aloud (SPEAK_MAX characters,
30
+ * SPEAK_PER_MIN a minute per key and box): never listen, record,
31
+ * change its settings nor update it box:<id> rw
32
+ * A room removed, a box withdrawn, takes its lines with it (main.mjs).
33
+ *
20
34
  * THE SECRET IS SHOWN ONCE. It is made here (32 random bytes, "clv_" before
21
35
  * them), given back by `create` and never again: only its sha256 is kept, as
22
36
  * a pairing's session is (devices.mjs). A 256-bit random secret needs no salt
@@ -40,6 +54,9 @@
40
54
  * POST /api/apikeys {label, scopes, conversations: {name: 'read'|'talk'}, days|null}
41
55
  * -> {key, secret}: the secret this once
42
56
  * POST /api/apikeys/<id>/conversations {conversation, level: 'read'|'talk'|null}: one given, changed, taken back
57
+ * POST /api/apikeys/<id>/rooms {room, level: 'read'|'talk'|null}
58
+ * POST /api/apikeys/<id>/boxes {box, level: 'state'|'speak'|null}
59
+ * (and on creation: rooms: {id: level}, boxes: {id: level})
43
60
  * POST /api/apikeys/<id>/revoke
44
61
  * GET /api/apikeys/<id>/journal its last uses
45
62
  *
@@ -48,9 +65,15 @@
48
65
  * GET /api/ext/v1/conversations those it may read: {conversations: [{id, name, level, chat}]}
49
66
  * GET /api/ext/v1/conversations/<name>/messages ?last=1..200 (50) | ?since=<cursor>: {chat, messages, cursor}
50
67
  * POST /api/ext/v1/conversations/<name>/messages {text}: posted in its chat, signed with the key's name
68
+ * GET /api/ext/v1/rooms those it may read: {rooms: [{id, name, level, host, members}]}
69
+ * GET /api/ext/v1/rooms/<id>/messages as a conversation's: {messages, cursor}
70
+ * POST /api/ext/v1/rooms/<id>/messages {text, to?: [names of its AIs]}: posted in the room, signed with the key
71
+ * GET /api/ext/v1/boxes {boxes: [{id, name, level, online, where, heardAt, volume}]}
72
+ * POST /api/ext/v1/boxes/<id>/say {text}: said aloud by the box
51
73
  * POST /api/ext/mcp the same as MCP tools (clavarium_status, clavarium_conversations,
52
- * clavarium_read, clavarium_say), JSON-RPC over HTTP
53
- * A conversation not given, or not there, is the same 403: a key learns nothing of what it was not given.
74
+ * clavarium_read, clavarium_say, clavarium_rooms, clavarium_room_read,
75
+ * clavarium_room_say, clavarium_boxes, clavarium_box_say), JSON-RPC over HTTP
76
+ * A conversation, room or box not given, or not there, is the same 403: a key learns nothing of what it was not given.
54
77
  *
55
78
  * The front lets /api/ext/ through without sign-on, as it does /c/
56
79
  * (deploy/install.sh, skip_auth_routes; deploy/updater.mjs for installs made
@@ -62,6 +85,7 @@ import { appendFileSync, readFileSync, writeFileSync, renameSync, statSync } fro
62
85
  import { Refusal } from './conversations.mjs';
63
86
  import { Grants, apiKeySubject } from './grants.mjs';
64
87
  import { isConversationName } from './names.mjs';
88
+ import { masked, lookOf } from './rooms.mjs';
65
89
 
66
90
  export const KEY_PREFIX = 'clv_';
67
91
  export const DEFAULT_DAYS = 90;
@@ -86,6 +110,14 @@ export const levelOf = (rights) => (String(rights).includes('w') ? 'talk' : Stri
86
110
  export const READ_DEFAULT = 50;
87
111
  export const READ_MAX = 200;
88
112
  export const SAY_MAX = 8000;
113
+ /* What a room and a voice box may be given: [rights in the table]. */
114
+ export const ROOM_LEVELS = Object.freeze({ read: 'r', talk: 'rw' });
115
+ export const BOX_LEVELS = Object.freeze({ state: 'r', speak: 'rw' });
116
+ export const boxLevelOf = (rights) => (String(rights).includes('w') ? 'speak' : String(rights).includes('r') ? 'state' : null);
117
+ /* Said aloud in the home: short, and a few a minute (per key and box). */
118
+ export const SPEAK_MAX = 300;
119
+ export const SPEAK_PER_MIN = 3;
120
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
89
121
 
90
122
  const digest = (text) => createHash('sha256').update(text).digest();
91
123
  const oneLine = (value) => typeof value === 'string' && value.trim() !== '' && value.trim().length <= LABEL_MAX
@@ -93,19 +125,25 @@ const oneLine = (value) => typeof value === 'string' && value.trim() !== '' && v
93
125
  const SECRET = /^clv_[A-Za-z0-9_-]{43}$/;
94
126
 
95
127
  export class ApiKeys {
96
- #store; #grants; #now; #journal; #exists;
128
+ #store; #grants; #now; #journal; #exists; #kinds;
97
129
  #used = new Map(); /* id -> { at, savedAt } */
98
130
  #calls = new Map(); /* id ('' for unknown keys) -> [times of the last minute] */
99
131
 
100
- /* `exists(name)`: whether a conversation is there (one is given only then). */
101
- constructor({ store, grants = new Grants(), journal = null, now = () => Date.now(), exists = () => true }) {
132
+ /* `exists(name)`: whether a conversation is there (one is given only then); `roomExists(id)`,
133
+ `boxExists(id)`: the same for a room, a voice box (none, without them). */
134
+ constructor({ store, grants = new Grants(), journal = null, now = () => Date.now(), exists = () => true, roomExists = () => false, boxExists = () => false }) {
102
135
  this.#store = store; this.#grants = grants; this.#journal = journal; this.#now = now; this.#exists = exists;
136
+ /* What is given one by one: its asset type, its levels, a valid name, whether it is there, its words. */
137
+ this.#kinds = {
138
+ room: { levels: ROOM_LEVELS, of: levelOf, valid: (v) => typeof v === 'string' && UUID.test(v), exists: roomExists, what: 'room', the: 'a room' },
139
+ box: { levels: BOX_LEVELS, of: boxLevelOf, valid: (v) => typeof v === 'string' && UUID.test(v), exists: boxExists, what: 'voice box', the: 'a voice box' },
140
+ };
103
141
  }
104
142
 
105
143
  #scopesOf(id) { return Object.entries(SCOPES).filter(([, s]) => this.#grants.can(apiKeySubject(id), s.asset, s.rights)).map(([name]) => name); }
106
144
  #shown(k) {
107
145
  const used = this.#used.get(k.name)?.at ?? k.usedAt ?? null;
108
- return { id: k.name, label: k.label, scopes: this.#scopesOf(k.name), conversations: this.conversationsOf(k.name), createdBy: k.createdBy ?? null, createdAt: k.createdAt, expiresAt: k.expiresAt ?? null,
146
+ return { id: k.name, label: k.label, scopes: this.#scopesOf(k.name), conversations: this.conversationsOf(k.name), rooms: this.roomsOf(k.name), boxes: this.boxesOf(k.name), createdBy: k.createdBy ?? null, createdAt: k.createdAt, expiresAt: k.expiresAt ?? null,
109
147
  usedAt: used, expired: Boolean(k.expiresAt && Date.parse(k.expiresAt) <= this.#now()) };
110
148
  }
111
149
 
@@ -119,6 +157,34 @@ export class ApiKeys {
119
157
  .sort((a, b) => a.name.localeCompare(b.name));
120
158
  }
121
159
 
160
+ /* The rooms, the voice boxes a key was given, each with its level: [{ id, level }] -- only those still there. */
161
+ #givenOf(id, type) {
162
+ const k = this.#kinds[type];
163
+ return this.#grants.of(apiKeySubject(id), type).map((g) => ({ id: g.name, level: k.of(g.rights) })).filter((x) => x.level && k.exists(x.id))
164
+ .sort((a, b) => a.id.localeCompare(b.id));
165
+ }
166
+ roomsOf(id) { return this.#givenOf(id, 'room'); }
167
+ boxesOf(id) { return this.#givenOf(id, 'box'); }
168
+ #oneAsked(type, ref, level) {
169
+ const k = this.#kinds[type];
170
+ if (!k.valid(ref) || !k.exists(ref)) throw new Refusal(404, `No such ${k.what}.`);
171
+ if (level !== null && !Object.hasOwn(k.levels, level)) throw new Refusal(400, `${k.the[0].toUpperCase()}${k.the.slice(1)} is given at one of: ${Object.keys(k.levels).join(', ')}.`);
172
+ }
173
+ #givenAll(type, given) {
174
+ if (!given || typeof given !== 'object' || Array.isArray(given)) throw new Refusal(400, `${type === 'room' ? 'Rooms' : 'Voice boxes'} are given by id: {id: level}.`);
175
+ for (const [ref, level] of Object.entries(given)) this.#oneAsked(type, ref, level);
176
+ }
177
+ /* One room or voice box given to a key, its level changed, or taken back (level null). */
178
+ #setOne(type, id, ref, level) {
179
+ if (typeof id !== 'string' || !this.#store.get(id)) throw new Refusal(404, 'No such key.');
180
+ this.#oneAsked(type, ref, level);
181
+ if (level === null) this.#grants.take(apiKeySubject(id), `${type}:${ref}`);
182
+ else this.#grants.give(apiKeySubject(id), `${type}:${ref}`, this.#kinds[type].levels[level]);
183
+ return this.get(id);
184
+ }
185
+ setRoom(id, room, level) { return this.#setOne('room', id, room, level); }
186
+ setBox(id, box, level) { return this.#setOne('box', id, box, level); }
187
+
122
188
  #conversationAsked(name, level) {
123
189
  if (!isConversationName(name) || !this.#exists(name)) throw new Refusal(404, 'No such conversation.');
124
190
  if (level !== null && !Object.hasOwn(LEVELS, level)) throw new Refusal(400, `A conversation is given to read or to talk: ${Object.keys(LEVELS).join(', ')}.`);
@@ -135,11 +201,12 @@ export class ApiKeys {
135
201
 
136
202
  /* A new key: its secret is in the answer, this once. `days`: its life (null: it does not expire).
137
203
  `conversations`: { name: 'read' | 'talk' }, each named -- there is no "all". */
138
- create({ label, scopes = [], conversations = {}, days = DEFAULT_DAYS } = {}, who = '') {
204
+ create({ label, scopes = [], conversations = {}, rooms = {}, boxes = {}, days = DEFAULT_DAYS } = {}, who = '') {
139
205
  if (!oneLine(label)) throw new Refusal(400, `A key has a name: one line, ${LABEL_MAX} characters at most.`);
140
206
  if (!Array.isArray(scopes) || scopes.some((s) => !Object.hasOwn(SCOPES, s))) throw new Refusal(400, `Scopes are among: ${Object.keys(SCOPES).join(', ')}.`);
141
207
  if (!conversations || typeof conversations !== 'object' || Array.isArray(conversations)) throw new Refusal(400, 'Conversations are given by name: {name: "read" | "talk"}.');
142
208
  for (const [name, level] of Object.entries(conversations)) this.#conversationAsked(name, level);
209
+ this.#givenAll('room', rooms); this.#givenAll('box', boxes);
143
210
  if (days !== null && !(Number.isInteger(days) && days >= 1 && days <= DAYS_MAX)) throw new Refusal(400, `A key lasts 1 to ${DAYS_MAX} days, or does not expire.`);
144
211
  if (this.#store.all().length >= KEYS_MAX) throw new Refusal(409, `At most ${KEYS_MAX} keys: revoke one first.`);
145
212
  const name = label.trim();
@@ -152,6 +219,8 @@ export class ApiKeys {
152
219
  this.#store.put(record);
153
220
  for (const s of new Set(scopes)) this.#grants.give(apiKeySubject(id), SCOPES[s].asset, SCOPES[s].rights);
154
221
  for (const [name, level] of Object.entries(conversations)) if (level !== null) this.#grants.give(apiKeySubject(id), `conversation:${name}`, LEVELS[level]);
222
+ for (const [ref, level] of Object.entries(rooms)) if (level !== null) this.#grants.give(apiKeySubject(id), `room:${ref}`, ROOM_LEVELS[level]);
223
+ for (const [ref, level] of Object.entries(boxes)) if (level !== null) this.#grants.give(apiKeySubject(id), `box:${ref}`, BOX_LEVELS[level]);
155
224
  return { key: this.#shown(record), secret };
156
225
  }
157
226
 
@@ -161,6 +230,7 @@ export class ApiKeys {
161
230
  this.#store.remove(id);
162
231
  this.#grants.forgetSubject(apiKeySubject(id));
163
232
  this.#used.delete(id); this.#calls.delete(id);
233
+ for (const c of [...this.#calls.keys()]) if (c.startsWith(`speak:${id}:`)) this.#calls.delete(c);
164
234
  }
165
235
 
166
236
  get(id) { const k = this.#store.get(id); return k ? this.#shown(k) : undefined; }
@@ -215,8 +285,31 @@ export class ApiKeys {
215
285
  return levelOf(rights);
216
286
  }
217
287
 
288
+ /* Whether a key (already checked) may read ('read') or post in ('talk') one room; 403 if not, the same for one
289
+ not given and one not there. */
290
+ mayRoom(id, room, level = 'read') {
291
+ const rights = typeof room === 'string' && UUID.test(room) && this.#kinds.room.exists(room) ? this.#grants.rightsOf(apiKeySubject(id), `room:${room}`) : '';
292
+ if (!rights.includes('r')) throw Object.assign(new Refusal(403, 'This key was not given this room.'), { key: id });
293
+ if (level === 'talk' && !rights.includes('w')) throw Object.assign(new Refusal(403, 'This key may read this room, not talk in it.'), { key: id });
294
+ return levelOf(rights);
295
+ }
296
+ /* Whether a key may see the state of ('state') or make speak ('speak') one voice box; 403 if not. */
297
+ mayBox(id, box, level = 'state') {
298
+ const rights = typeof box === 'string' && UUID.test(box) && this.#kinds.box.exists(box) ? this.#grants.rightsOf(apiKeySubject(id), `box:${box}`) : '';
299
+ if (!rights.includes('r')) throw Object.assign(new Refusal(403, 'This key was not given this voice box.'), { key: id });
300
+ if (level === 'speak' && !rights.includes('w')) throw Object.assign(new Refusal(403, 'This key may see this voice box, not make it speak.'), { key: id });
301
+ return boxLevelOf(rights);
302
+ }
303
+ /* One sentence said aloud, counted: SPEAK_PER_MIN a minute for this key and box, then 429. */
304
+ speakCounted(id, box) {
305
+ const wait = this.#counted(`speak:${id}:${box}`, SPEAK_PER_MIN);
306
+ if (wait) throw Object.assign(new Refusal(429, `Too many: ${SPEAK_PER_MIN} sentences a minute per voice box.`), { key: id, retry: wait });
307
+ }
308
+
218
309
  /* Its name, as its messages are signed in a chat (Parley's "by"). */
219
310
  signature(id) { const k = this.#store.get(id); return k ? { id: apiKeySubject(id), name: k.label, kind: 'ai', place: 'API key' } : null; }
311
+ /* In a room: its name and its look (the letter and colour the page draws it with). */
312
+ roomSignature(id) { const s = this.signature(id); return s ? { ...s, look: lookOf(s.name) } : null; }
220
313
 
221
314
  #seen(id) {
222
315
  const now = this.#now();
@@ -305,6 +398,23 @@ export function threadOf(read) {
305
398
  return { chat: true, messages, cursor: typeof read.cursor === 'string' ? read.cursor : null };
306
399
  }
307
400
 
401
+ /* A ROOM'S THREAD as a key reads it, as the page reads it: who said it by NAME (the person, an AI, a box, a key),
402
+ whom it is for by name, its words with any secret masked, a file by its name only. Never a member's id, a path,
403
+ nor what the page keeps for itself. `read`: what rooms.read gives. */
404
+ export function roomThreadOf(read) {
405
+ const messages = (Array.isArray(read?.messages) ? read.messages : []).filter((m) => m && (m.from === 'person' || m.from === 'agent')).map((m) => {
406
+ const files = [...(Array.isArray(m.files) ? m.files.map((f) => fileName(f?.name || f?.path)) : []), ...(m.kind === 'show' && m.path ? [fileName(m.path)] : [])].filter(Boolean).slice(0, 12);
407
+ const choices = Array.isArray(m.choices) ? m.choices.filter((c) => typeof c === 'string').slice(0, 8).map((c) => masked(c).slice(0, 200)) : [];
408
+ const to = Array.isArray(m.toNames) ? m.toNames.filter((x) => typeof x === 'string' && !UUID.test(x)).slice(0, 20).map((x) => x.slice(0, 80)) : [];
409
+ /* No name: the person (the room's own side), as in a conversation's thread. */
410
+ const by = typeof m.by?.name === 'string' && m.by.name ? m.by.name.slice(0, 80) : '';
411
+ return { id: String(m.id ?? '').slice(0, 80), at: String(m.at ?? '').slice(0, 40), from: m.from, kind: String(m.kind ?? 'say').slice(0, 20),
412
+ text: typeof m.text === 'string' ? masked(m.text).slice(0, TEXT_MAX) : '', ...(by ? { by } : {}),
413
+ ...(to.length ? { to } : {}), ...(m.toYou ? { toPerson: true } : {}), ...(choices.length ? { choices } : {}), ...(files.length ? { files } : {}) };
414
+ });
415
+ return { messages, cursor: typeof read?.cursor === 'string' ? read.cursor : null };
416
+ }
417
+
308
418
  /* What a read asks for: the newest `last` (READ_DEFAULT; READ_MAX at most), or what came `since` a cursor. */
309
419
  export function readPart(get) {
310
420
  const last = Number(get('last') ?? READ_DEFAULT);
@@ -345,13 +455,52 @@ export const SAY_TOOL = {
345
455
  inputSchema: { type: 'object', properties: { conversation: CONVERSATION_ARG, text: { type: 'string', minLength: 1, maxLength: SAY_MAX } }, required: ['conversation', 'text'], additionalProperties: false },
346
456
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
347
457
  };
348
- export const TOOLS = [STATUS_TOOL, CONVERSATIONS_TOOL, READ_TOOL, SAY_TOOL];
458
+ const ROOM_ARG = { type: 'string', description: 'The room\'s id, as clavarium_rooms gives it.' };
459
+ const BOX_ARG = { type: 'string', description: 'The voice box\'s id, as clavarium_boxes gives it.' };
460
+ export const ROOMS_TOOL = {
461
+ name: 'clavarium_rooms',
462
+ title: 'Rooms of this key',
463
+ description: 'The rooms this key was given, each with its id, name, level (read or talk), its host and its members by name.',
464
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
465
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
466
+ };
467
+ export const ROOM_READ_TOOL = {
468
+ name: 'clavarium_room_read',
469
+ title: 'Read a room',
470
+ description: `The newest messages of one room's thread (the person, its AIs, its voice boxes, keys), oldest first, by names; secrets masked. A cursor as for clavarium_read. ${READ_DEFAULT} by default, ${READ_MAX} at most.`,
471
+ inputSchema: { type: 'object', properties: { room: ROOM_ARG, last: { type: 'integer', minimum: 1, maximum: READ_MAX }, since: { type: 'string' } }, required: ['room'], additionalProperties: false },
472
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
473
+ };
474
+ export const ROOM_SAY_TOOL = {
475
+ name: 'clavarium_room_say',
476
+ title: 'Post a message in a room',
477
+ description: 'Post a message in one room, as the person would: it goes to the room\'s host, or to the AIs named in "to" (their names, as clavarium_rooms gives them). Shown signed with this key\'s name. The room must have been given to this key to talk.',
478
+ inputSchema: { type: 'object', properties: { room: ROOM_ARG, text: { type: 'string', minLength: 1, maxLength: SAY_MAX }, to: { type: 'array', items: { type: 'string' }, maxItems: 20 } }, required: ['room', 'text'], additionalProperties: false },
479
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
480
+ };
481
+ export const BOXES_TOOL = {
482
+ name: 'clavarium_boxes',
483
+ title: 'Voice boxes of this key',
484
+ description: 'The voice boxes this key was given, each with its id, name, level (state or speak), whether it is online, where it speaks, when it last heard someone, and its volume. Nothing it heard.',
485
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
486
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
487
+ };
488
+ export const BOX_SAY_TOOL = {
489
+ name: 'clavarium_box_say',
490
+ title: 'Say a short text aloud on a voice box',
491
+ description: `Make one voice box say a short text aloud, in the home: ${SPEAK_MAX} characters at most, ${SPEAK_PER_MIN} a minute. It does not listen after. The box must have been given to this key to speak.`,
492
+ inputSchema: { type: 'object', properties: { box: BOX_ARG, text: { type: 'string', minLength: 1, maxLength: SPEAK_MAX } }, required: ['box', 'text'], additionalProperties: false },
493
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
494
+ };
495
+ export const TOOLS = [STATUS_TOOL, CONVERSATIONS_TOOL, READ_TOOL, SAY_TOOL, ROOMS_TOOL, ROOM_READ_TOOL, ROOM_SAY_TOOL, BOXES_TOOL, BOX_SAY_TOOL];
349
496
 
350
497
  /* Returns true when the request was under /api/ext/ (answered here). `status()`: the state, as statusOf
351
498
  makes it. `chats`: { list() -> [{ id, name }], read(name, part) -> talk.of's answer, chatOn(name),
352
- say(name, text, by) -> { told } }. `originOk(headers)`: whether an Origin is this site's (gate.mjs); a
499
+ say(name, text, by) -> { told } }. `rooms`: { list() -> [{ id, name, host, members: [{ name, kind }] }],
500
+ read(id, part) -> rooms.read's answer, say(id, text, { as, to }) }. `boxes`: { list() -> [{ id, name, online, where,
501
+ heardAt, volume }], say(id, text, by) -> { seconds? } }. `originOk(headers)`: whether an Origin is this site's (gate.mjs); a
353
502
  call from a page of another site is refused. */
354
- export async function handleExternal(request, response, { keys, status, chats = null, headers = {}, originOk = () => false, version = null }) {
503
+ export async function handleExternal(request, response, { keys, status, chats = null, rooms = null, boxes = null, headers = {}, originOk = () => false, version = null }) {
355
504
  const url = new URL(request.url ?? '/', 'http://local');
356
505
  const { pathname } = url;
357
506
  if (!pathname.startsWith(EXT_PREFIX) && pathname !== EXT_PREFIX.slice(0, -1)) return false;
@@ -394,6 +543,39 @@ export async function handleExternal(request, response, { keys, status, chats =
394
543
  const said = await chats.say(name, text, keys.signature(id));
395
544
  return { ok: true, told: typeof said?.told === 'string' ? said.told : null };
396
545
  };
546
+ /* The same for rooms and voice boxes. */
547
+ const roomsListed = (id) => {
548
+ const there = new Map((rooms?.list() ?? []).map((r) => [r.id, r]));
549
+ return { rooms: keys.roomsOf(id).filter((g) => there.has(g.id)).map((g) => { const r = there.get(g.id);
550
+ return { id: g.id, name: String(r.name ?? ''), level: g.level, host: r.host ?? null, members: (Array.isArray(r.members) ? r.members : []).map((m) => ({ name: String(m.name ?? ''), kind: String(m.kind ?? '') })) }; }) };
551
+ };
552
+ const roomKnown = (room) => { if (!(rooms?.list() ?? []).some((r) => r.id === room)) throw new Refusal(403, 'This key was not given this room.'); };
553
+ const roomReading = (id, room, part) => {
554
+ keys.mayRoom(id, room, 'read'); roomKnown(room);
555
+ return roomThreadOf(rooms.read(room, part));
556
+ };
557
+ const roomSaying = async (id, room, text, to) => {
558
+ keys.mayRoom(id, room, 'talk'); roomKnown(room);
559
+ if (typeof text !== 'string' || !text.trim()) throw new Refusal(400, 'Say something: text.');
560
+ if (text.length > SAY_MAX) throw new Refusal(400, `${SAY_MAX} characters at most.`);
561
+ if (to !== undefined && to !== null && (!Array.isArray(to) || to.length > 20 || to.some((x) => typeof x !== 'string'))) throw new Refusal(400, 'to: the names of AIs of this room.');
562
+ await rooms.say(room, text, { as: keys.roomSignature(id), to: Array.isArray(to) ? to : [] });
563
+ return { ok: true };
564
+ };
565
+ const boxesListed = (id) => {
566
+ const there = new Map((boxes?.list() ?? []).map((b) => [b.id, b]));
567
+ return { boxes: keys.boxesOf(id).filter((g) => there.has(g.id)).map((g) => { const b = there.get(g.id);
568
+ return { id: g.id, name: String(b.name ?? ''), level: g.level, online: Boolean(b.online), where: b.where ?? null, heardAt: b.heardAt ?? null, volume: Number.isFinite(b.volume) ? b.volume : null }; }) };
569
+ };
570
+ const boxSaying = async (id, box, text) => {
571
+ keys.mayBox(id, box, 'speak');
572
+ if (!(boxes?.list() ?? []).some((b) => b.id === box)) throw new Refusal(403, 'This key was not given this voice box.');
573
+ if (typeof text !== 'string' || !text.trim()) throw new Refusal(400, 'Say something: text.');
574
+ if (text.length > SPEAK_MAX) throw new Refusal(400, `${SPEAK_MAX} characters at most, said aloud.`);
575
+ keys.speakCounted(id, box);
576
+ const done = await boxes.say(box, text, keys.signature(id));
577
+ return { ok: true, ...(Number.isFinite(done?.seconds) ? { seconds: done.seconds } : {}) };
578
+ };
397
579
  const ownError = (error, id) => (error instanceof Refusal ? Object.assign(error, { key: error.key ?? id }) : error);
398
580
  try {
399
581
  if (route === '/v1/status') {
@@ -423,6 +605,43 @@ export async function handleExternal(request, response, { keys, status, chats =
423
605
  return send(201, done);
424
606
  } catch (error) { return refused(ownError(error, id)); }
425
607
  }
608
+ const room = /^\/v1\/rooms(?:\/([^/]+)\/messages)?$/.exec(route);
609
+ if (room) {
610
+ const allowed = room[1] ? 'GET, POST' : 'GET';
611
+ if (!allowed.split(', ').includes(request.method)) return send(405, { error: `Call this address with ${allowed}.` }, { allow: allowed });
612
+ let id;
613
+ try { id = keys.check(request.headers.authorization); } catch (error) { return refused(error); }
614
+ try {
615
+ if (!room[1]) { const body = roomsListed(id); note(id, 200); return send(200, body); }
616
+ let ref;
617
+ try { ref = decodeURIComponent(room[1]); } catch { ref = ''; }
618
+ if (request.method === 'GET') { const body = roomReading(id, ref, readPart((k) => url.searchParams.get(k))); note(id, 200); return send(200, body); }
619
+ if (!String(request.headers['content-type'] ?? '').startsWith('application/json')) throw new Refusal(415, 'Send JSON: {"text": "..."}.');
620
+ let body;
621
+ try { body = JSON.parse(await bodyOf(SAY_MAX * 4 + 4096)); } catch (error) { if (error instanceof Refusal) throw error; throw new Refusal(400, 'The request is not JSON.'); }
622
+ const done = await roomSaying(id, ref, body?.text, body?.to);
623
+ note(id, 201);
624
+ return send(201, done);
625
+ } catch (error) { return refused(ownError(error, id)); }
626
+ }
627
+ const box = /^\/v1\/boxes(?:\/([^/]+)\/say)?$/.exec(route);
628
+ if (box) {
629
+ const allowed = box[1] ? 'POST' : 'GET';
630
+ if (request.method !== allowed) return send(405, { error: `Call this address with ${allowed}.` }, { allow: allowed });
631
+ let id;
632
+ try { id = keys.check(request.headers.authorization); } catch (error) { return refused(error); }
633
+ try {
634
+ if (!box[1]) { const body = boxesListed(id); note(id, 200); return send(200, body); }
635
+ let ref;
636
+ try { ref = decodeURIComponent(box[1]); } catch { ref = ''; }
637
+ if (!String(request.headers['content-type'] ?? '').startsWith('application/json')) throw new Refusal(415, 'Send JSON: {"text": "..."}.');
638
+ let body;
639
+ try { body = JSON.parse(await bodyOf(SPEAK_MAX * 4 + 1024)); } catch (error) { if (error instanceof Refusal) throw error; throw new Refusal(400, 'The request is not JSON.'); }
640
+ const done = await boxSaying(id, ref, body?.text);
641
+ note(id, 200);
642
+ return send(200, done);
643
+ } catch (error) { return refused(ownError(error, id)); }
644
+ }
426
645
  if (route === '/mcp') {
427
646
  if (request.method !== 'POST') return send(405, { error: 'MCP is spoken with POST here (no event stream).' }, { allow: 'POST' });
428
647
  let id;
@@ -435,8 +654,9 @@ export async function handleExternal(request, response, { keys, status, chats =
435
654
  note(id, 400, '/mcp'); return send(400, { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid Request' } });
436
655
  }
437
656
  const args = msg.params?.arguments && typeof msg.params.arguments === 'object' && !Array.isArray(msg.params.arguments) ? msg.params.arguments : {};
438
- /* The journal says which tool, and on which conversation. */
439
- const what = `/mcp ${msg.method.slice(0, 40)}${msg.method === 'tools/call' ? ` ${String(msg.params?.name ?? '').slice(0, 40)}${typeof args.conversation === 'string' ? ` ${args.conversation.slice(0, 32)}` : ''}` : ''}`;
657
+ /* The journal says which tool, and on which conversation, room or box. */
658
+ const on = [args.conversation, args.room, args.box].find((v) => typeof v === 'string');
659
+ const what = `/mcp ${msg.method.slice(0, 40)}${msg.method === 'tools/call' ? ` ${String(msg.params?.name ?? '').slice(0, 40)}${on ? ` ${on.slice(0, 36)}` : ''}` : ''}`;
440
660
  /* A notification (no id) is taken, never answered. */
441
661
  if (msg.id === undefined || msg.id === null) { note(id, 202, what); response.writeHead(202, { ...headers, 'cache-control': 'no-store' }).end(); return true; }
442
662
  const answer = (result, code = 200) => { note(id, code, what); return send(200, { jsonrpc: '2.0', id: msg.id, result }); };
@@ -447,7 +667,7 @@ export async function handleExternal(request, response, { keys, status, chats =
447
667
  const asked = String(msg.params?.protocolVersion ?? '');
448
668
  return answer({ protocolVersion: MCP_VERSIONS.includes(asked) ? asked : MCP_VERSIONS[0], capabilities: { tools: { listChanged: false } },
449
669
  serverInfo: { name: 'clavarium', title: 'Clavarium', version: version ?? '0' },
450
- instructions: 'This Clavarium, as far as this key was given it: its state (clavarium_status), the conversations given to it (clavarium_conversations), to read (clavarium_read) or to talk in (clavarium_say).' });
670
+ instructions: 'This Clavarium, as far as this key was given it: its state (clavarium_status), the conversations given to it (clavarium_conversations), to read (clavarium_read) or to talk in (clavarium_say); its rooms (clavarium_rooms, clavarium_room_read, clavarium_room_say); its voice boxes (clavarium_boxes; clavarium_box_say says a short text aloud in the home).' });
451
671
  }
452
672
  case 'ping': return answer({});
453
673
  case 'tools/list': return answer({ tools: TOOLS });
@@ -460,14 +680,28 @@ export async function handleExternal(request, response, { keys, status, chats =
460
680
  return result(await status());
461
681
  }
462
682
  if (name === CONVERSATIONS_TOOL.name) return result(listed(id));
463
- if (typeof args.conversation !== 'string') return fail(-32602, 'conversation: the id of a conversation.');
464
- if (name === READ_TOOL.name) return result(reading(id, args.conversation, readPart((k) => (args[k] === undefined ? null : typeof args[k] === 'number' ? String(args[k]) : args[k]))));
465
- const done = await saying(id, args.conversation, args.text);
683
+ if (name === ROOMS_TOOL.name) return result(roomsListed(id));
684
+ if (name === BOXES_TOOL.name) return result(boxesListed(id));
685
+ const part = () => readPart((k) => (args[k] === undefined ? null : typeof args[k] === 'number' ? String(args[k]) : args[k]));
686
+ let done;
687
+ if (name === ROOM_READ_TOOL.name || name === ROOM_SAY_TOOL.name) {
688
+ if (typeof args.room !== 'string') return fail(-32602, 'room: the id of a room.');
689
+ if (name === ROOM_READ_TOOL.name) return result(roomReading(id, args.room, part()));
690
+ done = await roomSaying(id, args.room, args.text, args.to);
691
+ } else if (name === BOX_SAY_TOOL.name) {
692
+ if (typeof args.box !== 'string') return fail(-32602, 'box: the id of a voice box.');
693
+ done = await boxSaying(id, args.box, args.text);
694
+ } else {
695
+ if (typeof args.conversation !== 'string') return fail(-32602, 'conversation: the id of a conversation.');
696
+ if (name === READ_TOOL.name) return result(reading(id, args.conversation, part()));
697
+ done = await saying(id, args.conversation, args.text);
698
+ }
466
699
  note(id, 201, what);
467
700
  return send(200, { jsonrpc: '2.0', id: msg.id, result: { content: [{ type: 'text', text: JSON.stringify(done) }], structuredContent: done, isError: false } });
468
701
  } catch (error) {
469
702
  if (!(error instanceof Refusal)) throw error;
470
- /* Not given: refused as the API refuses it. Anything else is the tool's own failure, said to the caller. */
703
+ /* Not given: refused as the API refuses it (too many, too). Anything else is the tool's own failure, said to the caller. */
704
+ if (error.status === 429) { note(id, 429, what); return send(429, { jsonrpc: '2.0', id: msg.id, error: { code: -32002, message: error.message } }, { 'retry-after': String(error.retry ?? 60) }); }
471
705
  if (error.status === 403) { note(id, 403, what); return send(403, { jsonrpc: '2.0', id: msg.id, error: { code: -32001, message: error.message } }); }
472
706
  note(id, error.status, what);
473
707
  return send(200, { jsonrpc: '2.0', id: msg.id, result: { content: [{ type: 'text', text: error.message }], isError: true } });
@@ -477,7 +711,7 @@ export async function handleExternal(request, response, { keys, status, chats =
477
711
  }
478
712
  }
479
713
  note(null, 404);
480
- return send(404, { error: 'Not found. GET /api/ext/v1/status, /api/ext/v1/conversations, or MCP at /api/ext/mcp.' });
714
+ return send(404, { error: 'Not found. GET /api/ext/v1/status, /api/ext/v1/conversations, /api/ext/v1/rooms, /api/ext/v1/boxes, or MCP at /api/ext/mcp.' });
481
715
  } catch (error) {
482
716
  if (error instanceof Refusal) return refused(error);
483
717
  note(null, 500);
@@ -503,9 +737,9 @@ export async function handleApiKeys(request, response, keys, originOk, headers =
503
737
  try { body = text ? JSON.parse(text) : {}; } catch { return send(400, { error: 'The request is not JSON.' }); }
504
738
  if (!body || typeof body !== 'object') body = {};
505
739
  if (pathname === '/api/apikeys') {
506
- const made = keys.create({ label: body.label, scopes: body.scopes ?? [], conversations: body.conversations ?? {}, days: body.days === undefined ? DEFAULT_DAYS : body.days }, who);
740
+ const made = keys.create({ label: body.label, scopes: body.scopes ?? [], conversations: body.conversations ?? {}, rooms: body.rooms ?? {}, boxes: body.boxes ?? {}, days: body.days === undefined ? DEFAULT_DAYS : body.days }, who);
507
741
  /* Its name and scopes; never the secret. */
508
- const what = [...made.key.scopes, ...made.key.conversations.map((c) => `${c.name} ${c.level}`)];
742
+ const what = [...made.key.scopes, ...made.key.conversations.map((c) => `${c.name} ${c.level}`), ...made.key.rooms.map((r) => `room ${r.id.slice(0, 8)} ${r.level}`), ...made.key.boxes.map((b) => `voice box ${b.id.slice(0, 8)} ${b.level}`)];
509
743
  events?.add({ kind: 'access', text: `API key "${made.key.label}" made${what.length ? ` (${what.join(', ')})` : ' (no scope)'}`, who: who || null });
510
744
  return send(201, made);
511
745
  }
@@ -516,6 +750,15 @@ export async function handleApiKeys(request, response, keys, originOk, headers =
516
750
  events?.add({ kind: 'access', text: `API key "${key.label}": ${body.conversation} ${level ?? 'taken back'}`, who: who || null });
517
751
  return send(200, { key });
518
752
  }
753
+ const one = /^\/api\/apikeys\/([0-9a-f-]{36})\/(rooms|boxes)$/.exec(pathname);
754
+ if (one) {
755
+ const level = body.level === undefined ? null : body.level;
756
+ const room = one[2] === 'rooms';
757
+ const ref = room ? body.room : body.box;
758
+ const key = room ? keys.setRoom(one[1], ref, level) : keys.setBox(one[1], ref, level);
759
+ events?.add({ kind: 'access', text: `API key "${key.label}": ${room ? 'room' : 'voice box'} ${String(ref).slice(0, 8)} ${level ?? 'taken back'}`, who: who || null });
760
+ return send(200, { key });
761
+ }
519
762
  const revoke = /^\/api\/apikeys\/([0-9a-f-]{36})\/revoke$/.exec(pathname);
520
763
  if (revoke) {
521
764
  const label = keys.get(revoke[1])?.label;
package/server/boxes.mjs CHANGED
@@ -284,12 +284,12 @@ export function piecesOf(pcm, { least = 8, most = 14 } = {}) {
284
284
 
285
285
  export class Boxes {
286
286
  #file; #codes = new Map(); #links = new Map(); #tries = []; #now;
287
- #exists; #hear; #say; #note; #speak; #log; #changed; #images = new Map(); #origin; #spawn; #fetch; #streams = new Map(); #recordDir; #parts; #keep; #react; #learn; #collect; #stopSay = new Map();
287
+ #exists; #hear; #say; #note; #speak; #log; #removed; #changed; #images = new Map(); #origin; #spawn; #fetch; #streams = new Map(); #recordDir; #parts; #keep; #react; #learn; #collect; #stopSay = new Map();
288
288
 
289
289
  /* file: a JsonFile holding [{ id, name, conversation, keyHash, firmware, createdAt, seenAt }].
290
290
  exists(conversation), hear(wav) -> text, say(conversation, text, room), speak(text) -> PCM 16 kHz. */
291
- constructor({ file, exists = () => true, hear, say, note = null, speak, changed = () => {}, origin = () => null, now = () => Date.now(), log = () => {}, spawn = spawnProcess, fetcher = fetch, recordDir = null, parts = null, keep = null, react = async () => null, learn = null, collect = null }) {
292
- this.#react = react; this.#learn = learn; this.#collect = collect;
291
+ constructor({ file, removed = () => {}, exists = () => true, hear, say, note = null, speak, changed = () => {}, origin = () => null, now = () => Date.now(), log = () => {}, spawn = spawnProcess, fetcher = fetch, recordDir = null, parts = null, keep = null, react = async () => null, learn = null, collect = null }) {
292
+ this.#removed = removed; this.#react = react; this.#learn = learn; this.#collect = collect;
293
293
  this.#spawn = spawn; this.#fetch = fetcher; this.#recordDir = recordDir; this.#parts = parts; this.#keep = keep;
294
294
  this.#file = file; this.#changed = changed; this.#origin = origin; this.#exists = exists; this.#hear = hear; this.#say = say; this.#note = note; this.#speak = speak; this.#now = now; this.#log = log;
295
295
  }
@@ -302,7 +302,8 @@ export class Boxes {
302
302
 
303
303
  /* What the operator sees: never the key, nor its hash. */
304
304
  all() {
305
- return this.#all().map(({ keyHash, ...b }) => ({ ...b, online: this.#links.has(b.id), muted: this.#links.get(b.id)?.muted ?? null, volume: b.volume ?? this.#links.get(b.id)?.volume ?? VOLUME_DEFAULT }));
305
+ return this.#all().map(({ keyHash, ...b }) => { const l = this.#links.get(b.id);
306
+ return { ...b, online: Boolean(l), muted: l?.muted ?? null, volume: b.volume ?? l?.volume ?? VOLUME_DEFAULT, heardAt: l?.heardAt ? new Date(l.heardAt).toISOString() : null }; });
306
307
  }
307
308
 
308
309
  code(conversation, name, zone = '') {
@@ -436,6 +437,21 @@ export class Boxes {
436
437
  return { ok: true };
437
438
  }
438
439
 
440
+ /* SAID ALOUD FOR AN API KEY (apikeys.mjs, "speak"): a short text, as given -- never another assistant's call, the
441
+ box does not listen after, nothing of its settings touched. `by`: the key's signature, for the log. */
442
+ async announce(id, text, by = null) {
443
+ const link = this.#links.get(id);
444
+ if (!link) throw new Refusal(409, 'This voice box is not connected.');
445
+ /* A meeting being recorded is not spoken over. */
446
+ if (link.recording) throw new Refusal(409, 'This voice box is recording: not now.');
447
+ const words = forSpeech(text);
448
+ if (!words) throw new Refusal(400, 'Nothing to say.');
449
+ const pcm = await this.#speak(words);
450
+ link.play(pcm);
451
+ this.#log(`voice box ${link.box().name}: said aloud for the API key "${String(by?.name ?? '?').slice(0, 40)}"`);
452
+ return { ok: true, seconds: Math.round(pcm.length / 32_000) };
453
+ }
454
+
439
455
  /* LISTENING TO THE INTERNET: a radio, a podcast, a music file, a playlist, read by this server (ffmpeg,
440
456
  any format it knows) and sent on to the box at the pace it is played, a few seconds ahead -- until it
441
457
  ends, stop(), or the person presses the button or says the wake word (the box then starts a sentence). */
@@ -517,6 +533,7 @@ export class Boxes {
517
533
  this.#save(list.filter((b) => b.id !== id));
518
534
  this.#links.get(id)?.socket.close(4001, 'withdrawn');
519
535
  try { this.#learn?.forget(id); } catch { /* nothing kept */ }
536
+ try { this.#removed(id); } catch { /* the caller's */ }
520
537
  }
521
538
 
522
539
  /* THE WAKE WORD THAT LEARNS (wakelearn.mjs): "normal" keeps nothing, "learn" keeps each wake clip with
package/server/grants.mjs CHANGED
@@ -20,6 +20,8 @@
20
20
  * git see the files change them -
21
21
  * secret never - be handed to a conversation
22
22
  * server read its state - -
23
+ * room read its thread post in it -
24
+ * box read its state make it say a text aloud -
23
25
  *
24
26
  * Three kinds of subject: a CONVERSATION (what is attached to it reaches its
25
27
  * machine), a PAIRING (what a device was given by a code) and an API KEY
@@ -29,8 +31,11 @@
29
31
  * An API key is not a device and not a conversation: it is given only what
30
32
  * its scopes name -- "server:status" (read), and conversations one by one,
31
33
  * their chat to read ("r") or to talk in ("rw"), never their terminal ("x")
32
- * -- never a secret nor a folder in this version; and nothing else is ever
33
- * given "server:".
34
+ * -- rooms one by one, their thread to read ("r") or to post in ("rw"), and
35
+ * voice boxes one by one, their state ("r") or a short text said aloud
36
+ * ("rw": never to listen, record, set or update it) -- never a secret nor a
37
+ * folder in this version; nothing else is ever given "server:", "room:" or
38
+ * "box:".
34
39
  *
35
40
  * No groups, no inheritance. One rule sits on top, in the door: whoever was
36
41
  * given a conversation, its terminal or its chat, sees the files of that
@@ -49,9 +54,9 @@
49
54
  */
50
55
  import { Refusal } from './conversations.mjs';
51
56
 
52
- export const RIGHTS = Object.freeze({ conversation: 'rwx', folder: 'rw', mailbox: 'r', git: 'rw', secret: 'x', server: 'r' });
57
+ export const RIGHTS = Object.freeze({ conversation: 'rwx', folder: 'rw', mailbox: 'r', git: 'rw', secret: 'x', server: 'r', room: 'rw', box: 'rw' });
53
58
  const SUBJECT = /^(conversation:[a-z]([a-z0-9-]{0,30}[a-z0-9])?|(pairing|apikey):[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
54
- const ASSET = /^(conversation|folder|mailbox|git):[a-z]([a-z0-9-]{0,30}[a-z0-9])?$|^secret:[A-Z][A-Z0-9_]{0,39}$|^server:status$/;
59
+ const ASSET = /^(conversation|folder|mailbox|git):[a-z]([a-z0-9-]{0,30}[a-z0-9])?$|^secret:[A-Z][A-Z0-9_]{0,39}$|^server:status$|^(room|box):[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
55
60
 
56
61
  export const typeOf = (asset) => String(asset).split(':')[0];
57
62
  export const nameOf = (asset) => String(asset).slice(String(asset).indexOf(':') + 1);
@@ -98,7 +103,8 @@ export class Grants {
98
103
  /* The state of the server is given to an API key only; an API key is given only that, and
99
104
  conversations one by one -- their chat, to read or to write in, never their terminal. */
100
105
  if (typeOf(asset) === 'server' && !subject.startsWith('apikey:')) throw new Refusal(400, 'The state of the server is given to an API key only.');
101
- if (subject.startsWith('apikey:') && !['server', 'conversation'].includes(typeOf(asset))) throw new Refusal(400, 'An API key is given the state of the server and conversations, nothing else.');
106
+ if (['room', 'box'].includes(typeOf(asset)) && !subject.startsWith('apikey:')) throw new Refusal(400, 'A room or a voice box is given to an API key only.');
107
+ if (subject.startsWith('apikey:') && !['server', 'conversation', 'room', 'box'].includes(typeOf(asset))) throw new Refusal(400, 'An API key is given the state of the server, conversations, rooms and voice boxes, nothing else.');
102
108
  if (subject.startsWith('apikey:') && clean.includes('x')) throw new Refusal(400, 'An API key reads a conversation or talks in it, never uses its terminal.');
103
109
  this.#store.put({ name: this.#key(subject, asset), subject, asset, rights: clean });
104
110
  return clean;