@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.
Files changed (39) hide show
  1. package/README.md +359 -2
  2. package/dist/apps/assertion.d.ts +30 -3
  3. package/dist/apps/assertion.js +40 -3
  4. package/dist/apps/config.d.ts +39 -0
  5. package/dist/apps/config.js +22 -0
  6. package/dist/apps/credential.d.ts +5 -0
  7. package/dist/apps/credential.js +55 -4
  8. package/dist/apps/index.d.ts +11 -0
  9. package/dist/client.d.ts +54 -1
  10. package/dist/client.js +34 -1
  11. package/dist/custom-objects/index.d.ts +66 -0
  12. package/dist/custom-objects/index.js +48 -0
  13. package/dist/customers/index.d.ts +225 -0
  14. package/dist/customers/index.js +279 -0
  15. package/dist/documents/index.d.ts +432 -0
  16. package/dist/documents/index.js +289 -0
  17. package/dist/index.d.ts +34 -2
  18. package/dist/index.js +16 -1
  19. package/dist/jobs/index.d.ts +143 -0
  20. package/dist/jobs/index.js +132 -0
  21. package/dist/metafields/index.d.ts +291 -0
  22. package/dist/metafields/index.js +141 -0
  23. package/dist/orders/index.d.ts +106 -0
  24. package/dist/orders/index.js +115 -0
  25. package/dist/orders/surcharges.d.ts +121 -0
  26. package/dist/orders/surcharges.js +110 -0
  27. package/dist/payments/index.d.ts +244 -0
  28. package/dist/payments/index.js +103 -0
  29. package/dist/payments/providers.d.ts +281 -0
  30. package/dist/payments/providers.js +205 -0
  31. package/dist/platform/provision-store.d.ts +9 -26
  32. package/dist/platform/provision-store.js +8 -66
  33. package/dist/tax/index.d.ts +128 -0
  34. package/dist/tax/index.js +101 -0
  35. package/dist/tenant/capabilities.d.ts +15 -0
  36. package/dist/types.d.ts +20 -3
  37. package/dist/uploads/index.d.ts +146 -0
  38. package/dist/uploads/index.js +72 -0
  39. 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, and ignored on a GraphQL one —
6
- * the Admin API has no header for it, so an `apiTokens.*` write is never
7
- * deduplicated and a repeat is a second write.
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.6.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.3.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",