@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.
@@ -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
- /** `api_token` never discloses the hidden administrator behind the token. */
52
- kind: 'api_token' | 'user';
53
- /** Present only for a token principal; null for a human session. */
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 a human session; null for a token principal. */
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.4.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.3"
36
+ "@sixtyninecommerce/core": "^0.3.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/node": "22.19.7",