@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 +65 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.js +5 -1
- package/dist/index.d.ts +1 -0
- package/dist/tenant/capabilities.d.ts +104 -0
- package/dist/tenant/capabilities.js +59 -0
- package/package.json +2 -2
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: {
|
|
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.
|
|
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.
|
|
36
|
+
"@sixtyninecommerce/core": "^0.3.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/node": "22.19.7",
|