@inixiative/signet 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.1.0",
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.49",
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,388 @@
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 IntegrationPairingProposal,
10
+ nonceSchema,
11
+ ownerRefSchema,
12
+ publicClientKeySchema,
13
+ renewalCredentialPattern,
14
+ signetDescriptionSchema,
15
+ signetKeySchema,
16
+ signetLifecycleSchema,
17
+ signetRequestResponseSchema,
18
+ } from './contracts';
19
+ import { readPrivateJson, writePrivateJson } from './files';
20
+ import { createSignetProof } from './proof';
21
+
22
+ /** A Kingdom API origin: HTTPS, or HTTP on loopback, with no path or credentials. */
23
+ export const kingdomUrl = (value: string) => {
24
+ const url = new URL(value);
25
+ if (
26
+ url.username ||
27
+ url.password ||
28
+ url.search ||
29
+ url.hash ||
30
+ url.pathname !== '/' ||
31
+ (url.protocol !== 'https:' &&
32
+ !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)))
33
+ )
34
+ throw Error('Kingdom requires an HTTPS origin or loopback HTTP');
35
+ return url.origin;
36
+ };
37
+
38
+ /** What a presenting client keeps on disk (0600) for one enrolled Signet. */
39
+ export const signetCredentialSchema = z
40
+ .object({
41
+ url: z.string().transform(kingdomUrl),
42
+ signetId: z.uuid(),
43
+ integrationId: z.uuid().optional(),
44
+ owner: ownerRefSchema.optional(),
45
+ enrollmentId: z.uuid(),
46
+ lifecycle: signetLifecycleSchema,
47
+ taskId: z.uuid().nullable(),
48
+ keyFile: z.string().refine(isAbsolute),
49
+ renewalCredential: z.string().regex(renewalCredentialPattern),
50
+ accessToken: z.string().regex(accessTokenPattern),
51
+ expiresAt: z.iso.datetime(),
52
+ renewalExpiresAt: z.iso.datetime(),
53
+ idleExpiresAt: z.iso.datetime(),
54
+ tokenType: z.literal('DPoP'),
55
+ })
56
+ .strict();
57
+ export type SignetCredential = z.infer<typeof signetCredentialSchema>;
58
+ export const deliveredSignetSchema = signetCredentialSchema.omit({ url: true, keyFile: true });
59
+ const renewalResponseSchema = deliveredSignetSchema.omit({
60
+ signetId: true,
61
+ integrationId: true,
62
+ owner: true,
63
+ renewalCredential: true,
64
+ });
65
+ const refreshing = new Map<string, Promise<SignetCredential>>();
66
+
67
+ export const generateSignetKey = () =>
68
+ signetKeySchema.parse(
69
+ generateKeyPairSync('ec', { namedCurve: 'prime256v1' }).privateKey.export({ format: 'jwk' }),
70
+ );
71
+ export const signetPublicKey = (key: unknown) =>
72
+ publicClientKeySchema.parse(
73
+ createPublicKey(createPrivateKey({ key: signetKeySchema.parse(key), format: 'jwk' })).export({
74
+ format: 'jwk',
75
+ }),
76
+ );
77
+
78
+ export class SignetHttpError extends Error {
79
+ constructor(
80
+ readonly status: number,
81
+ readonly detail?: string,
82
+ ) {
83
+ super(`Signet request refused (${status})${detail ? `: ${detail}` : ''}`);
84
+ }
85
+ }
86
+ export interface SignetRequestOptions {
87
+ signal?: AbortSignal;
88
+ /** Defaults to 20 s; raise it for large uploads. */
89
+ timeoutMs?: number;
90
+ }
91
+ const requestSignal = ({ signal, timeoutMs = 20000 }: SignetRequestOptions = {}) =>
92
+ signal
93
+ ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
94
+ : AbortSignal.timeout(timeoutMs);
95
+
96
+ /** Stop this caller waiting without canceling shared credential renewal. */
97
+ function abortable<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
98
+ if (signal.aborted) {
99
+ void pending.catch(() => {});
100
+ return Promise.reject(signal.reason);
101
+ }
102
+ return new Promise<T>((resolve, reject) => {
103
+ const aborted = () => reject(signal.reason);
104
+ signal.addEventListener('abort', aborted, { once: true });
105
+ pending.then(resolve, reject).finally(() => signal.removeEventListener('abort', aborted));
106
+ });
107
+ }
108
+
109
+ const errorDetail = async (response: Response) => {
110
+ try {
111
+ const body = JSON.parse((await response.text()).slice(0, 4096)) as {
112
+ message?: unknown;
113
+ error?: { message?: unknown };
114
+ };
115
+ const message = body.error?.message ?? body.message;
116
+ return typeof message === 'string' ? message.slice(0, 500) : undefined;
117
+ } catch {
118
+ return undefined;
119
+ }
120
+ };
121
+
122
+ /** POST to Kingdom's access API and return the response's `data`. */
123
+ export async function signetPost(
124
+ url: string,
125
+ action: string,
126
+ body: unknown,
127
+ headers: Record<string, string> = {},
128
+ onDispatch?: () => void,
129
+ options: SignetRequestOptions = {},
130
+ ): Promise<unknown> {
131
+ if (!/^[a-zA-Z]+$/.test(action)) throw Error('Invalid Signet action');
132
+ const signal = requestSignal(options);
133
+ signal.throwIfAborted();
134
+ const serialized = JSON.stringify(body);
135
+ onDispatch?.();
136
+ signal.throwIfAborted();
137
+ const pendingResponse = fetch(`${kingdomUrl(url)}/api/v1/access/${action}`, {
138
+ method: 'POST',
139
+ redirect: 'error',
140
+ signal,
141
+ headers: { 'content-type': 'application/json', ...headers },
142
+ body: serialized,
143
+ });
144
+ // Also close a response from a transport that settles after cancellation.
145
+ void pendingResponse.then(
146
+ (response) => {
147
+ if (signal.aborted) void response.body?.cancel().catch(() => {});
148
+ },
149
+ () => {},
150
+ );
151
+ const response = await abortable(pendingResponse, signal);
152
+ if (signal.aborted) {
153
+ void response.body?.cancel().catch(() => {});
154
+ signal.throwIfAborted();
155
+ }
156
+ if (!response.ok) throw new SignetHttpError(response.status, await errorDetail(response));
157
+ const reader = response.body?.getReader();
158
+ if (!reader) throw Error('Signet response unavailable');
159
+ const chunks: Uint8Array[] = [];
160
+ let bytes = 0;
161
+ try {
162
+ for (;;) {
163
+ const next = await abortable(reader.read(), signal);
164
+ signal.throwIfAborted();
165
+ if (next.done) break;
166
+ bytes += next.value.byteLength;
167
+ if (bytes > 1_048_576) throw Error('Signet response exceeds limit');
168
+ chunks.push(next.value);
169
+ }
170
+ } finally {
171
+ void reader.cancel().catch(() => {});
172
+ }
173
+ signal.throwIfAborted();
174
+ return z.object({ data: z.unknown() }).parse(JSON.parse(Buffer.concat(chunks).toString('utf8')))
175
+ .data;
176
+ }
177
+
178
+ /** A DPoP proof for one call to `action`, bound to a fresh Kingdom nonce (and the token, if any). */
179
+ export async function signetProof(
180
+ url: string,
181
+ action: string,
182
+ keyFile: string,
183
+ token?: string,
184
+ options: SignetRequestOptions = {},
185
+ ): Promise<string> {
186
+ const signal = requestSignal(options);
187
+ signal.throwIfAborted();
188
+ const key = signetKeySchema.parse(await abortable(readPrivateJson(keyFile), signal));
189
+ signal.throwIfAborted();
190
+ const { nonce } = z
191
+ .object({ nonce: nonceSchema })
192
+ .parse(await signetPost(url, 'nonce', {}, {}, undefined, { signal }));
193
+ signal.throwIfAborted();
194
+ return createSignetProof({
195
+ privateKey: key,
196
+ publicKey: signetPublicKey(key),
197
+ nonce,
198
+ url: `${kingdomUrl(url)}/api/v1/access/${action}`,
199
+ method: 'POST',
200
+ token,
201
+ });
202
+ }
203
+
204
+ export type PairingTerms = Omit<IntegrationPairingProposal, 'lifecycle' | 'taskId'>;
205
+
206
+ /** Ask Kingdom to pair this device as an integration; the owner approves the review code. */
207
+ export async function requestPairing(
208
+ url: string,
209
+ keyFile: string,
210
+ terms: PairingTerms,
211
+ options: SignetRequestOptions = {},
212
+ ) {
213
+ const publicKey = signetPublicKey(await readPrivateJson(keyFile));
214
+ return signetRequestResponseSchema.parse(
215
+ await signetPost(
216
+ url,
217
+ 'requestSignet',
218
+ { ...terms, lifecycle: 'ongoing', publicKey },
219
+ { DPoP: await signetProof(url, 'requestSignet', keyFile, undefined, options) },
220
+ undefined,
221
+ options,
222
+ ),
223
+ );
224
+ }
225
+
226
+ /**
227
+ * Collect an approved pairing. Returns null while it awaits review (Kingdom answers 409; stop
228
+ * polling at the request's expiresAt) and throws once it is denied (403).
229
+ */
230
+ export async function collectPairing(
231
+ url: string,
232
+ keyFile: string,
233
+ deviceCode: string,
234
+ options: SignetRequestOptions = {},
235
+ ) {
236
+ try {
237
+ return collectedSignetSchema.parse(
238
+ await signetPost(
239
+ url,
240
+ 'collectSignet',
241
+ { deviceCode },
242
+ { DPoP: await signetProof(url, 'collectSignet', keyFile, undefined, options) },
243
+ undefined,
244
+ options,
245
+ ),
246
+ );
247
+ } catch (error) {
248
+ if (error instanceof SignetHttpError && error.status === 409) return null;
249
+ throw error;
250
+ }
251
+ }
252
+
253
+ /** Write a collected pairing next to its key; the file is what a SignetClient presents. */
254
+ export async function saveCollectedSignet(
255
+ credentialFile: string,
256
+ url: string,
257
+ keyFile: string,
258
+ collected: z.infer<typeof collectedSignetSchema>,
259
+ ) {
260
+ const credential = signetCredentialSchema.parse({ ...collected, url, keyFile });
261
+ await writePrivateJson(credentialFile, credential);
262
+ return credential;
263
+ }
264
+
265
+ export type SignetAction = 'describe' | 'execute' | 'closeTask' | 'verifyAuthority' | 'settleTask';
266
+ const signetActions: SignetAction[] = [
267
+ 'describe',
268
+ 'execute',
269
+ 'closeTask',
270
+ 'verifyAuthority',
271
+ 'settleTask',
272
+ ];
273
+
274
+ /** Presents one enrolled Signet: renews its access token and signs each call with the device key. */
275
+ export class SignetClient {
276
+ constructor(
277
+ readonly url: string,
278
+ private credentialFile: string,
279
+ private signetId: string,
280
+ ) {
281
+ this.url = kingdomUrl(url);
282
+ }
283
+ /** A client for the Signet a credential file holds. */
284
+ static async fromFile(credentialFile: string) {
285
+ const credential = signetCredentialSchema.parse(await readPrivateJson(credentialFile));
286
+ return new SignetClient(credential.url, credentialFile, credential.signetId);
287
+ }
288
+ private async credentials(force = false, signal = requestSignal(), allowRenewal = true) {
289
+ signal.throwIfAborted();
290
+ const credential = signetCredentialSchema.parse(
291
+ await abortable(readPrivateJson(this.credentialFile), signal),
292
+ );
293
+ signal.throwIfAborted();
294
+ if (credential.url !== this.url || credential.signetId !== this.signetId)
295
+ throw Error('Signet credential audience mismatch');
296
+ if (Date.parse(credential.idleExpiresAt) <= Date.now())
297
+ throw Error('Signet enrollment idle timeout; enroll this device again');
298
+ if (!allowRenewal) {
299
+ if (Date.parse(credential.expiresAt) <= Date.now())
300
+ throw Error('Task settlement requires an unexpired existing access token');
301
+ return credential;
302
+ }
303
+ if (!force && Date.parse(credential.expiresAt) > Date.now() + 30000) return credential;
304
+ if (Date.parse(credential.renewalExpiresAt) <= Date.now())
305
+ throw Error('Signet enrollment expired; request renewed approval');
306
+ const existing = refreshing.get(this.credentialFile);
307
+ if (existing) return abortable(existing, signal);
308
+ const refresh = (async () => {
309
+ // Renewal belongs to the credential file, not the first waiting caller.
310
+ const refreshSignal = requestSignal();
311
+ const response = renewalResponseSchema.parse(
312
+ await signetPost(
313
+ this.url,
314
+ 'renewSignet',
315
+ { renewalCredential: credential.renewalCredential },
316
+ {
317
+ DPoP: await signetProof(this.url, 'renewSignet', credential.keyFile, undefined, {
318
+ signal: refreshSignal,
319
+ }),
320
+ },
321
+ undefined,
322
+ { signal: refreshSignal },
323
+ ),
324
+ );
325
+ if (
326
+ response.enrollmentId !== credential.enrollmentId ||
327
+ response.lifecycle !== credential.lifecycle ||
328
+ response.taskId !== credential.taskId
329
+ )
330
+ throw Error('Signet enrollment changed');
331
+ const updated = signetCredentialSchema.parse({ ...credential, ...response });
332
+ await writePrivateJson(this.credentialFile, updated);
333
+ return updated;
334
+ })();
335
+ refreshing.set(this.credentialFile, refresh);
336
+ const settled = refresh.finally(() => refreshing.delete(this.credentialFile));
337
+ return abortable(settled, signal);
338
+ }
339
+ async renew(options: SignetRequestOptions = {}): Promise<void> {
340
+ const signal = requestSignal(options);
341
+ await this.credentials(true, signal);
342
+ signal.throwIfAborted();
343
+ }
344
+ async post(
345
+ action: SignetAction,
346
+ body: unknown,
347
+ onDispatch?: () => void,
348
+ options: SignetRequestOptions = {},
349
+ ) {
350
+ if (!signetActions.includes(action)) throw Error('Unsupported Signet action');
351
+ const signal = requestSignal(options);
352
+ signal.throwIfAborted();
353
+ const credential = await this.credentials(false, signal, action !== 'settleTask');
354
+ signal.throwIfAborted();
355
+ const proof = await signetProof(this.url, action, credential.keyFile, credential.accessToken, {
356
+ signal,
357
+ timeoutMs: options.timeoutMs,
358
+ });
359
+ signal.throwIfAborted();
360
+ return signetPost(
361
+ this.url,
362
+ action,
363
+ body,
364
+ { authorization: `DPoP ${credential.accessToken}`, DPoP: proof },
365
+ onDispatch,
366
+ { signal, timeoutMs: options.timeoutMs },
367
+ );
368
+ }
369
+ async describe(options: SignetRequestOptions = {}) {
370
+ return signetDescriptionSchema.parse(
371
+ await this.post('describe', { signetId: this.signetId }, undefined, options),
372
+ );
373
+ }
374
+ /** Run one integration operation through the Signet; every call is a new request id. */
375
+ async execute(
376
+ request: Omit<AccessRequest, 'requestId' | 'signetId'>,
377
+ options: SignetRequestOptions = {},
378
+ ) {
379
+ return accessResultSchema.parse(
380
+ await this.post(
381
+ 'execute',
382
+ { ...request, signetId: this.signetId, requestId: crypto.randomUUID() },
383
+ undefined,
384
+ options,
385
+ ),
386
+ );
387
+ }
388
+ }
@@ -0,0 +1,198 @@
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
+ export const deviceCodeSchema = z.string().regex(token43);
9
+
10
+ export const archiveDocumentIdSchema = z.string().regex(/^[a-f0-9]{64}$/);
11
+ export const archiveFieldSchema = z.enum(['id', 'title', 'content', 'tags', 'createdAt']);
12
+ export type ArchiveField = z.infer<typeof archiveFieldSchema>;
13
+
14
+ export const ownerModelSchema = z.enum([
15
+ 'User',
16
+ 'OrganizationUser',
17
+ 'Organization',
18
+ 'Space',
19
+ 'SpaceUser',
20
+ ]);
21
+ export const ownerRefSchema = z.object({
22
+ ownerModel: ownerModelSchema,
23
+ userId: z.uuid().nullable(),
24
+ organizationId: z.uuid().nullable(),
25
+ spaceId: z.uuid().nullable(),
26
+ });
27
+ export type OwnerRef = z.infer<typeof ownerRefSchema>;
28
+
29
+ export const publicClientKeySchema = z.strictObject({
30
+ kty: z.literal('EC'),
31
+ crv: z.literal('P-256'),
32
+ x: z.string().regex(token43),
33
+ y: z.string().regex(token43),
34
+ });
35
+ export const signetKeySchema = publicClientKeySchema.extend({
36
+ d: z.string().regex(token43),
37
+ });
38
+ export type PublicClientKey = z.infer<typeof publicClientKeySchema>;
39
+ export type SignetKey = z.infer<typeof signetKeySchema>;
40
+
41
+ export const signetLensSchema = z.strictObject({
42
+ documentIds: z.array(archiveDocumentIdSchema).max(1000).optional(),
43
+ fields: z.array(archiveFieldSchema).max(6).optional(),
44
+ });
45
+ export const signetResourceSchema = z.strictObject({
46
+ resourceId: z.uuid(),
47
+ operations: z.array(z.string().min(1).max(80)).min(1).max(100),
48
+ lens: signetLensSchema.default({}),
49
+ });
50
+ export const signetRecipientSchema = z.discriminatedUnion('recipientModel', [
51
+ z.strictObject({ recipientModel: z.literal('User'), userId: z.uuid() }),
52
+ z.strictObject({ recipientModel: z.literal('Integration'), integrationId: z.uuid() }),
53
+ ]);
54
+ export const signetLifecycleSchema = z.enum(['request', 'task', 'ongoing']);
55
+ export type SignetLens = z.infer<typeof signetLensSchema>;
56
+ export type SignetRecipient = z.infer<typeof signetRecipientSchema>;
57
+ export type SignetResource = z.infer<typeof signetResourceSchema>;
58
+
59
+ export const signetEnrollmentSchema = z.strictObject({
60
+ signetId: z.uuid(),
61
+ runtimeInstallationId: z.uuid().optional(),
62
+ expectedRevision: z.number().int().min(1),
63
+ name: z.string().trim().min(1).max(120),
64
+ publicKey: publicClientKeySchema,
65
+ });
66
+ export const signetRenewSchema = z.strictObject({
67
+ renewalCredential: z.string().regex(renewalCredentialPattern),
68
+ });
69
+ export const signetCredentialsSchema = z.object({
70
+ enrollmentId: z.uuid(),
71
+ lifecycle: signetLifecycleSchema,
72
+ taskId: z.uuid().nullable(),
73
+ accessToken: z.string().regex(accessTokenPattern),
74
+ renewalCredential: z.string().regex(renewalCredentialPattern).optional(),
75
+ expiresAt: z.iso.datetime(),
76
+ renewalExpiresAt: z.iso.datetime(),
77
+ idleExpiresAt: z.iso.datetime(),
78
+ tokenType: z.literal('DPoP'),
79
+ });
80
+ export const collectedSignetSchema = signetCredentialsSchema.extend({
81
+ renewalCredential: z.string().regex(renewalCredentialPattern),
82
+ signetId: z.uuid(),
83
+ integrationId: z.uuid(),
84
+ owner: ownerRefSchema,
85
+ });
86
+ export type SignetCredentials = z.infer<typeof signetCredentialsSchema>;
87
+ export type CollectedSignet = z.infer<typeof collectedSignetSchema>;
88
+
89
+ const signetTerms = {
90
+ name: z.string().trim().min(1).max(120),
91
+ lifecycle: signetLifecycleSchema,
92
+ taskId: z.uuid().optional(),
93
+ expiresAt: z.iso.datetime().nullable(),
94
+ maxRequests: z.number().int().min(1).max(100000).nullable(),
95
+ maxConcurrent: z.number().int().min(1).max(20),
96
+ };
97
+ /** Kinds of integration a device can pair as; approval creates one and issues it a Signet. */
98
+ export const pairableProviderSchema = z.enum(['foundry', 'archive']);
99
+ export type PairableProvider = z.infer<typeof pairableProviderSchema>;
100
+ export const signetProposalSchema = z.strictObject({
101
+ ...signetTerms,
102
+ integrationId: z.uuid(),
103
+ resources: z.array(signetResourceSchema).min(1).max(100),
104
+ });
105
+ /** A local Archive names its sourceId; Kingdom binds every write through its Signets to it. */
106
+ const deviceIdentity = (value: { provider?: PairableProvider; deviceId?: string }) =>
107
+ value.provider !== 'archive' || z.uuid().safeParse(value.deviceId).success;
108
+ const deviceIdentityMessage = { message: 'A local Archive pairs with its sourceId as deviceId' };
109
+ export const integrationPairingProposalSchema = z
110
+ .strictObject({
111
+ ...signetTerms,
112
+ provider: pairableProviderSchema,
113
+ deviceId: z.string().min(1).max(256).optional(),
114
+ resources: z.array(signetResourceSchema).max(100),
115
+ lifecycle: z.literal('ongoing'),
116
+ })
117
+ .refine(deviceIdentity, deviceIdentityMessage);
118
+ export const signetRequestSchema = z
119
+ .strictObject({
120
+ ...signetTerms,
121
+ integrationId: z.uuid().optional(),
122
+ provider: pairableProviderSchema.optional(),
123
+ deviceId: z.string().min(1).max(256).optional(),
124
+ resources: z.array(signetResourceSchema).max(100),
125
+ publicKey: publicClientKeySchema,
126
+ })
127
+ .refine((value) => Boolean(value.integrationId) !== Boolean(value.provider), {
128
+ message: 'Name the host integration, or the kind of integration to pair',
129
+ })
130
+ .refine((value) => Boolean(value.provider) || !value.deviceId, {
131
+ message: 'Only integration pairing names a device',
132
+ })
133
+ .refine(deviceIdentity, deviceIdentityMessage);
134
+ export const signetRequestResponseSchema = z.object({
135
+ requestId: z.uuid(),
136
+ reviewCode: z.string(),
137
+ deviceCode: deviceCodeSchema,
138
+ expiresAt: z.iso.datetime(),
139
+ });
140
+ export const signetReviewProposalSchema = z.strictObject({
141
+ ...signetTerms,
142
+ resources: z.array(signetResourceSchema).max(100),
143
+ });
144
+ export type SignetProposal = z.infer<typeof signetProposalSchema>;
145
+ export type IntegrationPairingProposal = z.infer<typeof integrationPairingProposalSchema>;
146
+ export type SignetRequest = z.infer<typeof signetRequestSchema>;
147
+ export type SignetRequestResponse = z.infer<typeof signetRequestResponseSchema>;
148
+
149
+ export const signetDescriptionSchema = z.object({
150
+ signetId: z.uuid(),
151
+ integrationId: z.uuid(),
152
+ provider: z.string().nullable(),
153
+ name: z.string(),
154
+ expiresAt: z.iso.datetime().nullable(),
155
+ lifecycle: signetLifecycleSchema,
156
+ taskId: z.uuid().nullable(),
157
+ currentRevision: z.number().int(),
158
+ remainingRequests: z.number().nullable(),
159
+ operations: z.array(
160
+ z.object({
161
+ key: z.string(),
162
+ name: z.string(),
163
+ resources: z.array(
164
+ z.object({ id: z.uuid(), name: z.string(), kind: z.string(), integrationId: z.uuid() }),
165
+ ),
166
+ }),
167
+ ),
168
+ });
169
+ export type SignetDescription = z.infer<typeof signetDescriptionSchema>;
170
+
171
+ /** Operation-specific fields; Kingdom validates each against the operation it names. */
172
+ export const accessInputSchema = z.strictObject({
173
+ resourceId: z.uuid(),
174
+ documentId: archiveDocumentIdSchema.optional(),
175
+ fields: z.array(archiveFieldSchema).min(1).max(5).optional(),
176
+ limit: z.number().int().min(1).max(50).default(20),
177
+ query: z.string().max(1000).optional(),
178
+ projectId: z.string().min(1).max(256).optional(),
179
+ tag: z.string().min(1).max(120).optional(),
180
+ budget: z.number().int().min(16).max(32768).optional(),
181
+ snapshot: z.record(z.string(), z.unknown()).optional(),
182
+ previousDigest: z
183
+ .string()
184
+ .regex(/^[a-f0-9]{64}$/)
185
+ .nullable()
186
+ .optional(),
187
+ });
188
+ export const accessRequestSchema = z.strictObject({
189
+ requestId: z.uuid(),
190
+ taskId: z.uuid().optional(),
191
+ runId: z.uuid().optional(),
192
+ integrationId: z.uuid(),
193
+ signetId: z.uuid(),
194
+ operation: z.string().min(1).max(80),
195
+ input: accessInputSchema,
196
+ });
197
+ export type AccessRequest = z.infer<typeof accessRequestSchema>;
198
+ 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));