@inixiative/signet 0.2.1 → 0.2.2

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
@@ -10,22 +10,34 @@ This package is the one implementation of that protocol, shared by Kingdom (whic
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`:
13
- - device pairing: `requestPairing`, then the owner approves the review code in Kingdom, then `collectPairing` and `saveCollectedSignet`;
13
+ - an **Installation** (a local Foundry, Archive or Oracle) is an actor known to Kingdom by its key: `registerInstallation`, then `requestRegistration` returns a review code a person claims in Kingdom (a `registerIntegration` Inquiry), then `collectSignet` and `saveCollectedSignet`. `installationInquiries`, `installationSignets`, `cancelInstallationInquiry`, `enrollInstallationSignet` and `rotateInstallationKey` act as the installation;
14
+ - `InstallationSocket`, Kingdom's live line to an installation: it proves the key, receives a snapshot of the installation's inquiries and Signets on connect and on every change, keeps the installation's integrations marked online, advertises its viewer URL, and polls the same reads while the socket is down;
14
15
  - `SignetClient`, which renews its access token and signs each `describe` / `execute` call.
15
16
 
16
17
  ```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,
18
+ import {
19
+ collectSignet, generateSignetKey, InstallationSocket, registerInstallation, requestRegistration,
20
+ saveCollectedSignet, SignetClient, writePrivateJson,
21
+ } from '@inixiative/signet';
22
+
23
+ await writePrivateJson(keyFile, generateSignetKey());
24
+ await registerInstallation(kingdom, keyFile, { kind: 'archive', name: 'Laptop Archive', sourceId });
25
+ const { reviewCode } = await requestRegistration(kingdom, keyFile, {
26
+ name: 'Laptop Archive', lifecycle: 'ongoing', resources: [], expiresAt: null, maxRequests: null, maxConcurrent: 4,
27
+ });
28
+ // Show reviewCode; someone claims it in Kingdom. The socket tells you when the inquiry resolves.
29
+ const socket = new InstallationSocket({
30
+ url: kingdom,
31
+ keyFile,
32
+ onSnapshot: async ({ inquiries }) => {
33
+ const approved = inquiries.find((inquiry) => inquiry.status === 'approved' && inquiry.signetId);
34
+ const collected = approved && (await collectSignet(kingdom, keyFile, approved.id));
35
+ if (collected) await saveCollectedSignet(credentialFile, kingdom, keyFile, collected);
36
+ },
22
37
  });
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!);
38
+ socket.start();
26
39
 
27
- const signet = new SignetClient(credentialFile);
28
- const { operations } = await signet.describe();
40
+ const signet = new SignetClient(kingdom, credentialFile, signetId);
29
41
  await signet.execute({ integrationId, operation: 'sessions.write', input: { resourceId, snapshot, previousDigest: null } });
