@inixiative/signet 0.1.0 → 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 +23 -11
- package/package.json +2 -2
- package/src/client.ts +150 -28
- package/src/contracts.ts +79 -39
- package/src/index.ts +1 -0
- package/src/installationSocket.ts +194 -0
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
|
-
-
|
|
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 {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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/client.ts
CHANGED
|
@@ -6,18 +6,22 @@ import {
|
|
|
6
6
|
accessResultSchema,
|
|
7
7
|
accessTokenPattern,
|
|
8
8
|
collectedSignetSchema,
|
|
9
|
-
type
|
|
9
|
+
type InstallationKind,
|
|
10
|
+
installationInquiriesResponseSchema,
|
|
11
|
+
installationSignetsResponseSchema,
|
|
10
12
|
nonceSchema,
|
|
11
13
|
ownerRefSchema,
|
|
12
14
|
publicClientKeySchema,
|
|
15
|
+
type RegistrationTerms,
|
|
16
|
+
registerInstallationResponseSchema,
|
|
13
17
|
renewalCredentialPattern,
|
|
18
|
+
requestRegistrationResponseSchema,
|
|
14
19
|
signetDescriptionSchema,
|
|
15
20
|
signetKeySchema,
|
|
16
21
|
signetLifecycleSchema,
|
|
17
|
-
signetRequestResponseSchema,
|
|
18
22
|
} from './contracts';
|
|
19
23
|
import { readPrivateJson, writePrivateJson } from './files';
|
|
20
|
-
import { createSignetProof } from './proof';
|
|
24
|
+
import { createSignetProof, keyThumbprint } from './proof';
|
|
21
25
|
|
|
22
26
|
/** A Kingdom API origin: HTTPS, or HTTP on loopback, with no path or credentials. */
|
|
23
27
|
export const kingdomUrl = (value: string) => {
|
|
@@ -201,48 +205,109 @@ export async function signetProof(
|
|
|
201
205
|
});
|
|
202
206
|
}
|
|
203
207
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
/** Ask Kingdom to pair this device as an integration; the owner approves the review code. */
|
|
207
|
-
export async function requestPairing(
|
|
208
|
+
/** A call an Installation makes as itself: no access token, a DPoP proof by its key. */
|
|
209
|
+
async function installationPost<T>(
|
|
208
210
|
url: string,
|
|
209
211
|
keyFile: string,
|
|
210
|
-
|
|
212
|
+
action: string,
|
|
213
|
+
body: unknown,
|
|
214
|
+
schema: z.ZodType<T>,
|
|
211
215
|
options: SignetRequestOptions = {},
|
|
212
|
-
) {
|
|
213
|
-
|
|
214
|
-
return signetRequestResponseSchema.parse(
|
|
216
|
+
): Promise<T> {
|
|
217
|
+
return schema.parse(
|
|
215
218
|
await signetPost(
|
|
216
219
|
url,
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
{ DPoP: await signetProof(url,
|
|
220
|
+
action,
|
|
221
|
+
body,
|
|
222
|
+
{ DPoP: await signetProof(url, action, keyFile, undefined, options) },
|
|
220
223
|
undefined,
|
|
221
224
|
options,
|
|
222
225
|
),
|
|
223
226
|
);
|
|
224
227
|
}
|
|
225
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
|
+
|
|
226
293
|
/**
|
|
227
|
-
* Collect an approved
|
|
228
|
-
*
|
|
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).
|
|
229
296
|
*/
|
|
230
|
-
export async function
|
|
297
|
+
export async function collectSignet(
|
|
231
298
|
url: string,
|
|
232
299
|
keyFile: string,
|
|
233
|
-
|
|
300
|
+
inquiryId: string,
|
|
234
301
|
options: SignetRequestOptions = {},
|
|
235
302
|
) {
|
|
236
303
|
try {
|
|
237
|
-
return
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
options,
|
|
245
|
-
),
|
|
304
|
+
return await installationPost(
|
|
305
|
+
url,
|
|
306
|
+
keyFile,
|
|
307
|
+
'collectSignet',
|
|
308
|
+
{ inquiryId },
|
|
309
|
+
collectedSignetSchema,
|
|
310
|
+
options,
|
|
246
311
|
);
|
|
247
312
|
} catch (error) {
|
|
248
313
|
if (error instanceof SignetHttpError && error.status === 409) return null;
|
|
@@ -250,7 +315,64 @@ export async function collectPairing(
|
|
|
250
315
|
}
|
|
251
316
|
}
|
|
252
317
|
|
|
253
|
-
/**
|
|
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. */
|
|
254
376
|
export async function saveCollectedSignet(
|
|
255
377
|
credentialFile: string,
|
|
256
378
|
url: string,
|
package/src/contracts.ts
CHANGED
|
@@ -5,7 +5,6 @@ const token43 = /^[A-Za-z0-9_-]{43}$/;
|
|
|
5
5
|
export const accessTokenPattern = /^kingdom_[A-Za-z0-9_-]{43}$/;
|
|
6
6
|
export const renewalCredentialPattern = /^signet_renew_[A-Za-z0-9_-]{43}$/;
|
|
7
7
|
export const nonceSchema = z.string().regex(token43);
|
|
8
|
-
export const deviceCodeSchema = z.string().regex(token43);
|
|
9
8
|
|
|
10
9
|
export const archiveDocumentIdSchema = z.string().regex(/^[a-f0-9]{64}$/);
|
|
11
10
|
export const archiveFieldSchema = z.enum(['id', 'title', 'content', 'tags', 'createdAt']);
|
|
@@ -94,58 +93,93 @@ const signetTerms = {
|
|
|
94
93
|
maxRequests: z.number().int().min(1).max(100000).nullable(),
|
|
95
94
|
maxConcurrent: z.number().int().min(1).max(20),
|
|
96
95
|
};
|
|
97
|
-
/**
|
|
98
|
-
export const
|
|
99
|
-
export type
|
|
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>;
|
|
100
99
|
export const signetProposalSchema = z.strictObject({
|
|
101
100
|
...signetTerms,
|
|
102
101
|
integrationId: z.uuid(),
|
|
103
102
|
resources: z.array(signetResourceSchema).min(1).max(100),
|
|
104
103
|
});
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
.
|
|
120
|
-
|
|
121
|
-
|
|
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);
|
|
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
|
+
});
|
|
134
121
|
export const signetRequestResponseSchema = z.object({
|
|
135
122
|
requestId: z.uuid(),
|
|
136
123
|
reviewCode: z.string(),
|
|
137
|
-
deviceCode: deviceCodeSchema,
|
|
138
124
|
expiresAt: z.iso.datetime(),
|
|
139
125
|
});
|
|
140
|
-
export const signetReviewProposalSchema = z.strictObject({
|
|
141
|
-
...signetTerms,
|
|
142
|
-
resources: z.array(signetResourceSchema).max(100),
|
|
143
|
-
});
|
|
144
126
|
export type SignetProposal = z.infer<typeof signetProposalSchema>;
|
|
145
|
-
export type
|
|
127
|
+
export type RegistrationTerms = z.infer<typeof registrationTermsSchema>;
|
|
146
128
|
export type SignetRequest = z.infer<typeof signetRequestSchema>;
|
|
147
129
|
export type SignetRequestResponse = z.infer<typeof signetRequestResponseSchema>;
|
|
148
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
|
+
|
|
149
183
|
export const signetDescriptionSchema = z.object({
|
|
150
184
|
signetId: z.uuid(),
|
|
151
185
|
integrationId: z.uuid(),
|
|
@@ -196,3 +230,9 @@ export const accessRequestSchema = z.strictObject({
|
|
|
196
230
|
});
|
|
197
231
|
export type AccessRequest = z.infer<typeof accessRequestSchema>;
|
|
198
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
|
@@ -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
|
+
}
|