@devicechain/client 0.21.1 → 0.22.0-rc.1

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 CHANGED
@@ -69,6 +69,32 @@ setIdentityTokenGetter(async () => store.identityToken);
69
69
 
70
70
  Login and refresh calls run without a token; pass `{ anonymous: true }` for those.
71
71
 
72
+ `createSessionTokens` supplies the refresh logic for that getter. You tell it where
73
+ the token pair lives and how to call refresh. It refreshes shortly before the
74
+ access token expires, shares one in-flight refresh among concurrent callers
75
+ (refresh tokens rotate), and drops a result that arrives after the session was
76
+ replaced:
77
+
78
+ ```ts
79
+ import { createSessionTokens, gql, setAuthTokenGetter } from '@devicechain/client';
80
+
81
+ const session = createSessionTokens({
82
+ read: () => store.pair, // { accessToken, refreshToken } | null
83
+ apply: (pair) => { store.pair = pair; },
84
+ refresh: async (refreshToken) =>
85
+ (await gql('user-management', REFRESH, { refreshToken }, { anonymous: true })).refresh,
86
+ onEnd: (err) => showSignIn(err), // refused, lapsed, or offline past expiry
87
+ });
88
+ setAuthTokenGetter(session.getAccessToken);
89
+ const stop = session.startAutoRefresh(); // optional: refresh on a timer, not only on demand
90
+ ```
91
+
92
+ A refusal from the server, such as an invalid or revoked refresh token, ends the
93
+ session at once. A transient failure does not: no response, a 5xx, or a 429. While
94
+ the access token is still valid, callers keep getting it, and
95
+ `startAutoRefresh` retries with jittered backoff. The session ends only when the
96
+ access token actually expires.
97
+
72
98
  `decodeToken`, `isExpired` and `hasAuthority` are exported for reading claims
73
99
  client-side. They decode, they do not verify — the server is the authority.
74
100
 
