@sixtyninecommerce/admin 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -1
- package/dist/apps/assertion.d.ts +72 -0
- package/dist/apps/assertion.js +141 -0
- package/dist/apps/credential.d.ts +36 -0
- package/dist/apps/credential.js +182 -0
- package/dist/apps/index.d.ts +106 -0
- package/dist/apps/index.js +92 -0
- package/dist/client.d.ts +22 -4
- package/dist/client.js +115 -7
- package/dist/index.d.ts +5 -1
- package/dist/platform/apps.d.ts +76 -0
- package/dist/platform/apps.js +153 -0
- package/dist/types.d.ts +27 -4
- package/package.json +2 -2
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
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { Transport } from '@sixtyninecommerce/core';
|
|
2
|
+
import type { Call } from '../types.js';
|
|
3
|
+
export declare const APP_FIELDS = "\n id\n slug\n name\n ownerLabel\n declaredPermissions\n declaredEvents\n declaredFieldOwnership\n customerData\n customerDataPurpose\n metafieldDefinitions\n configSchema\n hooks\n headTemplates\n sandboxOnly\n status\n manifestVersion\n createdAt\n updatedAt\n";
|
|
4
|
+
export declare const INSTALLATION_FIELDS = "\n id\n appId\n targetKind\n targetId\n grantedPermissions\n grantedFieldOwnership\n locationIds\n status\n consentVersion\n pendingConsentReasons\n installedAt\n uninstalledAt\n";
|
|
5
|
+
export declare const AVAILABLE_APPS_QUERY = "\n query AvailableApps {\n availableApps { \n id\n slug\n name\n ownerLabel\n declaredPermissions\n declaredEvents\n declaredFieldOwnership\n customerData\n customerDataPurpose\n metafieldDefinitions\n configSchema\n hooks\n headTemplates\n sandboxOnly\n status\n manifestVersion\n createdAt\n updatedAt\n }\n }\n";
|
|
6
|
+
export declare const MY_INSTALLATIONS_QUERY = "\n query MyInstallations {\n myInstallations { \n id\n appId\n targetKind\n targetId\n grantedPermissions\n grantedFieldOwnership\n locationIds\n status\n consentVersion\n pendingConsentReasons\n installedAt\n uninstalledAt\n }\n }\n";
|
|
7
|
+
export declare const INSTALLATION_QUERY = "\n query AppInstallation($id: ID!) {\n appInstallation(id: $id) { \n id\n appId\n targetKind\n targetId\n grantedPermissions\n grantedFieldOwnership\n locationIds\n status\n consentVersion\n pendingConsentReasons\n installedAt\n uninstalledAt\n }\n }\n";
|
|
8
|
+
export declare const INSTALL_MUTATION = "\n mutation InstallApp($input: InstallAppInput!) {\n installApp(input: $input) { \n id\n appId\n targetKind\n targetId\n grantedPermissions\n grantedFieldOwnership\n locationIds\n status\n consentVersion\n pendingConsentReasons\n installedAt\n uninstalledAt\n }\n }\n";
|
|
9
|
+
export declare const APPROVE_MUTATION = "\n mutation ApproveInstallationConsent($id: ID!, $input: ApproveInstallationConsentInput) {\n approveInstallationConsent(id: $id, input: $input) { \n id\n appId\n targetKind\n targetId\n grantedPermissions\n grantedFieldOwnership\n locationIds\n status\n consentVersion\n pendingConsentReasons\n installedAt\n uninstalledAt\n }\n }\n";
|
|
10
|
+
export declare const UNINSTALL_MUTATION = "\n mutation UninstallApp($id: ID!) {\n uninstallApp(id: $id) { \n id\n appId\n targetKind\n targetId\n grantedPermissions\n grantedFieldOwnership\n locationIds\n status\n consentVersion\n pendingConsentReasons\n installedAt\n uninstalledAt\n }\n }\n";
|
|
11
|
+
/** A registered app, as a tenant admin sees it on the consent screen. */
|
|
12
|
+
export interface App {
|
|
13
|
+
id: string;
|
|
14
|
+
slug: string;
|
|
15
|
+
name: string;
|
|
16
|
+
ownerLabel: string;
|
|
17
|
+
/** The ceiling on what an installation may be granted. */
|
|
18
|
+
declaredPermissions: string[];
|
|
19
|
+
declaredEvents: string[];
|
|
20
|
+
/** Normalised `"<entity>.<field>"` claims, e.g. `product.price`. */
|
|
21
|
+
declaredFieldOwnership: string[];
|
|
22
|
+
/** `none` | `identity` | `full`. */
|
|
23
|
+
customerData: string;
|
|
24
|
+
customerDataPurpose: string | null;
|
|
25
|
+
metafieldDefinitions: unknown[];
|
|
26
|
+
configSchema: Record<string, unknown> | null;
|
|
27
|
+
hooks: unknown[];
|
|
28
|
+
headTemplates: unknown[];
|
|
29
|
+
sandboxOnly: boolean;
|
|
30
|
+
/** `active` | `suspended`. */
|
|
31
|
+
status: string;
|
|
32
|
+
manifestVersion: number;
|
|
33
|
+
createdAt: string;
|
|
34
|
+
updatedAt: string;
|
|
35
|
+
}
|
|
36
|
+
export interface AppInstallation {
|
|
37
|
+
id: string;
|
|
38
|
+
appId: string;
|
|
39
|
+
/** `channel` today; `organization` arrives with multi-region. */
|
|
40
|
+
targetKind: string;
|
|
41
|
+
targetId: string;
|
|
42
|
+
grantedPermissions: string[];
|
|
43
|
+
/** Normalised `"<entity>.<field>"` claims, e.g. `product.price`. */
|
|
44
|
+
grantedFieldOwnership: string[];
|
|
45
|
+
/** Null means unrestricted. */
|
|
46
|
+
locationIds: string[] | null;
|
|
47
|
+
/** `active` | `needs_consent` | `suspended` | `uninstalled`. */
|
|
48
|
+
status: string;
|
|
49
|
+
consentVersion: number;
|
|
50
|
+
/** Why consent is pending, in the approver's words. Null unless `needs_consent`. */
|
|
51
|
+
pendingConsentReasons: string[] | null;
|
|
52
|
+
installedAt: string;
|
|
53
|
+
uninstalledAt: string | null;
|
|
54
|
+
}
|
|
55
|
+
/** What an approver agrees to. Every field omitted means "keep what was granted". */
|
|
56
|
+
export interface ApproveConsentInput {
|
|
57
|
+
grantedPermissions?: string[];
|
|
58
|
+
grantedFieldOwnership?: string[];
|
|
59
|
+
/**
|
|
60
|
+
* Replace the stock-location restriction. Omit to keep it; pass `null` to LIFT
|
|
61
|
+
* it entirely — the engine distinguishes the two, so a plain `string[]` would
|
|
62
|
+
* leave a tenant who restricted an app to one warehouse no way back short of
|
|
63
|
+
* uninstalling it.
|
|
64
|
+
*/
|
|
65
|
+
locationIds?: string[] | null;
|
|
66
|
+
}
|
|
67
|
+
export interface InstallAppInput {
|
|
68
|
+
appId: string;
|
|
69
|
+
/** Must be a subset of the app's `declaredPermissions`, and of the caller's own. */
|
|
70
|
+
grantedPermissions?: string[];
|
|
71
|
+
grantedFieldOwnership?: string[];
|
|
72
|
+
/** Restrict the installation to these stock locations. Omit for unrestricted. */
|
|
73
|
+
locationIds?: string[];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Installing and managing apps on the caller's OWN store (headless spec §6.3).
|
|
77
|
+
*
|
|
78
|
+
* Needs `InstallApps` and a tenant context. Every method acts on the active
|
|
79
|
+
* channel — there is no channel parameter, because a channel-id input would have
|
|
80
|
+
* to be permission-checked against a store the caller may not belong to, and the
|
|
81
|
+
* engine refuses that shape for the same reason it does on API tokens.
|
|
82
|
+
*
|
|
83
|
+
* The REGISTRY side (registering apps, keys, suspension, enabling an app for a
|
|
84
|
+
* tenant) lives under `admin.platform.apps` and needs a human SuperAdmin session.
|
|
85
|
+
*/
|
|
86
|
+
export interface AppsModule {
|
|
87
|
+
/** Apps a platform operator has enabled for this store and that are not suspended. */
|
|
88
|
+
list(options?: Call): Promise<App[]>;
|
|
89
|
+
/** Consent to a subset of an app's declarations and install it. */
|
|
90
|
+
install(input: InstallAppInput, options?: Call): Promise<AppInstallation>;
|
|
91
|
+
/** This store's installations, newest first. */
|
|
92
|
+
installations(options?: Call): Promise<AppInstallation[]>;
|
|
93
|
+
installation(id: string, options?: Call): Promise<AppInstallation | null>;
|
|
94
|
+
/**
|
|
95
|
+
* Approve declarations that grew, granting what you name.
|
|
96
|
+
*
|
|
97
|
+
* Growth is granted only by naming it — omit `grants` and the installation
|
|
98
|
+
* keeps exactly what it had, which is the right default for an app that grew
|
|
99
|
+
* an event type or a metafield. Tokens already minted keep the OLD grants
|
|
100
|
+
* until this lands.
|
|
101
|
+
*/
|
|
102
|
+
approveConsent(id: string, grants?: ApproveConsentInput, options?: Call): Promise<AppInstallation>;
|
|
103
|
+
/** Revoke every token and end the grant. The row is kept as provenance. */
|
|
104
|
+
uninstall(id: string, options?: Call): Promise<AppInstallation>;
|
|
105
|
+
}
|
|
106
|
+
export declare function createAppsModule(transport: Transport): AppsModule;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export const APP_FIELDS = /* GraphQL */ `
|
|
2
|
+
id
|
|
3
|
+
slug
|
|
4
|
+
name
|
|
5
|
+
ownerLabel
|
|
6
|
+
declaredPermissions
|
|
7
|
+
declaredEvents
|
|
8
|
+
declaredFieldOwnership
|
|
9
|
+
customerData
|
|
10
|
+
customerDataPurpose
|
|
11
|
+
metafieldDefinitions
|
|
12
|
+
configSchema
|
|
13
|
+
hooks
|
|
14
|
+
headTemplates
|
|
15
|
+
sandboxOnly
|
|
16
|
+
status
|
|
17
|
+
manifestVersion
|
|
18
|
+
createdAt
|
|
19
|
+
updatedAt
|
|
20
|
+
`;
|
|
21
|
+
export const INSTALLATION_FIELDS = /* GraphQL */ `
|
|
22
|
+
id
|
|
23
|
+
appId
|
|
24
|
+
targetKind
|
|
25
|
+
targetId
|
|
26
|
+
grantedPermissions
|
|
27
|
+
grantedFieldOwnership
|
|
28
|
+
locationIds
|
|
29
|
+
status
|
|
30
|
+
consentVersion
|
|
31
|
+
pendingConsentReasons
|
|
32
|
+
installedAt
|
|
33
|
+
uninstalledAt
|
|
34
|
+
`;
|
|
35
|
+
export const AVAILABLE_APPS_QUERY = /* GraphQL */ `
|
|
36
|
+
query AvailableApps {
|
|
37
|
+
availableApps { ${APP_FIELDS} }
|
|
38
|
+
}
|
|
39
|
+
`;
|
|
40
|
+
export const MY_INSTALLATIONS_QUERY = /* GraphQL */ `
|
|
41
|
+
query MyInstallations {
|
|
42
|
+
myInstallations { ${INSTALLATION_FIELDS} }
|
|
43
|
+
}
|
|
44
|
+
`;
|
|
45
|
+
export const INSTALLATION_QUERY = /* GraphQL */ `
|
|
46
|
+
query AppInstallation($id: ID!) {
|
|
47
|
+
appInstallation(id: $id) { ${INSTALLATION_FIELDS} }
|
|
48
|
+
}
|
|
49
|
+
`;
|
|
50
|
+
export const INSTALL_MUTATION = /* GraphQL */ `
|
|
51
|
+
mutation InstallApp($input: InstallAppInput!) {
|
|
52
|
+
installApp(input: $input) { ${INSTALLATION_FIELDS} }
|
|
53
|
+
}
|
|
54
|
+
`;
|
|
55
|
+
export const APPROVE_MUTATION = /* GraphQL */ `
|
|
56
|
+
mutation ApproveInstallationConsent($id: ID!, $input: ApproveInstallationConsentInput) {
|
|
57
|
+
approveInstallationConsent(id: $id, input: $input) { ${INSTALLATION_FIELDS} }
|
|
58
|
+
}
|
|
59
|
+
`;
|
|
60
|
+
export const UNINSTALL_MUTATION = /* GraphQL */ `
|
|
61
|
+
mutation UninstallApp($id: ID!) {
|
|
62
|
+
uninstallApp(id: $id) { ${INSTALLATION_FIELDS} }
|
|
63
|
+
}
|
|
64
|
+
`;
|
|
65
|
+
export function createAppsModule(transport) {
|
|
66
|
+
return {
|
|
67
|
+
async list(options) {
|
|
68
|
+
const data = await transport.graphql({ api: 'admin', query: AVAILABLE_APPS_QUERY }, options);
|
|
69
|
+
return data.availableApps;
|
|
70
|
+
},
|
|
71
|
+
async install(input, options) {
|
|
72
|
+
const data = await transport.graphql({ api: 'admin', query: INSTALL_MUTATION, variables: { input } }, options);
|
|
73
|
+
return data.installApp;
|
|
74
|
+
},
|
|
75
|
+
async installations(options) {
|
|
76
|
+
const data = await transport.graphql({ api: 'admin', query: MY_INSTALLATIONS_QUERY }, options);
|
|
77
|
+
return data.myInstallations;
|
|
78
|
+
},
|
|
79
|
+
async installation(id, options) {
|
|
80
|
+
const data = await transport.graphql({ api: 'admin', query: INSTALLATION_QUERY, variables: { id } }, options);
|
|
81
|
+
return data.appInstallation;
|
|
82
|
+
},
|
|
83
|
+
async approveConsent(id, grants, options) {
|
|
84
|
+
const data = await transport.graphql({ api: 'admin', query: APPROVE_MUTATION, variables: { id, input: grants ?? null } }, options);
|
|
85
|
+
return data.approveInstallationConsent;
|
|
86
|
+
},
|
|
87
|
+
async uninstall(id, options) {
|
|
88
|
+
const data = await transport.graphql({ api: 'admin', query: UNINSTALL_MUTATION, variables: { id } }, options);
|
|
89
|
+
return data.uninstallApp;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { type DeprecationNotice, type RateLimitInfo, type RetryPolicy, type TenantContext, type TenantScope } from '@sixtyninecommerce/core';
|
|
2
2
|
import { type ApiTokensModule } from './api-tokens/index.js';
|
|
3
|
+
import { type AppCredentialConfig } from './apps/credential.js';
|
|
4
|
+
import { type AppsModule } from './apps/index.js';
|
|
3
5
|
import { type AuditModule } from './audit/index.js';
|
|
4
6
|
import { type AuthModule } from './auth/index.js';
|
|
5
7
|
import { type FormsModule } from './content/forms.js';
|
|
6
8
|
import { type HeadsModule } from './heads/index.js';
|
|
7
9
|
import { type LocationsModule } from './locations/index.js';
|
|
8
10
|
import { type MetaModule } from './meta/index.js';
|
|
11
|
+
import { type PlatformAppsModule } from './platform/apps.js';
|
|
9
12
|
import { type PlatformModule } from './platform/provision-store.js';
|
|
10
13
|
import { type RedirectsModule } from './seo/redirects.js';
|
|
11
14
|
import { type ChannelsModule } from './tenant/channels.js';
|
|
@@ -33,7 +36,7 @@ export interface AdminClientConfig {
|
|
|
33
36
|
token: string;
|
|
34
37
|
} | {
|
|
35
38
|
session: SessionConfig;
|
|
36
|
-
};
|
|
39
|
+
} | AppAuthConfig;
|
|
37
40
|
tenant?: Partial<TenantContext> & {
|
|
38
41
|
scope?: TenantScope;
|
|
39
42
|
};
|
|
@@ -42,9 +45,21 @@ export interface AdminClientConfig {
|
|
|
42
45
|
timeoutMs?: number;
|
|
43
46
|
onDeprecation?: (notice: DeprecationNotice) => void;
|
|
44
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* An APP's own credential (headless spec §6.3). The SDK signs an assertion with
|
|
50
|
+
* the app's private key, exchanges it for an `s69i_` bearer, and refreshes that
|
|
51
|
+
* bearer a minute before it expires — an app never handles the token itself.
|
|
52
|
+
*/
|
|
53
|
+
export interface AppAuthConfig {
|
|
54
|
+
app: AppCredentialConfig;
|
|
55
|
+
/** The installation this credential acts as, from `admin.apps.install(...)`. */
|
|
56
|
+
installationId: string;
|
|
57
|
+
}
|
|
45
58
|
export interface AdminClient {
|
|
46
59
|
auth: AuthModule;
|
|
47
60
|
apiTokens: ApiTokensModule;
|
|
61
|
+
/** Installing and managing apps on the ACTIVE store. Needs `InstallApps`. */
|
|
62
|
+
apps: AppsModule;
|
|
48
63
|
audit: AuditModule;
|
|
49
64
|
channels: ChannelsModule;
|
|
50
65
|
content: {
|
|
@@ -64,10 +79,13 @@ export interface AdminClient {
|
|
|
64
79
|
retention: RetentionModule;
|
|
65
80
|
};
|
|
66
81
|
/**
|
|
67
|
-
* Human SuperAdmin surface. Every method refuses an API token
|
|
68
|
-
* request leaves the process
|
|
82
|
+
* Human SuperAdmin surface. Every method refuses an API token or an app
|
|
83
|
+
* credential before the request leaves the process
|
|
84
|
+
* (`PLATFORM_REQUIRES_SESSION`).
|
|
69
85
|
*/
|
|
70
|
-
platform: PlatformModule
|
|
86
|
+
platform: PlatformModule & {
|
|
87
|
+
apps: PlatformAppsModule;
|
|
88
|
+
};
|
|
71
89
|
/** A derived client on another tenant, sharing this one's principal. */
|
|
72
90
|
withTenant(tenant: Partial<TenantContext> & {
|
|
73
91
|
scope?: TenantScope;
|
package/dist/client.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { createChannelResolver, createTransport, mergeTenant, UNSCOPED_TENANT, ValidationError, } from '@sixtyninecommerce/core';
|
|
2
2
|
import { createApiTokensModule } from './api-tokens/index.js';
|
|
3
|
+
import { createAppCredential, } from './apps/credential.js';
|
|
4
|
+
import { createAppsModule } from './apps/index.js';
|
|
3
5
|
import { createAuditModule } from './audit/index.js';
|
|
4
6
|
import { createAuthModule } from './auth/index.js';
|
|
5
7
|
import { createFormsModule } from './content/forms.js';
|
|
6
8
|
import { createHeadsModule } from './heads/index.js';
|
|
7
9
|
import { createLocationsModule } from './locations/index.js';
|
|
8
10
|
import { createMetaModule } from './meta/index.js';
|
|
11
|
+
import { createPlatformAppsModule } from './platform/apps.js';
|
|
9
12
|
import { createPlatformModule } from './platform/provision-store.js';
|
|
10
13
|
import { createRedirectsModule } from './seo/redirects.js';
|
|
11
14
|
import { createChannelsModule } from './tenant/channels.js';
|
|
@@ -16,7 +19,10 @@ import { createRetentionModule } from './tenant/retention.js';
|
|
|
16
19
|
* the dashboard is — so the refusal is specifically about a long-lived token.
|
|
17
20
|
*/
|
|
18
21
|
function assertNotInBrowser(auth) {
|
|
19
|
-
|
|
22
|
+
// An app's PRIVATE KEY is the strongest credential this SDK accepts — it mints
|
|
23
|
+
// tokens rather than being one — so it is refused in a browser exactly as a
|
|
24
|
+
// long-lived token is, and for the same reason.
|
|
25
|
+
if (!('token' in auth) && !('app' in auth))
|
|
20
26
|
return;
|
|
21
27
|
// Indexed off a typed view of globalThis: this package compiles without the
|
|
22
28
|
// DOM lib on purpose, so that a stray `document.` reference cannot slip in.
|
|
@@ -24,9 +30,10 @@ function assertNotInBrowser(auth) {
|
|
|
24
30
|
const inBrowser = runtime.window !== undefined && runtime.document !== undefined;
|
|
25
31
|
if (!inBrowser)
|
|
26
32
|
return;
|
|
33
|
+
const credential = 'app' in auth ? "An app's private key" : 'An API token';
|
|
27
34
|
throw new ValidationError({
|
|
28
35
|
code: 'CREDENTIAL_IN_BROWSER',
|
|
29
|
-
message:
|
|
36
|
+
message: `${credential} must never be constructed in a browser — it would ship the credential to every visitor. Use a session bearer in the browser and keep machine credentials server-side.`,
|
|
30
37
|
status: 400,
|
|
31
38
|
service: 'vendure',
|
|
32
39
|
});
|
|
@@ -140,15 +147,101 @@ export function createAdminClient(config) {
|
|
|
140
147
|
});
|
|
141
148
|
if ('session' in config.auth)
|
|
142
149
|
assertSessionIsWritable(config.auth.session);
|
|
150
|
+
else if ('app' in config.auth)
|
|
151
|
+
assertAppCredentialIsPresent(config.auth);
|
|
143
152
|
else
|
|
144
153
|
assertTokenIsPresent(config.auth);
|
|
145
154
|
// Shared by every client `withTenant` derives, because the session store is.
|
|
146
155
|
// `rotations` lets `login()` tell a bearer THIS sign-in produced apart from
|
|
147
156
|
// one that merely sits in the slot already.
|
|
148
157
|
const shared = { rotations: 0 };
|
|
149
|
-
|
|
158
|
+
// One credential per CLIENT, shared with everything `withTenant` derives:
|
|
159
|
+
// the token is minted for an installation, and an installation is one app on
|
|
160
|
+
// one store — deriving a tenant does not change which credential it is.
|
|
161
|
+
const credential = 'app' in config.auth ? appCredentialFor(config, config.auth) : undefined;
|
|
162
|
+
return buildClient(config, config.tenant, resolver, shared, credential);
|
|
150
163
|
}
|
|
151
|
-
|
|
164
|
+
/**
|
|
165
|
+
* Nothing about an app credential is readable live (unlike `auth.token`, which
|
|
166
|
+
* may be a getter over a secret store), so every field is checked at
|
|
167
|
+
* construction rather than on the call that would have used it.
|
|
168
|
+
*/
|
|
169
|
+
function assertAppCredentialIsPresent(auth) {
|
|
170
|
+
const missing = [
|
|
171
|
+
isPresent(auth.app?.appId) ? null : 'app.appId',
|
|
172
|
+
isPresent(auth.app?.kid) ? null : 'app.kid',
|
|
173
|
+
auth.app?.privateKey ? null : 'app.privateKey',
|
|
174
|
+
isPresent(auth.installationId) ? null : 'installationId',
|
|
175
|
+
].filter((field) => field !== null);
|
|
176
|
+
if (missing.length === 0)
|
|
177
|
+
return;
|
|
178
|
+
throw new ValidationError({
|
|
179
|
+
code: 'APP_CREDENTIAL_INCOMPLETE',
|
|
180
|
+
message: `An app credential needs ${missing.join(', ')}. Without them no assertion can be signed and every call would go out unauthenticated.`,
|
|
181
|
+
status: 400,
|
|
182
|
+
service: 'vendure',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
function appCredentialFor(config, auth) {
|
|
186
|
+
// The exchange is fetched DIRECTLY, never through the wrapper below: that
|
|
187
|
+
// wrapper asks this credential for a bearer, so routing the mint through it
|
|
188
|
+
// would recurse forever on the very first call.
|
|
189
|
+
const baseFetch = config.fetch ?? globalThis.fetch;
|
|
190
|
+
return createAppCredential(auth.app, auth.installationId, config.urls.vendureUrl, baseFetch);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Every request an app makes goes out with a LIVE bearer.
|
|
194
|
+
*
|
|
195
|
+
* Done in `fetch` rather than in `principal` because minting is asynchronous and
|
|
196
|
+
* `principal` is not: a synchronous getter cannot wait for an exchange. The
|
|
197
|
+
* header the transport built is overwritten here, which is also what makes the
|
|
198
|
+
* first call work — at that point nothing has been minted and `peek()` is empty.
|
|
199
|
+
*/
|
|
200
|
+
function appFetch(credential, base) {
|
|
201
|
+
const send = async (input, init, bearer) => {
|
|
202
|
+
const headers = { ...(init?.headers ?? {}) };
|
|
203
|
+
headers.Authorization = `Bearer ${bearer}`;
|
|
204
|
+
return base(input, { ...init, headers });
|
|
205
|
+
};
|
|
206
|
+
return async (input, init) => {
|
|
207
|
+
// No try/catch: a credential failure must reach the caller AS ITSELF, and it
|
|
208
|
+
// already does. `createTransport`'s `send()` opens its catch with
|
|
209
|
+
// `if (error instanceof PlatformError) throw error`, so the refusal the
|
|
210
|
+
// exchange returned — built by `errorFromResponse`, carrying the server's
|
|
211
|
+
// own `trace_id` — passes through untouched. Catching it here to re-emit a
|
|
212
|
+
// synthesized problem+json would only lose that trace id and turn a
|
|
213
|
+
// status-0 transport failure into a 503.
|
|
214
|
+
const bearer = await credential.current();
|
|
215
|
+
const response = await send(input, init, bearer);
|
|
216
|
+
// A 401 on a token this SDK believes is live means the SERVER killed it —
|
|
217
|
+
// an app suspended and resumed, a key revoked, a tenant re-consenting — and
|
|
218
|
+
// the cached bearer can be up to an hour from its natural expiry. Without
|
|
219
|
+
// this the app is simply down for that hour: `PermissionDeniedError` is not
|
|
220
|
+
// retryable, and nothing else ever calls `invalidate()`. Exactly ONE retry,
|
|
221
|
+
// and only when a fresh bearer actually differs, so a genuinely
|
|
222
|
+
// unauthorised call cannot become an exchange loop.
|
|
223
|
+
if (response.status !== 401)
|
|
224
|
+
return response;
|
|
225
|
+
// The 401 body is read and discarded: an unread body holds its socket out of
|
|
226
|
+
// the keep-alive pool, and this one is replaced by the retry's.
|
|
227
|
+
void response
|
|
228
|
+
.clone()
|
|
229
|
+
.text()
|
|
230
|
+
.catch(() => undefined);
|
|
231
|
+
// `refresh`, not invalidate-then-current: it returns a bearer a sibling has
|
|
232
|
+
// already minted, and joins a refresh already running for this same dead
|
|
233
|
+
// one, so a burst of concurrent 401s costs ONE exchange rather than one per
|
|
234
|
+
// request.
|
|
235
|
+
const fresh = await credential.refresh(bearer);
|
|
236
|
+
// Compared against the bearer THIS request used: an identical token back
|
|
237
|
+
// from the exchange (a caching proxy in front of it) must end here rather
|
|
238
|
+
// than retry forever.
|
|
239
|
+
if (fresh === bearer)
|
|
240
|
+
return response;
|
|
241
|
+
return send(input, init, fresh);
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
function buildClient(config, overrides, resolver, shared, credential) {
|
|
152
245
|
// No scope configured leaves the client on UNSCOPED_TENANT, which only the
|
|
153
246
|
// auth documents may use; the transport refuses everything else with
|
|
154
247
|
// TENANT_REQUIRED (spec §15.2).
|
|
@@ -160,6 +253,12 @@ function buildClient(config, overrides, resolver, shared) {
|
|
|
160
253
|
channels: resolver,
|
|
161
254
|
tenant,
|
|
162
255
|
principal: () => {
|
|
256
|
+
// The bearer the LAST mint produced, which `appFetch` overwrites with a
|
|
257
|
+
// live one before the request leaves. Empty on the very first call, and
|
|
258
|
+
// deliberately not refused for it: an app credential is complete or it
|
|
259
|
+
// was rejected at construction.
|
|
260
|
+
if (credential)
|
|
261
|
+
return { kind: 'bearer', token: credential.peek() };
|
|
163
262
|
if (!store) {
|
|
164
263
|
// Read live, so a getter over a request context follows the request
|
|
165
264
|
// being served — and refuse an empty one HERE rather than sending an
|
|
@@ -191,7 +290,12 @@ function buildClient(config, overrides, resolver, shared) {
|
|
|
191
290
|
},
|
|
192
291
|
};
|
|
193
292
|
},
|
|
194
|
-
|
|
293
|
+
// Every app request carries a freshly minted bearer; see `appFetch`.
|
|
294
|
+
...(credential
|
|
295
|
+
? { fetch: appFetch(credential, config.fetch ?? globalThis.fetch) }
|
|
296
|
+
: config.fetch
|
|
297
|
+
? { fetch: config.fetch }
|
|
298
|
+
: {}),
|
|
195
299
|
...(config.retry ? { retry: config.retry } : {}),
|
|
196
300
|
...(config.timeoutMs === undefined ? {} : { timeoutMs: config.timeoutMs }),
|
|
197
301
|
...(config.onDeprecation ? { onDeprecation: config.onDeprecation } : {}),
|
|
@@ -219,6 +323,7 @@ function buildClient(config, overrides, resolver, shared) {
|
|
|
219
323
|
},
|
|
220
324
|
}),
|
|
221
325
|
apiTokens: createApiTokensModule(transport),
|
|
326
|
+
apps: createAppsModule(transport),
|
|
222
327
|
audit: createAuditModule(transport),
|
|
223
328
|
channels: createChannelsModule(resolver),
|
|
224
329
|
content: { forms: createFormsModule(transport) },
|
|
@@ -227,7 +332,10 @@ function buildClient(config, overrides, resolver, shared) {
|
|
|
227
332
|
meta: createMetaModule(transport),
|
|
228
333
|
seo: { redirects: createRedirectsModule(transport) },
|
|
229
334
|
tenant: { retention: createRetentionModule(transport) },
|
|
230
|
-
platform:
|
|
335
|
+
platform: {
|
|
336
|
+
...createPlatformModule(transport, config),
|
|
337
|
+
apps: createPlatformAppsModule(transport, config),
|
|
338
|
+
},
|
|
231
339
|
withTenant(next) {
|
|
232
340
|
// `mergeTenant`, not a spread: it merges `actor` FIELD BY FIELD, so a
|
|
233
341
|
// derived client that names only a registerRef keeps the staffRef this
|
|
@@ -238,7 +346,7 @@ function buildClient(config, overrides, resolver, shared) {
|
|
|
238
346
|
// Resolver and rotation count are shared, and the session store IS the
|
|
239
347
|
// caller's object — so a derived client reuses the channel cache and the
|
|
240
348
|
// bearer this one signed in with, in both directions and in either order.
|
|
241
|
-
return buildClient(config, mergeTenant(tenant, next), resolver, shared);
|
|
349
|
+
return buildClient(config, mergeTenant(tenant, next), resolver, shared, credential);
|
|
242
350
|
},
|
|
243
351
|
get rateLimit() {
|
|
244
352
|
return transport.rateLimit;
|
package/dist/index.d.ts
CHANGED
|
@@ -25,15 +25,19 @@ export type { DeprecationNotice, Page, PageMeta, RateLimitInfo, RetryPolicy, } f
|
|
|
25
25
|
*/
|
|
26
26
|
export { AuthenticationError, BusinessRuleError, ConflictError, type ErrorDetail, NotFoundError, PermissionDeniedError, PlatformError, type ProblemDetails, RateLimitedError, TransportError, UpstreamError, ValidationError, } from '@sixtyninecommerce/core';
|
|
27
27
|
export type { ApiTokensModule, CreatedApiToken } from './api-tokens/index.js';
|
|
28
|
+
export type { AppCryptoKey, AppPrivateKey } from './apps/assertion.js';
|
|
29
|
+
export type { AppCredential, AppCredentialConfig } from './apps/credential.js';
|
|
30
|
+
export type { App, AppInstallation, ApproveConsentInput, AppsModule, InstallAppInput, } from './apps/index.js';
|
|
28
31
|
export type { AuditCall, AuditCallFilter, AuditCorrelation, AuditEvent, AuditEventFilter, AuditModule, } from './audit/index.js';
|
|
29
32
|
export type { AuthModule } from './auth/index.js';
|
|
30
|
-
export { type AdminClient, type AdminClientConfig, createAdminClient, type SessionConfig, } from './client.js';
|
|
33
|
+
export { type AdminClient, type AdminClientConfig, type AppAuthConfig, createAdminClient, type SessionConfig, } from './client.js';
|
|
31
34
|
export type { Form, FormField, FormFieldInput, FormFieldTranslation, FormFieldTranslationInput, FormInput, FormsModule, FormTranslation, FormTranslationInput, } from './content/forms.js';
|
|
32
35
|
export { AdminProjectionError } from './errors.js';
|
|
33
36
|
export type { CreateHeadInput, Head, HeadsModule, UpdateHeadInput, } from './heads/index.js';
|
|
34
37
|
export type { LocationProfile, LocationStockLocation, LocationsModule, OpeningHours, OpeningHoursDay, OpeningHoursInterval, StockLocationAddress, StockLocationAddressInput, UpsertLocationProfileInput, } from './locations/index.js';
|
|
35
38
|
export { OPENING_HOURS_DAYS } from './locations/index.js';
|
|
36
39
|
export type { ApiTokenTemplate, JsonSchema, LabelMap, MetaCatalog, MetaCatalogLabels, MetaCatalogPermissions, MetaCatalogPlugins, MetaCatalogTransitions, MetaModule, PermissionGroup, PluginCatalogEntry, RoleTemplate, StatusMetaMap, StatusTone, TransitionMap, } from './meta/index.js';
|
|
40
|
+
export type { AppKey, PlatformAppsModule, RegisterAppResult, RegisteredApp, } from './platform/apps.js';
|
|
37
41
|
export { requireHumanSession } from './platform/guard.js';
|
|
38
42
|
export type { JobHandle, PlatformModule, ProvisionProfile, ProvisionRun, ProvisionRunStatus, ProvisionStep, ProvisionStepStatus, ProvisionStoreInput, ProvisionStoreResult, WaitOptions, } from './platform/provision-store.js';
|
|
39
43
|
export { PROVISION_PROFILES } from './platform/provision-store.js';
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Transport } from '@sixtyninecommerce/core';
|
|
2
|
+
import { type App } from '../apps/index.js';
|
|
3
|
+
import type { AdminClientConfig } from '../client.js';
|
|
4
|
+
import type { Call } from '../types.js';
|
|
5
|
+
export declare const APPS_QUERY = "\n query Apps($options: AppListOptions) {\n apps(options: $options) {\n totalItems\n items { \n \n id\n slug\n name\n ownerLabel\n declaredPermissions\n declaredEvents\n declaredFieldOwnership\n customerData\n customerDataPurpose\n metafieldDefinitions\n configSchema\n hooks\n headTemplates\n sandboxOnly\n status\n manifestVersion\n createdAt\n updatedAt\n\n keys {\n id\n kid\n alg\n publicKey\n revokedAt\n createdAt\n }\n enabledChannelIds\n }\n }\n }\n";
|
|
6
|
+
export declare const APP_QUERY = "\n query App($id: ID!) {\n app(id: $id) { \n \n id\n slug\n name\n ownerLabel\n declaredPermissions\n declaredEvents\n declaredFieldOwnership\n customerData\n customerDataPurpose\n metafieldDefinitions\n configSchema\n hooks\n headTemplates\n sandboxOnly\n status\n manifestVersion\n createdAt\n updatedAt\n\n keys {\n id\n kid\n alg\n publicKey\n revokedAt\n createdAt\n }\n enabledChannelIds\n }\n }\n";
|
|
7
|
+
export declare const REGISTER_MUTATION = "\n mutation RegisterApp($manifest: JSON!) {\n registerApp(manifest: $manifest) { \n created\n consentRequiredInstallationIds\n consentReasons\n app { \n \n id\n slug\n name\n ownerLabel\n declaredPermissions\n declaredEvents\n declaredFieldOwnership\n customerData\n customerDataPurpose\n metafieldDefinitions\n configSchema\n hooks\n headTemplates\n sandboxOnly\n status\n manifestVersion\n createdAt\n updatedAt\n\n keys {\n id\n kid\n alg\n publicKey\n revokedAt\n createdAt\n }\n enabledChannelIds\n }\n }\n }\n";
|
|
8
|
+
export declare const UPDATE_MUTATION = "\n mutation UpdateApp($id: ID!, $manifest: JSON!) {\n updateApp(id: $id, manifest: $manifest) { \n created\n consentRequiredInstallationIds\n consentReasons\n app { \n \n id\n slug\n name\n ownerLabel\n declaredPermissions\n declaredEvents\n declaredFieldOwnership\n customerData\n customerDataPurpose\n metafieldDefinitions\n configSchema\n hooks\n headTemplates\n sandboxOnly\n status\n manifestVersion\n createdAt\n updatedAt\n\n keys {\n id\n kid\n alg\n publicKey\n revokedAt\n createdAt\n }\n enabledChannelIds\n }\n }\n }\n";
|
|
9
|
+
export declare const ADD_KEY_MUTATION = "\n mutation AddAppKey($appId: ID!, $kid: String!, $alg: String!, $publicKey: String!) {\n addAppKey(appId: $appId, kid: $kid, alg: $alg, publicKey: $publicKey) {\n id\n kid\n alg\n publicKey\n revokedAt\n createdAt\n }\n }\n";
|
|
10
|
+
export declare const REVOKE_KEY_MUTATION = "\n mutation RevokeAppKey($appId: ID!, $kid: String!) {\n revokeAppKey(appId: $appId, kid: $kid) {\n id\n kid\n alg\n revokedAt\n }\n }\n";
|
|
11
|
+
export declare const SUSPEND_MUTATION = "\n mutation SuspendApp($id: ID!) {\n suspendApp(id: $id) { \n \n id\n slug\n name\n ownerLabel\n declaredPermissions\n declaredEvents\n declaredFieldOwnership\n customerData\n customerDataPurpose\n metafieldDefinitions\n configSchema\n hooks\n headTemplates\n sandboxOnly\n status\n manifestVersion\n createdAt\n updatedAt\n\n keys {\n id\n kid\n alg\n publicKey\n revokedAt\n createdAt\n }\n enabledChannelIds\n }\n }\n";
|
|
12
|
+
export declare const RESUME_MUTATION = "\n mutation ResumeApp($id: ID!) {\n resumeApp(id: $id) { \n \n id\n slug\n name\n ownerLabel\n declaredPermissions\n declaredEvents\n declaredFieldOwnership\n customerData\n customerDataPurpose\n metafieldDefinitions\n configSchema\n hooks\n headTemplates\n sandboxOnly\n status\n manifestVersion\n createdAt\n updatedAt\n\n keys {\n id\n kid\n alg\n publicKey\n revokedAt\n createdAt\n }\n enabledChannelIds\n }\n }\n";
|
|
13
|
+
export declare const ENABLE_MUTATION = "\n mutation EnableAppForTenant($appId: ID!, $channelId: ID!) {\n enableAppForTenant(appId: $appId, channelId: $channelId) { \n \n id\n slug\n name\n ownerLabel\n declaredPermissions\n declaredEvents\n declaredFieldOwnership\n customerData\n customerDataPurpose\n metafieldDefinitions\n configSchema\n hooks\n headTemplates\n sandboxOnly\n status\n manifestVersion\n createdAt\n updatedAt\n\n keys {\n id\n kid\n alg\n publicKey\n revokedAt\n createdAt\n }\n enabledChannelIds\n }\n }\n";
|
|
14
|
+
export declare const DISABLE_MUTATION = "\n mutation DisableAppForTenant($appId: ID!, $channelId: ID!) {\n disableAppForTenant(appId: $appId, channelId: $channelId) { \n \n id\n slug\n name\n ownerLabel\n declaredPermissions\n declaredEvents\n declaredFieldOwnership\n customerData\n customerDataPurpose\n metafieldDefinitions\n configSchema\n hooks\n headTemplates\n sandboxOnly\n status\n manifestVersion\n createdAt\n updatedAt\n\n keys {\n id\n kid\n alg\n publicKey\n revokedAt\n createdAt\n }\n enabledChannelIds\n }\n }\n";
|
|
15
|
+
/** A registered public key. Only public material is ever returned. */
|
|
16
|
+
export interface AppKey {
|
|
17
|
+
id: string;
|
|
18
|
+
kid: string;
|
|
19
|
+
/** `EdDSA` | `RS256`. */
|
|
20
|
+
alg: string;
|
|
21
|
+
publicKey: string;
|
|
22
|
+
revokedAt: string | null;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
}
|
|
25
|
+
export interface RegisteredApp extends App {
|
|
26
|
+
keys: AppKey[];
|
|
27
|
+
/** Channels a SuperAdmin has enabled this app for. */
|
|
28
|
+
enabledChannelIds: string[];
|
|
29
|
+
}
|
|
30
|
+
export interface RegisterAppResult {
|
|
31
|
+
app: RegisteredApp;
|
|
32
|
+
/** False when the manifest updated an app that was already registered. */
|
|
33
|
+
created: boolean;
|
|
34
|
+
/** Installations this registration moved to `needs_consent`. */
|
|
35
|
+
consentRequiredInstallationIds: string[];
|
|
36
|
+
/** Why, in the words their approvers will read. */
|
|
37
|
+
consentReasons: string[];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The app REGISTRY — the platform operator's surface (headless spec §6.3, §6.12).
|
|
41
|
+
*
|
|
42
|
+
* Human SuperAdmin session only, enforced twice over: the engine gates every
|
|
43
|
+
* operation on Vendure's `SuperAdmin` permission — not a custom one, because a
|
|
44
|
+
* custom permission is evaluated against the ACTIVE channel and every mutation
|
|
45
|
+
* here is platform-wide — and the SDK refuses to put a machine credential on the
|
|
46
|
+
* wire towards it at all (`requireHumanSession`). An app that could register
|
|
47
|
+
* apps could declare anything and enable itself.
|
|
48
|
+
*/
|
|
49
|
+
export interface PlatformAppsModule {
|
|
50
|
+
list(options?: {
|
|
51
|
+
skip?: number;
|
|
52
|
+
take?: number;
|
|
53
|
+
}, call?: Call): Promise<{
|
|
54
|
+
items: RegisteredApp[];
|
|
55
|
+
totalItems: number;
|
|
56
|
+
}>;
|
|
57
|
+
get(id: string, call?: Call): Promise<RegisteredApp | null>;
|
|
58
|
+
/** Ingest a `sixtynine.app.json`. Upserts by slug: a known slug is an update. */
|
|
59
|
+
register(manifest: Record<string, unknown>, call?: Call): Promise<RegisterAppResult>;
|
|
60
|
+
/** The same ingest addressed by id; refuses a manifest whose slug is another app's. */
|
|
61
|
+
update(id: string, manifest: Record<string, unknown>, call?: Call): Promise<RegisterAppResult>;
|
|
62
|
+
addKey(input: {
|
|
63
|
+
appId: string;
|
|
64
|
+
kid: string;
|
|
65
|
+
alg: string;
|
|
66
|
+
publicKey: string;
|
|
67
|
+
}, call?: Call): Promise<AppKey>;
|
|
68
|
+
revokeKey(appId: string, kid: string, call?: Call): Promise<AppKey>;
|
|
69
|
+
/** Suspend platform-wide and kill every installation token in one action. */
|
|
70
|
+
suspend(id: string, call?: Call): Promise<RegisteredApp>;
|
|
71
|
+
resume(id: string, call?: Call): Promise<RegisteredApp>;
|
|
72
|
+
/** Offer an app to one tenant. A tenant admin can only install what is enabled. */
|
|
73
|
+
enableForTenant(appId: string, channelId: string, call?: Call): Promise<RegisteredApp>;
|
|
74
|
+
disableForTenant(appId: string, channelId: string, call?: Call): Promise<RegisteredApp>;
|
|
75
|
+
}
|
|
76
|
+
export declare function createPlatformAppsModule(transport: Transport, config: AdminClientConfig): PlatformAppsModule;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { APP_FIELDS } from '../apps/index.js';
|
|
2
|
+
import { requireHumanSession } from './guard.js';
|
|
3
|
+
const APP_WITH_KEYS_FIELDS = /* GraphQL */ `
|
|
4
|
+
${APP_FIELDS}
|
|
5
|
+
keys {
|
|
6
|
+
id
|
|
7
|
+
kid
|
|
8
|
+
alg
|
|
9
|
+
publicKey
|
|
10
|
+
revokedAt
|
|
11
|
+
createdAt
|
|
12
|
+
}
|
|
13
|
+
enabledChannelIds
|
|
14
|
+
`;
|
|
15
|
+
const REGISTER_RESULT_FIELDS = /* GraphQL */ `
|
|
16
|
+
created
|
|
17
|
+
consentRequiredInstallationIds
|
|
18
|
+
consentReasons
|
|
19
|
+
app { ${APP_WITH_KEYS_FIELDS} }
|
|
20
|
+
`;
|
|
21
|
+
export const APPS_QUERY = /* GraphQL */ `
|
|
22
|
+
query Apps($options: AppListOptions) {
|
|
23
|
+
apps(options: $options) {
|
|
24
|
+
totalItems
|
|
25
|
+
items { ${APP_WITH_KEYS_FIELDS} }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
`;
|
|
29
|
+
export const APP_QUERY = /* GraphQL */ `
|
|
30
|
+
query App($id: ID!) {
|
|
31
|
+
app(id: $id) { ${APP_WITH_KEYS_FIELDS} }
|
|
32
|
+
}
|
|
33
|
+
`;
|
|
34
|
+
export const REGISTER_MUTATION = /* GraphQL */ `
|
|
35
|
+
mutation RegisterApp($manifest: JSON!) {
|
|
36
|
+
registerApp(manifest: $manifest) { ${REGISTER_RESULT_FIELDS} }
|
|
37
|
+
}
|
|
38
|
+
`;
|
|
39
|
+
export const UPDATE_MUTATION = /* GraphQL */ `
|
|
40
|
+
mutation UpdateApp($id: ID!, $manifest: JSON!) {
|
|
41
|
+
updateApp(id: $id, manifest: $manifest) { ${REGISTER_RESULT_FIELDS} }
|
|
42
|
+
}
|
|
43
|
+
`;
|
|
44
|
+
export const ADD_KEY_MUTATION = /* GraphQL */ `
|
|
45
|
+
mutation AddAppKey($appId: ID!, $kid: String!, $alg: String!, $publicKey: String!) {
|
|
46
|
+
addAppKey(appId: $appId, kid: $kid, alg: $alg, publicKey: $publicKey) {
|
|
47
|
+
id
|
|
48
|
+
kid
|
|
49
|
+
alg
|
|
50
|
+
publicKey
|
|
51
|
+
revokedAt
|
|
52
|
+
createdAt
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
`;
|
|
56
|
+
export const REVOKE_KEY_MUTATION = /* GraphQL */ `
|
|
57
|
+
mutation RevokeAppKey($appId: ID!, $kid: String!) {
|
|
58
|
+
revokeAppKey(appId: $appId, kid: $kid) {
|
|
59
|
+
id
|
|
60
|
+
kid
|
|
61
|
+
alg
|
|
62
|
+
revokedAt
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
`;
|
|
66
|
+
export const SUSPEND_MUTATION = /* GraphQL */ `
|
|
67
|
+
mutation SuspendApp($id: ID!) {
|
|
68
|
+
suspendApp(id: $id) { ${APP_WITH_KEYS_FIELDS} }
|
|
69
|
+
}
|
|
70
|
+
`;
|
|
71
|
+
export const RESUME_MUTATION = /* GraphQL */ `
|
|
72
|
+
mutation ResumeApp($id: ID!) {
|
|
73
|
+
resumeApp(id: $id) { ${APP_WITH_KEYS_FIELDS} }
|
|
74
|
+
}
|
|
75
|
+
`;
|
|
76
|
+
export const ENABLE_MUTATION = /* GraphQL */ `
|
|
77
|
+
mutation EnableAppForTenant($appId: ID!, $channelId: ID!) {
|
|
78
|
+
enableAppForTenant(appId: $appId, channelId: $channelId) { ${APP_WITH_KEYS_FIELDS} }
|
|
79
|
+
}
|
|
80
|
+
`;
|
|
81
|
+
export const DISABLE_MUTATION = /* GraphQL */ `
|
|
82
|
+
mutation DisableAppForTenant($appId: ID!, $channelId: ID!) {
|
|
83
|
+
disableAppForTenant(appId: $appId, channelId: $channelId) { ${APP_WITH_KEYS_FIELDS} }
|
|
84
|
+
}
|
|
85
|
+
`;
|
|
86
|
+
export function createPlatformAppsModule(transport, config) {
|
|
87
|
+
const guarded = async (run) => {
|
|
88
|
+
requireHumanSession(config);
|
|
89
|
+
return run();
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
list(options, call) {
|
|
93
|
+
return guarded(async () => {
|
|
94
|
+
const data = await transport.graphql({ api: 'admin', query: APPS_QUERY, variables: { options: options ?? null } }, call);
|
|
95
|
+
return data.apps;
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
get(id, call) {
|
|
99
|
+
return guarded(async () => {
|
|
100
|
+
const data = await transport.graphql({ api: 'admin', query: APP_QUERY, variables: { id } }, call);
|
|
101
|
+
return data.app;
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
register(manifest, call) {
|
|
105
|
+
return guarded(async () => {
|
|
106
|
+
const data = await transport.graphql({ api: 'admin', query: REGISTER_MUTATION, variables: { manifest } }, call);
|
|
107
|
+
return data.registerApp;
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
update(id, manifest, call) {
|
|
111
|
+
return guarded(async () => {
|
|
112
|
+
const data = await transport.graphql({ api: 'admin', query: UPDATE_MUTATION, variables: { id, manifest } }, call);
|
|
113
|
+
return data.updateApp;
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
addKey(input, call) {
|
|
117
|
+
return guarded(async () => {
|
|
118
|
+
const data = await transport.graphql({ api: 'admin', query: ADD_KEY_MUTATION, variables: input }, call);
|
|
119
|
+
return data.addAppKey;
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
revokeKey(appId, kid, call) {
|
|
123
|
+
return guarded(async () => {
|
|
124
|
+
const data = await transport.graphql({ api: 'admin', query: REVOKE_KEY_MUTATION, variables: { appId, kid } }, call);
|
|
125
|
+
return data.revokeAppKey;
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
suspend(id, call) {
|
|
129
|
+
return guarded(async () => {
|
|
130
|
+
const data = await transport.graphql({ api: 'admin', query: SUSPEND_MUTATION, variables: { id } }, call);
|
|
131
|
+
return data.suspendApp;
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
resume(id, call) {
|
|
135
|
+
return guarded(async () => {
|
|
136
|
+
const data = await transport.graphql({ api: 'admin', query: RESUME_MUTATION, variables: { id } }, call);
|
|
137
|
+
return data.resumeApp;
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
enableForTenant(appId, channelId, call) {
|
|
141
|
+
return guarded(async () => {
|
|
142
|
+
const data = await transport.graphql({ api: 'admin', query: ENABLE_MUTATION, variables: { appId, channelId } }, call);
|
|
143
|
+
return data.enableAppForTenant;
|
|
144
|
+
});
|
|
145
|
+
},
|
|
146
|
+
disableForTenant(appId, channelId, call) {
|
|
147
|
+
return guarded(async () => {
|
|
148
|
+
const data = await transport.graphql({ api: 'admin', query: DISABLE_MUTATION, variables: { appId, channelId } }, call);
|
|
149
|
+
return data.disableAppForTenant;
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -48,9 +48,12 @@ export interface Me {
|
|
|
48
48
|
}
|
|
49
49
|
/** What `auth.whoami()` reports about the calling credential. */
|
|
50
50
|
export interface WhoAmI {
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
/**
|
|
52
|
+
* A MACHINE principal (`api_token`, `app_installation`) never discloses the
|
|
53
|
+
* hidden administrator behind it.
|
|
54
|
+
*/
|
|
55
|
+
kind: 'api_token' | 'user' | 'app_installation';
|
|
56
|
+
/** Present only for a personal API token; null for every other principal. */
|
|
54
57
|
token: {
|
|
55
58
|
id: string;
|
|
56
59
|
name: string;
|
|
@@ -60,7 +63,27 @@ export interface WhoAmI {
|
|
|
60
63
|
lastUsedAt: string | null;
|
|
61
64
|
scopeKind: string;
|
|
62
65
|
} | null;
|
|
63
|
-
/** Present only for
|
|
66
|
+
/** Present only for `kind: 'app_installation'`; null otherwise. */
|
|
67
|
+
app: {
|
|
68
|
+
id: string;
|
|
69
|
+
slug: string;
|
|
70
|
+
name: string;
|
|
71
|
+
} | null;
|
|
72
|
+
/** Present only for `kind: 'app_installation'`; null otherwise. */
|
|
73
|
+
installation: {
|
|
74
|
+
id: string;
|
|
75
|
+
/** `channel` today; `organization` arrives with multi-region. */
|
|
76
|
+
targetKind: string;
|
|
77
|
+
targetId: string;
|
|
78
|
+
/**
|
|
79
|
+
* `active` | `needs_consent` | `suspended` | `uninstalled`. `needs_consent`
|
|
80
|
+
* is how an app learns the merchant has to approve declarations it has since
|
|
81
|
+
* grown — this credential keeps working with the OLD grants meanwhile.
|
|
82
|
+
*/
|
|
83
|
+
status: string;
|
|
84
|
+
consentVersion: number;
|
|
85
|
+
} | null;
|
|
86
|
+
/** Present only for a human session; null for a machine principal. */
|
|
64
87
|
user: {
|
|
65
88
|
id: string;
|
|
66
89
|
identifier: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sixtyninecommerce/admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Back-office SDK for sixtynine-commerce: every capability of the platform as a method",
|
|
5
5
|
"homepage": "https://github.com/SIXTYNINE-digital/sixtynine-commerce-engine/tree/main/packages/admin-client#readme",
|
|
6
6
|
"repository": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"access": "public"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@sixtyninecommerce/core": "^0.2.
|
|
36
|
+
"@sixtyninecommerce/core": "^0.2.4"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/node": "22.19.7",
|