@inixiative/signet 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Inixiative
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,32 @@
1
- # Temporary Holding Version
1
+ # @inixiative/signet
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Present Kingdom Signets.
4
+
5
+ A **Signet** is Kingdom's only grant for using an integration. It names the resources, operations, lens and limits, and it can be revoked. Its grantee is a user or an integration, for example a paired Foundry or a machine's local Archive. The grantee presents it with an access token plus a DPoP proof signed by its device key (RFC 9449).
6
+
7
+ This package is the one implementation of that protocol, shared by Kingdom (which verifies) and Archive, Foundry and Oracle (which present):
8
+
9
+ - `contracts`: the wire schemas: keys, lenses, proposals, pairing requests, delivered credentials, `describe` and `execute`.
10
+ - `proof`: create and verify DPoP proofs (ES256, nonce-bound, access-token-bound).
11
+ - `files`: private 0600 JSON files for keys and credentials.
12
+ - `client`:
13
+ - device pairing: `requestPairing`, then the owner approves the review code in Kingdom, then `collectPairing` and `saveCollectedSignet`;
14
+ - `SignetClient`, which renews its access token and signs each `describe` / `execute` call.
15
+
16
+ ```ts
17
+ import { collectPairing, generateClientKey, requestPairing, saveCollectedSignet, SignetClient, writePrivateJson } from '@inixiative/signet';
18
+
19
+ await writePrivateJson(keyFile, generateClientKey());
20
+ const pending = await requestPairing(kingdom, keyFile, {
21
+ provider: 'archive', name: 'Laptop Archive', resources: [], expiresAt: null, maxRequests: null, maxConcurrent: 4,
22
+ });
23
+ // Show pending.reviewCode; poll until approved (null while pending, until pending.expiresAt).
24
+ const collected = await collectPairing(kingdom, keyFile, pending.deviceCode);
25
+ await saveCollectedSignet(credentialFile, kingdom, keyFile, collected!);
26
+
27
+ const signet = new SignetClient(credentialFile);
28
+ const { operations } = await signet.describe();
29
+ await signet.execute({ integrationId, operation: 'sessions.write', input: { resourceId, snapshot, previousDigest: null } });
30
+ ```
31
+
32
+ MIT
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@inixiative/signet",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
4
+ "description": "Present Kingdom Signets: the wire contracts, DPoP proofs, device pairing and a renewing client shared by Kingdom, Archive, Foundry and Oracle.",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./contracts": "./src/contracts.ts",
11
+ "./proof": "./src/proof.ts"
12
+ },
13
+ "files": [
14
+ "src"
15
+ ],
16
+ "scripts": {
17
+ "typecheck": "tsc --noEmit",
18
+ "test": "bun test",
19
+ "check": "bun run typecheck && bun run lint && bun run test",
20
+ "prepublishOnly": "bun run check",
21
+ "lint": "biome check .",
22
+ "prepare": "if git rev-parse --git-dir >/dev/null 2>&1; then lefthook install; fi"
23
+ },
24
+ "keywords": [
25
+ "kingdom",
26
+ "signet",
27
+ "dpop",
28
+ "access",
29
+ "grant"
30
+ ],
31
+ "license": "MIT",
32
+ "dependencies": {
33
+ "jose": "^6.2.3"
34
+ },
35
+ "devDependencies": {
36
+ "@biomejs/biome": "2.5.2",
37
+ "@inixiative/config": "^0.5.55",
38
+ "@types/bun": "1.4.2",
39
+ "lefthook": "2.1.9",
40
+ "typescript": "6.0.3",
41
+ "zod": "^4.3.6"
42
+ },
43
+ "packageManager": "bun@1.4.2",
44
+ "engines": {
45
+ "bun": ">=1.4.2"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+https://github.com/inixiative/signet.git"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "peerDependencies": {
55
+ "zod": "^4.3.6"
56
+ }
57
+ }
package/src/client.ts ADDED
@@ -0,0 +1,510 @@
1
+ import { createPrivateKey, createPublicKey, generateKeyPairSync } from 'node:crypto';
2
+ import { isAbsolute } from 'node:path';
3
+ import { z } from 'zod';
4
+ import {
5
+ type AccessRequest,
6
+ accessResultSchema,
7
+ accessTokenPattern,
8
+ collectedSignetSchema,
9
+ type InstallationKind,
10
+ installationInquiriesResponseSchema,
11
+ installationSignetsResponseSchema,
12
+ nonceSchema,
13
+ ownerRefSchema,
14
+ publicClientKeySchema,
15
+ type RegistrationTerms,
16
+ registerInstallationResponseSchema,
17
+ renewalCredentialPattern,
18
+ requestRegistrationResponseSchema,
19
+ signetDescriptionSchema,
20
+ signetKeySchema,
21
+ signetLifecycleSchema,
22
+ } from './contracts';
23
+ import { readPrivateJson, writePrivateJson } from './files';
24
+ import { createSignetProof, keyThumbprint } from './proof';
25
+
26
+ /** A Kingdom API origin: HTTPS, or HTTP on loopback, with no path or credentials. */
27
+ export const kingdomUrl = (value: string) => {
28
+ const url = new URL(value);
29
+ if (
30
+ url.username ||
31
+ url.password ||
32
+ url.search ||
33
+ url.hash ||
34
+ url.pathname !== '/' ||
35
+ (url.protocol !== 'https:' &&
36
+ !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))
37
+ )
38
+ throw Error('Kingdom requires an HTTPS origin or loopback HTTP');
39
+ return url.origin;
40
+ };
41
+
42
+ /** What a presenting client keeps on disk (0600) for one enrolled Signet. */
43
+ export const signetCredentialSchema = z
44
+ .object({
45
+ url: z.string().transform(kingdomUrl),
46
+ signetId: z.uuid(),
47
+ integrationId: z.uuid().optional(),
48
+ owner: ownerRefSchema.optional(),
49
+ enrollmentId: z.uuid(),
50
+ lifecycle: signetLifecycleSchema,
51
+ taskId: z.uuid().nullable(),
52
+ keyFile: z.string().refine(isAbsolute),
53
+ renewalCredential: z.string().regex(renewalCredentialPattern),
54
+ accessToken: z.string().regex(accessTokenPattern),
55
+ expiresAt: z.iso.datetime(),
56
+ renewalExpiresAt: z.iso.datetime(),
57
+ idleExpiresAt: z.iso.datetime(),
58
+ tokenType: z.literal('DPoP'),
59
+ })
60
+ .strict();
61
+ export type SignetCredential = z.infer<typeof signetCredentialSchema>;
62
+ export const deliveredSignetSchema = signetCredentialSchema.omit({ url: true, keyFile: true });
63
+ const renewalResponseSchema = deliveredSignetSchema.omit({
64
+ signetId: true,
65
+ integrationId: true,
66
+ owner: true,
67
+ renewalCredential: true,
68
+ });
69
+ const refreshing = new Map<string, Promise<SignetCredential>>();
70
+
71
+ export const generateSignetKey = () =>
72
+ signetKeySchema.parse(
73
+ generateKeyPairSync('ec', { namedCurve: 'prime256v1' }).privateKey.export({ format: 'jwk' }),
74
+ );
75
+ export const signetPublicKey = (key: unknown) =>
76
+ publicClientKeySchema.parse(
77
+ createPublicKey(createPrivateKey({ key: signetKeySchema.parse(key), format: 'jwk' })).export({
78
+ format: 'jwk',
79
+ }),
80
+ );
81
+
82
+ export class SignetHttpError extends Error {
83
+ constructor(
84
+ readonly status: number,
85
+ readonly detail?: string,
86
+ ) {
87
+ super(`Signet request refused (${status})${detail ? `: ${detail}` : ''}`);
88
+ }
89
+ }
90
+ export interface SignetRequestOptions {
91
+ signal?: AbortSignal;
92
+ /** Defaults to 20 s; raise it for large uploads. */
93
+ timeoutMs?: number;
94
+ }
95
+ const requestSignal = ({ signal, timeoutMs = 20000 }: SignetRequestOptions = {}) =>
96
+ signal
97
+ ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
98
+ : AbortSignal.timeout(timeoutMs);
99
+
100
+ /** Stop this caller waiting without canceling shared credential renewal. */
101
+ function abortable<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
102
+ if (signal.aborted) {
103
+ void pending.catch(() => {});
104
+ return Promise.reject(signal.reason);
105
+ }
106
+ return new Promise<T>((resolve, reject) => {
107
+ const aborted = () => reject(signal.reason);
108
+ signal.addEventListener('abort', aborted, { once: true });
109
+ pending.then(resolve, reject).finally(() => signal.removeEventListener('abort', aborted));
110
+ });
111
+ }
112
+
113
+ const errorDetail = async (response: Response) => {
114
+ try {
115
+ const body = JSON.parse((await response.text()).slice(0, 4096)) as {
116
+ message?: unknown;
117
+ error?: { message?: unknown };
118
+ };
119
+ const message = body.error?.message ?? body.message;
120
+ return typeof message === 'string' ? message.slice(0, 500) : undefined;
121
+ } catch {
122
+ return undefined;
123
+ }
124
+ };
125
+
126
+ /** POST to Kingdom's access API and return the response's `data`. */
127
+ export async function signetPost(
128
+ url: string,
129
+ action: string,
130
+ body: unknown,
131
+ headers: Record<string, string> = {},
132
+ onDispatch?: () => void,
133
+ options: SignetRequestOptions = {},
134
+ ): Promise<unknown> {
135
+ if (!/^[a-zA-Z]+$/.test(action)) throw Error('Invalid Signet action');
136
+ const signal = requestSignal(options);
137
+ signal.throwIfAborted();
138
+ const serialized = JSON.stringify(body);
139
+ onDispatch?.();
140
+ signal.throwIfAborted();
141
+ const pendingResponse = fetch(`${kingdomUrl(url)}/api/v1/access/${action}`, {
142
+ method: 'POST',
143
+ redirect: 'error',
144
+ signal,
145
+ headers: { 'content-type': 'application/json', ...headers },
146
+ body: serialized,
147
+ });
148
+ // Also close a response from a transport that settles after cancellation.
149
+ void pendingResponse.then(
150
+ (response) => {
151
+ if (signal.aborted) void response.body?.cancel().catch(() => {});
152
+ },
153
+ () => {},
154
+ );
155
+ const response = await abortable(pendingResponse, signal);
156
+ if (signal.aborted) {
157
+ void response.body?.cancel().catch(() => {});
158
+ signal.throwIfAborted();
159
+ }
160
+ if (!response.ok) throw new SignetHttpError(response.status, await errorDetail(response));
161
+ const reader = response.body?.getReader();
162
+ if (!reader) throw Error('Signet response unavailable');
163
+ const chunks: Uint8Array[] = [];
164
+ let bytes = 0;
165
+ try {
166
+ for (;;) {
167
+ const next = await abortable(reader.read(), signal);
168
+ signal.throwIfAborted();
169
+ if (next.done) break;
170
+ bytes += next.value.byteLength;
171
+ if (bytes > 1_048_576) throw Error('Signet response exceeds limit');
172
+ chunks.push(next.value);
173
+ }
174
+ } finally {
175
+ void reader.cancel().catch(() => {});
176
+ }
177
+ signal.throwIfAborted();
178
+ return z.object({ data: z.unknown() }).parse(JSON.parse(Buffer.concat(chunks).toString('utf8')))
179
+ .data;
180
+ }
181
+
182
+ /** A DPoP proof for one call to `action`, bound to a fresh Kingdom nonce (and the token, if any). */
183
+ export async function signetProof(
184
+ url: string,
185
+ action: string,
186
+ keyFile: string,
187
+ token?: string,
188
+ options: SignetRequestOptions = {},
189
+ ): Promise<string> {
190
+ const signal = requestSignal(options);
191
+ signal.throwIfAborted();
192
+ const key = signetKeySchema.parse(await abortable(readPrivateJson(keyFile), signal));
193
+ signal.throwIfAborted();
194
+ const { nonce } = z
195
+ .object({ nonce: nonceSchema })
196
+ .parse(await signetPost(url, 'nonce', {}, {}, undefined, { signal }));
197
+ signal.throwIfAborted();
198
+ return createSignetProof({
199
+ privateKey: key,
200
+ publicKey: signetPublicKey(key),
201
+ nonce,
202
+ url: `${kingdomUrl(url)}/api/v1/access/${action}`,
203
+ method: 'POST',
204
+ token,
205
+ });
206
+ }
207
+
208
+ /** A call an Installation makes as itself: no access token, a DPoP proof by its key. */
209
+ async function installationPost<T>(
210
+ url: string,
211
+ keyFile: string,
212
+ action: string,
213
+ body: unknown,
214
+ schema: z.ZodType<T>,
215
+ options: SignetRequestOptions = {},
216
+ ): Promise<T> {
217
+ return schema.parse(
218
+ await signetPost(
219
+ url,
220
+ action,
221
+ body,
222
+ { DPoP: await signetProof(url, action, keyFile, undefined, options) },
223
+ undefined,
224
+ options,
225
+ ),
226
+ );
227
+ }
228
+
229
+ /** Make this installation known to Kingdom. Idempotent for the same key. */
230
+ export const registerInstallation = (
231
+ url: string,
232
+ keyFile: string,
233
+ installation: { kind: InstallationKind; name: string; sourceId?: string },
234
+ options: SignetRequestOptions = {},
235
+ ) =>
236
+ readPrivateJson(keyFile).then((key) =>
237
+ installationPost(
238
+ url,
239
+ keyFile,
240
+ 'registerInstallation',
241
+ { ...installation, publicKey: signetPublicKey(key) },
242
+ registerInstallationResponseSchema,
243
+ options,
244
+ ),
245
+ );
246
+
247
+ /** Ask to be registered as an integration; a person claims the review code in Kingdom. */
248
+ export const requestRegistration = (
249
+ url: string,
250
+ keyFile: string,
251
+ terms: RegistrationTerms,
252
+ options: SignetRequestOptions = {},
253
+ ) =>
254
+ installationPost(
255
+ url,
256
+ keyFile,
257
+ 'requestRegistration',
258
+ { terms },
259
+ requestRegistrationResponseSchema,
260
+ options,
261
+ );
262
+
263
+ /** This installation's pending request and its inquiries. */
264
+ export const installationInquiries = (
265
+ url: string,
266
+ keyFile: string,
267
+ options: SignetRequestOptions = {},
268
+ ) =>
269
+ installationPost(
270
+ url,
271
+ keyFile,
272
+ 'installationInquiries',
273
+ {},
274
+ installationInquiriesResponseSchema,
275
+ options,
276
+ );
277
+
278
+ export const cancelInstallationInquiry = (
279
+ url: string,
280
+ keyFile: string,
281
+ inquiryId: string,
282
+ options: SignetRequestOptions = {},
283
+ ) =>
284
+ installationPost(
285
+ url,
286
+ keyFile,
287
+ 'cancelInstallationInquiry',
288
+ { inquiryId },
289
+ z.object({ id: z.uuid() }),
290
+ options,
291
+ );
292
+
293
+ /**
294
+ * Collect the Signet an approved inquiry minted. Returns null while it awaits review (Kingdom
295
+ * answers 409) and throws once it is declined, denied or past its delivery deadline (403).
296
+ */
297
+ export async function collectSignet(
298
+ url: string,
299
+ keyFile: string,
300
+ inquiryId: string,
301
+ options: SignetRequestOptions = {},
302
+ ) {
303
+ try {
304
+ return await installationPost(
305
+ url,
306
+ keyFile,
307
+ 'collectSignet',
308
+ { inquiryId },
309
+ collectedSignetSchema,
310
+ options,
311
+ );
312
+ } catch (error) {
313
+ if (error instanceof SignetHttpError && error.status === 409) return null;
314
+ throw error;
315
+ }
316
+ }
317
+
318
+ /** The Signets held by this installation's integrations. */
319
+ export const installationSignets = (
320
+ url: string,
321
+ keyFile: string,
322
+ options: SignetRequestOptions = {},
323
+ ) =>
324
+ installationPost(
325
+ url,
326
+ keyFile,
327
+ 'installationSignets',
328
+ {},
329
+ installationSignetsResponseSchema,
330
+ options,
331
+ );
332
+
333
+ /** Enroll this installation's key on a Signet one of its integrations holds. */
334
+ export const enrollInstallationSignet = (
335
+ url: string,
336
+ keyFile: string,
337
+ signetId: string,
338
+ options: SignetRequestOptions = {},
339
+ ) =>
340
+ installationPost(
341
+ url,
342
+ keyFile,
343
+ 'enrollInstallationSignet',
344
+ { signetId },
345
+ collectedSignetSchema,
346
+ options,
347
+ );
348
+
349
+ /** Move this installation to a new key; both keys sign, the new proof bound to the current key. */
350
+ export async function rotateInstallationKey(
351
+ url: string,
352
+ keyFile: string,
353
+ newKeyFile: string,
354
+ options: SignetRequestOptions = {},
355
+ ) {
356
+ const publicKey = signetPublicKey(await readPrivateJson(newKeyFile));
357
+ const currentThumbprint = await keyThumbprint(signetPublicKey(await readPrivateJson(keyFile)));
358
+ const proof = await signetProof(
359
+ url,
360
+ 'rotateInstallationKey',
361
+ newKeyFile,
362
+ currentThumbprint,
363
+ options,
364
+ );
365
+ return installationPost(
366
+ url,
367
+ keyFile,
368
+ 'rotateInstallationKey',
369
+ { publicKey, proof },
370
+ registerInstallationResponseSchema,
371
+ options,
372
+ );
373
+ }
374
+
375
+ /** Write a collected Signet next to its key; the file is what a SignetClient presents. */
376
+ export async function saveCollectedSignet(
377
+ credentialFile: string,
378
+ url: string,
379
+ keyFile: string,
380
+ collected: z.infer<typeof collectedSignetSchema>,
381
+ ) {
382
+ const credential = signetCredentialSchema.parse({ ...collected, url, keyFile });
383
+ await writePrivateJson(credentialFile, credential);
384
+ return credential;
385
+ }
386
+
387
+ export type SignetAction = 'describe' | 'execute' | 'closeTask' | 'verifyAuthority' | 'settleTask';
388
+ const signetActions: SignetAction[] = [
389
+ 'describe',
390
+ 'execute',
391
+ 'closeTask',
392
+ 'verifyAuthority',
393
+ 'settleTask',
394
+ ];
395
+
396
+ /** Presents one enrolled Signet: renews its access token and signs each call with the device key. */
397
+ export class SignetClient {
398
+ constructor(
399
+ readonly url: string,
400
+ private credentialFile: string,
401
+ private signetId: string,
402
+ ) {
403
+ this.url = kingdomUrl(url);
404
+ }
405
+ /** A client for the Signet a credential file holds. */
406
+ static async fromFile(credentialFile: string) {
407
+ const credential = signetCredentialSchema.parse(await readPrivateJson(credentialFile));
408
+ return new SignetClient(credential.url, credentialFile, credential.signetId);
409
+ }
410
+ private async credentials(force = false, signal = requestSignal(), allowRenewal = true) {
411
+ signal.throwIfAborted();
412
+ const credential = signetCredentialSchema.parse(
413
+ await abortable(readPrivateJson(this.credentialFile), signal),
414
+ );
415
+ signal.throwIfAborted();
416
+ if (credential.url !== this.url || credential.signetId !== this.signetId)
417
+ throw Error('Signet credential audience mismatch');
418
+ if (Date.parse(credential.idleExpiresAt) <= Date.now())
419
+ throw Error('Signet enrollment idle timeout; enroll this device again');
420
+ if (!allowRenewal) {
421
+ if (Date.parse(credential.expiresAt) <= Date.now())
422
+ throw Error('Task settlement requires an unexpired existing access token');
423
+ return credential;
424
+ }
425
+ if (!force && Date.parse(credential.expiresAt) > Date.now() + 30000) return credential;
426
+ if (Date.parse(credential.renewalExpiresAt) <= Date.now())
427
+ throw Error('Signet enrollment expired; request renewed approval');
428
+ const existing = refreshing.get(this.credentialFile);
429
+ if (existing) return abortable(existing, signal);
430
+ const refresh = (async () => {
431
+ // Renewal belongs to the credential file, not the first waiting caller.
432
+ const refreshSignal = requestSignal();
433
+ const response = renewalResponseSchema.parse(
434
+ await signetPost(
435
+ this.url,
436
+ 'renewSignet',
437
+ { renewalCredential: credential.renewalCredential },
438
+ {
439
+ DPoP: await signetProof(this.url, 'renewSignet', credential.keyFile, undefined, {
440
+ signal: refreshSignal,
441
+ }),
442
+ },
443
+ undefined,
444
+ { signal: refreshSignal },
445
+ ),
446
+ );
447
+ if (
448
+ response.enrollmentId !== credential.enrollmentId ||
449
+ response.lifecycle !== credential.lifecycle ||
450
+ response.taskId !== credential.taskId
451
+ )
452
+ throw Error('Signet enrollment changed');
453
+ const updated = signetCredentialSchema.parse({ ...credential, ...response });
454
+ await writePrivateJson(this.credentialFile, updated);
455
+ return updated;
456
+ })();
457
+ refreshing.set(this.credentialFile, refresh);
458
+ const settled = refresh.finally(() => refreshing.delete(this.credentialFile));
459
+ return abortable(settled, signal);
460
+ }
461
+ async renew(options: SignetRequestOptions = {}): Promise<void> {
462
+ const signal = requestSignal(options);
463
+ await this.credentials(true, signal);
464
+ signal.throwIfAborted();
465
+ }
466
+ async post(
467
+ action: SignetAction,
468
+ body: unknown,
469
+ onDispatch?: () => void,
470
+ options: SignetRequestOptions = {},
471
+ ) {
472
+ if (!signetActions.includes(action)) throw Error('Unsupported Signet action');
473
+ const signal = requestSignal(options);
474
+ signal.throwIfAborted();
475
+ const credential = await this.credentials(false, signal, action !== 'settleTask');
476
+ signal.throwIfAborted();
477
+ const proof = await signetProof(this.url, action, credential.keyFile, credential.accessToken, {
478
+ signal,
479
+ timeoutMs: options.timeoutMs,
480
+ });
481
+ signal.throwIfAborted();
482
+ return signetPost(
483
+ this.url,
484
+ action,
485
+ body,
486
+ { authorization: `DPoP ${credential.accessToken}`, DPoP: proof },
487
+ onDispatch,
488
+ { signal, timeoutMs: options.timeoutMs },
489
+ );
490
+ }
491
+ async describe(options: SignetRequestOptions = {}) {
492
+ return signetDescriptionSchema.parse(
493
+ await this.post('describe', { signetId: this.signetId }, undefined, options),
494
+ );
495
+ }
496
+ /** Run one integration operation through the Signet; every call is a new request id. */
497
+ async execute(
498
+ request: Omit<AccessRequest, 'requestId' | 'signetId'>,
499
+ options: SignetRequestOptions = {},
500
+ ) {
501
+ return accessResultSchema.parse(
502
+ await this.post(
503
+ 'execute',
504
+ { ...request, signetId: this.signetId, requestId: crypto.randomUUID() },
505
+ undefined,
506
+ options,
507
+ ),
508
+ );
509
+ }
510
+ }
@@ -0,0 +1,232 @@
1
+ import { z } from 'zod';
2
+
3
+ const token43 = /^[A-Za-z0-9_-]{43}$/;
4
+
5
+ export const accessTokenPattern = /^kingdom_[A-Za-z0-9_-]{43}$/;
6
+ export const renewalCredentialPattern = /^signet_renew_[A-Za-z0-9_-]{43}$/;
7
+ export const nonceSchema = z.string().regex(token43);
8
+
9
+ export const archiveDocumentIdSchema = z.string().regex(/^[a-f0-9]{64}$/);
10
+ export const archiveFieldSchema = z.enum(['id', 'title', 'content', 'tags', 'createdAt']);
11
+ export type ArchiveField = z.infer<typeof archiveFieldSchema>;
12
+
13
+ export const ownerModelSchema = z.enum([
14
+ 'User',
15
+ 'OrganizationUser',
16
+ 'Organization',
17
+ 'Space',
18
+ 'SpaceUser',
19
+ ]);
20
+ export const ownerRefSchema = z.object({
21
+ ownerModel: ownerModelSchema,
22
+ userId: z.uuid().nullable(),
23
+ organizationId: z.uuid().nullable(),
24
+ spaceId: z.uuid().nullable(),
25
+ });
26
+ export type OwnerRef = z.infer<typeof ownerRefSchema>;
27
+
28
+ export const publicClientKeySchema = z.strictObject({
29
+ kty: z.literal('EC'),
30
+ crv: z.literal('P-256'),
31
+ x: z.string().regex(token43),
32
+ y: z.string().regex(token43),
33
+ });
34
+ export const signetKeySchema = publicClientKeySchema.extend({
35
+ d: z.string().regex(token43),
36
+ });
37
+ export type PublicClientKey = z.infer<typeof publicClientKeySchema>;
38
+ export type SignetKey = z.infer<typeof signetKeySchema>;
39
+
40
+ export const signetLensSchema = z.strictObject({
41
+ documentIds: z.array(archiveDocumentIdSchema).max(1000).optional(),
42
+ fields: z.array(archiveFieldSchema).max(6).optional(),
43
+ });
44
+ export const signetResourceSchema = z.strictObject({
45
+ resourceId: z.uuid(),
46
+ operations: z.array(z.string().min(1).max(80)).min(1).max(100),
47
+ lens: signetLensSchema.default({}),
48
+ });
49
+ export const signetRecipientSchema = z.discriminatedUnion('recipientModel', [
50
+ z.strictObject({ recipientModel: z.literal('User'), userId: z.uuid() }),
51
+ z.strictObject({ recipientModel: z.literal('Integration'), integrationId: z.uuid() }),
52
+ ]);
53
+ export const signetLifecycleSchema = z.enum(['request', 'task', 'ongoing']);
54
+ export type SignetLens = z.infer<typeof signetLensSchema>;
55
+ export type SignetRecipient = z.infer<typeof signetRecipientSchema>;
56
+ export type SignetResource = z.infer<typeof signetResourceSchema>;
57
+
58
+ export const signetEnrollmentSchema = z.strictObject({
59
+ signetId: z.uuid(),
60
+ runtimeInstallationId: z.uuid().optional(),
61
+ expectedRevision: z.number().int().min(1),
62
+ name: z.string().trim().min(1).max(120),
63
+ publicKey: publicClientKeySchema,
64
+ });
65
+ export const signetRenewSchema = z.strictObject({
66
+ renewalCredential: z.string().regex(renewalCredentialPattern),
67
+ });
68
+ export const signetCredentialsSchema = z.object({
69
+ enrollmentId: z.uuid(),
70
+ lifecycle: signetLifecycleSchema,
71
+ taskId: z.uuid().nullable(),
72
+ accessToken: z.string().regex(accessTokenPattern),
73
+ renewalCredential: z.string().regex(renewalCredentialPattern).optional(),
74
+ expiresAt: z.iso.datetime(),
75
+ renewalExpiresAt: z.iso.datetime(),
76
+ idleExpiresAt: z.iso.datetime(),
77
+ tokenType: z.literal('DPoP'),
78
+ });
79
+ export const collectedSignetSchema = signetCredentialsSchema.extend({
80
+ renewalCredential: z.string().regex(renewalCredentialPattern),
81
+ signetId: z.uuid(),
82
+ integrationId: z.uuid(),
83
+ owner: ownerRefSchema,
84
+ });
85
+ export type SignetCredentials = z.infer<typeof signetCredentialsSchema>;
86
+ export type CollectedSignet = z.infer<typeof collectedSignetSchema>;
87
+
88
+ const signetTerms = {
89
+ name: z.string().trim().min(1).max(120),
90
+ lifecycle: signetLifecycleSchema,
91
+ taskId: z.uuid().optional(),
92
+ expiresAt: z.iso.datetime().nullable(),
93
+ maxRequests: z.number().int().min(1).max(100000).nullable(),
94
+ maxConcurrent: z.number().int().min(1).max(20),
95
+ };
96
+ /** A local Foundry, Archive or Oracle known to Kingdom by its key. */
97
+ export const installationKindSchema = z.enum(['foundry', 'archive', 'oracle']);
98
+ export type InstallationKind = z.infer<typeof installationKindSchema>;
99
+ export const signetProposalSchema = z.strictObject({
100
+ ...signetTerms,
101
+ integrationId: z.uuid(),
102
+ resources: z.array(signetResourceSchema).min(1).max(100),
103
+ });
104
+ export const signetReviewProposalSchema = z.strictObject({
105
+ ...signetTerms,
106
+ resources: z.array(signetResourceSchema).max(100),
107
+ });
108
+ /** What an Installation asks for when it asks to be registered as an owner's integration. */
109
+ export const registrationTermsSchema = z.strictObject({
110
+ ...signetTerms,
111
+ lifecycle: z.literal('ongoing'),
112
+ resources: z.array(signetResourceSchema).max(100),
113
+ });
114
+ /** A person asks for a Signet on an integration, to be presented with `publicKey`. */
115
+ export const signetRequestSchema = z.strictObject({
116
+ ...signetTerms,
117
+ integrationId: z.uuid(),
118
+ resources: z.array(signetResourceSchema).min(1).max(100),
119
+ publicKey: publicClientKeySchema,
120
+ });
121
+ export const signetRequestResponseSchema = z.object({
122
+ requestId: z.uuid(),
123
+ reviewCode: z.string(),
124
+ expiresAt: z.iso.datetime(),
125
+ });
126
+ export type SignetProposal = z.infer<typeof signetProposalSchema>;
127
+ export type RegistrationTerms = z.infer<typeof registrationTermsSchema>;
128
+ export type SignetRequest = z.infer<typeof signetRequestSchema>;
129
+ export type SignetRequestResponse = z.infer<typeof signetRequestResponseSchema>;
130
+
131
+ /** A local Archive names its sourceId; Kingdom binds every write through its Signets to it. */
132
+ export const registerInstallationSchema = z
133
+ .strictObject({
134
+ kind: installationKindSchema,
135
+ name: z.string().trim().min(1).max(120),
136
+ sourceId: z.uuid().optional(),
137
+ publicKey: publicClientKeySchema,
138
+ })
139
+ .refine((value) => value.kind !== 'archive' || Boolean(value.sourceId), {
140
+ message: 'A local Archive registers with its sourceId',
141
+ });
142
+ export const registerInstallationResponseSchema = z.object({ installationId: z.uuid() });
143
+ export const requestRegistrationSchema = z.strictObject({ terms: registrationTermsSchema });
144
+ export const requestRegistrationResponseSchema = z.object({
145
+ reviewCode: z.string(),
146
+ expiresAt: z.iso.datetime(),
147
+ });
148
+ export const installationInquirySchema = z.object({
149
+ id: z.uuid(),
150
+ type: z.string(),
151
+ status: z.enum(['draft', 'sent', 'changesRequested', 'approved', 'denied', 'canceled']),
152
+ createdAt: z.iso.datetime(),
153
+ expiresAt: z.iso.datetime().nullable(),
154
+ owner: ownerRefSchema.nullable(),
155
+ ownerName: z.string().nullable(),
156
+ integrationId: z.uuid().nullable(),
157
+ signetId: z.uuid().nullable(),
158
+ deliverBefore: z.iso.datetime().nullable(),
159
+ });
160
+ export const installationInquiriesResponseSchema = z.object({
161
+ pending: z.object({ reviewCode: z.string(), expiresAt: z.iso.datetime() }).nullable(),
162
+ declinedAt: z.iso.datetime().nullable(),
163
+ inquiries: z.array(installationInquirySchema),
164
+ });
165
+ export const inquiryReferenceSchema = z.strictObject({ inquiryId: z.uuid() });
166
+ export const installationSignetsResponseSchema = z.object({
167
+ signets: z.array(
168
+ z.object({
169
+ signetId: z.uuid(),
170
+ integrationId: z.uuid(),
171
+ name: z.string(),
172
+ owner: ownerRefSchema,
173
+ }),
174
+ ),
175
+ });
176
+ export const enrollInstallationSignetSchema = z.strictObject({ signetId: z.uuid() });
177
+ export const rotateInstallationKeySchema = z.strictObject({
178
+ publicKey: publicClientKeySchema,
179
+ proof: z.string().max(8192),
180
+ });
181
+ export type InstallationInquiry = z.infer<typeof installationInquirySchema>;
182
+
183
+ export const signetDescriptionSchema = z.object({
184
+ signetId: z.uuid(),
185
+ integrationId: z.uuid(),
186
+ provider: z.string().nullable(),
187
+ name: z.string(),
188
+ expiresAt: z.iso.datetime().nullable(),
189
+ lifecycle: signetLifecycleSchema,
190
+ taskId: z.uuid().nullable(),
191
+ currentRevision: z.number().int(),
192
+ remainingRequests: z.number().nullable(),
193
+ operations: z.array(
194
+ z.object({
195
+ key: z.string(),
196
+ name: z.string(),
197
+ resources: z.array(
198
+ z.object({ id: z.uuid(), name: z.string(), kind: z.string(), integrationId: z.uuid() }),
199
+ ),
200
+ }),
201
+ ),
202
+ });
203
+ export type SignetDescription = z.infer<typeof signetDescriptionSchema>;
204
+
205
+ /** Operation-specific fields; Kingdom validates each against the operation it names. */
206
+ export const accessInputSchema = z.strictObject({
207
+ resourceId: z.uuid(),
208
+ documentId: archiveDocumentIdSchema.optional(),
209
+ fields: z.array(archiveFieldSchema).min(1).max(5).optional(),
210
+ limit: z.number().int().min(1).max(50).default(20),
211
+ query: z.string().max(1000).optional(),
212
+ projectId: z.string().min(1).max(256).optional(),
213
+ tag: z.string().min(1).max(120).optional(),
214
+ budget: z.number().int().min(16).max(32768).optional(),
215
+ snapshot: z.record(z.string(), z.unknown()).optional(),
216
+ previousDigest: z
217
+ .string()
218
+ .regex(/^[a-f0-9]{64}$/)
219
+ .nullable()
220
+ .optional(),
221
+ });
222
+ export const accessRequestSchema = z.strictObject({
223
+ requestId: z.uuid(),
224
+ taskId: z.uuid().optional(),
225
+ runId: z.uuid().optional(),
226
+ integrationId: z.uuid(),
227
+ signetId: z.uuid(),
228
+ operation: z.string().min(1).max(80),
229
+ input: accessInputSchema,
230
+ });
231
+ export type AccessRequest = z.infer<typeof accessRequestSchema>;
232
+ export const accessResultSchema = z.object({ executionId: z.uuid(), result: z.unknown() });
package/src/files.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, mkdir, open, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ /** Reads a private JSON file: owned by this user, mode 0600, not a link. */
6
+ export async function readPrivateJson(path: string): Promise<unknown> {
7
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
8
+ try {
9
+ const stat = await file.stat();
10
+ if (
11
+ !stat.isFile() ||
12
+ stat.nlink !== 1 ||
13
+ stat.size > 65536 ||
14
+ (stat.mode & 0o077) !== 0 ||
15
+ (process.getuid && stat.uid !== process.getuid())
16
+ )
17
+ throw Error('Signet credential file must be an owned private regular file (0600)');
18
+ return JSON.parse(await file.readFile('utf8'));
19
+ } finally {
20
+ await file.close();
21
+ }
22
+ }
23
+
24
+ /** Atomically writes a private JSON file into an owned 0700 directory, creating it if absent. */
25
+ export async function writePrivateJson(path: string, value: unknown): Promise<void> {
26
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
27
+ const parent = await lstat(dirname(path));
28
+ if (
29
+ !parent.isDirectory() ||
30
+ parent.isSymbolicLink() ||
31
+ (parent.mode & 0o077) !== 0 ||
32
+ (process.getuid && parent.uid !== process.getuid())
33
+ )
34
+ throw Error('Signet credential directory must be owned and private (0700)');
35
+ const temporary = join(dirname(path), `.credential-${crypto.randomUUID()}`);
36
+ try {
37
+ await writeFile(temporary, JSON.stringify(value), { mode: 0o600, flag: 'wx' });
38
+ await rename(temporary, path);
39
+ } finally {
40
+ await unlink(temporary).catch((error) => {
41
+ if (error.code !== 'ENOENT') throw error;
42
+ });
43
+ }
44
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './client';
2
+ export * from './contracts';
3
+ export * from './files';
4
+ export * from './proof';
package/src/proof.ts ADDED
@@ -0,0 +1,83 @@
1
+ import {
2
+ calculateJwkThumbprint,
3
+ decodeProtectedHeader,
4
+ importJWK,
5
+ type JWK,
6
+ jwtVerify,
7
+ SignJWT,
8
+ } from 'jose';
9
+ import { publicClientKeySchema } from './contracts';
10
+
11
+ export const digestCredential = async (value: string): Promise<string> =>
12
+ Buffer.from(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))).toString(
13
+ 'base64url',
14
+ );
15
+ export const proofTarget = (url: string): string => {
16
+ const target = new URL(url);
17
+ target.search = '';
18
+ target.hash = '';
19
+ return target.href;
20
+ };
21
+ export const verifySignetProof = async (input: {
22
+ proof: string;
23
+ url: string;
24
+ method: string;
25
+ now: Date;
26
+ token?: string;
27
+ keyThumbprint?: string;
28
+ }) => {
29
+ if (input.proof.length > 8192) throw new Error('Invalid proof');
30
+ const header = decodeProtectedHeader(input.proof);
31
+ const publicKey = publicClientKeySchema.parse(header.jwk);
32
+ const keyThumbprint = await calculateJwkThumbprint(publicKey);
33
+ if (input.keyThumbprint && input.keyThumbprint !== keyThumbprint)
34
+ throw new Error('Wrong proof key');
35
+ const { payload } = await jwtVerify(input.proof, await importJWK(publicKey, 'ES256'), {
36
+ algorithms: ['ES256'],
37
+ typ: 'dpop+jwt',
38
+ currentDate: input.now,
39
+ requiredClaims: ['iat', 'jti', 'htu', 'htm', 'nonce'],
40
+ });
41
+ const now = Math.floor(input.now.getTime() / 1000);
42
+ if (
43
+ !Number.isInteger(payload.iat) ||
44
+ payload.iat! < now - 60 ||
45
+ payload.iat! > now + 5 ||
46
+ typeof payload.jti !== 'string' ||
47
+ payload.jti.length < 16 ||
48
+ payload.jti.length > 128 ||
49
+ typeof payload.nonce !== 'string' ||
50
+ !/^[A-Za-z0-9_-]{43}$/.test(payload.nonce) ||
51
+ payload.htu !== proofTarget(input.url) ||
52
+ payload.htm !== input.method.toUpperCase() ||
53
+ (input.token && payload.ath !== (await digestCredential(input.token)))
54
+ )
55
+ throw new Error('Invalid proof binding');
56
+ return { publicKey, keyThumbprint, proofId: payload.jti, nonce: payload.nonce };
57
+ };
58
+ export const createSignetProof = async (input: {
59
+ privateKey: JWK;
60
+ publicKey: JWK;
61
+ nonce: string;
62
+ url: string;
63
+ method: string;
64
+ token?: string;
65
+ proofId?: string;
66
+ }) =>
67
+ new SignJWT({
68
+ htu: proofTarget(input.url),
69
+ htm: input.method.toUpperCase(),
70
+ nonce: input.nonce,
71
+ ...(input.token ? { ath: await digestCredential(input.token) } : {}),
72
+ })
73
+ .setProtectedHeader({
74
+ alg: 'ES256',
75
+ typ: 'dpop+jwt',
76
+ jwk: publicClientKeySchema.parse(input.publicKey),
77
+ })
78
+ .setIssuedAt()
79
+ .setJti(input.proofId ?? crypto.randomUUID())
80
+ .sign(await importJWK(input.privateKey, 'ES256'));
81
+
82
+ export const keyThumbprint = (key: unknown) =>
83
+ calculateJwkThumbprint(publicClientKeySchema.parse(key));