@sixtyninecommerce/admin 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -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 +24 -4
- package/dist/client.js +120 -8
- package/dist/index.d.ts +6 -1
- package/dist/platform/apps.d.ts +76 -0
- package/dist/platform/apps.js +153 -0
- package/dist/tenant/capabilities.d.ts +104 -0
- package/dist/tenant/capabilities.js +59 -0
- package/dist/types.d.ts +27 -4
- package/package.json +2 -2
|
@@ -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
|
+
}
|
|
@@ -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/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.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",
|