@sixtyninecommerce/admin 0.5.0 → 0.7.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 +424 -2
- package/dist/apps/assertion.d.ts +30 -3
- package/dist/apps/assertion.js +40 -3
- package/dist/apps/config.d.ts +39 -0
- package/dist/apps/config.js +22 -0
- package/dist/apps/credential.d.ts +5 -0
- package/dist/apps/credential.js +55 -4
- package/dist/apps/index.d.ts +11 -0
- package/dist/client.d.ts +56 -1
- package/dist/client.js +39 -2
- package/dist/custom-objects/index.d.ts +66 -0
- package/dist/custom-objects/index.js +48 -0
- package/dist/customers/index.d.ts +225 -0
- package/dist/customers/index.js +279 -0
- package/dist/documents/index.d.ts +432 -0
- package/dist/documents/index.js +289 -0
- package/dist/index.d.ts +35 -2
- package/dist/index.js +16 -1
- package/dist/jobs/index.d.ts +143 -0
- package/dist/jobs/index.js +132 -0
- package/dist/metafields/index.d.ts +291 -0
- package/dist/metafields/index.js +141 -0
- package/dist/orders/index.d.ts +106 -0
- package/dist/orders/index.js +115 -0
- package/dist/orders/surcharges.d.ts +121 -0
- package/dist/orders/surcharges.js +110 -0
- package/dist/payments/index.d.ts +103 -0
- package/dist/payments/index.js +55 -0
- package/dist/payments/providers.d.ts +281 -0
- package/dist/payments/providers.js +205 -0
- package/dist/platform/provision-store.d.ts +9 -26
- package/dist/platform/provision-store.js +8 -66
- package/dist/tax/index.d.ts +128 -0
- package/dist/tax/index.js +101 -0
- package/dist/tenant/capabilities.d.ts +119 -0
- package/dist/tenant/capabilities.js +59 -0
- package/dist/types.d.ts +20 -3
- package/dist/uploads/index.d.ts +146 -0
- package/dist/uploads/index.js +72 -0
- package/package.json +3 -3
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { type Transport } from '@sixtyninecommerce/core';
|
|
2
|
+
import type { Call } from '../types.js';
|
|
3
|
+
/** One capability's state on the calling tenant's channel. */
|
|
4
|
+
export interface Capability {
|
|
5
|
+
/**
|
|
6
|
+
* The underlying objects exist here. For a feature a merchant switches on —
|
|
7
|
+
* quotes, returns, reviews, the age gate, subscription plans, B2B price
|
|
8
|
+
* lists — that means SWITCHED ON, not "supported by the platform".
|
|
9
|
+
*/
|
|
10
|
+
provisioned: boolean;
|
|
11
|
+
/** No hard blocker in the configuration. A degradation leaves this true. */
|
|
12
|
+
configured: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* The tenant holds this plugin's entitlement.
|
|
15
|
+
*
|
|
16
|
+
* FALSE ON EVERY CORE CAPABILITY, and that is not a bug: the entitlement
|
|
17
|
+
* registry has rows only for the entitlement-gated plugins of the catalogue,
|
|
18
|
+
* and `catalog`, `search`, `routes` and the rest are the platform itself.
|
|
19
|
+
* Branch on {@link ready}, never on this alone.
|
|
20
|
+
*/
|
|
21
|
+
entitled: boolean;
|
|
22
|
+
/** The answer to "may I use this": provisioned, configured, and licensed. */
|
|
23
|
+
ready: boolean;
|
|
24
|
+
/** Capability keys this one needs first — what to fix when it is not ready. */
|
|
25
|
+
dependsOn: string[];
|
|
26
|
+
/**
|
|
27
|
+
* The server's evidence for anything short of a clean pass, in the words the
|
|
28
|
+
* platform's own readiness preflight uses ("search returned 0 hits — reindex
|
|
29
|
+
* Meilisearch for this channel"). Never a UI flag, and never a sentence this
|
|
30
|
+
* SDK composed.
|
|
31
|
+
*
|
|
32
|
+
* NON-EMPTY WITH `ready: true` MEANS DEGRADED, not broken — a storefront that
|
|
33
|
+
* renders anyway is correct, and a merchant still wants to see it. A feature
|
|
34
|
+
* deliberately switched off carries no blocker at all: nothing is wrong.
|
|
35
|
+
*/
|
|
36
|
+
blockers: string[];
|
|
37
|
+
/** A field added after this SDK release arrives camelised and untouched. */
|
|
38
|
+
[extra: string]: unknown;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* What the tenant's channel can do, per capability key.
|
|
42
|
+
*
|
|
43
|
+
* KEYS ARE DATA. `content_pages`, `age_gate`, `b2b_pricing`, `payment-links`
|
|
44
|
+
* are identifiers the platform matches literally, so they reach you in their
|
|
45
|
+
* wire spelling and are never camelised — the same rule the meta catalogue's
|
|
46
|
+
* data-keyed maps follow. The FIELDS inside each entry are camelised.
|
|
47
|
+
*
|
|
48
|
+
* Core keys: `catalog`, `search`, `routes`, `menus`, `content_pages`,
|
|
49
|
+
* `checkout`, `branding`, `age_gate`, `reviews`, `subscriptions`,
|
|
50
|
+
* `b2b_pricing`, `quotes`, `returns`. Beside them, one key per
|
|
51
|
+
* entitlement-gated plugin in the platform catalogue (`mollie`, `stripe`,
|
|
52
|
+
* `myparcel`, `hendriks`, `afas`, `pos`, …).
|
|
53
|
+
*
|
|
54
|
+
* THE SET GROWS, so read an absent key as UNKNOWN and never as `false`. A
|
|
55
|
+
* capability whose rules are not yet settled is held back rather than published
|
|
56
|
+
* wrong, and `b2b_pricing` — which shipped one release later than the rest, for
|
|
57
|
+
* exactly that reason — is the precedent rather than the exception.
|
|
58
|
+
*/
|
|
59
|
+
export interface CapabilityManifest {
|
|
60
|
+
channelId: string;
|
|
61
|
+
channelCode: string;
|
|
62
|
+
/** ISO-8601 with an offset — when the probes ran, not when this was served. */
|
|
63
|
+
generatedAt: string;
|
|
64
|
+
capabilities: Record<string, Capability>;
|
|
65
|
+
/**
|
|
66
|
+
* The tenant's installed apps. Always empty until the app registry ships, so
|
|
67
|
+
* there is no element shape yet — declaring one here would pin a contract the
|
|
68
|
+
* apps work has not written.
|
|
69
|
+
*/
|
|
70
|
+
installedApps: never[];
|
|
71
|
+
/**
|
|
72
|
+
* The `aud` an app assertion must carry to be exchanged at this deployment —
|
|
73
|
+
* its public Vendure origin. Copy it VERBATIM into the claim; re-deriving it
|
|
74
|
+
* from a URL you hold is the drift the platform compares against as a plain
|
|
75
|
+
* string. `createAppCredential` already does this for you, so this member is
|
|
76
|
+
* for a caller signing assertions by hand.
|
|
77
|
+
*
|
|
78
|
+
* Camelised from the wire's `assertion_audience`, like every other member
|
|
79
|
+
* here. `null` means the plugins service cannot state one — which does NOT
|
|
80
|
+
* mean the exchange accepts an assertion without `aud`, since the two run as
|
|
81
|
+
* separate deployment resources; ask the operator rather than omitting it.
|
|
82
|
+
*
|
|
83
|
+
* Optional because a platform older than this field simply does not send it.
|
|
84
|
+
*/
|
|
85
|
+
assertionAudience?: string | null;
|
|
86
|
+
/** A member added after this SDK release arrives camelised and untouched. */
|
|
87
|
+
[extra: string]: unknown;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* `admin.tenant.capabilities()` — what this tenant's channel can actually do.
|
|
91
|
+
*
|
|
92
|
+
* One read replaces a dozen probes: whether the catalogue has products, whether
|
|
93
|
+
* search is indexed, whether checkout has an enabled method on both sides,
|
|
94
|
+
* which integrations are licensed AND connected, and for anything less than
|
|
95
|
+
* ready, the server's own evidence.
|
|
96
|
+
*
|
|
97
|
+
* Reading needs any permission on the channel — an admin credential always has
|
|
98
|
+
* one. It is cached 60 s per channel by the service, so polling it costs
|
|
99
|
+
* nothing; {@link CapabilitiesModule.refresh} is the escape when you have just
|
|
100
|
+
* changed something and want to see it.
|
|
101
|
+
*/
|
|
102
|
+
export interface CapabilitiesModule {
|
|
103
|
+
(options?: Call): Promise<CapabilityManifest>;
|
|
104
|
+
/**
|
|
105
|
+
* Rebuild the manifest now, ignoring the service's freshness window.
|
|
106
|
+
*
|
|
107
|
+
* Needs `UpdateSettings`: it makes the server re-probe three services, so it
|
|
108
|
+
* is not something a read-only credential may trigger at will.
|
|
109
|
+
*/
|
|
110
|
+
refresh(options?: Call): Promise<CapabilityManifest>;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Camelise each capability entry's OWN fields (`depends_on` → `dependsOn`) and
|
|
114
|
+
* leave the map's keys alone. A field this release has never seen is renamed
|
|
115
|
+
* and passed through, which is the half that matters — nothing else would
|
|
116
|
+
* protect it.
|
|
117
|
+
*/
|
|
118
|
+
export declare function projectManifest(body: unknown): CapabilityManifest;
|
|
119
|
+
export declare function createCapabilitiesModule(transport: Transport): CapabilitiesModule;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { PermissionDeniedError } from '@sixtyninecommerce/core';
|
|
2
|
+
/**
|
|
3
|
+
* The `capabilities` MAP is handed back verbatim by the transport, because its
|
|
4
|
+
* keys are capability identifiers rather than structure. Camelising them would
|
|
5
|
+
* be invisible damage: `content_pages` would simply read back as `contentPages`
|
|
6
|
+
* and match no key the platform has ever published.
|
|
7
|
+
*/
|
|
8
|
+
const PRESERVE_CAPABILITIES = new Set(['capabilities']);
|
|
9
|
+
function isRecord(value) {
|
|
10
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
function snakeToCamel(key) {
|
|
13
|
+
return key.replace(/_+([a-z0-9])/g, (_match, char) => char.toUpperCase());
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Camelise each capability entry's OWN fields (`depends_on` → `dependsOn`) and
|
|
17
|
+
* leave the map's keys alone. A field this release has never seen is renamed
|
|
18
|
+
* and passed through, which is the half that matters — nothing else would
|
|
19
|
+
* protect it.
|
|
20
|
+
*/
|
|
21
|
+
export function projectManifest(body) {
|
|
22
|
+
if (!isRecord(body) || !isRecord(body.capabilities)) {
|
|
23
|
+
// The transport has already turned a non-2xx into a PlatformError, so
|
|
24
|
+
// anything else here is a service answering 200 with a body this module
|
|
25
|
+
// cannot read — worth saying plainly rather than crashing two frames later.
|
|
26
|
+
throw new TypeError('GET /capabilities did not answer with a capability manifest');
|
|
27
|
+
}
|
|
28
|
+
const capabilities = {};
|
|
29
|
+
for (const [key, state] of Object.entries(body.capabilities)) {
|
|
30
|
+
if (!isRecord(state))
|
|
31
|
+
continue;
|
|
32
|
+
if (!('provisioned' in state)) {
|
|
33
|
+
// The public projection: `{ ready }` and nothing else. The service serves
|
|
34
|
+
// it to a principal it did not accept for this channel, and the status is
|
|
35
|
+
// 200, so nothing else in the hierarchy would tell the caller that their
|
|
36
|
+
// credential — not their request — is the problem.
|
|
37
|
+
throw new PermissionDeniedError({
|
|
38
|
+
code: 'CAPABILITIES_PROJECTION_UNAVAILABLE',
|
|
39
|
+
message: 'The service returned the anonymous capability projection, so this credential was not accepted for the channel — it is invalid or expired, or its principal holds no permission there. Only `ready` would be available.',
|
|
40
|
+
status: 403,
|
|
41
|
+
service: 'plugins',
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const projected = {};
|
|
45
|
+
for (const [field, value] of Object.entries(state))
|
|
46
|
+
projected[snakeToCamel(field)] = value;
|
|
47
|
+
capabilities[key] = projected;
|
|
48
|
+
}
|
|
49
|
+
return { ...body, capabilities };
|
|
50
|
+
}
|
|
51
|
+
export function createCapabilitiesModule(transport) {
|
|
52
|
+
const read = async (options) => projectManifest(await transport.rest('plugins', { method: 'GET', path: '/capabilities' }, { ...options, preserveKeys: PRESERVE_CAPABILITIES }));
|
|
53
|
+
// A callable module: `capabilities()` is the read a consumer makes constantly
|
|
54
|
+
// and `capabilities.refresh()` the one they make rarely, so the common case
|
|
55
|
+
// stays the shortest thing to write (spec §6.6 names it exactly this way).
|
|
56
|
+
const capabilities = ((options) => read(options));
|
|
57
|
+
capabilities.refresh = async (options) => projectManifest(await transport.rest('plugins', { method: 'POST', path: '/capabilities/refresh' }, { ...options, preserveKeys: PRESERVE_CAPABILITIES }));
|
|
58
|
+
return capabilities;
|
|
59
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -2,9 +2,26 @@ import type { ChannelInfo, TenantContext, TenantScope } from '@sixtyninecommerce
|
|
|
2
2
|
/** Per-call options every admin method accepts (spec §9.2). */
|
|
3
3
|
export interface Call {
|
|
4
4
|
/**
|
|
5
|
-
* Sent as `Idempotency-Key` on a REST write
|
|
6
|
-
* the Admin API has no header for it
|
|
7
|
-
*
|
|
5
|
+
* Sent as `Idempotency-Key` on a REST write. On a GraphQL one it is sent as
|
|
6
|
+
* an ARGUMENT — the Admin API has no header for it — and only the mutations
|
|
7
|
+
* that declare one act on it. Today those are the money mutations
|
|
8
|
+
* `payments.record`, `orders.park`, `orders.settle` and all three
|
|
9
|
+
* `orders.surcharges.*`, plus `payments.providers.completeOnboarding` and
|
|
10
|
+
* `payments.providers.terminals.provision`. Repeating one under the same key
|
|
11
|
+
* returns the first call's result instead of moving the money again; a
|
|
12
|
+
* DIFFERENT request under the same key is refused with
|
|
13
|
+
* `IDEMPOTENCY_CONFLICT`.
|
|
14
|
+
*
|
|
15
|
+
* Two deliberate exceptions on that list. `customers.erase` takes a key but
|
|
16
|
+
* RECORDS it for provenance rather than keying on it: that mutation is
|
|
17
|
+
* idempotent on the customer itself, which is the stronger key. And
|
|
18
|
+
* `payments.providers.startOnboarding` takes none at all, because its
|
|
19
|
+
* response carries a link that can be single-use and a stored response is
|
|
20
|
+
* re-served for 24 hours.
|
|
21
|
+
*
|
|
22
|
+
* Everything else — `apiTokens.*` and the rest of the GraphQL surface —
|
|
23
|
+
* still sends the key and acts on nothing, so a repeat there is a second
|
|
24
|
+
* write.
|
|
8
25
|
*
|
|
9
26
|
* On REST, three of this SDK's writes are deduplicated by the service itself:
|
|
10
27
|
* `content.forms.upsertBySlug` (`PUT /forms/by-slug/:slug`),
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { type Transport } from '@sixtyninecommerce/core';
|
|
2
|
+
import type { Call } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* What an upload is for. It decides which permission stages it and who may
|
|
5
|
+
* consume it.
|
|
6
|
+
*
|
|
7
|
+
* `asset` is the only purpose today. `bulk` and `document` arrive with the
|
|
8
|
+
* calls that consume them, each with its own storage decision — everything
|
|
9
|
+
* staged currently lands in the public asset bucket, which is right for a media
|
|
10
|
+
* asset and not for a customer export or an order attachment.
|
|
11
|
+
*/
|
|
12
|
+
export type UploadPurpose = 'asset';
|
|
13
|
+
export type UploadStatus = 'staged' | 'consumed' | 'expired';
|
|
14
|
+
export interface StagedUpload {
|
|
15
|
+
id: string;
|
|
16
|
+
purpose: UploadPurpose;
|
|
17
|
+
status: UploadStatus;
|
|
18
|
+
mimeType: string;
|
|
19
|
+
/**
|
|
20
|
+
* The EXACT size the signature was computed for, as a string.
|
|
21
|
+
*
|
|
22
|
+
* A string because it is a 64-bit column and a JSON number is a double; use
|
|
23
|
+
* `Number(...)` if you need arithmetic, which is exact below 2^53.
|
|
24
|
+
*/
|
|
25
|
+
sizeBytes: string;
|
|
26
|
+
/** The label you gave the file. It is never part of the object's location. */
|
|
27
|
+
filename: string | null;
|
|
28
|
+
/** When the RESERVATION lapses. After this nothing will accept the upload. */
|
|
29
|
+
expiresAt: string;
|
|
30
|
+
consumedAt: string | null;
|
|
31
|
+
createdAt: string;
|
|
32
|
+
}
|
|
33
|
+
export interface StagedUploadTicket extends StagedUpload {
|
|
34
|
+
/** PUT the bytes here. Single use. */
|
|
35
|
+
url: string;
|
|
36
|
+
method: 'PUT';
|
|
37
|
+
/**
|
|
38
|
+
* Send ALL of these, spelled exactly like this. They are part of the
|
|
39
|
+
* signature, so storage refuses a PUT that omits or changes one.
|
|
40
|
+
*/
|
|
41
|
+
headers: Record<string, string>;
|
|
42
|
+
/**
|
|
43
|
+
* When the URL stops working — at most {@link StagedUpload.expiresAt}, and
|
|
44
|
+
* less than it when you re-staged an upload that was already running down.
|
|
45
|
+
*/
|
|
46
|
+
urlExpiresAt: string;
|
|
47
|
+
}
|
|
48
|
+
export interface StageUploadInput {
|
|
49
|
+
purpose: UploadPurpose;
|
|
50
|
+
/** Checked against the platform allow-list and bound into the signature. */
|
|
51
|
+
mimeType: string;
|
|
52
|
+
/**
|
|
53
|
+
* The EXACT number of bytes you will send — not a maximum.
|
|
54
|
+
*
|
|
55
|
+
* A presigned PUT can only pin a precise `Content-Length`; there is no way to
|
|
56
|
+
* sign an upper bound. Storage refuses any other length, so this must be the
|
|
57
|
+
* real size of the body (`blob.size`, `buffer.length`, `stat.size`).
|
|
58
|
+
*/
|
|
59
|
+
size: number;
|
|
60
|
+
/**
|
|
61
|
+
* Your own handle for this upload, making the call repeatable.
|
|
62
|
+
*
|
|
63
|
+
* Required. Staging is the call that hands out a URL, and a dropped response
|
|
64
|
+
* on it is the ordinary failure: without a token every retry would reserve
|
|
65
|
+
* another place and leave an object nobody ever claims. Staging twice with
|
|
66
|
+
* the same token returns the SAME upload; staging DIFFERENT terms under it is
|
|
67
|
+
* refused with `UPLOAD_TOKEN_CONFLICT`.
|
|
68
|
+
*/
|
|
69
|
+
clientToken: string;
|
|
70
|
+
/** A label for humans. It never reaches the object's location. */
|
|
71
|
+
filename?: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Bytes this SDK knows how to PUT. A `Blob` in any modern runtime, or a
|
|
75
|
+
* `Uint8Array`/`ArrayBuffer` from Node.
|
|
76
|
+
*/
|
|
77
|
+
export type UploadBody = Blob | ArrayBuffer | ArrayBufferView;
|
|
78
|
+
export interface ClaimAsAssetInput {
|
|
79
|
+
/** Place the asset in this CMS asset folder. */
|
|
80
|
+
folderId?: string;
|
|
81
|
+
altText?: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Signed direct-to-storage uploads (spec §6.11).
|
|
85
|
+
*
|
|
86
|
+
* **The shape of it.** You say what you are uploading; the platform decides
|
|
87
|
+
* WHERE it goes and signs exactly that. You never see or choose a path. The
|
|
88
|
+
* three steps are `stage` (reserve and get a URL), `put` (send the bytes
|
|
89
|
+
* straight to storage — they do not pass through the platform), and a consuming
|
|
90
|
+
* call that takes the upload's id.
|
|
91
|
+
*
|
|
92
|
+
* ```ts
|
|
93
|
+
* const bytes = await readFile('hero.png');
|
|
94
|
+
* const ticket = await admin.uploads.stage({
|
|
95
|
+
* purpose: 'asset',
|
|
96
|
+
* mimeType: 'image/png',
|
|
97
|
+
* size: bytes.length,
|
|
98
|
+
* clientToken: crypto.randomUUID(),
|
|
99
|
+
* filename: 'hero.png',
|
|
100
|
+
* });
|
|
101
|
+
* await admin.uploads.put(ticket, bytes);
|
|
102
|
+
* const asset = await admin.uploads.claimAsAsset(ticket.id, { altText: 'Our hero' });
|
|
103
|
+
* ```
|
|
104
|
+
*
|
|
105
|
+
* **The size is exact.** `size` is the byte count storage will accept and no
|
|
106
|
+
* other — not a ceiling. Sending a different number of bytes fails at storage
|
|
107
|
+
* with a signature error, so compute it from the body you are about to send.
|
|
108
|
+
*
|
|
109
|
+
* **One use, and a window scaled to the size.** A URL writes one object, once.
|
|
110
|
+
* The reservation lasts at least fifteen minutes, and longer for a large file —
|
|
111
|
+
* enough to send it at about 1 Mbit/s — because a window too short to upload in
|
|
112
|
+
* would expire between the PUT and the claim. `expiresAt` is the reservation;
|
|
113
|
+
* `urlExpiresAt` is the signature, and never outlives it. An upload nobody
|
|
114
|
+
* consumes expires and its bytes are deleted.
|
|
115
|
+
*
|
|
116
|
+
* **It is yours alone.** An upload is visible and usable only to the principal
|
|
117
|
+
* that staged it, on the store it was staged for. Anything else answers
|
|
118
|
+
* `UPLOAD_NOT_FOUND` — the same answer as an id that never existed.
|
|
119
|
+
*
|
|
120
|
+
* **Browsers need CORS.** `put` sends a cross-origin PUT to the object store,
|
|
121
|
+
* which requires CORS rules on the bucket that this platform does not
|
|
122
|
+
* configure. Server-side callers are unaffected.
|
|
123
|
+
*/
|
|
124
|
+
export interface UploadsModule {
|
|
125
|
+
/** Reserve a place and get a signed URL to PUT one file to. */
|
|
126
|
+
stage(input: StageUploadInput, options?: Call): Promise<StagedUploadTicket>;
|
|
127
|
+
/**
|
|
128
|
+
* Send the bytes to storage using the ticket's URL and headers.
|
|
129
|
+
*
|
|
130
|
+
* It does NOT go through the platform and carries none of your credentials —
|
|
131
|
+
* the URL is the authorisation. Resolves on a 2xx and throws the store's
|
|
132
|
+
* status otherwise.
|
|
133
|
+
*/
|
|
134
|
+
put(ticket: StagedUploadTicket, body: UploadBody): Promise<void>;
|
|
135
|
+
/** One upload's state. Unknown, or another principal's, is `UPLOAD_NOT_FOUND`. */
|
|
136
|
+
get(id: string, options?: Call): Promise<StagedUpload>;
|
|
137
|
+
/**
|
|
138
|
+
* Turn a staged upload into a CMS asset.
|
|
139
|
+
*
|
|
140
|
+
* This is the consuming call: it verifies the object is there and is what was
|
|
141
|
+
* signed, spends the upload, and ingests the bytes. An upload can be consumed
|
|
142
|
+
* once — a second call is `UPLOAD_ALREADY_CONSUMED`.
|
|
143
|
+
*/
|
|
144
|
+
claimAsAsset(id: string, input?: ClaimAsAssetInput, options?: Call): Promise<unknown>;
|
|
145
|
+
}
|
|
146
|
+
export declare function createUploadsModule(transport: Transport, fetchImpl: typeof globalThis.fetch): UploadsModule;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { PLATFORM_PASSTHROUGH_KEYS, ValidationError, } from '@sixtyninecommerce/core';
|
|
2
|
+
/**
|
|
3
|
+
* `headers` carries the exact header NAMES a signature was computed over.
|
|
4
|
+
*
|
|
5
|
+
* Case-converting them would rewrite `content-type` to `contentType`, and the
|
|
6
|
+
* PUT would then go out without the header storage is checking for — a
|
|
7
|
+
* signature failure that looks like a credential problem and is not.
|
|
8
|
+
*/
|
|
9
|
+
const PRESERVE = new Set([...PLATFORM_PASSTHROUGH_KEYS, 'headers']);
|
|
10
|
+
export function createUploadsModule(transport, fetchImpl) {
|
|
11
|
+
return {
|
|
12
|
+
async stage(input, options) {
|
|
13
|
+
if (!Number.isSafeInteger(input.size) || input.size < 1) {
|
|
14
|
+
// Refused here rather than sent: a fractional or negative size would be
|
|
15
|
+
// rejected by the service as a validation failure whose message is
|
|
16
|
+
// about a field the caller did not type, and `size: NaN` from a missing
|
|
17
|
+
// `blob.size` is the way this goes wrong in practice.
|
|
18
|
+
throw new ValidationError({
|
|
19
|
+
code: 'UPLOAD_SIZE_INVALID',
|
|
20
|
+
message: `size must be the exact whole number of bytes you will upload; received ${String(input.size)}.`,
|
|
21
|
+
status: 400,
|
|
22
|
+
service: 'plugins',
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return await transport.rest('plugins', {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
path: '/uploads/stage',
|
|
28
|
+
body: {
|
|
29
|
+
purpose: input.purpose,
|
|
30
|
+
mime_type: input.mimeType,
|
|
31
|
+
size_bytes: input.size,
|
|
32
|
+
client_token: input.clientToken,
|
|
33
|
+
...(input.filename === undefined ? {} : { filename: input.filename }),
|
|
34
|
+
},
|
|
35
|
+
}, { ...options, preserveKeys: PRESERVE });
|
|
36
|
+
},
|
|
37
|
+
async put(ticket, body) {
|
|
38
|
+
const response = await fetchImpl(ticket.url, {
|
|
39
|
+
method: ticket.method,
|
|
40
|
+
// Exactly what was signed, and nothing else. An extra header is
|
|
41
|
+
// harmless, but a MISSING or altered one is a signature failure.
|
|
42
|
+
headers: { ...ticket.headers },
|
|
43
|
+
body: body,
|
|
44
|
+
});
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
const detail = await response.text().catch(() => '');
|
|
47
|
+
throw new ValidationError({
|
|
48
|
+
code: 'UPLOAD_PUT_REFUSED',
|
|
49
|
+
message: `The object store refused the upload (HTTP ${String(response.status)}). The commonest causes are a body whose length is not the \`size\` this upload was staged for, ` +
|
|
50
|
+
`a changed \`content-type\`, and a URL that has expired.${detail ? ` Store said: ${detail.slice(0, 300)}` : ''}`,
|
|
51
|
+
status: response.status,
|
|
52
|
+
service: 'plugins',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
async get(id, options) {
|
|
57
|
+
return await transport.rest('plugins', { method: 'GET', path: `/uploads/${encodeURIComponent(id)}` }, { ...options, preserveKeys: PRESERVE });
|
|
58
|
+
},
|
|
59
|
+
async claimAsAsset(id, input, options) {
|
|
60
|
+
const body = await transport.rest('cms', {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
path: '/assets/from-staged',
|
|
63
|
+
body: {
|
|
64
|
+
staged_upload_id: id,
|
|
65
|
+
...(input?.folderId === undefined ? {} : { folder_id: input.folderId }),
|
|
66
|
+
...(input?.altText === undefined ? {} : { alt_text: input.altText }),
|
|
67
|
+
},
|
|
68
|
+
}, options);
|
|
69
|
+
return body.data;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sixtyninecommerce/admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Back-office SDK for sixtynine-commerce: every capability of the platform as a method",
|
|
5
5
|
"homepage": "https://github.com/SIXTYNINE-digital/sixtynine-commerce-engine/tree/main/packages/admin-client#readme",
|
|
6
6
|
"repository": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"access": "public"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@sixtyninecommerce/core": "^0.
|
|
36
|
+
"@sixtyninecommerce/core": "^0.4.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/node": "22.19.7",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"vitest": "4.1.6"
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|
|
44
|
-
"build": "tsc -p tsconfig.build.json",
|
|
44
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
45
45
|
"dev": "tsc -p tsconfig.build.json --watch",
|
|
46
46
|
"typecheck": "tsc --noEmit",
|
|
47
47
|
"test": "vitest run",
|