clavarium 0.2.372-next.1 → 0.2.374-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.
- package/npm-shrinkwrap.json +6 -6
- package/package.json +2 -2
- package/server/apikeys.mjs +202 -25
- package/server/demandes.mjs +1 -1
- package/server/grants.mjs +20 -8
- package/server/main.mjs +21 -3
- package/server/settingswords.mjs +4 -3
- package/server/talk.mjs +2 -0
- package/web/app.js +8 -6
- package/web/index.html +3 -3
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.374-next.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "clavarium",
|
|
9
|
-
"version": "0.2.
|
|
9
|
+
"version": "0.2.374-next.1",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
|
13
13
|
"@xterm/xterm": "^6.0.0",
|
|
14
14
|
"clavarium-binder": "0.1.62-next.1",
|
|
15
15
|
"clavarium-docket": "0.1.2-next.2",
|
|
16
|
-
"clavarium-parley": "0.17.
|
|
16
|
+
"clavarium-parley": "0.17.87-next.1",
|
|
17
17
|
"clavarium-tether": "0.1.0-next.57",
|
|
18
18
|
"mammoth": "1.13.0",
|
|
19
19
|
"mermaid": "11.17.2",
|
|
@@ -787,9 +787,9 @@
|
|
|
787
787
|
}
|
|
788
788
|
},
|
|
789
789
|
"node_modules/clavarium-parley": {
|
|
790
|
-
"version": "0.17.
|
|
791
|
-
"resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.17.
|
|
792
|
-
"integrity": "sha512-
|
|
790
|
+
"version": "0.17.87-next.1",
|
|
791
|
+
"resolved": "https://registry.npmjs.org/clavarium-parley/-/clavarium-parley-0.17.87-next.1.tgz",
|
|
792
|
+
"integrity": "sha512-xp5kdNJPL+dGQq0zQXoSy5CCzoQkDFCbOkmvIiXC2JAOlZZg209B/d2IIUXMXHehiLG+uBLgFEvMwsoBIsqGPg==",
|
|
793
793
|
"license": "MIT",
|
|
794
794
|
"bin": {
|
|
795
795
|
"parley": "bin/parley.mjs",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clavarium",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.374-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.",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"@xterm/xterm": "^6.0.0",
|
|
47
47
|
"clavarium-binder": "0.1.62-next.1",
|
|
48
48
|
"clavarium-docket": "0.1.2-next.2",
|
|
49
|
-
"clavarium-parley": "0.17.
|
|
49
|
+
"clavarium-parley": "0.17.87-next.1",
|
|
50
50
|
"clavarium-tether": "0.1.0-next.57",
|
|
51
51
|
"mammoth": "1.13.0",
|
|
52
52
|
"mermaid": "11.17.2",
|
package/server/apikeys.mjs
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* read read its chat conversation:<name> r
|
|
15
15
|
* talk and post a message in it, as the chat does,
|
|
16
16
|
* signed with the key's name, never as the person conversation:<name> rw
|
|
17
|
+
* act and wake it, put it to sleep, stop its turn --
|
|
18
|
+
* as the owner's buttons do conversation:<name> rwm
|
|
17
19
|
* A conversation archived takes its lines with it (devices.forget). Its
|
|
18
20
|
* terminal ("x") is never given to a key.
|
|
19
21
|
*
|
|
@@ -22,6 +24,11 @@
|
|
|
22
24
|
* secrets masked) room:<id> r
|
|
23
25
|
* talk and post in it as the person does -- to its host, or to the AIs
|
|
24
26
|
* named -- signed with the key's name and look room:<id> rw
|
|
27
|
+
* manage and rename it, add or take away members, change
|
|
28
|
+
* its host, remove it -- its members only among the
|
|
29
|
+
* conversations and boxes the key was given room:<id> rwm
|
|
30
|
+
* create rooms make a room (its members as above): the room a key
|
|
31
|
+
* makes is given to it at "manage" server:rooms w
|
|
25
32
|
*
|
|
26
33
|
* a voice box, given ONE BY ONE:
|
|
27
34
|
* state online, where it speaks, when it last heard, its volume --
|
|
@@ -44,7 +51,14 @@
|
|
|
44
51
|
* never the secret, nor anything of it. Its own file, one JSON line each,
|
|
45
52
|
* cut to its newest half past JOURNAL_MAX. Each key is limited to
|
|
46
53
|
* RATE_PER_MIN calls a minute; calls with a key nobody has are limited too,
|
|
47
|
-
* all together, so that they cannot flood the journal.
|
|
54
|
+
* all together, so that they cannot flood the journal. What a key DOES (a
|
|
55
|
+
* conversation woken, a room made...) is said in What happened too, with
|
|
56
|
+
* its name.
|
|
57
|
+
*
|
|
58
|
+
* THE KEY'S RIGHTS ARE THE SECURITY (07-11, the owner): no approval card for
|
|
59
|
+
* what a key was given. Three limits hold whatever it was given: a key never
|
|
60
|
+
* reads a secret's value; never makes a key or an account; never changes its
|
|
61
|
+
* own rights or any key's -- no route under /api/ext/ reaches any of them.
|
|
48
62
|
*
|
|
49
63
|
* Who manages keys: whoever passes the gate (an administrator), from
|
|
50
64
|
* Settings, Access. A device (/c/) never reaches these routes; a key never
|
|
@@ -65,14 +79,20 @@
|
|
|
65
79
|
* GET /api/ext/v1/conversations those it may read: {conversations: [{id, name, level, chat}]}
|
|
66
80
|
* GET /api/ext/v1/conversations/<name>/messages ?last=1..200 (50) | ?since=<cursor>: {chat, messages, cursor}
|
|
67
81
|
* POST /api/ext/v1/conversations/<name>/messages {text}: posted in its chat, signed with the key's name
|
|
82
|
+
* POST /api/ext/v1/conversations/<name>/wake|sleep|stop (act) woken, put to sleep, its turn stopped (Escape)
|
|
68
83
|
* GET /api/ext/v1/rooms those it may read: {rooms: [{id, name, level, host, members}]}
|
|
69
84
|
* GET /api/ext/v1/rooms/<id>/messages as a conversation's: {messages, cursor}
|
|
70
85
|
* POST /api/ext/v1/rooms/<id>/messages {text, to?: [names of its AIs]}: posted in the room, signed with the key
|
|
86
|
+
* POST /api/ext/v1/rooms (create rooms) {name, conversations?: [ids], boxes?: [ids], host?: id}
|
|
87
|
+
* POST /api/ext/v1/rooms/<id> (manage) {name?, host?: id, add?: {conversations, boxes}, remove?: {conversations, boxes}}
|
|
88
|
+
* POST /api/ext/v1/rooms/<id>/remove (manage) the room removed
|
|
71
89
|
* GET /api/ext/v1/boxes {boxes: [{id, name, level, online, where, heardAt, volume}]}
|
|
72
90
|
* POST /api/ext/v1/boxes/<id>/say {text}: said aloud by the box
|
|
73
91
|
* POST /api/ext/mcp the same as MCP tools (clavarium_status, clavarium_conversations,
|
|
74
92
|
* clavarium_read, clavarium_say, clavarium_rooms, clavarium_room_read,
|
|
75
|
-
* clavarium_room_say, clavarium_boxes, clavarium_box_say
|
|
93
|
+
* clavarium_room_say, clavarium_boxes, clavarium_box_say, clavarium_conversation_wake,
|
|
94
|
+
* clavarium_conversation_sleep, clavarium_conversation_stop, clavarium_room_create,
|
|
95
|
+
* clavarium_room_change, clavarium_room_remove), JSON-RPC over HTTP
|
|
76
96
|
* A conversation, room or box not given, or not there, is the same 403: a key learns nothing of what it was not given.
|
|
77
97
|
*
|
|
78
98
|
* The front lets /api/ext/ through without sign-on, as it does /c/
|
|
@@ -102,16 +122,18 @@ const USED_SAVE_MS = 60_000;
|
|
|
102
122
|
export const EXT_PREFIX = '/api/ext/';
|
|
103
123
|
|
|
104
124
|
/* The scopes a key may be given: [asset, rights] in the table. */
|
|
105
|
-
export const SCOPES = Object.freeze({ status: { asset: 'server:status', rights: 'r', label: 'Its state (memory, disk, versions)' }
|
|
125
|
+
export const SCOPES = Object.freeze({ status: { asset: 'server:status', rights: 'r', label: 'Its state (memory, disk, versions)' },
|
|
126
|
+
'create-rooms': { asset: 'server:rooms', rights: 'w', label: 'Create rooms' } });
|
|
106
127
|
/* What a key may do in one conversation: [rights in the table]. */
|
|
107
|
-
export const LEVELS = Object.freeze({ read: 'r', talk: 'rw' });
|
|
108
|
-
export const levelOf = (rights) => (String(rights).includes('w') ? 'talk' : String(rights).includes('r') ? 'read' : null);
|
|
128
|
+
export const LEVELS = Object.freeze({ read: 'r', talk: 'rw', act: 'rwm' });
|
|
129
|
+
export const levelOf = (rights) => (String(rights).includes('m') ? 'act' : String(rights).includes('w') ? 'talk' : String(rights).includes('r') ? 'read' : null);
|
|
109
130
|
/* The messages one read gives, by default and at most. */
|
|
110
131
|
export const READ_DEFAULT = 50;
|
|
111
132
|
export const READ_MAX = 200;
|
|
112
133
|
export const SAY_MAX = 8000;
|
|
113
134
|
/* 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' });
|
|
135
|
+
export const ROOM_LEVELS = Object.freeze({ read: 'r', talk: 'rw', manage: 'rwm' });
|
|
136
|
+
export const roomLevelOf = (rights) => (String(rights).includes('m') ? 'manage' : String(rights).includes('w') ? 'talk' : String(rights).includes('r') ? 'read' : null);
|
|
115
137
|
export const BOX_LEVELS = Object.freeze({ state: 'r', speak: 'rw' });
|
|
116
138
|
export const boxLevelOf = (rights) => (String(rights).includes('w') ? 'speak' : String(rights).includes('r') ? 'state' : null);
|
|
117
139
|
/* Said aloud in the home: short, and a few a minute (per key and box). */
|
|
@@ -135,7 +157,7 @@ export class ApiKeys {
|
|
|
135
157
|
this.#store = store; this.#grants = grants; this.#journal = journal; this.#now = now; this.#exists = exists;
|
|
136
158
|
/* What is given one by one: its asset type, its levels, a valid name, whether it is there, its words. */
|
|
137
159
|
this.#kinds = {
|
|
138
|
-
room: { levels: ROOM_LEVELS, of:
|
|
160
|
+
room: { levels: ROOM_LEVELS, of: roomLevelOf, valid: (v) => typeof v === 'string' && UUID.test(v), exists: roomExists, what: 'room', the: 'a room' },
|
|
139
161
|
box: { levels: BOX_LEVELS, of: boxLevelOf, valid: (v) => typeof v === 'string' && UUID.test(v), exists: boxExists, what: 'voice box', the: 'a voice box' },
|
|
140
162
|
};
|
|
141
163
|
}
|
|
@@ -187,7 +209,7 @@ export class ApiKeys {
|
|
|
187
209
|
|
|
188
210
|
#conversationAsked(name, level) {
|
|
189
211
|
if (!isConversationName(name) || !this.#exists(name)) throw new Refusal(404, 'No such conversation.');
|
|
190
|
-
if (level !== null && !Object.hasOwn(LEVELS, level)) throw new Refusal(400, `A conversation is given to read or to
|
|
212
|
+
if (level !== null && !Object.hasOwn(LEVELS, level)) throw new Refusal(400, `A conversation is given to read, to talk or to act: ${Object.keys(LEVELS).join(', ')}.`);
|
|
191
213
|
}
|
|
192
214
|
|
|
193
215
|
/* One conversation given to a key, its level changed, or taken back (level null). */
|
|
@@ -282,6 +304,7 @@ export class ApiKeys {
|
|
|
282
304
|
const rights = isConversationName(name) ? this.#grants.rightsOf(apiKeySubject(id), `conversation:${name}`) : '';
|
|
283
305
|
if (!rights.includes('r')) throw Object.assign(new Refusal(403, 'This key was not given this conversation.'), { key: id });
|
|
284
306
|
if (level === 'talk' && !rights.includes('w')) throw Object.assign(new Refusal(403, 'This key may read this conversation, not talk in it.'), { key: id });
|
|
307
|
+
if (level === 'act' && !rights.includes('m')) throw Object.assign(new Refusal(403, 'This key may not act on this conversation (wake, sleep, stop).'), { key: id });
|
|
285
308
|
return levelOf(rights);
|
|
286
309
|
}
|
|
287
310
|
|
|
@@ -291,7 +314,17 @@ export class ApiKeys {
|
|
|
291
314
|
const rights = typeof room === 'string' && UUID.test(room) && this.#kinds.room.exists(room) ? this.#grants.rightsOf(apiKeySubject(id), `room:${room}`) : '';
|
|
292
315
|
if (!rights.includes('r')) throw Object.assign(new Refusal(403, 'This key was not given this room.'), { key: id });
|
|
293
316
|
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
|
-
|
|
317
|
+
if (level === 'manage' && !rights.includes('m')) throw Object.assign(new Refusal(403, 'This key may not manage this room.'), { key: id });
|
|
318
|
+
return roomLevelOf(rights);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/* A ROOM THE KEY MADE ITSELF (with "create rooms") is given to it at "manage". This is the one right a key ever
|
|
322
|
+
gets on its own, and it is not a key changing its rights: it is scoped to what it created, and nothing else
|
|
323
|
+
of this key or any other changes -- a room made by another, a conversation, a box, a scope stay as they were. */
|
|
324
|
+
madeRoom(id, room) {
|
|
325
|
+
if (!this.may(id, 'create-rooms')) throw Object.assign(new Refusal(403, `This key was not given the scope "${SCOPES['create-rooms'].label}".`), { key: id });
|
|
326
|
+
if (typeof room !== 'string' || !UUID.test(room) || !this.#kinds.room.exists(room)) throw new Refusal(404, 'No such room.');
|
|
327
|
+
this.#grants.give(apiKeySubject(id), `room:${room}`, ROOM_LEVELS.manage);
|
|
295
328
|
}
|
|
296
329
|
/* Whether a key may see the state of ('state') or make speak ('speak') one voice box; 403 if not. */
|
|
297
330
|
mayBox(id, box, level = 'state') {
|
|
@@ -437,7 +470,7 @@ export const STATUS_TOOL = {
|
|
|
437
470
|
export const CONVERSATIONS_TOOL = {
|
|
438
471
|
name: 'clavarium_conversations',
|
|
439
472
|
title: 'Conversations of this key',
|
|
440
|
-
description: 'The conversations this key was given, each with its id, name, level (read or
|
|
473
|
+
description: 'The conversations this key was given, each with its id, name, level (read, talk or act) and whether its chat is on.',
|
|
441
474
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
442
475
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
443
476
|
};
|
|
@@ -460,7 +493,7 @@ const BOX_ARG = { type: 'string', description: 'The voice box\'s id, as clavariu
|
|
|
460
493
|
export const ROOMS_TOOL = {
|
|
461
494
|
name: 'clavarium_rooms',
|
|
462
495
|
title: 'Rooms of this key',
|
|
463
|
-
description: 'The rooms this key was given, each with its id, name, level (read or
|
|
496
|
+
description: 'The rooms this key was given, each with its id, name, level (read, talk or manage), its host and its members by name (with their id when this key was given them).',
|
|
464
497
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
465
498
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
466
499
|
};
|
|
@@ -492,15 +525,52 @@ export const BOX_SAY_TOOL = {
|
|
|
492
525
|
inputSchema: { type: 'object', properties: { box: BOX_ARG, text: { type: 'string', minLength: 1, maxLength: SPEAK_MAX } }, required: ['box', 'text'], additionalProperties: false },
|
|
493
526
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
494
527
|
};
|
|
495
|
-
|
|
528
|
+
/* To act on a conversation (level "act"), and to manage rooms (level "manage", or "create rooms"). */
|
|
529
|
+
const actTool = (act, title, description, destructive) => ({
|
|
530
|
+
name: `clavarium_conversation_${act}`, title, description: `${description} The conversation must have been given to this key to act.`,
|
|
531
|
+
inputSchema: { type: 'object', properties: { conversation: CONVERSATION_ARG }, required: ['conversation'], additionalProperties: false },
|
|
532
|
+
annotations: { readOnlyHint: false, destructiveHint: destructive, idempotentHint: true, openWorldHint: false },
|
|
533
|
+
});
|
|
534
|
+
export const WAKE_TOOL = actTool('wake', 'Wake a conversation', 'Wake one conversation\'s machine (asleep: it starts again; awake: nothing changes).', false);
|
|
535
|
+
export const SLEEP_TOOL = actTool('sleep', 'Put a conversation to sleep', 'Put one conversation\'s machine to sleep, as the owner\'s Sleep button does: its files and chat are kept.', false);
|
|
536
|
+
export const STOP_TOOL = actTool('stop', 'Stop a conversation\'s turn', 'Stop what one conversation\'s AI is doing now (its current turn, as Escape does); the conversation stays awake.', true);
|
|
537
|
+
const IDS = { type: 'array', items: { type: 'string' }, maxItems: 40 };
|
|
538
|
+
const MEMBERS_ARG = { type: 'object', properties: { conversations: { ...IDS, description: 'Conversation ids, as clavarium_conversations gives them.' }, boxes: { ...IDS, description: 'Voice box ids, as clavarium_boxes gives them.' } }, additionalProperties: false };
|
|
539
|
+
export const ROOM_CREATE_TOOL = {
|
|
540
|
+
name: 'clavarium_room_create',
|
|
541
|
+
title: 'Create a room',
|
|
542
|
+
description: 'Make a room with conversations (AIs) and voice boxes this key was given; one conversation at least, its host (the first by default). The room is then given to this key to manage. Needs the scope "Create rooms".',
|
|
543
|
+
inputSchema: { type: 'object', properties: { name: { type: 'string', minLength: 1, maxLength: 60 }, conversations: { ...IDS, minItems: 1, description: 'Conversation ids, as clavarium_conversations gives them.' }, boxes: { ...IDS, description: 'Voice box ids, as clavarium_boxes gives them.' }, host: CONVERSATION_ARG }, required: ['name', 'conversations'], additionalProperties: false },
|
|
544
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
545
|
+
};
|
|
546
|
+
export const ROOM_CHANGE_TOOL = {
|
|
547
|
+
name: 'clavarium_room_change',
|
|
548
|
+
title: 'Change a room',
|
|
549
|
+
description: 'Rename a room, add or take away members (only conversations and voice boxes this key was given), or change its host (one of its conversations). The room must have been given to this key to manage.',
|
|
550
|
+
inputSchema: { type: 'object', properties: { room: ROOM_ARG, name: { type: 'string', minLength: 1, maxLength: 60 }, host: CONVERSATION_ARG, add: MEMBERS_ARG, remove: MEMBERS_ARG }, required: ['room'], additionalProperties: false },
|
|
551
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
552
|
+
};
|
|
553
|
+
export const ROOM_REMOVE_TOOL = {
|
|
554
|
+
name: 'clavarium_room_remove',
|
|
555
|
+
title: 'Remove a room',
|
|
556
|
+
description: 'Remove one room and its thread, for good. The room must have been given to this key to manage.',
|
|
557
|
+
inputSchema: { type: 'object', properties: { room: ROOM_ARG }, required: ['room'], additionalProperties: false },
|
|
558
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
559
|
+
};
|
|
560
|
+
export const TOOLS = [STATUS_TOOL, CONVERSATIONS_TOOL, READ_TOOL, SAY_TOOL, WAKE_TOOL, SLEEP_TOOL, STOP_TOOL, ROOMS_TOOL, ROOM_READ_TOOL, ROOM_SAY_TOOL,
|
|
561
|
+
ROOM_CREATE_TOOL, ROOM_CHANGE_TOOL, ROOM_REMOVE_TOOL, BOXES_TOOL, BOX_SAY_TOOL];
|
|
496
562
|
|
|
497
563
|
/* Returns true when the request was under /api/ext/ (answered here). `status()`: the state, as statusOf
|
|
498
564
|
makes it. `chats`: { list() -> [{ id, name }], read(name, part) -> talk.of's answer, chatOn(name),
|
|
499
565
|
say(name, text, by) -> { told } }. `rooms`: { list() -> [{ id, name, host, members: [{ name, kind }] }],
|
|
500
566
|
read(id, part) -> rooms.read's answer, say(id, text, { as, to }) }. `boxes`: { list() -> [{ id, name, online, where,
|
|
501
567
|
heardAt, volume }], say(id, text, by) -> { seconds? } }. `originOk(headers)`: whether an Origin is this site's (gate.mjs); a
|
|
502
|
-
call from a page of another site is refused.
|
|
503
|
-
|
|
568
|
+
call from a page of another site is refused.
|
|
569
|
+
To act (07-11): `chats` also { wake(name) -> { state }, sleep(name), stop(name) } -- the owner's own calls; `rooms` also
|
|
570
|
+
{ create({ name, conversations, boxes, host }) -> { id }, change(id, { name, host, add, remove }) -- by member ids, as the
|
|
571
|
+
page asks --, remove(id) }, and its list's members carry `ref` (a conversation's name, a box's id) and `id` (the member's).
|
|
572
|
+
`events`: What happened (events.mjs), where each act is said with the key's name. */
|
|
573
|
+
export async function handleExternal(request, response, { keys, status, chats = null, rooms = null, boxes = null, events = null, headers = {}, originOk = () => false, version = null }) {
|
|
504
574
|
const url = new URL(request.url ?? '/', 'http://local');
|
|
505
575
|
const { pathname } = url;
|
|
506
576
|
if (!pathname.startsWith(EXT_PREFIX) && pathname !== EXT_PREFIX.slice(0, -1)) return false;
|
|
@@ -546,8 +616,11 @@ export async function handleExternal(request, response, { keys, status, chats =
|
|
|
546
616
|
/* The same for rooms and voice boxes. */
|
|
547
617
|
const roomsListed = (id) => {
|
|
548
618
|
const there = new Map((rooms?.list() ?? []).map((r) => [r.id, r]));
|
|
619
|
+
/* A member's id only when the key was given it (a conversation's, a box's): what it may add or take away. */
|
|
620
|
+
const convs = new Set(keys.conversationsOf(id).map((c) => c.name)); const bs = new Set(keys.boxesOf(id).map((b) => b.id));
|
|
621
|
+
const mine = (m) => (m.kind === 'ai' && convs.has(m.ref)) || (m.kind === 'box' && bs.has(m.ref));
|
|
549
622
|
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 ?? '') })) }; }) };
|
|
623
|
+
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 ?? ''), ...(typeof m.ref === 'string' && mine(m) ? { id: m.ref } : {}) })) }; }) };
|
|
551
624
|
};
|
|
552
625
|
const roomKnown = (room) => { if (!(rooms?.list() ?? []).some((r) => r.id === room)) throw new Refusal(403, 'This key was not given this room.'); };
|
|
553
626
|
const roomReading = (id, room, part) => {
|
|
@@ -577,6 +650,95 @@ export async function handleExternal(request, response, { keys, status, chats =
|
|
|
577
650
|
const done = await boxes.say(box, text, keys.signature(id));
|
|
578
651
|
return { ok: true, ...(Number.isFinite(done?.seconds) ? { seconds: done.seconds } : {}) };
|
|
579
652
|
};
|
|
653
|
+
/* SAID IN WHAT HAPPENED, with the key's name: what a key did, never what it read. */
|
|
654
|
+
const said = (id, kind, text, conversation = null) => {
|
|
655
|
+
const label = keys.signature(id)?.name ?? '';
|
|
656
|
+
try { events?.add({ kind, text, ...(conversation ? { conversation } : {}), who: `API key "${label}"` }); } catch { /* never in the way */ }
|
|
657
|
+
};
|
|
658
|
+
/* ACT on a conversation (level "act"): the owner's own calls -- woken, put to sleep, its turn stopped. */
|
|
659
|
+
const ACTS = { wake: 'woken', sleep: 'put to sleep', stop: 'its turn stopped' };
|
|
660
|
+
const acting = async (id, name, act) => {
|
|
661
|
+
keys.mayConversation(id, name, 'act'); known(name);
|
|
662
|
+
if (typeof chats?.[act] !== 'function') throw new Refusal(409, 'Not possible here.');
|
|
663
|
+
const done = await chats[act](name);
|
|
664
|
+
said(id, act === 'stop' ? 'conversation' : 'machine', act === 'stop' ? `${name}: its turn stopped` : `Machine of ${name} ${ACTS[act]}`, name);
|
|
665
|
+
return { ok: true, ...(typeof done?.state === 'string' ? { state: done.state } : {}) };
|
|
666
|
+
};
|
|
667
|
+
/* MANAGE a room (level "manage"), or make one ("create rooms"). Its members are named as the key knows them --
|
|
668
|
+
a conversation by its id, a box by its id -- and only among those this key was given (at any level): else 403,
|
|
669
|
+
the same for one not there. The body takes these fields and no other: never a level, a right, a key. */
|
|
670
|
+
const refs = (value, what) => {
|
|
671
|
+
if (value === undefined || value === null) return [];
|
|
672
|
+
if (!Array.isArray(value) || value.length > 40 || value.some((x) => typeof x !== 'string')) throw new Refusal(400, `${what}: a list of ids.`);
|
|
673
|
+
return [...new Set(value)];
|
|
674
|
+
};
|
|
675
|
+
const sides = (value, what) => {
|
|
676
|
+
if (value === undefined || value === null) return { conversations: [], boxes: [] };
|
|
677
|
+
if (typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some((k) => !['conversations', 'boxes'].includes(k))) throw new Refusal(400, `${what}: {conversations: [ids], boxes: [ids]}.`);
|
|
678
|
+
return { conversations: refs(value.conversations, `${what}.conversations`), boxes: refs(value.boxes, `${what}.boxes`) };
|
|
679
|
+
};
|
|
680
|
+
const onlyFields = (body, fields) => {
|
|
681
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Refusal(400, 'Send JSON: an object.');
|
|
682
|
+
const other = Object.keys(body).filter((k) => !fields.includes(k));
|
|
683
|
+
if (other.length) throw new Refusal(400, `Not a field here: ${other[0].slice(0, 40)}. Only: ${fields.join(', ')}.`);
|
|
684
|
+
};
|
|
685
|
+
const givenMembers = (id, { conversations, boxes: bs }) => {
|
|
686
|
+
const convs = new Set(keys.conversationsOf(id).map((c) => c.name));
|
|
687
|
+
const there = new Set((chats?.list() ?? []).map((c) => c.id));
|
|
688
|
+
for (const c of conversations) if (!convs.has(c) || !there.has(c)) throw Object.assign(new Refusal(403, 'This key was not given this conversation.'), { key: id });
|
|
689
|
+
const given = new Set(keys.boxesOf(id).map((b) => b.id));
|
|
690
|
+
const boxesThere = new Set((boxes?.list() ?? []).map((b) => b.id));
|
|
691
|
+
for (const b of bs) if (!given.has(b) || !boxesThere.has(b)) throw Object.assign(new Refusal(403, 'This key was not given this voice box.'), { key: id });
|
|
692
|
+
};
|
|
693
|
+
const roomShown = (id, room) => roomsListed(id).rooms.find((r) => r.id === room) ?? null;
|
|
694
|
+
const roomCreating = (id, body) => {
|
|
695
|
+
if (!keys.may(id, 'create-rooms')) throw Object.assign(new Refusal(403, `This key was not given the scope "${SCOPES['create-rooms'].label}".`), { key: id });
|
|
696
|
+
onlyFields(body, ['name', 'conversations', 'boxes', 'host']);
|
|
697
|
+
const members = { conversations: refs(body.conversations, 'conversations'), boxes: refs(body.boxes, 'boxes') };
|
|
698
|
+
if (body.host !== undefined && body.host !== null && (typeof body.host !== 'string' || !members.conversations.includes(body.host))) throw new Refusal(400, 'host: one of its conversations.');
|
|
699
|
+
givenMembers(id, members);
|
|
700
|
+
if (typeof rooms?.create !== 'function') throw new Refusal(409, 'Not possible here.');
|
|
701
|
+
const made = rooms.create({ name: body.name, conversations: members.conversations, boxes: members.boxes, ...(body.host ? { host: body.host } : {}) });
|
|
702
|
+
/* The one right a key gets on its own: the room it made, at "manage" (see madeRoom). */
|
|
703
|
+
keys.madeRoom(id, made.id);
|
|
704
|
+
const shown = roomShown(id, made.id);
|
|
705
|
+
said(id, 'conversation', `Room "${String(shown?.name ?? made.name ?? '').slice(0, 60)}" made`);
|
|
706
|
+
return { ok: true, room: shown };
|
|
707
|
+
};
|
|
708
|
+
const roomChanging = (id, room, body) => {
|
|
709
|
+
keys.mayRoom(id, room, 'manage'); roomKnown(room);
|
|
710
|
+
onlyFields(body, ['name', 'host', 'add', 'remove']);
|
|
711
|
+
const add = sides(body.add, 'add'); const remove = sides(body.remove, 'remove');
|
|
712
|
+
if (body.host !== undefined && body.host !== null && typeof body.host !== 'string') throw new Refusal(400, 'host: the id of one of its conversations.');
|
|
713
|
+
const host = typeof body.host === 'string' ? body.host : null;
|
|
714
|
+
givenMembers(id, { conversations: [...add.conversations, ...remove.conversations, ...(host ? [host] : [])], boxes: [...add.boxes, ...remove.boxes] });
|
|
715
|
+
const before = rooms.list().find((r) => r.id === room);
|
|
716
|
+
const members = Array.isArray(before?.members) ? before.members : [];
|
|
717
|
+
const member = (kind, ref) => members.find((m) => m.kind === kind && m.ref === ref);
|
|
718
|
+
const gone = [...remove.conversations.map((c) => ['ai', c]), ...remove.boxes.map((b) => ['box', b])].map(([kind, ref]) => {
|
|
719
|
+
const m = member(kind, ref); if (!m) throw new Refusal(400, 'Not a member of this room.'); return m.id; });
|
|
720
|
+
/* The host: one of its AIs once the change is made (one added now, too). */
|
|
721
|
+
if (host && !(member('ai', host) && !remove.conversations.includes(host)) && !add.conversations.includes(host)) throw new Refusal(400, 'host: one of its conversations.');
|
|
722
|
+
const name = body.name === undefined || body.name === null ? undefined : String(body.name);
|
|
723
|
+
rooms.change(room, { ...(name !== undefined ? { name } : {}), add, remove: gone });
|
|
724
|
+
if (host) {
|
|
725
|
+
const now = rooms.list().find((r) => r.id === room)?.members ?? [];
|
|
726
|
+
const m = now.find((x) => x.kind === 'ai' && x.ref === host);
|
|
727
|
+
if (m && !m.host) rooms.change(room, { host: m.id });
|
|
728
|
+
}
|
|
729
|
+
const shown = roomShown(id, room);
|
|
730
|
+
const what = [...(name !== undefined ? [`renamed "${String(shown?.name ?? name).slice(0, 60)}"`] : []), ...(add.conversations.length + add.boxes.length ? [`${add.conversations.length + add.boxes.length} added`] : []),
|
|
731
|
+
...(gone.length ? [`${gone.length} taken away`] : []), ...(host ? [`host ${host}`] : [])];
|
|
732
|
+
said(id, 'conversation', `Room "${String(before?.name ?? '').slice(0, 60)}" changed${what.length ? `: ${what.join(', ')}` : ''}`);
|
|
733
|
+
return { ok: true, room: shown };
|
|
734
|
+
};
|
|
735
|
+
const roomRemoving = (id, room) => {
|
|
736
|
+
keys.mayRoom(id, room, 'manage'); roomKnown(room);
|
|
737
|
+
const name = rooms.list().find((r) => r.id === room)?.name ?? '';
|
|
738
|
+
rooms.remove(room);
|
|
739
|
+
said(id, 'conversation', `Room "${String(name).slice(0, 60)}" removed`);
|
|
740
|
+
return { ok: true, removed: room };
|
|
741
|
+
};
|
|
580
742
|
const ownError = (error, id) => (error instanceof Refusal ? Object.assign(error, { key: error.key ?? id }) : error);
|
|
581
743
|
try {
|
|
582
744
|
if (route === '/v1/status') {
|
|
@@ -587,9 +749,9 @@ export async function handleExternal(request, response, { keys, status, chats =
|
|
|
587
749
|
note(id, 200);
|
|
588
750
|
return send(200, body);
|
|
589
751
|
}
|
|
590
|
-
const one = /^\/v1\/conversations(?:\/([^/]+)\/messages)?$/.exec(route);
|
|
752
|
+
const one = /^\/v1\/conversations(?:\/([^/]+)\/(messages|wake|sleep|stop))?$/.exec(route);
|
|
591
753
|
if (one) {
|
|
592
|
-
const allowed = one[1] ? 'GET, POST' : 'GET';
|
|
754
|
+
const allowed = one[2] && one[2] !== 'messages' ? 'POST' : one[1] ? 'GET, POST' : 'GET';
|
|
593
755
|
if (!allowed.split(', ').includes(request.method)) return send(405, { error: `Call this address with ${allowed}.` }, { allow: allowed });
|
|
594
756
|
let id;
|
|
595
757
|
try { id = keys.check(request.headers.authorization); } catch (error) { return refused(error); }
|
|
@@ -597,6 +759,7 @@ export async function handleExternal(request, response, { keys, status, chats =
|
|
|
597
759
|
if (!one[1]) { const body = listed(id); note(id, 200); return send(200, body); }
|
|
598
760
|
let name;
|
|
599
761
|
try { name = decodeURIComponent(one[1]); } catch { name = ''; }
|
|
762
|
+
if (one[2] !== 'messages') { const done = await acting(id, name, one[2]); const code = one[2] === 'wake' ? 202 : 200; note(id, code); return send(code, done); }
|
|
600
763
|
if (request.method === 'GET') { const body = reading(id, name, readPart((k) => url.searchParams.get(k))); note(id, 200); return send(200, body); }
|
|
601
764
|
if (!String(request.headers['content-type'] ?? '').startsWith('application/json')) throw new Refusal(415, 'Send JSON: {"text": "..."}.');
|
|
602
765
|
let body;
|
|
@@ -606,17 +769,26 @@ export async function handleExternal(request, response, { keys, status, chats =
|
|
|
606
769
|
return send(201, done);
|
|
607
770
|
} catch (error) { return refused(ownError(error, id)); }
|
|
608
771
|
}
|
|
609
|
-
const room = /^\/v1\/rooms(?:\/([^/]+)\/messages)?$/.exec(route);
|
|
772
|
+
const room = /^\/v1\/rooms(?:\/([^/]+)(\/messages|\/remove)?)?$/.exec(route);
|
|
610
773
|
if (room) {
|
|
611
|
-
const allowed = room[1] ? 'GET, POST' : '
|
|
774
|
+
const allowed = room[2] === '/messages' || !room[1] ? 'GET, POST' : 'POST';
|
|
612
775
|
if (!allowed.split(', ').includes(request.method)) return send(405, { error: `Call this address with ${allowed}.` }, { allow: allowed });
|
|
613
776
|
let id;
|
|
614
777
|
try { id = keys.check(request.headers.authorization); } catch (error) { return refused(error); }
|
|
615
778
|
try {
|
|
616
|
-
if (!room[1]) { const body = roomsListed(id); note(id, 200); return send(200, body); }
|
|
779
|
+
if (!room[1] && request.method === 'GET') { const body = roomsListed(id); note(id, 200); return send(200, body); }
|
|
617
780
|
let ref;
|
|
618
|
-
try { ref = decodeURIComponent(room[1]); } catch { ref = ''; }
|
|
781
|
+
try { ref = decodeURIComponent(room[1] ?? ''); } catch { ref = ''; }
|
|
782
|
+
if (room[2] === '/remove') { const done = roomRemoving(id, ref); note(id, 200); return send(200, done); }
|
|
619
783
|
if (request.method === 'GET') { const body = roomReading(id, ref, readPart((k) => url.searchParams.get(k))); note(id, 200); return send(200, body); }
|
|
784
|
+
if (room[2] !== '/messages') {
|
|
785
|
+
if (!String(request.headers['content-type'] ?? '').startsWith('application/json')) throw new Refusal(415, 'Send JSON.');
|
|
786
|
+
let body;
|
|
787
|
+
try { body = JSON.parse(await bodyOf(16_384)); } catch (error) { if (error instanceof Refusal) throw error; throw new Refusal(400, 'The request is not JSON.'); }
|
|
788
|
+
const done = room[1] ? roomChanging(id, ref, body) : roomCreating(id, body);
|
|
789
|
+
const code = room[1] ? 200 : 201;
|
|
790
|
+
note(id, code); return send(code, done);
|
|
791
|
+
}
|
|
620
792
|
if (!String(request.headers['content-type'] ?? '').startsWith('application/json')) throw new Refusal(415, 'Send JSON: {"text": "..."}.');
|
|
621
793
|
let body;
|
|
622
794
|
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.'); }
|
|
@@ -668,7 +840,7 @@ export async function handleExternal(request, response, { keys, status, chats =
|
|
|
668
840
|
const asked = String(msg.params?.protocolVersion ?? '');
|
|
669
841
|
return answer({ protocolVersion: MCP_VERSIONS.includes(asked) ? asked : MCP_VERSIONS[0], capabilities: { tools: { listChanged: false } },
|
|
670
842
|
serverInfo: { name: 'clavarium', title: 'Clavarium', version: version ?? '0' },
|
|
671
|
-
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).' });
|
|
843
|
+
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). Where it was given more: a conversation woken, put to sleep, its turn stopped (clavarium_conversation_wake, _sleep, _stop); rooms made, changed, removed (clavarium_room_create, clavarium_room_change, clavarium_room_remove).' });
|
|
672
844
|
}
|
|
673
845
|
case 'ping': return answer({});
|
|
674
846
|
case 'tools/list': return answer({ tools: TOOLS });
|
|
@@ -685,17 +857,22 @@ export async function handleExternal(request, response, { keys, status, chats =
|
|
|
685
857
|
if (name === BOXES_TOOL.name) return result(boxesListed(id));
|
|
686
858
|
const part = () => readPart((k) => (args[k] === undefined ? null : typeof args[k] === 'number' ? String(args[k]) : args[k]));
|
|
687
859
|
let done;
|
|
688
|
-
if (name ===
|
|
860
|
+
if (name === ROOM_CREATE_TOOL.name) {
|
|
861
|
+
done = roomCreating(id, args);
|
|
862
|
+
} else if (name === ROOM_READ_TOOL.name || name === ROOM_SAY_TOOL.name || name === ROOM_CHANGE_TOOL.name || name === ROOM_REMOVE_TOOL.name) {
|
|
689
863
|
if (typeof args.room !== 'string') return fail(-32602, 'room: the id of a room.');
|
|
690
864
|
if (name === ROOM_READ_TOOL.name) return result(roomReading(id, args.room, part()));
|
|
691
|
-
done =
|
|
865
|
+
if (name === ROOM_CHANGE_TOOL.name) { const { room: target, ...change } = args; done = roomChanging(id, target, change); }
|
|
866
|
+
else if (name === ROOM_REMOVE_TOOL.name) done = roomRemoving(id, args.room);
|
|
867
|
+
else done = await roomSaying(id, args.room, args.text, args.to);
|
|
692
868
|
} else if (name === BOX_SAY_TOOL.name) {
|
|
693
869
|
if (typeof args.box !== 'string') return fail(-32602, 'box: the id of a voice box.');
|
|
694
870
|
done = await boxSaying(id, args.box, args.text);
|
|
695
871
|
} else {
|
|
696
872
|
if (typeof args.conversation !== 'string') return fail(-32602, 'conversation: the id of a conversation.');
|
|
697
873
|
if (name === READ_TOOL.name) return result(reading(id, args.conversation, part()));
|
|
698
|
-
|
|
874
|
+
const act = { [WAKE_TOOL.name]: 'wake', [SLEEP_TOOL.name]: 'sleep', [STOP_TOOL.name]: 'stop' }[name];
|
|
875
|
+
done = act ? await acting(id, args.conversation, act) : await saying(id, args.conversation, args.text);
|
|
699
876
|
}
|
|
700
877
|
note(id, 201, what);
|
|
701
878
|
return send(200, { jsonrpc: '2.0', id: msg.id, result: { content: [{ type: 'text', text: JSON.stringify(done) }], structuredContent: done, isError: false } });
|
package/server/demandes.mjs
CHANGED
|
@@ -34,7 +34,7 @@ const plain = (body) => String(body ?? '').replace(/\*\*(\S[^*\n]*?)\*\*/g, '$1'
|
|
|
34
34
|
|
|
35
35
|
/* A CALL OF AN EXTERNAL MCP SERVER'S TOOL (Tether's card { kind: 'mcp', action: 'call', server, tool, title?, class, open?,
|
|
36
36
|
* fresh?, args }, 07-11): which server, which tool, its class, its arguments in short, the AI's why. Parley has no card
|
|
37
|
-
* of this kind
|
|
37
|
+
* of this kind (its words since 0.17.87): drawn as its generic card (kind 'other'), each word with its English (`t`), which an
|
|
38
38
|
* older or unknowing screen says as it is. A tool that deletes or overwrites: irreversible (amber, never in Accept all). */
|
|
39
39
|
export const MCP_CLASS_SAID = { read: 'Reads only', write: 'Writes', destructive: 'Deletes or overwrites' };
|
|
40
40
|
const MCP_ARGS_MAX = 6; const MCP_ARG_MAX = 160;
|
package/server/grants.mjs
CHANGED
|
@@ -19,17 +19,23 @@
|
|
|
19
19
|
* mailbox read the messages (send, file: not built) -
|
|
20
20
|
* git see the files change them -
|
|
21
21
|
* secret never - be handed to a conversation
|
|
22
|
-
* server read its state -
|
|
22
|
+
* server read its state make rooms (API keys) -
|
|
23
23
|
* room read its thread post in it -
|
|
24
24
|
* box read its state make it say a text aloud -
|
|
25
25
|
*
|
|
26
|
+
* And a fourth letter, for an API key only (07-11): "m", to act on or manage
|
|
27
|
+
* what it was given -- a conversation woken, put to sleep, its turn stopped;
|
|
28
|
+
* a room renamed, its members and its host changed, removed. "m" brings "rw"
|
|
29
|
+
* with it (it is Talk and more), and is never given to a device or a
|
|
30
|
+
* conversation.
|
|
31
|
+
*
|
|
26
32
|
* Three kinds of subject: a CONVERSATION (what is attached to it reaches its
|
|
27
33
|
* machine), a PAIRING (what a device was given by a code) and an API KEY
|
|
28
34
|
* (what a program elsewhere may ask with it: apikeys.mjs). The owner is not
|
|
29
35
|
* in the table: the owner may do everything, always.
|
|
30
36
|
*
|
|
31
37
|
* An API key is not a device and not a conversation: it is given only what
|
|
32
|
-
* its scopes name -- "server:status" (read), and conversations one by one,
|
|
38
|
+
* its scopes name -- "server:status" (read), "server:rooms" (make rooms), and conversations one by one,
|
|
33
39
|
* their chat to read ("r") or to talk in ("rw"), never their terminal ("x")
|
|
34
40
|
* -- rooms one by one, their thread to read ("r") or to post in ("rw"), and
|
|
35
41
|
* voice boxes one by one, their state ("r") or a short text said aloud
|
|
@@ -54,9 +60,11 @@
|
|
|
54
60
|
*/
|
|
55
61
|
import { Refusal } from './conversations.mjs';
|
|
56
62
|
|
|
57
|
-
export const RIGHTS = Object.freeze({ conversation: '
|
|
63
|
+
export const RIGHTS = Object.freeze({ conversation: 'rwxm', folder: 'rw', mailbox: 'r', git: 'rw', secret: 'x', server: 'rw', room: 'rwm', box: 'rw' });
|
|
64
|
+
/* What each of the server's own assets takes: its state is read, rooms are made. */
|
|
65
|
+
const SERVER_RIGHTS = Object.freeze({ 'server:status': 'r', 'server:rooms': 'w' });
|
|
58
66
|
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})$/;
|
|
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}$/;
|
|
67
|
+
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|rooms)$|^(room|box):[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
60
68
|
|
|
61
69
|
export const typeOf = (asset) => String(asset).split(':')[0];
|
|
62
70
|
export const nameOf = (asset) => String(asset).slice(String(asset).indexOf(':') + 1);
|
|
@@ -70,12 +78,15 @@ export function readRights(asset, rights) {
|
|
|
70
78
|
if (typeof asset !== 'string' || !ASSET.test(asset)) throw new Refusal(400, 'Not an asset: a type and a name, such as "folder:reports".');
|
|
71
79
|
const allowed = RIGHTS[typeOf(asset)];
|
|
72
80
|
const asked = [...new Set(String(rights ?? ''))];
|
|
73
|
-
|
|
74
|
-
|
|
81
|
+
const takes = SERVER_RIGHTS[asset] ?? allowed;
|
|
82
|
+
if (asked.length === 0 || asked.some((r) => !takes.includes(r))) {
|
|
83
|
+
throw new Refusal(400, `A ${typeOf(asset)} takes these rights: ${[...takes].join(', ')}.`);
|
|
75
84
|
}
|
|
85
|
+
/* Acting, managing is talking and more: "m" brings "w" with it. */
|
|
86
|
+
if (asked.includes('m') && !asked.includes('w')) asked.push('w');
|
|
76
87
|
/* Writing without reading is not a thing here: "w" brings "r" with it. */
|
|
77
|
-
if (asked.includes('w') &&
|
|
78
|
-
return [...'
|
|
88
|
+
if (asked.includes('w') && takes.includes('r') && !asked.includes('r')) asked.push('r');
|
|
89
|
+
return [...'rwxm'].filter((r) => asked.includes(r)).join('');
|
|
79
90
|
}
|
|
80
91
|
|
|
81
92
|
/* A table kept in memory only: for tests, and for a part built without one. */
|
|
@@ -105,6 +116,7 @@ export class Grants {
|
|
|
105
116
|
if (typeOf(asset) === 'server' && !subject.startsWith('apikey:')) throw new Refusal(400, 'The state of the server is given to an API key only.');
|
|
106
117
|
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
118
|
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.');
|
|
119
|
+
if (clean.includes('m') && !subject.startsWith('apikey:')) throw new Refusal(400, 'Acting on a conversation, managing a room: given to an API key only.');
|
|
108
120
|
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.');
|
|
109
121
|
this.#store.put({ name: this.#key(subject, asset), subject, asset, rights: clean });
|
|
110
122
|
return clean;
|
package/server/main.mjs
CHANGED
|
@@ -45,7 +45,7 @@ import { Dockets } from './dockets.mjs';
|
|
|
45
45
|
import { PULSE_MS } from 'clavarium-parley/ui';
|
|
46
46
|
import { Wakeups, TypingWatch, CALL_PATH } from './wakeups.mjs';
|
|
47
47
|
import { Alerts } from './alerts.mjs';
|
|
48
|
-
import { Talk } from './talk.mjs';
|
|
48
|
+
import { Talk, escapeCommand } from './talk.mjs';
|
|
49
49
|
import { Toolbox } from './toolbox.mjs';
|
|
50
50
|
import { Guide, commonText } from './guide.mjs';
|
|
51
51
|
import { SignInMailboxes } from './signin.mjs';
|
|
@@ -235,7 +235,7 @@ function serve(port, conversations, resources, { gate = () => 'local', originOk,
|
|
|
235
235
|
/* THE DOOR FOR PROGRAMS (apikeys.mjs): an API key is the right, never the gate -- the front lets
|
|
236
236
|
/api/ext/ through without sign-on, and strips the identity headers there. */
|
|
237
237
|
if (apikeys && (pathname === '/api/ext' || pathname.startsWith('/api/ext/'))) {
|
|
238
|
-
await handleExternal(request, response, { keys: apikeys.keys, status: apikeys.status, chats: apikeys.chats, rooms: apikeys.rooms, boxes: apikeys.boxes, headers: HEADERS, originOk, version: platform?.version ?? null });
|
|
238
|
+
await handleExternal(request, response, { keys: apikeys.keys, status: apikeys.status, chats: apikeys.chats, rooms: apikeys.rooms, boxes: apikeys.boxes, events, headers: HEADERS, originOk, version: platform?.version ?? null });
|
|
239
239
|
return;
|
|
240
240
|
}
|
|
241
241
|
if (door && pathname.startsWith('/c/')) {
|
|
@@ -739,13 +739,31 @@ export async function startHome({ port, host, dataDir, mntDir = dataDir, gitRese
|
|
|
739
739
|
chatOn: (name) => { try { return talk.of(name, { state: '1' }).on === true; } catch { return false; } },
|
|
740
740
|
read: (name, part) => talk.of(name, part),
|
|
741
741
|
say: (name, text, by) => talk.say(name, text, undefined, [], undefined, { by }),
|
|
742
|
+
/* To act (level "act"): what the owner's buttons do. Woken as a terminal opened is (its machine started,
|
|
743
|
+
its terminal kept), not waited for; put to sleep as Sleep does; its turn stopped as the Esc key does. */
|
|
744
|
+
wake: (name) => {
|
|
745
|
+
const c = all.get(name);
|
|
746
|
+
if (c?.phase !== 'ready') throw new Refusal(409, 'This conversation is not ready.');
|
|
747
|
+
void bridge?.warm(name);
|
|
748
|
+
return { state: c.asleep === true ? 'waking' : 'awake' };
|
|
749
|
+
},
|
|
750
|
+
sleep: async (name) => { await all.sleep(name); return { state: 'asleep' }; },
|
|
751
|
+
stop: async (name) => {
|
|
752
|
+
const c = all.get(name);
|
|
753
|
+
if (c?.phase !== 'ready' || c.asleep === true) throw new Refusal(409, 'This conversation is asleep: nothing to stop.');
|
|
754
|
+
const r = await vms.exec(name, escapeCommand(), { timeoutMs: 30_000 });
|
|
755
|
+
if (r.code !== 0) throw new Refusal(502, 'Its turn could not be stopped.');
|
|
756
|
+
return { state: 'awake' };
|
|
757
|
+
},
|
|
742
758
|
},
|
|
743
759
|
/* Its rooms: read as the room's page reads them (names of the moment), never marked seen; posted as the person
|
|
744
760
|
posts there (rooms.mjs say, `as` the key). */
|
|
745
761
|
rooms: {
|
|
746
|
-
list: () => rooms.list().map((r) => ({ id: r.id, name: r.name, host: r.members.find((m) => m.host)?.name ?? null, members: r.members.map((m) => ({ name: m.name, kind: m.kind })) })),
|
|
762
|
+
list: () => rooms.list().map((r) => ({ id: r.id, name: r.name, host: r.members.find((m) => m.host)?.name ?? null, members: r.members.map((m) => ({ id: m.id, ref: m.ref, name: m.name, kind: m.kind, ...(m.host ? { host: true } : {}) })) })),
|
|
747
763
|
read: (id, part) => rooms.read(id, { ...part, ...(part.last ? { last: Number(part.last) } : {}), seen: false }),
|
|
748
764
|
say: (id, text, how) => rooms.say(id, text, how),
|
|
765
|
+
/* To manage (level "manage", or "create rooms"): as the page asks (api.mjs /api/rooms). */
|
|
766
|
+
create: (asked) => rooms.create(asked), change: (id, asked) => rooms.change(id, asked), remove: (id) => rooms.remove(id),
|
|
749
767
|
},
|
|
750
768
|
/* Its voice boxes: their state only, and a short text said aloud (boxes.mjs announce). */
|
|
751
769
|
boxes: {
|
package/server/settingswords.mjs
CHANGED
|
@@ -75,14 +75,15 @@ export const SETTINGS_WORDS_FR = {
|
|
|
75
75
|
'set-apikeys-name-hint': 'À quoi elle sert', 'set-apikeys-scopes': 'Serveur', 'set-apikeys-scopes-hint': 'Ce que la clé en voit',
|
|
76
76
|
'set-apikeys-expires': 'Expire', 'set-apikeys-30': 'Dans 30 jours', 'set-apikeys-90': 'Dans 90 jours', 'set-apikeys-365': 'Dans un an',
|
|
77
77
|
'set-apikeys-never': 'Jamais', 'set-apikeys-cancel': 'Annuler', 'set-apikeys-create': 'Créer', 'set-apikeys-new': 'Nouvelle clé',
|
|
78
|
-
'set-apikeys-never-by-key': 'Jamais : secrets, clés, comptes,
|
|
78
|
+
'set-apikeys-never-by-key': 'Jamais : secrets, clés, comptes, droits', 'set-apikeys-scope-status': 'Son état (mémoire, disque, versions)',
|
|
79
|
+
'set-apikeys-scope-create-rooms': 'Créer des salons', 'set-apikeys-act': 'Agir', 'set-apikeys-manage': 'Gérer',
|
|
79
80
|
'set-apikeys-no-scope': 'Aucune portée', 'set-apikeys-by-you': 'par vous', 'set-apikeys-used': 'Utilisée {when}', 'set-apikeys-unused': 'Jamais utilisée',
|
|
80
81
|
'set-apikeys-expired': 'Expirée', 'set-apikeys-expires-on': 'Expire le {date}', 'set-apikeys-no-expiry': 'N\'expire pas',
|
|
81
82
|
'set-apikeys-created': 'Créée le {date}', 'set-apikeys-revoke': 'Révoquer', 'set-apikeys-revoke-q': 'Révoquer maintenant ?',
|
|
82
|
-
'set-apikeys-convs': 'Conversations', 'set-apikeys-convs-hint': 'Une par une : lire ou
|
|
83
|
+
'set-apikeys-convs': 'Conversations', 'set-apikeys-convs-hint': 'Une par une : lire, parler ou agir', 'set-apikeys-off': 'Non',
|
|
83
84
|
'set-apikeys-read': 'Lire', 'set-apikeys-talk': 'Parler', 'set-apikeys-no-convs': 'Aucune conversation pour l\'instant',
|
|
84
85
|
'set-apikeys-journal': 'Journal', 'set-apikeys-no-use': 'Aucun appel pour l\'instant',
|
|
85
|
-
'set-apikeys-rooms': 'Salons', 'set-apikeys-rooms-hint': 'Un par un : lire ou
|
|
86
|
+
'set-apikeys-rooms': 'Salons', 'set-apikeys-rooms-hint': 'Un par un : lire, parler ou gérer', 'set-apikeys-no-rooms': 'Aucun salon pour l\'instant',
|
|
86
87
|
'set-apikeys-boxes': 'Boîtiers vocaux', 'set-apikeys-boxes-hint': 'Un par un : état, ou parler à voix haute', 'set-apikeys-no-boxes': 'Aucun boîtier pour l\'instant',
|
|
87
88
|
'set-apikeys-state': 'État', 'set-apikeys-speak': 'Parler à voix haute',
|
|
88
89
|
/* The rights of an asset (06-11, kit.js RIGHTS): what it has (a pill), a choice, its one line. */
|
package/server/talk.mjs
CHANGED
|
@@ -111,6 +111,8 @@ export const HEALTH_KNOWN_MS = 150_000;
|
|
|
111
111
|
end of a command: such a last ";" is written "\;", which tmux gives back
|
|
112
112
|
as ";" (tried). */
|
|
113
113
|
export const enterCommand = () => ['tmux', '-L', SOCKET, 'send-keys', '-t', PANE, 'Enter'];
|
|
114
|
+
/* Escape, alone: what the Esc key of the page's key row sends -- a tool's current turn stopped (an API key that may act, apikeys.mjs). */
|
|
115
|
+
export const escapeCommand = () => ['tmux', '-L', SOCKET, 'send-keys', '-t', PANE, 'Escape'];
|
|
114
116
|
export function typeCommand(line) {
|
|
115
117
|
const text = line.endsWith(';') ? `${line.slice(0, -1)}\\;` : line;
|
|
116
118
|
return ['tmux', '-L', SOCKET, 'send-keys', '-t', PANE, '-l', '--', text, ';', 'run-shell', '-d', String(ENTER_AFTER_MS / 1000), ';', 'send-keys', '-t', PANE, 'Enter'];
|
package/web/app.js
CHANGED
|
@@ -6612,13 +6612,15 @@
|
|
|
6612
6612
|
scope until one is ticked; its secret shown this once, then never; revoked at once, confirmed in place.
|
|
6613
6613
|
Administrators only: hidden on a device, and where the server does not answer it. */
|
|
6614
6614
|
const shortDate = (t) => new Date(t).toLocaleDateString([], { day: 'numeric', month: 'short', year: 'numeric' });
|
|
6615
|
-
/* Its conversations, one by one (step 2): each Off / Read / Talk -- never "all". `chosen`: name -> level.
|
|
6616
|
-
|
|
6615
|
+
/* Its conversations, one by one (step 2): each Off / Read / Talk / Act -- never "all". `chosen`: name -> level.
|
|
6616
|
+
Act (07-11): Talk, and wake it, put it to sleep, stop its turn. A room's Manage: Talk, and its name, members, host. */
|
|
6617
|
+
const apiKeyLevelWord = (level) => (level === 'act' ? uiSaid('set-apikeys-act', 'Act') : level === 'manage' ? uiSaid('set-apikeys-manage', 'Manage')
|
|
6618
|
+
: level === 'talk' ? uiSaid('set-apikeys-talk', 'Talk') : uiSaid('set-apikeys-read', 'Read'));
|
|
6617
6619
|
function apiKeyConvPicker(chosen, onChange) {
|
|
6618
6620
|
const box = el('div', 'apikey-convs');
|
|
6619
6621
|
const list = conversations.filter((c) => c && c.name);
|
|
6620
6622
|
if (!list.length) box.append(el('small', 'hint', uiSaid('set-apikeys-no-convs', 'No conversation yet')));
|
|
6621
|
-
const options = [{ value: '', label: uiSaid('set-apikeys-off', 'Off') }, { value: 'read', label: apiKeyLevelWord('read') }, { value: 'talk', label: apiKeyLevelWord('talk') }];
|
|
6623
|
+
const options = [{ value: '', label: uiSaid('set-apikeys-off', 'Off') }, { value: 'read', label: apiKeyLevelWord('read') }, { value: 'talk', label: apiKeyLevelWord('talk') }, { value: 'act', label: apiKeyLevelWord('act') }];
|
|
6622
6624
|
for (const c of list) {
|
|
6623
6625
|
const row = el('div', 'apikey-conv'); row.dataset.name = c.name;
|
|
6624
6626
|
row.append(el('span', '', shownName(c)), KIT.Segmented({ options, value: chosen.get(c.name) ?? '', label: shownName(c), onChange: (v) => onChange(c.name, v || null) }));
|
|
@@ -6635,7 +6637,7 @@
|
|
|
6635
6637
|
const list = apiKeyItems(kind);
|
|
6636
6638
|
if (!list.length) box.append(el('small', 'hint', kind === 'rooms' ? uiSaid('set-apikeys-no-rooms', 'No room yet') : uiSaid('set-apikeys-no-boxes', 'No voice box yet')));
|
|
6637
6639
|
const options = kind === 'rooms'
|
|
6638
|
-
? [{ value: '', label: uiSaid('set-apikeys-off', 'Off') }, { value: 'read', label: apiKeyLevelWord('read') }, { value: 'talk', label: apiKeyLevelWord('talk') }]
|
|
6640
|
+
? [{ value: '', label: uiSaid('set-apikeys-off', 'Off') }, { value: 'read', label: apiKeyLevelWord('read') }, { value: 'talk', label: apiKeyLevelWord('talk') }, { value: 'manage', label: apiKeyLevelWord('manage') }]
|
|
6639
6641
|
: [{ value: '', label: uiSaid('set-apikeys-off', 'Off') }, { value: 'state', label: apiKeyBoxWord('state') }, { value: 'speak', label: apiKeyBoxWord('speak') }];
|
|
6640
6642
|
for (const x of list) {
|
|
6641
6643
|
const row = el('div', kind === 'rooms' ? 'apikey-conv apikey-room' : 'apikey-conv apikey-box'); row.dataset.id = x.id;
|
|
@@ -6687,8 +6689,8 @@
|
|
|
6687
6689
|
what.append(head);
|
|
6688
6690
|
const tags = el('span', 'apikey-scopes');
|
|
6689
6691
|
const given = [...k.scopes.map((sc) => ({ text: scopeWord(sc) })),
|
|
6690
|
-
...(k.conversations ?? []).map((c) => ({ text: `${shownName(conversations.find((x) => x.name === c.name) ?? { name: c.name })} · ${apiKeyLevelWord(c.level)}`, tone: c.level === '
|
|
6691
|
-
...(k.rooms ?? []).map((r) => ({ text: `${roomsNow().find((x) => x.id === r.id)?.name ?? r.id.slice(0, 8)} · ${apiKeyLevelWord(r.level)}`, icon: 'room', tone: r.level === '
|
|
6692
|
+
...(k.conversations ?? []).map((c) => ({ text: `${shownName(conversations.find((x) => x.name === c.name) ?? { name: c.name })} · ${apiKeyLevelWord(c.level)}`, tone: c.level === 'read' ? undefined : 'note' })),
|
|
6693
|
+
...(k.rooms ?? []).map((r) => ({ text: `${roomsNow().find((x) => x.id === r.id)?.name ?? r.id.slice(0, 8)} · ${apiKeyLevelWord(r.level)}`, icon: 'room', tone: r.level === 'read' ? undefined : 'note' })),
|
|
6692
6694
|
...(k.boxes ?? []).map((b) => ({ text: `${boxesNow().find((x) => x.id === b.id)?.name ?? b.id.slice(0, 8)} · ${apiKeyBoxWord(b.level)}`, icon: 'box', tone: b.level === 'speak' ? 'note' : undefined }))];
|
|
6693
6695
|
tags.append(KIT.Chips(given.length ? given : [{ text: uiSaid('set-apikeys-no-scope', 'No scope') }]));
|
|
6694
6696
|
what.append(tags);
|
package/web/index.html
CHANGED
|
@@ -1070,14 +1070,14 @@
|
|
|
1070
1070
|
<form id="apikeys-form" class="set-rows" autocomplete="off" hidden>
|
|
1071
1071
|
<div class="set-row"><div class="set-what"><label for="apikeys-label" data-said="set-apikeys-name">Name</label><small data-said="set-apikeys-name-hint">What it is for</small></div><div class="set-control"><input id="apikeys-label" maxlength="40" spellcheck="false" required></div></div>
|
|
1072
1072
|
<div class="set-row"><div class="set-what"><span data-said="set-apikeys-scopes">Server</span><small data-said="set-apikeys-scopes-hint">What the key sees of it</small></div><div class="set-control" id="apikeys-scopes"></div></div>
|
|
1073
|
-
<div class="set-row"><div class="set-what"><span data-said="set-apikeys-convs">Conversations</span><small data-said="set-apikeys-convs-hint">One by one: read or
|
|
1074
|
-
<div class="set-row"><div class="set-what"><span data-said="set-apikeys-rooms">Rooms</span><small data-said="set-apikeys-rooms-hint">One by one: read or
|
|
1073
|
+
<div class="set-row"><div class="set-what"><span data-said="set-apikeys-convs">Conversations</span><small data-said="set-apikeys-convs-hint">One by one: read, talk or act</small></div><div class="set-control" id="apikeys-convs"></div></div>
|
|
1074
|
+
<div class="set-row"><div class="set-what"><span data-said="set-apikeys-rooms">Rooms</span><small data-said="set-apikeys-rooms-hint">One by one: read, talk or manage</small></div><div class="set-control" id="apikeys-rooms"></div></div>
|
|
1075
1075
|
<div class="set-row"><div class="set-what"><span data-said="set-apikeys-boxes">Voice boxes</span><small data-said="set-apikeys-boxes-hint">One by one: state, or speak aloud</small></div><div class="set-control" id="apikeys-boxes"></div></div>
|
|
1076
1076
|
<div class="set-row"><div class="set-what"><label for="apikeys-days" data-said="set-apikeys-expires">Expires</label></div><div class="set-control"><select id="apikeys-days"><option value="30" data-said="set-apikeys-30">In 30 days</option><option value="90" data-said="set-apikeys-90" selected>In 90 days</option><option value="365" data-said="set-apikeys-365">In a year</option><option value="never" data-said="set-apikeys-never">Never</option></select></div></div>
|
|
1077
1077
|
<div class="action-row"><span class="error" id="apikeys-error" role="alert" hidden></span><button type="button" id="apikeys-cancel" data-said="set-apikeys-cancel">Cancel</button><button type="submit" class="primary" data-said="set-apikeys-create">Create</button></div>
|
|
1078
1078
|
</form>
|
|
1079
1079
|
<div class="action-row" id="apikeys-new-row"><span class="hint" id="apikeys-where"></span><button type="button" id="apikeys-new" class="primary" data-said="set-apikeys-new">New key</button></div>
|
|
1080
|
-
<p class="hint" data-said="set-apikeys-never-by-key">Never by key: secrets, keys, accounts,
|
|
1080
|
+
<p class="hint" data-said="set-apikeys-never-by-key">Never by key: secrets, keys, accounts, rights</p>
|
|
1081
1081
|
</div>
|
|
1082
1082
|
</details>
|
|
1083
1083
|
<!-- NETWORK: this server's Wi-Fi (server/wifi.mjs; done as root by the host program,
|