@sixtyninecommerce/admin 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,6 +38,7 @@ for await (const form of await admin.content.forms.list()) {
38
38
  |---|---|---|
39
39
  | API token | `auth: { token: 's69t_…' }` | server only — constructing one in a browser throws `CREDENTIAL_IN_BROWSER` |
40
40
  | Admin session | `auth: { session: { token?, onToken } }` | server or browser |
41
+ | App installation | `auth: { app: { appId, kid, privateKey }, installationId }` | server only — a private key in a browser throws `CREDENTIAL_IN_BROWSER` |
41
42
 
42
43
  A session client can start empty and sign in:
43
44
 
@@ -94,14 +95,72 @@ const created = await scoped.apiTokens.create({
94
95
  console.log(created.token); // shown exactly once
95
96
  ```
96
97
 
98
+ ### Apps
99
+
100
+ An app is registered once by a platform operator from its `sixtynine.app.json` manifest, enabled
101
+ per tenant, and then installed by a tenant admin who consents to a subset of what it declares.
102
+
103
+ ```ts
104
+ // Tenant side — needs `InstallApps` and a tenant context.
105
+ const available = await scoped.apps.list();
106
+ const installation = await scoped.apps.install({
107
+ appId: available[0]!.id,
108
+ grantedPermissions: ['ReadCatalog', 'ReadOrder'], // ⊆ declaredPermissions, and ⊆ your own
109
+ });
110
+ ```
111
+
112
+ Your own permissions are the ceiling: you cannot grant an app something you do not hold on that
113
+ channel. When the app's declarations later grow, the installation moves to `needs_consent` and its
114
+ `pendingConsentReasons` say what changed — **tokens already minted keep the old grants and keep
115
+ working** until `apps.approveConsent(id)` lands, so this is a notice, not an outage. `apps.uninstall(id)`
116
+ revokes every token immediately and keeps the row as provenance for what the app wrote.
117
+
118
+ ```ts
119
+ // App side — the app authenticates as itself. No token handling.
120
+ const app = createAdminClient({
121
+ urls,
122
+ auth: {
123
+ app: { appId: process.env.APP_ID!, kid: '2026-09', privateKey: process.env.APP_PRIVATE_KEY! },
124
+ installationId: process.env.INSTALLATION_ID!,
125
+ },
126
+ tenant: { scope: { kind: 'channel', code: 'velvet-thread' } },
127
+ });
128
+
129
+ // A token is minted on the first call and refreshed for you from then on.
130
+ const me = await app.auth.whoami(); // kind: 'app_installation'
131
+ me.installation?.status; // 'needs_consent' → tell the merchant
132
+ me.permissions; // what this installation was actually granted
133
+ const products = await app.content.forms.list(); // …and any module those permissions allow
134
+ ```
135
+
136
+ The SDK signs a short-lived Ed25519 assertion with your private key, exchanges it for an `s69i_`
137
+ bearer, and refreshes that bearer a minute before it expires. The key never leaves your process and
138
+ the bearer is never yours to store. `privateKey` takes a PKCS#8 PEM, a JWK, or an already-imported
139
+ `CryptoKey` for a caller whose key lives in a KMS handle.
140
+
141
+ `apps.*` itself is a TENANT ADMIN's surface, not an app's: `InstallApps` is never grantable to an
142
+ app, because an installation is a minted credential and an app that could install one could grant
143
+ another app a subset of its own permissions with nobody consenting. An app reads its own state
144
+ through `auth.whoami()`.
145
+
146
+ Registering apps, managing their keys, suspension and per-tenant enablement live under
147
+ `platform.apps.*` and need a human SuperAdmin session — an app that could register apps could
148
+ declare anything and enable itself.
149
+
150
+ `APP_MANIFEST_SCHEMA` is exported from `@sixtyninecommerce/core`, so a build step can validate a
151
+ manifest before anyone tries to register it.
152
+
97
153
  ### Knowing what credential you hold
98
154
 
99
155
  ```ts
100
156
  const who = await admin.auth.whoami();
101
- who.kind; // 'api_token' | 'user'
157
+ who.kind; // 'api_token' | 'user' | 'app_installation'
102
158
  who.token?.expiresAt; // null means it never expires
103
159
  who.permissions; // effective on the ACTIVE channel, not the union
104
160
  who.entitlements; // connected plugin keys for that channel
161
+ // For an app: who.app (id, slug, name) and who.installation (status, consentVersion).
162
+ // `installation.status === 'needs_consent'` is how an app learns the merchant has
163
+ // to approve declarations it has since grown.
105
164
  ```
106
165
 
107
166
  For a machine credential this is the only way to learn any of it without being refused. It needs a
@@ -329,6 +388,71 @@ had before the registry.
329
388
  Read with `ReadSettings` and written with `UpdateSettings` — the same grants that govern
330
389
  `storefrontUrl` itself. A head an app installed (`installationId` set) is managed through that app's
331
390
  declarations and is refused here with `HEAD_APP_OWNED`.
391
+ ### Capabilities
392
+
393
+ `admin.tenant.capabilities()` answers what this tenant's channel can actually do — one read instead
394
+ of a dozen probes, and the read a head should make before it decides what to render.
395
+
396
+ ```ts
397
+ const manifest = await admin.tenant.capabilities();
398
+
399
+ manifest.capabilities.catalog.ready; // false on a catalog-less tenant — render no product routes
400
+ manifest.capabilities.search.blockers; // ['search returned 0 hits — reindex Meilisearch for this channel']
401
+ manifest.capabilities.age_gate.ready; // true only when the channel has a minimum age set
402
+ manifest.capabilities.mollie?.entitled; // is the integration licensed to this tenant
403
+ manifest.capabilities.mollie.ready; // …and actually connected and enabled
404
+ manifest.capabilities.checkout.dependsOn; // ['catalog']
405
+
406
+ await admin.tenant.capabilities.refresh(); // needs UpdateSettings; skips the freshness window
407
+ ```
408
+
409
+ Four fields per key, and they answer different questions:
410
+
411
+ | Field | Question |
412
+ |---|---|
413
+ | `provisioned` | does the thing exist here — and for a merchant switch (quotes, returns, reviews, the age gate, subscription plans, B2B price lists) that means **switched on** |
414
+ | `configured` | did the probe find no hard blocker |
415
+ | `entitled` | does the tenant hold this plugin's entitlement |
416
+ | `ready` | `provisioned && configured && (entitled || the key is core)` — the one to branch on |
417
+
418
+ `entitled` is `false` on every CORE capability and that is not a bug: the entitlement registry has
419
+ rows only for the catalogue's gated plugins, and `catalog`, `search`, `routes` and the rest are the
420
+ platform itself. `ready` is what excuses them.
421
+
422
+ `blockers` are the platform's own readiness evidence, in the words its preflight uses — never a
423
+ sentence this SDK composed, and never a UI flag. **Non-empty with `ready: true` means degraded, not
424
+ broken**: a storefront that renders anyway is correct, and a merchant still wants to see the line.
425
+ A feature deliberately left off carries no blocker at all — nothing is wrong with it.
426
+
427
+ A blocker that opens with **`not established`** is different from both: it means a dependency would
428
+ not answer, so the server could not read that capability's state at all. Treat it as unknown rather
429
+ than as off — and note that such a manifest is served with a much shorter `Cache-Control` window,
430
+ so asking again shortly is worth doing.
431
+
432
+ **Capability keys are data, so they keep their wire spelling** — `content_pages`, `age_gate`,
433
+ `b2b_pricing`, `payment-links` — exactly like the meta catalogue's data-keyed maps. The fields
434
+ *inside* each entry are camelised (`depends_on` → `dependsOn`). Core keys are `catalog`, `search`,
435
+ `routes`, `menus`, `content_pages`, `checkout`, `branding`, `age_gate`, `reviews`, `subscriptions`,
436
+ `b2b_pricing`, `quotes` and `returns`; beside them there is one key per entitlement-gated plugin in
437
+ the platform catalogue. **Treat an absent key as unknown, never as `false`** — the set grows, and a
438
+ key whose rules are not settled is held back rather than published wrong. A plugin with no cheap server-side configuration read says so in `blockers`
439
+ and stays `ready: false` — for those, `entitled` is the only authoritative field.
440
+
441
+ `b2b_pricing` answers whether the tenant does trade pricing, and it is decided by ONE thing: the
442
+ channel's trade customer groups. That is the registry the engine itself acts on — `checkIsB2BCustomer`,
443
+ B2B tax display, quote eligibility and credit all resolve through it, and with no trade groups
444
+ configured it returns false for every customer, so nothing trade can execute. Neither the quotes
445
+ switch nor the price lists an anonymous caller can see change the answer: the switch is inert
446
+ without trade groups, and that price-list query returns only applies-to-everyone lists, which an
447
+ ordinary retail sale also uses. Both are reported as evidence on a channel already known to be B2B.
448
+ So this key is worth reading with its `blockers`: `ready: true` with a blocker means the tenant IS a
449
+ B2B channel and one piece of evidence was unreadable, and a `not established` blocker means the
450
+ platform declined to guess. `ready: false` with an empty `blockers` means the channel has no trade
451
+ groups — note that this reads the same whether or not the quotes switch is on, so the manifest does
452
+ not currently distinguish a plain retail channel from one where quoting is enabled but inert.
453
+
454
+ The service caches the manifest 60 s per channel and serves it with an `ETag`, so polling it is
455
+ cheap. `installedApps` is on the wire already and stays empty until the app registry ships.
332
456
 
333
457
  ### Errors
334
458
 
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Signing the app assertion the platform exchanges for an `s69i_` token
3
+ * (headless spec §6.3).
4
+ *
5
+ * Web Crypto, not `jose`. `@sixtyninecommerce/core` has zero runtime
6
+ * dependencies by contract and `@sixtyninecommerce/admin` may depend only on
7
+ * it, so pulling a JWS library in here to produce ONE fixed shape — an EdDSA
8
+ * compact JWS over five claims — would trade that contract for about forty lines.
9
+ * `crypto.subtle` is available on Node 20+, Deno, Bun, Cloudflare Workers and
10
+ * every browser, which is the same reach.
11
+ *
12
+ * Ed25519 only. RS256 is accepted by the REGISTRY (an app may have been built
13
+ * around an existing RSA key), but this SDK signs with Ed25519: one algorithm
14
+ * means one code path, and an app using the SDK has no reason to want the
15
+ * larger, slower one.
16
+ */
17
+ /**
18
+ * Web Crypto, typed LOCALLY rather than from `lib.dom` or `@types/node`.
19
+ *
20
+ * This package compiles with `lib: ["ES2022"]` and no DOM — deliberately, so a
21
+ * stray `document.` reference cannot slip in (`client.ts` makes the same point
22
+ * about the browser guard). Naming just the two operations used keeps that
23
+ * property and keeps the published `.d.ts` free of a lib a consumer may not have.
24
+ */
25
+ export interface AppCryptoKey {
26
+ readonly type: string;
27
+ readonly algorithm: {
28
+ readonly name: string;
29
+ };
30
+ }
31
+ /** The private half, as an app author holds it. */
32
+ export type AppPrivateKey =
33
+ /** PKCS#8 PEM (`-----BEGIN PRIVATE KEY-----`). */
34
+ string
35
+ /** A JWK (`{ kty: 'OKP', crv: 'Ed25519', d, x }`). */
36
+ | Record<string, unknown>
37
+ /** An already-imported key, for a caller that keeps one in a KMS-backed handle. */
38
+ | AppCryptoKey;
39
+ /**
40
+ * Import the key once. Wrapping the failure is the point: `importKey` rejects
41
+ * with `DataError`/`OperationError`, which tells an app author nothing about
42
+ * which of their two keys they pasted.
43
+ */
44
+ export declare function importAppPrivateKey(key: AppPrivateKey): Promise<AppCryptoKey>;
45
+ export interface AssertionClaims {
46
+ /** The registered app's id — the platform checks it against the installation. */
47
+ appId: string;
48
+ /**
49
+ * The installation this assertion may be exchanged at. Signed, and compared to
50
+ * the id in the exchange URL, so an app installed on two tenants cannot be
51
+ * handed a token for the wrong one by sending the wrong path.
52
+ */
53
+ installationId: string;
54
+ kid: string;
55
+ /** Seconds. The platform refuses more than 600 (spec §6.3). */
56
+ lifetimeSeconds?: number;
57
+ /** Injectable clock, so a spec can pin `iat`/`exp` without waiting. */
58
+ now?: () => number;
59
+ }
60
+ /** Default assertion lifetime: well inside the 600 s cap, with room for skew. */
61
+ export declare const DEFAULT_ASSERTION_LIFETIME_SECONDS = 120;
62
+ /**
63
+ * Build and sign the assertion.
64
+ *
65
+ * `jti` is a fresh uuid every time: the platform refuses a replayed one, so a
66
+ * cached assertion is worse than useless. `sub` is the installation id, which is
67
+ * what actually binds the assertion to one tenant — the path parameter alone
68
+ * would not, since the endpoint is unauthenticated. `aud` is deliberately absent:
69
+ * there is no stable audience string a self-hosted platform could publish, and
70
+ * `iss` + `sub` already name exactly one app on one installation.
71
+ */
72
+ export declare function signAppAssertion(key: AppCryptoKey, claims: AssertionClaims): Promise<string>;
@@ -0,0 +1,141 @@
1
+ import { ValidationError } from '@sixtyninecommerce/core';
2
+ const PEM_BODY = /-----BEGIN [A-Z ]*PRIVATE KEY-----([\s\S]*?)-----END [A-Z ]*PRIVATE KEY-----/;
3
+ function base64UrlEncode(bytes) {
4
+ let binary = '';
5
+ for (const byte of bytes)
6
+ binary += String.fromCharCode(byte);
7
+ // `btoa` is in every runtime this package targets; Node has had it since 16.
8
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
9
+ }
10
+ function encodeJson(value) {
11
+ return base64UrlEncode(new TextEncoder().encode(JSON.stringify(value)));
12
+ }
13
+ function pemToDer(pem) {
14
+ const match = PEM_BODY.exec(pem);
15
+ if (!match?.[1]) {
16
+ throw new ValidationError({
17
+ code: 'APP_PRIVATE_KEY_INVALID',
18
+ message: 'The app private key is not a PKCS#8 PEM. Expected a block starting with "-----BEGIN PRIVATE KEY-----", a JWK object, or an imported CryptoKey.',
19
+ status: 400,
20
+ service: 'vendure',
21
+ });
22
+ }
23
+ const binary = atob(match[1].replace(/\s+/g, ''));
24
+ const bytes = new Uint8Array(binary.length);
25
+ for (let i = 0; i < binary.length; i += 1)
26
+ bytes[i] = binary.charCodeAt(i);
27
+ return bytes;
28
+ }
29
+ function subtle() {
30
+ const webcrypto = globalThis.crypto;
31
+ if (!webcrypto?.subtle) {
32
+ throw new ValidationError({
33
+ code: 'WEB_CRYPTO_UNAVAILABLE',
34
+ message: 'globalThis.crypto.subtle is not available, so an app assertion cannot be signed. Node 20+, Deno, Bun and browsers all provide it; a Node 18 runtime needs --experimental-global-webcrypto.',
35
+ status: 400,
36
+ service: 'vendure',
37
+ });
38
+ }
39
+ return webcrypto.subtle;
40
+ }
41
+ /**
42
+ * Import the key once. Wrapping the failure is the point: `importKey` rejects
43
+ * with `DataError`/`OperationError`, which tells an app author nothing about
44
+ * which of their two keys they pasted.
45
+ */
46
+ export async function importAppPrivateKey(key) {
47
+ if (typeof key === 'object' && 'type' in key && 'algorithm' in key) {
48
+ const handle = key;
49
+ // Checked, not trusted. The registry also accepts RS256 keys, so an app
50
+ // built around one can reach here with a perfectly valid RSA handle —
51
+ // `subtle.sign('Ed25519', …)` would then reject with a raw WebCrypto
52
+ // `InvalidAccessError`, and every other failure in this file is an
53
+ // actionable `APP_PRIVATE_KEY_INVALID`.
54
+ if (handle.algorithm?.name !== 'Ed25519') {
55
+ throw new ValidationError({
56
+ code: 'APP_PRIVATE_KEY_INVALID',
57
+ message: `This SDK signs app assertions with Ed25519, but the key handle provided is ${String(handle.algorithm?.name)}. Register an Ed25519 key for the app, or sign the assertion yourself.`,
58
+ status: 400,
59
+ service: 'vendure',
60
+ });
61
+ }
62
+ // The PRIVATE half, too. A public Ed25519 handle passes the check above and
63
+ // fails inside `subtle.sign` as a raw `DOMException`, which is not a
64
+ // `PlatformError`: the transport reads it as a network fault, marks it
65
+ // retryable and burns the whole backoff budget reporting a permanent key
66
+ // mistake as an outage.
67
+ if (handle.type !== 'private') {
68
+ throw new ValidationError({
69
+ code: 'APP_PRIVATE_KEY_INVALID',
70
+ message: `The key handle provided is a "${String(handle.type)}" key. Sign with the PRIVATE half; the public half is what you register with addAppKey.`,
71
+ status: 400,
72
+ service: 'vendure',
73
+ });
74
+ }
75
+ return handle;
76
+ }
77
+ try {
78
+ if (typeof key === 'string') {
79
+ return await subtle().importKey('pkcs8', pemToDer(key), 'Ed25519', false, ['sign']);
80
+ }
81
+ return await subtle().importKey('jwk', key, 'Ed25519', false, ['sign']);
82
+ }
83
+ catch (error) {
84
+ if (error instanceof ValidationError)
85
+ throw error;
86
+ throw new ValidationError({
87
+ code: 'APP_PRIVATE_KEY_INVALID',
88
+ message: 'The app private key could not be imported as an Ed25519 key. Check that it is the PRIVATE half, in PKCS#8 PEM or JWK form, and that the runtime supports Ed25519.',
89
+ status: 400,
90
+ service: 'vendure',
91
+ cause: error,
92
+ });
93
+ }
94
+ }
95
+ /** Default assertion lifetime: well inside the 600 s cap, with room for skew. */
96
+ export const DEFAULT_ASSERTION_LIFETIME_SECONDS = 120;
97
+ /**
98
+ * Build and sign the assertion.
99
+ *
100
+ * `jti` is a fresh uuid every time: the platform refuses a replayed one, so a
101
+ * cached assertion is worse than useless. `sub` is the installation id, which is
102
+ * what actually binds the assertion to one tenant — the path parameter alone
103
+ * would not, since the endpoint is unauthenticated. `aud` is deliberately absent:
104
+ * there is no stable audience string a self-hosted platform could publish, and
105
+ * `iss` + `sub` already name exactly one app on one installation.
106
+ */
107
+ export async function signAppAssertion(key, claims) {
108
+ const now = Math.floor((claims.now?.() ?? Date.now()) / 1000);
109
+ const lifetime = claims.lifetimeSeconds ?? DEFAULT_ASSERTION_LIFETIME_SECONDS;
110
+ const header = encodeJson({ alg: 'EdDSA', typ: 'JWT', kid: claims.kid });
111
+ const payload = encodeJson({
112
+ iss: claims.appId,
113
+ sub: claims.installationId,
114
+ iat: now,
115
+ exp: now + lifetime,
116
+ jti: randomId(),
117
+ });
118
+ const signingInput = `${header}.${payload}`;
119
+ const signature = await subtle().sign('Ed25519', key, new TextEncoder().encode(signingInput));
120
+ return `${signingInput}.${base64UrlEncode(new Uint8Array(signature))}`;
121
+ }
122
+ /** A uuid for `jti`. `randomUUID` is available wherever `subtle` is. */
123
+ function randomId() {
124
+ const webcrypto = globalThis.crypto;
125
+ if (webcrypto?.randomUUID)
126
+ return webcrypto.randomUUID();
127
+ // Only reachable on a runtime with `subtle` but no `randomUUID`; 16 random
128
+ // bytes are as unique as a v4 uuid, and the platform treats `jti` as opaque.
129
+ // `Math.random` is NOT a fallback here: a guessable `jti` would let anyone who
130
+ // saw one assertion pre-burn the next one's id and lock the app out.
131
+ if (!webcrypto?.getRandomValues) {
132
+ throw new ValidationError({
133
+ code: 'WEB_CRYPTO_UNAVAILABLE',
134
+ message: 'This runtime provides neither crypto.randomUUID nor crypto.getRandomValues, so a unique assertion id cannot be generated.',
135
+ status: 400,
136
+ service: 'vendure',
137
+ });
138
+ }
139
+ const bytes = webcrypto.getRandomValues(new Uint8Array(16));
140
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
141
+ }
@@ -0,0 +1,36 @@
1
+ import { type AppPrivateKey } from './assertion.js';
2
+ /**
3
+ * Holding, minting and refreshing an installation token (headless spec §6.3).
4
+ *
5
+ * An app never handles the `s69i_` bearer itself: it hands the SDK its private
6
+ * key and installation id, and every call goes out with a live token. The token
7
+ * lasts an hour, so this mints once and refreshes a minute before expiry —
8
+ * early enough that a call is never made with a token that expires mid-flight.
9
+ *
10
+ * Concurrent calls collapse onto ONE mint (`inFlight`). Without it, an app that
11
+ * fires ten requests at startup signs ten assertions and burns ten tokens, and
12
+ * every one of them is a round trip the caller waits for.
13
+ */
14
+ export interface AppCredentialConfig {
15
+ /** The registered app's id, which the assertion carries as `iss`. */
16
+ appId: string;
17
+ /** The `kid` of the key below, as registered with `addAppKey`. */
18
+ kid: string;
19
+ /** PKCS#8 PEM, a JWK, or an already-imported `CryptoKey`. Never leaves the process. */
20
+ privateKey: AppPrivateKey;
21
+ }
22
+ export interface AppCredential {
23
+ /** A live bearer, minting or refreshing if needed. */
24
+ current(): Promise<string>;
25
+ /** The last bearer minted, or `''`. Synchronous, for the transport's principal. */
26
+ peek(): string;
27
+ /** Drop the cached token, so the next call mints a fresh one. */
28
+ invalidate(): void;
29
+ /**
30
+ * Replace `stale`, which the server has just refused. Returns whatever a
31
+ * sibling minted if one already has, so a burst of concurrent 401s collapses
32
+ * onto ONE exchange instead of one per request.
33
+ */
34
+ refresh(stale: string): Promise<string>;
35
+ }
36
+ export declare function createAppCredential(app: AppCredentialConfig, installationId: string, vendureUrl: string, fetchImpl: typeof globalThis.fetch): AppCredential;
@@ -0,0 +1,182 @@
1
+ import { errorFromResponse, TransportError } from '@sixtyninecommerce/core';
2
+ import { importAppPrivateKey, signAppAssertion, } from './assertion.js';
3
+ /** Refresh this long before expiry, so no call goes out on a dying token. */
4
+ const REFRESH_MARGIN_MS = 60_000;
5
+ /**
6
+ * Wall-clock budget for one exchange; see the `signal` below. The SAME 15 s the
7
+ * transport gives every other call — a longer one here meant a caller who
8
+ * budgeted 15 s could block for 45 while a stalled mint ran to its own deadline.
9
+ */
10
+ const EXCHANGE_TIMEOUT_MS = 15_000;
11
+ export function createAppCredential(app, installationId, vendureUrl, fetchImpl) {
12
+ let token = '';
13
+ let expiresAt = 0;
14
+ let inFlight = null;
15
+ /** Bumped by `invalidate()`; a mint from an older generation is not cached. */
16
+ let invalidations = 0;
17
+ /** The dead bearer the in-flight mint is REPLACING, for `refresh()`. */
18
+ let refreshingFrom = null;
19
+ let key = null;
20
+ async function mint() {
21
+ // The generation this mint belongs to. An `invalidate()` that lands while
22
+ // this is in flight bumps it, and the result is then returned to the caller
23
+ // but NOT cached — otherwise the token the server has just killed would be
24
+ // written back over the fresh one and the app would 401 again.
25
+ const generation = invalidations;
26
+ // Imported once and kept: `importKey` is not free, and an app makes this
27
+ // call every hour for the life of the process.
28
+ key ??= importAppPrivateKey(app.privateKey);
29
+ const assertion = await signAppAssertion(await key, {
30
+ appId: app.appId,
31
+ installationId,
32
+ kid: app.kid,
33
+ });
34
+ // Trailing slash stripped, as every other URL in the client is: a
35
+ // `vendureUrl` of `https://api.example.test/` works for every GraphQL call
36
+ // and produced `…test//apps/installations/…` here, which the controller's
37
+ // route does not match — app auth 404s while the rest of the client works.
38
+ const base = vendureUrl.replace(/\/+$/, '');
39
+ const url = `${base}/apps/installations/${encodeURIComponent(installationId)}/access-tokens`;
40
+ let response;
41
+ try {
42
+ response = await fetchImpl(url, {
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/json' },
45
+ body: JSON.stringify({ assertion }),
46
+ // DEADLINED, like every other call the client makes. Without it an
47
+ // exchange that never settles wedges `current()` for the life of the
48
+ // process: the slot it holds is freed only by `invalidate()`, which is
49
+ // only reachable from a 401 — which can never arrive, because no
50
+ // request ever goes out.
51
+ signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS),
52
+ });
53
+ }
54
+ catch (error) {
55
+ // A network failure minting the credential is retryable, and must not be
56
+ // reported as "your key is wrong".
57
+ throw new TransportError({
58
+ code: 'INSTALLATION_TOKEN_UNREACHABLE',
59
+ message: `Could not reach the token exchange at ${url}.`,
60
+ status: 0,
61
+ service: 'vendure',
62
+ retryable: true,
63
+ cause: error,
64
+ });
65
+ }
66
+ if (!response.ok) {
67
+ // problem+json first, legacy second — the exchange answers the same shape
68
+ // every other refusal on this platform does.
69
+ throw await errorFromResponse(response, 'vendure');
70
+ }
71
+ // Read inside the same guard as the request: an abort that fires while the
72
+ // body is still streaming would otherwise escape as a raw `AbortError`,
73
+ // which is not a `PlatformError` — the transport then re-wraps it as a
74
+ // timeout naming a deadline that did not fire.
75
+ let body;
76
+ try {
77
+ body = (await response.json());
78
+ }
79
+ catch (error) {
80
+ throw new TransportError({
81
+ code: 'INSTALLATION_TOKEN_UNREACHABLE',
82
+ message: `The token exchange at ${url} did not answer in full.`,
83
+ status: 0,
84
+ service: 'vendure',
85
+ retryable: true,
86
+ cause: error,
87
+ });
88
+ }
89
+ const minted = typeof body.access_token === 'string' ? body.access_token : '';
90
+ if (minted === '') {
91
+ throw new TransportError({
92
+ code: 'INSTALLATION_TOKEN_MALFORMED',
93
+ message: 'The token exchange answered 200 with no access_token.',
94
+ status: 0,
95
+ service: 'vendure',
96
+ retryable: true,
97
+ });
98
+ }
99
+ // `expires_in` WINS, because it is the only one of the two that does not
100
+ // require the two clocks to agree. `expires_at` is the SERVER's absolute
101
+ // instant; comparing it against this host's `Date.now()` on a client running
102
+ // two minutes slow means refreshing two minutes late in real time — the
103
+ // bearer is already dead, and the gate answers a NON-retryable 401 that
104
+ // nothing here recovers from. The relative form is measured from the moment
105
+ // the response arrived, on this clock, and cannot skew.
106
+ //
107
+ // `expires_at` stays the fallback for a server that sends only the absolute
108
+ // form. A body with neither is treated as already-expiring rather than
109
+ // never-expiring: minting again costs one round trip, while trusting a
110
+ // missing expiry means every call 401s from the hour mark on with nothing to
111
+ // tell the caller why.
112
+ const relative = typeof body.expires_in === 'number' ? Date.now() + body.expires_in * 1000 : Number.NaN;
113
+ const absolute = typeof body.expires_at === 'string' ? Date.parse(body.expires_at) : Number.NaN;
114
+ const expiry = Number.isFinite(relative)
115
+ ? relative
116
+ : Number.isFinite(absolute)
117
+ ? absolute
118
+ : Date.now();
119
+ // Nothing from a discarded generation is cached — not the token, and not
120
+ // its expiry, which would otherwise move the live token's refresh window.
121
+ if (generation !== invalidations)
122
+ return minted;
123
+ expiresAt = expiry;
124
+ token = minted;
125
+ return token;
126
+ }
127
+ /** Start a mint, or join the one already running. */
128
+ function mintOnce() {
129
+ if (inFlight)
130
+ return inFlight;
131
+ // The slot is cleared only by the mint that OWNS it: `invalidate()` also
132
+ // nulls it, and an unconditional `finally` then cleared the slot belonging
133
+ // to the newer mint that had already replaced it.
134
+ const pending = mint().finally(() => {
135
+ if (inFlight === pending) {
136
+ inFlight = null;
137
+ refreshingFrom = null;
138
+ }
139
+ });
140
+ inFlight = pending;
141
+ return pending;
142
+ }
143
+ return {
144
+ async current() {
145
+ if (token !== '' && Date.now() < expiresAt - REFRESH_MARGIN_MS)
146
+ return token;
147
+ return mintOnce();
148
+ },
149
+ peek: () => token,
150
+ async refresh(stale) {
151
+ // A sibling has already replaced it — the commonest case when a burst of
152
+ // concurrent calls all meet the same 401. Its freshness is checked too, so
153
+ // the retry cannot go out on a bearer that has since expired and burn the
154
+ // one attempt it gets.
155
+ if (token !== '' && token !== stale && Date.now() < expiresAt - REFRESH_MARGIN_MS) {
156
+ return token;
157
+ }
158
+ // A mint is already running for a state that is no older than this refusal
159
+ // — either a refresh for this same dead bearer, or one started after an
160
+ // invalidation (`token === ''`, so nothing is cached to be stale). Join it
161
+ // rather than dropping it and starting another: N concurrent 401s would
162
+ // otherwise sign N assertions for the one kill they are all reacting to.
163
+ if (inFlight && (refreshingFrom === stale || token === ''))
164
+ return inFlight;
165
+ token = '';
166
+ expiresAt = 0;
167
+ invalidations += 1;
168
+ inFlight = null;
169
+ refreshingFrom = stale;
170
+ return mintOnce();
171
+ },
172
+ invalidate() {
173
+ token = '';
174
+ expiresAt = 0;
175
+ // Drops any mint already in flight as well: it was started for the
176
+ // credential state that has just been declared dead.
177
+ invalidations += 1;
178
+ inFlight = null;
179
+ refreshingFrom = null;
180
+ },
181
+ };
182
+ }