agent-protocols 0.8.0 → 0.10.0
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/README.md +91 -19
- package/dist/delegation.d.ts +57 -36
- package/dist/delegation.d.ts.map +1 -1
- package/dist/delegation.js +208 -69
- package/dist/delegation.js.map +1 -1
- package/dist/discourse.d.ts +196 -74
- package/dist/discourse.d.ts.map +1 -1
- package/dist/discourse.js +568 -116
- package/dist/discourse.js.map +1 -1
- package/dist/http-client.d.ts +65 -18
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +217 -34
- package/dist/http-client.js.map +1 -1
- package/dist/identity.d.ts +117 -3
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +367 -7
- package/dist/identity.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/knowledge-schema.d.ts +978 -0
- package/dist/knowledge-schema.d.ts.map +1 -0
- package/dist/knowledge-schema.js +1187 -0
- package/dist/knowledge-schema.js.map +1 -0
- package/dist/knowledge.d.ts +275 -0
- package/dist/knowledge.d.ts.map +1 -0
- package/dist/knowledge.js +795 -0
- package/dist/knowledge.js.map +1 -0
- package/dist/local-connector/catalog.d.ts +1 -16
- package/dist/local-connector/catalog.d.ts.map +1 -1
- package/dist/local-connector/catalog.js +53 -71
- package/dist/local-connector/catalog.js.map +1 -1
- package/dist/local-connector/engine.d.ts +55 -32
- package/dist/local-connector/engine.d.ts.map +1 -1
- package/dist/local-connector/engine.js +636 -608
- package/dist/local-connector/engine.js.map +1 -1
- package/dist/local-connector/inputs.d.ts +28 -76
- package/dist/local-connector/inputs.d.ts.map +1 -1
- package/dist/local-connector/inputs.js +19 -1
- package/dist/local-connector/inputs.js.map +1 -1
- package/dist/local-connector/projection.d.ts +24 -9
- package/dist/local-connector/projection.d.ts.map +1 -1
- package/dist/local-connector/projection.js +78 -39
- package/dist/local-connector/projection.js.map +1 -1
- package/dist/local-connector/state.d.ts +10 -3
- package/dist/local-connector/state.d.ts.map +1 -1
- package/dist/local-connector/state.js +3 -0
- package/dist/local-connector/state.js.map +1 -1
- package/dist/local-connector/views.d.ts +36 -12
- package/dist/local-connector/views.d.ts.map +1 -1
- package/dist/local-connector/views.js +49 -31
- package/dist/local-connector/views.js.map +1 -1
- package/dist/profile.d.ts +23 -9
- package/dist/profile.d.ts.map +1 -1
- package/dist/profile.js +120 -3
- package/dist/profile.js.map +1 -1
- package/package.json +9 -5
package/dist/discourse.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Agent Discourse Protocol 1.0: kernel types, the room type system, and
|
|
3
3
|
* verification helpers.
|
|
4
4
|
*
|
|
5
|
-
* The protocol defines
|
|
5
|
+
* The protocol defines twelve built-in event types. Every other event type is
|
|
6
6
|
* declared per room as a schema-validated type definition, either inline or
|
|
7
7
|
* imported from a type pack. Hosts validate structure and permissions; they
|
|
8
8
|
* never need to understand application semantics.
|
|
@@ -10,14 +10,15 @@
|
|
|
10
10
|
import { Validator } from "@cfworker/json-schema";
|
|
11
11
|
import canonicalize from "canonicalize";
|
|
12
12
|
import { createHash } from "node:crypto";
|
|
13
|
-
import { protocolError } from "./errors.js";
|
|
14
|
-
import { MAX_SAFE_NONCE, createEvent, validateAgentId, verifyEnvelope, withRoomHead, withRoomId, } from "./identity.js";
|
|
13
|
+
import { AgentProtocolError, protocolError } from "./errors.js";
|
|
14
|
+
import { MAX_SAFE_NONCE, createEvent, validateAgentId, validateEventFields, validateOrigin, verifyEnvelope, withRoomHead, withRoomId, } from "./identity.js";
|
|
15
15
|
export const DISCOURSE_PROTOCOL = "agent-discourse/1.0";
|
|
16
|
-
/** The
|
|
16
|
+
/** The twelve built-in event types. All other types are room-defined. */
|
|
17
17
|
export const eventType = {
|
|
18
18
|
ROOM_CREATE: "room.create",
|
|
19
19
|
ROOM_UPDATE: "room.update",
|
|
20
20
|
ROOM_JOIN: "room.join",
|
|
21
|
+
ROOM_JOIN_REQUEST: "room.join.request",
|
|
21
22
|
ROOM_JOIN_REVIEW: "room.join.review",
|
|
22
23
|
ROOM_LEAVE: "room.leave",
|
|
23
24
|
ROOM_MEMBER_ROLE_UPDATE: "room.member.role.update",
|
|
@@ -29,9 +30,9 @@ export const eventType = {
|
|
|
29
30
|
};
|
|
30
31
|
export const BUILTIN_EVENT_TYPES = Object.values(eventType);
|
|
31
32
|
/**
|
|
32
|
-
* Built-in membership events. They
|
|
33
|
-
*
|
|
34
|
-
* rooms cannot starve joins, reviews, or other membership writes.
|
|
33
|
+
* Built-in membership events. They are `signal`-class: they anchor to an
|
|
34
|
+
* accepted record but are never checked against or advance the room head, so
|
|
35
|
+
* busy rooms cannot starve joins, reviews, or other membership writes.
|
|
35
36
|
*/
|
|
36
37
|
export const MEMBERSHIP_EVENT_TYPES = [
|
|
37
38
|
eventType.ROOM_JOIN,
|
|
@@ -40,22 +41,36 @@ export const MEMBERSHIP_EVENT_TYPES = [
|
|
|
40
41
|
eventType.ROOM_MEMBER_ROLE_UPDATE,
|
|
41
42
|
eventType.ROOM_MEMBER_REMOVE,
|
|
42
43
|
];
|
|
43
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Contract writes (Section 5.1): anchored like signals, so discussion traffic
|
|
46
|
+
* cannot starve them, but head-advancing, so messages and control writes
|
|
47
|
+
* composed against the old contract are rejected and re-read.
|
|
48
|
+
*/
|
|
49
|
+
export const CONTRACT_EVENT_TYPES = [
|
|
50
|
+
eventType.ROOM_UPDATE,
|
|
51
|
+
eventType.ROOM_CLOSE,
|
|
52
|
+
eventType.ROOM_CANCEL,
|
|
53
|
+
eventType.TYPE_DEFINE,
|
|
54
|
+
];
|
|
55
|
+
/**
|
|
56
|
+
* Section 12.2 class of a built-in type; `undefined` for room-defined types and
|
|
57
|
+
* for `room.join.request`, which never becomes a record.
|
|
58
|
+
*/
|
|
44
59
|
export function builtinEventClass(type) {
|
|
45
60
|
switch (type) {
|
|
46
61
|
case eventType.ROOM_CREATE:
|
|
62
|
+
return "genesis";
|
|
47
63
|
case eventType.ROOM_UPDATE:
|
|
48
64
|
case eventType.ROOM_CLOSE:
|
|
49
65
|
case eventType.ROOM_CANCEL:
|
|
50
|
-
|
|
66
|
+
case eventType.TYPE_DEFINE:
|
|
67
|
+
return "contract";
|
|
51
68
|
case eventType.ROOM_JOIN:
|
|
52
69
|
case eventType.ROOM_JOIN_REVIEW:
|
|
53
70
|
case eventType.ROOM_LEAVE:
|
|
54
71
|
case eventType.ROOM_MEMBER_ROLE_UPDATE:
|
|
55
72
|
case eventType.ROOM_MEMBER_REMOVE:
|
|
56
73
|
return "signal";
|
|
57
|
-
case eventType.TYPE_DEFINE:
|
|
58
|
-
return "control";
|
|
59
74
|
case eventType.MESSAGE_CREATE:
|
|
60
75
|
return "message";
|
|
61
76
|
default:
|
|
@@ -63,55 +78,87 @@ export function builtinEventClass(type) {
|
|
|
63
78
|
}
|
|
64
79
|
}
|
|
65
80
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
81
|
+
* Record class of an event type; `undefined` for `room.join.request` and for
|
|
82
|
+
* custom types absent from `registry`.
|
|
83
|
+
*/
|
|
84
|
+
export function recordClass(type, registry) {
|
|
85
|
+
const builtin = builtinEventClass(type);
|
|
86
|
+
if (builtin !== undefined)
|
|
87
|
+
return builtin;
|
|
88
|
+
if (isBuiltinEventType(type))
|
|
89
|
+
return undefined;
|
|
90
|
+
const def = Array.isArray(registry)
|
|
91
|
+
? registry.find((d) => d.type === type)
|
|
92
|
+
: registry?.get(type);
|
|
93
|
+
return def?.kind;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Whether an accepted record of this type advances the room head (Section
|
|
97
|
+
* 5.1): `genesis`, `contract`, and `control` records. Unknown custom types
|
|
98
|
+
* default to head-advancing.
|
|
71
99
|
*/
|
|
72
100
|
export function eventAdvancesRoomHead(type, registry) {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
101
|
+
const cls = recordClass(type, registry);
|
|
102
|
+
return cls !== "message" && cls !== "signal";
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Whether a write of this type is checked against the room head (Section
|
|
106
|
+
* 5.1): `message.create` and custom `message`/`control` kinds must be based at
|
|
107
|
+
* or after the current head. Contract and signal writes only anchor. Unknown
|
|
108
|
+
* custom types default to head-checked.
|
|
109
|
+
*/
|
|
110
|
+
export function eventRequiresRoomHead(type, registry) {
|
|
111
|
+
const cls = recordClass(type, registry);
|
|
112
|
+
if (cls === undefined)
|
|
113
|
+
return !isBuiltinEventType(type);
|
|
114
|
+
return cls === "message" || cls === "control";
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Section 5.1 base check for a room write based on `baseSeq` / `baseHash`.
|
|
118
|
+
* `anchorHash` is the hash of the accepted record at `baseSeq` in the same
|
|
119
|
+
* room (`undefined` when there is none) and `headSeq` is the current room
|
|
120
|
+
* head. Every base must name an accepted record (`base_record_mismatch`);
|
|
121
|
+
* `message` and `control` writes must also be based at or after the head
|
|
122
|
+
* (`room_head_mismatch`).
|
|
123
|
+
*/
|
|
124
|
+
export function validateRoomBase(type, registry, baseSeq, baseHash, anchorHash, headSeq) {
|
|
125
|
+
if (anchorHash !== baseHash) {
|
|
126
|
+
throw protocolError("base_record_mismatch", `base ${baseSeq} does not name an accepted record of this room`);
|
|
127
|
+
}
|
|
128
|
+
if (eventRequiresRoomHead(type, registry) && baseSeq < headSeq) {
|
|
129
|
+
throw protocolError("room_head_mismatch", `base ${baseSeq} is before the room head ${headSeq}`);
|
|
130
|
+
}
|
|
78
131
|
}
|
|
79
|
-
/**
|
|
132
|
+
/** ADP-specific error codes (Section 19); shared codes come from Agent Identity. */
|
|
80
133
|
export const DISCOURSE_ERROR_CODES = [
|
|
81
|
-
"invalid_event",
|
|
82
|
-
"invalid_event_hash",
|
|
83
|
-
"invalid_signature",
|
|
84
|
-
"invalid_actor",
|
|
85
|
-
"timestamp_out_of_window",
|
|
86
|
-
"nonce_not_greater",
|
|
87
134
|
"room_not_found",
|
|
88
135
|
"room_not_active",
|
|
89
|
-
"
|
|
90
|
-
"permission_denied",
|
|
136
|
+
"host_mismatch",
|
|
91
137
|
"approval_required",
|
|
92
138
|
"join_request_not_found",
|
|
93
|
-
"
|
|
94
|
-
"join_request_role_mismatch",
|
|
95
|
-
"join_request_expired",
|
|
139
|
+
"join_request_not_pending",
|
|
96
140
|
"member_banned",
|
|
97
141
|
"role_not_allowed",
|
|
98
142
|
"max_speakers_exceeded",
|
|
99
143
|
"membership_required",
|
|
100
|
-
"invalid_token",
|
|
101
144
|
"room_head_mismatch",
|
|
102
145
|
"base_record_mismatch",
|
|
103
146
|
"agent_status_not_found",
|
|
104
|
-
"rate_limited",
|
|
105
|
-
"payload_too_large",
|
|
106
147
|
"type_not_defined",
|
|
107
148
|
"type_disabled",
|
|
149
|
+
"type_conflict",
|
|
150
|
+
"invalid_type_schema",
|
|
108
151
|
"payload_schema_violation",
|
|
109
152
|
"pack_unavailable",
|
|
110
153
|
];
|
|
111
154
|
/** Hosts MUST reject events with more than this many `mentions` entries. */
|
|
112
155
|
export const MAX_MENTIONS = 32;
|
|
156
|
+
/** Room IDs are host-assigned and URL-safe (Section 6.1). */
|
|
157
|
+
export const ROOM_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
158
|
+
/** `<algorithm>:<base64url-digest>` content digests (Section 12.5). */
|
|
159
|
+
export const CONTENT_DIGEST_PATTERN = /^(sha256|sha3-256):[A-Za-z0-9_-]{43}$/;
|
|
113
160
|
/** Custom event types must not use these prefixes. */
|
|
114
|
-
export const RESERVED_TYPE_PREFIXES = ["room.", "type."];
|
|
161
|
+
export const RESERVED_TYPE_PREFIXES = ["room.", "type.", "message."];
|
|
115
162
|
/** Registered type packs defined by the specification in `1.0.packs.json`. */
|
|
116
163
|
export const packId = {
|
|
117
164
|
REACTIONS: "adp:reactions/1.0",
|
|
@@ -134,6 +181,10 @@ export function packMap(document) {
|
|
|
134
181
|
export function roomCreateEvent(actor, createdAt, nonce, payload) {
|
|
135
182
|
return createEvent(DISCOURSE_PROTOCOL, eventType.ROOM_CREATE, actor, createdAt, nonce, payload);
|
|
136
183
|
}
|
|
184
|
+
/** A `room.join.request` carries `room_id` but no base: its author may not be able to read the room. */
|
|
185
|
+
export function roomJoinRequestEvent(actor, createdAt, nonce, roomId, payload) {
|
|
186
|
+
return withRoomId(createEvent(DISCOURSE_PROTOCOL, eventType.ROOM_JOIN_REQUEST, actor, createdAt, nonce, payload), roomId);
|
|
187
|
+
}
|
|
137
188
|
export function typeDefineEvent(actor, createdAt, nonce, roomId, baseSeq, baseHash, declaration) {
|
|
138
189
|
return withRoomHead(withRoomId(createEvent(DISCOURSE_PROTOCOL, eventType.TYPE_DEFINE, actor, createdAt, nonce, declaration), roomId), baseSeq, baseHash);
|
|
139
190
|
}
|
|
@@ -146,44 +197,52 @@ export function isBuiltinEventType(type) {
|
|
|
146
197
|
export function eventRequiresRoomId(type) {
|
|
147
198
|
return type !== eventType.ROOM_CREATE;
|
|
148
199
|
}
|
|
200
|
+
/** Whether events of this type carry `base_seq` / `base_hash`. */
|
|
201
|
+
export function eventRequiresBase(type) {
|
|
202
|
+
return type !== eventType.ROOM_CREATE && type !== eventType.ROOM_JOIN_REQUEST;
|
|
203
|
+
}
|
|
204
|
+
export function validateRoomId(roomId) {
|
|
205
|
+
if (typeof roomId !== "string" || !ROOM_ID_PATTERN.test(roomId)) {
|
|
206
|
+
throw protocolError("invalid_event", "room_id must match [A-Za-z0-9_-]{1,64}");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
149
209
|
export function validateDiscourseEnvelope(envelope) {
|
|
150
210
|
verifyEnvelope(envelope);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
211
|
+
validateDiscourseEventFields(envelope.event);
|
|
212
|
+
}
|
|
213
|
+
/** Section 5 event-shape rules: closed fields, room ID, base, and mentions. */
|
|
214
|
+
export function validateDiscourseEventFields(event) {
|
|
215
|
+
if (event.protocol !== DISCOURSE_PROTOCOL) {
|
|
216
|
+
throw protocolError("invalid_event_protocol", `expected ${DISCOURSE_PROTOCOL}, got ${event.protocol}`);
|
|
154
217
|
}
|
|
155
|
-
if (
|
|
156
|
-
|
|
218
|
+
if (event.type === eventType.ROOM_CREATE) {
|
|
219
|
+
validateEventFields(event);
|
|
220
|
+
return;
|
|
157
221
|
}
|
|
158
|
-
|
|
159
|
-
|
|
222
|
+
if (event.type === eventType.ROOM_JOIN_REQUEST) {
|
|
223
|
+
validateEventFields(event, ["room_id"]);
|
|
224
|
+
if (event.room_id === undefined) {
|
|
160
225
|
throw protocolError("missing_room_id", "event requires a room_id");
|
|
161
226
|
}
|
|
162
|
-
|
|
163
|
-
|
|
227
|
+
validateRoomId(event.room_id);
|
|
228
|
+
return;
|
|
164
229
|
}
|
|
230
|
+
validateEventFields(event, ["room_id", "base_seq", "base_hash", "mentions"]);
|
|
231
|
+
if (event.room_id === undefined) {
|
|
232
|
+
throw protocolError("missing_room_id", "event requires a room_id");
|
|
233
|
+
}
|
|
234
|
+
validateRoomId(event.room_id);
|
|
235
|
+
validateRoomHeadPrecondition(event);
|
|
236
|
+
validateMentions(event.mentions);
|
|
165
237
|
}
|
|
166
238
|
export function validateRoomPath(envelope, pathRoomId) {
|
|
167
|
-
|
|
168
|
-
if (envelope.event.type === eventType.ROOM_CREATE)
|
|
169
|
-
validateRoomCreateEventFields(envelope.event);
|
|
239
|
+
validateDiscourseEventFields(envelope.event);
|
|
240
|
+
if (envelope.event.type === eventType.ROOM_CREATE)
|
|
170
241
|
return;
|
|
171
|
-
|
|
172
|
-
validateRoomHeadPrecondition(envelope.event);
|
|
173
|
-
validateMentions(envelope.event.mentions);
|
|
174
|
-
if (actual === undefined)
|
|
175
|
-
throw protocolError("missing_room_id", "event requires a room_id");
|
|
242
|
+
const actual = envelope.event.room_id;
|
|
176
243
|
if (actual !== pathRoomId)
|
|
177
244
|
throw protocolError("room_id_mismatch", `expected ${pathRoomId}, got ${actual}`);
|
|
178
245
|
}
|
|
179
|
-
function validateRoomCreateEventFields(event) {
|
|
180
|
-
if (event.room_id !== undefined ||
|
|
181
|
-
event.base_seq !== undefined ||
|
|
182
|
-
event.base_hash !== undefined ||
|
|
183
|
-
Object.prototype.hasOwnProperty.call(event, "mentions")) {
|
|
184
|
-
throw protocolError("invalid_event", "room.create must not include room_id, base_seq, base_hash, or mentions");
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
246
|
export function validateRoomHeadPrecondition(event) {
|
|
188
247
|
const baseSeq = event.base_seq;
|
|
189
248
|
const baseHash = event.base_hash;
|
|
@@ -240,7 +299,16 @@ export function isTypeDef(declaration) {
|
|
|
240
299
|
!isPackImport(declaration) &&
|
|
241
300
|
"type" in declaration);
|
|
242
301
|
}
|
|
302
|
+
const TYPE_DEF_FIELDS = [
|
|
303
|
+
"type", "kind", "title", "description", "schema", "roles", "instructions",
|
|
304
|
+
"status", "rate_hint", "max_payload_hint", "extra",
|
|
305
|
+
];
|
|
243
306
|
export function validateTypeDef(def) {
|
|
307
|
+
for (const key of Object.keys(def)) {
|
|
308
|
+
if (!TYPE_DEF_FIELDS.includes(key)) {
|
|
309
|
+
throw protocolError("invalid_event", `unknown type definition field: ${key}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
244
312
|
validateCustomEventTypeName(def.type);
|
|
245
313
|
if (!includes(TYPE_KINDS, def.kind)) {
|
|
246
314
|
throw protocolError("invalid_event", `invalid type kind: ${def.kind}`);
|
|
@@ -251,7 +319,15 @@ export function validateTypeDef(def) {
|
|
|
251
319
|
if (typeof def.schema !== "object" ||
|
|
252
320
|
def.schema === null ||
|
|
253
321
|
Array.isArray(def.schema)) {
|
|
254
|
-
throw protocolError("
|
|
322
|
+
throw protocolError("invalid_type_schema", "type definition schema must be a JSON Schema object");
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
validateTypeSchemaProfile(def.schema);
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
if (error instanceof AgentProtocolError)
|
|
329
|
+
throw error;
|
|
330
|
+
throw protocolError("invalid_type_schema", `invalid type schema: ${error}`);
|
|
255
331
|
}
|
|
256
332
|
compileSchema(def.schema);
|
|
257
333
|
if (def.roles !== undefined) {
|
|
@@ -285,15 +361,23 @@ export function validatePackImport(declaration) {
|
|
|
285
361
|
}
|
|
286
362
|
}
|
|
287
363
|
else if (hasExternal) {
|
|
288
|
-
if (declaration.
|
|
289
|
-
throw protocolError("invalid_event", "external pack
|
|
364
|
+
if (!/^https:\/\//.test(declaration.pack)) {
|
|
365
|
+
throw protocolError("invalid_event", "external pack must be an HTTPS URL");
|
|
366
|
+
}
|
|
367
|
+
if (!CONTENT_DIGEST_PATTERN.test(declaration.digest)) {
|
|
368
|
+
throw protocolError("invalid_event", "external pack digest must be <sha256|sha3-256>:<base64url-digest>");
|
|
290
369
|
}
|
|
291
370
|
}
|
|
292
371
|
else {
|
|
293
372
|
throw protocolError("invalid_event", "pack import requires either use, or pack with digest");
|
|
294
373
|
}
|
|
295
|
-
if (declaration.types !== undefined
|
|
296
|
-
|
|
374
|
+
if (declaration.types !== undefined) {
|
|
375
|
+
if (declaration.types.length === 0) {
|
|
376
|
+
throw protocolError("invalid_event", "pack import types subset must not be empty");
|
|
377
|
+
}
|
|
378
|
+
if (new Set(declaration.types).size !== declaration.types.length) {
|
|
379
|
+
throw protocolError("type_conflict", "pack import types subset has duplicates");
|
|
380
|
+
}
|
|
297
381
|
}
|
|
298
382
|
}
|
|
299
383
|
export function validateTypeDeclaration(declaration) {
|
|
@@ -310,36 +394,346 @@ export function validateTypeDeclaration(declaration) {
|
|
|
310
394
|
function isRegisteredPackId(id) {
|
|
311
395
|
return /^adp:[a-z0-9-]+\/[0-9]+\.[0-9]+$/.test(id);
|
|
312
396
|
}
|
|
397
|
+
// ── Type schema profile (Section 12.3.1).
|
|
398
|
+
const FORBIDDEN_SCHEMA_KEYWORDS = [
|
|
399
|
+
"$dynamicRef",
|
|
400
|
+
"$dynamicAnchor",
|
|
401
|
+
"$recursiveRef",
|
|
402
|
+
"$recursiveAnchor",
|
|
403
|
+
"$vocabulary",
|
|
404
|
+
];
|
|
405
|
+
const SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema";
|
|
406
|
+
const ANNOTATION_KEYWORDS = ["format", "contentEncoding", "contentMediaType", "contentSchema"];
|
|
407
|
+
const SUBSCHEMA_KEYWORDS = [
|
|
408
|
+
"items",
|
|
409
|
+
"additionalProperties",
|
|
410
|
+
"not",
|
|
411
|
+
"if",
|
|
412
|
+
"then",
|
|
413
|
+
"else",
|
|
414
|
+
"contains",
|
|
415
|
+
"propertyNames",
|
|
416
|
+
"unevaluatedItems",
|
|
417
|
+
"unevaluatedProperties",
|
|
418
|
+
"additionalItems",
|
|
419
|
+
];
|
|
420
|
+
const SUBSCHEMA_ARRAY_KEYWORDS = ["allOf", "anyOf", "oneOf", "prefixItems"];
|
|
421
|
+
const SUBSCHEMA_MAP_KEYWORDS = ["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"];
|
|
422
|
+
/** Calls `visit` on every subschema object of `schema`, depth first. */
|
|
423
|
+
function walkSchema(schema, visit) {
|
|
424
|
+
if (typeof schema !== "object" || schema === null || Array.isArray(schema))
|
|
425
|
+
return;
|
|
426
|
+
const node = schema;
|
|
427
|
+
visit(node);
|
|
428
|
+
for (const key of SUBSCHEMA_KEYWORDS)
|
|
429
|
+
walkSchema(node[key], visit);
|
|
430
|
+
for (const key of SUBSCHEMA_ARRAY_KEYWORDS) {
|
|
431
|
+
if (Array.isArray(node[key]))
|
|
432
|
+
for (const item of node[key])
|
|
433
|
+
walkSchema(item, visit);
|
|
434
|
+
}
|
|
435
|
+
for (const key of SUBSCHEMA_MAP_KEYWORDS) {
|
|
436
|
+
const map = node[key];
|
|
437
|
+
if (typeof map === "object" && map !== null && !Array.isArray(map)) {
|
|
438
|
+
for (const value of Object.values(map))
|
|
439
|
+
walkSchema(value, visit);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Enforces the type schema profile (Section 12.3.1): fragment-only `$ref`, no
|
|
445
|
+
* dynamic or recursive references, the 2020-12 dialect, and portable
|
|
446
|
+
* regular expressions.
|
|
447
|
+
*/
|
|
448
|
+
export function validateTypeSchemaProfile(schema) {
|
|
449
|
+
walkSchema(schema, (node) => {
|
|
450
|
+
for (const keyword of FORBIDDEN_SCHEMA_KEYWORDS) {
|
|
451
|
+
if (keyword in node)
|
|
452
|
+
throw protocolError("invalid_type_schema", `${keyword} is not allowed`);
|
|
453
|
+
}
|
|
454
|
+
if ("$schema" in node && node.$schema !== SCHEMA_DIALECT) {
|
|
455
|
+
throw protocolError("invalid_type_schema", "$schema must be the draft 2020-12 dialect");
|
|
456
|
+
}
|
|
457
|
+
if ("$ref" in node && (typeof node.$ref !== "string" || !node.$ref.startsWith("#"))) {
|
|
458
|
+
throw protocolError("invalid_type_schema", "$ref must be a fragment inside the schema");
|
|
459
|
+
}
|
|
460
|
+
if ("pattern" in node) {
|
|
461
|
+
if (typeof node.pattern !== "string")
|
|
462
|
+
throw protocolError("invalid_type_schema", "pattern must be a string");
|
|
463
|
+
validatePortablePattern(node.pattern);
|
|
464
|
+
}
|
|
465
|
+
const patternProperties = node.patternProperties;
|
|
466
|
+
if (typeof patternProperties === "object" && patternProperties !== null) {
|
|
467
|
+
for (const key of Object.keys(patternProperties))
|
|
468
|
+
validatePortablePattern(key);
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Checks that a pattern is an I-Regexp (RFC 9485) without `\p{…}`/`\P{…}`
|
|
474
|
+
* and without `.` outside a character class, optionally anchored with a
|
|
475
|
+
* leading `^` and a trailing `$` (Section 12.3.1).
|
|
476
|
+
*/
|
|
477
|
+
export function validatePortablePattern(pattern) {
|
|
478
|
+
const reject = (reason) => {
|
|
479
|
+
throw protocolError("invalid_type_schema", `pattern ${JSON.stringify(pattern)} is not portable: ${reason}`);
|
|
480
|
+
};
|
|
481
|
+
let body = pattern;
|
|
482
|
+
if (body.startsWith("^"))
|
|
483
|
+
body = body.slice(1);
|
|
484
|
+
if (body.endsWith("$") && !body.endsWith("\\$"))
|
|
485
|
+
body = body.slice(0, -1);
|
|
486
|
+
const chars = [...body];
|
|
487
|
+
let i = 0;
|
|
488
|
+
let depth = 0;
|
|
489
|
+
let canQuantify = false;
|
|
490
|
+
const singleEscapes = "()*+-.?[\\]^{|}nrt";
|
|
491
|
+
const readEscape = () => {
|
|
492
|
+
const next = chars[i + 1];
|
|
493
|
+
if (next === undefined)
|
|
494
|
+
reject("dangling escape");
|
|
495
|
+
if (!singleEscapes.includes(next))
|
|
496
|
+
reject(`escape \\${next}`);
|
|
497
|
+
i += 2;
|
|
498
|
+
};
|
|
499
|
+
while (i < chars.length) {
|
|
500
|
+
const ch = chars[i];
|
|
501
|
+
if (ch === "\\") {
|
|
502
|
+
readEscape();
|
|
503
|
+
canQuantify = true;
|
|
504
|
+
}
|
|
505
|
+
else if (ch === "[") {
|
|
506
|
+
i += 1;
|
|
507
|
+
if (chars[i] === "^")
|
|
508
|
+
i += 1;
|
|
509
|
+
// A leading `]` is literal in some engines and an empty class in others.
|
|
510
|
+
if (chars[i] === "]")
|
|
511
|
+
reject("empty character class");
|
|
512
|
+
while (i < chars.length && chars[i] !== "]") {
|
|
513
|
+
if (chars[i] === "\\")
|
|
514
|
+
readEscape();
|
|
515
|
+
else if (chars[i] === "[")
|
|
516
|
+
reject("nested character class");
|
|
517
|
+
else
|
|
518
|
+
i += 1;
|
|
519
|
+
}
|
|
520
|
+
if (chars[i] !== "]")
|
|
521
|
+
reject("unterminated character class");
|
|
522
|
+
i += 1;
|
|
523
|
+
canQuantify = true;
|
|
524
|
+
}
|
|
525
|
+
else if (ch === "(") {
|
|
526
|
+
if (chars[i + 1] === "?")
|
|
527
|
+
reject("group modifiers");
|
|
528
|
+
depth += 1;
|
|
529
|
+
i += 1;
|
|
530
|
+
canQuantify = false;
|
|
531
|
+
}
|
|
532
|
+
else if (ch === ")") {
|
|
533
|
+
if (depth === 0)
|
|
534
|
+
reject("unbalanced parenthesis");
|
|
535
|
+
depth -= 1;
|
|
536
|
+
i += 1;
|
|
537
|
+
canQuantify = true;
|
|
538
|
+
}
|
|
539
|
+
else if (ch === "|") {
|
|
540
|
+
i += 1;
|
|
541
|
+
canQuantify = false;
|
|
542
|
+
}
|
|
543
|
+
else if (ch === "*" || ch === "+" || ch === "?") {
|
|
544
|
+
if (!canQuantify)
|
|
545
|
+
reject("quantifier without operand");
|
|
546
|
+
i += 1;
|
|
547
|
+
if (chars[i] === "?" || chars[i] === "+")
|
|
548
|
+
reject("lazy or possessive quantifier");
|
|
549
|
+
canQuantify = false;
|
|
550
|
+
}
|
|
551
|
+
else if (ch === "{") {
|
|
552
|
+
if (!canQuantify)
|
|
553
|
+
reject("quantifier without operand");
|
|
554
|
+
const rest = chars.slice(i).join("");
|
|
555
|
+
const match = /^\{[0-9]+(,[0-9]*)?\}/.exec(rest);
|
|
556
|
+
if (!match)
|
|
557
|
+
reject("malformed quantifier");
|
|
558
|
+
i += [...match[0]].length;
|
|
559
|
+
if (chars[i] === "?" || chars[i] === "+")
|
|
560
|
+
reject("lazy or possessive quantifier");
|
|
561
|
+
canQuantify = false;
|
|
562
|
+
}
|
|
563
|
+
else if (ch === ".") {
|
|
564
|
+
reject("'.' outside a character class");
|
|
565
|
+
}
|
|
566
|
+
else if (ch === "^" || ch === "$") {
|
|
567
|
+
reject("anchor inside the pattern");
|
|
568
|
+
}
|
|
569
|
+
else if (ch === "}" || ch === "]") {
|
|
570
|
+
reject(`unescaped ${ch}`);
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
i += 1;
|
|
574
|
+
canQuantify = true;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (depth !== 0)
|
|
578
|
+
reject("unbalanced parenthesis");
|
|
579
|
+
}
|
|
580
|
+
/** A copy of `schema` without annotation-only keywords, which validators must not assert. */
|
|
581
|
+
function stripAnnotations(schema) {
|
|
582
|
+
const copy = structuredClone(schema);
|
|
583
|
+
walkSchema(copy, (node) => {
|
|
584
|
+
for (const keyword of ANNOTATION_KEYWORDS)
|
|
585
|
+
delete node[keyword];
|
|
586
|
+
});
|
|
587
|
+
return copy;
|
|
588
|
+
}
|
|
589
|
+
const ROOM_POLICY_FIELDS = ["invites", "open_roles", "max_speakers", "observer_allowed", "extra"];
|
|
590
|
+
/** Section 8.3 rules for a room policy. */
|
|
591
|
+
export function validateRoomPolicy(policy) {
|
|
592
|
+
if (policy === undefined)
|
|
593
|
+
return;
|
|
594
|
+
if (typeof policy !== "object" || policy === null || Array.isArray(policy)) {
|
|
595
|
+
throw protocolError("invalid_event", "policy must be an object");
|
|
596
|
+
}
|
|
597
|
+
for (const key of Object.keys(policy)) {
|
|
598
|
+
if (!ROOM_POLICY_FIELDS.includes(key))
|
|
599
|
+
throw protocolError("invalid_event", `unknown policy field: ${key}`);
|
|
600
|
+
}
|
|
601
|
+
const maxSpeakers = policy.max_speakers;
|
|
602
|
+
if (maxSpeakers !== undefined &&
|
|
603
|
+
(!Number.isInteger(maxSpeakers) || maxSpeakers < 1)) {
|
|
604
|
+
throw protocolError("invalid_event", "max_speakers must be a positive integer");
|
|
605
|
+
}
|
|
606
|
+
const observerAllowed = policy.observer_allowed ?? true;
|
|
607
|
+
if (policy.invites !== undefined) {
|
|
608
|
+
if (typeof policy.invites !== "object" || policy.invites === null || Array.isArray(policy.invites)) {
|
|
609
|
+
throw protocolError("invalid_event", "invites must be an object");
|
|
610
|
+
}
|
|
611
|
+
for (const [agentId, role] of Object.entries(policy.invites)) {
|
|
612
|
+
validateAgentId(agentId);
|
|
613
|
+
if (!includes(ROLES, role))
|
|
614
|
+
throw protocolError("invalid_event", `invalid invited role: ${role}`);
|
|
615
|
+
if (role === "observer" && !observerAllowed)
|
|
616
|
+
throw protocolError("role_not_allowed", "observers are not allowed");
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (policy.open_roles !== undefined) {
|
|
620
|
+
if (!Array.isArray(policy.open_roles) || new Set(policy.open_roles).size !== policy.open_roles.length) {
|
|
621
|
+
throw protocolError("invalid_event", "open_roles must be a list of unique roles");
|
|
622
|
+
}
|
|
623
|
+
for (const role of policy.open_roles) {
|
|
624
|
+
if (role !== "speaker" && role !== "observer")
|
|
625
|
+
throw protocolError("invalid_event", `open_roles cannot contain ${role}`);
|
|
626
|
+
if (role === "observer" && !observerAllowed)
|
|
627
|
+
throw protocolError("role_not_allowed", "observers are not allowed");
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Section 8.3 rule tying the policy to the room's visibility: a private room
|
|
633
|
+
* admits agents only by invitation or review, so its `open_roles` must be empty.
|
|
634
|
+
*/
|
|
635
|
+
export function validateRoomVisibilityPolicy(visibility, policy) {
|
|
636
|
+
if (visibility === "private" && (policy?.open_roles?.length ?? 0) > 0) {
|
|
637
|
+
throw protocolError("invalid_event", "a private room cannot have open roles");
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* The roles any agent may take by direct `room.join` (Section 8.3): the
|
|
642
|
+
* explicit `open_roles`, or by default `observer` in a public room when
|
|
643
|
+
* observers are allowed, and none otherwise.
|
|
644
|
+
*/
|
|
645
|
+
export function effectiveOpenRoles(visibility, policy) {
|
|
646
|
+
if (policy?.open_roles !== undefined)
|
|
647
|
+
return [...policy.open_roles];
|
|
648
|
+
return visibility === "public" && policy?.observer_allowed !== false ? ["observer"] : [];
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Section 9.2 direct-join eligibility: the actor is invited with exactly
|
|
652
|
+
* `role`, or `role` is one of the room's effective open roles. Bans and
|
|
653
|
+
* quotas are separate host checks.
|
|
654
|
+
*/
|
|
655
|
+
export function canJoinDirectly(visibility, policy, actor, role) {
|
|
656
|
+
if (policy?.invites?.[actor] === role)
|
|
657
|
+
return true;
|
|
658
|
+
return effectiveOpenRoles(visibility, policy).includes(role);
|
|
659
|
+
}
|
|
313
660
|
export function validateRoomCreatePayload(payload) {
|
|
661
|
+
try {
|
|
662
|
+
validateOrigin(payload.host);
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
throw protocolError("invalid_event", "room.create host must be an HTTPS origin");
|
|
666
|
+
}
|
|
314
667
|
if (payload.topic.trim() === "") {
|
|
315
668
|
throw protocolError("invalid_event", "room topic must not be empty");
|
|
316
669
|
}
|
|
317
670
|
if (payload.start_time >= payload.end_time) {
|
|
318
671
|
throw protocolError("invalid_event", "start_time must be before end_time");
|
|
319
672
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
(!Number.isInteger(maxSpeakers) || maxSpeakers < 1)) {
|
|
323
|
-
throw protocolError("invalid_event", "max_speakers must be a positive integer");
|
|
324
|
-
}
|
|
673
|
+
validateRoomPolicy(payload.policy);
|
|
674
|
+
validateRoomVisibilityPolicy(payload.visibility, payload.policy);
|
|
325
675
|
for (const declaration of payload.types ?? []) {
|
|
326
676
|
validateTypeDeclaration(declaration);
|
|
327
677
|
}
|
|
328
678
|
}
|
|
679
|
+
/** Host binding check (Section 8.1): `host` must be the receiving host's API origin. */
|
|
680
|
+
export function validateRoomCreateHost(payload, hostOrigin) {
|
|
681
|
+
if (payload.host !== hostOrigin) {
|
|
682
|
+
throw protocolError("host_mismatch", `room.create names ${payload.host}, not ${hostOrigin}`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
329
685
|
export function validateMessageCreatePayload(payload) {
|
|
330
|
-
if (payload.content_type.trim() === "") {
|
|
686
|
+
if (typeof payload.content_type !== "string" || payload.content_type.trim() === "") {
|
|
331
687
|
throw protocolError("invalid_event", "content_type must not be empty");
|
|
332
688
|
}
|
|
689
|
+
const content = payload.content;
|
|
690
|
+
if (typeof content !== "string" && (typeof content !== "object" || content === null || Array.isArray(content))) {
|
|
691
|
+
throw protocolError("invalid_event", "content must be a string or an object");
|
|
692
|
+
}
|
|
333
693
|
}
|
|
334
694
|
export function validateRoomJoinPayload(payload) {
|
|
695
|
+
for (const key of Object.keys(payload)) {
|
|
696
|
+
if (key !== "role" && key !== "perspective") {
|
|
697
|
+
throw protocolError("invalid_event", `unknown room.join payload field: ${key}`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
if (!includes(ROLES, payload.role)) {
|
|
701
|
+
throw protocolError("invalid_event", `invalid room role: ${payload.role}`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
export function validateRoomJoinRequestPayload(payload) {
|
|
705
|
+
for (const key of Object.keys(payload)) {
|
|
706
|
+
if (!["role", "perspective", "reason", "extra"].includes(key)) {
|
|
707
|
+
throw protocolError("invalid_event", `unknown room.join.request payload field: ${key}`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
335
710
|
if (!includes(ROLES, payload.role)) {
|
|
336
711
|
throw protocolError("invalid_event", `invalid room role: ${payload.role}`);
|
|
337
712
|
}
|
|
338
|
-
|
|
339
|
-
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Verifies a signed `room.join.request` envelope for embedding or review:
|
|
716
|
+
* hash, signature, and shape — historical verification, without the live
|
|
717
|
+
* time window or nonce check.
|
|
718
|
+
*/
|
|
719
|
+
export function validateJoinRequestEnvelope(envelope, roomId) {
|
|
720
|
+
validateDiscourseEnvelope(envelope);
|
|
721
|
+
if (envelope.event.type !== eventType.ROOM_JOIN_REQUEST) {
|
|
722
|
+
throw protocolError("invalid_event", "embedded request must be a room.join.request");
|
|
723
|
+
}
|
|
724
|
+
if (roomId !== undefined && envelope.event.room_id !== roomId) {
|
|
725
|
+
throw protocolError("room_id_mismatch", "join request belongs to another room");
|
|
726
|
+
}
|
|
727
|
+
validateRoomJoinRequestPayload(envelope.event.payload);
|
|
728
|
+
}
|
|
729
|
+
/** Shape checks for `room.join.review`, including the embedded signed request. */
|
|
730
|
+
export function validateRoomJoinReviewPayload(payload, roomId) {
|
|
731
|
+
validateJoinRequestEnvelope(payload.request, roomId);
|
|
732
|
+
if (payload.decision !== "approve" && payload.decision !== "reject") {
|
|
733
|
+
throw protocolError("invalid_event", `invalid review decision: ${payload.decision}`);
|
|
340
734
|
}
|
|
341
|
-
if (payload.
|
|
342
|
-
throw protocolError("invalid_event", "
|
|
735
|
+
if (payload.decision === "approve" && !includes(ROLES, payload.role)) {
|
|
736
|
+
throw protocolError("invalid_event", "an approving review requires a role");
|
|
343
737
|
}
|
|
344
738
|
}
|
|
345
739
|
const ROOM_UPDATE_FIELDS = [
|
|
@@ -375,11 +769,7 @@ export function validateRoomUpdatePayload(payload) {
|
|
|
375
769
|
payload.start_time >= payload.end_time) {
|
|
376
770
|
throw protocolError("invalid_event", "start_time must be before end_time");
|
|
377
771
|
}
|
|
378
|
-
|
|
379
|
-
if (maxSpeakers !== undefined &&
|
|
380
|
-
(!Number.isInteger(maxSpeakers) || maxSpeakers < 1)) {
|
|
381
|
-
throw protocolError("invalid_event", "max_speakers must be a positive integer");
|
|
382
|
-
}
|
|
772
|
+
validateRoomPolicy(payload.policy);
|
|
383
773
|
}
|
|
384
774
|
/**
|
|
385
775
|
* Shape checks for a `room.member.remove` payload. Creator, self, and
|
|
@@ -395,36 +785,43 @@ export function validateRoomMemberRemovePayload(payload) {
|
|
|
395
785
|
export class TypeRegistry {
|
|
396
786
|
types = new Map();
|
|
397
787
|
/**
|
|
398
|
-
* Materializes a registry from declarations, resolving
|
|
399
|
-
* `packs`, keyed by registered pack id or external pack
|
|
788
|
+
* Materializes a registry from the `room.create` declarations, resolving
|
|
789
|
+
* pack imports from `packs`, keyed by registered pack id or external pack
|
|
790
|
+
* URI. A type name may appear only once across these declarations.
|
|
400
791
|
*/
|
|
401
792
|
static fromDeclarations(declarations, packs = {}) {
|
|
402
793
|
const registry = new TypeRegistry();
|
|
794
|
+
const declared = new Set();
|
|
403
795
|
for (const declaration of declarations) {
|
|
404
|
-
registry.apply(declaration, packs)
|
|
796
|
+
for (const name of registry.apply(declaration, packs)) {
|
|
797
|
+
if (declared.has(name)) {
|
|
798
|
+
throw protocolError("type_conflict", `type ${name} is declared twice`);
|
|
799
|
+
}
|
|
800
|
+
declared.add(name);
|
|
801
|
+
}
|
|
405
802
|
}
|
|
406
803
|
return registry;
|
|
407
804
|
}
|
|
408
805
|
/**
|
|
409
|
-
* Applies one declaration
|
|
410
|
-
*
|
|
806
|
+
* Applies one declaration — an inline definition or a pack import — and
|
|
807
|
+
* returns the type names it declared. Declaring an existing type is a
|
|
808
|
+
* redefinition: it must keep the type's kind, and the latest definition wins.
|
|
411
809
|
*/
|
|
412
810
|
apply(declaration, packs = {}) {
|
|
413
811
|
if (isPackImport(declaration)) {
|
|
414
|
-
this.import(declaration, packs);
|
|
812
|
+
return this.import(declaration, packs);
|
|
415
813
|
}
|
|
416
|
-
|
|
814
|
+
if (isTypeDef(declaration)) {
|
|
417
815
|
this.define(declaration);
|
|
816
|
+
return [declaration.type];
|
|
418
817
|
}
|
|
419
|
-
|
|
420
|
-
throw protocolError("invalid_event", "type declaration must be an inline definition or a pack import");
|
|
421
|
-
}
|
|
818
|
+
throw protocolError("invalid_event", "type declaration must be an inline definition or a pack import");
|
|
422
819
|
}
|
|
423
820
|
define(def) {
|
|
424
821
|
validateTypeDef(def);
|
|
425
822
|
const existing = this.types.get(def.type);
|
|
426
823
|
if (existing && existing.kind !== def.kind) {
|
|
427
|
-
throw protocolError("
|
|
824
|
+
throw protocolError("type_conflict", `type ${def.type} cannot change kind from ${existing.kind} to ${def.kind}`);
|
|
428
825
|
}
|
|
429
826
|
this.types.set(def.type, def);
|
|
430
827
|
}
|
|
@@ -435,25 +832,34 @@ export class TypeRegistry {
|
|
|
435
832
|
if (!pack) {
|
|
436
833
|
throw protocolError("pack_unavailable", `pack not available: ${reference}`);
|
|
437
834
|
}
|
|
438
|
-
const available = new Set(
|
|
835
|
+
const available = new Set();
|
|
836
|
+
for (const def of pack.types) {
|
|
837
|
+
if (available.has(def.type)) {
|
|
838
|
+
throw protocolError("type_conflict", `pack ${reference} defines ${def.type} twice`);
|
|
839
|
+
}
|
|
840
|
+
available.add(def.type);
|
|
841
|
+
}
|
|
439
842
|
for (const name of declaration.types ?? []) {
|
|
440
843
|
if (!available.has(name)) {
|
|
441
|
-
throw protocolError("
|
|
844
|
+
throw protocolError("type_conflict", `type ${name} is not in pack ${reference}`);
|
|
442
845
|
}
|
|
443
846
|
}
|
|
444
847
|
const subset = declaration.types !== undefined ? new Set(declaration.types) : undefined;
|
|
445
848
|
for (const name of Object.keys(declaration.overrides ?? {})) {
|
|
446
849
|
const imported = subset ? subset.has(name) : available.has(name);
|
|
447
850
|
if (!imported) {
|
|
448
|
-
throw protocolError("
|
|
851
|
+
throw protocolError("type_conflict", `override target ${name} is not imported from pack ${reference}`);
|
|
449
852
|
}
|
|
450
853
|
}
|
|
854
|
+
const declared = [];
|
|
451
855
|
for (const def of pack.types) {
|
|
452
856
|
if (subset && !subset.has(def.type))
|
|
453
857
|
continue;
|
|
454
858
|
const override = declaration.overrides?.[def.type];
|
|
455
859
|
this.define(override ? { ...def, ...override } : { ...def });
|
|
860
|
+
declared.push(def.type);
|
|
456
861
|
}
|
|
862
|
+
return declared;
|
|
457
863
|
}
|
|
458
864
|
get(type) {
|
|
459
865
|
return this.types.get(type);
|
|
@@ -498,10 +904,11 @@ export function validateEventAgainstRegistry(type, payload, registry) {
|
|
|
498
904
|
}
|
|
499
905
|
function compileSchema(schema) {
|
|
500
906
|
try {
|
|
501
|
-
|
|
907
|
+
// Annotation keywords are never asserted (Section 12.3.1).
|
|
908
|
+
return new Validator(stripAnnotations(schema), "2020-12", false);
|
|
502
909
|
}
|
|
503
910
|
catch (error) {
|
|
504
|
-
throw protocolError("
|
|
911
|
+
throw protocolError("invalid_type_schema", `invalid type schema: ${error}`);
|
|
505
912
|
}
|
|
506
913
|
}
|
|
507
914
|
/**
|
|
@@ -509,15 +916,12 @@ function compileSchema(schema) {
|
|
|
509
916
|
* Supports `sha256` and `sha3-256`.
|
|
510
917
|
*/
|
|
511
918
|
export function verifyPackDigest(bytes, digest) {
|
|
512
|
-
|
|
513
|
-
if (separator < 0) {
|
|
919
|
+
if (!CONTENT_DIGEST_PATTERN.test(digest)) {
|
|
514
920
|
throw protocolError("pack_unavailable", `invalid digest format: ${digest}`);
|
|
515
921
|
}
|
|
922
|
+
const separator = digest.indexOf(":");
|
|
516
923
|
const algorithm = digest.slice(0, separator);
|
|
517
924
|
const expected = digest.slice(separator + 1);
|
|
518
|
-
if (algorithm !== "sha256" && algorithm !== "sha3-256") {
|
|
519
|
-
throw protocolError("pack_unavailable", `unsupported digest algorithm: ${algorithm}`);
|
|
520
|
-
}
|
|
521
925
|
const actual = createHash(algorithm)
|
|
522
926
|
.update(bytes)
|
|
523
927
|
.digest("base64url");
|
|
@@ -525,34 +929,57 @@ export function verifyPackDigest(bytes, digest) {
|
|
|
525
929
|
throw protocolError("pack_unavailable", "pack digest mismatch");
|
|
526
930
|
}
|
|
527
931
|
}
|
|
528
|
-
export function serverRecordHashPayload(roomId, seq, preHash, envelopeHash,
|
|
932
|
+
export function serverRecordHashPayload(roomId, seq, preHash, envelopeHash, acceptedAt) {
|
|
529
933
|
return {
|
|
530
934
|
room_id: roomId,
|
|
531
935
|
seq,
|
|
532
936
|
pre_hash: preHash ?? null,
|
|
533
937
|
envelope_hash: envelopeHash,
|
|
534
|
-
|
|
938
|
+
accepted_at: acceptedAt,
|
|
535
939
|
};
|
|
536
940
|
}
|
|
537
|
-
export function serverRecordHash(roomId, seq, preHash, envelopeHash,
|
|
538
|
-
return hashCanonicalJson(serverRecordHashPayload(roomId, seq, preHash, envelopeHash,
|
|
941
|
+
export function serverRecordHash(roomId, seq, preHash, envelopeHash, acceptedAt) {
|
|
942
|
+
return hashCanonicalJson(serverRecordHashPayload(roomId, seq, preHash, envelopeHash, acceptedAt));
|
|
539
943
|
}
|
|
540
|
-
export function buildServerRecord(roomId, seq, preHash,
|
|
944
|
+
export function buildServerRecord(roomId, seq, preHash, acceptedAt, envelope) {
|
|
541
945
|
const normalizedPreHash = preHash ?? null;
|
|
542
946
|
return {
|
|
543
947
|
room_id: roomId,
|
|
544
948
|
seq,
|
|
545
949
|
pre_hash: normalizedPreHash,
|
|
546
|
-
hash: serverRecordHash(roomId, seq, normalizedPreHash, envelope.hash,
|
|
547
|
-
|
|
950
|
+
hash: serverRecordHash(roomId, seq, normalizedPreHash, envelope.hash, acceptedAt),
|
|
951
|
+
accepted_at: acceptedAt,
|
|
548
952
|
envelope,
|
|
549
953
|
};
|
|
550
954
|
}
|
|
955
|
+
export function isRedactedEnvelope(value) {
|
|
956
|
+
return typeof value === "object" && value !== null && value.redacted === true;
|
|
957
|
+
}
|
|
958
|
+
export function isRedactedRecord(record) {
|
|
959
|
+
return isRedactedEnvelope(record.envelope);
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Replaces a record's envelope with its redacted form (Section 14.1). Only
|
|
963
|
+
* `message.create` and custom-type records may be redacted.
|
|
964
|
+
*/
|
|
965
|
+
export function redactServerRecord(record) {
|
|
966
|
+
const type = record.envelope.event.type;
|
|
967
|
+
if (isBuiltinEventType(type) && type !== eventType.MESSAGE_CREATE) {
|
|
968
|
+
throw protocolError("invalid_event", `${type} records cannot be redacted`);
|
|
969
|
+
}
|
|
970
|
+
return { ...record, envelope: { hash: record.envelope.hash, redacted: true, type } };
|
|
971
|
+
}
|
|
551
972
|
export function verifyServerRecord(record) {
|
|
552
|
-
const expected = serverRecordHash(record.room_id, record.seq, record.pre_hash, record.envelope.hash, record.
|
|
973
|
+
const expected = serverRecordHash(record.room_id, record.seq, record.pre_hash, record.envelope.hash, record.accepted_at);
|
|
553
974
|
if (record.hash !== expected) {
|
|
554
975
|
throw protocolError("invalid_record_hash", `invalid server record hash: expected ${expected}, got ${record.hash}`);
|
|
555
976
|
}
|
|
977
|
+
if (isRedactedRecord(record)) {
|
|
978
|
+
const type = record.envelope.type;
|
|
979
|
+
if (isBuiltinEventType(type) && type !== eventType.MESSAGE_CREATE) {
|
|
980
|
+
throw protocolError("invalid_record_chain", `${type} records cannot be redacted`);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
556
983
|
}
|
|
557
984
|
export function verifyServerRecordChain(records) {
|
|
558
985
|
let previous;
|
|
@@ -575,8 +1002,29 @@ export function verifyServerRecordChain(records) {
|
|
|
575
1002
|
previous = record;
|
|
576
1003
|
}
|
|
577
1004
|
}
|
|
578
|
-
|
|
579
|
-
|
|
1005
|
+
/**
|
|
1006
|
+
* Archive verification steps 1–3 (Section 18): a gap-free chain from seq 1 to
|
|
1007
|
+
* `last_seq` ending in `last_hash`, and a valid signature on every record that
|
|
1008
|
+
* is not redacted. Returns the sequence numbers of redacted records, which
|
|
1009
|
+
* verifiers must report. State replay (step 4) is the caller's.
|
|
1010
|
+
*/
|
|
1011
|
+
export function verifyArchiveRecords(manifest, records) {
|
|
1012
|
+
verifyServerRecordChain(records);
|
|
1013
|
+
const last = records[records.length - 1];
|
|
1014
|
+
if (!last || last.seq !== manifest.last_seq || last.hash !== manifest.last_hash) {
|
|
1015
|
+
throw protocolError("invalid_record_chain", "archive does not end at last_seq / last_hash");
|
|
1016
|
+
}
|
|
1017
|
+
const redacted = [];
|
|
1018
|
+
for (const record of records) {
|
|
1019
|
+
if (record.room_id !== manifest.room_id) {
|
|
1020
|
+
throw protocolError("invalid_record_chain", "record belongs to another room");
|
|
1021
|
+
}
|
|
1022
|
+
if (isRedactedRecord(record))
|
|
1023
|
+
redacted.push(record.seq);
|
|
1024
|
+
else
|
|
1025
|
+
validateDiscourseEnvelope(record.envelope);
|
|
1026
|
+
}
|
|
1027
|
+
return redacted;
|
|
580
1028
|
}
|
|
581
1029
|
/** Default sender roles for each kind. The creator passes every role check. */
|
|
582
1030
|
export function defaultKindRoles(kind) {
|
|
@@ -598,9 +1046,12 @@ export function canSubmitEvent(type, context, registry = new TypeRegistry()) {
|
|
|
598
1046
|
case eventType.ROOM_CREATE:
|
|
599
1047
|
return true;
|
|
600
1048
|
case eventType.ROOM_JOIN:
|
|
601
|
-
return Boolean(context.
|
|
1049
|
+
return Boolean(context.directJoinAllowed) && !context.isCreator && context.role === undefined;
|
|
1050
|
+
case eventType.ROOM_JOIN_REQUEST:
|
|
1051
|
+
return !context.isCreator && context.role === undefined;
|
|
602
1052
|
case eventType.ROOM_LEAVE:
|
|
603
|
-
|
|
1053
|
+
// The creator is a member until the room ends and cannot leave.
|
|
1054
|
+
return !context.isCreator && context.role !== undefined;
|
|
604
1055
|
case eventType.ROOM_UPDATE:
|
|
605
1056
|
case eventType.ROOM_JOIN_REVIEW:
|
|
606
1057
|
case eventType.ROOM_MEMBER_ROLE_UPDATE:
|
|
@@ -629,7 +1080,8 @@ export function canSubmitEvent(type, context, registry = new TypeRegistry()) {
|
|
|
629
1080
|
export function canWriteInState(type, state) {
|
|
630
1081
|
switch (state) {
|
|
631
1082
|
case "scheduled":
|
|
632
|
-
return (type === eventType.
|
|
1083
|
+
return (type === eventType.ROOM_JOIN_REQUEST ||
|
|
1084
|
+
type === eventType.ROOM_JOIN ||
|
|
633
1085
|
type === eventType.ROOM_JOIN_REVIEW ||
|
|
634
1086
|
type === eventType.ROOM_MEMBER_ROLE_UPDATE ||
|
|
635
1087
|
type === eventType.ROOM_MEMBER_REMOVE ||
|