@sixtyninecommerce/admin 0.2.0 → 0.3.1
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 +144 -0
- package/dist/api-tokens/index.d.ts +5 -5
- package/dist/api-tokens/index.js +1 -0
- package/dist/audit/index.d.ts +168 -0
- package/dist/audit/index.js +71 -0
- package/dist/client.d.ts +13 -0
- package/dist/client.js +8 -0
- package/dist/index.d.ts +9 -3
- package/dist/index.js +5 -3
- package/dist/locations/index.d.ts +138 -0
- package/dist/locations/index.js +72 -0
- package/dist/meta/index.d.ts +146 -0
- package/dist/meta/index.js +142 -0
- package/dist/tenant/retention.d.ts +81 -0
- package/dist/tenant/retention.js +61 -0
- package/dist/types.d.ts +36 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -108,6 +108,89 @@ For a machine credential this is the only way to learn any of it without being r
|
|
|
108
108
|
tenant — permissions and entitlements have no meaning without a channel — and it is admin-plane
|
|
109
109
|
only: a storefront principal is refused with `PermissionDeniedError`.
|
|
110
110
|
|
|
111
|
+
### Locations
|
|
112
|
+
|
|
113
|
+
A location profile is a Vendure stock location plus the fields a head, a POS or an order-entry
|
|
114
|
+
client needs to address it. **`TenantContext.location` is a profile's `stockLocationId`** — not its
|
|
115
|
+
`code` — and setting it puts `x-location` on every request.
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
const locations = await admin.locations.list(); // a Page; the list is complete today
|
|
119
|
+
await admin.locations.upsert({
|
|
120
|
+
stockLocationId: locations.items[0]!.stockLocationId,
|
|
121
|
+
code: 'main-warehouse', // kebab-case, unique per store
|
|
122
|
+
name: 'Main Warehouse',
|
|
123
|
+
timezone: 'Europe/Amsterdam', // IANA zone name
|
|
124
|
+
openingHours: { mon: [{ open: '09:00', close: '17:30' }] },
|
|
125
|
+
address: { streetLine1: 'Keizersgracht 1', city: 'Amsterdam', postalCode: '1015 CJ', countryCode: 'NL' },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const register = admin.withTenant({ location: 'the-stock-location-id' });
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`upsert` states the **whole** profile: an omitted optional field clears it. `address` is the one
|
|
132
|
+
exception — it writes through to the stock location's own field, so omit it to leave that field
|
|
133
|
+
alone and pass `null` to clear it. `get()` answers `null` when a stock location has no profile, and
|
|
134
|
+
`delete()` answers `false` when there was none; neither is an error. Refusals arrive as
|
|
135
|
+
`LOCATION_CODE_TAKEN` (`ConflictError`), `INVALID_TIMEZONE` / `INVALID_OPENING_HOURS` /
|
|
136
|
+
`INVALID_ADDRESS` (`ValidationError`) and `LOCATION_NOT_FOUND` (`NotFoundError`).
|
|
137
|
+
|
|
138
|
+
An API token can be pinned to a set of them, after which it may only send an `x-location` naming
|
|
139
|
+
one of those (`LOCATION_NOT_ALLOWED` otherwise, enforced on the Admin API today):
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
await scoped.apiTokens.create({
|
|
143
|
+
name: 'POS register 1',
|
|
144
|
+
permissions: ['ReadOrder', 'ReadStockLocation'], // no POS template exists yet
|
|
145
|
+
locationIds: ['sl-1'],
|
|
146
|
+
});
|
|
147
|
+
await scoped.apiTokens.update('tok-1', { locationIds: null }); // null lifts it; omitting leaves it
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
An **empty** list is refused with `INVALID_LOCATION_RESTRICTION` — "allowed nowhere" is never what a
|
|
151
|
+
caller means, so pass `null` to lift the restriction instead.
|
|
152
|
+
|
|
153
|
+
`auth.whoami().locations` is meant to report the same list for the credential in hand, but the
|
|
154
|
+
plugins `GET /me` projection still answers `null` for every principal — read `apiTokens.list()` for
|
|
155
|
+
the restriction a token actually carries until that is wired.
|
|
156
|
+
|
|
157
|
+
### Retention and legal holds
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
await admin.tenant.retention.get(); // class, holdUntil, holds
|
|
161
|
+
await admin.tenant.retention.set({ retentionClass: 'gdpr-7y' }); // omitting holdUntil CLEARS it
|
|
162
|
+
await admin.tenant.retention.hold('litigation 2026-114'); // this principal's hold
|
|
163
|
+
await admin.tenant.retention.release('litigation 2026-114');
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Always the **active** channel — there is no channel argument, which is what lets a tenant-scoped
|
|
167
|
+
token hold its own store and reach no other. `retentionClass` is free text (default `standard`) that
|
|
168
|
+
the platform stores and does not interpret yet. While any hold stands, or `holdUntil` is in the
|
|
169
|
+
future, a hard delete of the store is refused with `RETENTION_HOLD_ACTIVE` (`BusinessRuleError`).
|
|
170
|
+
`release()` lifts only the calling principal's own hold: another's is `RETENTION_HOLD_NOT_YOURS`
|
|
171
|
+
(`PermissionDeniedError`) unless you are a SuperAdmin, which releases every hold. A bad class or
|
|
172
|
+
instant is `RETENTION_INPUT_INVALID` (`ValidationError`).
|
|
173
|
+
|
|
174
|
+
### Audit
|
|
175
|
+
|
|
176
|
+
The tenant's own forensic trail. It needs the `ReadAudit` permission, which is deliberately outside
|
|
177
|
+
the "Read-only"/"Full access" token shortcuts: a platform operator mints an *Audit reader* token,
|
|
178
|
+
a merchant administrator cannot.
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
const failures = await admin.audit.events({ outcome: 'failed', action: 'order.', limit: 50 });
|
|
182
|
+
for await (const event of failures) console.log(event.occurredAt, event.action, event.errorCode);
|
|
183
|
+
|
|
184
|
+
const timeline = await admin.audit.correlation(failures.items[0]!.correlationId);
|
|
185
|
+
timeline.events; timeline.calls; // one trace, both tables, oldest first
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`events` and `calls` are cursor pages (`.next()`, `for await`) and report no `total` — the feed
|
|
189
|
+
keyset-paginates and never counts. `correlation` is not a page: it is capped at 500 rows per table
|
|
190
|
+
and the two arrays are meant to be read together. `metadata`, `requestBody` and `responseBody` keep
|
|
191
|
+
their own key spellings, because they are evidence of what a vendor actually sent. A trace this
|
|
192
|
+
tenant did not touch answers two empty arrays rather than an error.
|
|
193
|
+
|
|
111
194
|
### Tenancy
|
|
112
195
|
|
|
113
196
|
A client with no `tenant.scope` may only call `auth.*` and `channels.byCode()`; every other module
|
|
@@ -126,12 +209,73 @@ write is never deduplicated.
|
|
|
126
209
|
|---|---|
|
|
127
210
|
| `content.forms.upsertBySlug` | yes — the CMS replays the first response for the same key |
|
|
128
211
|
| `seo.redirects.create` | yes — the plugins service does the same |
|
|
212
|
+
| `locations.*`, `tenant.retention.*`, `apiTokens.*` | no — they are Admin API writes |
|
|
129
213
|
| everything else | no; the key is recorded on the wire and acts on nothing |
|
|
130
214
|
|
|
131
215
|
Where it is honoured, the same key with a **different** body is refused with 409. This SDK never
|
|
132
216
|
retries a write on its own, so a repeat is always one you sent: use a fresh key per intended write
|
|
133
217
|
and reuse it only when retrying that exact write.
|
|
134
218
|
|
|
219
|
+
### Meta catalog
|
|
220
|
+
|
|
221
|
+
`admin.meta.catalog()` is the one call a UI makes before it can render anything: the labels, state
|
|
222
|
+
machines, permission and plugin catalogs, and JSON Schema for the platform's opaque channel
|
|
223
|
+
settings. It is readable by any authenticated principal and identical for every tenant.
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
const meta = await admin.meta.catalog();
|
|
227
|
+
|
|
228
|
+
meta.labels.orderState.PaymentSettled; // 'Payment Settled'
|
|
229
|
+
meta.labels.returnState.items_received; // 'Items received'
|
|
230
|
+
meta.labels.paymentStatus.overdue; // { label: 'Overdue', tone: 'danger' }
|
|
231
|
+
meta.transitions.return.approved; // ['items_received', 'settlement_pending', …]
|
|
232
|
+
meta.transitions.fulfillmentRank.Packed; // 3
|
|
233
|
+
meta.permissions.catalog; // every permission the Admin API registers
|
|
234
|
+
meta.plugins.catalog; // [{ key: 'myparcel', name: 'MyParcel', … }]
|
|
235
|
+
meta.settingsSchemas.shipping_countries; // a draft 2020-12 schema
|
|
236
|
+
meta.settingsSchemas.ai_bot_defaults; // …keyed by SETTING name, not camelised
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
| Member | What it holds |
|
|
240
|
+
|---|---|
|
|
241
|
+
| `labels` | `orderState`, `returnState`, `returnReason`, `paymentStatus`, `fulfillmentStatus`, `fulfillmentHoldReason`, `subscriptionStatus`, `quoteStatus`, `settlementMethod`, `creditNoteKind`, `auditService`, `auditCategory`, `auditEventOutcome`, `auditCallOutcome` |
|
|
242
|
+
| `transitions` | `quote`, `return` (`from` → allowed `to`), `fulfillmentRank` |
|
|
243
|
+
| `permissions` | `catalog`, `groups` (the role-editor grid), `roleTemplates`, `tokenTemplates` |
|
|
244
|
+
| `plugins` | `catalog`, `groupLabels` |
|
|
245
|
+
| `settingsSchemas` | a schema per opaque `Channel.customFields` column (ten today) |
|
|
246
|
+
|
|
247
|
+
**Member names are camelCase; the keys inside them are not.** Everything below a member is keyed by
|
|
248
|
+
platform data — `items_received`, `past_due`, `PaymentSettled`, `order_status_rules`, a schema's own
|
|
249
|
+
`targetState` — and this SDK preserves those keys exactly as the platform spells them. Look a label
|
|
250
|
+
up by the value you received on the wire, never by a camelised version of it.
|
|
251
|
+
|
|
252
|
+
Three things the settings schemas do not say out loud:
|
|
253
|
+
|
|
254
|
+
- each describes the **decoded** value, while the column stores `JSON.stringify` of it — so
|
|
255
|
+
validate `JSON.parse(customFields.socialLinks)` and write a string back;
|
|
256
|
+
- a schema states the shape, not every rule. Only four of the ten columns are validated server-side
|
|
257
|
+
at all — for the other six a bad value is not refused on write, it is dropped or ignored on read,
|
|
258
|
+
with no error anywhere (each description says which it is). And one rule is inexpressible:
|
|
259
|
+
`shipping_countries` and `tax_exclusive_countries` both refuse `["US","us"]` on write as a
|
|
260
|
+
duplicate, which `uniqueItems` cannot see. Schema-valid means "worth sending", not "certain to be
|
|
261
|
+
accepted" — handle the `ValidationError`, and do not skip your own validation;
|
|
262
|
+
- `transitions` is the state machine, not the whole gate. `return.approved → settled` is a legal
|
|
263
|
+
edge that the service still refuses when the channel requires a receipt first, and
|
|
264
|
+
`quote.approved → converted_to_order` is refused when the projected order misses the channel's
|
|
265
|
+
order minimum. Offer the action, handle the refusal.
|
|
266
|
+
|
|
267
|
+
`version` is a hash of everything else and changes only when the catalog does, so the client caches
|
|
268
|
+
the document for its own lifetime and the second call costs nothing. `admin.meta.refresh()` fetches
|
|
269
|
+
again for a long-lived process that wants to pick up a redeploy; when the version has not moved it
|
|
270
|
+
keeps the object you already hold, so identity tells you whether anything changed. A client derived
|
|
271
|
+
with `withTenant()` has its own cache.
|
|
272
|
+
|
|
273
|
+
Concurrent first calls share one request, and a `signal` passed to `catalog()` cancels only your own
|
|
274
|
+
wait — never the shared request, and never another caller's. So handing it a framework-supplied
|
|
275
|
+
signal (a TanStack Query `queryFn` gets one for free) is safe: one component unmounting cannot fail
|
|
276
|
+
the catalog for the rest of the page. `refresh()` is not shared, so its `signal` cancels the request
|
|
277
|
+
itself.
|
|
278
|
+
|
|
135
279
|
### Errors
|
|
136
280
|
|
|
137
281
|
One hierarchy, all re-exported from this package so you need no direct dependency on
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { type Page, type Transport } from '@sixtyninecommerce/core';
|
|
2
2
|
import type { ApiToken, Call, CreateApiTokenInput, ListOptions, UpdateApiTokenInput } from '../types.js';
|
|
3
|
-
export declare const API_TOKEN_FIELDS = "\n id\n name\n tokenPrefix\n permissions\n channelId\n expiresAt\n lastUsedAt\n revokedAt\n createdAt\n";
|
|
4
|
-
export declare const LIST_QUERY = "\n query ApiTokens($options: ApiTokenListOptions) {\n apiTokens(options: $options) {\n totalItems\n items { \n id\n name\n tokenPrefix\n permissions\n channelId\n expiresAt\n lastUsedAt\n revokedAt\n createdAt\n }\n }\n }\n";
|
|
5
|
-
export declare const CREATE_MUTATION = "\n mutation CreateApiToken($input: CreateApiTokenInput!) {\n createApiToken(input: $input) {\n token\n apiToken { \n id\n name\n tokenPrefix\n permissions\n channelId\n expiresAt\n lastUsedAt\n revokedAt\n createdAt\n }\n }\n }\n";
|
|
6
|
-
export declare const UPDATE_MUTATION = "\n mutation UpdateApiToken($input: UpdateApiTokenInput!) {\n updateApiToken(input: $input) { \n id\n name\n tokenPrefix\n permissions\n channelId\n expiresAt\n lastUsedAt\n revokedAt\n createdAt\n }\n }\n";
|
|
7
|
-
export declare const REVOKE_MUTATION = "\n mutation RevokeApiToken($id: ID!) {\n revokeApiToken(id: $id) { \n id\n name\n tokenPrefix\n permissions\n channelId\n expiresAt\n lastUsedAt\n revokedAt\n createdAt\n }\n }\n";
|
|
3
|
+
export declare const API_TOKEN_FIELDS = "\n id\n name\n tokenPrefix\n permissions\n channelId\n locationIds\n expiresAt\n lastUsedAt\n revokedAt\n createdAt\n";
|
|
4
|
+
export declare const LIST_QUERY = "\n query ApiTokens($options: ApiTokenListOptions) {\n apiTokens(options: $options) {\n totalItems\n items { \n id\n name\n tokenPrefix\n permissions\n channelId\n locationIds\n expiresAt\n lastUsedAt\n revokedAt\n createdAt\n }\n }\n }\n";
|
|
5
|
+
export declare const CREATE_MUTATION = "\n mutation CreateApiToken($input: CreateApiTokenInput!) {\n createApiToken(input: $input) {\n token\n apiToken { \n id\n name\n tokenPrefix\n permissions\n channelId\n locationIds\n expiresAt\n lastUsedAt\n revokedAt\n createdAt\n }\n }\n }\n";
|
|
6
|
+
export declare const UPDATE_MUTATION = "\n mutation UpdateApiToken($input: UpdateApiTokenInput!) {\n updateApiToken(input: $input) { \n id\n name\n tokenPrefix\n permissions\n channelId\n locationIds\n expiresAt\n lastUsedAt\n revokedAt\n createdAt\n }\n }\n";
|
|
7
|
+
export declare const REVOKE_MUTATION = "\n mutation RevokeApiToken($id: ID!) {\n revokeApiToken(id: $id) { \n id\n name\n tokenPrefix\n permissions\n channelId\n locationIds\n expiresAt\n lastUsedAt\n revokedAt\n createdAt\n }\n }\n";
|
|
8
8
|
export declare const DELETE_MUTATION = "\n mutation DeleteApiTokens($ids: [ID!]!) {\n deleteApiTokens(ids: $ids) {\n result\n message\n }\n }\n";
|
|
9
9
|
/** The raw token is shown exactly once, at creation. */
|
|
10
10
|
export interface CreatedApiToken {
|
package/dist/api-tokens/index.js
CHANGED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { type Page, type Transport } from '@sixtyninecommerce/core';
|
|
2
|
+
import type { Call, ListOptions } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* One row of the tenant's own audit trail — who did what, to which record, and
|
|
5
|
+
* whether it worked.
|
|
6
|
+
*
|
|
7
|
+
* Every field the platform writes is typed; the index signature is there
|
|
8
|
+
* because the endpoint answers `SELECT *` over a partitioned table that gains
|
|
9
|
+
* columns additively (`error_code` arrived that way), so a consumer on an older
|
|
10
|
+
* SDK still receives them.
|
|
11
|
+
*/
|
|
12
|
+
export interface AuditEvent {
|
|
13
|
+
id: string;
|
|
14
|
+
/** ISO-8601 UTC. */
|
|
15
|
+
occurredAt: string;
|
|
16
|
+
/** Always the caller's own channel on these routes; null for a platform-level row. */
|
|
17
|
+
channelId: string | null;
|
|
18
|
+
/** `vendure` | `cms` | `plugins` | `dashboard`. */
|
|
19
|
+
service: string;
|
|
20
|
+
/** `admin_action` | `customer_action` | `auth` | `integration` | `job` | `webhook` | `system`. */
|
|
21
|
+
category: string;
|
|
22
|
+
/** `domain.resource.verb`, e.g. `afas.invoice.push`. */
|
|
23
|
+
action: string;
|
|
24
|
+
/** `user` | `customer` | `api_token` | `system` | `service` | `anonymous`. */
|
|
25
|
+
actorType: string;
|
|
26
|
+
actorId: string | null;
|
|
27
|
+
/** A token's name or a human's email. Never both — see the actor rules in the audit ADR. */
|
|
28
|
+
actorLabel: string | null;
|
|
29
|
+
onBehalfOfId: string | null;
|
|
30
|
+
targetType: string | null;
|
|
31
|
+
targetId: string | null;
|
|
32
|
+
targetLabel: string | null;
|
|
33
|
+
/** `succeeded` | `failed` | `partial` | `skipped` | `rejected`. */
|
|
34
|
+
outcome: string;
|
|
35
|
+
/** The failure taxonomy alerting groups on (`data_validation`, `transient`, …). */
|
|
36
|
+
errorClass: string | null;
|
|
37
|
+
/** The specific SCREAMING_SNAKE reason. */
|
|
38
|
+
errorCode: string | null;
|
|
39
|
+
/** Operator text, sanitized by the writer. Never a customer-facing message. */
|
|
40
|
+
errorDetail: string | null;
|
|
41
|
+
/** W3C trace id — the key {@link AuditModule.correlation} takes. */
|
|
42
|
+
correlationId: string;
|
|
43
|
+
durationMs: number | null;
|
|
44
|
+
/** `merchant_visible` | `internal`. Every row is `internal` today (no emit site sets it). */
|
|
45
|
+
sensitivity: string;
|
|
46
|
+
/**
|
|
47
|
+
* Free-form bag written by the emitting site. Its own keys are NEVER
|
|
48
|
+
* case-converted (`preserveKeys`), so they read exactly as they were stored.
|
|
49
|
+
*/
|
|
50
|
+
metadata: Record<string, unknown> | null;
|
|
51
|
+
[extra: string]: unknown;
|
|
52
|
+
}
|
|
53
|
+
/** One call record: the evidence of a single request to or from an integration. */
|
|
54
|
+
export interface AuditCall {
|
|
55
|
+
id: string;
|
|
56
|
+
/** ISO-8601 UTC. */
|
|
57
|
+
occurredAt: string;
|
|
58
|
+
channelId: string | null;
|
|
59
|
+
correlationId: string;
|
|
60
|
+
service: string;
|
|
61
|
+
/** `afas` | `exactonline` | `mollie` | `meilisearch` | … */
|
|
62
|
+
integration: string;
|
|
63
|
+
/** `outbound` | `inbound`. */
|
|
64
|
+
direction: string;
|
|
65
|
+
/** `http` | `sdk` | `graphql` | `soap` | `sftp` | `smtp`. */
|
|
66
|
+
transport: string;
|
|
67
|
+
operation: string;
|
|
68
|
+
/** Query strings are stripped before persistence. */
|
|
69
|
+
endpoint: string | null;
|
|
70
|
+
/** The REAL upstream status, or null for a transport that has none (SDK, SFTP). */
|
|
71
|
+
statusCode: number | null;
|
|
72
|
+
/** `succeeded` | `failed` | `timeout`. */
|
|
73
|
+
outcome: string;
|
|
74
|
+
/** The remote receipt (an AFAS V-number, a Mollie payment id, a Meili task uid). */
|
|
75
|
+
remoteRef: string | null;
|
|
76
|
+
attempt: number;
|
|
77
|
+
durationMs: number | null;
|
|
78
|
+
/** Redacted and capped by the writer; keys preserved verbatim. */
|
|
79
|
+
requestBody: unknown;
|
|
80
|
+
responseBody: unknown;
|
|
81
|
+
error: string | null;
|
|
82
|
+
errorCode: string | null;
|
|
83
|
+
metadata: Record<string, unknown> | null;
|
|
84
|
+
[extra: string]: unknown;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Filters for {@link AuditModule.events}. Every one is an exact match except
|
|
88
|
+
* `action` (prefix, so `order.` finds the whole family) and `q` (free text over
|
|
89
|
+
* action, target label, error detail and actor label).
|
|
90
|
+
*
|
|
91
|
+
* `dateFrom`/`dateTo` are inclusive ISO-8601. A value with no offset is read as
|
|
92
|
+
* UTC by the service, and a bare date covers that whole UTC day — so pass an
|
|
93
|
+
* explicit offset whenever the tenant's own day is what you mean.
|
|
94
|
+
*
|
|
95
|
+
* `limit` is 1–200, default 50. A larger value is REJECTED with
|
|
96
|
+
* `ValidationError`, not clamped — a silently short page would read as "that is
|
|
97
|
+
* everything". Walk the pages with `.next()` or `for await` instead of asking
|
|
98
|
+
* for a bigger one.
|
|
99
|
+
*/
|
|
100
|
+
export interface AuditEventFilter extends ListOptions {
|
|
101
|
+
service?: string;
|
|
102
|
+
category?: string;
|
|
103
|
+
/** Prefix match, e.g. `order.` */
|
|
104
|
+
action?: string;
|
|
105
|
+
actorId?: string;
|
|
106
|
+
targetType?: string;
|
|
107
|
+
targetId?: string;
|
|
108
|
+
outcome?: string;
|
|
109
|
+
errorClass?: string;
|
|
110
|
+
correlationId?: string;
|
|
111
|
+
/** Free text over action, target label, error detail and actor label. */
|
|
112
|
+
q?: string;
|
|
113
|
+
dateFrom?: string;
|
|
114
|
+
dateTo?: string;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Filters for {@link AuditModule.calls}. See {@link AuditEventFilter} for the
|
|
118
|
+
* date rules and the 1–200 `limit` cap, which are the same here.
|
|
119
|
+
*/
|
|
120
|
+
export interface AuditCallFilter extends ListOptions {
|
|
121
|
+
integration?: string;
|
|
122
|
+
direction?: string;
|
|
123
|
+
outcome?: string;
|
|
124
|
+
/** The real upstream HTTP status. */
|
|
125
|
+
statusCode?: number;
|
|
126
|
+
correlationId?: string;
|
|
127
|
+
/** Free text over operation, error and receipt. */
|
|
128
|
+
q?: string;
|
|
129
|
+
dateFrom?: string;
|
|
130
|
+
dateTo?: string;
|
|
131
|
+
}
|
|
132
|
+
/** Everything one trace touched inside this tenant, oldest first. */
|
|
133
|
+
export interface AuditCorrelation {
|
|
134
|
+
events: AuditEvent[];
|
|
135
|
+
calls: AuditCall[];
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The tenant's own audit trail (`GET /audit/mine/*`).
|
|
139
|
+
*
|
|
140
|
+
* **What it is not.** These are not the platform operator's cross-tenant
|
|
141
|
+
* forensic routes (`/audit/*`), and they are not a merchant Activity feed
|
|
142
|
+
* either: they serve every row of this channel, `internal` sensitivity
|
|
143
|
+
* included. The permission that opens them, `ReadAudit`, is deliberately
|
|
144
|
+
* outside `PERMISSION_GROUPS`, so it is not in the "Read-only" or "Full access"
|
|
145
|
+
* token shortcuts and a merchant administrator cannot mint it — an operator
|
|
146
|
+
* mints an "Audit reader" token and hands it over. A credential without it is
|
|
147
|
+
* refused with `PermissionDeniedError`.
|
|
148
|
+
*
|
|
149
|
+
* **Freshness.** The routes are `@FreshAuth()`: revoking the token stops these
|
|
150
|
+
* reads at once rather than up to a permission-cache later. That costs one
|
|
151
|
+
* extra round trip per call, which is why these are poller routes, not
|
|
152
|
+
* something to put behind a table a browser repaints.
|
|
153
|
+
*/
|
|
154
|
+
export interface AuditModule {
|
|
155
|
+
events(filter?: AuditEventFilter): Promise<Page<AuditEvent>>;
|
|
156
|
+
calls(filter?: AuditCallFilter): Promise<Page<AuditCall>>;
|
|
157
|
+
/**
|
|
158
|
+
* Both tables for one W3C trace id, capped at 500 rows each and NOT paged —
|
|
159
|
+
* a trace is a bounded thing, and the two arrays are meant to be read
|
|
160
|
+
* together as one timeline.
|
|
161
|
+
*
|
|
162
|
+
* A trace can legitimately span tenants (a platform job touching several).
|
|
163
|
+
* Only this channel's rows come back, so an unrelated trace id answers with
|
|
164
|
+
* two empty arrays rather than an error.
|
|
165
|
+
*/
|
|
166
|
+
correlation(traceId: string, options?: Call): Promise<AuditCorrelation>;
|
|
167
|
+
}
|
|
168
|
+
export declare function createAuditModule(transport: Transport): AuditModule;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createPage, PLATFORM_PASSTHROUGH_KEYS, readRestPage, } from '@sixtyninecommerce/core';
|
|
2
|
+
/**
|
|
3
|
+
* The audit rows carry three JSONB bags — `metadata`, `request_body`,
|
|
4
|
+
* `response_body` — whose keys are a vendor's or an emitting site's own. All
|
|
5
|
+
* three are in the platform passthrough set, so their subtrees survive the
|
|
6
|
+
* camelisation that the surrounding row goes through.
|
|
7
|
+
*/
|
|
8
|
+
const AUDIT_PASSTHROUGH = PLATFORM_PASSTHROUGH_KEYS;
|
|
9
|
+
/**
|
|
10
|
+
* The wire cursor is `?before=`, because these lists run newest-first and the
|
|
11
|
+
* keyset walks backwards through `(occurred_at, id)`. The SDK's name for "the
|
|
12
|
+
* cursor the previous page handed me" is `after` on every list it has
|
|
13
|
+
* (`ListOptions.after`), and the value is opaque either way — so the direction
|
|
14
|
+
* stays the endpoint's business and consumers keep one vocabulary.
|
|
15
|
+
*/
|
|
16
|
+
function pageQuery(filter) {
|
|
17
|
+
return { before: filter?.after, limit: filter?.limit };
|
|
18
|
+
}
|
|
19
|
+
export function createAuditModule(transport) {
|
|
20
|
+
async function events(filter) {
|
|
21
|
+
const body = await transport.rest('plugins', {
|
|
22
|
+
method: 'GET',
|
|
23
|
+
path: '/audit/mine/events',
|
|
24
|
+
query: {
|
|
25
|
+
...pageQuery(filter),
|
|
26
|
+
service: filter?.service,
|
|
27
|
+
category: filter?.category,
|
|
28
|
+
action: filter?.action,
|
|
29
|
+
actor_id: filter?.actorId,
|
|
30
|
+
target_type: filter?.targetType,
|
|
31
|
+
target_id: filter?.targetId,
|
|
32
|
+
outcome: filter?.outcome,
|
|
33
|
+
error_class: filter?.errorClass,
|
|
34
|
+
correlation_id: filter?.correlationId,
|
|
35
|
+
q: filter?.q,
|
|
36
|
+
date_from: filter?.dateFrom,
|
|
37
|
+
date_to: filter?.dateTo,
|
|
38
|
+
},
|
|
39
|
+
}, { ...filter, preserveKeys: AUDIT_PASSTHROUGH });
|
|
40
|
+
// No `total`: the endpoint keyset-paginates and never counts, so `Page.total`
|
|
41
|
+
// is absent here rather than zero.
|
|
42
|
+
const { items, meta } = readRestPage(body);
|
|
43
|
+
return createPage(items, meta, (cursor) => events({ ...filter, after: cursor }));
|
|
44
|
+
}
|
|
45
|
+
async function calls(filter) {
|
|
46
|
+
const body = await transport.rest('plugins', {
|
|
47
|
+
method: 'GET',
|
|
48
|
+
path: '/audit/mine/calls',
|
|
49
|
+
query: {
|
|
50
|
+
...pageQuery(filter),
|
|
51
|
+
integration: filter?.integration,
|
|
52
|
+
direction: filter?.direction,
|
|
53
|
+
outcome: filter?.outcome,
|
|
54
|
+
status_code: filter?.statusCode,
|
|
55
|
+
correlation_id: filter?.correlationId,
|
|
56
|
+
q: filter?.q,
|
|
57
|
+
date_from: filter?.dateFrom,
|
|
58
|
+
date_to: filter?.dateTo,
|
|
59
|
+
},
|
|
60
|
+
}, { ...filter, preserveKeys: AUDIT_PASSTHROUGH });
|
|
61
|
+
const { items, meta } = readRestPage(body);
|
|
62
|
+
return createPage(items, meta, (cursor) => calls({ ...filter, after: cursor }));
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
events,
|
|
66
|
+
calls,
|
|
67
|
+
correlation(traceId, options) {
|
|
68
|
+
return transport.rest('plugins', { method: 'GET', path: `/audit/mine/correlation/${encodeURIComponent(traceId)}` }, { ...options, preserveKeys: AUDIT_PASSTHROUGH });
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
package/dist/client.d.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { type DeprecationNotice, type RateLimitInfo, type RetryPolicy, type TenantContext, type TenantScope } from '@sixtyninecommerce/core';
|
|
2
2
|
import { type ApiTokensModule } from './api-tokens/index.js';
|
|
3
|
+
import { type AuditModule } from './audit/index.js';
|
|
3
4
|
import { type AuthModule } from './auth/index.js';
|
|
4
5
|
import { type FormsModule } from './content/forms.js';
|
|
6
|
+
import { type LocationsModule } from './locations/index.js';
|
|
7
|
+
import { type MetaModule } from './meta/index.js';
|
|
5
8
|
import { type RedirectsModule } from './seo/redirects.js';
|
|
6
9
|
import { type ChannelsModule } from './tenant/channels.js';
|
|
10
|
+
import { type RetentionModule } from './tenant/retention.js';
|
|
7
11
|
export interface AdminClientConfig {
|
|
8
12
|
urls: {
|
|
9
13
|
vendureUrl: string;
|
|
@@ -39,13 +43,22 @@ export interface AdminClientConfig {
|
|
|
39
43
|
export interface AdminClient {
|
|
40
44
|
auth: AuthModule;
|
|
41
45
|
apiTokens: ApiTokensModule;
|
|
46
|
+
audit: AuditModule;
|
|
42
47
|
channels: ChannelsModule;
|
|
43
48
|
content: {
|
|
44
49
|
forms: FormsModule;
|
|
45
50
|
};
|
|
51
|
+
/** Stock-location profiles (spec §8.7); `TenantContext.location` names one by id. */
|
|
52
|
+
locations: LocationsModule;
|
|
53
|
+
/** Labels, transitions, permission and plugin catalogs, settings schemas. */
|
|
54
|
+
meta: MetaModule;
|
|
46
55
|
seo: {
|
|
47
56
|
redirects: RedirectsModule;
|
|
48
57
|
};
|
|
58
|
+
/** The calling tenant's own settings. Always the ACTIVE channel — no channel argument. */
|
|
59
|
+
tenant: {
|
|
60
|
+
retention: RetentionModule;
|
|
61
|
+
};
|
|
49
62
|
/** Human SuperAdmin surface. Empty in phase 0. */
|
|
50
63
|
platform: Record<string, never>;
|
|
51
64
|
/** A derived client on another tenant, sharing this one's principal. */
|
package/dist/client.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { createChannelResolver, createTransport, mergeTenant, UNSCOPED_TENANT, ValidationError, } from '@sixtyninecommerce/core';
|
|
2
2
|
import { createApiTokensModule } from './api-tokens/index.js';
|
|
3
|
+
import { createAuditModule } from './audit/index.js';
|
|
3
4
|
import { createAuthModule } from './auth/index.js';
|
|
4
5
|
import { createFormsModule } from './content/forms.js';
|
|
6
|
+
import { createLocationsModule } from './locations/index.js';
|
|
7
|
+
import { createMetaModule } from './meta/index.js';
|
|
5
8
|
import { createRedirectsModule } from './seo/redirects.js';
|
|
6
9
|
import { createChannelsModule } from './tenant/channels.js';
|
|
10
|
+
import { createRetentionModule } from './tenant/retention.js';
|
|
7
11
|
/**
|
|
8
12
|
* An admin credential in a browser is a credential the whole internet can read
|
|
9
13
|
* (spec §18 amendment 3, §15.21). A SESSION bearer is fine there — that is what
|
|
@@ -213,9 +217,13 @@ function buildClient(config, overrides, resolver, shared) {
|
|
|
213
217
|
},
|
|
214
218
|
}),
|
|
215
219
|
apiTokens: createApiTokensModule(transport),
|
|
220
|
+
audit: createAuditModule(transport),
|
|
216
221
|
channels: createChannelsModule(resolver),
|
|
217
222
|
content: { forms: createFormsModule(transport) },
|
|
223
|
+
locations: createLocationsModule(transport),
|
|
224
|
+
meta: createMetaModule(transport),
|
|
218
225
|
seo: { redirects: createRedirectsModule(transport) },
|
|
226
|
+
tenant: { retention: createRetentionModule(transport) },
|
|
219
227
|
platform: {},
|
|
220
228
|
withTenant(next) {
|
|
221
229
|
// `mergeTenant`, not a spread: it merges `actor` FIELD BY FIELD, so a
|
package/dist/index.d.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* `@sixtyninecommerce/admin` — the back-office SDK.
|
|
3
3
|
*
|
|
4
4
|
* Every capability of the platform is a method here (the parity rule, spec §5).
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Shipped so far: auth, personal API tokens, channel resolution, CMS forms, SEO
|
|
6
|
+
* redirects, the meta catalog, the tenant's own audit trail, its retention
|
|
7
|
+
* policy and its stock location profiles — plus the `platform` guard every
|
|
8
|
+
* cross-tenant method added later runs through.
|
|
8
9
|
*
|
|
9
10
|
* Server-side by default. A session bearer is welcome in a browser — the
|
|
10
11
|
* dashboard is one — but constructing a client with an API token there throws
|
|
@@ -24,11 +25,16 @@ export type { DeprecationNotice, Page, PageMeta, RateLimitInfo, RetryPolicy, } f
|
|
|
24
25
|
*/
|
|
25
26
|
export { AuthenticationError, BusinessRuleError, ConflictError, type ErrorDetail, NotFoundError, PermissionDeniedError, PlatformError, type ProblemDetails, RateLimitedError, TransportError, UpstreamError, ValidationError, } from '@sixtyninecommerce/core';
|
|
26
27
|
export type { ApiTokensModule, CreatedApiToken } from './api-tokens/index.js';
|
|
28
|
+
export type { AuditCall, AuditCallFilter, AuditCorrelation, AuditEvent, AuditEventFilter, AuditModule, } from './audit/index.js';
|
|
27
29
|
export type { AuthModule } from './auth/index.js';
|
|
28
30
|
export { type AdminClient, type AdminClientConfig, createAdminClient, type SessionConfig, } from './client.js';
|
|
29
31
|
export type { Form, FormField, FormFieldInput, FormFieldTranslation, FormFieldTranslationInput, FormInput, FormsModule, FormTranslation, FormTranslationInput, } from './content/forms.js';
|
|
30
32
|
export { AdminProjectionError } from './errors.js';
|
|
33
|
+
export type { LocationProfile, LocationStockLocation, LocationsModule, OpeningHours, OpeningHoursDay, OpeningHoursInterval, StockLocationAddress, StockLocationAddressInput, UpsertLocationProfileInput, } from './locations/index.js';
|
|
34
|
+
export { OPENING_HOURS_DAYS } from './locations/index.js';
|
|
35
|
+
export type { ApiTokenTemplate, JsonSchema, LabelMap, MetaCatalog, MetaCatalogLabels, MetaCatalogPermissions, MetaCatalogPlugins, MetaCatalogTransitions, MetaModule, PermissionGroup, PluginCatalogEntry, RoleTemplate, StatusMetaMap, StatusTone, TransitionMap, } from './meta/index.js';
|
|
31
36
|
export { requireHumanSession } from './platform/guard.js';
|
|
32
37
|
export type { Redirect, RedirectCreateInput, RedirectsModule, RedirectUpdateInput, } from './seo/redirects.js';
|
|
33
38
|
export type { ChannelsModule } from './tenant/channels.js';
|
|
39
|
+
export type { ChannelRetention, RetentionHold, RetentionModule, SetRetentionInput, } from './tenant/retention.js';
|
|
34
40
|
export type { ApiToken, Call, ChannelInfo, CreateApiTokenInput, ListOptions, Me, TenantContext, TenantScope, UpdateApiTokenInput, WhoAmI, } from './types.js';
|
package/dist/index.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* `@sixtyninecommerce/admin` — the back-office SDK.
|
|
3
3
|
*
|
|
4
4
|
* Every capability of the platform is a method here (the parity rule, spec §5).
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Shipped so far: auth, personal API tokens, channel resolution, CMS forms, SEO
|
|
6
|
+
* redirects, the meta catalog, the tenant's own audit trail, its retention
|
|
7
|
+
* policy and its stock location profiles — plus the `platform` guard every
|
|
8
|
+
* cross-tenant method added later runs through.
|
|
8
9
|
*
|
|
9
10
|
* Server-side by default. A session bearer is welcome in a browser — the
|
|
10
11
|
* dashboard is one — but constructing a client with an API token there throws
|
|
@@ -18,4 +19,5 @@
|
|
|
18
19
|
export { AuthenticationError, BusinessRuleError, ConflictError, NotFoundError, PermissionDeniedError, PlatformError, RateLimitedError, TransportError, UpstreamError, ValidationError, } from '@sixtyninecommerce/core';
|
|
19
20
|
export { createAdminClient, } from './client.js';
|
|
20
21
|
export { AdminProjectionError } from './errors.js';
|
|
22
|
+
export { OPENING_HOURS_DAYS } from './locations/index.js';
|
|
21
23
|
export { requireHumanSession } from './platform/guard.js';
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { type Page, type Transport } from '@sixtyninecommerce/core';
|
|
2
|
+
import type { Call } from '../types.js';
|
|
3
|
+
/** `openingHours` keys: lowercase, three letters, Monday first. */
|
|
4
|
+
export declare const OPENING_HOURS_DAYS: readonly ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
|
5
|
+
export type OpeningHoursDay = (typeof OPENING_HOURS_DAYS)[number];
|
|
6
|
+
export interface OpeningHoursInterval {
|
|
7
|
+
/** `HH:mm`, 24-hour. */
|
|
8
|
+
open: string;
|
|
9
|
+
/** `HH:mm`, 24-hour. May be EARLIER than `open`: that is an overnight shift, and it is allowed. */
|
|
10
|
+
close: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Trading hours, shape-validated only — the platform does not compute an "open
|
|
14
|
+
* now" from them, and a day may carry at most 12 intervals. A missing day means
|
|
15
|
+
* "nothing stated", not "closed".
|
|
16
|
+
*/
|
|
17
|
+
export type OpeningHours = Partial<Record<OpeningHoursDay, OpeningHoursInterval[]>>;
|
|
18
|
+
/**
|
|
19
|
+
* The postal address, read from and written to
|
|
20
|
+
* `StockLocation.customFields.address`. There is no second copy.
|
|
21
|
+
*
|
|
22
|
+
* Every field is optional on the way OUT because that column predates this API
|
|
23
|
+
* and holds whatever earlier writers put there.
|
|
24
|
+
*/
|
|
25
|
+
export interface StockLocationAddress {
|
|
26
|
+
contactName?: string | null;
|
|
27
|
+
company?: string | null;
|
|
28
|
+
streetLine1?: string | null;
|
|
29
|
+
streetLine2?: string | null;
|
|
30
|
+
city?: string | null;
|
|
31
|
+
province?: string | null;
|
|
32
|
+
postalCode?: string | null;
|
|
33
|
+
/** ISO 3166-1 alpha-2, upper case. */
|
|
34
|
+
countryCode?: string | null;
|
|
35
|
+
phoneNumber?: string | null;
|
|
36
|
+
}
|
|
37
|
+
/** On the way IN street, city, postal code and country are required; the server refuses less. */
|
|
38
|
+
export interface StockLocationAddressInput {
|
|
39
|
+
contactName?: string | null;
|
|
40
|
+
streetLine1: string;
|
|
41
|
+
streetLine2?: string | null;
|
|
42
|
+
city: string;
|
|
43
|
+
province?: string | null;
|
|
44
|
+
postalCode: string;
|
|
45
|
+
/** ISO 3166-1 alpha-2; stored upper case. */
|
|
46
|
+
countryCode: string;
|
|
47
|
+
company?: string | null;
|
|
48
|
+
phoneNumber?: string | null;
|
|
49
|
+
}
|
|
50
|
+
/** The stock location a profile belongs to, as much of it as the profile selects. */
|
|
51
|
+
export interface LocationStockLocation {
|
|
52
|
+
id: string;
|
|
53
|
+
name: string;
|
|
54
|
+
description: string;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A physical place: a Vendure `StockLocation` plus the fields a head, a POS or
|
|
58
|
+
* an order-entry client needs to address it.
|
|
59
|
+
*
|
|
60
|
+
* **`TenantContext.location` is the `stockLocationId` of one of these** — not
|
|
61
|
+
* the `code`. Set it on the client (`createAdminClient({ tenant: { location } })`,
|
|
62
|
+
* `withTenant({ location })`, or per call) and every request carries
|
|
63
|
+
* `x-location`; the server refuses an id that does not belong to the request's
|
|
64
|
+
* channel, and an API token restricted with `locationIds` is refused for any
|
|
65
|
+
* location outside its list.
|
|
66
|
+
*/
|
|
67
|
+
export interface LocationProfile {
|
|
68
|
+
stockLocationId: string;
|
|
69
|
+
/** Kebab-case handle, unique among this store's locations. */
|
|
70
|
+
code: string;
|
|
71
|
+
name: string;
|
|
72
|
+
address: StockLocationAddress | null;
|
|
73
|
+
/** IANA zone name; null means the store's own time zone applies. */
|
|
74
|
+
timezone: string | null;
|
|
75
|
+
phone: string | null;
|
|
76
|
+
openingHours: OpeningHours | null;
|
|
77
|
+
stockLocation: LocationStockLocation;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The WHOLE profile, not a patch: an omitted optional field CLEARS it.
|
|
81
|
+
*
|
|
82
|
+
* `address` is the one exception, because it writes through to the stock
|
|
83
|
+
* location's own field: omit it to leave that field alone, pass `null` to clear
|
|
84
|
+
* it.
|
|
85
|
+
*/
|
|
86
|
+
export interface UpsertLocationProfileInput {
|
|
87
|
+
stockLocationId: string;
|
|
88
|
+
/** Kebab-case, 1–64 characters, unique per channel (`LOCATION_CODE_TAKEN` otherwise). */
|
|
89
|
+
code: string;
|
|
90
|
+
name: string;
|
|
91
|
+
/** IANA zone name, validated by constructing an `Intl.DateTimeFormat` (`INVALID_TIMEZONE`). */
|
|
92
|
+
timezone?: string | null;
|
|
93
|
+
phone?: string | null;
|
|
94
|
+
openingHours?: OpeningHours | null;
|
|
95
|
+
address?: StockLocationAddressInput | null;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Location profiles for the active channel (spec §8.7).
|
|
99
|
+
*
|
|
100
|
+
* Reading needs `ReadStockLocation`, writing `UpdateStockLocation`. Everything
|
|
101
|
+
* is scoped to the channel the client is on: a stock location belonging to
|
|
102
|
+
* another tenant is invisible to `list`/`get` and unwritable by `upsert`.
|
|
103
|
+
*
|
|
104
|
+
* **Coded refusals**, delivered as GraphQL errors with `extensions.code`:
|
|
105
|
+
* `LOCATION_NOT_FOUND` (→ `NotFoundError`) — which is also the answer for a
|
|
106
|
+
* stock location belonging to another tenant, deliberately worded so a caller
|
|
107
|
+
* cannot probe another store's ids; `LOCATION_CODE_TAKEN` (→ `ConflictError`);
|
|
108
|
+
* and `INVALID_LOCATION_CODE` / `INVALID_LOCATION_NAME` /
|
|
109
|
+
* `INVALID_LOCATION_PHONE` / `INVALID_TIMEZONE` / `INVALID_OPENING_HOURS` /
|
|
110
|
+
* `INVALID_ADDRESS` (→ `ValidationError`).
|
|
111
|
+
*
|
|
112
|
+
* `LOCATION_NOT_IN_CHANNEL` is NOT one of these: it belongs to the two places
|
|
113
|
+
* that take a location id from outside this surface — `apiTokens.create`/
|
|
114
|
+
* `update` with a foreign `locationIds` entry, and the `x-location` gate — and
|
|
115
|
+
* arrives as a `ValidationError` from both.
|
|
116
|
+
*/
|
|
117
|
+
export interface LocationsModule {
|
|
118
|
+
/**
|
|
119
|
+
* Every profile of the active channel.
|
|
120
|
+
*
|
|
121
|
+
* A `Page` because every list in this SDK is one (spec §9.2), not because
|
|
122
|
+
* this query pages: `locationProfiles` answers the complete array, so the
|
|
123
|
+
* page is always the last one and `total` is its length. If the endpoint ever
|
|
124
|
+
* grows a cursor, consumers walking it with `.next()` or `for await` keep
|
|
125
|
+
* working unchanged.
|
|
126
|
+
*/
|
|
127
|
+
list(options?: Call): Promise<Page<LocationProfile>>;
|
|
128
|
+
/** The profile of one stock location, or null when it has none (or is not this channel's). */
|
|
129
|
+
get(stockLocationId: string, options?: Call): Promise<LocationProfile | null>;
|
|
130
|
+
upsert(input: UpsertLocationProfileInput, options?: Call): Promise<LocationProfile>;
|
|
131
|
+
/** True when a profile was removed, false when there was none. The stock location survives. */
|
|
132
|
+
delete(stockLocationId: string, options?: Call): Promise<boolean>;
|
|
133
|
+
}
|
|
134
|
+
export declare const LOCATION_PROFILES_QUERY = "\n query LocationProfiles {\n locationProfiles { \n stockLocationId\n code\n name\n timezone\n phone\n openingHours\n address {\n contactName\n company\n streetLine1\n streetLine2\n city\n province\n postalCode\n countryCode\n phoneNumber\n }\n stockLocation {\n id\n name\n description\n }\n }\n }\n";
|
|
135
|
+
export declare const LOCATION_PROFILE_QUERY = "\n query LocationProfile($stockLocationId: ID!) {\n locationProfile(stockLocationId: $stockLocationId) { \n stockLocationId\n code\n name\n timezone\n phone\n openingHours\n address {\n contactName\n company\n streetLine1\n streetLine2\n city\n province\n postalCode\n countryCode\n phoneNumber\n }\n stockLocation {\n id\n name\n description\n }\n }\n }\n";
|
|
136
|
+
export declare const UPSERT_LOCATION_PROFILE_MUTATION = "\n mutation UpsertLocationProfile($input: UpsertLocationProfileInput!) {\n upsertLocationProfile(input: $input) { \n stockLocationId\n code\n name\n timezone\n phone\n openingHours\n address {\n contactName\n company\n streetLine1\n streetLine2\n city\n province\n postalCode\n countryCode\n phoneNumber\n }\n stockLocation {\n id\n name\n description\n }\n }\n }\n";
|
|
137
|
+
export declare const DELETE_LOCATION_PROFILE_MUTATION = "\n mutation DeleteLocationProfile($stockLocationId: ID!) {\n deleteLocationProfile(stockLocationId: $stockLocationId)\n }\n";
|
|
138
|
+
export declare function createLocationsModule(transport: Transport): LocationsModule;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createPage } from '@sixtyninecommerce/core';
|
|
2
|
+
/** `openingHours` keys: lowercase, three letters, Monday first. */
|
|
3
|
+
export const OPENING_HOURS_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
|
4
|
+
const LOCATION_PROFILE_FIELDS = /* GraphQL */ `
|
|
5
|
+
stockLocationId
|
|
6
|
+
code
|
|
7
|
+
name
|
|
8
|
+
timezone
|
|
9
|
+
phone
|
|
10
|
+
openingHours
|
|
11
|
+
address {
|
|
12
|
+
contactName
|
|
13
|
+
company
|
|
14
|
+
streetLine1
|
|
15
|
+
streetLine2
|
|
16
|
+
city
|
|
17
|
+
province
|
|
18
|
+
postalCode
|
|
19
|
+
countryCode
|
|
20
|
+
phoneNumber
|
|
21
|
+
}
|
|
22
|
+
stockLocation {
|
|
23
|
+
id
|
|
24
|
+
name
|
|
25
|
+
description
|
|
26
|
+
}
|
|
27
|
+
`;
|
|
28
|
+
export const LOCATION_PROFILES_QUERY = /* GraphQL */ `
|
|
29
|
+
query LocationProfiles {
|
|
30
|
+
locationProfiles { ${LOCATION_PROFILE_FIELDS} }
|
|
31
|
+
}
|
|
32
|
+
`;
|
|
33
|
+
export const LOCATION_PROFILE_QUERY = /* GraphQL */ `
|
|
34
|
+
query LocationProfile($stockLocationId: ID!) {
|
|
35
|
+
locationProfile(stockLocationId: $stockLocationId) { ${LOCATION_PROFILE_FIELDS} }
|
|
36
|
+
}
|
|
37
|
+
`;
|
|
38
|
+
export const UPSERT_LOCATION_PROFILE_MUTATION = /* GraphQL */ `
|
|
39
|
+
mutation UpsertLocationProfile($input: UpsertLocationProfileInput!) {
|
|
40
|
+
upsertLocationProfile(input: $input) { ${LOCATION_PROFILE_FIELDS} }
|
|
41
|
+
}
|
|
42
|
+
`;
|
|
43
|
+
export const DELETE_LOCATION_PROFILE_MUTATION = /* GraphQL */ `
|
|
44
|
+
mutation DeleteLocationProfile($stockLocationId: ID!) {
|
|
45
|
+
deleteLocationProfile(stockLocationId: $stockLocationId)
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
export function createLocationsModule(transport) {
|
|
49
|
+
return {
|
|
50
|
+
async list(options) {
|
|
51
|
+
const data = await transport.graphql({ api: 'admin', query: LOCATION_PROFILES_QUERY }, options);
|
|
52
|
+
const items = data.locationProfiles;
|
|
53
|
+
return createPage(items, { nextCursor: null, hasNext: false, total: items.length }, () => {
|
|
54
|
+
// Unreachable while `hasNext` is false — `Page.next()` short-circuits —
|
|
55
|
+
// and a rejection rather than a silent re-fetch if that ever changes.
|
|
56
|
+
throw new Error('locationProfiles returns a complete list; there is no next page');
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
async get(stockLocationId, options) {
|
|
60
|
+
const data = await transport.graphql({ api: 'admin', query: LOCATION_PROFILE_QUERY, variables: { stockLocationId } }, options);
|
|
61
|
+
return data.locationProfile;
|
|
62
|
+
},
|
|
63
|
+
async upsert(input, options) {
|
|
64
|
+
const data = await transport.graphql({ api: 'admin', query: UPSERT_LOCATION_PROFILE_MUTATION, variables: { input } }, options);
|
|
65
|
+
return data.upsertLocationProfile;
|
|
66
|
+
},
|
|
67
|
+
async delete(stockLocationId, options) {
|
|
68
|
+
const data = await transport.graphql({ api: 'admin', query: DELETE_LOCATION_PROFILE_MUTATION, variables: { stockLocationId } }, options);
|
|
69
|
+
return data.deleteLocationProfile;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { type Transport } from '@sixtyninecommerce/core';
|
|
2
|
+
import type { Call } from '../types.js';
|
|
3
|
+
/** A wire value (`items_received`, `PaymentSettled`) to its merchant-facing English. */
|
|
4
|
+
export type LabelMap = Record<string, string>;
|
|
5
|
+
/** The semantic colour intent a UI renders a status with. */
|
|
6
|
+
export type StatusTone = 'neutral' | 'muted' | 'info' | 'success' | 'warning' | 'danger';
|
|
7
|
+
/** A label that also carries a tone — the two order-status axes use these. */
|
|
8
|
+
export type StatusMetaMap = Record<string, {
|
|
9
|
+
label: string;
|
|
10
|
+
tone: StatusTone;
|
|
11
|
+
}>;
|
|
12
|
+
/** `from` state to the states it may move to. An empty array means terminal. */
|
|
13
|
+
export type TransitionMap = Record<string, string[]>;
|
|
14
|
+
/** A JSON Schema document (draft 2020-12). Deliberately loose: it is data. */
|
|
15
|
+
export type JsonSchema = Record<string, unknown>;
|
|
16
|
+
export interface MetaCatalogLabels {
|
|
17
|
+
/** Vendure order states. `Draft` and `Created` are absent — render those raw. */
|
|
18
|
+
orderState: LabelMap;
|
|
19
|
+
returnState: LabelMap;
|
|
20
|
+
returnReason: LabelMap;
|
|
21
|
+
paymentStatus: StatusMetaMap;
|
|
22
|
+
fulfillmentStatus: StatusMetaMap;
|
|
23
|
+
fulfillmentHoldReason: LabelMap;
|
|
24
|
+
subscriptionStatus: LabelMap;
|
|
25
|
+
quoteStatus: LabelMap;
|
|
26
|
+
settlementMethod: LabelMap;
|
|
27
|
+
creditNoteKind: LabelMap;
|
|
28
|
+
auditService: LabelMap;
|
|
29
|
+
auditCategory: LabelMap;
|
|
30
|
+
auditEventOutcome: LabelMap;
|
|
31
|
+
auditCallOutcome: LabelMap;
|
|
32
|
+
/**
|
|
33
|
+
* A vocabulary added after this SDK release arrives camelised, with its map
|
|
34
|
+
* untouched.
|
|
35
|
+
*/
|
|
36
|
+
[extra: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
export interface MetaCatalogTransitions {
|
|
39
|
+
quote: TransitionMap;
|
|
40
|
+
/** `return` is the wire name; it is not a reserved word in a property position. */
|
|
41
|
+
return: TransitionMap;
|
|
42
|
+
/**
|
|
43
|
+
* Forward order of the fulfillment lifecycle. `Cancelled` is terminal and
|
|
44
|
+
* UNRANKED, so it is absent — treat "no rank" as "no forward move", never 0.
|
|
45
|
+
*/
|
|
46
|
+
fulfillmentRank: Record<string, number>;
|
|
47
|
+
[extra: string]: unknown;
|
|
48
|
+
}
|
|
49
|
+
export interface PermissionGroup {
|
|
50
|
+
label: string;
|
|
51
|
+
/** Four CRUD slots in order — Create, Read, Update, Delete — `null` where one does not apply. */
|
|
52
|
+
permissions: Array<string | null>;
|
|
53
|
+
}
|
|
54
|
+
export interface RoleTemplate {
|
|
55
|
+
/** Role codes are `{channelCode}-{suffix}`. */
|
|
56
|
+
suffix: string;
|
|
57
|
+
label: string;
|
|
58
|
+
description: string;
|
|
59
|
+
permissions: string[];
|
|
60
|
+
}
|
|
61
|
+
export interface ApiTokenTemplate {
|
|
62
|
+
key: string;
|
|
63
|
+
label: string;
|
|
64
|
+
description: string;
|
|
65
|
+
permissions: string[];
|
|
66
|
+
}
|
|
67
|
+
export interface MetaCatalogPermissions {
|
|
68
|
+
/** Every permission name the Admin API registers. */
|
|
69
|
+
catalog: string[];
|
|
70
|
+
/** The role-editor grid. */
|
|
71
|
+
groups: PermissionGroup[];
|
|
72
|
+
roleTemplates: RoleTemplate[];
|
|
73
|
+
tokenTemplates: ApiTokenTemplate[];
|
|
74
|
+
[extra: string]: unknown;
|
|
75
|
+
}
|
|
76
|
+
export interface PluginCatalogEntry {
|
|
77
|
+
key: string;
|
|
78
|
+
name: string;
|
|
79
|
+
group: string;
|
|
80
|
+
/** `system` plugins are always on; `external` ones need an entitlement. */
|
|
81
|
+
kind: string;
|
|
82
|
+
}
|
|
83
|
+
export interface MetaCatalogPlugins {
|
|
84
|
+
catalog: PluginCatalogEntry[];
|
|
85
|
+
groupLabels: Record<string, string>;
|
|
86
|
+
[extra: string]: unknown;
|
|
87
|
+
}
|
|
88
|
+
export interface MetaCatalog {
|
|
89
|
+
/**
|
|
90
|
+
* A short hash of everything else. It changes only when the catalog's content
|
|
91
|
+
* does, so it is safe to cache against indefinitely — this client does.
|
|
92
|
+
*/
|
|
93
|
+
version: string;
|
|
94
|
+
labels: MetaCatalogLabels;
|
|
95
|
+
transitions: MetaCatalogTransitions;
|
|
96
|
+
permissions: MetaCatalogPermissions;
|
|
97
|
+
plugins: MetaCatalogPlugins;
|
|
98
|
+
/**
|
|
99
|
+
* Draft 2020-12 schemas for the opaque `Channel.customFields` columns, keyed
|
|
100
|
+
* by SETTING NAME — `social_links`, `order_status_rules`, … — which stay in
|
|
101
|
+
* their wire spelling because they are identifiers, not structure.
|
|
102
|
+
*
|
|
103
|
+
* Two things each schema does not say out loud: it describes the DECODED
|
|
104
|
+
* value while the column stores `JSON.stringify` of it, and its property
|
|
105
|
+
* names are the camelCase keys really stored inside (`targetState`), not DTO
|
|
106
|
+
* shape.
|
|
107
|
+
*/
|
|
108
|
+
settingsSchemas: Record<string, JsonSchema>;
|
|
109
|
+
/**
|
|
110
|
+
* A member added after this SDK release arrives with its NAME camelised and
|
|
111
|
+
* its contents untouched — the half that matters, since a member this build
|
|
112
|
+
* has never seen is the one nothing else would protect.
|
|
113
|
+
*/
|
|
114
|
+
[extra: string]: unknown;
|
|
115
|
+
}
|
|
116
|
+
export interface MetaModule {
|
|
117
|
+
/**
|
|
118
|
+
* The platform's labels, state machines, permission and plugin catalogs and
|
|
119
|
+
* settings schemas.
|
|
120
|
+
*
|
|
121
|
+
* Fetched once per client and then served from memory: the document changes
|
|
122
|
+
* only when the platform is redeployed, and its `version` says so. Concurrent
|
|
123
|
+
* first calls share one request, and each caller's `signal` cancels only its
|
|
124
|
+
* own wait — never the shared request, and never anyone else's. A client
|
|
125
|
+
* derived with `withTenant` has its own cache; the catalog is the same for
|
|
126
|
+
* every tenant, so that costs one extra request and never a wrong answer.
|
|
127
|
+
*/
|
|
128
|
+
catalog(options?: Call): Promise<MetaCatalog>;
|
|
129
|
+
/**
|
|
130
|
+
* Fetch it again, ignoring the cache — for a long-lived process that wants to
|
|
131
|
+
* pick up a redeploy without restarting.
|
|
132
|
+
*
|
|
133
|
+
* When the version comes back unchanged the CACHED OBJECT is kept, so a
|
|
134
|
+
* consumer holding a reference can tell by identity alone whether the
|
|
135
|
+
* platform has been redeployed under it.
|
|
136
|
+
*/
|
|
137
|
+
refresh(options?: Call): Promise<MetaCatalog>;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Rename the group names inside `labels`/`transitions`/`permissions`/`plugins`
|
|
141
|
+
* and leave every value alone. A member this release does not know about is
|
|
142
|
+
* passed through whole — the transport has already camelised its name, and its
|
|
143
|
+
* contents were preserved, which is the half that carries data.
|
|
144
|
+
*/
|
|
145
|
+
export declare function projectCatalog(body: unknown): MetaCatalog;
|
|
146
|
+
export declare function createMetaModule(transport: Transport): MetaModule;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { TransportError } from '@sixtyninecommerce/core';
|
|
2
|
+
/**
|
|
3
|
+
* A set that contains every key.
|
|
4
|
+
*
|
|
5
|
+
* `Transport.rest` camelises every response key whose subtree is not preserved,
|
|
6
|
+
* and it renames a preserved key while leaving its VALUE untouched. Handing it
|
|
7
|
+
* this makes the conversion exactly one level deep: the catalog's top-level
|
|
8
|
+
* members are camelised (`settings_schemas` → `settingsSchemas`) and everything
|
|
9
|
+
* below them survives byte for byte.
|
|
10
|
+
*
|
|
11
|
+
* That matters more here than anywhere else in this SDK, because almost every
|
|
12
|
+
* value in this document is a map KEYED BY DATA — `items_received`,
|
|
13
|
+
* `past_due`, `order_status_rules`, a JSON Schema's own property names. A
|
|
14
|
+
* generic camelisation would rewrite all of them, and the damage would be
|
|
15
|
+
* invisible: `items_received` would simply read back as `itemsReceived` and
|
|
16
|
+
* match no state the platform has ever heard of.
|
|
17
|
+
*
|
|
18
|
+
* Naming a fixed list of members instead would leave a member added after this
|
|
19
|
+
* release unprotected — the one case where nobody is watching.
|
|
20
|
+
*/
|
|
21
|
+
class EveryKeySet extends Set {
|
|
22
|
+
has() {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const PRESERVE_EVERY_SUBTREE = new EveryKeySet();
|
|
27
|
+
/**
|
|
28
|
+
* The members whose OWN keys are structure (group names), so those keys are
|
|
29
|
+
* camelised one level further. `settings_schemas` is deliberately not one of
|
|
30
|
+
* them: its keys name settings, which are data.
|
|
31
|
+
*/
|
|
32
|
+
const GROUPED_MEMBERS = new Set(['labels', 'transitions', 'permissions', 'plugins']);
|
|
33
|
+
function snakeToCamel(key) {
|
|
34
|
+
return key.replace(/_+([a-z0-9])/g, (_match, char) => char.toUpperCase());
|
|
35
|
+
}
|
|
36
|
+
function isRecord(value) {
|
|
37
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
38
|
+
}
|
|
39
|
+
function camelKeys(value) {
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const [key, entry] of Object.entries(value))
|
|
42
|
+
out[snakeToCamel(key)] = entry;
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Rename the group names inside `labels`/`transitions`/`permissions`/`plugins`
|
|
47
|
+
* and leave every value alone. A member this release does not know about is
|
|
48
|
+
* passed through whole — the transport has already camelised its name, and its
|
|
49
|
+
* contents were preserved, which is the half that carries data.
|
|
50
|
+
*/
|
|
51
|
+
export function projectCatalog(body) {
|
|
52
|
+
if (!isRecord(body)) {
|
|
53
|
+
// The transport already turned a non-2xx into a PlatformError, so anything
|
|
54
|
+
// that is not an object here is a service answering 200 with a body this
|
|
55
|
+
// module cannot read — worth saying plainly rather than crashing on
|
|
56
|
+
// `catalog.version` two frames later.
|
|
57
|
+
throw new TypeError('GET /meta/catalog did not answer with a JSON object');
|
|
58
|
+
}
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const [member, value] of Object.entries(body)) {
|
|
61
|
+
// `member` arrives camelised from the transport. The four structural names
|
|
62
|
+
// are single words, so both spellings are the same — checked anyway, so a
|
|
63
|
+
// member that ever gains an underscore does not quietly stop being handled.
|
|
64
|
+
const structural = GROUPED_MEMBERS.has(member) || GROUPED_MEMBERS.has(snakeToCamel(member));
|
|
65
|
+
out[member] = structural && isRecord(value) ? camelKeys(value) : value;
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
function abortedError() {
|
|
70
|
+
return new TransportError({
|
|
71
|
+
code: 'REQUEST_ABORTED',
|
|
72
|
+
message: 'The request was aborted by the caller.',
|
|
73
|
+
status: 0,
|
|
74
|
+
service: 'plugins',
|
|
75
|
+
retryable: false,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Settle when `promise` does, or reject as soon as THIS caller's signal aborts.
|
|
80
|
+
*
|
|
81
|
+
* The shared request is deliberately not cancelled: the other callers waiting
|
|
82
|
+
* on it did not ask for that. The rejection is the same `REQUEST_ABORTED`
|
|
83
|
+
* TransportError the transport raises for a signal it owns, so a consumer
|
|
84
|
+
* branching on `error.code` cannot tell the two apart — and should not have to.
|
|
85
|
+
*/
|
|
86
|
+
function abortable(promise, signal) {
|
|
87
|
+
if (signal === undefined)
|
|
88
|
+
return promise;
|
|
89
|
+
if (signal.aborted) {
|
|
90
|
+
// Still attach a handler, or the shared request's own rejection becomes an
|
|
91
|
+
// unhandled one when this caller is the only waiter.
|
|
92
|
+
promise.catch(() => undefined);
|
|
93
|
+
return Promise.reject(abortedError());
|
|
94
|
+
}
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const onAbort = () => reject(abortedError());
|
|
97
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
98
|
+
promise.then(resolve, reject).finally(() => {
|
|
99
|
+
signal.removeEventListener('abort', onAbort);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
export function createMetaModule(transport) {
|
|
104
|
+
let cached = null;
|
|
105
|
+
/** Shared by every caller that arrives while the first request is still out. */
|
|
106
|
+
let inFlight = null;
|
|
107
|
+
async function fetchCatalog(options) {
|
|
108
|
+
const body = await transport.rest('plugins', { method: 'GET', path: '/meta/catalog' }, { ...options, preserveKeys: PRESERVE_EVERY_SUBTREE });
|
|
109
|
+
const next = projectCatalog(body);
|
|
110
|
+
// Same version means the same document, so the object a consumer is already
|
|
111
|
+
// holding stays the one this client serves.
|
|
112
|
+
if (cached !== null && cached.version === next.version)
|
|
113
|
+
return cached;
|
|
114
|
+
cached = next;
|
|
115
|
+
return next;
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
catalog(options) {
|
|
119
|
+
if (cached !== null)
|
|
120
|
+
return Promise.resolve(cached);
|
|
121
|
+
if (inFlight === null) {
|
|
122
|
+
// The SHARED request carries no caller's signal. One that did would let
|
|
123
|
+
// whichever consumer happened to arrive first cancel the catalog for
|
|
124
|
+
// every other consumer waiting on the same promise — and would leave
|
|
125
|
+
// every later caller's own `signal` silently inert, because it was
|
|
126
|
+
// never wired to the request it is waiting on.
|
|
127
|
+
// `createChannelResolver` shares its resolutions the same way, with a
|
|
128
|
+
// controller of its own.
|
|
129
|
+
const { signal: _ignored, ...shared } = options ?? {};
|
|
130
|
+
inFlight = fetchCatalog(shared).finally(() => {
|
|
131
|
+
inFlight = null;
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return abortable(inFlight, options?.signal);
|
|
135
|
+
},
|
|
136
|
+
refresh(options) {
|
|
137
|
+
// Not shared: one request per call, so the caller's signal cancels the
|
|
138
|
+
// request it actually owns.
|
|
139
|
+
return fetchCatalog(options);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Transport } from '@sixtyninecommerce/core';
|
|
2
|
+
import type { Call } from '../types.js';
|
|
3
|
+
/** One principal's standing claim that this store's data must not be destroyed. */
|
|
4
|
+
export interface RetentionHold {
|
|
5
|
+
/** `api_token` or `user` — the kind of principal that placed it. */
|
|
6
|
+
holderType: string;
|
|
7
|
+
/** The api-token id or the user id. A token holds on its own behalf, never on its operator's. */
|
|
8
|
+
holderId: string;
|
|
9
|
+
reason: string;
|
|
10
|
+
/** ISO-8601. Restating a hold updates its reason and leaves this alone. */
|
|
11
|
+
placedAt: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ChannelRetention {
|
|
14
|
+
channelId: string;
|
|
15
|
+
/**
|
|
16
|
+
* Free text, default `standard`. The platform stores it and does not
|
|
17
|
+
* interpret it: no schedule is attached to a class yet.
|
|
18
|
+
*/
|
|
19
|
+
retentionClass: string;
|
|
20
|
+
/**
|
|
21
|
+
* A time-based hold, independent of {@link holds}. While it is in the future
|
|
22
|
+
* the store cannot be hard-deleted, with no holder involved.
|
|
23
|
+
*/
|
|
24
|
+
holdUntil: string | null;
|
|
25
|
+
holds: RetentionHold[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The WHOLE policy, not a patch. Omitting `holdUntil` CLEARS it — that is the
|
|
29
|
+
* mutation's own PUT semantics, not something this SDK adds. Holder holds are
|
|
30
|
+
* untouched either way.
|
|
31
|
+
*/
|
|
32
|
+
export interface SetRetentionInput {
|
|
33
|
+
retentionClass: string;
|
|
34
|
+
/** ISO-8601 with an explicit offset. Omit or pass null to clear the time-based hold. */
|
|
35
|
+
holdUntil?: string | null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Retention class and legal holds for the ACTIVE channel (`admin.tenant.retention`).
|
|
39
|
+
*
|
|
40
|
+
* No channel argument anywhere: these read and write the tenant the client is
|
|
41
|
+
* scoped to, which is what lets a tenant-scoped API token hold its own store.
|
|
42
|
+
* Reading needs `ReadSettings`, writing `UpdateSettings`.
|
|
43
|
+
*
|
|
44
|
+
* **Coded refusals**, all delivered as GraphQL errors with `extensions.code`
|
|
45
|
+
* and mapped by `core` onto the error hierarchy:
|
|
46
|
+
* - `RETENTION_HOLD_ACTIVE` → {@link BusinessRuleError}: `hardDeleteChannel`
|
|
47
|
+
* refused because a hold stands. It is thrown by the delete, not by these
|
|
48
|
+
* methods.
|
|
49
|
+
* - `RETENTION_HOLD_NOT_YOURS` → {@link PermissionDeniedError}: `release()`
|
|
50
|
+
* called by a principal that holds none while another's hold stands. A
|
|
51
|
+
* SuperAdmin overrides and releases every hold.
|
|
52
|
+
* - `RETENTION_INPUT_INVALID` → {@link ValidationError}: an empty or
|
|
53
|
+
* over-long class, or a `holdUntil` that is not an ISO-8601 instant.
|
|
54
|
+
*
|
|
55
|
+
* A CLOSED store answers 403 `CHANNEL_SOFT_DELETED` to almost everything — but
|
|
56
|
+
* not to these four for a SuperAdmin, which is the one exemption that makes a
|
|
57
|
+
* hold usable at the moment it matters (a store must be closed before it can be
|
|
58
|
+
* hard-deleted). A merchant has to reopen the store first.
|
|
59
|
+
*/
|
|
60
|
+
export interface RetentionModule {
|
|
61
|
+
/** The active channel's retention state. A channel never configured answers the defaults. */
|
|
62
|
+
get(options?: Call): Promise<ChannelRetention>;
|
|
63
|
+
set(input: SetRetentionInput, options?: Call): Promise<ChannelRetention>;
|
|
64
|
+
/**
|
|
65
|
+
* Place the CALLING principal's hold. Idempotent per holder: restating
|
|
66
|
+
* updates the reason and leaves `placedAt` where it was.
|
|
67
|
+
*/
|
|
68
|
+
hold(reason: string, options?: Call): Promise<ChannelRetention>;
|
|
69
|
+
/**
|
|
70
|
+
* Release the calling principal's own hold. Releasing when nothing is held is
|
|
71
|
+
* a no-op; a caller holding none while somebody else's hold stands is refused
|
|
72
|
+
* with `RETENTION_HOLD_NOT_YOURS` unless it is a SuperAdmin, which releases
|
|
73
|
+
* every hold as an override.
|
|
74
|
+
*/
|
|
75
|
+
release(reason: string, options?: Call): Promise<ChannelRetention>;
|
|
76
|
+
}
|
|
77
|
+
export declare const RETENTION_QUERY = "\n query ChannelRetention {\n channelRetention { \n channelId\n retentionClass\n holdUntil\n holds {\n holderType\n holderId\n reason\n placedAt\n }\n }\n }\n";
|
|
78
|
+
export declare const SET_RETENTION_MUTATION = "\n mutation SetChannelRetention($retentionClass: String!, $holdUntil: DateTime) {\n setChannelRetention(retentionClass: $retentionClass, holdUntil: $holdUntil) {\n \n channelId\n retentionClass\n holdUntil\n holds {\n holderType\n holderId\n reason\n placedAt\n }\n\n }\n }\n";
|
|
79
|
+
export declare const PLACE_HOLD_MUTATION = "\n mutation PlaceRetentionHold($reason: String!) {\n placeRetentionHold(reason: $reason) { \n channelId\n retentionClass\n holdUntil\n holds {\n holderType\n holderId\n reason\n placedAt\n }\n }\n }\n";
|
|
80
|
+
export declare const RELEASE_HOLD_MUTATION = "\n mutation ReleaseRetentionHold($reason: String!) {\n releaseRetentionHold(reason: $reason) { \n channelId\n retentionClass\n holdUntil\n holds {\n holderType\n holderId\n reason\n placedAt\n }\n }\n }\n";
|
|
81
|
+
export declare function createRetentionModule(transport: Transport): RetentionModule;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const RETENTION_FIELDS = /* GraphQL */ `
|
|
2
|
+
channelId
|
|
3
|
+
retentionClass
|
|
4
|
+
holdUntil
|
|
5
|
+
holds {
|
|
6
|
+
holderType
|
|
7
|
+
holderId
|
|
8
|
+
reason
|
|
9
|
+
placedAt
|
|
10
|
+
}
|
|
11
|
+
`;
|
|
12
|
+
export const RETENTION_QUERY = /* GraphQL */ `
|
|
13
|
+
query ChannelRetention {
|
|
14
|
+
channelRetention { ${RETENTION_FIELDS} }
|
|
15
|
+
}
|
|
16
|
+
`;
|
|
17
|
+
export const SET_RETENTION_MUTATION = /* GraphQL */ `
|
|
18
|
+
mutation SetChannelRetention($retentionClass: String!, $holdUntil: DateTime) {
|
|
19
|
+
setChannelRetention(retentionClass: $retentionClass, holdUntil: $holdUntil) {
|
|
20
|
+
${RETENTION_FIELDS}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
`;
|
|
24
|
+
export const PLACE_HOLD_MUTATION = /* GraphQL */ `
|
|
25
|
+
mutation PlaceRetentionHold($reason: String!) {
|
|
26
|
+
placeRetentionHold(reason: $reason) { ${RETENTION_FIELDS} }
|
|
27
|
+
}
|
|
28
|
+
`;
|
|
29
|
+
export const RELEASE_HOLD_MUTATION = /* GraphQL */ `
|
|
30
|
+
mutation ReleaseRetentionHold($reason: String!) {
|
|
31
|
+
releaseRetentionHold(reason: $reason) { ${RETENTION_FIELDS} }
|
|
32
|
+
}
|
|
33
|
+
`;
|
|
34
|
+
export function createRetentionModule(transport) {
|
|
35
|
+
return {
|
|
36
|
+
async get(options) {
|
|
37
|
+
const data = await transport.graphql({ api: 'admin', query: RETENTION_QUERY }, options);
|
|
38
|
+
return data.channelRetention;
|
|
39
|
+
},
|
|
40
|
+
async set(input, options) {
|
|
41
|
+
const data = await transport.graphql({
|
|
42
|
+
api: 'admin',
|
|
43
|
+
query: SET_RETENTION_MUTATION,
|
|
44
|
+
// `holdUntil` is sent as null when the caller omits it, on purpose:
|
|
45
|
+
// this mutation states the whole policy, so "not mentioned" and
|
|
46
|
+
// "cleared" are the same thing and hiding that behind an absent
|
|
47
|
+
// variable would only make the wire disagree with the signature.
|
|
48
|
+
variables: { retentionClass: input.retentionClass, holdUntil: input.holdUntil ?? null },
|
|
49
|
+
}, options);
|
|
50
|
+
return data.setChannelRetention;
|
|
51
|
+
},
|
|
52
|
+
async hold(reason, options) {
|
|
53
|
+
const data = await transport.graphql({ api: 'admin', query: PLACE_HOLD_MUTATION, variables: { reason } }, options);
|
|
54
|
+
return data.placeRetentionHold;
|
|
55
|
+
},
|
|
56
|
+
async release(reason, options) {
|
|
57
|
+
const data = await transport.graphql({ api: 'admin', query: RELEASE_HOLD_MUTATION, variables: { reason } }, options);
|
|
58
|
+
return data.releaseRetentionHold;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -68,7 +68,14 @@ export interface WhoAmI {
|
|
|
68
68
|
code: string;
|
|
69
69
|
permissions: string[];
|
|
70
70
|
}>;
|
|
71
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* The stock locations this credential may address, or null when it is
|
|
73
|
+
* unrestricted — a human session is always null.
|
|
74
|
+
*
|
|
75
|
+
* The plugins `GET /me` projection hardcoded null for every principal until
|
|
76
|
+
* 2026-09-16; a server older than that describes a restricted token as
|
|
77
|
+
* unrestricted, and `apiTokens.list()` is the reliable read against one.
|
|
78
|
+
*/
|
|
72
79
|
locations: string[] | null;
|
|
73
80
|
/** Effective permissions on the ACTIVE channel, not the union of all of them. */
|
|
74
81
|
permissions: string[];
|
|
@@ -83,6 +90,17 @@ export interface ApiToken {
|
|
|
83
90
|
tokenPrefix: string;
|
|
84
91
|
permissions: string[];
|
|
85
92
|
channelId: string;
|
|
93
|
+
/**
|
|
94
|
+
* Stock locations this token may name in `x-location`
|
|
95
|
+
* (`TenantContext.location`). Null means unrestricted; the platform never
|
|
96
|
+
* stores an empty list.
|
|
97
|
+
*
|
|
98
|
+
* Enforced on `/admin-api` only today. The `x-location` header itself is
|
|
99
|
+
* checked on both APIs — a location outside the request's channel is refused
|
|
100
|
+
* everywhere — but the per-token restriction is not yet mounted on
|
|
101
|
+
* `/shop-api`, because nothing consumes `req.locationId` there.
|
|
102
|
+
*/
|
|
103
|
+
locationIds: string[] | null;
|
|
86
104
|
/**
|
|
87
105
|
* ISO-8601 instant, or null for a token that never expires — the Admin API
|
|
88
106
|
* models `expiresAt` as nullable and the service returns null when neither
|
|
@@ -108,11 +126,28 @@ export interface CreateApiTokenInput {
|
|
|
108
126
|
expiresInDays?: number;
|
|
109
127
|
/** Defaults to the client's own tenant channel. */
|
|
110
128
|
channelId?: string;
|
|
129
|
+
/**
|
|
130
|
+
* Restrict the token to these stock locations: it may then only send an
|
|
131
|
+
* `x-location` naming one of them, and each id must belong to the token's
|
|
132
|
+
* channel.
|
|
133
|
+
*
|
|
134
|
+
* Omit or pass null to leave it unrestricted. An EMPTY list is refused with
|
|
135
|
+
* `INVALID_LOCATION_RESTRICTION` (a `ValidationError`) — "allowed nowhere" is
|
|
136
|
+
* never what a caller means — and an id outside the token's channel with
|
|
137
|
+
* `LOCATION_NOT_IN_CHANNEL`.
|
|
138
|
+
*/
|
|
139
|
+
locationIds?: string[] | null;
|
|
111
140
|
}
|
|
112
141
|
/** Expiry is absent on purpose: it is fixed at creation (ADR 2026-07-29). */
|
|
113
142
|
export interface UpdateApiTokenInput {
|
|
114
143
|
name?: string;
|
|
115
144
|
permissions?: string[];
|
|
116
145
|
templateKeys?: string[];
|
|
146
|
+
/**
|
|
147
|
+
* Replace the location restriction. Omit to leave it as it is; null lifts it
|
|
148
|
+
* entirely. An empty list is refused with `INVALID_LOCATION_RESTRICTION`, as
|
|
149
|
+
* on create.
|
|
150
|
+
*/
|
|
151
|
+
locationIds?: string[] | null;
|
|
117
152
|
}
|
|
118
153
|
export type { ChannelInfo, TenantContext, TenantScope };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sixtyninecommerce/admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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.
|
|
36
|
+
"@sixtyninecommerce/core": "^0.2.3"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/node": "22.19.7",
|