@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.
@@ -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 { InstallationKind, OwnerRef, RegistrationRequest } from './contracts';
16
+ import type {
17
+ InstallationKind,
18
+ InstallationSnapshot,
19
+ OwnerRef,
20
+ RegistrationRequest,
21
+ } from './contracts';
16
22
  import { readPrivateJson, writePrivateJson } from './files';
17
- import { type InstallationSnapshot, InstallationSocket } from './installationSocket';
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: InstallationSnapshot['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
- /** Failures while waiting (a poll Kingdom refused, a dropped socket); waiting continues. */
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: `${url}/dashboard?reviewSignet=${pending.reviewCode}`,
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: input.onError,
150
- onSnapshot: (snapshot) => {
151
- const settled = settledInquiry(snapshot, earlier, baseline.declinedAt);
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: InstallationSnapshot,
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.inquiries.find(
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 !== 'sent')
304
+ if (inquiry && !openStatuses.has(inquiry.status))
197
305
  return Error(`The registration was ${inquiry.status} in Kingdom`);
198
- if (!inquiry && !snapshot.pending)
199
- return Error('The review code expired before it was claimed; pair again');
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
  };