@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/signet",
3
- "version": "0.4.2",
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.90",
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 message = typeof error === 'string' ? error : (error?.message ?? body.message);
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
- /** Failures while waiting (a poll Kingdom refused, a dropped socket); waiting continues. */
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: `${url}/dashboard?reviewSignet=${pending.reviewCode}`,
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: 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);
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: InstallationSnapshot,
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.inquiries.find(
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 !== 'sent')
293
+ if (inquiry && !openStatuses.has(inquiry.status))
197
294
  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');
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
  };