@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 CHANGED
@@ -6,7 +6,7 @@ A **Signet** is Kingdom's only grant for using an integration. It names the reso
6
6
 
7
7
  This package is the one implementation of that protocol, shared by Kingdom (which verifies) and Archive, Foundry and Oracle (which present):
8
8
 
9
- - `contracts`: the wire schemas: keys, lenses, proposals, pairing requests, delivered credentials, `describe` and `execute`.
9
+ - `contracts`: the wire schemas: keys, lenses, proposals, pairing requests, delivered credentials, `describe` and `execute`, and the conversation frames.
10
10
  - `proof`: create and verify DPoP proofs (ES256, nonce-bound, access-token-bound).
11
11
  - `files`: private 0600 JSON files for keys and credentials.
12
12
  - `client`:
@@ -57,6 +57,35 @@ const signet = await SignetClient.fromFile(credentialFile);
57
57
  await signet.describe();
58
58
  ```
59
59
 
60
+ ## Agent conversations
61
+
62
+ A conversation wraps a host Foundry's main thread (Kingdom `docs/AGENT-CONVERSATIONS.md`, Foundry `docs/reference/CONVERSATIONS.md`). A member is a Signet on the host Foundry's one `threads` resource; Kingdom admits each call and relays it over the host's Installation socket, and stores no conversation or message.
63
+
64
+ - **Lens.** `signetLensSchema` is a union of strict shapes, `archiveLensSchema | threadsLensSchema`, because a resource grant names its resource by id with no kind. `resourceLensSchema(kind)` gives the shape a kind takes; Kingdom checks every grant with it against the kind it stores. A threads lens is `{ threads, view: 'own' | 'thread', delivery: 'note' | 'turn', outbound: 'review' | 'auto' }`, the terms defaulting to the narrowest value. `threads` is a selector, a union on `form`: v1 builds `{ form: 'ids', ids }` (1–100 thread ids); `withMember` and `tag` are named for standing Signets and fail to parse everywhere until a release adds them, so a new form never widens access silently. `narrowsSignetLens` is the rule for approving someone else's request: a subset of ids, `own` < `thread`, `note` < `turn`, `review` < `auto`.
65
+ - **Join requests** may name a resource by kind: `requestSignet` takes `{ resourceKind: 'threads', operations, lens }`, and Kingdom resolves it before review.
66
+ - **Per-entry reads.** `installationInquiries`, `installationSignets`, the socket snapshot and `describe` read each entry on its own. An entry this package can't read is dropped and listed in `dropped`, and the rest is kept. Inside a Signet, resources are read one by one: a resource with a lens this package can't read (a selector form from a newer Kingdom, a lens that doesn't fit its resource's kind) is dropped, and the Signet stays, so an installation never loses a held Signet, or mistakes a member for revoked, over one resource. The snapshot carries `hostedSignets` (members of this Foundry's conversations) and `hostedSignetRequests` (join requests aimed at its integrations); held Signets carry their `resources`.
67
+ - **Host.** `InstallationSocket` takes `threads`, one handler per operation (`thread.read`, `thread.prompt`, `thread.floor`, `prompt.resolve`, `thread.subscribe`), answering `threadResult(...)` or `threadRefused(code)`. A frame with an unknown selector is refused `unknown_selector` before any handler runs. `thread.subscribe` answers with the member's view and the `journalSeq` it was taken at, and the subscription starts there: `appendThread(subscriptionId, entry)` holds entries pushed meanwhile and never sends one twice. Each append carries a dense per-subscription `seq` (a filtered view skips journal entries, so `journalSeq` can't reveal loss); an entry that can't be sent still spends its `seq`, so Kingdom sees the gap and the member resyncs. A subscription ends on `thread.unsubscribe`, when Kingdom stops re-asserting its lease (`thread.lease`; dropped 90 s after the last one, so one late 45 s sweep is survived), or when the socket drops.
68
+ - **Member.** An outside agent holds its live views with `SignetSocket`: it authenticates as its Signet's presentation (`authenticateSignet`, a proof bound to `POST /api/v1/access/signetSocket` with `ath`), renews it before the five-minute token expires (`renewPresentation`), and opens one `accessThread` stream per thread: a snapshot, then appends, with Kingdom's `seq`/`resync` rules. Frames are handled in order, one open is in flight per stream, and Kingdom's close carries the reason: after a revision the stream reopens under the new lens, while the host is offline it retries with backoff, when the presentation lapsed the socket re-presents and reopens, and on revocation or expiry it ends (`onClosed`). Posts are separate `execute` calls (`thread.prompt`), each admitted and metered. A Foundry holding a conversation Signet opens the same streams over its Installation socket (`threadStreams`, `openThread`).
69
+
70
+ ```ts
71
+ import { SignetClient, SignetSocket } from '@inixiative/signet';
72
+
73
+ const signet = await SignetClient.fromFile(credentialFile);
74
+ const socket = new SignetSocket({
75
+ client: signet,
76
+ onSnapshot: ({ threadId }, view) => show(threadId, view.entries),
77
+ onEntry: ({ threadId }, entry) => append(threadId, entry),
78
+ onClosed: ({ threadId }, reason) => console.log(`left ${threadId}: ${reason}`),
79
+ });
80
+ socket.open(threadId);
81
+ socket.start();
82
+ await signet.execute({
83
+ integrationId,
84
+ operation: 'thread.prompt',
85
+ input: { resourceId, threadId, postId: crypto.randomUUID(), text: 'The role you wanted opened' },
86
+ });
87
+ ```
88
+
60
89
  Kingdom's OpenAPI spec (`/openapi/docs`) publishes each step's raw HTTP call and credentials.
61
90
 
62
91
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/signet",
3
- "version": "0.4.6",
3
+ "version": "0.5.1",
4
4
  "description": "Present Kingdom Signets: the wire contracts, DPoP proofs, device pairing and a renewing client shared by Kingdom, Archive, Foundry and Oracle.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -33,8 +33,9 @@
33
33
  "jose": "^6.2.3"
34
34
  },
35
35
  "devDependencies": {
36
+ "@asteasolutions/zod-to-openapi": "^9.1.0",
36
37
  "@biomejs/biome": "2.5.2",
37
- "@inixiative/config": "^0.5.105",
38
+ "@inixiative/config": "^0.5.116",
38
39
  "@types/bun": "1.4.2",
39
40
  "lefthook": "2.1.9",
40
41
  "typescript": "6.0.3",
package/src/client.ts CHANGED
@@ -7,10 +7,11 @@ import {
7
7
  accessTokenPattern,
8
8
  collectedSignetSchema,
9
9
  type InstallationKind,
10
- installationInquiriesResponseSchema,
11
- installationSignetsResponseSchema,
12
10
  nonceSchema,
13
11
  ownerRefSchema,
12
+ parseInstallationInquiries,
13
+ parseInstallationSignets,
14
+ parseSignetDescription,
14
15
  publicClientKeySchema,
15
16
  type RegistrationRequest,
16
17
  registerInstallationResponseSchema,
@@ -19,11 +20,11 @@ import {
19
20
  type SignetRequestResponse,
20
21
  setInstallationCredentialResponseSchema,
21
22
  signetCredentialsSchema,
22
- signetDescriptionSchema,
23
23
  signetKeySchema,
24
24
  signetLifecycleSchema,
25
25
  signetProposalSchema,
26
26
  signetRequestResponseSchema,
27
+ signetSocketAction,
27
28
  } from './contracts';
28
29
  import { readPrivateJson, writePrivateJson } from './files';
29
30
  import { createSignetProof, keyThumbprint } from './proof';
@@ -224,7 +225,7 @@ async function keyPost<T>(
224
225
  keyFile: string,
225
226
  action: string,
226
227
  body: unknown,
227
- schema: z.ZodType<T>,
228
+ schema: z.ZodType<T, unknown>,
228
229
  options: SignetRequestOptions = {},
229
230
  headers: Record<string, string> = {},
230
231
  ): Promise<T> {
@@ -274,13 +275,23 @@ export const requestRegistration = (
274
275
  options,
275
276
  );
276
277
 
277
- /** This installation's pending request and its inquiries. */
278
+ /**
279
+ * This installation's pending request, its inquiries, and the join requests aimed at its
280
+ * integrations. Read per entry: an entry this package can't read is dropped and listed in `dropped`.
281
+ */
278
282
  export const installationInquiries = (
279
283
  url: string,
280
284
  keyFile: string,
281
285
  options: SignetRequestOptions = {},
282
286
  ) =>
283
- keyPost(url, keyFile, 'installationInquiries', {}, installationInquiriesResponseSchema, options);
287
+ keyPost(
288
+ url,
289
+ keyFile,
290
+ 'installationInquiries',
291
+ {},
292
+ z.unknown().transform((value) => parseInstallationInquiries(value)),
293
+ options,
294
+ );
284
295
 
285
296
  export const cancelInstallationInquiry = (
286
297
  url: string,
@@ -396,12 +407,23 @@ export const enrollSignet = async (
396
407
  signetId: enrollment.signetId,
397
408
  });
398
409
 
399
- /** The Signets held by this installation's integrations. */
410
+ /**
411
+ * The Signets held by this installation's integrations, and those issued on them (`hostedSignets`).
412
+ * Read per entry: a Signet whose lens this package can't read is dropped and listed in `dropped`.
413
+ */
400
414
  export const installationSignets = (
401
415
  url: string,
402
416
  keyFile: string,
403
417
  options: SignetRequestOptions = {},
404
- ) => keyPost(url, keyFile, 'installationSignets', {}, installationSignetsResponseSchema, options);
418
+ ) =>
419
+ keyPost(
420
+ url,
421
+ keyFile,
422
+ 'installationSignets',
423
+ {},
424
+ z.unknown().transform((value) => parseInstallationSignets(value)),
425
+ options,
426
+ );
405
427
 
406
428
  /** Enroll this installation's key on a Signet one of its integrations holds. */
407
429
  export const enrollInstallationSignet = (
@@ -498,7 +520,7 @@ export class SignetClient {
498
520
  constructor(
499
521
  readonly url: string,
500
522
  private credentialFile: string,
501
- private signetId: string,
523
+ readonly signetId: string,
502
524
  ) {
503
525
  this.url = kingdomUrl(url);
504
526
  }
@@ -588,11 +610,29 @@ export class SignetClient {
588
610
  { signal, timeoutMs: options.timeoutMs },
589
611
  );
590
612
  }
613
+ /** Read per entry: a resource whose lens this package can't read is dropped, listed in `dropped`. */
591
614
  async describe(options: SignetRequestOptions = {}) {
592
- return signetDescriptionSchema.parse(
615
+ return parseSignetDescription(
593
616
  await this.post('describe', { signetId: this.signetId }, undefined, options),
594
617
  );
595
618
  }
619
+ /**
620
+ * The access token and a proof bound to the Signet socket, for `authenticateSignet` (or, with
621
+ * `renew`, a freshly renewed token for `renewPresentation`). Each proof spends one Kingdom nonce.
622
+ */
623
+ async socketPresentation(options: SignetRequestOptions & { renew?: boolean } = {}) {
624
+ const signal = requestSignal(options);
625
+ const credential = await this.credentials(options.renew ?? false, signal);
626
+ signal.throwIfAborted();
627
+ const proof = await signetProof(
628
+ this.url,
629
+ signetSocketAction,
630
+ credential.keyFile,
631
+ credential.accessToken,
632
+ { signal, timeoutMs: options.timeoutMs },
633
+ );
634
+ return { accessToken: credential.accessToken, proof, expiresAt: credential.expiresAt };
635
+ }
596
636
  /** Run one integration operation through the Signet; every call is a new request id. */
597
637
  async execute(
598
638
  request: Omit<AccessRequest, 'requestId' | 'signetId'>,
package/src/contracts.ts CHANGED
@@ -1,4 +1,17 @@
1
1
  import { z } from 'zod';
2
+ import {
3
+ foundryIdSchema,
4
+ narrowsThreadsLens,
5
+ type ThreadOperation,
6
+ threadFloorActionSchema,
7
+ threadIdSchema,
8
+ threadInputSchemas,
9
+ threadsLensSchema,
10
+ threadsResourceKind,
11
+ threadTextLimit,
12
+ } from './threads';
13
+
14
+ export * from './threads';
2
15
 
3
16
  const token43 = /^[A-Za-z0-9_-]{43}$/;
4
17
 
@@ -37,10 +50,50 @@ export const signetKeySchema = publicClientKeySchema.extend({
37
50
  export type PublicClientKey = z.infer<typeof publicClientKeySchema>;
38
51
  export type SignetKey = z.infer<typeof signetKeySchema>;
39
52
 
40
- export const signetLensSchema = z.strictObject({
53
+ /** The resource kind of a hosted Archive library: one per Archive integration. */
54
+ export const archiveLibraryResourceKind = 'archiveLibrary';
55
+ /** Which documents and fields of an `archiveLibrary` a grant reaches; `{}` reaches all of them. */
56
+ export const archiveLensSchema = z.strictObject({
41
57
  documentIds: z.array(archiveDocumentIdSchema).max(1000).optional(),
42
58
  fields: z.array(archiveFieldSchema).max(6).optional(),
43
59
  });
60
+ export type ArchiveLens = z.infer<typeof archiveLensSchema>;
61
+ /** Every other resource kind takes no lens. */
62
+ export const emptyLensSchema = z.strictObject({});
63
+
64
+ /**
65
+ * A grant's lens. `signetResourceSchema` names a resource by id with no `kind`, so the contract can
66
+ * only hold a union of the strict shapes; which shape a grant may carry depends on its resource's
67
+ * kind, and Kingdom checks that with `resourceLensSchema` against the kind it stores (review R9).
68
+ * `{}` parses as the empty Archive lens: "no lens", valid for every kind but `threads`.
69
+ */
70
+ export const signetLensSchema = z.union([archiveLensSchema, threadsLensSchema]);
71
+ export type SignetLens = z.infer<typeof signetLensSchema>;
72
+
73
+ /** The lens shape a resource of `kind` takes. */
74
+ export const resourceLensSchema = (kind: string) =>
75
+ kind === archiveLibraryResourceKind
76
+ ? archiveLensSchema
77
+ : kind === threadsResourceKind
78
+ ? threadsLensSchema
79
+ : emptyLensSchema;
80
+
81
+ const subset = <T>(granted: T[] | undefined, requested: T[] | undefined) =>
82
+ !requested || (granted !== undefined && granted.every((item) => requested.includes(item)));
83
+
84
+ /**
85
+ * `granted` reaches nothing `requested` doesn't, for lenses of the same resource. Approving someone
86
+ * else's request may only narrow; an owner revising their own grant may widen (review U3). A lens of
87
+ * another shape than the request's is refused.
88
+ */
89
+ export const narrowsSignetLens = (granted: SignetLens, requested: SignetLens): boolean => {
90
+ if ('threads' in granted || 'threads' in requested)
91
+ return 'threads' in granted && 'threads' in requested && narrowsThreadsLens(granted, requested);
92
+ return (
93
+ subset(granted.documentIds, requested.documentIds) && subset(granted.fields, requested.fields)
94
+ );
95
+ };
96
+
44
97
  export const signetResourceSchema = z.strictObject({
45
98
  resourceId: z.uuid(),
46
99
  operations: z.array(z.string().min(1).max(80)).min(1).max(100),
@@ -51,7 +104,6 @@ export const signetRecipientSchema = z.discriminatedUnion('recipientModel', [
51
104
  z.strictObject({ recipientModel: z.literal('Integration'), integrationId: z.uuid() }),
52
105
  ]);
53
106
  export const signetLifecycleSchema = z.enum(['request', 'task', 'ongoing']);
54
- export type SignetLens = z.infer<typeof signetLensSchema>;
55
107
  export type SignetRecipient = z.infer<typeof signetRecipientSchema>;
56
108
  export type SignetResource = z.infer<typeof signetResourceSchema>;
57
109
 
@@ -95,11 +147,25 @@ const signetTerms = {
95
147
  /** A local Foundry, Archive or Oracle known to Kingdom by its key. */
96
148
  export const installationKindSchema = z.enum(['foundry', 'archive', 'oracle']);
97
149
  export type InstallationKind = z.infer<typeof installationKindSchema>;
150
+ /**
151
+ * A resource asked for by kind, and optionally name, when the asker does not know its id: an
152
+ * Installation registering, or a party asking to join a Foundry's `threads` (one per Foundry, so the
153
+ * integration id names it). Kingdom resolves it to the resource's id before anyone reviews it.
154
+ */
155
+ export const requestedResourceKindSchema = z.strictObject({
156
+ resourceKind: z.string().min(1).max(80),
157
+ resourceName: z.string().trim().min(1).max(120).optional(),
158
+ operations: z.array(z.string().min(1).max(80)).min(1).max(100),
159
+ lens: signetLensSchema.default({}),
160
+ });
161
+ export const requestedResourceSchema = z.union([signetResourceSchema, requestedResourceKindSchema]);
162
+ /** What a party asks for on an integration: resources by id or by kind (review R8). */
98
163
  export const signetProposalSchema = z.strictObject({
99
164
  ...signetTerms,
100
165
  integrationId: z.uuid(),
101
- resources: z.array(signetResourceSchema).min(1).max(100),
166
+ resources: z.array(requestedResourceSchema).min(1).max(100),
102
167
  });
168
+ /** The terms as reviewed and issued: every resource named by id. */
103
169
  export const signetReviewProposalSchema = z.strictObject({
104
170
  ...signetTerms,
105
171
  resources: z.array(signetResourceSchema).max(100),
@@ -116,14 +182,6 @@ export const requestedOwnerSchema = z.strictObject({
116
182
  organization: z.string().trim().min(1).max(120).optional(),
117
183
  spaceId: z.uuid().optional(),
118
184
  });
119
- /** A resource asked for by kind, and optionally name, when the Installation does not know its id. */
120
- export const requestedResourceKindSchema = z.strictObject({
121
- resourceKind: z.string().min(1).max(80),
122
- resourceName: z.string().trim().min(1).max(120).optional(),
123
- operations: z.array(z.string().min(1).max(80)).min(1).max(100),
124
- lens: signetLensSchema.default({}),
125
- });
126
- export const requestedResourceSchema = z.union([signetResourceSchema, requestedResourceKindSchema]);
127
185
  /** What an Installation asks for: the owner it expects and the resources and operations it needs. */
128
186
  export const registrationRequestSchema = z.strictObject({
129
187
  ...signetTerms,
@@ -132,10 +190,7 @@ export const registrationRequestSchema = z.strictObject({
132
190
  resources: z.array(requestedResourceSchema).max(100),
133
191
  });
134
192
  /** A person asks for a Signet on an integration, to be presented with `publicKey`. */
135
- export const signetRequestSchema = z.strictObject({
136
- ...signetTerms,
137
- integrationId: z.uuid(),
138
- resources: z.array(signetResourceSchema).min(1).max(100),
193
+ export const signetRequestSchema = signetProposalSchema.extend({
139
194
  publicKey: publicClientKeySchema,
140
195
  });
141
196
  export const signetRequestResponseSchema = z.object({
@@ -172,10 +227,18 @@ export const requestRegistrationResponseSchema = z.object({
172
227
  /** Where a person reviews the registration in Kingdom's web app. */
173
228
  reviewUrl: kingdomLinkSchema,
174
229
  });
230
+ const inquiryStatusSchema = z.enum([
231
+ 'draft',
232
+ 'sent',
233
+ 'changesRequested',
234
+ 'approved',
235
+ 'denied',
236
+ 'canceled',
237
+ ]);
175
238
  export const installationInquirySchema = z.object({
176
239
  id: z.uuid(),
177
240
  type: z.string(),
178
- status: z.enum(['draft', 'sent', 'changesRequested', 'approved', 'denied', 'canceled']),
241
+ status: inquiryStatusSchema,
179
242
  createdAt: z.iso.datetime(),
180
243
  expiresAt: z.iso.datetime().nullable(),
181
244
  owner: ownerRefSchema.nullable(),
@@ -184,23 +247,90 @@ export const installationInquirySchema = z.object({
184
247
  signetId: z.uuid().nullable(),
185
248
  deliverBefore: z.iso.datetime().nullable(),
186
249
  });
250
+ /**
251
+ * A pending `signetRequest` whose target is one of the Installation's integrations: a party asking
252
+ * to join a Foundry's threads. The owner reviews it in Kingdom at `reviewUrl`.
253
+ */
254
+ export const hostedSignetRequestSchema = z.object({
255
+ id: z.uuid(),
256
+ status: inquiryStatusSchema,
257
+ createdAt: z.iso.datetime(),
258
+ expiresAt: z.iso.datetime().nullable(),
259
+ /** The Installation's integration the request targets. */
260
+ integrationId: z.uuid(),
261
+ requester: z.object({ sourceModel: z.string(), id: z.uuid(), displayName: z.string() }),
262
+ terms: signetReviewProposalSchema,
263
+ reviewUrl: kingdomLinkSchema,
264
+ });
265
+ export type HostedSignetRequest = z.infer<typeof hostedSignetRequestSchema>;
187
266
  export const installationInquiriesResponseSchema = z.object({
188
267
  pending: z.object({ reviewCode: z.string(), expiresAt: z.iso.datetime() }).nullable(),
189
268
  declinedAt: z.iso.datetime().nullable(),
190
269
  inquiries: z.array(installationInquirySchema),
270
+ hostedSignetRequests: z.array(hostedSignetRequestSchema),
191
271
  });
192
272
  export const inquiryReferenceSchema = z.strictObject({ inquiryId: z.uuid() });
273
+
274
+ /** One resource a Signet reaches, with its stored kind, so its lens can be checked against it. */
275
+ export const signetGrantSchema = z.object({
276
+ resourceId: z.uuid(),
277
+ kind: z.string().min(1).max(80),
278
+ operations: z.array(z.string().min(1).max(80)),
279
+ lens: signetLensSchema,
280
+ });
281
+ export type SignetGrant = z.infer<typeof signetGrantSchema>;
282
+ const grantsSchema = z.array(signetGrantSchema);
283
+ /** Whether a grant's lens is the shape its resource's kind takes. */
284
+ export const grantLensFitsKind = (grant: Pick<SignetGrant, 'kind' | 'lens'>) =>
285
+ resourceLensSchema(grant.kind).safeParse(grant.lens).success;
286
+
287
+ /** Who a Signet is issued to. Recipients run broad (2026-10-18); Kingdom widens issuing in K0. */
288
+ export const signetRecipientModelSchema = z.enum([
289
+ 'User',
290
+ 'OrganizationUser',
291
+ 'Organization',
292
+ 'Space',
293
+ 'SpaceUser',
294
+ 'Integration',
295
+ ]);
296
+ export const installationSignetSchema = z.object({
297
+ signetId: z.uuid(),
298
+ integrationId: z.uuid(),
299
+ name: z.string(),
300
+ /** The enrollment that is current for this installation's key, if any. */
301
+ enrollmentId: z.uuid().nullable(),
302
+ owner: ownerRefSchema,
303
+ /** What the Signet reaches, so a holder knows, for example, which threads it may open. */
304
+ resources: grantsSchema,
305
+ });
306
+ /**
307
+ * A Signet issued on one of the Installation's integrations: a member of a Foundry's conversations.
308
+ * For display and for cancelling a member's queued work once it is gone, never as an admission
309
+ * gate: Kingdom admits every call.
310
+ */
311
+ export const hostedSignetSchema = z.object({
312
+ signetId: z.uuid(),
313
+ /** The Installation's integration the Signet is issued on. */
314
+ integrationId: z.uuid(),
315
+ name: z.string(),
316
+ /** The current revision; the host compares it with the `acceptedRevision` frames carry. */
317
+ revision: z.number().int().min(1),
318
+ lifecycle: signetLifecycleSchema,
319
+ expiresAt: z.iso.datetime().nullable(),
320
+ maxRequests: z.number().int().nullable(),
321
+ maxConcurrent: z.number().int(),
322
+ recipient: z.object({
323
+ recipientModel: signetRecipientModelSchema,
324
+ id: z.uuid(),
325
+ displayName: z.string(),
326
+ }),
327
+ resources: grantsSchema,
328
+ });
329
+ export type InstallationSignet = z.infer<typeof installationSignetSchema>;
330
+ export type HostedSignet = z.infer<typeof hostedSignetSchema>;
193
331
  export const installationSignetsResponseSchema = z.object({
194
- signets: z.array(
195
- z.object({
196
- signetId: z.uuid(),
197
- integrationId: z.uuid(),
198
- name: z.string(),
199
- /** The enrollment that is current for this installation's key, if any. */
200
- enrollmentId: z.uuid().nullable(),
201
- owner: ownerRefSchema,
202
- }),
203
- ),
332
+ signets: z.array(installationSignetSchema),
333
+ hostedSignets: z.array(hostedSignetSchema),
204
334
  });
205
335
  export const enrollInstallationSignetSchema = z.strictObject({ signetId: z.uuid() });
206
336
  export const rotateInstallationKeySchema = z.strictObject({
@@ -209,6 +339,13 @@ export const rotateInstallationKeySchema = z.strictObject({
209
339
  });
210
340
  export type InstallationInquiry = z.infer<typeof installationInquirySchema>;
211
341
 
342
+ export const signetDescriptionResourceSchema = z.object({
343
+ id: z.uuid(),
344
+ name: z.string(),
345
+ kind: z.string(),
346
+ integrationId: z.uuid(),
347
+ lens: signetLensSchema,
348
+ });
212
349
  export const signetDescriptionSchema = z.object({
213
350
  signetId: z.uuid(),
214
351
  integrationId: z.uuid(),
@@ -223,9 +360,7 @@ export const signetDescriptionSchema = z.object({
223
360
  z.object({
224
361
  key: z.string(),
225
362
  name: z.string(),
226
- resources: z.array(
227
- z.object({ id: z.uuid(), name: z.string(), kind: z.string(), integrationId: z.uuid() }),
228
- ),
363
+ resources: z.array(signetDescriptionResourceSchema),
229
364
  }),
230
365
  ),
231
366
  });
@@ -271,6 +406,14 @@ export const accessInputSchema = z.strictObject({
271
406
  .regex(/^[a-f0-9]{64}$/)
272
407
  .nullable()
273
408
  .optional(),
409
+ threadId: threadIdSchema.optional(),
410
+ postId: z.uuid().optional(),
411
+ text: z.string().min(1).max(threadTextLimit).optional(),
412
+ inReplyTo: foundryIdSchema.optional(),
413
+ afterSeq: z.number().int().min(0).optional(),
414
+ floor: threadFloorActionSchema.optional(),
415
+ promptId: foundryIdSchema.optional(),
416
+ answer: threadInputSchemas['prompt.resolve'].shape.answer.optional(),
274
417
  });
275
418
  export const accessRequestSchema = z.strictObject({
276
419
  requestId: z.uuid(),
@@ -283,12 +426,195 @@ export const accessRequestSchema = z.strictObject({
283
426
  });
284
427
  export type AccessRequest = z.infer<typeof accessRequestSchema>;
285
428
  export const accessResultSchema = z.object({ executionId: z.uuid(), result: z.unknown() });
429
+
430
+ /**
431
+ * The host-side input of a thread operation, taken from an `access/execute` input: exactly the keys
432
+ * that operation takes (no `resourceId`, no `limit`). Kingdom builds `thread.request` frames with it.
433
+ */
434
+ export const threadInputFromAccess = <O extends ThreadOperation>(
435
+ operation: O,
436
+ input: AccessRequest['input'],
437
+ ) => {
438
+ const schema = threadInputSchemas[operation];
439
+ const picked = Object.fromEntries(
440
+ Object.keys(schema.shape).flatMap((key) =>
441
+ key in input ? [[key, input[key as keyof typeof input]]] : [],
442
+ ),
443
+ );
444
+ return schema.safeParse(picked) as z.ZodSafeParseResult<z.infer<(typeof threadInputSchemas)[O]>>;
445
+ };
446
+
286
447
  /** What Kingdom pushes over the Installation socket, and what a poll of both reads assembles. */
287
448
  export const installationSnapshotSchema = installationInquiriesResponseSchema.extend(
288
449
  installationSignetsResponseSchema.shape,
289
450
  );
451
+
452
+ /** An entry a reader dropped instead of failing the whole read, and why (review U1). */
453
+ export type DroppedEntry = { list: string; index: number; id: string | null; reason: string };
454
+ export type InstallationSnapshot = z.infer<typeof installationSnapshotSchema> & {
455
+ dropped: DroppedEntry[];
456
+ };
457
+
458
+ const entryId = (value: unknown) => {
459
+ if (typeof value !== 'object' || value === null) return null;
460
+ const record = value as Record<string, unknown>;
461
+ const id = record.signetId ?? record.id;
462
+ return typeof id === 'string' ? id.slice(0, 80) : null;
463
+ };
464
+ /**
465
+ * Parse each entry on its own and drop the ones that don't parse. A Kingdom ahead of this package
466
+ * (a selector form it doesn't know, a new field shape) then costs only those entries: never the
467
+ * Installation's other grants or its own held Signets. A missing list reads as empty, reported.
468
+ */
469
+ export function parseEntries<T>(
470
+ list: string,
471
+ value: unknown,
472
+ schema: z.ZodType<T>,
473
+ dropped: DroppedEntry[],
474
+ check: (entry: T) => string | null = () => null,
475
+ ): T[] {
476
+ if (!Array.isArray(value)) {
477
+ dropped.push({ list, index: -1, id: null, reason: 'missing or not a list' });
478
+ return [];
479
+ }
480
+ return value.flatMap((raw, index) => {
481
+ const parsed = schema.safeParse(raw);
482
+ const reason = parsed.success
483
+ ? check(parsed.data)
484
+ : parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; ');
485
+ if (reason === null && parsed.success) return [parsed.data];
486
+ dropped.push({ list, index, id: entryId(raw), reason: (reason ?? 'invalid').slice(0, 500) });
487
+ return [];
488
+ });
489
+ }
490
+
491
+ const lensFits = (grant: Pick<SignetGrant, 'kind' | 'lens'>) =>
492
+ grantLensFitsKind(grant) ? null : `a ${grant.kind} resource carries a lens of another shape`;
493
+
494
+ /**
495
+ * Signets read per entry, and their resources per entry inside them: a resource this package can't
496
+ * read (a newer lens, a lens that doesn't fit its kind) is dropped and reported, and the Signet is
497
+ * kept, so the Installation never loses a held Signet (or mistakes a member for revoked) over one
498
+ * resource.
499
+ */
500
+ function parseSignetsWithGrants<S extends z.ZodObject<{ resources: typeof grantsSchema }>>(
501
+ list: string,
502
+ value: unknown,
503
+ schema: S,
504
+ dropped: DroppedEntry[],
505
+ ): z.infer<S>[] {
506
+ const identity = schema.omit({ resources: true }).extend({ resources: z.unknown().optional() });
507
+ return parseEntries(list, value, identity, dropped).map(
508
+ (entry) =>
509
+ ({
510
+ ...entry,
511
+ resources: parseEntries(
512
+ `${list}.${entryId(entry)}.resources`,
513
+ entry.resources,
514
+ signetGrantSchema,
515
+ dropped,
516
+ lensFits,
517
+ ),
518
+ }) as z.infer<S>,
519
+ );
520
+ }
521
+
522
+ const inquiriesScalars = installationInquiriesResponseSchema.pick({
523
+ pending: true,
524
+ declinedAt: true,
525
+ });
526
+
527
+ /** `installationInquiries`, per entry. */
528
+ export function parseInstallationInquiries(value: unknown, dropped: DroppedEntry[] = []) {
529
+ const record = z.object({}).loose().parse(value);
530
+ return {
531
+ ...inquiriesScalars.parse(record),
532
+ inquiries: parseEntries('inquiries', record.inquiries, installationInquirySchema, dropped),
533
+ hostedSignetRequests: parseEntries(
534
+ 'hostedSignetRequests',
535
+ record.hostedSignetRequests,
536
+ hostedSignetRequestSchema,
537
+ dropped,
538
+ ),
539
+ dropped,
540
+ };
541
+ }
542
+
543
+ /** `installationSignets`, per Signet and per resource inside it. */
544
+ export function parseInstallationSignets(value: unknown, dropped: DroppedEntry[] = []) {
545
+ const record = z.object({}).loose().parse(value);
546
+ return {
547
+ signets: parseSignetsWithGrants('signets', record.signets, installationSignetSchema, dropped),
548
+ hostedSignets: parseSignetsWithGrants(
549
+ 'hostedSignets',
550
+ record.hostedSignets,
551
+ hostedSignetSchema,
552
+ dropped,
553
+ ),
554
+ dropped,
555
+ };
556
+ }
557
+
558
+ /** The Installation socket's snapshot frame (or a poll of both reads), per entry. */
559
+ export function parseInstallationSnapshot(value: unknown): InstallationSnapshot {
560
+ const dropped: DroppedEntry[] = [];
561
+ const { dropped: _inquiries, ...inquiries } = parseInstallationInquiries(value, dropped);
562
+ const { dropped: _signets, ...signets } = parseInstallationSignets(value, dropped);
563
+ return { ...inquiries, ...signets, dropped };
564
+ }
565
+
566
+ export type ParsedSignetDescription = SignetDescription & { dropped: DroppedEntry[] };
567
+
568
+ /** `access/describe`, per entry: a resource whose lens doesn't parse, or doesn't fit its kind, is dropped. */
569
+ export function parseSignetDescription(value: unknown): ParsedSignetDescription {
570
+ const dropped: DroppedEntry[] = [];
571
+ const record = z
572
+ .object({ operations: z.array(z.object({}).loose()) })
573
+ .loose()
574
+ .parse(value);
575
+ const operations = record.operations.map((operation, index) => ({
576
+ ...signetDescriptionSchema.shape.operations.element.omit({ resources: true }).parse(operation),
577
+ resources: parseEntries(
578
+ `operations.${index}.resources`,
579
+ operation.resources,
580
+ signetDescriptionResourceSchema,
581
+ dropped,
582
+ (resource) => (grantLensFitsKind(resource) ? null : 'lens does not fit the resource kind'),
583
+ ),
584
+ }));
585
+ return { ...signetDescriptionSchema.parse({ ...record, operations }), dropped };
586
+ }
587
+
290
588
  /** The path a socket proof is bound to (POST), though the socket itself opens at the API origin. */
291
589
  export const installationSocketAction = 'installationSocket';
590
+
591
+ /**
592
+ * A Signet presenter's socket (design §5.6.1, review Round 4). Like the Installation socket: a DPoP
593
+ * proof bound to `POST /api/v1/access/signetSocket`, on a single-use Kingdom nonce, with `ath` over
594
+ * the access token. The socket then carries one actor, that Signet's presentation, until the token
595
+ * expires; `renewPresentation` (a new token, a new proof) extends it.
596
+ */
597
+ export const signetSocketAction = 'signetSocket';
598
+ const presentation = {
599
+ accessToken: z.string().regex(accessTokenPattern),
600
+ proof: z.string().max(8192),
601
+ };
602
+ export const authenticateSignetFrameSchema = z.strictObject({
603
+ action: z.literal('authenticateSignet'),
604
+ ...presentation,
605
+ });
606
+ export const renewPresentationFrameSchema = z.strictObject({
607
+ action: z.literal('renewPresentation'),
608
+ ...presentation,
609
+ });
610
+ /** Kingdom's answer to either: the presentation the socket now carries, and until when. */
611
+ export const signetPresentedFrameSchema = z.object({
612
+ type: z.literal('signet'),
613
+ signetId: z.uuid(),
614
+ enrollmentId: z.uuid(),
615
+ expiresAt: z.iso.datetime(),
616
+ });
617
+ export const signetRejectedFrameSchema = z.object({ type: z.literal('signetRejected') });
292
618
  /** An installation hands Kingdom the credential Kingdom uses to reach it (a Foundry's tunnel token). */
293
619
  export const setInstallationCredentialSchema = z.strictObject({
294
620
  integrationId: z.uuid(),
package/src/index.ts CHANGED
@@ -4,3 +4,5 @@ export * from './files';
4
4
  export * from './installationPairing';
5
5
  export * from './installationSocket';
6
6
  export * from './proof';
7
+ export * from './signetSocket';
8
+ export * from './threadStreams';