@inixiative/signet 0.4.6 → 0.5.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/README.md +30 -1
- package/package.json +3 -2
- package/src/client.ts +50 -10
- package/src/contracts.ts +355 -29
- package/src/index.ts +2 -0
- package/src/installationPairing.ts +17 -4
- package/src/installationSocket.ts +381 -10
- package/src/signetSocket.ts +214 -0
- package/src/threadStreams.ts +315 -0
- package/src/threads.ts +426 -0
package/src/threads.ts
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Agent conversations (Kingdom `docs/AGENT-CONVERSATIONS.md`, Foundry `docs/reference/CONVERSATIONS.md`).
|
|
5
|
+
* A member reaches a host Foundry's main thread through a Signet on the Foundry's single `threads`
|
|
6
|
+
* resource. Kingdom admits each call and relays it over the host's Installation socket; the host
|
|
7
|
+
* answers. Nothing here is stored by Kingdom.
|
|
8
|
+
*/
|
|
9
|
+
export const threadsResourceKind = 'threads';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A Foundry id (a thread, an invitation, a held prompt). Thread ids sit inside Kingdom stream names,
|
|
13
|
+
* which are colon-separated, and inside URL paths, so neither `:` nor `/` is allowed. Main threads
|
|
14
|
+
* are `thread_<uuidv7>`.
|
|
15
|
+
*/
|
|
16
|
+
export const foundryIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,128}$/);
|
|
17
|
+
export const threadIdSchema = foundryIdSchema;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Selector forms that are named for standing Signets (design §9) but not built. Each fails to parse
|
|
21
|
+
* on every party until a release adds it to `threadSelectorSchema`, so a new form only takes effect
|
|
22
|
+
* once every side has upgraded and never widens access silently.
|
|
23
|
+
*
|
|
24
|
+
* - `{ form: 'withMember' }`: threads opened with the grant's recipient;
|
|
25
|
+
* - `{ form: 'tag', tag }`: threads carrying a tag.
|
|
26
|
+
*/
|
|
27
|
+
export const unbuiltThreadSelectorForms = ['withMember', 'tag'] as const;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `ids` takes `within` only once a non-`ids` form exists: `within` will hold exactly one such form
|
|
31
|
+
* (never a selector, so it can't nest), set only by approving a request for a selector form narrowed
|
|
32
|
+
* to `ids` (the grant is then `ids ∩ within`), and approval may never drop or widen it. Until then
|
|
33
|
+
* the strict object refuses `within` like any unknown key: structural, and representable in Kingdom's
|
|
34
|
+
* OpenAPI (a `z.never()` placeholder is not).
|
|
35
|
+
*/
|
|
36
|
+
export const idsThreadSelectorSchema = z.strictObject({
|
|
37
|
+
form: z.literal('ids'),
|
|
38
|
+
ids: z.array(threadIdSchema).min(1).max(100),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** Which threads a grant reaches. A discriminated union on `form`; v1 builds only `ids`. */
|
|
42
|
+
export const threadSelectorSchema = z.discriminatedUnion('form', [idsThreadSelectorSchema]);
|
|
43
|
+
export type ThreadSelector = z.infer<typeof threadSelectorSchema>;
|
|
44
|
+
|
|
45
|
+
/** `own`: the member's posts and the sends addressed to it. `thread`: the main thread's messages. */
|
|
46
|
+
export const threadViewSchema = z.enum(['own', 'thread']);
|
|
47
|
+
/** `note`: rides the owner's next turn. `turn`: queued as a turn of its own. */
|
|
48
|
+
export const threadDeliverySchema = z.enum(['note', 'turn']);
|
|
49
|
+
/** `review`: the host's sends to this member wait for the owner. `auto`: they leave at once. */
|
|
50
|
+
export const threadOutboundSchema = z.enum(['review', 'auto']);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A member's terms on a `threads` resource. Every term but the selector defaults to its narrowest
|
|
54
|
+
* value, so a lens that omits one can only be more careful. `threads` has no default: `{}` is never a
|
|
55
|
+
* valid threads lens, which is why a grant's preset must fill it (review R9).
|
|
56
|
+
*/
|
|
57
|
+
export const threadsLensSchema = z.strictObject({
|
|
58
|
+
threads: threadSelectorSchema,
|
|
59
|
+
view: threadViewSchema.default('own'),
|
|
60
|
+
delivery: threadDeliverySchema.default('note'),
|
|
61
|
+
outbound: threadOutboundSchema.default('review'),
|
|
62
|
+
});
|
|
63
|
+
export type ThreadsLens = z.infer<typeof threadsLensSchema>;
|
|
64
|
+
|
|
65
|
+
/** Is `threadId` inside the selector? For `ids`, Kingdom answers this at admission. */
|
|
66
|
+
export const selectsThread = (selector: ThreadSelector, threadId: string): boolean => {
|
|
67
|
+
switch (selector.form) {
|
|
68
|
+
case 'ids':
|
|
69
|
+
return selector.ids.includes(threadId);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** `granted` reaches no thread `requested` doesn't: per form, as the design's narrowing rules say. */
|
|
74
|
+
export const narrowsThreadSelector = (
|
|
75
|
+
granted: ThreadSelector,
|
|
76
|
+
requested: ThreadSelector,
|
|
77
|
+
): boolean => {
|
|
78
|
+
switch (requested.form) {
|
|
79
|
+
case 'ids':
|
|
80
|
+
// When `within` exists, it must also be kept as asked: never dropped, never added.
|
|
81
|
+
return granted.form === 'ids' && granted.ids.every((id) => requested.ids.includes(id));
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const order = <T extends string>(values: readonly T[]) => {
|
|
86
|
+
const rank = (value: T) => values.indexOf(value);
|
|
87
|
+
return (granted: T, requested: T) => rank(granted) <= rank(requested);
|
|
88
|
+
};
|
|
89
|
+
const narrowsView = order(threadViewSchema.options);
|
|
90
|
+
const narrowsDelivery = order(threadDeliverySchema.options);
|
|
91
|
+
const narrowsOutbound = order(threadOutboundSchema.options);
|
|
92
|
+
|
|
93
|
+
/** Approving a request may only narrow (`own` < `thread`, `note` < `turn`, `review` < `auto`). */
|
|
94
|
+
export const narrowsThreadsLens = (granted: ThreadsLens, requested: ThreadsLens): boolean =>
|
|
95
|
+
narrowsThreadSelector(granted.threads, requested.threads) &&
|
|
96
|
+
narrowsView(granted.view, requested.view) &&
|
|
97
|
+
narrowsDelivery(granted.delivery, requested.delivery) &&
|
|
98
|
+
narrowsOutbound(granted.outbound, requested.outbound);
|
|
99
|
+
|
|
100
|
+
/** The operations a `threads` resource carries. `prompt.resolve` is for owner recipients only. */
|
|
101
|
+
export const threadOperationSchema = z.enum([
|
|
102
|
+
'thread.read',
|
|
103
|
+
'thread.prompt',
|
|
104
|
+
'thread.floor',
|
|
105
|
+
'prompt.resolve',
|
|
106
|
+
]);
|
|
107
|
+
export type ThreadOperation = z.infer<typeof threadOperationSchema>;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A party in a conversation, as Foundry names it (not "participant": that is a Warden contributor).
|
|
111
|
+
* Read paths (entries, views) use plain objects, so a host that adds a field never costs an older
|
|
112
|
+
* member its entries; lenses and request frames stay strict.
|
|
113
|
+
*/
|
|
114
|
+
export const conversationMemberSchema = z.object({
|
|
115
|
+
kind: z.enum(['owner', 'local-herald', 'signet']),
|
|
116
|
+
id: z.string().min(1).max(128),
|
|
117
|
+
});
|
|
118
|
+
export type ConversationMember = z.infer<typeof conversationMemberSchema>;
|
|
119
|
+
|
|
120
|
+
/** A post or a read is capped at 16,000 characters, like Archive document relays. */
|
|
121
|
+
export const threadTextLimit = 16_000;
|
|
122
|
+
|
|
123
|
+
export const threadFloorActionSchema = z.discriminatedUnion('action', [
|
|
124
|
+
z.strictObject({ action: z.literal('request') }),
|
|
125
|
+
z.strictObject({ action: z.literal('release') }),
|
|
126
|
+
z.strictObject({ action: z.literal('pass'), to: conversationMemberSchema }),
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The input each thread operation takes, as the host receives it. `access/execute` carries the same
|
|
131
|
+
* fields beside `resourceId` (`accessInputSchema`); Kingdom allows exactly these keys per operation.
|
|
132
|
+
*/
|
|
133
|
+
export const threadInputSchemas = {
|
|
134
|
+
'thread.read': z.strictObject({
|
|
135
|
+
threadId: threadIdSchema,
|
|
136
|
+
afterSeq: z.number().int().min(0).optional(),
|
|
137
|
+
}),
|
|
138
|
+
/** `postId` is the member's own id for the post: a retry with a new request id keeps it. */
|
|
139
|
+
'thread.prompt': z.strictObject({
|
|
140
|
+
threadId: threadIdSchema,
|
|
141
|
+
postId: z.uuid(),
|
|
142
|
+
text: z.string().min(1).max(threadTextLimit),
|
|
143
|
+
/** The invitation a `speak-when-spoken-to` reply answers. */
|
|
144
|
+
inReplyTo: foundryIdSchema.optional(),
|
|
145
|
+
}),
|
|
146
|
+
'thread.floor': z.strictObject({ threadId: threadIdSchema, floor: threadFloorActionSchema }),
|
|
147
|
+
'prompt.resolve': z.strictObject({
|
|
148
|
+
threadId: threadIdSchema,
|
|
149
|
+
promptId: foundryIdSchema,
|
|
150
|
+
/** `approved`, `rejected` or an option id, plus the edited text where the prompt takes input. */
|
|
151
|
+
answer: z.strictObject({
|
|
152
|
+
action: z.string().min(1).max(80),
|
|
153
|
+
input: z.string().max(threadTextLimit).optional(),
|
|
154
|
+
}),
|
|
155
|
+
}),
|
|
156
|
+
} as const satisfies Record<ThreadOperation, z.ZodObject>;
|
|
157
|
+
/** The `access/execute` input keys each thread operation takes, besides `resourceId`. */
|
|
158
|
+
export const threadInputKeys = <O extends ThreadOperation>(operation: O) =>
|
|
159
|
+
Object.keys(threadInputSchemas[operation].shape) as (keyof ThreadInput<O>)[];
|
|
160
|
+
export type ThreadInput<O extends ThreadOperation> = z.infer<(typeof threadInputSchemas)[O]>;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* One entry of the main thread's journal as a member sees it: an owner message, a main-agent
|
|
164
|
+
* message, a member's post (`member` = its author) or a released send (`member` = the addressee).
|
|
165
|
+
*/
|
|
166
|
+
export const threadEntrySchema = z.object({
|
|
167
|
+
journalSeq: z.number().int().min(0),
|
|
168
|
+
at: z.iso.datetime(),
|
|
169
|
+
kind: z.enum(['owner', 'agent', 'post', 'send']),
|
|
170
|
+
member: conversationMemberSchema.nullable(),
|
|
171
|
+
text: z.string().max(threadTextLimit),
|
|
172
|
+
});
|
|
173
|
+
export type ThreadEntry = z.infer<typeof threadEntrySchema>;
|
|
174
|
+
|
|
175
|
+
/** A member's view, taken at `journalSeq`: the answer to `thread.read` and to `thread.subscribe`. */
|
|
176
|
+
export const threadViewResultSchema = z.object({
|
|
177
|
+
journalSeq: z.number().int().min(0),
|
|
178
|
+
entries: z.array(threadEntrySchema).max(1000),
|
|
179
|
+
/** The view was cut at the character cap; read again after the last entry's `journalSeq`. */
|
|
180
|
+
truncated: z.boolean(),
|
|
181
|
+
});
|
|
182
|
+
export type ThreadViewResult = z.infer<typeof threadViewResultSchema>;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A view read per entry (`thread.read` results, stream snapshots): an entry this package can't read
|
|
186
|
+
* is dropped and counted, never costing the whole view. Null when the view itself is unreadable.
|
|
187
|
+
*/
|
|
188
|
+
export const parseThreadView = (value: unknown) => {
|
|
189
|
+
const record = z
|
|
190
|
+
.object({ entries: z.array(z.unknown()).max(1000) })
|
|
191
|
+
.loose()
|
|
192
|
+
.safeParse(value);
|
|
193
|
+
const view = threadViewResultSchema.omit({ entries: true }).safeParse(value);
|
|
194
|
+
if (!record.success || !view.success) return null;
|
|
195
|
+
const entries = record.data.entries.flatMap((entry) => {
|
|
196
|
+
const parsed = threadEntrySchema.safeParse(entry);
|
|
197
|
+
return parsed.success ? [parsed.data] : [];
|
|
198
|
+
});
|
|
199
|
+
return { ...view.data, entries, dropped: record.data.entries.length - entries.length };
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export const floorModeSchema = z.enum(['talk', 'speak-when-spoken-to', 'conch']);
|
|
203
|
+
|
|
204
|
+
/** What the host answers per operation (and to `thread.subscribe`). */
|
|
205
|
+
export const threadResultSchemas = {
|
|
206
|
+
'thread.read': threadViewResultSchema,
|
|
207
|
+
/** `duplicate`: the same `(signetId, postId)` was already admitted; `journalSeq` is the original's. */
|
|
208
|
+
'thread.prompt': z.strictObject({
|
|
209
|
+
accepted: z.literal(true),
|
|
210
|
+
journalSeq: z.number().int().min(0),
|
|
211
|
+
duplicate: z.boolean(),
|
|
212
|
+
}),
|
|
213
|
+
'thread.floor': z.strictObject({
|
|
214
|
+
mode: floorModeSchema,
|
|
215
|
+
holder: conversationMemberSchema.nullable(),
|
|
216
|
+
expiresAt: z.iso.datetime().nullable(),
|
|
217
|
+
}),
|
|
218
|
+
'prompt.resolve': z.strictObject({ resolved: z.literal(true) }),
|
|
219
|
+
'thread.subscribe': threadViewResultSchema,
|
|
220
|
+
} as const satisfies Record<ThreadOperation | 'thread.subscribe', z.ZodType>;
|
|
221
|
+
export type ThreadResult<O extends keyof typeof threadResultSchemas> = z.infer<
|
|
222
|
+
(typeof threadResultSchemas)[O]
|
|
223
|
+
>;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Why the host refused. The first six are the design's refusals, which Kingdom records as
|
|
227
|
+
* `AccessExecution.status = 'denied'` with this `errorCode` (§5.4). `invalid_request` is a frame the
|
|
228
|
+
* host could not read for any other reason, and `failed` an error inside the host; Kingdom records
|
|
229
|
+
* both as `failed`.
|
|
230
|
+
*/
|
|
231
|
+
export const threadRefusalSchema = z.enum([
|
|
232
|
+
'unknown_thread',
|
|
233
|
+
'unknown_selector',
|
|
234
|
+
'thread_closed',
|
|
235
|
+
'floor_denied',
|
|
236
|
+
'not_invited',
|
|
237
|
+
'conversation_paused',
|
|
238
|
+
'invalid_request',
|
|
239
|
+
'failed',
|
|
240
|
+
]);
|
|
241
|
+
export type ThreadRefusal = z.infer<typeof threadRefusalSchema>;
|
|
242
|
+
export const threadDeniedRefusals = [
|
|
243
|
+
'unknown_thread',
|
|
244
|
+
'unknown_selector',
|
|
245
|
+
'thread_closed',
|
|
246
|
+
'floor_denied',
|
|
247
|
+
'not_invited',
|
|
248
|
+
'conversation_paused',
|
|
249
|
+
] as const satisfies readonly ThreadRefusal[];
|
|
250
|
+
|
|
251
|
+
/** The member a frame acts for: its Signet and the name Kingdom shows for its recipient. */
|
|
252
|
+
export const threadMemberSchema = z.strictObject({
|
|
253
|
+
signetId: z.uuid(),
|
|
254
|
+
displayName: z.string().min(1).max(200),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const relayed = {
|
|
258
|
+
/** The `AccessExecution` id: one per admitted call, and the id the answer carries back. */
|
|
259
|
+
requestId: z.uuid(),
|
|
260
|
+
signetId: z.uuid(),
|
|
261
|
+
/** The Signet revision Kingdom admitted the call under; the lens is that revision's. */
|
|
262
|
+
acceptedRevision: z.number().int().min(1),
|
|
263
|
+
member: threadMemberSchema,
|
|
264
|
+
lens: threadsLensSchema,
|
|
265
|
+
};
|
|
266
|
+
const requestFrame = <O extends ThreadOperation>(operation: O) =>
|
|
267
|
+
z.strictObject({
|
|
268
|
+
type: z.literal('thread.request'),
|
|
269
|
+
...relayed,
|
|
270
|
+
operation: z.literal(operation),
|
|
271
|
+
input: threadInputSchemas[operation],
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Kingdom → host over the Installation socket: one admitted call. The host trusts it (Kingdom's
|
|
276
|
+
* admission is the membership check) and answers with `thread.response` within 15 s.
|
|
277
|
+
*/
|
|
278
|
+
export const threadRequestFrameSchema = z.discriminatedUnion('operation', [
|
|
279
|
+
requestFrame('thread.read'),
|
|
280
|
+
requestFrame('thread.prompt'),
|
|
281
|
+
requestFrame('thread.floor'),
|
|
282
|
+
requestFrame('prompt.resolve'),
|
|
283
|
+
]);
|
|
284
|
+
export type ThreadRequestFrame = z.infer<typeof threadRequestFrameSchema>;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Kingdom → host: open a member's live view, atomically. The answer (`thread.response` with
|
|
288
|
+
* `requestId`) is the view and the `journalSeq` it was taken at, and the subscription starts at that
|
|
289
|
+
* same point. `requestId` is also the subscription's id for appends, leases and unsubscribe.
|
|
290
|
+
*/
|
|
291
|
+
export const threadSubscribeFrameSchema = z.strictObject({
|
|
292
|
+
type: z.literal('thread.subscribe'),
|
|
293
|
+
...relayed,
|
|
294
|
+
threadId: threadIdSchema,
|
|
295
|
+
});
|
|
296
|
+
export type ThreadSubscribeFrame = z.infer<typeof threadSubscribeFrameSchema>;
|
|
297
|
+
|
|
298
|
+
/** Kingdom → host: the member's stream closed (leave, revocation, any revision, …). */
|
|
299
|
+
export const threadUnsubscribeFrameSchema = z.strictObject({
|
|
300
|
+
type: z.literal('thread.unsubscribe'),
|
|
301
|
+
subscriptionId: z.uuid(),
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Kingdom → host, on every 45 s re-authorize sweep: the subscriptions it still holds. The host drops
|
|
306
|
+
* any subscription not re-asserted within `threadSubscriptionLeaseMs`, and all of them when its
|
|
307
|
+
* socket reconnects, so a dead Kingdom node leaves nothing pushing.
|
|
308
|
+
*/
|
|
309
|
+
export const threadLeaseFrameSchema = z.strictObject({
|
|
310
|
+
type: z.literal('thread.lease'),
|
|
311
|
+
subscriptionIds: z.array(z.uuid()).max(10_000),
|
|
312
|
+
});
|
|
313
|
+
/**
|
|
314
|
+
* Two sweeps from the last re-assertion: a subscription survives one late sweep and is dropped by the
|
|
315
|
+
* next missed one, so at most 45 s after a missed re-assertion (Foundry test 27).
|
|
316
|
+
*/
|
|
317
|
+
export const threadSubscriptionLeaseMs = 90_000;
|
|
318
|
+
|
|
319
|
+
/** Every frame Kingdom sends a host for conversations. */
|
|
320
|
+
export const threadHostFrameSchema = z.discriminatedUnion('type', [
|
|
321
|
+
threadRequestFrameSchema,
|
|
322
|
+
threadSubscribeFrameSchema,
|
|
323
|
+
threadUnsubscribeFrameSchema,
|
|
324
|
+
threadLeaseFrameSchema,
|
|
325
|
+
]);
|
|
326
|
+
|
|
327
|
+
const refusedOutcome = z.strictObject({
|
|
328
|
+
ok: z.literal(false),
|
|
329
|
+
refusal: threadRefusalSchema,
|
|
330
|
+
message: z.string().max(500).optional(),
|
|
331
|
+
});
|
|
332
|
+
const responseFrame = <R extends z.ZodType>(result: R) =>
|
|
333
|
+
z.strictObject({
|
|
334
|
+
action: z.literal('thread.response'),
|
|
335
|
+
requestId: z.uuid(),
|
|
336
|
+
outcome: z.discriminatedUnion('ok', [
|
|
337
|
+
z.strictObject({ ok: z.literal(true), result }),
|
|
338
|
+
refusedOutcome,
|
|
339
|
+
]),
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
/** Host → Kingdom: the answer to a `thread.request` or `thread.subscribe`, read by what was asked. */
|
|
343
|
+
export const threadResponseFrameSchema = <O extends keyof typeof threadResultSchemas>(
|
|
344
|
+
operation: O,
|
|
345
|
+
) => responseFrame(threadResultSchemas[operation]);
|
|
346
|
+
/** The response envelope before Kingdom knows which request it answers. */
|
|
347
|
+
export const threadResponseEnvelopeSchema = responseFrame(z.unknown());
|
|
348
|
+
export type ThreadOutcome<O extends keyof typeof threadResultSchemas> =
|
|
349
|
+
| { ok: true; result: ThreadResult<O> }
|
|
350
|
+
| { ok: false; refusal: ThreadRefusal; message?: string };
|
|
351
|
+
export type ThreadResponseFrame<O extends keyof typeof threadResultSchemas> = {
|
|
352
|
+
action: 'thread.response';
|
|
353
|
+
requestId: string;
|
|
354
|
+
outcome: ThreadOutcome<O>;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
/** A host's answer: the operation's result. */
|
|
358
|
+
export const threadResult = <R>(result: R) => ({ ok: true as const, result });
|
|
359
|
+
/** A host's refusal. */
|
|
360
|
+
export const threadRefused = (refusal: ThreadRefusal, message?: string) => ({
|
|
361
|
+
ok: false as const,
|
|
362
|
+
refusal,
|
|
363
|
+
...(message ? { message: message.slice(0, 500) } : {}),
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Host → Kingdom: an entry for one subscription, already filtered to that member's view. `seq` counts
|
|
368
|
+
* this subscription's appends densely from 1 (the snapshot is 0): a filtered view skips journal
|
|
369
|
+
* entries, so `journalSeq` jumps on purpose and can't detect loss. Kingdom drops an append whose
|
|
370
|
+
* `journalSeq` is at or below the snapshot's, and sends the member `resync` when `seq` jumps. An
|
|
371
|
+
* entry the host can't send still spends its `seq`, so the member resyncs instead of missing it.
|
|
372
|
+
*/
|
|
373
|
+
export const threadAppendFrameSchema = z.strictObject({
|
|
374
|
+
action: z.literal('thread.append'),
|
|
375
|
+
subscriptionId: z.uuid(),
|
|
376
|
+
signetId: z.uuid(),
|
|
377
|
+
threadId: threadIdSchema,
|
|
378
|
+
seq: z.number().int().min(1),
|
|
379
|
+
journalSeq: z.number().int().min(0),
|
|
380
|
+
entry: threadEntrySchema,
|
|
381
|
+
});
|
|
382
|
+
export type ThreadAppendFrame = z.infer<typeof threadAppendFrameSchema>;
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* A member's live view of one thread is a Kingdom data stream (design §5.6.1): open, snapshot,
|
|
386
|
+
* append, close, a per-stream `seq`, `resync` on a gap, no replay. Its family is the operationId of
|
|
387
|
+
* Kingdom's snapshot route `GET /api/v1/access/threads/{signetId}/{threadId}`, and a stream's name is
|
|
388
|
+
* that route's channel key: the family, then each path field and value, fields sorted.
|
|
389
|
+
*/
|
|
390
|
+
export const accessThreadStreamFamily = 'accessThread';
|
|
391
|
+
export const accessThreadStreamName = (stream: { signetId: string; threadId: string }) =>
|
|
392
|
+
`${accessThreadStreamFamily}:signetId:${z.uuid().parse(stream.signetId)}:threadId:${threadIdSchema.parse(stream.threadId)}`;
|
|
393
|
+
export const parseAccessThreadStreamName = (name: string) => {
|
|
394
|
+
const [family, signetField, signetId, threadField, threadId, ...rest] = name.split(':');
|
|
395
|
+
if (
|
|
396
|
+
family !== accessThreadStreamFamily ||
|
|
397
|
+
signetField !== 'signetId' ||
|
|
398
|
+
threadField !== 'threadId'
|
|
399
|
+
)
|
|
400
|
+
return null;
|
|
401
|
+
const parsed = z
|
|
402
|
+
.object({ signetId: z.uuid(), threadId: threadIdSchema })
|
|
403
|
+
.safeParse({ signetId, threadId });
|
|
404
|
+
return parsed.success && rest.length === 0 ? parsed.data : null;
|
|
405
|
+
};
|
|
406
|
+
/** The snapshot is the route's body: Kingdom's `{ data }` envelope around the member's view. */
|
|
407
|
+
export const accessThreadSnapshotSchema = z.object({ data: threadViewResultSchema });
|
|
408
|
+
/** A `log` stream's one op: each append's payload is one `ThreadEntry`. */
|
|
409
|
+
export const accessThreadAppendType = 'append';
|
|
410
|
+
|
|
411
|
+
/** Why Kingdom closed a member's stream (sent on `closed` and `openRejected`). */
|
|
412
|
+
export const accessThreadCloseReasonSchema = z.enum([
|
|
413
|
+
/** The member closed it. */
|
|
414
|
+
'left',
|
|
415
|
+
'revoked',
|
|
416
|
+
/** Any revision closes the stream; the member reopens under the new lens. */
|
|
417
|
+
'revised',
|
|
418
|
+
'enrollmentRevoked',
|
|
419
|
+
'expired',
|
|
420
|
+
'hostOffline',
|
|
421
|
+
/** The 45 s re-authorize sweep no longer admits the stream. */
|
|
422
|
+
'unauthorized',
|
|
423
|
+
/** No `renewPresentation` arrived before the socket's access token expired. */
|
|
424
|
+
'presentationExpired',
|
|
425
|
+
]);
|
|
426
|
+
export type AccessThreadCloseReason = z.infer<typeof accessThreadCloseReasonSchema>;
|