@inixiative/signet 0.4.3 → 0.5.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/README.md +30 -1
- package/package.json +2 -2
- package/src/client.ts +50 -10
- package/src/contracts.ts +383 -29
- package/src/index.ts +2 -0
- package/src/installationPairing.ts +137 -26
- package/src/installationSocket.ts +381 -10
- package/src/signetSocket.ts +214 -0
- package/src/threadStreams.ts +315 -0
- package/src/threads.ts +431 -0
|
@@ -9,12 +9,18 @@ import {
|
|
|
9
9
|
kingdomUrl,
|
|
10
10
|
registerInstallation,
|
|
11
11
|
requestRegistration,
|
|
12
|
+
SignetHttpError,
|
|
12
13
|
saveCollectedSignet,
|
|
13
14
|
signetCredentialSchema,
|
|
14
15
|
} from './client';
|
|
15
|
-
import type {
|
|
16
|
+
import type {
|
|
17
|
+
InstallationKind,
|
|
18
|
+
InstallationSnapshot,
|
|
19
|
+
OwnerRef,
|
|
20
|
+
RegistrationRequest,
|
|
21
|
+
} from './contracts';
|
|
16
22
|
import { readPrivateJson, writePrivateJson } from './files';
|
|
17
|
-
import {
|
|
23
|
+
import { InstallationSocket } from './installationSocket';
|
|
18
24
|
|
|
19
25
|
/** Where an installation keeps what it holds for one Kingdom: its key and one file per Signet. */
|
|
20
26
|
export async function installationDirectory(root: string, url: string) {
|
|
@@ -78,7 +84,10 @@ export async function holdInstallationSignets(
|
|
|
78
84
|
url: string,
|
|
79
85
|
keyFile: string,
|
|
80
86
|
directory: string,
|
|
81
|
-
signets:
|
|
87
|
+
signets: Pick<
|
|
88
|
+
InstallationSnapshot['signets'][number],
|
|
89
|
+
'signetId' | 'integrationId' | 'owner' | 'enrollmentId'
|
|
90
|
+
>[],
|
|
82
91
|
): Promise<HeldSignet[]> {
|
|
83
92
|
return withHoldLock(directory, async () => {
|
|
84
93
|
const held: HeldSignet[] = [];
|
|
@@ -112,7 +121,10 @@ export type InstallationPairing = {
|
|
|
112
121
|
terms: RegistrationRequest;
|
|
113
122
|
onReview: (review: { reviewCode: string; review: string; expiresAt: string }) => void;
|
|
114
123
|
confirmOwner?: (owner: { ownerName: string | null; owner: OwnerRef | null }) => Promise<boolean>;
|
|
115
|
-
/**
|
|
124
|
+
/**
|
|
125
|
+
* Failures while waiting (a poll Kingdom refused, a dropped socket); waiting continues, except
|
|
126
|
+
* at the deadline, where repeated failures end it.
|
|
127
|
+
*/
|
|
116
128
|
onError?: (error: unknown) => void;
|
|
117
129
|
signal?: AbortSignal;
|
|
118
130
|
socketOptions?: { pollMs?: number; retryBaseMs?: number; authTimeoutMs?: number };
|
|
@@ -137,30 +149,106 @@ export async function pairInstallation(input: InstallationPairing) {
|
|
|
137
149
|
const pending = await requestRegistration(url, keyFile, input.terms);
|
|
138
150
|
input.onReview({
|
|
139
151
|
reviewCode: pending.reviewCode,
|
|
140
|
-
review:
|
|
152
|
+
review: pending.reviewUrl,
|
|
141
153
|
expiresAt: pending.expiresAt,
|
|
142
154
|
});
|
|
155
|
+
const retryBaseMs = input.socketOptions?.retryBaseMs ?? 1_000;
|
|
143
156
|
const inquiry = await new Promise<InstallationSnapshot['inquiries'][number]>(
|
|
144
157
|
(resolve, reject) => {
|
|
158
|
+
let deadline: ReturnType<typeof setTimeout> | undefined;
|
|
159
|
+
let retry: ReturnType<typeof setTimeout> | undefined;
|
|
160
|
+
let done = false;
|
|
161
|
+
let claimed = false;
|
|
162
|
+
// Refusals that count toward revocation, each at least a backoff step after the last.
|
|
163
|
+
const refusals: number[] = [];
|
|
164
|
+
// The review code's expiry until a claim creates the inquiry; then the inquiry's own.
|
|
165
|
+
let waitUntil: string | null = pending.expiresAt;
|
|
166
|
+
const onAbort = () => {
|
|
167
|
+
stop();
|
|
168
|
+
reject(input.signal?.reason);
|
|
169
|
+
};
|
|
170
|
+
const stop = () => {
|
|
171
|
+
done = true;
|
|
172
|
+
clearTimeout(deadline);
|
|
173
|
+
clearTimeout(retry);
|
|
174
|
+
input.signal?.removeEventListener('abort', onAbort);
|
|
175
|
+
socket.close();
|
|
176
|
+
};
|
|
177
|
+
const finish = (settled: ReturnType<typeof settledInquiry>) => {
|
|
178
|
+
if (!settled || done) return;
|
|
179
|
+
stop();
|
|
180
|
+
if (settled instanceof Error) reject(settled);
|
|
181
|
+
else resolve(settled);
|
|
182
|
+
};
|
|
183
|
+
const arm = (until: string | null) => {
|
|
184
|
+
clearTimeout(deadline);
|
|
185
|
+
waitUntil = until;
|
|
186
|
+
if (!until || done) return;
|
|
187
|
+
const delay = Math.max(0, Date.parse(until) + expiryGraceMs - Date.now());
|
|
188
|
+
deadline =
|
|
189
|
+
delay > maxTimerMs
|
|
190
|
+
? setTimeout(() => arm(until), maxTimerMs)
|
|
191
|
+
: setTimeout(() => void check(until), delay);
|
|
192
|
+
};
|
|
193
|
+
const follow = (snapshot: Snapshot) => {
|
|
194
|
+
refusals.length = 0;
|
|
195
|
+
const unread = snapshot.dropped.filter((drop) => drop.list === 'inquiries');
|
|
196
|
+
if (unread.length)
|
|
197
|
+
input.onError?.(Error(`Could not read ${unread.length} inquiries from Kingdom`));
|
|
198
|
+
const settled = settledInquiry(snapshot, earlier, baseline.declinedAt, claimed);
|
|
199
|
+
if (settled) return finish(settled);
|
|
200
|
+
const inquiry = newInquiry(snapshot, earlier);
|
|
201
|
+
if (!inquiry) return;
|
|
202
|
+
claimed = true;
|
|
203
|
+
if (inquiry.expiresAt !== waitUntil) arm(inquiry.expiresAt);
|
|
204
|
+
};
|
|
205
|
+
// Kingdom refuses a revoked installation's key (the revoke frame only reaches a live socket),
|
|
206
|
+
// but a 401 can also be a passing failure; only a refusal that persists ends the wait.
|
|
207
|
+
const refused = (error: unknown) => {
|
|
208
|
+
const refusal =
|
|
209
|
+
error instanceof SignetHttpError && (error.status === 401 || error.status === 403);
|
|
210
|
+
const last = refusals.at(-1);
|
|
211
|
+
if (
|
|
212
|
+
refusal &&
|
|
213
|
+
(last === undefined || Date.now() - last >= retryBaseMs * 2 ** refusals.length)
|
|
214
|
+
)
|
|
215
|
+
refusals.push(Date.now());
|
|
216
|
+
if (refusal && refusals.length >= refusalsToRevoke) return finish(revoked());
|
|
217
|
+
input.onError?.(error);
|
|
218
|
+
};
|
|
219
|
+
const check = async (until: string) => {
|
|
220
|
+
let last: Snapshot | null = null;
|
|
221
|
+
for (let attempt = 0; !last && attempt < 3; attempt++) {
|
|
222
|
+
if (attempt)
|
|
223
|
+
await new Promise((wake) => {
|
|
224
|
+
retry = setTimeout(wake, retryBaseMs * 2 ** attempt);
|
|
225
|
+
});
|
|
226
|
+
if (done) return;
|
|
227
|
+
last = await installationInquiries(url, keyFile).catch((error) => {
|
|
228
|
+
refused(error);
|
|
229
|
+
return null;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
if (done || waitUntil !== until) return;
|
|
233
|
+
if (!last) return finish(unconfirmed());
|
|
234
|
+
follow(last);
|
|
235
|
+
if (done || waitUntil !== until) return;
|
|
236
|
+
finish(newInquiry(last, earlier) ? unapproved() : expired());
|
|
237
|
+
};
|
|
145
238
|
const socket = new InstallationSocket({
|
|
146
239
|
url,
|
|
147
240
|
keyFile,
|
|
148
241
|
...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);
|
|
242
|
+
onError: refused,
|
|
243
|
+
onSnapshot: follow,
|
|
244
|
+
onRevoked: () => finish(revoked()),
|
|
163
245
|
});
|
|
246
|
+
if (input.signal?.aborted) {
|
|
247
|
+
stop();
|
|
248
|
+
return reject(input.signal.reason);
|
|
249
|
+
}
|
|
250
|
+
input.signal?.addEventListener('abort', onAbort, { once: true });
|
|
251
|
+
arm(waitUntil);
|
|
164
252
|
socket.start();
|
|
165
253
|
},
|
|
166
254
|
);
|
|
@@ -182,20 +270,43 @@ export async function pairInstallation(input: InstallationPairing) {
|
|
|
182
270
|
};
|
|
183
271
|
}
|
|
184
272
|
|
|
273
|
+
/** setTimeout fires at once past this delay (~24.8 days). */
|
|
274
|
+
const maxTimerMs = 2 ** 31 - 1;
|
|
275
|
+
/** Slack past an expiry (clock skew, a claim in its last second) before giving up. */
|
|
276
|
+
const expiryGraceMs = 5_000;
|
|
277
|
+
/** Consecutive refusals of this installation's key, a backoff step apart, that mean revoked. */
|
|
278
|
+
const refusalsToRevoke = 3;
|
|
279
|
+
/** Kingdom can still approve these. */
|
|
280
|
+
const openStatuses = new Set(['sent', 'changesRequested']);
|
|
281
|
+
|
|
282
|
+
type Snapshot = Pick<InstallationSnapshot, 'pending' | 'declinedAt' | 'inquiries' | 'dropped'>;
|
|
283
|
+
|
|
284
|
+
const expired = () => Error('The review code expired before it was claimed; pair again');
|
|
285
|
+
const revoked = () => Error('Kingdom revoked this installation');
|
|
286
|
+
const withdrawn = () => Error('The registration was withdrawn in Kingdom');
|
|
287
|
+
const unapproved = () => Error('The registration expired in Kingdom before it was approved');
|
|
288
|
+
const unconfirmed = () =>
|
|
289
|
+
Error('Could not reach Kingdom to confirm the registration; check Kingdom before pairing again');
|
|
290
|
+
|
|
291
|
+
const newInquiry = (snapshot: Snapshot, earlier: Set<string>) =>
|
|
292
|
+
snapshot.inquiries.find((item) => item.type === 'registerIntegration' && !earlier.has(item.id));
|
|
293
|
+
|
|
185
294
|
const settledInquiry = (
|
|
186
|
-
snapshot:
|
|
295
|
+
snapshot: Snapshot,
|
|
187
296
|
earlier: Set<string>,
|
|
188
297
|
earlierDeclinedAt: string | null,
|
|
298
|
+
claimed: boolean,
|
|
189
299
|
) => {
|
|
190
300
|
if (snapshot.declinedAt && snapshot.declinedAt !== earlierDeclinedAt)
|
|
191
301
|
return Error('The registration was declined in Kingdom');
|
|
192
|
-
const inquiry = snapshot
|
|
193
|
-
(item) => item.type === 'registerIntegration' && !earlier.has(item.id),
|
|
194
|
-
);
|
|
302
|
+
const inquiry = newInquiry(snapshot, earlier);
|
|
195
303
|
if (inquiry?.status === 'approved') return inquiry;
|
|
196
|
-
if (inquiry && inquiry.status
|
|
304
|
+
if (inquiry && !openStatuses.has(inquiry.status))
|
|
197
305
|
return Error(`The registration was ${inquiry.status} in Kingdom`);
|
|
198
|
-
|
|
199
|
-
|
|
306
|
+
// An inquiry this package couldn't read is not a withdrawn or expired one: keep waiting.
|
|
307
|
+
if (!inquiry && snapshot.dropped.some((drop) => drop.list === 'inquiries')) return null;
|
|
308
|
+
// A canceled inquiry is soft-deleted, so it drops out of the snapshot.
|
|
309
|
+
if (!inquiry && claimed) return withdrawn();
|
|
310
|
+
if (!inquiry && !snapshot.pending) return expired();
|
|
200
311
|
return null;
|
|
201
312
|
};
|