@sixtyninecommerce/admin 0.3.1 → 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.
@@ -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,10 +1,15 @@
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';
8
+ import { type HeadsModule } from './heads/index.js';
6
9
  import { type LocationsModule } from './locations/index.js';
7
10
  import { type MetaModule } from './meta/index.js';
11
+ import { type PlatformAppsModule } from './platform/apps.js';
12
+ import { type PlatformModule } from './platform/provision-store.js';
8
13
  import { type RedirectsModule } from './seo/redirects.js';
9
14
  import { type ChannelsModule } from './tenant/channels.js';
10
15
  import { type RetentionModule } from './tenant/retention.js';
@@ -31,7 +36,7 @@ export interface AdminClientConfig {
31
36
  token: string;
32
37
  } | {
33
38
  session: SessionConfig;
34
- };
39
+ } | AppAuthConfig;
35
40
  tenant?: Partial<TenantContext> & {
36
41
  scope?: TenantScope;
37
42
  };
@@ -40,14 +45,28 @@ export interface AdminClientConfig {
40
45
  timeoutMs?: number;
41
46
  onDeprecation?: (notice: DeprecationNotice) => void;
42
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
+ }
43
58
  export interface AdminClient {
44
59
  auth: AuthModule;
45
60
  apiTokens: ApiTokensModule;
61
+ /** Installing and managing apps on the ACTIVE store. Needs `InstallApps`. */
62
+ apps: AppsModule;
46
63
  audit: AuditModule;
47
64
  channels: ChannelsModule;
48
65
  content: {
49
66
  forms: FormsModule;
50
67
  };
68
+ /** The store's head registry: every client surface it serves (spec §6.7). */
69
+ heads: HeadsModule;
51
70
  /** Stock-location profiles (spec §8.7); `TenantContext.location` names one by id. */
52
71
  locations: LocationsModule;
53
72
  /** Labels, transitions, permission and plugin catalogs, settings schemas. */
@@ -59,8 +78,14 @@ export interface AdminClient {
59
78
  tenant: {
60
79
  retention: RetentionModule;
61
80
  };
62
- /** Human SuperAdmin surface. Empty in phase 0. */
63
- platform: Record<string, never>;
81
+ /**
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`).
85
+ */
86
+ platform: PlatformModule & {
87
+ apps: PlatformAppsModule;
88
+ };
64
89
  /** A derived client on another tenant, sharing this one's principal. */
65
90
  withTenant(tenant: Partial<TenantContext> & {
66
91
  scope?: TenantScope;
package/dist/client.js CHANGED
@@ -1,10 +1,15 @@
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';
8
+ import { createHeadsModule } from './heads/index.js';
6
9
  import { createLocationsModule } from './locations/index.js';
7
10
  import { createMetaModule } from './meta/index.js';
11
+ import { createPlatformAppsModule } from './platform/apps.js';
12
+ import { createPlatformModule } from './platform/provision-store.js';
8
13
  import { createRedirectsModule } from './seo/redirects.js';
9
14
  import { createChannelsModule } from './tenant/channels.js';
10
15
  import { createRetentionModule } from './tenant/retention.js';
@@ -14,7 +19,10 @@ import { createRetentionModule } from './tenant/retention.js';
14
19
  * the dashboard is — so the refusal is specifically about a long-lived token.
15
20
  */
16
21
  function assertNotInBrowser(auth) {
17
- if (!('token' in auth))
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))
18
26
  return;
19
27
  // Indexed off a typed view of globalThis: this package compiles without the
20
28
  // DOM lib on purpose, so that a stray `document.` reference cannot slip in.
@@ -22,9 +30,10 @@ function assertNotInBrowser(auth) {
22
30
  const inBrowser = runtime.window !== undefined && runtime.document !== undefined;
23
31
  if (!inBrowser)
24
32
  return;
33
+ const credential = 'app' in auth ? "An app's private key" : 'An API token';
25
34
  throw new ValidationError({
26
35
  code: 'CREDENTIAL_IN_BROWSER',
27
- message: 'An API token must never be constructed in a browser — it would ship the credential to every visitor. Use a session bearer in the browser and keep tokens server-side.',
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.`,
28
37
  status: 400,
29
38
  service: 'vendure',
30
39
  });
@@ -138,15 +147,101 @@ export function createAdminClient(config) {
138
147
  });
139
148
  if ('session' in config.auth)
140
149
  assertSessionIsWritable(config.auth.session);
150
+ else if ('app' in config.auth)
151
+ assertAppCredentialIsPresent(config.auth);
141
152
  else
142
153
  assertTokenIsPresent(config.auth);
143
154
  // Shared by every client `withTenant` derives, because the session store is.
144
155
  // `rotations` lets `login()` tell a bearer THIS sign-in produced apart from
145
156
  // one that merely sits in the slot already.
146
157
  const shared = { rotations: 0 };
147
- return buildClient(config, config.tenant, resolver, shared);
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);
148
163
  }
149
- function buildClient(config, overrides, resolver, shared) {
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) {
150
245
  // No scope configured leaves the client on UNSCOPED_TENANT, which only the
151
246
  // auth documents may use; the transport refuses everything else with
152
247
  // TENANT_REQUIRED (spec §15.2).
@@ -158,6 +253,12 @@ function buildClient(config, overrides, resolver, shared) {
158
253
  channels: resolver,
159
254
  tenant,
160
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() };
161
262
  if (!store) {
162
263
  // Read live, so a getter over a request context follows the request
163
264
  // being served — and refuse an empty one HERE rather than sending an
@@ -189,7 +290,12 @@ function buildClient(config, overrides, resolver, shared) {
189
290
  },
190
291
  };
191
292
  },
192
- ...(config.fetch ? { fetch: config.fetch } : {}),
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
+ : {}),
193
299
  ...(config.retry ? { retry: config.retry } : {}),
194
300
  ...(config.timeoutMs === undefined ? {} : { timeoutMs: config.timeoutMs }),
195
301
  ...(config.onDeprecation ? { onDeprecation: config.onDeprecation } : {}),
@@ -217,14 +323,19 @@ function buildClient(config, overrides, resolver, shared) {
217
323
  },
218
324
  }),
219
325
  apiTokens: createApiTokensModule(transport),
326
+ apps: createAppsModule(transport),
220
327
  audit: createAuditModule(transport),
221
328
  channels: createChannelsModule(resolver),
222
329
  content: { forms: createFormsModule(transport) },
330
+ heads: createHeadsModule(transport),
223
331
  locations: createLocationsModule(transport),
224
332
  meta: createMetaModule(transport),
225
333
  seo: { redirects: createRedirectsModule(transport) },
226
334
  tenant: { retention: createRetentionModule(transport) },
227
- platform: {},
335
+ platform: {
336
+ ...createPlatformModule(transport, config),
337
+ apps: createPlatformAppsModule(transport, config),
338
+ },
228
339
  withTenant(next) {
229
340
  // `mergeTenant`, not a spread: it merges `actor` FIELD BY FIELD, so a
230
341
  // derived client that names only a registerRef keeps the staffRef this
@@ -235,7 +346,7 @@ function buildClient(config, overrides, resolver, shared) {
235
346
  // Resolver and rotation count are shared, and the session store IS the
236
347
  // caller's object — so a derived client reuses the channel cache and the
237
348
  // bearer this one signed in with, in both directions and in either order.
238
- return buildClient(config, mergeTenant(tenant, next), resolver, shared);
349
+ return buildClient(config, mergeTenant(tenant, next), resolver, shared, credential);
239
350
  },
240
351
  get rateLimit() {
241
352
  return transport.rateLimit;
@@ -0,0 +1,103 @@
1
+ import type { Transport } from '@sixtyninecommerce/core';
2
+ import type { Call } from '../types.js';
3
+ /**
4
+ * A HEAD: one client surface a store serves — its own storefront, a native app,
5
+ * a trade portal, a POS till, an integration (spec §6.7).
6
+ *
7
+ * The registry is what CORS, the email deep links and the payment return-URL
8
+ * allow-list are built from. `Channel.customFields.storefrontUrl` remains for
9
+ * one release as a read-only mirror of the default `web` head's deep-link base
10
+ * (its first origin when it has none).
11
+ */
12
+ export interface Head {
13
+ id: string;
14
+ channelId: string;
15
+ /**
16
+ * The app installation that owns this head, or null for a head the merchant
17
+ * owns. An app-owned head is managed through that app's declarations and is
18
+ * refused here with `HEAD_APP_OWNED`.
19
+ */
20
+ installationId: string | null;
21
+ /** Kebab-case handle, unique in this store. What a client sends as `x-head`. */
22
+ key: string;
23
+ /** One of: `web`, `native`, `trade`, `backoffice`, `integration`. */
24
+ kind: string;
25
+ name: string;
26
+ /** Browser origins, `scheme://host[:port]` — what a browser sends as `Origin`. */
27
+ origins: string[];
28
+ /** Base every email link is composed onto. Null means "use the first origin". */
29
+ deepLinkBase: string | null;
30
+ /** One of: `https`, `app`. */
31
+ deepLinkScheme: string;
32
+ /** One of: `CONSUMER`, `TRADE`. Null means the store's own default applies. */
33
+ defaultOrderScope: string | null;
34
+ eventSubscriptionId: string | null;
35
+ /**
36
+ * The store's default head; at most one per store.
37
+ *
38
+ * A request that sends no `x-head` resolves to it only when it is a CUSTOMER
39
+ * surface a browser can follow: `kind` `'web'` or `'trade'`, and
40
+ * `deepLinkScheme: 'https'`. Otherwise the request falls through to the
41
+ * store's first such head — a `native` till would otherwise collect every
42
+ * shopper's login link by being the first head a retail tenant registers.
43
+ *
44
+ * `x-head` may name a `web`, `trade` or `native` head, which is how a native
45
+ * client gets its own deep links. It may NOT name a `backoffice` or
46
+ * `integration` head: that header arrives on an unauthenticated mutation, and
47
+ * honouring it would let anyone mail a customer's login token to the
48
+ * dashboard host or to a third party's origin.
49
+ */
50
+ isDefault: boolean;
51
+ createdAt: string;
52
+ updatedAt: string;
53
+ }
54
+ export interface CreateHeadInput {
55
+ key: string;
56
+ kind: string;
57
+ name: string;
58
+ /** At least one absolute URL; the server stores each as its origin. */
59
+ origins: string[];
60
+ deepLinkBase?: string | null;
61
+ deepLinkScheme?: string | null;
62
+ defaultOrderScope?: string | null;
63
+ /**
64
+ * The store's FIRST head is always its default whatever this says — a store
65
+ * with heads but no default resolves no deep link at all.
66
+ */
67
+ isDefault?: boolean;
68
+ }
69
+ /** A PATCH: an omitted field is left as it is, an explicit null clears a nullable one. */
70
+ export interface UpdateHeadInput {
71
+ key?: string;
72
+ kind?: string;
73
+ name?: string;
74
+ origins?: string[];
75
+ deepLinkBase?: string | null;
76
+ deepLinkScheme?: string;
77
+ defaultOrderScope?: string | null;
78
+ }
79
+ export declare const HEAD_FIELDS = "\n id\n channelId\n installationId\n key\n kind\n name\n origins\n deepLinkBase\n deepLinkScheme\n defaultOrderScope\n eventSubscriptionId\n isDefault\n createdAt\n updatedAt\n";
80
+ export declare const LIST_QUERY = "\n query ChannelHeads {\n channelHeads { \n id\n channelId\n installationId\n key\n kind\n name\n origins\n deepLinkBase\n deepLinkScheme\n defaultOrderScope\n eventSubscriptionId\n isDefault\n createdAt\n updatedAt\n }\n }\n";
81
+ export declare const GET_QUERY = "\n query ChannelHead($id: ID!) {\n channelHead(id: $id) { \n id\n channelId\n installationId\n key\n kind\n name\n origins\n deepLinkBase\n deepLinkScheme\n defaultOrderScope\n eventSubscriptionId\n isDefault\n createdAt\n updatedAt\n }\n }\n";
82
+ export declare const CREATE_MUTATION = "\n mutation CreateChannelHead($input: CreateChannelHeadInput!) {\n createChannelHead(input: $input) { \n id\n channelId\n installationId\n key\n kind\n name\n origins\n deepLinkBase\n deepLinkScheme\n defaultOrderScope\n eventSubscriptionId\n isDefault\n createdAt\n updatedAt\n }\n }\n";
83
+ export declare const UPDATE_MUTATION = "\n mutation UpdateChannelHead($id: ID!, $input: UpdateChannelHeadInput!) {\n updateChannelHead(id: $id, input: $input) { \n id\n channelId\n installationId\n key\n kind\n name\n origins\n deepLinkBase\n deepLinkScheme\n defaultOrderScope\n eventSubscriptionId\n isDefault\n createdAt\n updatedAt\n }\n }\n";
84
+ export declare const DELETE_MUTATION = "\n mutation DeleteChannelHead($id: ID!) {\n deleteChannelHead(id: $id)\n }\n";
85
+ export declare const SET_DEFAULT_MUTATION = "\n mutation SetDefaultChannelHead($id: ID!) {\n setDefaultChannelHead(id: $id) { \n id\n channelId\n installationId\n key\n kind\n name\n origins\n deepLinkBase\n deepLinkScheme\n defaultOrderScope\n eventSubscriptionId\n isDefault\n createdAt\n updatedAt\n }\n }\n";
86
+ /**
87
+ * The store's head registry (spec §6.7).
88
+ *
89
+ * `list` returns a plain array, not a `Page`: the Admin API does not paginate
90
+ * heads, and wrapping a handful of rows in a cursor would promise a second page
91
+ * that can never exist. Read and written with the settings permissions.
92
+ */
93
+ export interface HeadsModule {
94
+ list(options?: Call): Promise<Head[]>;
95
+ get(id: string, options?: Call): Promise<Head | null>;
96
+ create(input: CreateHeadInput, options?: Call): Promise<Head>;
97
+ update(id: string, input: UpdateHeadInput, options?: Call): Promise<Head>;
98
+ /** True when the head was removed. Removing the default is refused while others remain. */
99
+ delete(id: string, options?: Call): Promise<boolean>;
100
+ /** Move the store's default head. There is no way to unset one, only to move it. */
101
+ setDefault(id: string, options?: Call): Promise<Head>;
102
+ }
103
+ export declare function createHeadsModule(transport: Transport): HeadsModule;
@@ -0,0 +1,74 @@
1
+ export const HEAD_FIELDS = /* GraphQL */ `
2
+ id
3
+ channelId
4
+ installationId
5
+ key
6
+ kind
7
+ name
8
+ origins
9
+ deepLinkBase
10
+ deepLinkScheme
11
+ defaultOrderScope
12
+ eventSubscriptionId
13
+ isDefault
14
+ createdAt
15
+ updatedAt
16
+ `;
17
+ export const LIST_QUERY = /* GraphQL */ `
18
+ query ChannelHeads {
19
+ channelHeads { ${HEAD_FIELDS} }
20
+ }
21
+ `;
22
+ export const GET_QUERY = /* GraphQL */ `
23
+ query ChannelHead($id: ID!) {
24
+ channelHead(id: $id) { ${HEAD_FIELDS} }
25
+ }
26
+ `;
27
+ export const CREATE_MUTATION = /* GraphQL */ `
28
+ mutation CreateChannelHead($input: CreateChannelHeadInput!) {
29
+ createChannelHead(input: $input) { ${HEAD_FIELDS} }
30
+ }
31
+ `;
32
+ export const UPDATE_MUTATION = /* GraphQL */ `
33
+ mutation UpdateChannelHead($id: ID!, $input: UpdateChannelHeadInput!) {
34
+ updateChannelHead(id: $id, input: $input) { ${HEAD_FIELDS} }
35
+ }
36
+ `;
37
+ export const DELETE_MUTATION = /* GraphQL */ `
38
+ mutation DeleteChannelHead($id: ID!) {
39
+ deleteChannelHead(id: $id)
40
+ }
41
+ `;
42
+ export const SET_DEFAULT_MUTATION = /* GraphQL */ `
43
+ mutation SetDefaultChannelHead($id: ID!) {
44
+ setDefaultChannelHead(id: $id) { ${HEAD_FIELDS} }
45
+ }
46
+ `;
47
+ export function createHeadsModule(transport) {
48
+ return {
49
+ async list(options) {
50
+ const data = await transport.graphql({ api: 'admin', query: LIST_QUERY }, options);
51
+ return data.channelHeads;
52
+ },
53
+ async get(id, options) {
54
+ const data = await transport.graphql({ api: 'admin', query: GET_QUERY, variables: { id } }, options);
55
+ return data.channelHead;
56
+ },
57
+ async create(input, options) {
58
+ const data = await transport.graphql({ api: 'admin', query: CREATE_MUTATION, variables: { input } }, options);
59
+ return data.createChannelHead;
60
+ },
61
+ async update(id, input, options) {
62
+ const data = await transport.graphql({ api: 'admin', query: UPDATE_MUTATION, variables: { id, input } }, options);
63
+ return data.updateChannelHead;
64
+ },
65
+ async delete(id, options) {
66
+ const data = await transport.graphql({ api: 'admin', query: DELETE_MUTATION, variables: { id } }, options);
67
+ return data.deleteChannelHead;
68
+ },
69
+ async setDefault(id, options) {
70
+ const data = await transport.graphql({ api: 'admin', query: SET_DEFAULT_MUTATION, variables: { id } }, options);
71
+ return data.setDefaultChannelHead;
72
+ },
73
+ };
74
+ }