@sixtyninecommerce/admin 0.6.0 → 0.8.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 +359 -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 +54 -1
- package/dist/client.js +34 -1
- 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 +34 -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 +244 -0
- package/dist/payments/index.js +103 -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 +15 -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,128 @@
|
|
|
1
|
+
import { type Page, type Transport } from '@sixtyninecommerce/core';
|
|
2
|
+
import type { Call } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The mapping used when no mapping names the country the money is taxed in.
|
|
5
|
+
*
|
|
6
|
+
* A real row, not an absence: it is set and removed through the same two
|
|
7
|
+
* methods as any country's, so "this class says nothing about Germany" and
|
|
8
|
+
* "this class charges Germany the fallback" are different states rather than
|
|
9
|
+
* the same one read two ways.
|
|
10
|
+
*/
|
|
11
|
+
export declare const TAX_CLASS_FALLBACK_COUNTRY = "*";
|
|
12
|
+
/** As much of a Vendure tax category as a mapping projects. */
|
|
13
|
+
export interface TaxClassTaxCategory {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
/** True for a category the store's own tax setup treats as the default. */
|
|
17
|
+
isDefault: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** One country's answer for one class. */
|
|
20
|
+
export interface TaxClassMapping {
|
|
21
|
+
/** ISO 3166-1 alpha-2, upper case — or `*`, the fallback. */
|
|
22
|
+
countryCode: string;
|
|
23
|
+
taxCategoryId: string;
|
|
24
|
+
taxCategory: TaxClassTaxCategory;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* A store's own name for how something is taxed — `standard`, `reduced`,
|
|
28
|
+
* `zero-rated` (spec §8.6).
|
|
29
|
+
*
|
|
30
|
+
* Tax categories and their rates are platform-wide, shared by every store on
|
|
31
|
+
* the platform. The class is this store's indirection in front of them: the
|
|
32
|
+
* store owns the key and the label, and the mappings say which global category
|
|
33
|
+
* prices it in which country.
|
|
34
|
+
*/
|
|
35
|
+
export interface TaxClass {
|
|
36
|
+
id: string;
|
|
37
|
+
/**
|
|
38
|
+
* Kebab-case handle, unique in this store. The identity: it is what
|
|
39
|
+
* `ProductVariant.customFields.taxClassKey` carries, what a surcharge names,
|
|
40
|
+
* and what `tax_class.changed` puts on the event bus.
|
|
41
|
+
*/
|
|
42
|
+
key: string;
|
|
43
|
+
label: string;
|
|
44
|
+
mappings: TaxClassMapping[];
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Create a class, or re-label the one that already carries the key.
|
|
48
|
+
*
|
|
49
|
+
* There is NO rename. The key is stored on product variants and held by every
|
|
50
|
+
* consumer that has seen a `tax_class.changed`, so a merchant who wants a
|
|
51
|
+
* different key creates one and deletes the old — in that order, because the
|
|
52
|
+
* delete is refused while variants still carry it.
|
|
53
|
+
*/
|
|
54
|
+
export interface UpsertTaxClassInput {
|
|
55
|
+
/** Kebab-case, 1–64 characters (`INVALID_TAX_CLASS_KEY` otherwise). */
|
|
56
|
+
key: string;
|
|
57
|
+
label: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The store's tax classes and the country mappings that price them (spec §8.6).
|
|
61
|
+
*
|
|
62
|
+
* Reading needs `ReadSettings`, writing `UpdateSettings` — deliberately NOT the
|
|
63
|
+
* tax-category permissions, which govern the platform-wide table every store
|
|
64
|
+
* shares. Everything is scoped to the channel the client is on: another store's
|
|
65
|
+
* class is invisible to `get`, unwritable by `mappings.set`, and reported as
|
|
66
|
+
* absent by `delete`.
|
|
67
|
+
*
|
|
68
|
+
* **Coded refusals**, delivered as GraphQL errors with `extensions.code`:
|
|
69
|
+
* `TAX_CLASS_NOT_FOUND` (→ `NotFoundError`), which is also the answer for a
|
|
70
|
+
* class belonging to another store; `TAX_CLASS_KEY_TAKEN` (→ `ConflictError`),
|
|
71
|
+
* which a caller meets only by losing a race with a concurrent create;
|
|
72
|
+
* `TAX_CLASS_IN_USE` (→ `ConflictError`) on a delete while product variants
|
|
73
|
+
* still carry the key; `TAX_CLASS_UNMAPPED` (→ `ValidationError`) when
|
|
74
|
+
* something asks a class to price money in a country it says nothing about, and
|
|
75
|
+
* `TAX_CLASS_RATE_MISSING` (→ `ValidationError`) when it IS mapped but that
|
|
76
|
+
* category has no rate in the zone the money is taxed in;
|
|
77
|
+
* `TAX_CATEGORY_NOT_FOUND` (→ `NotFoundError`); and
|
|
78
|
+
* `INVALID_TAX_CLASS_KEY` / `INVALID_TAX_CLASS_LABEL` /
|
|
79
|
+
* `INVALID_TAX_CLASS_COUNTRY` (→ `ValidationError`).
|
|
80
|
+
*/
|
|
81
|
+
export interface TaxClassesModule {
|
|
82
|
+
/**
|
|
83
|
+
* Every class of this store, by key.
|
|
84
|
+
*
|
|
85
|
+
* A `Page` because every list in this SDK is one (spec §9.2), not because
|
|
86
|
+
* this query pages: `taxClasses` answers the complete array, so the page is
|
|
87
|
+
* always the last one and `total` is its length. If the endpoint ever grows a
|
|
88
|
+
* cursor, consumers walking it with `.next()` or `for await` keep working.
|
|
89
|
+
*/
|
|
90
|
+
list(options?: Call): Promise<Page<TaxClass>>;
|
|
91
|
+
/**
|
|
92
|
+
* One class, or null when this store has none with that key — including a key
|
|
93
|
+
* that belongs to another store. A key that is not kebab-case at all is a
|
|
94
|
+
* different answer: it is refused with `INVALID_TAX_CLASS_KEY` rather than
|
|
95
|
+
* reported as absent.
|
|
96
|
+
*/
|
|
97
|
+
get(key: string, options?: Call): Promise<TaxClass | null>;
|
|
98
|
+
upsert(input: UpsertTaxClassInput, options?: Call): Promise<TaxClass>;
|
|
99
|
+
/**
|
|
100
|
+
* True when a class was removed, false when there was none — so a repeated
|
|
101
|
+
* delete is not an error. A malformed key is still refused with
|
|
102
|
+
* `INVALID_TAX_CLASS_KEY`, and a class product variants still carry with
|
|
103
|
+
* `TAX_CLASS_IN_USE`.
|
|
104
|
+
*/
|
|
105
|
+
delete(key: string, options?: Call): Promise<boolean>;
|
|
106
|
+
}
|
|
107
|
+
/** The country → tax category table of one class. */
|
|
108
|
+
export interface TaxMappingsModule {
|
|
109
|
+
/**
|
|
110
|
+
* Point one country — or {@link TAX_CLASS_FALLBACK_COUNTRY} — at a tax
|
|
111
|
+
* category, replacing that country's mapping if it had one. Country codes are
|
|
112
|
+
* stored upper case; `nl` and `NL` are the same mapping.
|
|
113
|
+
*/
|
|
114
|
+
set(key: string, countryCode: string, taxCategoryId: string, options?: Call): Promise<TaxClass>;
|
|
115
|
+
/** Drop one country's mapping. Silent when there was none. */
|
|
116
|
+
remove(key: string, countryCode: string, options?: Call): Promise<TaxClass>;
|
|
117
|
+
}
|
|
118
|
+
export interface TaxModule {
|
|
119
|
+
classes: TaxClassesModule;
|
|
120
|
+
mappings: TaxMappingsModule;
|
|
121
|
+
}
|
|
122
|
+
export declare const TAX_CLASSES_QUERY = "\n query TaxClasses {\n taxClasses { \n id\n key\n label\n mappings {\n countryCode\n taxCategoryId\n taxCategory {\n id\n name\n isDefault\n }\n }\n }\n }\n";
|
|
123
|
+
export declare const TAX_CLASS_QUERY = "\n query TaxClass($key: String!) {\n taxClass(key: $key) { \n id\n key\n label\n mappings {\n countryCode\n taxCategoryId\n taxCategory {\n id\n name\n isDefault\n }\n }\n }\n }\n";
|
|
124
|
+
export declare const UPSERT_TAX_CLASS_MUTATION = "\n mutation UpsertTaxClass($input: UpsertTaxClassInput!) {\n upsertTaxClass(input: $input) { \n id\n key\n label\n mappings {\n countryCode\n taxCategoryId\n taxCategory {\n id\n name\n isDefault\n }\n }\n }\n }\n";
|
|
125
|
+
export declare const DELETE_TAX_CLASS_MUTATION = "\n mutation DeleteTaxClass($key: String!) {\n deleteTaxClass(key: $key)\n }\n";
|
|
126
|
+
export declare const SET_TAX_CLASS_MAPPING_MUTATION = "\n mutation SetTaxClassMapping($key: String!, $countryCode: String!, $taxCategoryId: ID!) {\n setTaxClassMapping(key: $key, countryCode: $countryCode, taxCategoryId: $taxCategoryId) {\n \n id\n key\n label\n mappings {\n countryCode\n taxCategoryId\n taxCategory {\n id\n name\n isDefault\n }\n }\n\n }\n }\n";
|
|
127
|
+
export declare const REMOVE_TAX_CLASS_MAPPING_MUTATION = "\n mutation RemoveTaxClassMapping($key: String!, $countryCode: String!) {\n removeTaxClassMapping(key: $key, countryCode: $countryCode) { \n id\n key\n label\n mappings {\n countryCode\n taxCategoryId\n taxCategory {\n id\n name\n isDefault\n }\n }\n }\n }\n";
|
|
128
|
+
export declare function createTaxModule(transport: Transport): TaxModule;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { createPage } from '@sixtyninecommerce/core';
|
|
2
|
+
/**
|
|
3
|
+
* The mapping used when no mapping names the country the money is taxed in.
|
|
4
|
+
*
|
|
5
|
+
* A real row, not an absence: it is set and removed through the same two
|
|
6
|
+
* methods as any country's, so "this class says nothing about Germany" and
|
|
7
|
+
* "this class charges Germany the fallback" are different states rather than
|
|
8
|
+
* the same one read two ways.
|
|
9
|
+
*/
|
|
10
|
+
export const TAX_CLASS_FALLBACK_COUNTRY = '*';
|
|
11
|
+
const TAX_CLASS_FIELDS = /* GraphQL */ `
|
|
12
|
+
id
|
|
13
|
+
key
|
|
14
|
+
label
|
|
15
|
+
mappings {
|
|
16
|
+
countryCode
|
|
17
|
+
taxCategoryId
|
|
18
|
+
taxCategory {
|
|
19
|
+
id
|
|
20
|
+
name
|
|
21
|
+
isDefault
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
`;
|
|
25
|
+
export const TAX_CLASSES_QUERY = /* GraphQL */ `
|
|
26
|
+
query TaxClasses {
|
|
27
|
+
taxClasses { ${TAX_CLASS_FIELDS} }
|
|
28
|
+
}
|
|
29
|
+
`;
|
|
30
|
+
export const TAX_CLASS_QUERY = /* GraphQL */ `
|
|
31
|
+
query TaxClass($key: String!) {
|
|
32
|
+
taxClass(key: $key) { ${TAX_CLASS_FIELDS} }
|
|
33
|
+
}
|
|
34
|
+
`;
|
|
35
|
+
export const UPSERT_TAX_CLASS_MUTATION = /* GraphQL */ `
|
|
36
|
+
mutation UpsertTaxClass($input: UpsertTaxClassInput!) {
|
|
37
|
+
upsertTaxClass(input: $input) { ${TAX_CLASS_FIELDS} }
|
|
38
|
+
}
|
|
39
|
+
`;
|
|
40
|
+
export const DELETE_TAX_CLASS_MUTATION = /* GraphQL */ `
|
|
41
|
+
mutation DeleteTaxClass($key: String!) {
|
|
42
|
+
deleteTaxClass(key: $key)
|
|
43
|
+
}
|
|
44
|
+
`;
|
|
45
|
+
export const SET_TAX_CLASS_MAPPING_MUTATION = /* GraphQL */ `
|
|
46
|
+
mutation SetTaxClassMapping($key: String!, $countryCode: String!, $taxCategoryId: ID!) {
|
|
47
|
+
setTaxClassMapping(key: $key, countryCode: $countryCode, taxCategoryId: $taxCategoryId) {
|
|
48
|
+
${TAX_CLASS_FIELDS}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
`;
|
|
52
|
+
export const REMOVE_TAX_CLASS_MAPPING_MUTATION = /* GraphQL */ `
|
|
53
|
+
mutation RemoveTaxClassMapping($key: String!, $countryCode: String!) {
|
|
54
|
+
removeTaxClassMapping(key: $key, countryCode: $countryCode) { ${TAX_CLASS_FIELDS} }
|
|
55
|
+
}
|
|
56
|
+
`;
|
|
57
|
+
export function createTaxModule(transport) {
|
|
58
|
+
return {
|
|
59
|
+
classes: {
|
|
60
|
+
async list(options) {
|
|
61
|
+
const data = await transport.graphql({ api: 'admin', query: TAX_CLASSES_QUERY }, options);
|
|
62
|
+
const items = data.taxClasses;
|
|
63
|
+
return createPage(items, { nextCursor: null, hasNext: false, total: items.length }, () => {
|
|
64
|
+
// Unreachable while `hasNext` is false — `Page.next()` short-circuits
|
|
65
|
+
// — and a rejection rather than a silent re-fetch if that changes.
|
|
66
|
+
throw new Error('taxClasses returns a complete list; there is no next page');
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
async get(key, options) {
|
|
70
|
+
const data = await transport.graphql({ api: 'admin', query: TAX_CLASS_QUERY, variables: { key } }, options);
|
|
71
|
+
return data.taxClass;
|
|
72
|
+
},
|
|
73
|
+
async upsert(input, options) {
|
|
74
|
+
const data = await transport.graphql({ api: 'admin', query: UPSERT_TAX_CLASS_MUTATION, variables: { input } }, options);
|
|
75
|
+
return data.upsertTaxClass;
|
|
76
|
+
},
|
|
77
|
+
async delete(key, options) {
|
|
78
|
+
const data = await transport.graphql({ api: 'admin', query: DELETE_TAX_CLASS_MUTATION, variables: { key } }, options);
|
|
79
|
+
return data.deleteTaxClass;
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
mappings: {
|
|
83
|
+
async set(key, countryCode, taxCategoryId, options) {
|
|
84
|
+
const data = await transport.graphql({
|
|
85
|
+
api: 'admin',
|
|
86
|
+
query: SET_TAX_CLASS_MAPPING_MUTATION,
|
|
87
|
+
variables: { key, countryCode, taxCategoryId },
|
|
88
|
+
}, options);
|
|
89
|
+
return data.setTaxClassMapping;
|
|
90
|
+
},
|
|
91
|
+
async remove(key, countryCode, options) {
|
|
92
|
+
const data = await transport.graphql({
|
|
93
|
+
api: 'admin',
|
|
94
|
+
query: REMOVE_TAX_CLASS_MAPPING_MUTATION,
|
|
95
|
+
variables: { key, countryCode },
|
|
96
|
+
}, options);
|
|
97
|
+
return data.removeTaxClassMapping;
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -68,6 +68,21 @@ export interface CapabilityManifest {
|
|
|
68
68
|
* apps work has not written.
|
|
69
69
|
*/
|
|
70
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;
|
|
71
86
|
/** A member added after this SDK release arrives camelised and untouched. */
|
|
72
87
|
[extra: string]: unknown;
|
|
73
88
|
}
|
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.8.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",
|