@sixtyninecommerce/admin 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -388,6 +388,71 @@ had before the registry.
388
388
  Read with `ReadSettings` and written with `UpdateSettings` — the same grants that govern
389
389
  `storefrontUrl` itself. A head an app installed (`installationId` set) is managed through that app's
390
390
  declarations and is refused here with `HEAD_APP_OWNED`.
391
+ ### Capabilities
392
+
393
+ `admin.tenant.capabilities()` answers what this tenant's channel can actually do — one read instead
394
+ of a dozen probes, and the read a head should make before it decides what to render.
395
+
396
+ ```ts
397
+ const manifest = await admin.tenant.capabilities();
398
+
399
+ manifest.capabilities.catalog.ready; // false on a catalog-less tenant — render no product routes
400
+ manifest.capabilities.search.blockers; // ['search returned 0 hits — reindex Meilisearch for this channel']
401
+ manifest.capabilities.age_gate.ready; // true only when the channel has a minimum age set
402
+ manifest.capabilities.mollie?.entitled; // is the integration licensed to this tenant
403
+ manifest.capabilities.mollie.ready; // …and actually connected and enabled
404
+ manifest.capabilities.checkout.dependsOn; // ['catalog']
405
+
406
+ await admin.tenant.capabilities.refresh(); // needs UpdateSettings; skips the freshness window
407
+ ```
408
+
409
+ Four fields per key, and they answer different questions:
410
+
411
+ | Field | Question |
412
+ |---|---|
413
+ | `provisioned` | does the thing exist here — and for a merchant switch (quotes, returns, reviews, the age gate, subscription plans, B2B price lists) that means **switched on** |
414
+ | `configured` | did the probe find no hard blocker |
415
+ | `entitled` | does the tenant hold this plugin's entitlement |
416
+ | `ready` | `provisioned && configured && (entitled || the key is core)` — the one to branch on |
417
+
418
+ `entitled` is `false` on every CORE capability and that is not a bug: the entitlement registry has
419
+ rows only for the catalogue's gated plugins, and `catalog`, `search`, `routes` and the rest are the
420
+ platform itself. `ready` is what excuses them.
421
+
422
+ `blockers` are the platform's own readiness evidence, in the words its preflight uses — never a
423
+ sentence this SDK composed, and never a UI flag. **Non-empty with `ready: true` means degraded, not
424
+ broken**: a storefront that renders anyway is correct, and a merchant still wants to see the line.
425
+ A feature deliberately left off carries no blocker at all — nothing is wrong with it.
426
+
427
+ A blocker that opens with **`not established`** is different from both: it means a dependency would
428
+ not answer, so the server could not read that capability's state at all. Treat it as unknown rather
429
+ than as off — and note that such a manifest is served with a much shorter `Cache-Control` window,
430
+ so asking again shortly is worth doing.
431
+
432
+ **Capability keys are data, so they keep their wire spelling** — `content_pages`, `age_gate`,
433
+ `b2b_pricing`, `payment-links` — exactly like the meta catalogue's data-keyed maps. The fields
434
+ *inside* each entry are camelised (`depends_on` → `dependsOn`). Core keys are `catalog`, `search`,
435
+ `routes`, `menus`, `content_pages`, `checkout`, `branding`, `age_gate`, `reviews`, `subscriptions`,
436
+ `b2b_pricing`, `quotes` and `returns`; beside them there is one key per entitlement-gated plugin in
437
+ the platform catalogue. **Treat an absent key as unknown, never as `false`** — the set grows, and a
438
+ key whose rules are not settled is held back rather than published wrong. A plugin with no cheap server-side configuration read says so in `blockers`
439
+ and stays `ready: false` — for those, `entitled` is the only authoritative field.
440
+
441
+ `b2b_pricing` answers whether the tenant does trade pricing, and it is decided by ONE thing: the
442
+ channel's trade customer groups. That is the registry the engine itself acts on — `checkIsB2BCustomer`,
443
+ B2B tax display, quote eligibility and credit all resolve through it, and with no trade groups
444
+ configured it returns false for every customer, so nothing trade can execute. Neither the quotes
445
+ switch nor the price lists an anonymous caller can see change the answer: the switch is inert
446
+ without trade groups, and that price-list query returns only applies-to-everyone lists, which an
447
+ ordinary retail sale also uses. Both are reported as evidence on a channel already known to be B2B.
448
+ So this key is worth reading with its `blockers`: `ready: true` with a blocker means the tenant IS a
449
+ B2B channel and one piece of evidence was unreadable, and a `not established` blocker means the
450
+ platform declined to guess. `ready: false` with an empty `blockers` means the channel has no trade
451
+ groups — note that this reads the same whether or not the quotes switch is on, so the manifest does
452
+ not currently distinguish a plain retail channel from one where quoting is enabled but inert.
453
+
454
+ The service caches the manifest 60 s per channel and serves it with an `ETag`, so polling it is
455
+ cheap. `installedApps` is on the wire already and stays empty until the app registry ships.
391
456
 