package/dist/index.d.ts CHANGED
@@ -2,5 +2,6 @@ export { gql, areaPath, areaKey, AREAS, resolveAuthToken, setAuthTokenGetter, se
2
2
  export { isForbiddenError } from './errors';
3
3
  export { resolveBasemap, renderableBasemap, fallbackView, type Basemap, type ResolvedBasemap, } from './basemap';
4
4
  export { subscribe, disposeSubscriptions, type SubscriptionSink } from './subscribe';
5
+ export { createSessionTokens, type SessionTokens, type SessionTokensOptions, type TokenPair, } from './session';
5
6
  export { decodeToken, isExpired, hasAuthority, type DecodedClaims, } from './jwt';
6
7
  export { generateToken, normalizeToken, conformsToMask, maskToRegExp, parseMask, resolveMask, isValidToken, sampleToken, validateMask, MAX_TOKEN_LEN, type GenerateOptions, type MaskSegment, type MaskProblem, } from './tokens';
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ var AREAS = [
11
11
  "dashboard-management",
12
12
  "outbound-connectors",
13
13
  "notification-management",
14
+ "update-management",
14
15
  "ai-inference/admin"
15
16
  ];
16
17
  function areaPath(area) {
@@ -138,6 +139,11 @@ function wsUrl(area) {
138
139
  const scheme = window.location.protocol === "https:" ? "wss" : "ws";
139
140
  return `${scheme}://${window.location.host}${areaPath(area)}`;
140
141
  }
142
+ var RETRY_CEILING_MS = 3e4;
143
+ function retryDelayMs(retries, random = Math.random) {
144
+ const ceiling = Math.min(1e3 * 2 ** retries, RETRY_CEILING_MS);
145
+ return ceiling / 2 * (1 + random());
146
+ }
141
147
  var clients = /* @__PURE__ */ new Map();
142
148
  function clientFor(area) {
143
149
  let entry = clients.get(area);
@@ -147,7 +153,24 @@ function clientFor(area) {
147
153
  connectionParams: async () => {
148
154
  const token = await resolveAuthToken();
149
155
  return token ? { Authorization: `Bearer ${token}` } : {};
150
- }
156
+ },
157
+ // 🔴 Keep reconnecting for as long as anything is subscribed. Left to its defaults,
158
+ // graphql-ws gives up after 5 attempts (about 40 s; in a browser, after the first one
159
+ // that fails, see shouldRetry) and then errors every operation, and nothing reopened
160
+ // them: a wall display that lost its network for a minute showed
161
+ // "Data unavailable" until someone reloaded it. One socket per area carries every
162
+ // operation, so each attempt is ONE connection however many widgets are waiting, and
163
+ // graphql-ws resubscribes all of them on it once it is acknowledged.
164
+ retryAttempts: Infinity,
165
+ retryWait: (retries) => new Promise((resolve) => setTimeout(resolve, retryDelayMs(retries))),
166
+ // graphql-ws retries only close events by default, and a browser reports a handshake
167
+ // that failed (the server or the network still down) as an `error` event first, so
168
+ // the first failed attempt would end every operation however many were allowed. A
169
+ // socket's own error event is a network problem and is retried; an Error thrown in
170
+ // this client is not. The closes that say the request itself is wrong (4400, 4401,
171
+ // 4409, 4429) are fatal inside graphql-ws whatever this answers, so an expired or
172
+ // refused token still reaches subscribe() below and the session flow, never a loop.
173
+ shouldRetry: (err) => typeof Event != "undefined" && err instanceof Event || hasCode(err)
151
174
  });
152
175
  const state = { client, acked: false, lastCloseWasAcked: false };
153
176
  client.on("connecting", () => {
@@ -165,8 +188,11 @@ function clientFor(area) {
165
188
  }
166
189
  return entry;
167
190
  }
191
+ function hasCode(err) {
192
+ return typeof err === "object" && err !== null && "code" in err;
193
+ }
168
194
  function isUnauthorizedClose(err) {
169
- return typeof err === "object" && err !== null && "code" in err && err.code === CLOSE_UNAUTHORIZED;
195
+ return hasCode(err) && err.code === CLOSE_UNAUTHORIZED;
170
196
  }
171
197
  function subscribe(area, document, variables, sink) {
172
198
  const entry = clientFor(area);
@@ -245,7 +271,8 @@ function decodeToken(token) {
245
271
  roles: payload.roles ?? [],
246
272
  authorities: payload.authorities ?? [],
247
273
  typ: payload.typ ?? "",
248
- exp: payload.exp
274
+ exp: payload.exp,
275
+ iat: payload.iat
249
276
  };
250
277
  } catch {
251
278
  return null;
@@ -262,6 +289,131 @@ function hasAuthority(claims, required) {
262
289
  return claims.authorities.some((a) => a === "*" || a === required);
263
290
  }
264
291
 
292
+ // src/session.ts
293
+ var DEFAULT_SKEW_SECONDS = 30;
294
+ var MAX_TIMER_MS = 2 ** 31 - 1;
295
+ var RETRY_BASE_MS = 1e3;
296
+ var RETRY_CAP_MS = 3e4;
297
+ var RETRY_EXPIRY_MARGIN_MS = 1e3;
298
+ var MIN_REFRESH_GAP_MS = 1e4;
299
+ function isTransientRefreshError(err) {
300
+ if (!(err instanceof GraphQLRequestError)) return false;
301
+ if (err.errors && err.errors.length > 0) return false;
302
+ return err.status === 0 || err.status === 429 || err.status >= 500;
303
+ }
304
+ function expiryMs(token) {
305
+ const exp = decodeToken(token)?.exp;
306
+ return exp === void 0 ? void 0 : exp * 1e3;
307
+ }
308
+ function createSessionTokens(opts) {
309
+ const skew = opts.skewSeconds ?? DEFAULT_SKEW_SECONDS;
310
+ let inFlight = null;
311
+ const skewFor = (token) => {
312
+ const c = decodeToken(token);
313
+ if (c?.exp === void 0 || c.iat === void 0) return skew;
314
+ return Math.min(skew, Math.max(0, (c.exp - c.iat) / 2));
315
+ };
316
+ const due = (token) => isExpired(token, skewFor(token));
317
+ const end = (err) => {
318
+ inFlight = null;
319
+ opts.onEnd(err);
320
+ };
321
+ const refreshShared = (current) => {
322
+ if (!inFlight) {
323
+ inFlight = (async () => {
324
+ try {
325
+ const pair = await opts.refresh(current.refreshToken);
326
+ if (opts.read() !== current) return { kind: "stale" };
327
+ opts.apply({ accessToken: pair.accessToken, refreshToken: pair.refreshToken });
328
+ return { kind: "ok", token: pair.accessToken };
329
+ } catch (err) {
330
+ if (opts.read() !== current) return { kind: "stale" };
331
+ if (isTransientRefreshError(err)) return { kind: "transient", err };
332
+ end(err);
333
+ return { kind: "ended" };
334
+ } finally {
335
+ inFlight = null;
336
+ }
337
+ })();
338
+ }
339
+ return inFlight;
340
+ };
341
+ const getAccessToken = async () => {
342
+ const current = opts.read();
343
+ if (!current) return null;
344
+ if (isExpired(current.refreshToken, 0)) {
345
+ end();
346
+ return null;
347
+ }
348
+ if (!due(current.accessToken)) return current.accessToken;
349
+ const out = await refreshShared(current);
350
+ if (out.kind === "ok") return out.token;
351
+ if (out.kind !== "transient") return null;
352
+ if (opts.read() !== current) return null;
353
+ if (!isExpired(current.accessToken, 0)) return current.accessToken;
354
+ end(out.err);
355
+ return null;
356
+ };
357
+ const startAutoRefresh = () => {
358
+ let timer;
359
+ let stopped = false;
360
+ let failures = 0;
361
+ const arm = (delay) => {
362
+ timer = setTimeout(() => void fire(), Math.min(Math.max(0, delay), MAX_TIMER_MS));
363
+ };
364
+ const schedule = (floor = 0) => {
365
+ if (stopped) return;
366
+ const current = opts.read();
367
+ const exp = current ? expiryMs(current.accessToken) : void 0;
368
+ if (!current || exp === void 0) return;
369
+ arm(Math.max(floor, exp - skewFor(current.accessToken) * 1e3 - Date.now()));
370
+ };
371
+ const fire = async () => {
372
+ if (stopped) return;
373
+ const current = opts.read();
374
+ if (!current) return;
375
+ if (isExpired(current.refreshToken, 0)) {
376
+ end();
377
+ return;
378
+ }
379
+ if (!due(current.accessToken)) {
380
+ failures = 0;
381
+ schedule(MIN_REFRESH_GAP_MS);
382
+ return;
383
+ }
384
+ const out = await refreshShared(current);
385
+ if (stopped) return;
386
+ if (out.kind === "ok") {
387
+ failures = 0;
388
+ schedule(MIN_REFRESH_GAP_MS);
389
+ return;
390
+ }
391
+ if (out.kind !== "transient") return;
392
+ if (opts.read() !== current) return;
393
+ const left = (expiryMs(current.accessToken) ?? 0) - RETRY_EXPIRY_MARGIN_MS - Date.now();
394
+ if (left <= 0) {
395
+ end(out.err);
396
+ return;
397
+ }
398
+ const backoff = Math.min(RETRY_CAP_MS, RETRY_BASE_MS * 2 ** failures) * (0.5 + Math.random() / 2);
399
+ failures++;
400
+ arm(Math.min(backoff, left));
401
+ };
402
+ schedule();
403
+ return () => {
404
+ stopped = true;
405
+ if (timer !== void 0) clearTimeout(timer);
406
+ };
407
+ };
408
+ return {
409
+ getAccessToken,
410
+ reset: () => {
411
+ inFlight = null;
412
+ },
413
+ startAutoRefresh
414
+ };
415
+ }
416
+
265
417
  // src/tokens.ts
266
418
  var READABLE_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
267
419
  var DIGITS = "0123456789";
@@ -384,6 +536,7 @@ export {
384
536
  areaKey,
385
537
  areaPath,
386
538
  conformsToMask,
539
+ createSessionTokens,
387
540
  decodeToken,
388
541
  disposeSubscriptions,
389
542
  fallbackView,
package/dist/index.js.map CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["../src/transport.ts", "../src/errors.ts", "../src/basemap.ts", "../src/subscribe.ts", "../src/jwt.ts", "../src/tokens.ts"],
4
- "sourcesContent": ["// Copyright The DeviceChain Authors\n// SPDX-License-Identifier: Apache-2.0\n\n// A tiny GraphQL-over-fetch client, in the spirit of a REST `fetchJson` helper \u2014\n// no Apollo. Each DeviceChain functional area serves its own /graphql endpoint;\n// `area` selects which one. Requests carry the access-token Bearer when one is\n// available (see setAuthTokenGetter); login/refresh run with no token (the\n// GraphQL handler lets an absent token through with no tenant).\n\nimport type { DocumentTypeDecoration } from '@graphql-typed-document-node/core';\n\n// DeviceChain functional areas that expose a GraphQL endpoint. An \"area\" ending in\n// `/admin` is a service's instance-scoped admin API (ADR-033), served by that\n// service at a second path and authenticated with the identity token rather than a\n// tenant access token (see the `identity` request option).\n//\n// `ai-inference` has no `/admin` sibling here because it has no tenant-facing\n// surface a console would call: its data plane serves exactly one mutation, reached\n// by event-processing's service token, never by a browser. Its whole console-facing\n// surface is the operator provider list (ADR-065).\n// Declared as a const array with the type derived from it, rather than as a bare\n// union, so the set is enumerable at RUNTIME. That is what lets a test walk every\n// member and check it against the deployment catalog the Helm chart uses \u2014 a\n// union alone erases at compile time, leaving the check to a hand-kept copy of\n// this list, which is the drift this whole mechanism exists to prevent.\nexport const AREAS = [\n 'user-management',\n 'user-management/admin',\n 'user-management/settings',\n 'device-management',\n 'event-management',\n 'event-processing',\n 'device-state',\n 'command-delivery',\n 'dashboard-management',\n 'outbound-connectors',\n 'notification-management',\n 'ai-inference/admin',\n] as const;\n\nexport type Area = (typeof AREAS)[number];\n\n// Relative URL matching the cluster ingress contract: the ingress routes\n// https://<host>/api/<area>/graphql to each functional-area service and serves\n// the SPA at \"/\". In `vite dev` the same path is handled by the config proxy.\n// 'user-management/admin' resolves to /api/user-management/admin/graphql.\nexport function areaPath(area: Area): string {\n return `/api/${area}/graphql`;\n}\n\n/**\n * The deployed functional area an Area is served by \u2014 the segment before the\n * first slash, since a suffix like `/admin` or `/settings` names a PLANE of an\n * area rather than an area of its own.\n *\n * This is the join between an Area (a client-side routing convention) and the\n * area vocabulary the server reports from what the chart actually deployed, so\n * the console can tell \"never deployed here\" apart from a transport failure.\n * Note it is deliberately many-to-one: `user-management`,\n * `user-management/admin` and `user-management/settings` all key to\n * `user-management`, and the reverse direction does not exist \u2014 plenty of\n * deployed areas (event-sources, sparkplug-ingest) have no Area member.\n */\nexport function areaKey(area: Area): string {\n return area.split('/')[0];\n}\n\n// Whether an area is served on an instance-scoped lane that authenticates with the\n// IDENTITY token rather than a tenant access token (ADR-033). Those endpoints accept\n// nothing else, so the credential follows from the area and is not a per-call\n// choice: deriving it here means a new admin call cannot silently ride the tenant\n// token and 401 (or, worse, be given a tenant token that some future endpoint\n// accepts). Callers may still pass `identity` explicitly; this is the default, not\n// an override.\nexport function isIdentityArea(area: Area): boolean {\n return area.endsWith('/admin') || area.endsWith('/settings');\n}\n\n// \u2500\u2500 Auth token injection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n//\n// A host (e.g. the console's AuthProvider) registers a getter so the client can\n// attach a fresh access token \u2014 refreshing it first if near expiry \u2014 without the\n// SDK owning React state or storage.\n\nlet tokenGetter: (() => Promise<string | null>) | null = null;\n\nexport function setAuthTokenGetter(getter: (() => Promise<string | null>) | null) {\n tokenGetter = getter;\n}\n\n// The identity-token getter feeds the admin API (ADR-033): admin requests carry\n// the instance-scoped identity token, not the tenant access token. Registered\n// alongside setAuthTokenGetter; selected per request via the `identity` option.\nlet identityTokenGetter: (() => Promise<string | null>) | null = null;\n\nexport function setIdentityTokenGetter(getter: (() => Promise<string | null>) | null) {\n identityTokenGetter = getter;\n}\n\n// resolveAuthToken returns the current access token (refreshing via the getter),\n// or null. Internal to the SDK \u2014 the ws subscribe path (subscribe.ts) reuses it\n// so subscriptions authenticate with the same token as queries.\nexport async function resolveAuthToken(): Promise<string | null> {\n return tokenGetter ? tokenGetter() : null;\n}\n\n// \u2500\u2500 Errors \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport class GraphQLRequestError extends Error {\n constructor(\n message: string,\n /** HTTP status, or 0 for a transport-level failure. */\n public status: number,\n /** The raw `errors` array from a GraphQL response, when present. */\n public errors?: GraphQLErrorEntry[],\n ) {\n super(message);\n this.name = 'GraphQLRequestError';\n }\n}\n\n/**\n * One entry of a GraphQL response's `errors` array. `extensions` carries the server's\n * machine-readable detail when it sends any \u2014 for example `code: \"THROTTLED\"` with\n * `retryAfterSeconds` on a sign-in refused by the per-account backoff \u2014 so a caller can\n * branch on a code instead of matching the message text.\n */\nexport interface GraphQLErrorEntry {\n message: string;\n extensions?: Record<string, unknown>;\n}\n\ninterface GraphQLResponse<T> {\n data?: T;\n errors?: GraphQLErrorEntry[];\n}\n\nexport interface RequestOptions {\n /** Skip attaching the Bearer token (login / refresh run unauthenticated). */\n anonymous?: boolean;\n /**\n * Authenticate with the identity token instead of the tenant access token \u2014\n * for the instance-scoped admin API (ADR-033). Ignored when `anonymous`.\n *\n * Rarely needed: an `/admin` or `/settings` area defaults to the identity token\n * already (see isIdentityArea). Pass it only to force the lane for an area whose\n * name does not say so.\n */\n identity?: boolean;\n}\n\n// A GraphQL document tagged with its result/variable types \u2014 what gql()/subscribe()\n// accept. A code-generated `TypedDocumentString` (client preset) satisfies it, and\n// so does a hand-authored query string cast to it: the SDK runs in documentMode\n// 'string', calling only `toString()` to get the query text, so the phantom result\n// (`TResult`) and variable (`TVariables`) generics are all that distinguish them.\n// Exported so packages/apps without codegen (dashboards, widgets, the dash app)\n// hand-author typed documents against one shared alias instead of re-inlining it.\nexport type TypedDocument<TResult, TVariables> = DocumentTypeDecoration<TResult, TVariables> & {\n toString(): string;\n};\n\n// `document` is a code-generated typed document (a `TypedDocumentString` from the\n// GraphQL Code Generator client preset). It implements\n// `DocumentTypeDecoration<TResult, TVariables>`, which carries the result and\n// variable types as phantom generics, and stringifies (via `toString()`) to the\n// raw query text the server expects. The structural `& { toString(): string }`\n// keeps this accepting a document from ANY service while still inferring both the\n// result (`TResult`) and the variables (`TVariables`).\nexport async function gql<TResult, TVariables>(\n area: Area,\n document: TypedDocument<TResult, TVariables>,\n ...[variables, options]: TVariables extends Record<string, never>\n ? [variables?: undefined, options?: RequestOptions]\n : [variables: TVariables, options?: RequestOptions]\n): Promise<TResult> {\n const headers: Record<string, string> = { 'Content-Type': 'application/json' };\n\n if (!options?.anonymous) {\n const useIdentity = options?.identity ?? isIdentityArea(area);\n const getter = useIdentity ? identityTokenGetter : tokenGetter;\n if (getter) {\n const token = await getter();\n if (token) headers['Authorization'] = `Bearer ${token}`;\n }\n }\n\n let res: Response;\n try {\n res = await fetch(areaPath(area), {\n method: 'POST',\n headers,\n body: JSON.stringify({ query: document.toString(), variables }),\n });\n } catch (err) {\n throw new GraphQLRequestError(\n err instanceof Error ? err.message : 'Network request failed',\n 0,\n );\n }\n\n if (!res.ok) {\n throw new GraphQLRequestError(`Request failed (${res.status})`, res.status);\n }\n\n const body = (await res.json()) as GraphQLResponse<TResult>;\n if (body.errors && body.errors.length > 0) {\n throw new GraphQLRequestError(body.errors[0].message, res.status, body.errors);\n }\n if (body.data === undefined) {\n throw new GraphQLRequestError('Empty GraphQL response', res.status);\n }\n return body.data;\n}\n", "// Copyright The DeviceChain Authors\n// SPDX-License-Identifier: Apache-2.0\n\n// Classification of transport failures a caller has to render differently.\n//\n// Most errors are one shape to a screen (\"something went wrong\"), but a refusal\n// is not: \"you may not see this\" and \"this could not be loaded\" call for\n// different copy, and collapsing them makes a permission boundary look like an\n// outage.\n//\n// This lives beside GraphQLRequestError rather than in a host app because more\n// than one consumer needs it: the console renders a device's position panel from\n// it, and the dashboard runtime's location channel turns the same refusal into a\n// first-class snapshot state (location:read is deliberately outside the read-only\n// viewer baseline, so being refused is an ordinary state rather than a fault).\n\nimport { GraphQLRequestError } from './transport';\n\n// The backend's authorization gate returns core/auth's ErrForbidden, whose text\n// is \"forbidden: missing required authority\"; graphql-go surfaces a resolver\n// error verbatim as the entry's `message`, over HTTP 200. Anchoring the match at\n// the START of the message keeps it from firing on an unrelated error that\n// merely mentions the word (an entity literally named \"forbidden-zone\", say).\nconst FORBIDDEN_PREFIX = /^forbidden\\b/i;\n\n/**\n * True when the server REFUSED the request for lack of an authority, as opposed\n * to failing it. Callers use this to render a permission state rather than an\n * error state.\n */\nexport function isForbiddenError(err: unknown): boolean {\n if (!(err instanceof GraphQLRequestError)) return false;\n // A transport-level 403 counts even with no GraphQL body to read.\n if (err.status === 403) return true;\n const messages = err.errors?.length ? err.errors.map((e) => e.message) : [err.message];\n return messages.some((m) => FORBIDDEN_PREFIX.test(m.trim()));\n}\n", "// Copyright The DeviceChain Authors\n// SPDX-License-Identifier: Apache-2.0\n\n// The client half of the basemap cascade (ADR-079).\n//\n// The SERVER resolves a tenant's override over the operator's instance default and\n// hands back one effective value. This module resolves the last tier \u2014 a per-surface\n// override, set on the widget or held as a personal preference in the browser \u2014 over\n// that tenant value, and turns the result into something a renderer can use.\n//\n// It lives in the SDK rather than in either app because THREE surfaces need the\n// identical answer: the console's geofence editor, the dashboard map widget, and the\n// `/dash` viewer app, which has its own login and would otherwise be the one place\n// that quietly disagrees.\n\n/** A basemap at any tier: the tile source, and a fallback view. */\nexport interface Basemap {\n tileUrl?: string | null;\n attribution?: string | null;\n centerLat?: number | null;\n centerLon?: number | null;\n zoom?: number | null;\n}\n\n/** A resolved basemap that definitely has a tile source, so a renderer can draw it. */\nexport interface ResolvedBasemap {\n tileUrl: string;\n attribution: string | null;\n centerLat: number | null;\n centerLon: number | null;\n zoom: number | null;\n}\n\n/** Treat blank and whitespace-only strings as unset \u2014 an emptied form field means \"inherit\". */\nfunction text(v: string | null | undefined): string | null {\n if (typeof v !== 'string') return null;\n const trimmed = v.trim();\n return trimmed === '' ? null : trimmed;\n}\n\nfunction num(v: number | null | undefined): number | null {\n return typeof v === 'number' && Number.isFinite(v) ? v : null;\n}\n\n/**\n * Fold a per-surface override over the tenant's effective basemap.\n *\n * \uD83D\uDD34 The TILE SOURCE is atomic, exactly as it is on the server. If the override names\n * a tile URL, its attribution comes from the override too \u2014 never from the tenant. A\n * per-field fold would credit the tenant's provider for the override's tiles, which\n * is a licence violation manufactured by the merge rather than a cosmetic mismatch.\n * This is the same rule as basemap.Merge in user-management, and it has to be\n * restated here because this tier never reaches the server.\n *\n * The camera folds per field: a widget that only wants to open closer in should not\n * have to restate a provider to keep it.\n */\nexport function resolveBasemap(override: Basemap | null | undefined, tenant: Basemap | null | undefined): Basemap {\n const o = override ?? {};\n const t = tenant ?? {};\n\n // \uD83D\uDD34 AN INCOMPLETE OVERRIDE IS NOT AN OVERRIDE. A tile URL with no credit line is\n // refused outright by the server at the tenant and instance tiers \u2014 the two halves\n // are stored as one value or not at all \u2014 but THIS tier never reaches the server: a\n // widget's own options and the fence editor's browser-local fields are entered\n // client-side and rendered directly.\n //\n // Without this the atomic fold below did its job and still produced the defect it\n // exists to prevent: it correctly refused to lend the tenant's credit line to the\n // override's tiles, and then drew those tiles with NO credit line at all. Correct\n // about the cross-crediting, wrong about the licence.\n //\n // So an override naming tiles without their attribution is discarded whole, and the\n // surface falls back to the tenant's properly-credited basemap. Failing closed to a\n // map that is credited beats honouring a preference that cannot legally be shown.\n //\n // On an ordinary instance the fallback is a real map, because a shipped default\n // means some tier below almost always has one. On an instance whose operator\n // deliberately stored `{}` there is nothing below, and the surface falls through to\n // the bundled schematic world \u2014 which is the correct outcome for the same reason,\n // just a less pretty one.\n const overrideAttribution = text(o.attribution);\n const overrideTiles = overrideAttribution === null ? null : text(o.tileUrl);\n const tileUrl = overrideTiles ?? text(t.tileUrl);\n const attribution = overrideTiles ? overrideAttribution : text(t.attribution);\n\n // Latitude and longitude move as a pair for the same reason: half a coordinate\n // from each tier names a point neither one chose.\n const hasOverrideCentre = num(o.centerLat) !== null || num(o.centerLon) !== null;\n const centerLat = hasOverrideCentre ? num(o.centerLat) : num(t.centerLat);\n const centerLon = hasOverrideCentre ? num(o.centerLon) : num(t.centerLon);\n\n return {\n tileUrl,\n attribution,\n centerLat,\n centerLon,\n zoom: num(o.zoom) ?? num(t.zoom),\n };\n}\n\n/**\n * Narrow a folded basemap to one that can actually be rendered, or null.\n *\n * A basemap with a camera but no tile source is legitimate \u2014 an operator may set a\n * starting view before choosing a provider \u2014 but there is nothing to draw, and the\n * caller should say so rather than request tiles from an empty URL.\n */\nexport function renderableBasemap(b: Basemap | null | undefined): ResolvedBasemap | null {\n const tileUrl = text(b?.tileUrl);\n if (!tileUrl) return null;\n return {\n tileUrl,\n attribution: text(b?.attribution),\n centerLat: num(b?.centerLat),\n centerLon: num(b?.centerLon),\n zoom: num(b?.zoom),\n };\n}\n\n/**\n * The fallback view a surface opens at when it has nothing of its own to fit to.\n *\n * \uD83D\uDD34 A FALLBACK, never an override (ADR-079 \u00A75). A surface with real content \u2014 an\n * existing fence's ring, a widget's markers \u2014 fits that and ignores this entirely.\n * Returns null when no tier declared a centre, so the caller keeps its own default\n * rather than being moved to 0,0.\n */\nexport function fallbackView(b: Basemap | null | undefined): { center: [number, number]; zoom: number | null } | null {\n const lat = num(b?.centerLat);\n const lon = num(b?.centerLon);\n if (lat === null || lon === null) return null;\n return { center: [lon, lat], zoom: num(b?.zoom) };\n}\n", "// Copyright The DeviceChain Authors\n// SPDX-License-Identifier: Apache-2.0\n\n// GraphQL subscriptions over the graphql-transport-ws protocol (ADR-037) \u2014 the\n// live half of the client wire, consumed by dashboards (the ADR-039 Hub) for\n// live telemetry. The backend serves subscriptions on the same /api/<area>/graphql\n// path via a WebSocket upgrade; a client derives the ws:// URL from the http one.\n//\n// This is the low-level SDK primitive: one lazily-created graphql-ws client per\n// area (connection reuse), and a subscribe() that binds a typed document to a\n// sink. The dashboard Hub layers subscription multiplexing on top (many widgets,\n// one connection).\n//\n// The server accepts only SUBSCRIPTION operations on this socket \u2014 queries and\n// mutations go over HTTP \u2014 and it closes the socket with 4401 when the access token\n// it authenticated with expires. subscribe() handles that close itself: when a\n// socket that had been acknowledged is closed with 4401, it re-issues the operation\n// once, which opens a new socket whose connection_init carries a freshly resolved\n// token, and reports the reconnect to the sink as connected(true) so a consumer\n// re-queries whatever it may have missed. graphql-ws treats 4401 as fatal and does\n// not retry it on its own.\n\nimport type { DocumentTypeDecoration } from '@graphql-typed-document-node/core';\nimport { createClient, type Client } from 'graphql-ws';\n\nimport { areaPath, resolveAuthToken, type Area } from './transport';\n\n// The graphql-transport-ws close code for an unauthorized connection. Mid-session\n// it means the token the socket authenticated with has expired.\nconst CLOSE_UNAUTHORIZED = 4401;\n\n// wsUrl builds the absolute ws(s):// URL for an area from the current origin,\n// mirroring the relative http path so dev proxy and prod ingress both work.\nfunction wsUrl(area: Area): string {\n const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';\n return `${scheme}://${window.location.host}${areaPath(area)}`;\n}\n\n// An area's shared graphql-ws client, plus what subscribe() needs to know about its\n// socket to decide whether a 4401 close is worth one re-subscribe.\ninterface AreaClient {\n client: Client;\n // The current socket has received connection_ack.\n acked: boolean;\n // The most recently closed socket had been acknowledged. A 4401 on an\n // acknowledged socket is an expiry mid-session, which a fresh token cures; a 4401\n // on one that was never acknowledged is the server refusing the token presented\n // at connect, which re-subscribing would only repeat.\n lastCloseWasAcked: boolean;\n}\n\n// One graphql-ws client per area \u2014 the WebSocket (and its connection_init auth)\n// is reused across every subscription to that area. connectionParams is a\n// function so the token is re-resolved on each (re)connect, surviving refresh.\nconst clients = new Map<Area, AreaClient>();\n\nfunction clientFor(area: Area): AreaClient {\n let entry = clients.get(area);\n if (!entry) {\n const client = createClient({\n url: wsUrl(area),\n connectionParams: async () => {\n const token = await resolveAuthToken();\n return token ? { Authorization: `Bearer ${token}` } : {};\n },\n });\n const state: AreaClient = { client, acked: false, lastCloseWasAcked: false };\n // Registered once, here, before any subscription can register its own \u2014 so on a\n // close this runs first and every sink's error handler (which graphql-ws delivers\n // later, through a rejected promise) reads the same lastCloseWasAcked. That holds\n // even after the first of them re-subscribes and a new socket starts connecting.\n client.on('connecting', () => {\n state.acked = false;\n });\n client.on('connected', () => {\n state.acked = true;\n });\n client.on('closed', () => {\n state.lastCloseWasAcked = state.acked;\n state.acked = false;\n });\n entry = state;\n clients.set(area, entry);\n }\n return entry;\n}\n\n// isUnauthorizedClose reports whether a graphql-ws sink error is a 4401 close event.\nfunction isUnauthorizedClose(err: unknown): boolean {\n return (\n typeof err === 'object' && err !== null && 'code' in err && (err as { code: unknown }).code === CLOSE_UNAUTHORIZED\n );\n}\n\nexport interface SubscriptionSink<T> {\n next: (data: T) => void;\n error?: (err: unknown) => void;\n complete?: () => void;\n // Connection-level signals for the shared per-area socket, distinct from the\n // per-operation next/error/complete above. `connected` fires on each successful\n // connection_ack \u2014 `wasRetry` distinguishes a reconnect (after a dropped socket,\n // or after the server closed an expired session) from the first connect; `closed`\n // fires on each socket close (graphql-ws then auto-retries a transient close). A\n // consumer that only needs data can omit both; a live-status indicator uses them\n // to tell \"connected but idle\" from \"offline\", and a consumer that must not miss\n // events re-queries on connected(true).\n connected?: (wasRetry: boolean) => void;\n closed?: () => void;\n}\n\n// subscribe binds a typed subscription document to a sink and returns an\n// unsubscribe function. The document is the same code-generated typed document\n// the query path uses, so the result type is inferred; it must be a subscription,\n// since the server refuses anything else on this socket. Errors from the operation\n// (or the socket) surface via sink.error; normal termination via sink.complete. A\n// 4401 close of an acknowledged socket \u2014 the server ending an expired session \u2014 is\n// not surfaced: the operation is re-issued once on a new socket with a fresh token.\nexport function subscribe<TResult, TVariables>(\n area: Area,\n document: DocumentTypeDecoration<TResult, TVariables> & { toString(): string },\n variables: TVariables extends Record<string, never> ? undefined : TVariables,\n sink: SubscriptionSink<TResult>,\n): () => void {\n const entry = clientFor(area);\n const { client } = entry;\n // Set when this operation is re-issued after an expiry, so the next connection_ack\n // reaches the sink as a reconnect: graphql-ws itself reports it as a first connect,\n // because a fatal close ends its retry state.\n let reauth = false;\n // Client-level connection events are registered per subscribe() and torn down with\n // it, alongside the operation itself, so a single dispose() call releases both.\n const disposers: Array<() => void> = [];\n if (sink.connected) {\n disposers.push(\n client.on('connected', (_socket, _payload, wasRetry) => {\n const retried = wasRetry || reauth;\n reauth = false;\n sink.connected!(retried);\n }),\n );\n }\n if (sink.closed) {\n disposers.push(client.on('closed', () => sink.closed!()));\n }\n\n const payload = {\n query: document.toString(),\n variables: variables as Record<string, unknown> | undefined,\n };\n let disposed = false;\n let current: (() => void) | null = null;\n const start = (): void => {\n const previous = current;\n current = client.subscribe<TResult>(payload, {\n next: (result) => {\n if (result.data != null) sink.next(result.data);\n },\n error: (err) => {\n if (disposed) return;\n if (isUnauthorizedClose(err) && entry.lastCloseWasAcked) {\n reauth = true;\n start();\n return;\n }\n sink.error?.(err);\n },\n complete: () => sink.complete?.(),\n });\n // Release the ended operation only once its replacement holds the client, so the\n // client's count of live operations never touches zero in between (graphql-ws\n // does not release an operation that ended in a fatal close by itself).\n previous?.();\n };\n start();\n disposers.push(() => {\n disposed = true;\n current?.();\n });\n return () => {\n for (const dispose of disposers) dispose();\n };\n}\n\n// disposeSubscriptions tears down every cached area client (their sockets). A\n// host calls this on sign-out so a stale token's socket does not linger.\nexport function disposeSubscriptions(): void {\n for (const { client } of clients.values()) {\n void client.dispose();\n }\n clients.clear();\n}\n", "// Copyright The DeviceChain Authors\n// SPDX-License-Identifier: Apache-2.0\n\n// Client-side decode of the DeviceChain access token (ADR-008 JWT). This reads\n// the payload for display + routing only; it does NOT verify the signature \u2014\n// every protected operation is still enforced server-side by the token's\n// authorities. Never trust these claims for authorization decisions beyond what\n// the UI shows.\n\nexport interface DecodedClaims {\n /** Tenant the subject acts within (authoritative server-side). */\n tenant: string;\n /** Human-readable subject identifier. */\n username: string;\n /** Assigned role names \u2014 display/audit only; enforcement is on authorities. */\n roles: string[];\n /** Effective capabilities (e.g. \"device:write\", or \"*\" for super-authority). */\n authorities: string[];\n /** \"access\" | \"refresh\". */\n typ: string;\n /** Expiry, epoch seconds (from the standard `exp` claim). */\n exp?: number;\n}\n\nfunction base64UrlDecode(segment: string): string {\n const padded = segment.replace(/-/g, '+').replace(/_/g, '/');\n const json = atob(padded);\n // Handle UTF-8 payloads.\n return decodeURIComponent(\n Array.prototype.map\n .call(json, (c: string) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))\n .join(''),\n );\n}\n\n/** Decode a JWT's payload, or null if it is malformed. */\nexport function decodeToken(token: string): DecodedClaims | null {\n const parts = token.split('.');\n if (parts.length !== 3) return null;\n try {\n const payload = JSON.parse(base64UrlDecode(parts[1]));\n return {\n tenant: payload.tenant ?? '',\n username: payload.username ?? '',\n roles: payload.roles ?? [],\n authorities: payload.authorities ?? [],\n typ: payload.typ ?? '',\n exp: payload.exp,\n };\n } catch {\n return null;\n }\n}\n\n/** True if the token is missing, malformed, or within `skewSeconds` of expiry. */\nexport function isExpired(token: string, skewSeconds = 30): boolean {\n const claims = decodeToken(token);\n if (!claims || claims.exp === undefined) return true;\n const nowSeconds = Date.now() / 1000;\n return claims.exp - skewSeconds <= nowSeconds;\n}\n\n/** True if the claims grant the authority (the super-authority \"*\" passes all). */\nexport function hasAuthority(claims: DecodedClaims | null, required: string): boolean {\n if (!claims) return false;\n return claims.authorities.some((a) => a === '*' || a === required);\n}\n", "// Copyright The DeviceChain Authors\n// SPDX-License-Identifier: Apache-2.0\n\n// Token masks (ADR-042 P3): the client-side template language that mints, and\n// soft-validates, entity tokens. A mask is literal text plus typed placeholders,\n// and one mask drives three operations:\n//\n// generate fill placeholders to produce a concrete token\n// normalize kebab-case a human string into a {slug}\n// validate does a token conform to the mask's shape (soft, UI-only)\n//\n// Masks are advisory: the backend enforces only the security grammar\n// (isValidToken below mirrors rdb.ValidateToken). Style \u2014 lowercase-kebab,\n// prefixes \u2014 lives in the mask, applied here in the console, never on the wire.\n\n// The human-readable alphabet for generated ids: lowercase letters + digits minus\n// the ambiguous 0 o 1 l i, so a minted token can be read back off a label without\n// transcription errors.\nconst READABLE_ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';\nconst DIGITS = '0123456789';\n\n// The security grammar the backend enforces (rdb.ValidateToken): letters (either\n// case), digits, hyphen and underscore, starting alphanumeric. Kept in sync by\n// hand \u2014 the same regex, mirrored for a pre-submit check so the user sees a bad\n// token before the server rejects it.\nconst TOKEN_GRAMMAR = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;\n\n/** The maximum token length (mirrors rdb.MaxTokenLen). */\nexport const MAX_TOKEN_LEN = 128;\n\n/** isValidToken mirrors the backend security grammar for a pre-submit check. */\nexport function isValidToken(token: string): boolean {\n return token.length > 0 && token.length <= MAX_TOKEN_LEN && TOKEN_GRAMMAR.test(token);\n}\n\n/** A parsed mask segment: a literal run, or a typed placeholder. */\nexport type MaskSegment =\n | { kind: 'literal'; text: string }\n | { kind: 'placeholder'; type: 'alphanumeric' | 'numeric' | 'slug' | 'uuid' | 'unknown'; n?: number; raw: string };\n\nconst PLACEHOLDER = /\\{([a-zA-Z]+)(?:-(\\d+))?\\}/g;\nconst KNOWN = ['alphanumeric', 'numeric', 'slug', 'uuid'] as const;\n\n/** parseMask splits a mask into its literal and placeholder segments. */\nexport function parseMask(mask: string): MaskSegment[] {\n const segments: MaskSegment[] = [];\n let last = 0;\n for (const m of mask.matchAll(PLACEHOLDER)) {\n const at = m.index ?? 0;\n if (at > last) segments.push({ kind: 'literal', text: mask.slice(last, at) });\n const name = m[1].toLowerCase();\n const type = (KNOWN as readonly string[]).includes(name) ? (name as 'alphanumeric') : 'unknown';\n segments.push({ kind: 'placeholder', type, n: m[2] ? parseInt(m[2], 10) : undefined, raw: m[0] });\n last = at + m[0].length;\n }\n if (last < mask.length) segments.push({ kind: 'literal', text: mask.slice(last) });\n return segments;\n}\n\nexport interface GenerateOptions {\n /** A human string (e.g. the entity's name) that fills {slug} placeholders. */\n seed?: string;\n /** Injectable RNG in [0,1) for deterministic tests; defaults to Math.random. */\n random?: () => number;\n /** Injectable uuid for deterministic tests; defaults to crypto.randomUUID. */\n uuid?: () => string;\n}\n\nfunction randomUUID(): string {\n const g = globalThis as { crypto?: { randomUUID?: () => string } };\n if (g.crypto?.randomUUID) return g.crypto.randomUUID();\n // Fallback for environments without Web Crypto (not the console; belt & braces).\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {\n const r = (Math.random() * 16) | 0;\n return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);\n });\n}\n\n/**\n * generateToken fills a mask's placeholders to produce a concrete token:\n * {alphanumeric-N} \u2192 N readable chars, {numeric-N} \u2192 N digits, {uuid} \u2192 a uuid,\n * {slug} \u2192 the normalized seed (or a short readable id when no seed is given, so\n * a \"regenerate\" button always yields something). Literals pass through.\n */\nexport function generateToken(mask: string, opts: GenerateOptions = {}): string {\n const rand = opts.random ?? Math.random;\n const uuid = opts.uuid ?? randomUUID;\n // \uD83D\uDD34 The width is bounded before it is used. A mask may name any width the\n // regexp matches, and `Array.from({length: 1e20})` THROWS RangeError \u2014 which\n // made this function, called during render by the settings editor and by every\n // create form, a way to kill the page from a stored value. No width past\n // MAX_TOKEN_LEN can mint a legal token anyway, so an absurd one contributes\n // nothing rather than exploding. validateMask refuses such a mask outright;\n // this keeps generation safe for a mask stored before it did.\n const pick = (alphabet: string, n: number) =>\n n > MAX_TOKEN_LEN\n ? ''\n : Array.from({ length: n }, () => alphabet[Math.floor(rand() * alphabet.length)]).join('');\n\n return parseMask(mask)\n .map((seg) => {\n if (seg.kind === 'literal') return seg.text;\n switch (seg.type) {\n case 'alphanumeric':\n return pick(READABLE_ALPHABET, seg.n ?? 8);\n case 'numeric':\n return pick(DIGITS, seg.n ?? 4);\n case 'slug':\n return opts.seed ? normalizeToken(opts.seed) : pick(READABLE_ALPHABET, 6);\n case 'uuid':\n return uuid();\n default:\n return ''; // an unknown placeholder contributes nothing to a generated token\n }\n })\n .join('');\n}\n\n/**\n * normalizeToken kebab-cases a human string into a slug: lower-case, spaces and\n * underscores to hyphens, drop anything else, collapse and trim hyphens.\n * \"Ops Overview\" \u2192 \"ops-overview\".\n */\nexport function normalizeToken(input: string): string {\n return trimHyphens(\n input\n .toLowerCase()\n .trim()\n .replace(/[\\s_]+/g, '-')\n .replace(/[^a-z0-9-]+/g, '')\n .replace(/-+/g, '-'),\n );\n}\n\n/** Strips leading and trailing hyphens in linear time (no backtracking pattern). */\nfunction trimHyphens(s: string): string {\n let start = 0;\n let end = s.length;\n while (start < end && s.charCodeAt(start) === 45) start++;\n while (end > start && s.charCodeAt(end - 1) === 45) end--;\n return s.slice(start, end);\n}\n\nfunction escapeRegExp(s: string): string {\n return s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\n/**\n * maskToRegExp compiles a mask into a whole-string RegExp for soft, UI-only\n * conformance checking. It is intentionally lenient (accepts either case in\n * alphanumeric/uuid segments) \u2014 the backend, not this, is the authority.\n */\nexport function maskToRegExp(mask: string): RegExp {\n const body = parseMask(mask)\n .map((seg) => {\n if (seg.kind === 'literal') return escapeRegExp(seg.text);\n switch (seg.type) {\n case 'alphanumeric':\n return `[A-Za-z0-9]{${seg.n ?? 8}}`;\n case 'numeric':\n return `[0-9]{${seg.n ?? 4}}`;\n case 'slug':\n return `[a-z0-9]+(?:-[a-z0-9]+)*`;\n case 'uuid':\n return `[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`;\n default:\n return escapeRegExp(seg.raw); // unknown placeholder must match itself literally\n }\n })\n .join('');\n return new RegExp(`^${body}$`);\n}\n\n/** conformsToMask reports whether a token matches the mask's shape (soft check). */\nexport function conformsToMask(mask: string, token: string): boolean {\n return maskToRegExp(mask).test(token);\n}\n\n/** The fixed seed sampleToken fills {slug} placeholders from. */\nconst SAMPLE_SEED = 'Sample Name';\n\n/**\n * sampleToken generates the token a mask would mint, deterministically. Same\n * generator as generateToken \u2014 a pinned RNG and uuid rather than a second\n * implementation, so a sample can never disagree with what a create form\n * actually produces.\n */\nexport function sampleToken(mask: string, seed: string = SAMPLE_SEED): string {\n return generateToken(mask, {\n seed,\n random: () => 0,\n uuid: () => '3f2b8c14-9d7e-4a51-b6c2-0e8d5a1f7b93',\n });\n}\n\n/**\n * Why a mask cannot mint usable tokens. Structured rather than a message\n * because this package is consumed by localized apps \u2014 the caller maps a reason\n * to its own wording.\n */\nexport type MaskProblem =\n | { reason: 'empty' }\n | { reason: 'unknownPlaceholder'; placeholder: string }\n | { reason: 'noPlaceholder' }\n | { reason: 'widthTooLarge'; placeholder: string; max: number }\n | { reason: 'invalidToken'; sample: string };\n\n/**\n * validateMask reports why a mask is unusable, or null when it is fine. It\n * mirrors the server (the tokenmask Go package), which is the authority and\n * refuses the same four things \u2014 every one of which fails SILENTLY otherwise:\n *\n * empty nothing to mint from\n * unknownPlaceholder contributes nothing, so \"dev-{sulg}\" mints \"dev-\"\n * noPlaceholder every entity mints the SAME token; the second create collides\n * invalidToken the minted sample is not a legal token (a space, a slash\u2026)\n *\n * \uD83D\uDD34 Keep this NO STRICTER than the server. A check the server does not make\n * would refuse a mask the platform accepts, and the operator would have no way\n * to get past it.\n */\nexport function validateMask(mask: string): MaskProblem | null {\n if (mask === '') return { reason: 'empty' };\n let placeholders = 0;\n for (const seg of parseMask(mask)) {\n if (seg.kind !== 'placeholder') continue;\n if (seg.type === 'unknown') return { reason: 'unknownPlaceholder', placeholder: seg.raw };\n // \uD83D\uDD34 Checked BEFORE anything samples the mask. This function is called during\n // render, per keystroke \u2014 a width of 5e7 froze the tab for seconds building a\n // string nobody would ever see, and 1e20 threw RangeError and killed the\n // page. Neither reached the `invalidToken` check below, which is where a\n // reader would expect an over-long token to be caught.\n if (seg.n !== undefined && seg.n > MAX_TOKEN_LEN) {\n return { reason: 'widthTooLarge', placeholder: seg.raw, max: MAX_TOKEN_LEN };\n }\n placeholders++;\n }\n if (placeholders === 0) return { reason: 'noPlaceholder' };\n const sample = sampleToken(mask);\n if (!isValidToken(sample)) return { reason: 'invalidToken', sample };\n return null;\n}\n\n/**\n * resolveMask picks the mask for an entity type from a token-masks map, falling\n * back to the \"default\" entry and finally to a bare {slug}.\n */\nexport function resolveMask(masks: Record<string, string>, entityType: string): string {\n return masks[entityType] ?? masks.default ?? '{slug}';\n}\n"],
5
- "mappings": "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",
3
+ "sources": ["../src/transport.ts", "../src/errors.ts", "../src/basemap.ts", "../src/subscribe.ts", "../src/jwt.ts", "../src/session.ts", "../src/tokens.ts"],
4
+ "sourcesContent": ["// Copyright The DeviceChain Authors\n// SPDX-License-Identifier: Apache-2.0\n\n// A tiny GraphQL-over-fetch client, in the spirit of a REST `fetchJson` helper \u2014\n// no Apollo. Each DeviceChain functional area serves its own /graphql endpoint;\n// `area` selects which one. Requests carry the access-token Bearer when one is\n// available (see setAuthTokenGetter); login/refresh run with no token (the\n// GraphQL handler lets an absent token through with no tenant).\n\nimport type { DocumentTypeDecoration } from '@graphql-typed-document-node/core';\n\n// DeviceChain functional areas that expose a GraphQL endpoint. An \"area\" ending in\n// `/admin` is a service's instance-scoped admin API (ADR-033), served by that\n// service at a second path and authenticated with the identity token rather than a\n// tenant access token (see the `identity` request option).\n//\n// `ai-inference` has no `/admin` sibling here because it has no tenant-facing\n// surface a console would call: its data plane serves exactly one mutation, reached\n// by event-processing's service token, never by a browser. Its whole console-facing\n// surface is the operator provider list (ADR-065).\n// Declared as a const array with the type derived from it, rather than as a bare\n// union, so the set is enumerable at RUNTIME. That is what lets a test walk every\n// member and check it against the deployment catalog the Helm chart uses \u2014 a\n// union alone erases at compile time, leaving the check to a hand-kept copy of\n// this list, which is the drift this whole mechanism exists to prevent.\nexport const AREAS = [\n 'user-management',\n 'user-management/admin',\n 'user-management/settings',\n 'device-management',\n 'event-management',\n 'event-processing',\n 'device-state',\n 'command-delivery',\n 'dashboard-management',\n 'outbound-connectors',\n 'notification-management',\n 'update-management',\n 'ai-inference/admin',\n] as const;\n\nexport type Area = (typeof AREAS)[number];\n\n// Relative URL matching the cluster ingress contract: the ingress routes\n// https://<host>/api/<area>/graphql to each functional-area service and serves\n// the SPA at \"/\". In `vite dev` the same path is handled by the config proxy.\n// 'user-management/admin' resolves to /api/user-management/admin/graphql.\nexport function areaPath(area: Area): string {\n return `/api/${area}/graphql`;\n}\n\n/**\n * The deployed functional area an Area is served by \u2014 the segment before the\n * first slash, since a suffix like `/admin` or `/settings` names a PLANE of an\n * area rather than an area of its own.\n *\n * This is the join between an Area (a client-side routing convention) and the\n * area vocabulary the server reports from what the chart actually deployed, so\n * the console can tell \"never deployed here\" apart from a transport failure.\n * Note it is deliberately many-to-one: `user-management`,\n * `user-management/admin` and `user-management/settings` all key to\n * `user-management`, and the reverse direction does not exist \u2014 plenty of\n * deployed areas (event-sources, sparkplug-ingest) have no Area member.\n */\nexport function areaKey(area: Area): string {\n return area.split('/')[0];\n}\n\n// Whether an area is served on an instance-scoped lane that authenticates with the\n// IDENTITY token rather than a tenant access token (ADR-033). Those endpoints accept\n// nothing else, so the credential follows from the area and is not a per-call\n// choice: deriving it here means a new admin call cannot silently ride the tenant\n// token and 401 (or, worse, be given a tenant token that some future endpoint\n// accepts). Callers may still pass `identity` explicitly; this is the default, not\n// an override.\nexport function isIdentityArea(area: Area): boolean {\n return area.endsWith('/admin') || area.endsWith('/settings');\n}\n\n// \u2500\u2500 Auth token injection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n//\n// A host (e.g. the console's AuthProvider) registers a getter so the client can\n// attach a fresh access token \u2014 refreshing it first if near expiry \u2014 without the\n// SDK owning React state or storage.\n\nlet tokenGetter: (() => Promise<string | null>) | null = null;\n\nexport function setAuthTokenGetter(getter: (() => Promise<string | null>) | null) {\n tokenGetter = getter;\n}\n\n// The identity-token getter feeds the admin API (ADR-033): admin requests carry\n// the instance-scoped identity token, not the tenant access token. Registered\n// alongside setAuthTokenGetter; selected per request via the `identity` option.\nlet identityTokenGetter: (() => Promise<string | null>) | null = null;\n\nexport function setIdentityTokenGetter(getter: (() => Promise<string | null>) | null) {\n identityTokenGetter = getter;\n}\n\n// resolveAuthToken returns the current access token (refreshing via the getter),\n// or null. Internal to the SDK \u2014 the ws subscribe path (subscribe.ts) reuses it\n// so subscriptions authenticate with the same token as queries.\nexport async function resolveAuthToken(): Promise<string | null> {\n return tokenGetter ? tokenGetter() : null;\n}\n\n// \u2500\u2500 Errors \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport class GraphQLRequestError extends Error {\n constructor(\n message: string,\n /** HTTP status, or 0 for a transport-level failure. */\n public status: number,\n /** The raw `errors` array from a GraphQL response, when present. */\n public errors?: GraphQLErrorEntry[],\n ) {\n super(message);\n this.name = 'GraphQLRequestError';\n }\n}\n\n/**\n * One entry of a GraphQL response's `errors` array. `extensions` carries the server's\n * machine-readable detail when it sends any \u2014 for example `code: \"THROTTLED\"` with\n * `retryAfterSeconds` on a sign-in refused by the per-account backoff \u2014 so a caller can\n * branch on a code instead of matching the message text.\n */\nexport interface GraphQLErrorEntry {\n message: string;\n extensions?: Record<string, unknown>;\n}\n\ninterface GraphQLResponse<T> {\n data?: T;\n errors?: GraphQLErrorEntry[];\n}\n\nexport interface RequestOptions {\n /** Skip attaching the Bearer token (login / refresh run unauthenticated). */\n anonymous?: boolean;\n /**\n * Authenticate with the identity token instead of the tenant access token \u2014\n * for the instance-scoped admin API (ADR-033). Ignored when `anonymous`.\n *\n * Rarely needed: an `/admin` or `/settings` area defaults to the identity token\n * already (see isIdentityArea). Pass it only to force the lane for an area whose\n * name does not say so.\n */\n identity?: boolean;\n}\n\n// A GraphQL document tagged with its result/variable types \u2014 what gql()/subscribe()\n// accept. A code-generated `TypedDocumentString` (client preset) satisfies it, and\n// so does a hand-authored query string cast to it: the SDK runs in documentMode\n// 'string', calling only `toString()` to get the query text, so the phantom result\n// (`TResult`) and variable (`TVariables`) generics are all that distinguish them.\n// Exported so packages/apps without codegen (dashboards, widgets, the dash app)\n// hand-author typed documents against one shared alias instead of re-inlining it.\nexport type TypedDocument<TResult, TVariables> = DocumentTypeDecoration<TResult, TVariables> & {\n toString(): string;\n};\n\n// `document` is a code-generated typed document (a `TypedDocumentString` from the\n// GraphQL Code Generator client preset). It implements\n// `DocumentTypeDecoration<TResult, TVariables>`, which carries the result and\n// variable types as phantom generics, and stringifies (via `toString()`) to the\n// raw query text the server expects. The structural `& { toString(): string }`\n// keeps this accepting a document from ANY service while still inferring both the\n// result (`TResult`) and the variables (`TVariables`).\nexport async function gql<TResult, TVariables>(\n area: Area,\n document: TypedDocument<TResult, TVariables>,\n ...[variables, options]: TVariables extends Record<string, never>\n ? [variables?: undefined, options?: RequestOptions]\n : [variables: TVariables, options?: RequestOptions]\n): Promise<TResult> {\n const headers: Record<string, string> = { 'Content-Type': 'application/json' };\n\n if (!options?.anonymous) {\n const useIdentity = options?.identity ?? isIdentityArea(area);\n const getter = useIdentity ? identityTokenGetter : tokenGetter;\n if (getter) {\n const token = await getter();\n if (token) headers['Authorization'] = `Bearer ${token}`;\n }\n }\n\n let res: Response;\n try {\n res = await fetch(areaPath(area), {\n method: 'POST',\n headers,\n body: JSON.stringify({ query: document.toString(), variables }),\n });\n } catch (err) {\n throw new GraphQLRequestError(\n err instanceof Error ? err.message : 'Network request failed',\n 0,\n );\n }\n\n if (!res.ok) {\n throw new GraphQLRequestError(`Request failed (${res.status})`, res.status);\n }\n\n const body = (await res.json()) as GraphQLResponse<TResult>;\n if (body.errors && body.errors.length > 0) {\n throw new GraphQLRequestError(body.errors[0].message, res.status, body.errors);\n }\n if (body.data === undefined) {\n throw new GraphQLRequestError('Empty GraphQL response', res.status);\n }\n return body.data;\n}\n", "// Copyright The DeviceChain Authors\n// SPDX-License-Identifier: Apache-2.0\n\n// Classification of transport failures a caller has to render differently.\n//\n// Most errors are one shape to a screen (\"something went wrong\"), but a refusal\n// is not: \"you may not see this\" and \"this could not be loaded\" call for\n// different copy, and collapsing them makes a permission boundary look like an\n// outage.\n//\n// This lives beside GraphQLRequestError rather than in a host app because more\n// than one consumer needs it: the console renders a device's position panel from\n// it, and the dashboard runtime's location channel turns the same refusal into a\n// first-class snapshot state (location:read is deliberately outside the read-only\n// viewer baseline, so being refused is an ordinary state rather than a fault).\n\nimport { GraphQLRequestError } from './transport';\n\n// The backend's authorization gate returns core/auth's ErrForbidden, whose text\n// is \"forbidden: missing required authority\"; graphql-go surfaces a resolver\n// error verbatim as the entry's `message`, over HTTP 200. Anchoring the match at\n// the START of the message keeps it from firing on an unrelated error that\n// merely mentions the word (an entity literally named \"forbidden-zone\", say).\nconst FORBIDDEN_PREFIX = /^forbidden\\b/i;\n\n/**\n * True when the server REFUSED the request for lack of an authority, as opposed\n * to failing it. Callers use this to render a permission state rather than an\n * error state.\n */\nexport function isForbiddenError(err: unknown): boolean {\n if (!(err instanceof GraphQLRequestError)) return false;\n // A transport-level 403 counts even with no GraphQL body to read.\n if (err.status === 403) return true;\n const messages = err.errors?.length ? err.errors.map((e) => e.message) : [err.message];\n return messages.some((m) => FORBIDDEN_PREFIX.test(m.trim()));\n}\n", "// Copyright The DeviceChain Authors\n// SPDX-License-Identifier: Apache-2.0\n\n// The client half of the basemap cascade (ADR-079).\n//\n// The SERVER resolves a tenant's override over the operator's instance default and\n// hands back one effective value. This module resolves the last tier \u2014 a per-surface\n// override, set on the widget or held as a personal preference in the browser \u2014 over\n// that tenant value, and turns the result into something a renderer can use.\n//\n// It lives in the SDK rather than in either app because THREE surfaces need the\n// identical answer: the console's geofence editor, the dashboard map widget, and the\n// `/dash` viewer app, which has its own login and would otherwise be the one place\n// that quietly disagrees.\n\n/** A basemap at any tier: the tile source, and a fallback view. */\nexport interface Basemap {\n tileUrl?: string | null;\n attribution?: string | null;\n centerLat?: number | null;\n centerLon?: number | null;\n zoom?: number | null;\n}\n\n/** A resolved basemap that definitely has a tile source, so a renderer can draw it. */\nexport interface ResolvedBasemap {\n tileUrl: string;\n attribution: string | null;\n centerLat: number | null;\n centerLon: number | null;\n zoom: number | null;\n}\n\n/** Treat blank and whitespace-only strings as unset \u2014 an emptied form field means \"inherit\". */\nfunction text(v: string | null | undefined): string | null {\n if (typeof v !== 'string') return null;\n const trimmed = v.trim();\n return trimmed === '' ? null : trimmed;\n}\n\nfunction num(v: number | null | undefined): number | null {\n return typeof v === 'number' && Number.isFinite(v) ? v : null;\n}\n\n/**\n * Fold a per-surface override over the tenant's effective basemap.\n *\n * \uD83D\uDD34 The TILE SOURCE is atomic, exactly as it is on the server. If the override names\n * a tile URL, its attribution comes from the override too \u2014 never from the tenant. A\n * per-field fold would credit the tenant's provider for the override's tiles, which\n * is a licence violation manufactured by the merge rather than a cosmetic mismatch.\n * This is the same rule as basemap.Merge in user-management, and it has to be\n * restated here because this tier never reaches the server.\n *\n * The camera folds per field: a widget that only wants to open closer in should not\n * have to restate a provider to keep it.\n */\nexport function resolveBasemap(override: Basemap | null | undefined, tenant: Basemap | null | undefined): Basemap {\n const o = override ?? {};\n const t = tenant ?? {};\n\n // \uD83D\uDD34 AN INCOMPLETE OVERRIDE IS NOT AN OVERRIDE. A tile URL with no credit line is\n // refused outright by the server at the tenant and instance tiers \u2014 the two halves\n // are stored as one value or not at all \u2014 but THIS tier never reaches the server: a\n // widget's own options and the fence editor's browser-local fields are entered\n // client-side and rendered directly.\n //\n // Without this the atomic fold below did its job and still produced the defect it\n // exists to prevent: it correctly refused to lend the tenant's credit line to the\n // override's tiles, and then drew those tiles with NO credit line at all. Correct\n // about the cross-crediting, wrong about the licence.\n //\n // So an override naming tiles without their attribution is discarded whole, and the\n // surface falls back to the tenant's properly-credited basemap. Failing closed to a\n // map that is credited beats honouring a preference that cannot legally be shown.\n //\n // On an ordinary instance the fallback is a real map, because a shipped default\n // means some tier below almost always has one. On an instance whose operator\n // deliberately stored `{}` there is nothing below, and the surface falls through to\n // the bundled schematic world \u2014 which is the correct outcome for the same reason,\n // just a less pretty one.\n const overrideAttribution = text(o.attribution);\n const overrideTiles = overrideAttribution === null ? null : text(o.tileUrl);\n const tileUrl = overrideTiles ?? text(t.tileUrl);\n const attribution = overrideTiles ? overrideAttribution : text(t.attribution);\n\n // Latitude and longitude move as a pair for the same reason: half a coordinate\n // from each tier names a point neither one chose.\n const hasOverrideCentre = num(o.centerLat) !== null || num(o.centerLon) !== null;\n const centerLat = hasOverrideCentre ? num(o.centerLat) : num(t.centerLat);\n const centerLon = hasOverrideCentre ? num(o.centerLon) : num(t.centerLon);\n\n return {\n tileUrl,\n attribution,\n centerLat,\n centerLon,\n zoom: num(o.zoom) ?? num(t.zoom),\n };\n}\n\n/**\n * Narrow a folded basemap to one that can actually be rendered, or null.\n *\n * A basemap with a camera but no tile source is legitimate \u2014 an operator may set a\n * starting view before choosing a provider \u2014 but there is nothing to draw, and the\n * caller should say so rather than request tiles from an empty URL.\n */\nexport function renderableBasemap(b: Basemap | null | undefined): ResolvedBasemap | null {\n const tileUrl = text(b?.tileUrl);\n if (!tileUrl) return null;\n return {\n tileUrl,\n attribution: text(b?.attribution),\n centerLat: num(b?.centerLat),\n centerLon: num(b?.centerLon),\n zoom: num(b?.zoom),\n };\n}\n\n/**\n * The fallback view a surface opens at when it has nothing of its own to fit to.\n *\n * \uD83D\uDD34 A FALLBACK, never an override (ADR-079 \u00A75). A surface with real content \u2014 an\n * existing fence's ring, a widget's markers \u2014 fits that and ignores this entirely.\n * Returns null when no tier declared a centre, so the caller keeps its own default\n * rather than being moved to 0,0.\n */\nexport function fallbackView(b: Basemap | null | undefined): { center: [number, number]; zoom: number | null } | null {\n const lat = num(b?.centerLat);\n const lon = num(b?.centerLon);\n if (lat === null || lon === null) return null;\n return { center: [lon, lat], zoom: num(b?.zoom) };\n}\n", "// Copyright The DeviceChain Authors\n// SPDX-License-Identifier: Apache-2.0\n\n// GraphQL subscriptions over the graphql-transport-ws protocol (ADR-037) \u2014 the\n// live half of the client wire, consumed by dashboards (the ADR-039 Hub) for\n// live telemetry. The backend serves subscriptions on the same /api/<area>/graphql\n// path via a WebSocket upgrade; a client derives the ws:// URL from the http one.\n//\n// This is the low-level SDK primitive: one lazily-created graphql-ws client per\n// area (connection reuse), and a subscribe() that binds a typed document to a\n// sink. The dashboard Hub layers subscription multiplexing on top (many widgets,\n// one connection).\n//\n// The server accepts only SUBSCRIPTION operations on this socket \u2014 queries and\n// mutations go over HTTP \u2014 and it closes the socket with 4401 when the access token\n// it authenticated with expires. subscribe() handles that close itself: when a\n// socket that had been acknowledged is closed with 4401, it re-issues the operation\n// once, which opens a new socket whose connection_init carries a freshly resolved\n// token, and reports the reconnect to the sink as connected(true) so a consumer\n// re-queries whatever it may have missed. graphql-ws treats 4401 as fatal and does\n// not retry it on its own. Any other dropped socket is retried for as long as the\n// operation is wanted, with a capped, jittered back-off (clientFor).\n\nimport type { DocumentTypeDecoration } from '@graphql-typed-document-node/core';\nimport { createClient, type Client } from 'graphql-ws';\n\nimport { areaPath, resolveAuthToken, type Area } from './transport';\n\n// The graphql-transport-ws close code for an unauthorized connection. Mid-session\n// it means the token the socket authenticated with has expired.\nconst CLOSE_UNAUTHORIZED = 4401;\n\n// wsUrl builds the absolute ws(s):// URL for an area from the current origin,\n// mirroring the relative http path so dev proxy and prod ingress both work.\nfunction wsUrl(area: Area): string {\n const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';\n return `${scheme}://${window.location.host}${areaPath(area)}`;\n}\n\n// An area's shared graphql-ws client, plus what subscribe() needs to know about its\n// socket to decide whether a 4401 close is worth one re-subscribe.\ninterface AreaClient {\n client: Client;\n // The current socket has received connection_ack.\n acked: boolean;\n // The most recently closed socket had been acknowledged. A 4401 on an\n // acknowledged socket is an expiry mid-session, which a fresh token cures; a 4401\n // on one that was never acknowledged is the server refusing the token presented\n // at connect, which re-subscribing would only repeat.\n lastCloseWasAcked: boolean;\n}\n\n// The longest wait between two reconnect attempts. graphql-ws doubles its wait on each\n// attempt, so without a ceiling an outage of a few minutes would push the next attempt\n// hours away.\nexport const RETRY_CEILING_MS = 30_000;\n\n// retryDelayMs is the wait before reconnect attempt `retries` (0-based): doubling from one\n// second up to RETRY_CEILING_MS, then a random point in the upper half of that. The random\n// half spreads out every screen that lost the same server at the same moment, so they do\n// not all come back on the same tick; the fixed half keeps attempts from bunching near zero.\nexport function retryDelayMs(retries: number, random: () => number = Math.random): number {\n const ceiling = Math.min(1000 * 2 ** retries, RETRY_CEILING_MS);\n return (ceiling / 2) * (1 + random());\n}\n\n// One graphql-ws client per area \u2014 the WebSocket (and its connection_init auth)\n// is reused across every subscription to that area. connectionParams is a\n// function so the token is re-resolved on each (re)connect, surviving refresh.\nconst clients = new Map<Area, AreaClient>();\n\nfunction clientFor(area: Area): AreaClient {\n let entry = clients.get(area);\n if (!entry) {\n const client = createClient({\n url: wsUrl(area),\n connectionParams: async () => {\n const token = await resolveAuthToken();\n return token ? { Authorization: `Bearer ${token}` } : {};\n },\n // \uD83D\uDD34 Keep reconnecting for as long as anything is subscribed. Left to its defaults,\n // graphql-ws gives up after 5 attempts (about 40 s; in a browser, after the first one\n // that fails, see shouldRetry) and then errors every operation, and nothing reopened\n // them: a wall display that lost its network for a minute showed\n // \"Data unavailable\" until someone reloaded it. One socket per area carries every\n // operation, so each attempt is ONE connection however many widgets are waiting, and\n // graphql-ws resubscribes all of them on it once it is acknowledged.\n retryAttempts: Infinity,\n retryWait: (retries) => new Promise((resolve) => setTimeout(resolve, retryDelayMs(retries))),\n // graphql-ws retries only close events by default, and a browser reports a handshake\n // that failed (the server or the network still down) as an `error` event first, so\n // the first failed attempt would end every operation however many were allowed. A\n // socket's own error event is a network problem and is retried; an Error thrown in\n // this client is not. The closes that say the request itself is wrong (4400, 4401,\n // 4409, 4429) are fatal inside graphql-ws whatever this answers, so an expired or\n // refused token still reaches subscribe() below and the session flow, never a loop.\n shouldRetry: (err) => (typeof Event != 'undefined' && err instanceof Event) || hasCode(err),\n });\n const state: AreaClient = { client, acked: false, lastCloseWasAcked: false };\n // Registered once, here, before any subscription can register its own \u2014 so on a\n // close this runs first and every sink's error handler (which graphql-ws delivers\n // later, through a rejected promise) reads the same lastCloseWasAcked. That holds\n // even after the first of them re-subscribes and a new socket starts connecting.\n client.on('connecting', () => {\n state.acked = false;\n });\n client.on('connected', () => {\n state.acked = true;\n });\n client.on('closed', () => {\n state.lastCloseWasAcked = state.acked;\n state.acked = false;\n });\n entry = state;\n clients.set(area, entry);\n }\n return entry;\n}\n\n// hasCode reports whether a graphql-ws error is a close event (anything with a close code).\nfunction hasCode(err: unknown): err is { code: unknown } {\n return typeof err === 'object' && err !== null && 'code' in err;\n}\n\n// isUnauthorizedClose reports whether a graphql-ws sink error is a 4401 close event.\nfunction isUnauthorizedClose(err: unknown): boolean {\n return hasCode(err) && err.code === CLOSE_UNAUTHORIZED;\n}\n\nexport interface SubscriptionSink<T> {\n next: (data: T) => void;\n error?: (err: unknown) => void;\n complete?: () => void;\n // Connection-level signals for the shared per-area socket, distinct from the\n // per-operation next/error/complete above. `connected` fires on each successful\n // connection_ack \u2014 `wasRetry` distinguishes a reconnect (after a dropped socket,\n // or after the server closed an expired session) from the first connect; `closed`\n // fires on each socket close (graphql-ws then auto-retries a transient close). A\n // consumer that only needs data can omit both; a live-status indicator uses them\n // to tell \"connected but idle\" from \"offline\", and a consumer that must not miss\n // events re-queries on connected(true).\n connected?: (wasRetry: boolean) => void;\n closed?: () => void;\n}\n\n// subscribe binds a typed subscription document to a sink and returns an\n// unsubscribe function. The document is the same code-generated typed document\n// the query path uses, so the result type is inferred; it must be a subscription,\n// since the server refuses anything else on this socket. Errors from the operation\n// (or the socket) surface via sink.error; normal termination via sink.complete. A\n// 4401 close of an acknowledged socket \u2014 the server ending an expired session \u2014 is\n// not surfaced: the operation is re-issued once on a new socket with a fresh token.\nexport function subscribe<TResult, TVariables>(\n area: Area,\n document: DocumentTypeDecoration<TResult, TVariables> & { toString(): string },\n variables: TVariables extends Record<string, never> ? undefined : TVariables,\n sink: SubscriptionSink<TResult>,\n): () => void {\n const entry = clientFor(area);\n const { client } = entry;\n // Set when this operation is re-issued after an expiry, so the next connection_ack\n // reaches the sink as a reconnect: graphql-ws itself reports it as a first connect,\n // because a fatal close ends its retry state.\n let reauth = false;\n // Client-level connection events are registered per subscribe() and torn down with\n // it, alongside the operation itself, so a single dispose() call releases both.\n const disposers: Array<() => void> = [];\n if (sink.connected) {\n disposers.push(\n client.on('connected', (_socket, _payload, wasRetry) => {\n const retried = wasRetry || reauth;\n reauth = false;\n sink.connected!(retried);\n }),\n );\n }\n if (sink.closed) {\n disposers.push(client.on('closed', () => sink.closed!()));\n }\n\n const payload = {\n query: document.toString(),\n variables: variables as Record<string, unknown> | undefined,\n };\n let disposed = false;\n let current: (() => void) | null = null;\n const start = (): void => {\n const previous = current;\n current = client.subscribe<TResult>(payload, {\n next: (result) => {\n if (result.data != null) sink.next(result.data);\n },\n error: (err) => {\n if (disposed) return;\n if (isUnauthorizedClose(err) && entry.lastCloseWasAcked) {\n reauth = true;\n start();\n return;\n }\n sink.error?.(err);\n },\n complete: () => sink.complete?.(),\n });\n // Release the ended operation only once its replacement holds the client, so the\n // client's count of live operations never touches zero in between (graphql-ws\n // does not release an operation that ended in a fatal close by itself).\n previous?.();\n };\n start();\n disposers.push(() => {\n disposed = true;\n current?.();\n });\n return () => {\n for (const dispose of disposers) dispose();\n };\n}\n\n// disposeSubscriptions tears down every cached area client (their sockets). A\n// host calls this on sign-out so a stale token's socket does not linger.\nexport function disposeSubscriptions(): void {\n for (const { client } of clients.values()) {\n void client.dispose();\n }\n clients.clear();\n}\n", "// Copyright The DeviceChain Authors\n// SPDX-License-Identifier: Apache-2.0\n\n// Client-side decode of the DeviceChain access token (ADR-008 JWT). This reads\n// the payload for display + routing only; it does NOT verify the signature \u2014\n// every protected operation is still enforced server-side by the token's\n// authorities. Never trust these claims for authorization decisions beyond what\n// the UI shows.\n\nexport interface DecodedClaims {\n /** Tenant the subject acts within (authoritative server-side). */\n tenant: string;\n /** Human-readable subject identifier. */\n username: string;\n /** Assigned role names \u2014 display/audit only; enforcement is on authorities. */\n roles: string[];\n /** Effective capabilities (e.g. \"device:write\", or \"*\" for super-authority). */\n authorities: string[];\n /** \"access\" | \"refresh\". */\n typ: string;\n /** Expiry, epoch seconds (from the standard `exp` claim). */\n exp?: number;\n /** Issue time, epoch seconds (from the standard `iat` claim). */\n iat?: number;\n}\n\nfunction base64UrlDecode(segment: string): string {\n const padded = segment.replace(/-/g, '+').replace(/_/g, '/');\n const json = atob(padded);\n // Handle UTF-8 payloads.\n return decodeURIComponent(\n Array.prototype.map\n .call(json, (c: string) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))\n .join(''),\n );\n}\n\n/** Decode a JWT's payload, or null if it is malformed. */\nexport function decodeToken(token: string): DecodedClaims | null {\n const parts = token.split('.');\n if (parts.length !== 3) return null;\n try {\n const payload = JSON.parse(base64UrlDecode(parts[1]));\n return {\n tenant: payload.tenant ?? '',\n username: payload.username ?? '',\n roles: payload.roles ?? [],\n authorities: payload.authorities ?? [],\n typ: payload.typ ?? '',\n exp: payload.exp,\n iat: payload.iat,\n };\n } catch {\n return null;\n }\n}\n\n/** True if the token is missing, malformed, or within `skewSeconds` of expiry. */\nexport function isExpired(token: string, skewSeconds = 30): boolean {\n const claims = decodeToken(token);\n if (!claims || claims.exp === undefined) return true;\n const nowSeconds = Date.now() / 1000;\n return claims.exp - skewSeconds <= nowSeconds;\n}\n\n/** True if the claims grant the authority (the super-authority \"*\" passes all). */\nexport function hasAuthority(claims: DecodedClaims | null, required: string): boolean {\n if (!claims) return false;\n return claims.authorities.some((a) => a === '*' || a === required);\n}\n", "// Copyright The DeviceChain Authors\n// SPDX-License-Identifier: Apache-2.0\n\n// The tenant session's access-token lifecycle, framework-free. A host owns WHERE\n// the token pair lives (React state + localStorage in the console, memory in /dash)\n// and WHAT the refresh call is; this owns WHEN to refresh and how concurrent callers\n// share one refresh:\n//\n// - getAccessToken() returns the held access token while it is comfortably valid,\n// and otherwise refreshes first.\n// - concurrent callers share ONE in-flight refresh. Refresh tokens rotate, so two\n// parallel refreshes would spend the same token twice and the second would fail.\n// - a result that arrives after the session was cleared or replaced is dropped, so a\n// slow refresh can never resurrect a dead session.\n// - a lapsed refresh token, or a DEFINITIVE refresh failure (the server answered and\n// refused: a GraphQL error such as an invalid, revoked or SSO-gated refresh token, or\n// any 4xx but 429), ends the session through onEnd and the caller gets null. The host\n// decides what \"ended\" looks like.\n// - a TRANSIENT refresh failure (no response at all, a 5xx, or a 429) does not end a\n// session whose access token is still valid: the caller is handed that token, and the\n// next call (or the auto-refresh retry below) tries again. Only once the access token\n// has actually expired does a transient failure end the session, since there is then\n// nothing left to authenticate with.\n// - startAutoRefresh() refreshes ahead of expiry without waiting for a caller, and\n// retries a transient failure with jittered exponential backoff until the access\n// token actually expires. A display that only holds a websocket makes no request that\n// would trigger the lazy path, so without this it would sit on a token that is about\n// to expire, and a network blip at the wrong moment would end an unattended session.\n\nimport { isExpired, decodeToken } from './jwt';\nimport { GraphQLRequestError } from './transport';\n\nexport interface TokenPair {\n accessToken: string;\n refreshToken: string;\n}\n\nexport interface SessionTokensOptions {\n /** The held pair, or null when signed out. Must return the same object until it is replaced. */\n read: () => TokenPair | null;\n /** Stores a refreshed pair. */\n apply: (next: TokenPair) => void;\n /** Exchanges a refresh token for a new pair. */\n refresh: (refreshToken: string) => Promise<TokenPair>;\n /**\n * The session can no longer be sustained: the refresh token lapsed (`err` undefined), the\n * server refused the refresh (`err` is what it threw), or transient failures outlasted the\n * access token (`err` is the last one). The host clears its session here.\n */\n onEnd: (err?: unknown) => void;\n /**\n * Refresh this many seconds before the access token expires. Default 30. A larger value\n * gives startAutoRefresh's retries a longer window to ride out a network outage. It is\n * capped at half the token's lifetime (`exp - iat`), so a server configured with short-lived\n * tokens never hands out one that is already due, which would refresh on every call.\n */\n skewSeconds?: number;\n}\n\nexport interface SessionTokens {\n /** A usable access token, refreshing first when it is within the skew of expiry; null when signed out or ended. */\n getAccessToken: () => Promise<string | null>;\n /** Forget any in-flight refresh (the host cleared or replaced the session itself). */\n reset: () => void;\n /**\n * Refresh ahead of expiry on a timer, retrying a transient failure with backoff until the\n * access token expires. Returns a stop function.\n */\n startAutoRefresh: () => () => void;\n}\n\nconst DEFAULT_SKEW_SECONDS = 30;\n// setTimeout stores its delay in a signed 32-bit int; a longer one fires immediately.\nconst MAX_TIMER_MS = 2 ** 31 - 1;\n// Auto-refresh retry backoff: 1 s doubling to 30 s, each scaled by a random 50-100 % so a\n// fleet of displays that lost the network together does not come back in lockstep.\nconst RETRY_BASE_MS = 1_000;\nconst RETRY_CAP_MS = 30_000;\n// The last retry is aimed this far ahead of the access token's expiry, not at it.\nconst RETRY_EXPIRY_MARGIN_MS = 1_000;\n// The shortest gap between two scheduled refreshes. A backstop: if the held token still\n// looks due right after a refresh (the device clock runs well ahead of the server's), the\n// timer must not turn into a refresh loop against the server.\nconst MIN_REFRESH_GAP_MS = 10_000;\n\n/**\n * Whether a refresh failure is worth retrying: the request never got a response (status 0),\n * or the server answered 5xx or 429 without a GraphQL error. Anything else (a GraphQL error\n * such as an invalid or revoked refresh token or SSO_REQUIRED, a 400 or 401, or an error that\n * is not a transport error at all) is the server's answer, and asking again would repeat it.\n */\nfunction isTransientRefreshError(err: unknown): boolean {\n if (!(err instanceof GraphQLRequestError)) return false;\n if (err.errors && err.errors.length > 0) return false;\n return err.status === 0 || err.status === 429 || err.status >= 500;\n}\n\ntype Outcome =\n | { kind: 'ok'; token: string }\n | { kind: 'stale' } // the session was cleared or replaced while the refresh ran\n | { kind: 'ended' }\n | { kind: 'transient'; err: unknown };\n\nfunction expiryMs(token: string): number | undefined {\n const exp = decodeToken(token)?.exp;\n return exp === undefined ? undefined : exp * 1000;\n}\n\nexport function createSessionTokens(opts: SessionTokensOptions): SessionTokens {\n const skew = opts.skewSeconds ?? DEFAULT_SKEW_SECONDS;\n let inFlight: Promise<Outcome> | null = null;\n\n // The skew for one access token: the configured one, or half its lifetime if that is less.\n const skewFor = (token: string): number => {\n const c = decodeToken(token);\n if (c?.exp === undefined || c.iat === undefined) return skew;\n return Math.min(skew, Math.max(0, (c.exp - c.iat) / 2));\n };\n const due = (token: string): boolean => isExpired(token, skewFor(token));\n\n const end = (err?: unknown) => {\n inFlight = null;\n opts.onEnd(err);\n };\n\n // One refresh of `current`, shared by every caller that asks while it runs.\n const refreshShared = (current: TokenPair): Promise<Outcome> => {\n if (!inFlight) {\n inFlight = (async (): Promise<Outcome> => {\n try {\n const pair = await opts.refresh(current.refreshToken);\n // A promise cannot be cancelled, so guard here: if the live session is no\n // longer the one this refresh started from, drop the result.\n if (opts.read() !== current) return { kind: 'stale' };\n opts.apply({ accessToken: pair.accessToken, refreshToken: pair.refreshToken });\n return { kind: 'ok', token: pair.accessToken };\n } catch (err) {\n // Only end the session this refresh belonged to, not one since replaced.\n if (opts.read() !== current) return { kind: 'stale' };\n if (isTransientRefreshError(err)) return { kind: 'transient', err };\n end(err);\n return { kind: 'ended' };\n } finally {\n inFlight = null;\n }\n })();\n }\n return inFlight;\n };\n\n const getAccessToken = async (): Promise<string | null> => {\n const current = opts.read();\n if (!current) return null;\n if (isExpired(current.refreshToken, 0)) {\n end();\n return null;\n }\n if (!due(current.accessToken)) return current.accessToken;\n\n const out = await refreshShared(current);\n if (out.kind === 'ok') return out.token;\n if (out.kind !== 'transient') return null;\n // A transient failure: the held token is still good if it has not actually expired.\n if (opts.read() !== current) return null;\n if (!isExpired(current.accessToken, 0)) return current.accessToken;\n end(out.err);\n return null;\n };\n\n const startAutoRefresh = (): (() => void) => {\n let timer: ReturnType<typeof setTimeout> | undefined;\n let stopped = false;\n let failures = 0;\n\n const arm = (delay: number) => {\n timer = setTimeout(() => void fire(), Math.min(Math.max(0, delay), MAX_TIMER_MS));\n };\n\n // Arm for the next refresh of whatever pair is held now, no sooner than `floor` ms.\n const schedule = (floor = 0) => {\n if (stopped) return;\n const current = opts.read();\n const exp = current ? expiryMs(current.accessToken) : undefined;\n if (!current || exp === undefined) return; // signed out, or nothing to time against\n arm(Math.max(floor, exp - skewFor(current.accessToken) * 1000 - Date.now()));\n };\n\n const fire = async () => {\n if (stopped) return;\n const current = opts.read();\n if (!current) return;\n if (isExpired(current.refreshToken, 0)) {\n end();\n return;\n }\n // Someone else refreshed already (a request took the lazy path): time against that.\n if (!due(current.accessToken)) {\n failures = 0;\n schedule(MIN_REFRESH_GAP_MS);\n return;\n }\n const out = await refreshShared(current);\n if (stopped) return;\n if (out.kind === 'ok') {\n failures = 0;\n schedule(MIN_REFRESH_GAP_MS);\n return;\n }\n if (out.kind !== 'transient') return; // ended, or the host replaced the session\n if (opts.read() !== current) return;\n\n const left = (expiryMs(current.accessToken) ?? 0) - RETRY_EXPIRY_MARGIN_MS - Date.now();\n if (left <= 0) {\n end(out.err);\n return;\n }\n const backoff = Math.min(RETRY_CAP_MS, RETRY_BASE_MS * 2 ** failures) * (0.5 + Math.random() / 2);\n failures++;\n arm(Math.min(backoff, left));\n };\n\n schedule();\n return () => {\n stopped = true;\n if (timer !== undefined) clearTimeout(timer);\n };\n };\n\n return {\n getAccessToken,\n reset: () => {\n inFlight = null;\n },\n startAutoRefresh,\n };\n}\n", "// Copyright The DeviceChain Authors\n// SPDX-License-Identifier: Apache-2.0\n\n// Token masks (ADR-042 P3): the client-side template language that mints, and\n// soft-validates, entity tokens. A mask is literal text plus typed placeholders,\n// and one mask drives three operations:\n//\n// generate fill placeholders to produce a concrete token\n// normalize kebab-case a human string into a {slug}\n// validate does a token conform to the mask's shape (soft, UI-only)\n//\n// Masks are advisory: the backend enforces only the security grammar\n// (isValidToken below mirrors rdb.ValidateToken). Style \u2014 lowercase-kebab,\n// prefixes \u2014 lives in the mask, applied here in the console, never on the wire.\n\n// The human-readable alphabet for generated ids: lowercase letters + digits minus\n// the ambiguous 0 o 1 l i, so a minted token can be read back off a label without\n// transcription errors.\nconst READABLE_ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';\nconst DIGITS = '0123456789';\n\n// The security grammar the backend enforces (rdb.ValidateToken): letters (either\n// case), digits, hyphen and underscore, starting alphanumeric. Kept in sync by\n// hand \u2014 the same regex, mirrored for a pre-submit check so the user sees a bad\n// token before the server rejects it.\nconst TOKEN_GRAMMAR = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;\n\n/** The maximum token length (mirrors rdb.MaxTokenLen). */\nexport const MAX_TOKEN_LEN = 128;\n\n/** isValidToken mirrors the backend security grammar for a pre-submit check. */\nexport function isValidToken(token: string): boolean {\n return token.length > 0 && token.length <= MAX_TOKEN_LEN && TOKEN_GRAMMAR.test(token);\n}\n\n/** A parsed mask segment: a literal run, or a typed placeholder. */\nexport type MaskSegment =\n | { kind: 'literal'; text: string }\n | { kind: 'placeholder'; type: 'alphanumeric' | 'numeric' | 'slug' | 'uuid' | 'unknown'; n?: number; raw: string };\n\nconst PLACEHOLDER = /\\{([a-zA-Z]+)(?:-(\\d+))?\\}/g;\nconst KNOWN = ['alphanumeric', 'numeric', 'slug', 'uuid'] as const;\n\n/** parseMask splits a mask into its literal and placeholder segments. */\nexport function parseMask(mask: string): MaskSegment[] {\n const segments: MaskSegment[] = [];\n let last = 0;\n for (const m of mask.matchAll(PLACEHOLDER)) {\n const at = m.index ?? 0;\n if (at > last) segments.push({ kind: 'literal', text: mask.slice(last, at) });\n const name = m[1].toLowerCase();\n const type = (KNOWN as readonly string[]).includes(name) ? (name as 'alphanumeric') : 'unknown';\n segments.push({ kind: 'placeholder', type, n: m[2] ? parseInt(m[2], 10) : undefined, raw: m[0] });\n last = at + m[0].length;\n }\n if (last < mask.length) segments.push({ kind: 'literal', text: mask.slice(last) });\n return segments;\n}\n\nexport interface GenerateOptions {\n /** A human string (e.g. the entity's name) that fills {slug} placeholders. */\n seed?: string;\n /** Injectable RNG in [0,1) for deterministic tests; defaults to Math.random. */\n random?: () => number;\n /** Injectable uuid for deterministic tests; defaults to crypto.randomUUID. */\n uuid?: () => string;\n}\n\nfunction randomUUID(): string {\n const g = globalThis as { crypto?: { randomUUID?: () => string } };\n if (g.crypto?.randomUUID) return g.crypto.randomUUID();\n // Fallback for environments without Web Crypto (not the console; belt & braces).\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {\n const r = (Math.random() * 16) | 0;\n return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);\n });\n}\n\n/**\n * generateToken fills a mask's placeholders to produce a concrete token:\n * {alphanumeric-N} \u2192 N readable chars, {numeric-N} \u2192 N digits, {uuid} \u2192 a uuid,\n * {slug} \u2192 the normalized seed (or a short readable id when no seed is given, so\n * a \"regenerate\" button always yields something). Literals pass through.\n */\nexport function generateToken(mask: string, opts: GenerateOptions = {}): string {\n const rand = opts.random ?? Math.random;\n const uuid = opts.uuid ?? randomUUID;\n // \uD83D\uDD34 The width is bounded before it is used. A mask may name any width the\n // regexp matches, and `Array.from({length: 1e20})` THROWS RangeError \u2014 which\n // made this function, called during render by the settings editor and by every\n // create form, a way to kill the page from a stored value. No width past\n // MAX_TOKEN_LEN can mint a legal token anyway, so an absurd one contributes\n // nothing rather than exploding. validateMask refuses such a mask outright;\n // this keeps generation safe for a mask stored before it did.\n const pick = (alphabet: string, n: number) =>\n n > MAX_TOKEN_LEN\n ? ''\n : Array.from({ length: n }, () => alphabet[Math.floor(rand() * alphabet.length)]).join('');\n\n return parseMask(mask)\n .map((seg) => {\n if (seg.kind === 'literal') return seg.text;\n switch (seg.type) {\n case 'alphanumeric':\n return pick(READABLE_ALPHABET, seg.n ?? 8);\n case 'numeric':\n return pick(DIGITS, seg.n ?? 4);\n case 'slug':\n return opts.seed ? normalizeToken(opts.seed) : pick(READABLE_ALPHABET, 6);\n case 'uuid':\n return uuid();\n default:\n return ''; // an unknown placeholder contributes nothing to a generated token\n }\n })\n .join('');\n}\n\n/**\n * normalizeToken kebab-cases a human string into a slug: lower-case, spaces and\n * underscores to hyphens, drop anything else, collapse and trim hyphens.\n * \"Ops Overview\" \u2192 \"ops-overview\".\n */\nexport function normalizeToken(input: string): string {\n return trimHyphens(\n input\n .toLowerCase()\n .trim()\n .replace(/[\\s_]+/g, '-')\n .replace(/[^a-z0-9-]+/g, '')\n .replace(/-+/g, '-'),\n );\n}\n\n/** Strips leading and trailing hyphens in linear time (no backtracking pattern). */\nfunction trimHyphens(s: string): string {\n let start = 0;\n let end = s.length;\n while (start < end && s.charCodeAt(start) === 45) start++;\n while (end > start && s.charCodeAt(end - 1) === 45) end--;\n return s.slice(start, end);\n}\n\nfunction escapeRegExp(s: string): string {\n return s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\n/**\n * maskToRegExp compiles a mask into a whole-string RegExp for soft, UI-only\n * conformance checking. It is intentionally lenient (accepts either case in\n * alphanumeric/uuid segments) \u2014 the backend, not this, is the authority.\n */\nexport function maskToRegExp(mask: string): RegExp {\n const body = parseMask(mask)\n .map((seg) => {\n if (seg.kind === 'literal') return escapeRegExp(seg.text);\n switch (seg.type) {\n case 'alphanumeric':\n return `[A-Za-z0-9]{${seg.n ?? 8}}`;\n case 'numeric':\n return `[0-9]{${seg.n ?? 4}}`;\n case 'slug':\n return `[a-z0-9]+(?:-[a-z0-9]+)*`;\n case 'uuid':\n return `[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}`;\n default:\n return escapeRegExp(seg.raw); // unknown placeholder must match itself literally\n }\n })\n .join('');\n return new RegExp(`^${body}$`);\n}\n\n/** conformsToMask reports whether a token matches the mask's shape (soft check). */\nexport function conformsToMask(mask: string, token: string): boolean {\n return maskToRegExp(mask).test(token);\n}\n\n/** The fixed seed sampleToken fills {slug} placeholders from. */\nconst SAMPLE_SEED = 'Sample Name';\n\n/**\n * sampleToken generates the token a mask would mint, deterministically. Same\n * generator as generateToken \u2014 a pinned RNG and uuid rather than a second\n * implementation, so a sample can never disagree with what a create form\n * actually produces.\n */\nexport function sampleToken(mask: string, seed: string = SAMPLE_SEED): string {\n return generateToken(mask, {\n seed,\n random: () => 0,\n uuid: () => '3f2b8c14-9d7e-4a51-b6c2-0e8d5a1f7b93',\n });\n}\n\n/**\n * Why a mask cannot mint usable tokens. Structured rather than a message\n * because this package is consumed by localized apps \u2014 the caller maps a reason\n * to its own wording.\n */\nexport type MaskProblem =\n | { reason: 'empty' }\n | { reason: 'unknownPlaceholder'; placeholder: string }\n | { reason: 'noPlaceholder' }\n | { reason: 'widthTooLarge'; placeholder: string; max: number }\n | { reason: 'invalidToken'; sample: string };\n\n/**\n * validateMask reports why a mask is unusable, or null when it is fine. It\n * mirrors the server (the tokenmask Go package), which is the authority and\n * refuses the same four things \u2014 every one of which fails SILENTLY otherwise:\n *\n * empty nothing to mint from\n * unknownPlaceholder contributes nothing, so \"dev-{sulg}\" mints \"dev-\"\n * noPlaceholder every entity mints the SAME token; the second create collides\n * invalidToken the minted sample is not a legal token (a space, a slash\u2026)\n *\n * \uD83D\uDD34 Keep this NO STRICTER than the server. A check the server does not make\n * would refuse a mask the platform accepts, and the operator would have no way\n * to get past it.\n */\nexport function validateMask(mask: string): MaskProblem | null {\n if (mask === '') return { reason: 'empty' };\n let placeholders = 0;\n for (const seg of parseMask(mask)) {\n if (seg.kind !== 'placeholder') continue;\n if (seg.type === 'unknown') return { reason: 'unknownPlaceholder', placeholder: seg.raw };\n // \uD83D\uDD34 Checked BEFORE anything samples the mask. This function is called during\n // render, per keystroke \u2014 a width of 5e7 froze the tab for seconds building a\n // string nobody would ever see, and 1e20 threw RangeError and killed the\n // page. Neither reached the `invalidToken` check below, which is where a\n // reader would expect an over-long token to be caught.\n if (seg.n !== undefined && seg.n > MAX_TOKEN_LEN) {\n return { reason: 'widthTooLarge', placeholder: seg.raw, max: MAX_TOKEN_LEN };\n }\n placeholders++;\n }\n if (placeholders === 0) return { reason: 'noPlaceholder' };\n const sample = sampleToken(mask);\n if (!isValidToken(sample)) return { reason: 'invalidToken', sample };\n return null;\n}\n\n/**\n * resolveMask picks the mask for an entity type from a token-masks map, falling\n * back to the \"default\" entry and finally to a bare {slug}.\n */\nexport function resolveMask(masks: Record<string, string>, entityType: string): string {\n return masks[entityType] ?? masks.default ?? '{slug}';\n}\n"],
5
+ "mappings": ";AAyBO,IAAM,QAAQ;AAAA,EACnB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAQO,SAAS,SAAS,MAAoB;AAC3C,SAAO,QAAQ,IAAI;AACrB;AAeO,SAAS,QAAQ,MAAoB;AAC1C,SAAO,KAAK,MAAM,GAAG,EAAE,CAAC;AAC1B;AASO,SAAS,eAAe,MAAqB;AAClD,SAAO,KAAK,SAAS,QAAQ,KAAK,KAAK,SAAS,WAAW;AAC7D;AAQA,IAAI,cAAqD;AAElD,SAAS,mBAAmB,QAA+C;AAChF,gBAAc;AAChB;AAKA,IAAI,sBAA6D;AAE1D,SAAS,uBAAuB,QAA+C;AACpF,wBAAsB;AACxB;AAKA,eAAsB,mBAA2C;AAC/D,SAAO,cAAc,YAAY,IAAI;AACvC;AAIO,IAAM,sBAAN,cAAkC,MAAM;AAAA,EAC7C,YACE,SAEO,QAEA,QACP;AACA,UAAM,OAAO;AAJN;AAEA;AAGP,SAAK,OAAO;AAAA,EACd;AACF;AAkDA,eAAsB,IACpB,MACA,aACG,CAAC,WAAW,OAAO,GAGJ;AAClB,QAAM,UAAkC,EAAE,gBAAgB,mBAAmB;AAE7E,MAAI,CAAC,SAAS,WAAW;AACvB,UAAM,cAAc,SAAS,YAAY,eAAe,IAAI;AAC5D,UAAM,SAAS,cAAc,sBAAsB;AACnD,QAAI,QAAQ;AACV,YAAM,QAAQ,MAAM,OAAO;AAC3B,UAAI,MAAO,SAAQ,eAAe,IAAI,UAAU,KAAK;AAAA,IACvD;AAAA,EACF;AAEA,MAAI;AACJ,MAAI;AACF,UAAM,MAAM,MAAM,SAAS,IAAI,GAAG;AAAA,MAChC,QAAQ;AAAA,MACR;AAAA,MACA,MAAM,KAAK,UAAU,EAAE,OAAO,SAAS,SAAS,GAAG,UAAU,CAAC;AAAA,IAChE,CAAC;AAAA,EACH,SAAS,KAAK;AACZ,UAAM,IAAI;AAAA,MACR,eAAe,QAAQ,IAAI,UAAU;AAAA,MACrC;AAAA,IACF;AAAA,EACF;AAEA,MAAI,CAAC,IAAI,IAAI;AACX,UAAM,IAAI,oBAAoB,mBAAmB,IAAI,MAAM,KAAK,IAAI,MAAM;AAAA,EAC5E;AAEA,QAAM,OAAQ,MAAM,IAAI,KAAK;AAC7B,MAAI,KAAK,UAAU,KAAK,OAAO,SAAS,GAAG;AACzC,UAAM,IAAI,oBAAoB,KAAK,OAAO,CAAC,EAAE,SAAS,IAAI,QAAQ,KAAK,MAAM;AAAA,EAC/E;AACA,MAAI,KAAK,SAAS,QAAW;AAC3B,UAAM,IAAI,oBAAoB,0BAA0B,IAAI,MAAM;AAAA,EACpE;AACA,SAAO,KAAK;AACd;;;AC/LA,IAAM,mBAAmB;AAOlB,SAAS,iBAAiB,KAAuB;AACtD,MAAI,EAAE,eAAe,qBAAsB,QAAO;AAElD,MAAI,IAAI,WAAW,IAAK,QAAO;AAC/B,QAAM,WAAW,IAAI,QAAQ,SAAS,IAAI,OAAO,IAAI,CAAC,MAAM,EAAE,OAAO,IAAI,CAAC,IAAI,OAAO;AACrF,SAAO,SAAS,KAAK,CAAC,MAAM,iBAAiB,KAAK,EAAE,KAAK,CAAC,CAAC;AAC7D;;;ACFA,SAAS,KAAK,GAA6C;AACzD,MAAI,OAAO,MAAM,SAAU,QAAO;AAClC,QAAM,UAAU,EAAE,KAAK;AACvB,SAAO,YAAY,KAAK,OAAO;AACjC;AAEA,SAAS,IAAI,GAA6C;AACxD,SAAO,OAAO,MAAM,YAAY,OAAO,SAAS,CAAC,IAAI,IAAI;AAC3D;AAeO,SAAS,eAAe,UAAsC,QAA6C;AAChH,QAAM,IAAI,YAAY,CAAC;AACvB,QAAM,IAAI,UAAU,CAAC;AAsBrB,QAAM,sBAAsB,KAAK,EAAE,WAAW;AAC9C,QAAM,gBAAgB,wBAAwB,OAAO,OAAO,KAAK,EAAE,OAAO;AAC1E,QAAM,UAAU,iBAAiB,KAAK,EAAE,OAAO;AAC/C,QAAM,cAAc,gBAAgB,sBAAsB,KAAK,EAAE,WAAW;AAI5E,QAAM,oBAAoB,IAAI,EAAE,SAAS,MAAM,QAAQ,IAAI,EAAE,SAAS,MAAM;AAC5E,QAAM,YAAY,oBAAoB,IAAI,EAAE,SAAS,IAAI,IAAI,EAAE,SAAS;AACxE,QAAM,YAAY,oBAAoB,IAAI,EAAE,SAAS,IAAI,IAAI,EAAE,SAAS;AAExE,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,MAAM,IAAI,EAAE,IAAI,KAAK,IAAI,EAAE,IAAI;AAAA,EACjC;AACF;AASO,SAAS,kBAAkB,GAAuD;AACvF,QAAM,UAAU,KAAK,GAAG,OAAO;AAC/B,MAAI,CAAC,QAAS,QAAO;AACrB,SAAO;AAAA,IACL;AAAA,IACA,aAAa,KAAK,GAAG,WAAW;AAAA,IAChC,WAAW,IAAI,GAAG,SAAS;AAAA,IAC3B,WAAW,IAAI,GAAG,SAAS;AAAA,IAC3B,MAAM,IAAI,GAAG,IAAI;AAAA,EACnB;AACF;AAUO,SAAS,aAAa,GAAyF;AACpH,QAAM,MAAM,IAAI,GAAG,SAAS;AAC5B,QAAM,MAAM,IAAI,GAAG,SAAS;AAC5B,MAAI,QAAQ,QAAQ,QAAQ,KAAM,QAAO;AACzC,SAAO,EAAE,QAAQ,CAAC,KAAK,GAAG,GAAG,MAAM,IAAI,GAAG,IAAI,EAAE;AAClD;;;AC7GA,SAAS,oBAAiC;AAM1C,IAAM,qBAAqB;AAI3B,SAAS,MAAM,MAAoB;AACjC,QAAM,SAAS,OAAO,SAAS,aAAa,WAAW,QAAQ;AAC/D,SAAO,GAAG,MAAM,MAAM,OAAO,SAAS,IAAI,GAAG,SAAS,IAAI,CAAC;AAC7D;AAkBO,IAAM,mBAAmB;AAMzB,SAAS,aAAa,SAAiB,SAAuB,KAAK,QAAgB;AACxF,QAAM,UAAU,KAAK,IAAI,MAAO,KAAK,SAAS,gBAAgB;AAC9D,SAAQ,UAAU,KAAM,IAAI,OAAO;AACrC;AAKA,IAAM,UAAU,oBAAI,IAAsB;AAE1C,SAAS,UAAU,MAAwB;AACzC,MAAI,QAAQ,QAAQ,IAAI,IAAI;AAC5B,MAAI,CAAC,OAAO;AACV,UAAM,SAAS,aAAa;AAAA,MAC1B,KAAK,MAAM,IAAI;AAAA,MACf,kBAAkB,YAAY;AAC5B,cAAM,QAAQ,MAAM,iBAAiB;AACrC,eAAO,QAAQ,EAAE,eAAe,UAAU,KAAK,GAAG,IAAI,CAAC;AAAA,MACzD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAQA,eAAe;AAAA,MACf,WAAW,CAAC,YAAY,IAAI,QAAQ,CAAC,YAAY,WAAW,SAAS,aAAa,OAAO,CAAC,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAQ3F,aAAa,CAAC,QAAS,OAAO,SAAS,eAAe,eAAe,SAAU,QAAQ,GAAG;AAAA,IAC5F,CAAC;AACD,UAAM,QAAoB,EAAE,QAAQ,OAAO,OAAO,mBAAmB,MAAM;AAK3E,WAAO,GAAG,cAAc,MAAM;AAC5B,YAAM,QAAQ;AAAA,IAChB,CAAC;AACD,WAAO,GAAG,aAAa,MAAM;AAC3B,YAAM,QAAQ;AAAA,IAChB,CAAC;AACD,WAAO,GAAG,UAAU,MAAM;AACxB,YAAM,oBAAoB,MAAM;AAChC,YAAM,QAAQ;AAAA,IAChB,CAAC;AACD,YAAQ;AACR,YAAQ,IAAI,MAAM,KAAK;AAAA,EACzB;AACA,SAAO;AACT;AAGA,SAAS,QAAQ,KAAwC;AACvD,SAAO,OAAO,QAAQ,YAAY,QAAQ,QAAQ,UAAU;AAC9D;AAGA,SAAS,oBAAoB,KAAuB;AAClD,SAAO,QAAQ,GAAG,KAAK,IAAI,SAAS;AACtC;AAyBO,SAAS,UACd,MACA,UACA,WACA,MACY;AACZ,QAAM,QAAQ,UAAU,IAAI;AAC5B,QAAM,EAAE,OAAO,IAAI;AAInB,MAAI,SAAS;AAGb,QAAM,YAA+B,CAAC;AACtC,MAAI,KAAK,WAAW;AAClB,cAAU;AAAA,MACR,OAAO,GAAG,aAAa,CAAC,SAAS,UAAU,aAAa;AACtD,cAAM,UAAU,YAAY;AAC5B,iBAAS;AACT,aAAK,UAAW,OAAO;AAAA,MACzB,CAAC;AAAA,IACH;AAAA,EACF;AACA,MAAI,KAAK,QAAQ;AACf,cAAU,KAAK,OAAO,GAAG,UAAU,MAAM,KAAK,OAAQ,CAAC,CAAC;AAAA,EAC1D;AAEA,QAAM,UAAU;AAAA,IACd,OAAO,SAAS,SAAS;AAAA,IACzB;AAAA,EACF;AACA,MAAI,WAAW;AACf,MAAI,UAA+B;AACnC,QAAM,QAAQ,MAAY;AACxB,UAAM,WAAW;AACjB,cAAU,OAAO,UAAmB,SAAS;AAAA,MAC3C,MAAM,CAAC,WAAW;AAChB,YAAI,OAAO,QAAQ,KAAM,MAAK,KAAK,OAAO,IAAI;AAAA,MAChD;AAAA,MACA,OAAO,CAAC,QAAQ;AACd,YAAI,SAAU;AACd,YAAI,oBAAoB,GAAG,KAAK,MAAM,mBAAmB;AACvD,mBAAS;AACT,gBAAM;AACN;AAAA,QACF;AACA,aAAK,QAAQ,GAAG;AAAA,MAClB;AAAA,MACA,UAAU,MAAM,KAAK,WAAW;AAAA,IAClC,CAAC;AAID,eAAW;AAAA,EACb;AACA,QAAM;AACN,YAAU,KAAK,MAAM;AACnB,eAAW;AACX,cAAU;AAAA,EACZ,CAAC;AACD,SAAO,MAAM;AACX,eAAW,WAAW,UAAW,SAAQ;AAAA,EAC3C;AACF;AAIO,SAAS,uBAA6B;AAC3C,aAAW,EAAE,OAAO,KAAK,QAAQ,OAAO,GAAG;AACzC,SAAK,OAAO,QAAQ;AAAA,EACtB;AACA,UAAQ,MAAM;AAChB;;;ACvMA,SAAS,gBAAgB,SAAyB;AAChD,QAAM,SAAS,QAAQ,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG;AAC3D,QAAM,OAAO,KAAK,MAAM;AAExB,SAAO;AAAA,IACL,MAAM,UAAU,IACb,KAAK,MAAM,CAAC,MAAc,OAAO,OAAO,EAAE,WAAW,CAAC,EAAE,SAAS,EAAE,GAAG,MAAM,EAAE,CAAC,EAC/E,KAAK,EAAE;AAAA,EACZ;AACF;AAGO,SAAS,YAAY,OAAqC;AAC/D,QAAM,QAAQ,MAAM,MAAM,GAAG;AAC7B,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,MAAI;AACF,UAAM,UAAU,KAAK,MAAM,gBAAgB,MAAM,CAAC,CAAC,CAAC;AACpD,WAAO;AAAA,MACL,QAAQ,QAAQ,UAAU;AAAA,MAC1B,UAAU,QAAQ,YAAY;AAAA,MAC9B,OAAO,QAAQ,SAAS,CAAC;AAAA,MACzB,aAAa,QAAQ,eAAe,CAAC;AAAA,MACrC,KAAK,QAAQ,OAAO;AAAA,MACpB,KAAK,QAAQ;AAAA,MACb,KAAK,QAAQ;AAAA,IACf;AAAA,EACF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAGO,SAAS,UAAU,OAAe,cAAc,IAAa;AAClE,QAAM,SAAS,YAAY,KAAK;AAChC,MAAI,CAAC,UAAU,OAAO,QAAQ,OAAW,QAAO;AAChD,QAAM,aAAa,KAAK,IAAI,IAAI;AAChC,SAAO,OAAO,MAAM,eAAe;AACrC;AAGO,SAAS,aAAa,QAA8B,UAA2B;AACpF,MAAI,CAAC,OAAQ,QAAO;AACpB,SAAO,OAAO,YAAY,KAAK,CAAC,MAAM,MAAM,OAAO,MAAM,QAAQ;AACnE;;;ACEA,IAAM,uBAAuB;AAE7B,IAAM,eAAe,KAAK,KAAK;AAG/B,IAAM,gBAAgB;AACtB,IAAM,eAAe;AAErB,IAAM,yBAAyB;AAI/B,IAAM,qBAAqB;AAQ3B,SAAS,wBAAwB,KAAuB;AACtD,MAAI,EAAE,eAAe,qBAAsB,QAAO;AAClD,MAAI,IAAI,UAAU,IAAI,OAAO,SAAS,EAAG,QAAO;AAChD,SAAO,IAAI,WAAW,KAAK,IAAI,WAAW,OAAO,IAAI,UAAU;AACjE;AAQA,SAAS,SAAS,OAAmC;AACnD,QAAM,MAAM,YAAY,KAAK,GAAG;AAChC,SAAO,QAAQ,SAAY,SAAY,MAAM;AAC/C;AAEO,SAAS,oBAAoB,MAA2C;AAC7E,QAAM,OAAO,KAAK,eAAe;AACjC,MAAI,WAAoC;AAGxC,QAAM,UAAU,CAAC,UAA0B;AACzC,UAAM,IAAI,YAAY,KAAK;AAC3B,QAAI,GAAG,QAAQ,UAAa,EAAE,QAAQ,OAAW,QAAO;AACxD,WAAO,KAAK,IAAI,MAAM,KAAK,IAAI,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC;AAAA,EACxD;AACA,QAAM,MAAM,CAAC,UAA2B,UAAU,OAAO,QAAQ,KAAK,CAAC;AAEvE,QAAM,MAAM,CAAC,QAAkB;AAC7B,eAAW;AACX,SAAK,MAAM,GAAG;AAAA,EAChB;AAGA,QAAM,gBAAgB,CAAC,YAAyC;AAC9D,QAAI,CAAC,UAAU;AACb,kBAAY,YAA8B;AACxC,YAAI;AACF,gBAAM,OAAO,MAAM,KAAK,QAAQ,QAAQ,YAAY;AAGpD,cAAI,KAAK,KAAK,MAAM,QAAS,QAAO,EAAE,MAAM,QAAQ;AACpD,eAAK,MAAM,EAAE,aAAa,KAAK,aAAa,cAAc,KAAK,aAAa,CAAC;AAC7E,iBAAO,EAAE,MAAM,MAAM,OAAO,KAAK,YAAY;AAAA,QAC/C,SAAS,KAAK;AAEZ,cAAI,KAAK,KAAK,MAAM,QAAS,QAAO,EAAE,MAAM,QAAQ;AACpD,cAAI,wBAAwB,GAAG,EAAG,QAAO,EAAE,MAAM,aAAa,IAAI;AAClE,cAAI,GAAG;AACP,iBAAO,EAAE,MAAM,QAAQ;AAAA,QACzB,UAAE;AACA,qBAAW;AAAA,QACb;AAAA,MACF,GAAG;AAAA,IACL;AACA,WAAO;AAAA,EACT;AAEA,QAAM,iBAAiB,YAAoC;AACzD,UAAM,UAAU,KAAK,KAAK;AAC1B,QAAI,CAAC,QAAS,QAAO;AACrB,QAAI,UAAU,QAAQ,cAAc,CAAC,GAAG;AACtC,UAAI;AACJ,aAAO;AAAA,IACT;AACA,QAAI,CAAC,IAAI,QAAQ,WAAW,EAAG,QAAO,QAAQ;AAE9C,UAAM,MAAM,MAAM,cAAc,OAAO;AACvC,QAAI,IAAI,SAAS,KAAM,QAAO,IAAI;AAClC,QAAI,IAAI,SAAS,YAAa,QAAO;AAErC,QAAI,KAAK,KAAK,MAAM,QAAS,QAAO;AACpC,QAAI,CAAC,UAAU,QAAQ,aAAa,CAAC,EAAG,QAAO,QAAQ;AACvD,QAAI,IAAI,GAAG;AACX,WAAO;AAAA,EACT;AAEA,QAAM,mBAAmB,MAAoB;AAC3C,QAAI;AACJ,QAAI,UAAU;AACd,QAAI,WAAW;AAEf,UAAM,MAAM,CAAC,UAAkB;AAC7B,cAAQ,WAAW,MAAM,KAAK,KAAK,GAAG,KAAK,IAAI,KAAK,IAAI,GAAG,KAAK,GAAG,YAAY,CAAC;AAAA,IAClF;AAGA,UAAM,WAAW,CAAC,QAAQ,MAAM;AAC9B,UAAI,QAAS;AACb,YAAM,UAAU,KAAK,KAAK;AAC1B,YAAM,MAAM,UAAU,SAAS,QAAQ,WAAW,IAAI;AACtD,UAAI,CAAC,WAAW,QAAQ,OAAW;AACnC,UAAI,KAAK,IAAI,OAAO,MAAM,QAAQ,QAAQ,WAAW,IAAI,MAAO,KAAK,IAAI,CAAC,CAAC;AAAA,IAC7E;AAEA,UAAM,OAAO,YAAY;AACvB,UAAI,QAAS;AACb,YAAM,UAAU,KAAK,KAAK;AAC1B,UAAI,CAAC,QAAS;AACd,UAAI,UAAU,QAAQ,cAAc,CAAC,GAAG;AACtC,YAAI;AACJ;AAAA,MACF;AAEA,UAAI,CAAC,IAAI,QAAQ,WAAW,GAAG;AAC7B,mBAAW;AACX,iBAAS,kBAAkB;AAC3B;AAAA,MACF;AACA,YAAM,MAAM,MAAM,cAAc,OAAO;AACvC,UAAI,QAAS;AACb,UAAI,IAAI,SAAS,MAAM;AACrB,mBAAW;AACX,iBAAS,kBAAkB;AAC3B;AAAA,MACF;AACA,UAAI,IAAI,SAAS,YAAa;AAC9B,UAAI,KAAK,KAAK,MAAM,QAAS;AAE7B,YAAM,QAAQ,SAAS,QAAQ,WAAW,KAAK,KAAK,yBAAyB,KAAK,IAAI;AACtF,UAAI,QAAQ,GAAG;AACb,YAAI,IAAI,GAAG;AACX;AAAA,MACF;AACA,YAAM,UAAU,KAAK,IAAI,cAAc,gBAAgB,KAAK,QAAQ,KAAK,MAAM,KAAK,OAAO,IAAI;AAC/F;AACA,UAAI,KAAK,IAAI,SAAS,IAAI,CAAC;AAAA,IAC7B;AAEA,aAAS;AACT,WAAO,MAAM;AACX,gBAAU;AACV,UAAI,UAAU,OAAW,cAAa,KAAK;AAAA,IAC7C;AAAA,EACF;AAEA,SAAO;AAAA,IACL;AAAA,IACA,OAAO,MAAM;AACX,iBAAW;AAAA,IACb;AAAA,IACA;AAAA,EACF;AACF;;;ACzNA,IAAM,oBAAoB;AAC1B,IAAM,SAAS;AAMf,IAAM,gBAAgB;AAGf,IAAM,gBAAgB;AAGtB,SAAS,aAAa,OAAwB;AACnD,SAAO,MAAM,SAAS,KAAK,MAAM,UAAU,iBAAiB,cAAc,KAAK,KAAK;AACtF;AAOA,IAAM,cAAc;AACpB,IAAM,QAAQ,CAAC,gBAAgB,WAAW,QAAQ,MAAM;AAGjD,SAAS,UAAU,MAA6B;AACrD,QAAM,WAA0B,CAAC;AACjC,MAAI,OAAO;AACX,aAAW,KAAK,KAAK,SAAS,WAAW,GAAG;AAC1C,UAAM,KAAK,EAAE,SAAS;AACtB,QAAI,KAAK,KAAM,UAAS,KAAK,EAAE,MAAM,WAAW,MAAM,KAAK,MAAM,MAAM,EAAE,EAAE,CAAC;AAC5E,UAAM,OAAO,EAAE,CAAC,EAAE,YAAY;AAC9B,UAAM,OAAQ,MAA4B,SAAS,IAAI,IAAK,OAA0B;AACtF,aAAS,KAAK,EAAE,MAAM,eAAe,MAAM,GAAG,EAAE,CAAC,IAAI,SAAS,EAAE,CAAC,GAAG,EAAE,IAAI,QAAW,KAAK,EAAE,CAAC,EAAE,CAAC;AAChG,WAAO,KAAK,EAAE,CAAC,EAAE;AAAA,EACnB;AACA,MAAI,OAAO,KAAK,OAAQ,UAAS,KAAK,EAAE,MAAM,WAAW,MAAM,KAAK,MAAM,IAAI,EAAE,CAAC;AACjF,SAAO;AACT;AAWA,SAAS,aAAqB;AAC5B,QAAM,IAAI;AACV,MAAI,EAAE,QAAQ,WAAY,QAAO,EAAE,OAAO,WAAW;AAErD,SAAO,uCAAuC,QAAQ,SAAS,CAAC,MAAM;AACpE,UAAM,IAAK,KAAK,OAAO,IAAI,KAAM;AACjC,YAAQ,MAAM,MAAM,IAAK,IAAI,IAAO,GAAK,SAAS,EAAE;AAAA,EACtD,CAAC;AACH;AAQO,SAAS,cAAc,MAAc,OAAwB,CAAC,GAAW;AAC9E,QAAM,OAAO,KAAK,UAAU,KAAK;AACjC,QAAM,OAAO,KAAK,QAAQ;AAQ1B,QAAM,OAAO,CAAC,UAAkB,MAC9B,IAAI,gBACA,KACA,MAAM,KAAK,EAAE,QAAQ,EAAE,GAAG,MAAM,SAAS,KAAK,MAAM,KAAK,IAAI,SAAS,MAAM,CAAC,CAAC,EAAE,KAAK,EAAE;AAE7F,SAAO,UAAU,IAAI,EAClB,IAAI,CAAC,QAAQ;AACZ,QAAI,IAAI,SAAS,UAAW,QAAO,IAAI;AACvC,YAAQ,IAAI,MAAM;AAAA,MAChB,KAAK;AACH,eAAO,KAAK,mBAAmB,IAAI,KAAK,CAAC;AAAA,MAC3C,KAAK;AACH,eAAO,KAAK,QAAQ,IAAI,KAAK,CAAC;AAAA,MAChC,KAAK;AACH,eAAO,KAAK,OAAO,eAAe,KAAK,IAAI,IAAI,KAAK,mBAAmB,CAAC;AAAA,MAC1E,KAAK;AACH,eAAO,KAAK;AAAA,MACd;AACE,eAAO;AAAA,IACX;AAAA,EACF,CAAC,EACA,KAAK,EAAE;AACZ;AAOO,SAAS,eAAe,OAAuB;AACpD,SAAO;AAAA,IACL,MACG,YAAY,EACZ,KAAK,EACL,QAAQ,WAAW,GAAG,EACtB,QAAQ,gBAAgB,EAAE,EAC1B,QAAQ,OAAO,GAAG;AAAA,EACvB;AACF;AAGA,SAAS,YAAY,GAAmB;AACtC,MAAI,QAAQ;AACZ,MAAI,MAAM,EAAE;AACZ,SAAO,QAAQ,OAAO,EAAE,WAAW,KAAK,MAAM,GAAI;AAClD,SAAO,MAAM,SAAS,EAAE,WAAW,MAAM,CAAC,MAAM,GAAI;AACpD,SAAO,EAAE,MAAM,OAAO,GAAG;AAC3B;AAEA,SAAS,aAAa,GAAmB;AACvC,SAAO,EAAE,QAAQ,uBAAuB,MAAM;AAChD;AAOO,SAAS,aAAa,MAAsB;AACjD,QAAM,OAAO,UAAU,IAAI,EACxB,IAAI,CAAC,QAAQ;AACZ,QAAI,IAAI,SAAS,UAAW,QAAO,aAAa,IAAI,IAAI;AACxD,YAAQ,IAAI,MAAM;AAAA,MAChB,KAAK;AACH,eAAO,eAAe,IAAI,KAAK,CAAC;AAAA,MAClC,KAAK;AACH,eAAO,SAAS,IAAI,KAAK,CAAC;AAAA,MAC5B,KAAK;AACH,eAAO;AAAA,MACT,KAAK;AACH,eAAO;AAAA,MACT;AACE,eAAO,aAAa,IAAI,GAAG;AAAA,IAC/B;AAAA,EACF,CAAC,EACA,KAAK,EAAE;AACV,SAAO,IAAI,OAAO,IAAI,IAAI,GAAG;AAC/B;AAGO,SAAS,eAAe,MAAc,OAAwB;AACnE,SAAO,aAAa,IAAI,EAAE,KAAK,KAAK;AACtC;AAGA,IAAM,cAAc;AAQb,SAAS,YAAY,MAAc,OAAe,aAAqB;AAC5E,SAAO,cAAc,MAAM;AAAA,IACzB;AAAA,IACA,QAAQ,MAAM;AAAA,IACd,MAAM,MAAM;AAAA,EACd,CAAC;AACH;AA4BO,SAAS,aAAa,MAAkC;AAC7D,MAAI,SAAS,GAAI,QAAO,EAAE,QAAQ,QAAQ;AAC1C,MAAI,eAAe;AACnB,aAAW,OAAO,UAAU,IAAI,GAAG;AACjC,QAAI,IAAI,SAAS,cAAe;AAChC,QAAI,IAAI,SAAS,UAAW,QAAO,EAAE,QAAQ,sBAAsB,aAAa,IAAI,IAAI;AAMxF,QAAI,IAAI,MAAM,UAAa,IAAI,IAAI,eAAe;AAChD,aAAO,EAAE,QAAQ,iBAAiB,aAAa,IAAI,KAAK,KAAK,cAAc;AAAA,IAC7E;AACA;AAAA,EACF;AACA,MAAI,iBAAiB,EAAG,QAAO,EAAE,QAAQ,gBAAgB;AACzD,QAAM,SAAS,YAAY,IAAI;AAC/B,MAAI,CAAC,aAAa,MAAM,EAAG,QAAO,EAAE,QAAQ,gBAAgB,OAAO;AACnE,SAAO;AACT;AAMO,SAAS,YAAY,OAA+B,YAA4B;AACrF,SAAO,MAAM,UAAU,KAAK,MAAM,WAAW;AAC/C;",
6
6
  "names": []
7
7
  }
package/dist/jwt.d.ts CHANGED
@@ -11,6 +11,8 @@ export interface DecodedClaims {
11
11
  typ: string;
12
12
  /** Expiry, epoch seconds (from the standard `exp` claim). */
13
13
  exp?: number;
14
+ /** Issue time, epoch seconds (from the standard `iat` claim). */
15
+ iat?: number;
14
16
  }
15
17
  /** Decode a JWT's payload, or null if it is malformed. */
16
18
  export declare function decodeToken(token: string): DecodedClaims | null;
@@ -0,0 +1,37 @@
1
+ export interface TokenPair {
2
+ accessToken: string;
3
+ refreshToken: string;
4
+ }
5
+ export interface SessionTokensOptions {
6
+ /** The held pair, or null when signed out. Must return the same object until it is replaced. */
7
+ read: () => TokenPair | null;
8
+ /** Stores a refreshed pair. */
9
+ apply: (next: TokenPair) => void;
10
+ /** Exchanges a refresh token for a new pair. */
11
+ refresh: (refreshToken: string) => Promise<TokenPair>;
12
+ /**
13
+ * The session can no longer be sustained: the refresh token lapsed (`err` undefined), the
14
+ * server refused the refresh (`err` is what it threw), or transient failures outlasted the
15
+ * access token (`err` is the last one). The host clears its session here.
16
+ */
17
+ onEnd: (err?: unknown) => void;
18
+ /**
19
+ * Refresh this many seconds before the access token expires. Default 30. A larger value
20
+ * gives startAutoRefresh's retries a longer window to ride out a network outage. It is
21
+ * capped at half the token's lifetime (`exp - iat`), so a server configured with short-lived
22
+ * tokens never hands out one that is already due, which would refresh on every call.
23
+ */
24
+ skewSeconds?: number;
25
+ }
26
+ export interface SessionTokens {
27
+ /** A usable access token, refreshing first when it is within the skew of expiry; null when signed out or ended. */
28
+ getAccessToken: () => Promise<string | null>;
29
+ /** Forget any in-flight refresh (the host cleared or replaced the session itself). */
30
+ reset: () => void;
31
+ /**
32
+ * Refresh ahead of expiry on a timer, retrying a transient failure with backoff until the
33
+ * access token expires. Returns a stop function.
34
+ */
35
+ startAutoRefresh: () => () => void;
36
+ }
37
+ export declare function createSessionTokens(opts: SessionTokensOptions): SessionTokens;
@@ -1,5 +1,7 @@
1
1
  import type { DocumentTypeDecoration } from '@graphql-typed-document-node/core';
2
2
  import { type Area } from './transport';
3
+ export declare const RETRY_CEILING_MS = 30000;
4
+ export declare function retryDelayMs(retries: number, random?: () => number): number;
3
5
  export interface SubscriptionSink<T> {
4
6
  next: (data: T) => void;
5
7
  error?: (err: unknown) => void;
@@ -1,5 +1,5 @@
1
1
  import type { DocumentTypeDecoration } from '@graphql-typed-document-node/core';
2
- export declare const AREAS: readonly ['user-management', 'user-management/admin', 'user-management/settings', 'device-management', 'event-management', 'event-processing', 'device-state', 'command-delivery', 'dashboard-management', 'outbound-connectors', 'notification-management', 'ai-inference/admin'];
2
+ export declare const AREAS: readonly ['user-management', 'user-management/admin', 'user-management/settings', 'device-management', 'event-management', 'event-processing', 'device-state', 'command-delivery', 'dashboard-management', 'outbound-connectors', 'notification-management', 'update-management', 'ai-inference/admin'];
3
3
  export type Area = (typeof AREAS)[number];
4
4
  export declare function areaPath(area: Area): string;
5
5
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devicechain/client",
3
- "version": "0.21.1",
3
+ "version": "0.22.0-rc.1",
4
4
  "description": "DeviceChain client SDK — two-tier auth, typed GraphQL operations, and graphql-ws subscriptions over the client wire.",
5
5
  "keywords": [
6
6
  "devicechain",