@sixtyninecommerce/admin 0.3.1 → 0.4.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 +54 -0
- package/dist/client.d.ts +9 -2
- package/dist/client.js +4 -1
- package/dist/heads/index.d.ts +103 -0
- package/dist/heads/index.js +74 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/platform/provision-store.d.ts +157 -0
- package/dist/platform/provision-store.js +140 -0
- package/dist/types.d.ts +9 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -276,6 +276,60 @@ signal (a TanStack Query `queryFn` gets one for free) is safe: one component unm
|
|
|
276
276
|
the catalog for the rest of the page. `refresh()` is not shared, so its `signal` cancels the request
|
|
277
277
|
itself.
|
|
278
278
|
|
|
279
|
+
### Heads
|
|
280
|
+
|
|
281
|
+
A HEAD is one client surface a store serves — its own storefront, a native app, a trade portal, a
|
|
282
|
+
POS till, an integration. The registry is what CORS, the email deep links and the payment
|
|
283
|
+
return-URL allow-list are built from.
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
const heads = await admin.heads.list();
|
|
287
|
+
|
|
288
|
+
const pos = await admin.heads.create({
|
|
289
|
+
key: 'pos',
|
|
290
|
+
kind: 'native',
|
|
291
|
+
name: 'Shop floor till',
|
|
292
|
+
origins: ['https://pos.example.com'],
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
await admin.heads.update(pos.id, { origins: ['https://pos.example.com', 'https://till.example.com'] });
|
|
296
|
+
await admin.heads.delete(pos.id);
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
`list()` returns a plain array, not a `Page`: the Admin API does not paginate heads, and a cursor
|
|
300
|
+
would promise a second page that cannot exist. `update()` is a PATCH — an omitted field is left as
|
|
301
|
+
it is, an explicit `null` clears a nullable one.
|
|
302
|
+
|
|
303
|
+
`key` is what a client sends as the `x-head` header (`tenant.head` in a `TenantContext`), and it is
|
|
304
|
+
how the platform routes an identity email's deep link back to the surface the customer started on.
|
|
305
|
+
|
|
306
|
+
A request with no `x-head` gets the store's default head — as long as that head is a CUSTOMER
|
|
307
|
+
surface a browser can follow: `kind` `web` or `trade`, with `deepLinkScheme: 'https'`. Otherwise it
|
|
308
|
+
falls through to the store's first such head; a `native` till would otherwise collect every
|
|
309
|
+
shopper's magic link simply by being the first head a retail tenant registers.
|
|
310
|
+
|
|
311
|
+
`x-head` may name a `web`, `trade` or `native` head — that is how a login started in the app returns
|
|
312
|
+
to the app. It may **not** name a `backoffice` or `integration` head, and such a key is ignored like
|
|
313
|
+
an unknown one: the header arrives on an unauthenticated mutation whose only other input is the
|
|
314
|
+
recipient's address, so honouring it would let anyone have a customer's login token mailed to the
|
|
315
|
+
dashboard host or to a third party's origin.
|
|
316
|
+
|
|
317
|
+
There is always exactly one default once a store has any head: the first head created becomes it,
|
|
318
|
+
`setDefault()` moves it, and removing it is refused (`HEAD_DEFAULT_REQUIRED`) while other heads
|
|
319
|
+
remain — a store with heads but no default would resolve no link at all. `isDefault` is therefore
|
|
320
|
+
absent from the update input.
|
|
321
|
+
|
|
322
|
+
`Channel.customFields.storefrontUrl` remains for one release as a read-only mirror of the default
|
|
323
|
+
`web` head's `deepLinkBase` (its first origin when it has none); the platform writes it on every head
|
|
324
|
+
change. The base and not the bare origin, because that field is also what canonical, hreflang and
|
|
325
|
+
sitemap URLs are composed on — mirroring the origin over a storefront mounted under a path would
|
|
326
|
+
point them all at a 404. It is never cleared: a store that removes its last head keeps the value it
|
|
327
|
+
had before the registry.
|
|
328
|
+
|
|
329
|
+
Read with `ReadSettings` and written with `UpdateSettings` — the same grants that govern
|
|
330
|
+
`storefrontUrl` itself. A head an app installed (`installationId` set) is managed through that app's
|
|
331
|
+
declarations and is refused here with `HEAD_APP_OWNED`.
|
|
332
|
+
|
|
279
333
|
### Errors
|
|
280
334
|
|
|
281
335
|
One hierarchy, all re-exported from this package so you need no direct dependency on
|
package/dist/client.d.ts
CHANGED
|
@@ -3,8 +3,10 @@ import { type ApiTokensModule } from './api-tokens/index.js';
|
|
|
3
3
|
import { type AuditModule } from './audit/index.js';
|
|
4
4
|
import { type AuthModule } from './auth/index.js';
|
|
5
5
|
import { type FormsModule } from './content/forms.js';
|
|
6
|
+
import { type HeadsModule } from './heads/index.js';
|
|
6
7
|
import { type LocationsModule } from './locations/index.js';
|
|
7
8
|
import { type MetaModule } from './meta/index.js';
|
|
9
|
+
import { type PlatformModule } from './platform/provision-store.js';
|
|
8
10
|
import { type RedirectsModule } from './seo/redirects.js';
|
|
9
11
|
import { type ChannelsModule } from './tenant/channels.js';
|
|
10
12
|
import { type RetentionModule } from './tenant/retention.js';
|
|
@@ -48,6 +50,8 @@ export interface AdminClient {
|
|
|
48
50
|
content: {
|
|
49
51
|
forms: FormsModule;
|
|
50
52
|
};
|
|
53
|
+
/** The store's head registry: every client surface it serves (spec §6.7). */
|
|
54
|
+
heads: HeadsModule;
|
|
51
55
|
/** Stock-location profiles (spec §8.7); `TenantContext.location` names one by id. */
|
|
52
56
|
locations: LocationsModule;
|
|
53
57
|
/** Labels, transitions, permission and plugin catalogs, settings schemas. */
|
|
@@ -59,8 +63,11 @@ export interface AdminClient {
|
|
|
59
63
|
tenant: {
|
|
60
64
|
retention: RetentionModule;
|
|
61
65
|
};
|
|
62
|
-
/**
|
|
63
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Human SuperAdmin surface. Every method refuses an API token before the
|
|
68
|
+
* request leaves the process (`PLATFORM_REQUIRES_SESSION`).
|
|
69
|
+
*/
|
|
70
|
+
platform: PlatformModule;
|
|
64
71
|
/** A derived client on another tenant, sharing this one's principal. */
|
|
65
72
|
withTenant(tenant: Partial<TenantContext> & {
|
|
66
73
|
scope?: TenantScope;
|
package/dist/client.js
CHANGED
|
@@ -3,8 +3,10 @@ import { createApiTokensModule } from './api-tokens/index.js';
|
|
|
3
3
|
import { createAuditModule } from './audit/index.js';
|
|
4
4
|
import { createAuthModule } from './auth/index.js';
|
|
5
5
|
import { createFormsModule } from './content/forms.js';
|
|
6
|
+
import { createHeadsModule } from './heads/index.js';
|
|
6
7
|
import { createLocationsModule } from './locations/index.js';
|
|
7
8
|
import { createMetaModule } from './meta/index.js';
|
|
9
|
+
import { createPlatformModule } from './platform/provision-store.js';
|
|
8
10
|
import { createRedirectsModule } from './seo/redirects.js';
|
|
9
11
|
import { createChannelsModule } from './tenant/channels.js';
|
|
10
12
|
import { createRetentionModule } from './tenant/retention.js';
|
|
@@ -220,11 +222,12 @@ function buildClient(config, overrides, resolver, shared) {
|
|
|
220
222
|
audit: createAuditModule(transport),
|
|
221
223
|
channels: createChannelsModule(resolver),
|
|
222
224
|
content: { forms: createFormsModule(transport) },
|
|
225
|
+
heads: createHeadsModule(transport),
|
|
223
226
|
locations: createLocationsModule(transport),
|
|
224
227
|
meta: createMetaModule(transport),
|
|
225
228
|
seo: { redirects: createRedirectsModule(transport) },
|
|
226
229
|
tenant: { retention: createRetentionModule(transport) },
|
|
227
|
-
platform:
|
|
230
|
+
platform: createPlatformModule(transport, config),
|
|
228
231
|
withTenant(next) {
|
|
229
232
|
// `mergeTenant`, not a spread: it merges `actor` FIELD BY FIELD, so a
|
|
230
233
|
// derived client that names only a registerRef keeps the staffRef this
|
|
@@ -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
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -30,10 +30,13 @@ export type { AuthModule } from './auth/index.js';
|
|
|
30
30
|
export { type AdminClient, type AdminClientConfig, createAdminClient, type SessionConfig, } from './client.js';
|
|
31
31
|
export type { Form, FormField, FormFieldInput, FormFieldTranslation, FormFieldTranslationInput, FormInput, FormsModule, FormTranslation, FormTranslationInput, } from './content/forms.js';
|
|
32
32
|
export { AdminProjectionError } from './errors.js';
|
|
33
|
+
export type { CreateHeadInput, Head, HeadsModule, UpdateHeadInput, } from './heads/index.js';
|
|
33
34
|
export type { LocationProfile, LocationStockLocation, LocationsModule, OpeningHours, OpeningHoursDay, OpeningHoursInterval, StockLocationAddress, StockLocationAddressInput, UpsertLocationProfileInput, } from './locations/index.js';
|
|
34
35
|
export { OPENING_HOURS_DAYS } from './locations/index.js';
|
|
35
36
|
export type { ApiTokenTemplate, JsonSchema, LabelMap, MetaCatalog, MetaCatalogLabels, MetaCatalogPermissions, MetaCatalogPlugins, MetaCatalogTransitions, MetaModule, PermissionGroup, PluginCatalogEntry, RoleTemplate, StatusMetaMap, StatusTone, TransitionMap, } from './meta/index.js';
|
|
36
37
|
export { requireHumanSession } from './platform/guard.js';
|
|
38
|
+
export type { JobHandle, PlatformModule, ProvisionProfile, ProvisionRun, ProvisionRunStatus, ProvisionStep, ProvisionStepStatus, ProvisionStoreInput, ProvisionStoreResult, WaitOptions, } from './platform/provision-store.js';
|
|
39
|
+
export { PROVISION_PROFILES } from './platform/provision-store.js';
|
|
37
40
|
export type { Redirect, RedirectCreateInput, RedirectsModule, RedirectUpdateInput, } from './seo/redirects.js';
|
|
38
41
|
export type { ChannelsModule } from './tenant/channels.js';
|
|
39
42
|
export type { ChannelRetention, RetentionHold, RetentionModule, SetRetentionInput, } from './tenant/retention.js';
|
package/dist/index.js
CHANGED
|
@@ -21,3 +21,4 @@ export { createAdminClient, } from './client.js';
|
|
|
21
21
|
export { AdminProjectionError } from './errors.js';
|
|
22
22
|
export { OPENING_HOURS_DAYS } from './locations/index.js';
|
|
23
23
|
export { requireHumanSession } from './platform/guard.js';
|
|
24
|
+
export { PROVISION_PROFILES } from './platform/provision-store.js';
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { type Transport } from '@sixtyninecommerce/core';
|
|
2
|
+
import type { AdminClientConfig } from '../client.js';
|
|
3
|
+
import type { Call } from '../types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Which objects a new store is provisioned with.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors the server's list; the server is the enforcer, so an unknown value is
|
|
8
|
+
* refused with `VALIDATION_FAILED` naming the accepted ones rather than failing
|
|
9
|
+
* here.
|
|
10
|
+
*/
|
|
11
|
+
export type ProvisionProfile = 'cms-only' | 'catalog' | 'commerce' | 'pos-commerce' | 'full';
|
|
12
|
+
/** Every profile, for a caller that renders a picker. */
|
|
13
|
+
export declare const PROVISION_PROFILES: readonly ProvisionProfile[];
|
|
14
|
+
export interface ProvisionStoreInput {
|
|
15
|
+
/** Human store name — becomes the Seller name and the store's business name. */
|
|
16
|
+
name: string;
|
|
17
|
+
/**
|
|
18
|
+
* Lowercase kebab-case. It is also the object-storage prefix, the CMS key and
|
|
19
|
+
* the order-number namespace, and it can only be changed later by a rename
|
|
20
|
+
* job — so it is worth getting right.
|
|
21
|
+
*/
|
|
22
|
+
code: string;
|
|
23
|
+
/** Defaults to `commerce`, which is the full webshop. */
|
|
24
|
+
profile?: ProvisionProfile;
|
|
25
|
+
defaultLanguageCode: string;
|
|
26
|
+
availableLanguageCodes?: string[];
|
|
27
|
+
defaultCurrencyCode: string;
|
|
28
|
+
availableCurrencyCodes?: string[];
|
|
29
|
+
pricesIncludeTax?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* The store's tax and shipping zones. Send BOTH or NEITHER — a request naming
|
|
32
|
+
* one is refused, because the other would then be derived and the store would
|
|
33
|
+
* quietly end up with two different zones.
|
|
34
|
+
*
|
|
35
|
+
* Omitted, both are resolved from `country`: the SMALLEST zone that actually
|
|
36
|
+
* CONTAINS that country wins (ties broken by name, then id). A country in no
|
|
37
|
+
* zone is an error, not a guess — the old heuristic matched zone NAMES and
|
|
38
|
+
* fell back to Europe/EU, which is how a US store ended up on European VAT.
|
|
39
|
+
*/
|
|
40
|
+
defaultTaxZoneId?: string;
|
|
41
|
+
defaultShippingZoneId?: string;
|
|
42
|
+
addressLine1: string;
|
|
43
|
+
addressLine2?: string;
|
|
44
|
+
city: string;
|
|
45
|
+
postalCode: string;
|
|
46
|
+
province?: string;
|
|
47
|
+
/** ISO 3166-1 alpha-2. */
|
|
48
|
+
country: string;
|
|
49
|
+
/** IANA zone. Every day-resolution comparison for this store uses it. */
|
|
50
|
+
timezone: string;
|
|
51
|
+
legalName: string;
|
|
52
|
+
vatNumber?: string;
|
|
53
|
+
registrationNumber?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Invite a merchant administrator to the new store. SOFT-FAIL: a bounced
|
|
56
|
+
* invitation never fails the provisioning — it lands in
|
|
57
|
+
* {@link ProvisionRun.warnings} and the store is still built.
|
|
58
|
+
*/
|
|
59
|
+
invite?: {
|
|
60
|
+
email: string;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Reserved. Refused today with `SANDBOX_NOT_AVAILABLE` (a
|
|
64
|
+
* {@link ValidationError}) until the sandbox channel flag ships.
|
|
65
|
+
*/
|
|
66
|
+
sandbox?: boolean;
|
|
67
|
+
/** An organization code. Refused with `ORGANIZATIONS_NOT_AVAILABLE`. */
|
|
68
|
+
organization?: string;
|
|
69
|
+
/** A Seller id. Refused with `ORGANIZATIONS_NOT_AVAILABLE`. */
|
|
70
|
+
legalEntity?: string;
|
|
71
|
+
}
|
|
72
|
+
export type ProvisionRunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'compensated';
|
|
73
|
+
export type ProvisionStepStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'skipped' | 'compensated';
|
|
74
|
+
export interface ProvisionStep {
|
|
75
|
+
/** `zones`, `seller`, `channel`, `shipping-methods`, `stock-location`, `roles`, `cms-channel`, `cms-content`, `invite`. */
|
|
76
|
+
name: string;
|
|
77
|
+
/** `skipped` is a DECISION — the profile did not ask for this object. */
|
|
78
|
+
status: ProvisionStepStatus;
|
|
79
|
+
/** ISO-8601 UTC, or null for a step that never started. */
|
|
80
|
+
startedAt: string | null;
|
|
81
|
+
finishedAt: string | null;
|
|
82
|
+
error?: string;
|
|
83
|
+
}
|
|
84
|
+
export interface ProvisionStoreResult {
|
|
85
|
+
channelId: string;
|
|
86
|
+
channelCode: string;
|
|
87
|
+
/** The new store's channel token — what a storefront and this SDK address it by. */
|
|
88
|
+
channelToken: string;
|
|
89
|
+
sellerId: string;
|
|
90
|
+
/** The role a merchant invitation attaches to. */
|
|
91
|
+
adminRoleId: string;
|
|
92
|
+
}
|
|
93
|
+
export interface ProvisionRun {
|
|
94
|
+
id: string;
|
|
95
|
+
status: ProvisionRunStatus;
|
|
96
|
+
/** The step running now, or the one that failed. Null when nothing is running. */
|
|
97
|
+
step: string | null;
|
|
98
|
+
profile: string;
|
|
99
|
+
channelCode: string;
|
|
100
|
+
steps: ProvisionStep[];
|
|
101
|
+
/** Present only on a `succeeded` run. */
|
|
102
|
+
result?: ProvisionStoreResult;
|
|
103
|
+
/** Non-fatal notes. A merchant invite that bounced is the only one today. */
|
|
104
|
+
warnings: string[];
|
|
105
|
+
error?: string;
|
|
106
|
+
createdAt: string;
|
|
107
|
+
startedAt: string | null;
|
|
108
|
+
completedAt: string | null;
|
|
109
|
+
[extra: string]: unknown;
|
|
110
|
+
}
|
|
111
|
+
export interface WaitOptions {
|
|
112
|
+
/** How often to poll. Default 2000 ms; anything below 250 ms is raised to it. */
|
|
113
|
+
intervalMs?: number;
|
|
114
|
+
/** Give up after this long. Default 300000 ms (5 minutes). */
|
|
115
|
+
timeoutMs?: number;
|
|
116
|
+
signal?: AbortSignal;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* A long-running operation in flight (spec §6.11). `runId` is the receipt: it
|
|
120
|
+
* outlives this process, so a caller that cannot wait can hand it on and poll
|
|
121
|
+
* `provisionRun(runId)` later.
|
|
122
|
+
*/
|
|
123
|
+
export interface JobHandle<T> {
|
|
124
|
+
runId: string;
|
|
125
|
+
/**
|
|
126
|
+
* Poll until the run reaches a terminal status.
|
|
127
|
+
*
|
|
128
|
+
* Resolves with the result on success. Throws `PROVISION_RUN_COMPENSATED`
|
|
129
|
+
* when the run failed and the platform undid everything it had built (no
|
|
130
|
+
* store, no leftovers — retrying is safe), and `PROVISION_RUN_FAILED` when
|
|
131
|
+
* the cleanup ITSELF could not finish, whose message names what is left
|
|
132
|
+
* behind for an operator. `PROVISION_RUN_TIMEOUT` means only that waiting
|
|
133
|
+
* stopped: the run is still going, and {@link runId} still resolves it.
|
|
134
|
+
*/
|
|
135
|
+
wait(options?: WaitOptions): Promise<T>;
|
|
136
|
+
}
|
|
137
|
+
export interface PlatformModule {
|
|
138
|
+
/**
|
|
139
|
+
* Build a whole store: seller, channel, zones, shipping methods, stock
|
|
140
|
+
* location, roles, CMS registration and the default CMS menus and forms —
|
|
141
|
+
* which of them depends on `profile`.
|
|
142
|
+
*
|
|
143
|
+
* Returns as soon as the run is accepted. The work runs server-side as a saga
|
|
144
|
+
* with recorded compensation, so a failure half-way leaves nothing behind.
|
|
145
|
+
*
|
|
146
|
+
* `options.idempotencyKey` makes a retry return the run already in flight
|
|
147
|
+
* instead of starting a second one — send one, and reuse it only for retries
|
|
148
|
+
* of that same call.
|
|
149
|
+
*
|
|
150
|
+
* Needs a human SuperAdmin session: an API token is refused before it leaves
|
|
151
|
+
* the process (`PLATFORM_REQUIRES_SESSION`).
|
|
152
|
+
*/
|
|
153
|
+
provisionStore(input: ProvisionStoreInput, options?: Call): Promise<JobHandle<ProvisionStoreResult>>;
|
|
154
|
+
/** One provisioning run, by the id `provisionStore` returned. */
|
|
155
|
+
provisionRun(runId: string, options?: Call): Promise<ProvisionRun>;
|
|
156
|
+
}
|
|
157
|
+
export declare function createPlatformModule(transport: Transport, config: AdminClientConfig): PlatformModule;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { BusinessRuleError, TransportError } from '@sixtyninecommerce/core';
|
|
2
|
+
import { requireHumanSession } from './guard.js';
|
|
3
|
+
/** Every profile, for a caller that renders a picker. */
|
|
4
|
+
export const PROVISION_PROFILES = [
|
|
5
|
+
'cms-only',
|
|
6
|
+
'catalog',
|
|
7
|
+
'commerce',
|
|
8
|
+
'pos-commerce',
|
|
9
|
+
'full',
|
|
10
|
+
];
|
|
11
|
+
const TERMINAL = new Set(['succeeded', 'failed', 'compensated']);
|
|
12
|
+
const DEFAULT_INTERVAL_MS = 2_000;
|
|
13
|
+
const MIN_INTERVAL_MS = 250;
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
15
|
+
/**
|
|
16
|
+
* Consecutive failed READS tolerated while waiting. A poll is a read of a run
|
|
17
|
+
* that is still building a store: one 503 from a rolling restart must not
|
|
18
|
+
* report a failure for work that is going fine. Reset by any successful read,
|
|
19
|
+
* so only a sustained outage gives up — and a refusal that is ABOUT the run
|
|
20
|
+
* (not found, no permission) is not retried at all.
|
|
21
|
+
*/
|
|
22
|
+
const MAX_CONSECUTIVE_POLL_FAILURES = 3;
|
|
23
|
+
/** A read that failed for a reason polling again cannot fix. */
|
|
24
|
+
function isTerminalPollError(error) {
|
|
25
|
+
const status = error?.status;
|
|
26
|
+
return typeof status === 'number' && status >= 400 && status < 500 && status !== 429;
|
|
27
|
+
}
|
|
28
|
+
export function createPlatformModule(transport, config) {
|
|
29
|
+
async function provisionRun(runId, options) {
|
|
30
|
+
requireHumanSession(config);
|
|
31
|
+
return transport.rest('plugins', { method: 'GET', path: `/platform/provision-runs/${encodeURIComponent(runId)}` },
|
|
32
|
+
// Provisioning is cross-tenant: it creates a channel that does not exist
|
|
33
|
+
// yet, so the call must not be scoped to one.
|
|
34
|
+
{ ...options, allowUnscoped: true });
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
provisionRun,
|
|
38
|
+
async provisionStore(input, options) {
|
|
39
|
+
requireHumanSession(config);
|
|
40
|
+
const accepted = await transport.rest('plugins', { method: 'POST', path: '/platform/provision-store', body: input }, { ...options, allowUnscoped: true });
|
|
41
|
+
const runId = accepted.runId;
|
|
42
|
+
return {
|
|
43
|
+
runId,
|
|
44
|
+
async wait(waitOptions) {
|
|
45
|
+
const intervalMs = Math.max(waitOptions?.intervalMs ?? DEFAULT_INTERVAL_MS, MIN_INTERVAL_MS);
|
|
46
|
+
const deadline = Date.now() + (waitOptions?.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
47
|
+
let consecutiveFailures = 0;
|
|
48
|
+
let lastSeen;
|
|
49
|
+
for (;;) {
|
|
50
|
+
let run;
|
|
51
|
+
try {
|
|
52
|
+
run = await provisionRun(runId, {
|
|
53
|
+
...options,
|
|
54
|
+
...(waitOptions?.signal ? { signal: waitOptions.signal } : {}),
|
|
55
|
+
});
|
|
56
|
+
consecutiveFailures = 0;
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (waitOptions?.signal?.aborted === true)
|
|
60
|
+
throw error;
|
|
61
|
+
consecutiveFailures += 1;
|
|
62
|
+
if (isTerminalPollError(error) ||
|
|
63
|
+
consecutiveFailures > MAX_CONSECUTIVE_POLL_FAILURES) {
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
if (Date.now() >= deadline)
|
|
67
|
+
throw timedOut(runId, lastSeen);
|
|
68
|
+
await delay(intervalMs, waitOptions?.signal);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
lastSeen = run;
|
|
72
|
+
if (TERMINAL.has(run.status))
|
|
73
|
+
return resultOf(run);
|
|
74
|
+
if (Date.now() >= deadline)
|
|
75
|
+
throw timedOut(runId, run);
|
|
76
|
+
await delay(intervalMs, waitOptions?.signal);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function resultOf(run) {
|
|
84
|
+
if (run.status === 'succeeded') {
|
|
85
|
+
if (run.result)
|
|
86
|
+
return run.result;
|
|
87
|
+
throw new BusinessRuleError({
|
|
88
|
+
code: 'PROVISION_RUN_INCOMPLETE',
|
|
89
|
+
message: `Provisioning run ${run.id} reports success but carries no store. Read the run for its steps.`,
|
|
90
|
+
status: 422,
|
|
91
|
+
service: 'plugins',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
// `compensated` and `failed` differ in ONE way that matters to a caller:
|
|
95
|
+
// whether anything was left behind. Retrying is safe after the first and
|
|
96
|
+
// needs an operator after the second, so they are different codes.
|
|
97
|
+
//
|
|
98
|
+
// The detail is the failing step's own words — usually a service's message,
|
|
99
|
+
// which may or may not be a sentence — so it is punctuated here rather than
|
|
100
|
+
// run into the sentence that follows it.
|
|
101
|
+
const raw = run.error ?? `Provisioning failed at step "${run.step ?? 'unknown'}"`;
|
|
102
|
+
const detail = /[.!?]$/.test(raw.trim()) ? raw.trim() : `${raw.trim()}.`;
|
|
103
|
+
throw new BusinessRuleError({
|
|
104
|
+
code: run.status === 'compensated' ? 'PROVISION_RUN_COMPENSATED' : 'PROVISION_RUN_FAILED',
|
|
105
|
+
message: run.status === 'compensated'
|
|
106
|
+
? `${detail} Everything it had built was removed, so nothing is left behind. (That does not mean the code is free: the most common cause is a code another store already owns.)`
|
|
107
|
+
: `${detail} The cleanup did not finish — an operator has to remove what the message names.`,
|
|
108
|
+
status: 422,
|
|
109
|
+
service: 'plugins',
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
function timedOut(runId, run) {
|
|
113
|
+
const at = run
|
|
114
|
+
? `while it was at "${run.step ?? run.status}"`
|
|
115
|
+
: 'without reading it successfully';
|
|
116
|
+
return new TransportError({
|
|
117
|
+
code: 'PROVISION_RUN_TIMEOUT',
|
|
118
|
+
message: `Stopped waiting for provisioning run ${runId} ${at}. The run may still be going — poll platform.provisionRun("${runId}") to follow it.`,
|
|
119
|
+
status: 0,
|
|
120
|
+
service: 'plugins',
|
|
121
|
+
retryable: true,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function delay(ms, signal) {
|
|
125
|
+
return new Promise((resolve, reject) => {
|
|
126
|
+
if (signal?.aborted) {
|
|
127
|
+
reject(signal.reason instanceof Error ? signal.reason : new Error('Aborted'));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const timer = setTimeout(() => {
|
|
131
|
+
signal?.removeEventListener('abort', onAbort);
|
|
132
|
+
resolve();
|
|
133
|
+
}, ms);
|
|
134
|
+
function onAbort() {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
reject(signal?.reason instanceof Error ? signal.reason : new Error('Aborted'));
|
|
137
|
+
}
|
|
138
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
139
|
+
});
|
|
140
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -6,12 +6,15 @@ export interface Call {
|
|
|
6
6
|
* the Admin API has no header for it, so an `apiTokens.*` write is never
|
|
7
7
|
* deduplicated and a repeat is a second write.
|
|
8
8
|
*
|
|
9
|
-
* On REST,
|
|
10
|
-
* `content.forms.upsertBySlug` (`PUT /forms/by-slug/:slug`)
|
|
11
|
-
* `seo.redirects.create` (`POST /redirects`)
|
|
12
|
-
*
|
|
13
|
-
* same key
|
|
14
|
-
*
|
|
9
|
+
* On REST, three of this SDK's writes are deduplicated by the service itself:
|
|
10
|
+
* `content.forms.upsertBySlug` (`PUT /forms/by-slug/:slug`),
|
|
11
|
+
* `seo.redirects.create` (`POST /redirects`) and
|
|
12
|
+
* `platform.provisionStore` (`POST /platform/provision-store`). Repeating one
|
|
13
|
+
* under the same key replays the first response instead of running it again
|
|
14
|
+
* — for `provisionStore` that means the run already in flight, not a second
|
|
15
|
+
* store on a code that is now taken; sending the same key with a DIFFERENT
|
|
16
|
+
* body is refused with 409. Everything else records the key on the wire and
|
|
17
|
+
* acts on nothing.
|
|
15
18
|
*
|
|
16
19
|
* Use a fresh key per intended write — a UUIDv7 is ideal — and reuse it only
|
|
17
20
|
* when retrying that same write. This SDK never retries a write on its own,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sixtyninecommerce/admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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": {
|