392
457
  ### Errors
393
458
 
package/dist/client.d.ts CHANGED
@@ -11,6 +11,7 @@ import { type MetaModule } from './meta/index.js';
11
11
  import { type PlatformAppsModule } from './platform/apps.js';
12
12
  import { type PlatformModule } from './platform/provision-store.js';
13
13
  import { type RedirectsModule } from './seo/redirects.js';
14
+ import { type CapabilitiesModule } from './tenant/capabilities.js';
14
15
  import { type ChannelsModule } from './tenant/channels.js';
15
16
  import { type RetentionModule } from './tenant/retention.js';
16
17
  export interface AdminClientConfig {
@@ -76,6 +77,7 @@ export interface AdminClient {
76
77
  };
77
78
  /** The calling tenant's own settings. Always the ACTIVE channel — no channel argument. */
78
79
  tenant: {
80
+ capabilities: CapabilitiesModule;
79
81
  retention: RetentionModule;
80
82
  };
81
83
  /**
package/dist/client.js CHANGED
@@ -11,6 +11,7 @@ import { createMetaModule } from './meta/index.js';
11
11
  import { createPlatformAppsModule } from './platform/apps.js';
12
12
  import { createPlatformModule } from './platform/provision-store.js';
13
13
  import { createRedirectsModule } from './seo/redirects.js';
14
+ import { createCapabilitiesModule } from './tenant/capabilities.js';
14
15
  import { createChannelsModule } from './tenant/channels.js';
15
16
  import { createRetentionModule } from './tenant/retention.js';
16
17
  /**
@@ -331,7 +332,10 @@ function buildClient(config, overrides, resolver, shared, credential) {
331
332
  locations: createLocationsModule(transport),
332
333
  meta: createMetaModule(transport),
333
334
  seo: { redirects: createRedirectsModule(transport) },
334
- tenant: { retention: createRetentionModule(transport) },
335
+ tenant: {
336
+ capabilities: createCapabilitiesModule(transport),
337
+ retention: createRetentionModule(transport),
338
+ },
335
339
  platform: {
336
340
  ...createPlatformModule(transport, config),
337
341
  apps: createPlatformAppsModule(transport, config),
package/dist/index.d.ts CHANGED
@@ -42,6 +42,7 @@ export { requireHumanSession } from './platform/guard.js';
42
42
  export type { JobHandle, PlatformModule, ProvisionProfile, ProvisionRun, ProvisionRunStatus, ProvisionStep, ProvisionStepStatus, ProvisionStoreInput, ProvisionStoreResult, WaitOptions, } from './platform/provision-store.js';
43
43
  export { PROVISION_PROFILES } from './platform/provision-store.js';
44
44
  export type { Redirect, RedirectCreateInput, RedirectsModule, RedirectUpdateInput, } from './seo/redirects.js';
45
+ export type { CapabilitiesModule, Capability, CapabilityManifest, } from './tenant/capabilities.js';
45
46
  export type { ChannelsModule } from './tenant/channels.js';
46
47
  export type { ChannelRetention, RetentionHold, RetentionModule, SetRetentionInput, } from './tenant/retention.js';
47
48
  export type { ApiToken, Call, ChannelInfo, CreateApiTokenInput, ListOptions, Me, TenantContext, TenantScope, UpdateApiTokenInput, WhoAmI, } from './types.js';
@@ -0,0 +1,104 @@
1
+ import { type Transport } from '@sixtyninecommerce/core';
2
+ import type { Call } from '../types.js';
3
+ /** One capability's state on the calling tenant's channel. */
4
+ export interface Capability {
5
+ /**
6
+ * The underlying objects exist here. For a feature a merchant switches on —
7
+ * quotes, returns, reviews, the age gate, subscription plans, B2B price
8
+ * lists — that means SWITCHED ON, not "supported by the platform".
9
+ */
10
+ provisioned: boolean;
11
+ /** No hard blocker in the configuration. A degradation leaves this true. */
12
+ configured: boolean;
13
+ /**
14
+ * The tenant holds this plugin's entitlement.
15
+ *
16
+ * FALSE ON EVERY CORE CAPABILITY, and that is not a bug: the entitlement
17
+ * registry has rows only for the entitlement-gated plugins of the catalogue,
18
+ * and `catalog`, `search`, `routes` and the rest are the platform itself.
19
+ * Branch on {@link ready}, never on this alone.
20
+ */
21
+ entitled: boolean;
22
+ /** The answer to "may I use this": provisioned, configured, and licensed. */
23
+ ready: boolean;
24
+ /** Capability keys this one needs first — what to fix when it is not ready. */
25
+ dependsOn: string[];
26
+ /**
27
+ * The server's evidence for anything short of a clean pass, in the words the
28
+ * platform's own readiness preflight uses ("search returned 0 hits — reindex
29
+ * Meilisearch for this channel"). Never a UI flag, and never a sentence this
30
+ * SDK composed.
31
+ *
32
+ * NON-EMPTY WITH `ready: true` MEANS DEGRADED, not broken — a storefront that
33
+ * renders anyway is correct, and a merchant still wants to see it. A feature
34
+ * deliberately switched off carries no blocker at all: nothing is wrong.
35
+ */
36
+ blockers: string[];
37
+ /** A field added after this SDK release arrives camelised and untouched. */
38
+ [extra: string]: unknown;
39
+ }
40
+ /**
41
+ * What the tenant's channel can do, per capability key.
42
+ *
43
+ * KEYS ARE DATA. `content_pages`, `age_gate`, `b2b_pricing`, `payment-links`
44
+ * are identifiers the platform matches literally, so they reach you in their
45
+ * wire spelling and are never camelised — the same rule the meta catalogue's
46
+ * data-keyed maps follow. The FIELDS inside each entry are camelised.
47
+ *
48
+ * Core keys: `catalog`, `search`, `routes`, `menus`, `content_pages`,
49
+ * `checkout`, `branding`, `age_gate`, `reviews`, `subscriptions`,
50
+ * `b2b_pricing`, `quotes`, `returns`. Beside them, one key per
51
+ * entitlement-gated plugin in the platform catalogue (`mollie`, `stripe`,
52
+ * `myparcel`, `hendriks`, `afas`, `pos`, …).
53
+ *
54
+ * THE SET GROWS, so read an absent key as UNKNOWN and never as `false`. A
55
+ * capability whose rules are not yet settled is held back rather than published
56
+ * wrong, and `b2b_pricing` — which shipped one release later than the rest, for
57
+ * exactly that reason — is the precedent rather than the exception.
58
+ */
59
+ export interface CapabilityManifest {
60
+ channelId: string;
61
+ channelCode: string;
62
+ /** ISO-8601 with an offset — when the probes ran, not when this was served. */
63
+ generatedAt: string;
64
+ capabilities: Record<string, Capability>;
65
+ /**
66
+ * The tenant's installed apps. Always empty until the app registry ships, so
67
+ * there is no element shape yet — declaring one here would pin a contract the
68
+ * apps work has not written.
69
+ */
70
+ installedApps: never[];
71
+ /** A member added after this SDK release arrives camelised and untouched. */
72
+ [extra: string]: unknown;
73
+ }
74
+ /**
75
+ * `admin.tenant.capabilities()` — what this tenant's channel can actually do.
76
+ *
77
+ * One read replaces a dozen probes: whether the catalogue has products, whether
78
+ * search is indexed, whether checkout has an enabled method on both sides,
79
+ * which integrations are licensed AND connected, and for anything less than
80
+ * ready, the server's own evidence.
81
+ *
82
+ * Reading needs any permission on the channel — an admin credential always has
83
+ * one. It is cached 60 s per channel by the service, so polling it costs
84
+ * nothing; {@link CapabilitiesModule.refresh} is the escape when you have just
85
+ * changed something and want to see it.
86
+ */
87
+ export interface CapabilitiesModule {
88
+ (options?: Call): Promise<CapabilityManifest>;
89
+ /**
90
+ * Rebuild the manifest now, ignoring the service's freshness window.
91
+ *
92
+ * Needs `UpdateSettings`: it makes the server re-probe three services, so it
93
+ * is not something a read-only credential may trigger at will.
94
+ */
95
+ refresh(options?: Call): Promise<CapabilityManifest>;
96
+ }
97
+ /**
98
+ * Camelise each capability entry's OWN fields (`depends_on` → `dependsOn`) and
99
+ * leave the map's keys alone. A field this release has never seen is renamed
100
+ * and passed through, which is the half that matters — nothing else would
101
+ * protect it.
102
+ */
103
+ export declare function projectManifest(body: unknown): CapabilityManifest;
104
+ export declare function createCapabilitiesModule(transport: Transport): CapabilitiesModule;
@@ -0,0 +1,59 @@
1
+ import { PermissionDeniedError } from '@sixtyninecommerce/core';
2
+ /**
3
+ * The `capabilities` MAP is handed back verbatim by the transport, because its
4
+ * keys are capability identifiers rather than structure. Camelising them would
5
+ * be invisible damage: `content_pages` would simply read back as `contentPages`
6
+ * and match no key the platform has ever published.
7
+ */
8
+ const PRESERVE_CAPABILITIES = new Set(['capabilities']);
9
+ function isRecord(value) {
10
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
11
+ }
12
+ function snakeToCamel(key) {
13
+ return key.replace(/_+([a-z0-9])/g, (_match, char) => char.toUpperCase());
14
+ }
15
+ /**
16
+ * Camelise each capability entry's OWN fields (`depends_on` → `dependsOn`) and
17
+ * leave the map's keys alone. A field this release has never seen is renamed
18
+ * and passed through, which is the half that matters — nothing else would
19
+ * protect it.
20
+ */
21
+ export function projectManifest(body) {
22
+ if (!isRecord(body) || !isRecord(body.capabilities)) {
23
+ // The transport has already turned a non-2xx into a PlatformError, so
24
+ // anything else here is a service answering 200 with a body this module
25
+ // cannot read — worth saying plainly rather than crashing two frames later.
26
+ throw new TypeError('GET /capabilities did not answer with a capability manifest');
27
+ }
28
+ const capabilities = {};
29
+ for (const [key, state] of Object.entries(body.capabilities)) {
30
+ if (!isRecord(state))
31
+ continue;
32
+ if (!('provisioned' in state)) {
33
+ // The public projection: `{ ready }` and nothing else. The service serves
34
+ // it to a principal it did not accept for this channel, and the status is
35
+ // 200, so nothing else in the hierarchy would tell the caller that their
36
+ // credential — not their request — is the problem.
37
+ throw new PermissionDeniedError({
38
+ code: 'CAPABILITIES_PROJECTION_UNAVAILABLE',
39
+ message: 'The service returned the anonymous capability projection, so this credential was not accepted for the channel — it is invalid or expired, or its principal holds no permission there. Only `ready` would be available.',
40
+ status: 403,
41
+ service: 'plugins',
42
+ });
43
+ }
44
+ const projected = {};
45
+ for (const [field, value] of Object.entries(state))
46
+ projected[snakeToCamel(field)] = value;
47
+ capabilities[key] = projected;
48
+ }
49
+ return { ...body, capabilities };
50
+ }
51
+ export function createCapabilitiesModule(transport) {
52
+ const read = async (options) => projectManifest(await transport.rest('plugins', { method: 'GET', path: '/capabilities' }, { ...options, preserveKeys: PRESERVE_CAPABILITIES }));
53
+ // A callable module: `capabilities()` is the read a consumer makes constantly
54
+ // and `capabilities.refresh()` the one they make rarely, so the common case
55
+ // stays the shortest thing to write (spec §6.6 names it exactly this way).
56
+ const capabilities = ((options) => read(options));
57
+ capabilities.refresh = async (options) => projectManifest(await transport.rest('plugins', { method: 'POST', path: '/capabilities/refresh' }, { ...options, preserveKeys: PRESERVE_CAPABILITIES }));
58
+ return capabilities;
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sixtyninecommerce/admin",
3
- "version": "0.5.0",
3
+ "version": "0.6.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.4"
36
+ "@sixtyninecommerce/core": "^0.3.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/node": "22.19.7",