30
42
  ```
31
43
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/signet",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
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",
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "devDependencies": {
36
36
  "@biomejs/biome": "2.5.2",
37
- "@inixiative/config": "^0.5.55",
37
+ "@inixiative/config": "^0.5.58",
38
38
  "@types/bun": "1.4.2",
39
39
  "lefthook": "2.1.9",
40
40
  "typescript": "6.0.3",
package/src/contracts.ts CHANGED
@@ -230,3 +230,9 @@ export const accessRequestSchema = z.strictObject({
230
230
  });
231
231
  export type AccessRequest = z.infer<typeof accessRequestSchema>;
232
232
  export const accessResultSchema = z.object({ executionId: z.uuid(), result: z.unknown() });
233
+ /** What Kingdom pushes over the Installation socket, and what a poll of both reads assembles. */
234
+ export const installationSnapshotSchema = installationInquiriesResponseSchema.extend(
235
+ installationSignetsResponseSchema.shape,
236
+ );
237
+ /** The path a socket proof is bound to (POST), though the socket itself opens at the API origin. */
238
+ export const installationSocketAction = 'installationSocket';
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './client';
2
2
  export * from './contracts';
3
3
  export * from './files';
4
+ export * from './installationSocket';
4
5
  export * from './proof';
@@ -0,0 +1,194 @@
1
+ import { z } from 'zod';
2
+ import { installationInquiries, installationSignets, kingdomUrl, signetProof } from './client';
3
+ import { installationSnapshotSchema, installationSocketAction } from './contracts';
4
+
5
+ export type InstallationSnapshot = z.infer<typeof installationSnapshotSchema>;
6
+
7
+ export type InstallationSocketOptions = {
8
+ url: string;
9
+ keyFile: string;
10
+ onSnapshot: (snapshot: InstallationSnapshot) => void | Promise<void>;
11
+ /** Kingdom revoked this installation; the socket has stopped for good. */
12
+ onRevoked?: () => void;
13
+ onError?: (error: unknown) => void;
14
+ /** Where Kingdom can reach this installation's viewer (Foundry's tunnel); re-advertised on every connect. */
15
+ viewerUrl?: string | null;
16
+ /** Reported on every ping so Kingdom can show how busy this installation is. */
17
+ sessionCount?: () => number;
18
+ pingMs?: number;
19
+ pollMs?: number;
20
+ retryBaseMs?: number;
21
+ retryMaxMs?: number;
22
+ WebSocket?: typeof WebSocket;
23
+ };
24
+
25
+ const frameSchema = z.object({ type: z.string() }).loose();
26
+
27
+ /**
28
+ * Kingdom's live line to an Installation. It proves the installation's key, then receives a
29
+ * snapshot of its inquiries and Signets on connect and again whenever one changes. While the
30
+ * socket is down it polls the same reads, so nothing waits on the socket coming back.
31
+ */
32
+ export class InstallationSocket {
33
+ private socket: WebSocket | null = null;
34
+ private stopped = false;
35
+ private attempts = 0;
36
+ private pingTimer: ReturnType<typeof setInterval> | null = null;
37
+ private pollTimer: ReturnType<typeof setTimeout> | null = null;
38
+ private retryTimer: ReturnType<typeof setTimeout> | null = null;
39
+ private authenticated = false;
40
+ private polling = false;
41
+ private pollGeneration = 0;
42
+ private viewerUrl: string | null;
43
+
44
+ constructor(private readonly options: InstallationSocketOptions) {
45
+ this.viewerUrl = options.viewerUrl ?? null;
46
+ }
47
+
48
+ get connected() {
49
+ return this.authenticated;
50
+ }
51
+
52
+ start() {
53
+ if (this.socket || this.retryTimer) return;
54
+ this.stopped = false;
55
+ this.connect();
56
+ }
57
+
58
+ /** Tell Kingdom this installation is restarting (presence shows it as such), then close. */
59
+ restarting() {
60
+ this.send({ action: 'restarting' });
61
+ this.close();
62
+ }
63
+
64
+ advertise(viewerUrl: string | null) {
65
+ this.viewerUrl = viewerUrl;
66
+ if (this.authenticated) this.send({ action: 'advertise', viewerUrl });
67
+ }
68
+
69
+ close() {
70
+ this.stopped = true;
71
+ this.clearTimers();
72
+ this.socket?.close(1000, 'closed');
73
+ this.socket = null;
74
+ this.authenticated = false;
75
+ }
76
+
77
+ private connect() {
78
+ this.retryTimer = null;
79
+ if (this.stopped) return;
80
+ const Socket = this.options.WebSocket ?? WebSocket;
81
+ const socket = new Socket(kingdomUrl(this.options.url).replace(/^http/, 'ws'));
82
+ this.socket = socket;
83
+ socket.onmessage = (event) => void this.receive(socket, String(event.data));
84
+ socket.onclose = () => this.disconnected(socket);
85
+ socket.onerror = () => socket.close();
86
+ }
87
+
88
+ private async receive(socket: WebSocket, raw: string) {
89
+ let parsed: ReturnType<typeof frameSchema.safeParse>;
90
+ try {
91
+ parsed = frameSchema.safeParse(JSON.parse(raw));
92
+ } catch {
93
+ return;
94
+ }
95
+ if (!parsed.success || socket !== this.socket) return;
96
+ const frame = parsed.data;
97
+ try {
98
+ switch (frame.type) {
99
+ case 'connected':
100
+ this.send({
101
+ action: 'authenticateInstallation',
102
+ proof: await signetProof(
103
+ this.options.url,
104
+ installationSocketAction,
105
+ this.options.keyFile,
106
+ ),
107
+ });
108
+ return;
109
+ case 'installation':
110
+ this.authenticated = true;
111
+ this.attempts = 0;
112
+ this.stopPolling();
113
+ this.pingTimer ??= setInterval(
114
+ () => this.send({ action: 'ping', sessionCount: this.options.sessionCount?.() }),
115
+ this.options.pingMs ?? 20_000,
116
+ );
117
+ if (this.viewerUrl) this.send({ action: 'advertise', viewerUrl: this.viewerUrl });
118
+ return;
119
+ case 'installation.snapshot':
120
+ await this.options.onSnapshot(installationSnapshotSchema.parse(frame));
121
+ return;
122
+ case 'installationRejected':
123
+ socket.close();
124
+ return;
125
+ case 'installationRevoked':
126
+ this.close();
127
+ this.options.onRevoked?.();
128
+ return;
129
+ case 'reconnect':
130
+ socket.close();
131
+ return;
132
+ case 'advertiseRejected':
133
+ this.options.onError?.(Error(`Kingdom refused viewer URL ${this.viewerUrl}`));
134
+ return;
135
+ }
136
+ } catch (error) {
137
+ this.options.onError?.(error);
138
+ if (frame.type === 'connected') socket.close();
139
+ }
140
+ }
141
+
142
+ private send(frame: Record<string, unknown>) {
143
+ if (this.socket) this.sendOn(this.socket, frame);
144
+ }
145
+
146
+ private sendOn(socket: WebSocket, frame: Record<string, unknown>) {
147
+ if (socket === this.socket && socket.readyState === WebSocket.OPEN)
148
+ socket.send(JSON.stringify(frame));
149
+ }
150
+
151
+ private disconnected(socket: WebSocket) {
152
+ if (socket !== this.socket) return;
153
+ this.socket = null;
154
+ this.authenticated = false;
155
+ if (this.pingTimer) clearInterval(this.pingTimer);
156
+ this.pingTimer = null;
157
+ if (this.stopped) return;
158
+ if (!this.polling) void this.poll();
159
+ const base = this.options.retryBaseMs ?? 1_000;
160
+ const delay = Math.min(base * 2 ** this.attempts++, this.options.retryMaxMs ?? 30_000);
161
+ this.retryTimer = setTimeout(() => this.connect(), delay);
162
+ }
163
+
164
+ private async poll(generation = ++this.pollGeneration) {
165
+ if (this.stopped || this.authenticated || generation !== this.pollGeneration) return;
166
+ this.polling = true;
167
+ try {
168
+ const [inquiries, signets] = await Promise.all([
169
+ installationInquiries(this.options.url, this.options.keyFile),
170
+ installationSignets(this.options.url, this.options.keyFile),
171
+ ]);
172
+ if (!this.authenticated) await this.options.onSnapshot({ ...inquiries, ...signets });
173
+ } catch (error) {
174
+ this.options.onError?.(error);
175
+ }
176
+ if (this.stopped || this.authenticated || generation !== this.pollGeneration) return;
177
+ this.pollTimer = setTimeout(() => void this.poll(generation), this.options.pollMs ?? 60_000);
178
+ }
179
+
180
+ private stopPolling() {
181
+ if (this.pollTimer) clearTimeout(this.pollTimer);
182
+ this.pollTimer = null;
183
+ this.polling = false;
184
+ this.pollGeneration++;
185
+ }
186
+
187
+ private clearTimers() {
188
+ this.stopPolling();
189
+ if (this.pingTimer) clearInterval(this.pingTimer);
190
+ if (this.retryTimer) clearTimeout(this.retryTimer);
191
+ this.pingTimer = null;
192
+ this.retryTimer = null;
193
+ }
194
+ }