@inixiative/signet 0.4.2 → 0.4.6
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/package.json +2 -2
- package/src/client.ts +2 -1
- package/src/contracts.ts +28 -0
- package/src/installationPairing.ts +121 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inixiative/signet",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
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.105",
|
|
38
38
|
"@types/bun": "1.4.2",
|
|
39
39
|
"lefthook": "2.1.9",
|
|
40
40
|
"typescript": "6.0.3",
|
package/src/client.ts
CHANGED
|
@@ -126,7 +126,8 @@ const errorDetail = async (response: Response) => {
|
|
|
126
126
|
try {
|
|
127
127
|
const body = JSON.parse(text) as { message?: unknown; error?: unknown };
|
|
128
128
|
const error = body.error as { message?: unknown } | string | undefined;
|
|
129
|
-
const
|
|
129
|
+
const errorMessage = typeof error === 'string' ? error : error?.message;
|
|
130
|
+
const message = typeof body.message === 'string' ? body.message : errorMessage;
|
|
130
131
|
if (typeof message === 'string') return message.slice(0, 500);
|
|
131
132
|
} catch {}
|
|
132
133
|
return text.replace(/\s+/g, ' ').slice(0, 500) || undefined;
|
package/src/contracts.ts
CHANGED
|
@@ -164,9 +164,13 @@ export const registerInstallationSchema = z
|
|
|
164
164
|
});
|
|
165
165
|
export const registerInstallationResponseSchema = z.object({ installationId: z.uuid() });
|
|
166
166
|
export const requestRegistrationSchema = z.strictObject({ terms: registrationRequestSchema });
|
|
167
|
+
/** A link into Kingdom's web app that callers open, so http(s) only. */
|
|
168
|
+
const kingdomLinkSchema = z.url({ protocol: /^https?$/ });
|
|
167
169
|
export const requestRegistrationResponseSchema = z.object({
|
|
168
170
|
reviewCode: z.string(),
|
|
169
171
|
expiresAt: z.iso.datetime(),
|
|
172
|
+
/** Where a person reviews the registration in Kingdom's web app. */
|
|
173
|
+
reviewUrl: kingdomLinkSchema,
|
|
170
174
|
});
|
|
171
175
|
export const installationInquirySchema = z.object({
|
|
172
176
|
id: z.uuid(),
|
|
@@ -227,6 +231,30 @@ export const signetDescriptionSchema = z.object({
|
|
|
227
231
|
});
|
|
228
232
|
export type SignetDescription = z.infer<typeof signetDescriptionSchema>;
|
|
229
233
|
|
|
234
|
+
const holderPersonSchema = z.object({
|
|
235
|
+
id: z.uuid(),
|
|
236
|
+
name: z.string().nullable(),
|
|
237
|
+
email: z.string(),
|
|
238
|
+
});
|
|
239
|
+
/**
|
|
240
|
+
* `access/describe`'s `holder` for an Integration-held Signet (an Installation such as a Foundry):
|
|
241
|
+
* the integration, its owner, who issued the Signet, and where to manage it in Kingdom.
|
|
242
|
+
*/
|
|
243
|
+
export const signetHolderSchema = z.object({
|
|
244
|
+
integration: z.object({ id: z.uuid(), name: z.string(), provider: z.string().nullable() }),
|
|
245
|
+
owner: z.object({
|
|
246
|
+
ownerModel: ownerModelSchema,
|
|
247
|
+
name: z.string().nullable(),
|
|
248
|
+
organization: z.object({ id: z.uuid(), name: z.string(), slug: z.string() }).nullable(),
|
|
249
|
+
space: z.object({ id: z.uuid(), name: z.string() }).nullable(),
|
|
250
|
+
user: holderPersonSchema.nullable(),
|
|
251
|
+
}),
|
|
252
|
+
issuedBy: holderPersonSchema.nullable(),
|
|
253
|
+
webUrl: kingdomLinkSchema,
|
|
254
|
+
manageUrl: kingdomLinkSchema,
|
|
255
|
+
});
|
|
256
|
+
export type SignetHolder = z.infer<typeof signetHolderSchema>;
|
|
257
|
+
|
|
230
258
|
/** Operation-specific fields; Kingdom validates each against the operation it names. */
|
|
231
259
|
export const accessInputSchema = z.strictObject({
|
|
232
260
|
resourceId: z.uuid(),
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
kingdomUrl,
|
|
10
10
|
registerInstallation,
|
|
11
11
|
requestRegistration,
|
|
12
|
+
SignetHttpError,
|
|
12
13
|
saveCollectedSignet,
|
|
13
14
|
signetCredentialSchema,
|
|
14
15
|
} from './client';
|
|
@@ -112,7 +113,10 @@ export type InstallationPairing = {
|
|
|
112
113
|
terms: RegistrationRequest;
|
|
113
114
|
onReview: (review: { reviewCode: string; review: string; expiresAt: string }) => void;
|
|
114
115
|
confirmOwner?: (owner: { ownerName: string | null; owner: OwnerRef | null }) => Promise<boolean>;
|
|
115
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* Failures while waiting (a poll Kingdom refused, a dropped socket); waiting continues, except
|
|
118
|
+
* at the deadline, where repeated failures end it.
|
|
119
|
+
*/
|
|
116
120
|
onError?: (error: unknown) => void;
|
|
117
121
|
signal?: AbortSignal;
|
|
118
122
|
socketOptions?: { pollMs?: number; retryBaseMs?: number; authTimeoutMs?: number };
|
|
@@ -137,30 +141,103 @@ export async function pairInstallation(input: InstallationPairing) {
|
|
|
137
141
|
const pending = await requestRegistration(url, keyFile, input.terms);
|
|
138
142
|
input.onReview({
|
|
139
143
|
reviewCode: pending.reviewCode,
|
|
140
|
-
review:
|
|
144
|
+
review: pending.reviewUrl,
|
|
141
145
|
expiresAt: pending.expiresAt,
|
|
142
146
|
});
|
|
147
|
+
const retryBaseMs = input.socketOptions?.retryBaseMs ?? 1_000;
|
|
143
148
|
const inquiry = await new Promise<InstallationSnapshot['inquiries'][number]>(
|
|
144
149
|
(resolve, reject) => {
|
|
150
|
+
let deadline: ReturnType<typeof setTimeout> | undefined;
|
|
151
|
+
let retry: ReturnType<typeof setTimeout> | undefined;
|
|
152
|
+
let done = false;
|
|
153
|
+
let claimed = false;
|
|
154
|
+
// Refusals that count toward revocation, each at least a backoff step after the last.
|
|
155
|
+
const refusals: number[] = [];
|
|
156
|
+
// The review code's expiry until a claim creates the inquiry; then the inquiry's own.
|
|
157
|
+
let waitUntil: string | null = pending.expiresAt;
|
|
158
|
+
const onAbort = () => {
|
|
159
|
+
stop();
|
|
160
|
+
reject(input.signal?.reason);
|
|
161
|
+
};
|
|
162
|
+
const stop = () => {
|
|
163
|
+
done = true;
|
|
164
|
+
clearTimeout(deadline);
|
|
165
|
+
clearTimeout(retry);
|
|
166
|
+
input.signal?.removeEventListener('abort', onAbort);
|
|
167
|
+
socket.close();
|
|
168
|
+
};
|
|
169
|
+
const finish = (settled: ReturnType<typeof settledInquiry>) => {
|
|
170
|
+
if (!settled || done) return;
|
|
171
|
+
stop();
|
|
172
|
+
if (settled instanceof Error) reject(settled);
|
|
173
|
+
else resolve(settled);
|
|
174
|
+
};
|
|
175
|
+
const arm = (until: string | null) => {
|
|
176
|
+
clearTimeout(deadline);
|
|
177
|
+
waitUntil = until;
|
|
178
|
+
if (!until || done) return;
|
|
179
|
+
const delay = Math.max(0, Date.parse(until) + expiryGraceMs - Date.now());
|
|
180
|
+
deadline =
|
|
181
|
+
delay > maxTimerMs
|
|
182
|
+
? setTimeout(() => arm(until), maxTimerMs)
|
|
183
|
+
: setTimeout(() => void check(until), delay);
|
|
184
|
+
};
|
|
185
|
+
const follow = (snapshot: Snapshot) => {
|
|
186
|
+
refusals.length = 0;
|
|
187
|
+
const settled = settledInquiry(snapshot, earlier, baseline.declinedAt, claimed);
|
|
188
|
+
if (settled) return finish(settled);
|
|
189
|
+
const inquiry = newInquiry(snapshot, earlier);
|
|
190
|
+
if (!inquiry) return;
|
|
191
|
+
claimed = true;
|
|
192
|
+
if (inquiry.expiresAt !== waitUntil) arm(inquiry.expiresAt);
|
|
193
|
+
};
|
|
194
|
+
// Kingdom refuses a revoked installation's key (the revoke frame only reaches a live socket),
|
|
195
|
+
// but a 401 can also be a passing failure; only a refusal that persists ends the wait.
|
|
196
|
+
const refused = (error: unknown) => {
|
|
197
|
+
const refusal =
|
|
198
|
+
error instanceof SignetHttpError && (error.status === 401 || error.status === 403);
|
|
199
|
+
const last = refusals.at(-1);
|
|
200
|
+
if (
|
|
201
|
+
refusal &&
|
|
202
|
+
(last === undefined || Date.now() - last >= retryBaseMs * 2 ** refusals.length)
|
|
203
|
+
)
|
|
204
|
+
refusals.push(Date.now());
|
|
205
|
+
if (refusal && refusals.length >= refusalsToRevoke) return finish(revoked());
|
|
206
|
+
input.onError?.(error);
|
|
207
|
+
};
|
|
208
|
+
const check = async (until: string) => {
|
|
209
|
+
let last: Snapshot | null = null;
|
|
210
|
+
for (let attempt = 0; !last && attempt < 3; attempt++) {
|
|
211
|
+
if (attempt)
|
|
212
|
+
await new Promise((wake) => {
|
|
213
|
+
retry = setTimeout(wake, retryBaseMs * 2 ** attempt);
|
|
214
|
+
});
|
|
215
|
+
if (done) return;
|
|
216
|
+
last = await installationInquiries(url, keyFile).catch((error) => {
|
|
217
|
+
refused(error);
|
|
218
|
+
return null;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
if (done || waitUntil !== until) return;
|
|
222
|
+
if (!last) return finish(unconfirmed());
|
|
223
|
+
follow(last);
|
|
224
|
+
if (done || waitUntil !== until) return;
|
|
225
|
+
finish(newInquiry(last, earlier) ? unapproved() : expired());
|
|
226
|
+
};
|
|
145
227
|
const socket = new InstallationSocket({
|
|
146
228
|
url,
|
|
147
229
|
keyFile,
|
|
148
230
|
...input.socketOptions,
|
|
149
|
-
onError:
|
|
150
|
-
onSnapshot:
|
|
151
|
-
|
|
152
|
-
if (!settled) return;
|
|
153
|
-
socket.close();
|
|
154
|
-
if (settled instanceof Error) reject(settled);
|
|
155
|
-
else resolve(settled);
|
|
156
|
-
},
|
|
157
|
-
onRevoked: () => reject(Error('Kingdom revoked this installation')),
|
|
158
|
-
});
|
|
159
|
-
if (input.signal?.aborted) return reject(input.signal.reason);
|
|
160
|
-
input.signal?.addEventListener('abort', () => {
|
|
161
|
-
socket.close();
|
|
162
|
-
reject(input.signal!.reason);
|
|
231
|
+
onError: refused,
|
|
232
|
+
onSnapshot: follow,
|
|
233
|
+
onRevoked: () => finish(revoked()),
|
|
163
234
|
});
|
|
235
|
+
if (input.signal?.aborted) {
|
|
236
|
+
stop();
|
|
237
|
+
return reject(input.signal.reason);
|
|
238
|
+
}
|
|
239
|
+
input.signal?.addEventListener('abort', onAbort, { once: true });
|
|
240
|
+
arm(waitUntil);
|
|
164
241
|
socket.start();
|
|
165
242
|
},
|
|
166
243
|
);
|
|
@@ -182,20 +259,41 @@ export async function pairInstallation(input: InstallationPairing) {
|
|
|
182
259
|
};
|
|
183
260
|
}
|
|
184
261
|
|
|
262
|
+
/** setTimeout fires at once past this delay (~24.8 days). */
|
|
263
|
+
const maxTimerMs = 2 ** 31 - 1;
|
|
264
|
+
/** Slack past an expiry (clock skew, a claim in its last second) before giving up. */
|
|
265
|
+
const expiryGraceMs = 5_000;
|
|
266
|
+
/** Consecutive refusals of this installation's key, a backoff step apart, that mean revoked. */
|
|
267
|
+
const refusalsToRevoke = 3;
|
|
268
|
+
/** Kingdom can still approve these. */
|
|
269
|
+
const openStatuses = new Set(['sent', 'changesRequested']);
|
|
270
|
+
|
|
271
|
+
type Snapshot = Pick<InstallationSnapshot, 'pending' | 'declinedAt' | 'inquiries'>;
|
|
272
|
+
|
|
273
|
+
const expired = () => Error('The review code expired before it was claimed; pair again');
|
|
274
|
+
const revoked = () => Error('Kingdom revoked this installation');
|
|
275
|
+
const withdrawn = () => Error('The registration was withdrawn in Kingdom');
|
|
276
|
+
const unapproved = () => Error('The registration expired in Kingdom before it was approved');
|
|
277
|
+
const unconfirmed = () =>
|
|
278
|
+
Error('Could not reach Kingdom to confirm the registration; check Kingdom before pairing again');
|
|
279
|
+
|
|
280
|
+
const newInquiry = (snapshot: Snapshot, earlier: Set<string>) =>
|
|
281
|
+
snapshot.inquiries.find((item) => item.type === 'registerIntegration' && !earlier.has(item.id));
|
|
282
|
+
|
|
185
283
|
const settledInquiry = (
|
|
186
|
-
snapshot:
|
|
284
|
+
snapshot: Snapshot,
|
|
187
285
|
earlier: Set<string>,
|
|
188
286
|
earlierDeclinedAt: string | null,
|
|
287
|
+
claimed: boolean,
|
|
189
288
|
) => {
|
|
190
289
|
if (snapshot.declinedAt && snapshot.declinedAt !== earlierDeclinedAt)
|
|
191
290
|
return Error('The registration was declined in Kingdom');
|
|
192
|
-
const inquiry = snapshot
|
|
193
|
-
(item) => item.type === 'registerIntegration' && !earlier.has(item.id),
|
|
194
|
-
);
|
|
291
|
+
const inquiry = newInquiry(snapshot, earlier);
|
|
195
292
|
if (inquiry?.status === 'approved') return inquiry;
|
|
196
|
-
if (inquiry && inquiry.status
|
|
293
|
+
if (inquiry && !openStatuses.has(inquiry.status))
|
|
197
294
|
return Error(`The registration was ${inquiry.status} in Kingdom`);
|
|
198
|
-
|
|
199
|
-
|
|
295
|
+
// A canceled inquiry is soft-deleted, so it drops out of the snapshot.
|
|
296
|
+
if (!inquiry && claimed) return withdrawn();
|
|
297
|
+
if (!inquiry && !snapshot.pending) return expired();
|
|
200
298
|
return null;
|
|
201
299
|
};
|