@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
package/dist/apps/credential.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { errorFromResponse, TransportError } from '@sixtyninecommerce/core';
|
|
2
|
-
import { importAppPrivateKey, signAppAssertion, } from './assertion.js';
|
|
1
|
+
import { errorFromResponse, RateLimitedError, TransportError } from '@sixtyninecommerce/core';
|
|
2
|
+
import { assertionAudience, importAppPrivateKey, signAppAssertion, } from './assertion.js';
|
|
3
3
|
/** Refresh this long before expiry, so no call goes out on a dying token. */
|
|
4
4
|
const REFRESH_MARGIN_MS = 60_000;
|
|
5
5
|
/**
|
|
@@ -8,6 +8,16 @@ const REFRESH_MARGIN_MS = 60_000;
|
|
|
8
8
|
* budgeted 15 s could block for 45 while a stalled mint ran to its own deadline.
|
|
9
9
|
*/
|
|
10
10
|
const EXCHANGE_TIMEOUT_MS = 15_000;
|
|
11
|
+
/**
|
|
12
|
+
* Longest hold-off this credential takes from a 429's `Retry-After`.
|
|
13
|
+
*
|
|
14
|
+
* The exchange's budget is measured over a trailing minute, so a longer wait is
|
|
15
|
+
* a mistyped limiter or an intermediary's own throttle — and obeying it
|
|
16
|
+
* literally would keep an app from minting for as long as the hint says, on a
|
|
17
|
+
* route whose bucket has certainly freed up by then. The caller still sees the
|
|
18
|
+
* server's uncapped number on `error.retryAfterSeconds`.
|
|
19
|
+
*/
|
|
20
|
+
const MAX_RATE_LIMIT_HOLD_MS = 60_000;
|
|
11
21
|
export function createAppCredential(app, installationId, vendureUrl, fetchImpl) {
|
|
12
22
|
let token = '';
|
|
13
23
|
let expiresAt = 0;
|
|
@@ -17,7 +27,33 @@ export function createAppCredential(app, installationId, vendureUrl, fetchImpl)
|
|
|
17
27
|
/** The dead bearer the in-flight mint is REPLACING, for `refresh()`. */
|
|
18
28
|
let refreshingFrom = null;
|
|
19
29
|
let key = null;
|
|
30
|
+
/** Instant the exchange's own 429 said its budget frees up; 0 ⇒ not limited. */
|
|
31
|
+
let rateLimitedUntil = 0;
|
|
32
|
+
/** The refusal a caller gets while the exchange's stated window has not passed. */
|
|
33
|
+
function rateLimited(seconds) {
|
|
34
|
+
return new RateLimitedError({
|
|
35
|
+
code: 'RATE_LIMITED',
|
|
36
|
+
message: `The app token exchange refused the last attempt for this installation and asked to be ` +
|
|
37
|
+
`called again in ${String(seconds)}s. No request was sent.`,
|
|
38
|
+
status: 429,
|
|
39
|
+
service: 'vendure',
|
|
40
|
+
retryAfterSeconds: seconds,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
20
43
|
async function mint() {
|
|
44
|
+
// HONOUR THE 429 THE EXCHANGE ALREADY ANSWERED, without spending a round
|
|
45
|
+
// trip to be told the same thing. `Retry-After` is the server naming the
|
|
46
|
+
// instant its bucket frees up, and a client that keeps calling through it
|
|
47
|
+
// is the traffic the budget exists to stop. The refusal carries the
|
|
48
|
+
// REMAINING wait, so the caller's own backoff — and the transport's retry
|
|
49
|
+
// loop, which reads `retryAfterSeconds` — paces on the time actually left.
|
|
50
|
+
//
|
|
51
|
+
// Only MINTING is held off. `current()` answers from the cached bearer
|
|
52
|
+
// above, so an app with a live token is untouched by a window it will never
|
|
53
|
+
// reach.
|
|
54
|
+
const holdMs = rateLimitedUntil - Date.now();
|
|
55
|
+
if (holdMs > 0)
|
|
56
|
+
throw rateLimited(Math.ceil(holdMs / 1000));
|
|
21
57
|
// The generation this mint belongs to. An `invalidate()` that lands while
|
|
22
58
|
// this is in flight bumps it, and the result is then returned to the caller
|
|
23
59
|
// but NOT cached — otherwise the token the server has just killed would be
|
|
@@ -30,6 +66,10 @@ export function createAppCredential(app, installationId, vendureUrl, fetchImpl)
|
|
|
30
66
|
appId: app.appId,
|
|
31
67
|
installationId,
|
|
32
68
|
kid: app.kid,
|
|
69
|
+
// The SAME `vendureUrl` the exchange URL is built from, reduced to its
|
|
70
|
+
// origin — so the claim and the endpoint can never name two different
|
|
71
|
+
// deployments, whatever shape the caller configured.
|
|
72
|
+
audience: assertionAudience(vendureUrl),
|
|
33
73
|
});
|
|
34
74
|
// Trailing slash stripped, as every other URL in the client is: a
|
|
35
75
|
// `vendureUrl` of `https://api.example.test/` works for every GraphQL call
|
|
@@ -65,8 +105,19 @@ export function createAppCredential(app, installationId, vendureUrl, fetchImpl)
|
|
|
65
105
|
}
|
|
66
106
|
if (!response.ok) {
|
|
67
107
|
// problem+json first, legacy second — the exchange answers the same shape
|
|
68
|
-
// every other refusal on this platform does.
|
|
69
|
-
|
|
108
|
+
// every other refusal on this platform does. `errorFromResponse` folds the
|
|
109
|
+
// `Retry-After` header into `retryAfterSeconds` for a 429 whose body left
|
|
110
|
+
// it out, so this reads one field either way.
|
|
111
|
+
const error = await errorFromResponse(response, 'vendure');
|
|
112
|
+
if (error.status === 429) {
|
|
113
|
+
const seconds = typeof error.retryAfterSeconds === 'number' && Number.isFinite(error.retryAfterSeconds)
|
|
114
|
+
? Math.max(0, error.retryAfterSeconds)
|
|
115
|
+
: 0;
|
|
116
|
+
// A 429 with no usable hint sets no hold: guessing a window would be
|
|
117
|
+
// this client inventing a throttle the server never asked for.
|
|
118
|
+
rateLimitedUntil = Date.now() + Math.min(seconds * 1_000, MAX_RATE_LIMIT_HOLD_MS);
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
70
121
|
}
|
|
71
122
|
// Read inside the same guard as the request: an abort that fires while the
|
|
72
123
|
// body is still streaming would otherwise escape as a raw `AbortError`,
|
package/dist/apps/index.d.ts
CHANGED
|
@@ -54,6 +54,17 @@ export interface AppInstallation {
|
|
|
54
54
|
}
|
|
55
55
|
/** What an approver agrees to. Every field omitted means "keep what was granted". */
|
|
56
56
|
export interface ApproveConsentInput {
|
|
57
|
+
/**
|
|
58
|
+
* The `pendingConsentReasons` that were on screen when the approver read
|
|
59
|
+
* them.
|
|
60
|
+
*
|
|
61
|
+
* Optional, and worth sending from any surface a human reads: the engine
|
|
62
|
+
* refuses the approval when the app has changed since, so a re-registration
|
|
63
|
+
* between the screen and the button cannot have an escalation ratified by
|
|
64
|
+
* somebody who was never shown it. Omit it and the approval applies to
|
|
65
|
+
* whatever is pending at the moment it lands.
|
|
66
|
+
*/
|
|
67
|
+
acknowledgedConsentReasons?: string[];
|
|
57
68
|
grantedPermissions?: string[];
|
|
58
69
|
grantedFieldOwnership?: string[];
|
|
59
70
|
/**
|
package/dist/client.d.ts
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
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 AppInstallationConfigModule } from './apps/config.js';
|
|
3
4
|
import { type AppCredentialConfig } from './apps/credential.js';
|
|
4
5
|
import { type AppsModule } from './apps/index.js';
|
|
5
6
|
import { type AuditModule } from './audit/index.js';
|
|
6
7
|
import { type AuthModule } from './auth/index.js';
|
|
7
8
|
import { type FormsModule } from './content/forms.js';
|
|
9
|
+
import { type CustomObjectsModule } from './custom-objects/index.js';
|
|
10
|
+
import { type CustomersModule } from './customers/index.js';
|
|
11
|
+
import { type DocumentsModule } from './documents/index.js';
|
|
8
12
|
import { type HeadsModule } from './heads/index.js';
|
|
13
|
+
import { type JobsModule } from './jobs/index.js';
|
|
9
14
|
import { type LocationsModule } from './locations/index.js';
|
|
10
15
|
import { type MetaModule } from './meta/index.js';
|
|
16
|
+
import { type MetafieldsModule } from './metafields/index.js';
|
|
17
|
+
import { type OrdersModule } from './orders/index.js';
|
|
18
|
+
import { type PaymentsModule } from './payments/index.js';
|
|
11
19
|
import { type PlatformAppsModule } from './platform/apps.js';
|
|
12
20
|
import { type PlatformModule } from './platform/provision-store.js';
|
|
13
21
|
import { type RedirectsModule } from './seo/redirects.js';
|
|
22
|
+
import { type TaxModule } from './tax/index.js';
|
|
14
23
|
import { type CapabilitiesModule } from './tenant/capabilities.js';
|
|
15
24
|
import { type ChannelsModule } from './tenant/channels.js';
|
|
16
25
|
import { type RetentionModule } from './tenant/retention.js';
|
|
26
|
+
import { type UploadsModule } from './uploads/index.js';
|
|
17
27
|
export interface AdminClientConfig {
|
|
18
28
|
urls: {
|
|
19
29
|
vendureUrl: string;
|
|
@@ -60,21 +70,64 @@ export interface AdminClient {
|
|
|
60
70
|
auth: AuthModule;
|
|
61
71
|
apiTokens: ApiTokensModule;
|
|
62
72
|
/** Installing and managing apps on the ACTIVE store. Needs `InstallApps`. */
|
|
63
|
-
apps: AppsModule
|
|
73
|
+
apps: AppsModule & {
|
|
74
|
+
/** One installation's own surfaces. `config` is its settings form (spec §8.13). */
|
|
75
|
+
installation: {
|
|
76
|
+
config: AppInstallationConfigModule;
|
|
77
|
+
};
|
|
78
|
+
};
|
|
64
79
|
audit: AuditModule;
|
|
65
80
|
channels: ChannelsModule;
|
|
66
81
|
content: {
|
|
67
82
|
forms: FormsModule;
|
|
68
83
|
};
|
|
84
|
+
/**
|
|
85
|
+
* Invoices, credit notes and packing slips: issuing them, the numbered facts
|
|
86
|
+
* they were built from, and the store's own extension blocks (spec §8.5).
|
|
87
|
+
*/
|
|
88
|
+
documents: DocumentsModule;
|
|
89
|
+
/**
|
|
90
|
+
* Customers — the commerce master record, plus their groups and addresses.
|
|
91
|
+
* `list`/`get` need `ReadCustomer`, the writes `UpdateCustomer`, and `erase`
|
|
92
|
+
* `DeleteCustomer`. `create` is CHANNEL-SCOPED: core `createCustomer` shares
|
|
93
|
+
* a customer across channels by email and is not reachable through this SDK.
|
|
94
|
+
*/
|
|
95
|
+
customers: CustomersModule;
|
|
69
96
|
/** The store's head registry: every client surface it serves (spec §6.7). */
|
|
70
97
|
heads: HeadsModule;
|
|
98
|
+
/** Every long-running operation the platform runs, under one resource (spec §6.11). */
|
|
99
|
+
jobs: JobsModule;
|
|
71
100
|
/** Stock-location profiles (spec §8.7); `TenantContext.location` names one by id. */
|
|
72
101
|
locations: LocationsModule;
|
|
73
102
|
/** Labels, transitions, permission and plugin catalogs, settings schemas. */
|
|
74
103
|
meta: MetaModule;
|
|
104
|
+
/**
|
|
105
|
+
* An app's own private state, keyed `(container, key)` (spec §8.13). Only an
|
|
106
|
+
* installation token reaches it.
|
|
107
|
+
*/
|
|
108
|
+
customObjects: CustomObjectsModule;
|
|
109
|
+
/** App and platform custom data on core entities (spec §8.13, §8.4). */
|
|
110
|
+
metafields: MetafieldsModule;
|
|
111
|
+
/**
|
|
112
|
+
* Surcharges on a placed order (spec §8.6 for `taxClass`), and the deposit
|
|
113
|
+
* flow: park, read the payment position, settle (spec §8.2, §15.5).
|
|
114
|
+
*/
|
|
115
|
+
orders: OrdersModule;
|
|
116
|
+
/**
|
|
117
|
+
* Payment-provider accounts, terminals and readiness (spec §8.9), and money
|
|
118
|
+
* recorded against an order with what it stands at (spec §8.2).
|
|
119
|
+
*/
|
|
120
|
+
payments: PaymentsModule;
|
|
75
121
|
seo: {
|
|
76
122
|
redirects: RedirectsModule;
|
|
77
123
|
};
|
|
124
|
+
/** The store's own tax classes and their per-country mappings (spec §8.6). */
|
|
125
|
+
tax: TaxModule;
|
|
126
|
+
/**
|
|
127
|
+
* Signed direct-to-storage uploads (spec §6.11): reserve a place, PUT the
|
|
128
|
+
* bytes straight to the object store, then hand the id to a consuming call.
|
|
129
|
+
*/
|
|
130
|
+
uploads: UploadsModule;
|
|
78
131
|
/** The calling tenant's own settings. Always the ACTIVE channel — no channel argument. */
|
|
79
132
|
tenant: {
|
|
80
133
|
capabilities: CapabilitiesModule;
|
package/dist/client.js
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
import { createChannelResolver, createTransport, mergeTenant, UNSCOPED_TENANT, ValidationError, } from '@sixtyninecommerce/core';
|
|
2
2
|
import { createApiTokensModule } from './api-tokens/index.js';
|
|
3
|
+
import { createAppConfigModule } from './apps/config.js';
|
|
3
4
|
import { createAppCredential, } from './apps/credential.js';
|
|
4
5
|
import { createAppsModule } from './apps/index.js';
|
|
5
6
|
import { createAuditModule } from './audit/index.js';
|
|
6
7
|
import { createAuthModule } from './auth/index.js';
|
|
7
8
|
import { createFormsModule } from './content/forms.js';
|
|
9
|
+
import { createCustomObjectsModule } from './custom-objects/index.js';
|
|
10
|
+
import { createCustomersModule } from './customers/index.js';
|
|
11
|
+
import { createDocumentsModule } from './documents/index.js';
|
|
8
12
|
import { createHeadsModule } from './heads/index.js';
|
|
13
|
+
import { createJobsModule } from './jobs/index.js';
|
|
9
14
|
import { createLocationsModule } from './locations/index.js';
|
|
10
15
|
import { createMetaModule } from './meta/index.js';
|
|
16
|
+
import { createMetafieldsModule } from './metafields/index.js';
|
|
17
|
+
import { createOrdersModule } from './orders/index.js';
|
|
18
|
+
import { createPaymentsModule } from './payments/index.js';
|
|
11
19
|
import { createPlatformAppsModule } from './platform/apps.js';
|
|
12
20
|
import { createPlatformModule } from './platform/provision-store.js';
|
|
13
21
|
import { createRedirectsModule } from './seo/redirects.js';
|
|
22
|
+
import { createTaxModule } from './tax/index.js';
|
|
14
23
|
import { createCapabilitiesModule } from './tenant/capabilities.js';
|
|
15
24
|
import { createChannelsModule } from './tenant/channels.js';
|
|
16
25
|
import { createRetentionModule } from './tenant/retention.js';
|
|
26
|
+
import { createUploadsModule } from './uploads/index.js';
|
|
17
27
|
/**
|
|
18
28
|
* An admin credential in a browser is a credential the whole internet can read
|
|
19
29
|
* (spec §18 amendment 3, §15.21). A SESSION bearer is fine there — that is what
|
|
@@ -139,6 +149,17 @@ function assertSessionIsWritable(session) {
|
|
|
139
149
|
service: 'vendure',
|
|
140
150
|
});
|
|
141
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* `apps.installation` is BOTH a call and a namespace: `installation(id)` reads
|
|
154
|
+
* one, and `installation.config` is that installation's settings form. The two
|
|
155
|
+
* belong to the same noun, so they share the path rather than growing a second
|
|
156
|
+
* one (`apps.config`) that a reader would have to learn separately.
|
|
157
|
+
*/
|
|
158
|
+
function buildAppsModule(transport) {
|
|
159
|
+
const apps = createAppsModule(transport);
|
|
160
|
+
const installation = Object.assign((id, options) => apps.installation(id, options), { config: createAppConfigModule(transport) });
|
|
161
|
+
return { ...apps, installation };
|
|
162
|
+
}
|
|
142
163
|
export function createAdminClient(config) {
|
|
143
164
|
assertNotInBrowser(config.auth);
|
|
144
165
|
const resolver = createChannelResolver({
|
|
@@ -324,14 +345,26 @@ function buildClient(config, overrides, resolver, shared, credential) {
|
|
|
324
345
|
},
|
|
325
346
|
}),
|
|
326
347
|
apiTokens: createApiTokensModule(transport),
|
|
327
|
-
apps:
|
|
348
|
+
apps: buildAppsModule(transport),
|
|
328
349
|
audit: createAuditModule(transport),
|
|
329
350
|
channels: createChannelsModule(resolver),
|
|
330
351
|
content: { forms: createFormsModule(transport) },
|
|
331
352
|
heads: createHeadsModule(transport),
|
|
353
|
+
jobs: createJobsModule(transport),
|
|
332
354
|
locations: createLocationsModule(transport),
|
|
333
355
|
meta: createMetaModule(transport),
|
|
356
|
+
customObjects: createCustomObjectsModule(transport),
|
|
357
|
+
metafields: createMetafieldsModule(transport),
|
|
358
|
+
orders: createOrdersModule(transport),
|
|
359
|
+
payments: createPaymentsModule(transport),
|
|
360
|
+
documents: createDocumentsModule(transport),
|
|
361
|
+
customers: createCustomersModule(transport),
|
|
334
362
|
seo: { redirects: createRedirectsModule(transport) },
|
|
363
|
+
tax: createTaxModule(transport),
|
|
364
|
+
// The PUT goes to the object store, not to this platform, so it needs the
|
|
365
|
+
// caller's own fetch rather than the transport — which would attach the
|
|
366
|
+
// tenant headers and the bearer to a request that must carry neither.
|
|
367
|
+
uploads: createUploadsModule(transport, config.fetch ?? globalThis.fetch),
|
|
335
368
|
tenant: {
|
|
336
369
|
capabilities: createCapabilitiesModule(transport),
|
|
337
370
|
retention: createRetentionModule(transport),
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { type Page, type Transport } from '@sixtyninecommerce/core';
|
|
2
|
+
import type { Call, ListOptions } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* A document an app keeps for itself (spec §8.13).
|
|
5
|
+
*
|
|
6
|
+
* The platform stores and returns the value and interprets none of it. The one
|
|
7
|
+
* exception is the reserved `$config` container, which holds the merchant's
|
|
8
|
+
* settings and is reached through `apps.installation.config` instead — these
|
|
9
|
+
* methods refuse it.
|
|
10
|
+
*/
|
|
11
|
+
export interface CustomObject {
|
|
12
|
+
container: string;
|
|
13
|
+
key: string;
|
|
14
|
+
/** Whatever JSON was written. */
|
|
15
|
+
value: unknown;
|
|
16
|
+
/** The stored value's size in bytes, which is what the quota counts. */
|
|
17
|
+
sizeBytes: number;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
updatedAt: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* An app's own state, addressed by `(container, key)`.
|
|
23
|
+
*
|
|
24
|
+
* **Only an installation token reaches these.** They are the app's private
|
|
25
|
+
* bookkeeping: a human session — even a SuperAdmin's — is refused with
|
|
26
|
+
* `CUSTOM_OBJECT_FORBIDDEN`, and one installation never sees another's rows,
|
|
27
|
+
* on the same channel or on any other. That is what makes it safe for an app to
|
|
28
|
+
* keep an API cursor or a field map here.
|
|
29
|
+
*
|
|
30
|
+
* **Two quotas, per installation per store.** A single document may be 128 KiB,
|
|
31
|
+
* and an installation may hold 10,000 live objects
|
|
32
|
+
* (`CUSTOM_OBJECT_TOO_LARGE`, `CUSTOM_OBJECT_LIMIT_REACHED`). The row quota is
|
|
33
|
+
* checked only when a NEW key is written, so a full installation can still
|
|
34
|
+
* shrink a value it already has.
|
|
35
|
+
*
|
|
36
|
+
* **Names are capped.** A container is 1-64 characters, starts with a lowercase
|
|
37
|
+
* letter or digit and uses only lowercase letters, digits, `-` and `_`; a key is
|
|
38
|
+
* 1-200 characters and carries no control characters. `$config` is reserved for
|
|
39
|
+
* the settings form and is reached through `apps.installation.config`.
|
|
40
|
+
*
|
|
41
|
+
* **The value must be JSON that survives a round trip.** `undefined`, a
|
|
42
|
+
* function and a non-finite number are refused at any depth
|
|
43
|
+
* (`CUSTOM_OBJECT_INVALID`) rather than stored as `null` and read back as
|
|
44
|
+
* something nobody wrote.
|
|
45
|
+
*/
|
|
46
|
+
export interface CustomObjectsModule {
|
|
47
|
+
/** One container's objects, newest first. */
|
|
48
|
+
list(container: string, options?: ListOptions): Promise<Page<CustomObject>>;
|
|
49
|
+
/** One object. Unknown `(container, key)` is `CUSTOM_OBJECT_NOT_FOUND`. */
|
|
50
|
+
get(container: string, key: string, options?: Call): Promise<CustomObject>;
|
|
51
|
+
/**
|
|
52
|
+
* Create or replace one object. Last write wins.
|
|
53
|
+
*
|
|
54
|
+
* An `idempotencyKey` is forwarded if you pass one, but the route does NOT
|
|
55
|
+
* honour it: the store namespaces keys by channel while these rows are per
|
|
56
|
+
* installation, so a shared namespace would let one app's key collide with
|
|
57
|
+
* another's. A PUT that replaces a value by key needs no key of its own —
|
|
58
|
+
* repeating it is the same as doing it once.
|
|
59
|
+
*/
|
|
60
|
+
set(container: string, key: string, value: unknown, options?: Call & {
|
|
61
|
+
idempotencyKey?: string;
|
|
62
|
+
}): Promise<CustomObject>;
|
|
63
|
+
/** Delete one object. Deleting what is not there is `CUSTOM_OBJECT_NOT_FOUND`. */
|
|
64
|
+
delete(container: string, key: string, options?: Call): Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
export declare function createCustomObjectsModule(transport: Transport): CustomObjectsModule;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createPage, PLATFORM_PASSTHROUGH_KEYS, readRestPage, } from '@sixtyninecommerce/core';
|
|
2
|
+
/**
|
|
3
|
+
* `value` holds the APP's own JSON, so its inner keys must survive the
|
|
4
|
+
* transport's case conversion untouched.
|
|
5
|
+
*
|
|
6
|
+
* Without this an app that writes `{ order_ref: 1 }` reads back
|
|
7
|
+
* `{ orderRef: 1 }` — and the round trip hides it, because the read path
|
|
8
|
+
* converts the mangled key into something that looks right while the stored row
|
|
9
|
+
* is wrong. This is the same trap `PLATFORM_PASSTHROUGH_KEYS` exists for; the
|
|
10
|
+
* key is added here rather than to that shared set because `value` is a common
|
|
11
|
+
* enough name that preserving it platform-wide would change modules that mean
|
|
12
|
+
* something else by it.
|
|
13
|
+
*/
|
|
14
|
+
const PRESERVE = new Set([...PLATFORM_PASSTHROUGH_KEYS, 'value']);
|
|
15
|
+
const segment = (value) => encodeURIComponent(value);
|
|
16
|
+
export function createCustomObjectsModule(transport) {
|
|
17
|
+
async function list(container, options) {
|
|
18
|
+
const body = await transport.rest('plugins', {
|
|
19
|
+
method: 'GET',
|
|
20
|
+
path: `/custom-objects/${segment(container)}`,
|
|
21
|
+
query: { after: options?.after, limit: options?.limit },
|
|
22
|
+
}, { ...options, preserveKeys: PRESERVE });
|
|
23
|
+
const { items, meta } = readRestPage(body);
|
|
24
|
+
return createPage(items, meta, (cursor) => list(container, { ...options, after: cursor }));
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
list,
|
|
28
|
+
async get(container, key, options) {
|
|
29
|
+
return await transport.rest('plugins', { method: 'GET', path: `/custom-objects/${segment(container)}/${segment(key)}` }, { ...options, preserveKeys: PRESERVE });
|
|
30
|
+
},
|
|
31
|
+
async set(container, key, value, options) {
|
|
32
|
+
return await transport.rest('plugins', {
|
|
33
|
+
method: 'PUT',
|
|
34
|
+
path: `/custom-objects/${segment(container)}/${segment(key)}`,
|
|
35
|
+
// Wrapped rather than sent bare so the body can gain a field later;
|
|
36
|
+
// a bare JSON value has nowhere to put one.
|
|
37
|
+
body: { value },
|
|
38
|
+
}, { ...options, preserveKeys: PRESERVE });
|
|
39
|
+
},
|
|
40
|
+
async delete(container, key, options) {
|
|
41
|
+
await transport.rest('plugins', {
|
|
42
|
+
method: 'DELETE',
|
|
43
|
+
path: `/custom-objects/${segment(container)}/${segment(key)}`,
|
|
44
|
+
expect: 'void',
|
|
45
|
+
}, options);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { type Page, type Transport } from '@sixtyninecommerce/core';
|
|
2
|
+
import type { Call, ListOptions } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Customers — the commerce master record (headless spec §6.5).
|
|
5
|
+
*
|
|
6
|
+
* Commerce owns customer identity and addresses; a vertical stores a
|
|
7
|
+
* `platformCustomerId` and asks here. The two things worth knowing before
|
|
8
|
+
* reading further are both traps this module exists to close:
|
|
9
|
+
*
|
|
10
|
+
* 1. **`create` wraps `createChannelCustomer`, never core `createCustomer`.**
|
|
11
|
+
* Vendure's own mutation silently SHARES a customer across channels when the
|
|
12
|
+
* email already exists anywhere on the platform (ADR-016): it finds that
|
|
13
|
+
* customer and pushes the new channel onto them. On a multi-tenant platform
|
|
14
|
+
* that is a cross-tenant data leak wearing the clothes of a convenience.
|
|
15
|
+
* There is no way to reach the leaking path through this SDK.
|
|
16
|
+
* 2. **`erase` is a scrub, not a delete.** It removes the person and keeps the
|
|
17
|
+
* record, the orders and the documents. It is the only destructive customer
|
|
18
|
+
* operation the platform has.
|
|
19
|
+
*/
|
|
20
|
+
export declare const CUSTOMER_FIELDS = "\n id\n createdAt\n updatedAt\n title\n firstName\n lastName\n emailAddress\n phoneNumber\n groups {\n id\n name\n }\n";
|
|
21
|
+
export declare const CUSTOMER_ADDRESS_FIELDS = "\n id\n fullName\n company\n streetLine1\n streetLine2\n city\n province\n postalCode\n country {\n code\n name\n }\n phoneNumber\n defaultShippingAddress\n defaultBillingAddress\n";
|
|
22
|
+
export declare const LIST_QUERY = "\n query Customers($options: CustomerListOptions) {\n customers(options: $options) {\n items { \n id\n createdAt\n updatedAt\n title\n firstName\n lastName\n emailAddress\n phoneNumber\n groups {\n id\n name\n }\n }\n totalItems\n }\n }\n";
|
|
23
|
+
export declare const GET_QUERY = "\n query CustomerById($id: ID!) {\n customer(id: $id) { \n id\n createdAt\n updatedAt\n title\n firstName\n lastName\n emailAddress\n phoneNumber\n groups {\n id\n name\n }\n }\n }\n";
|
|
24
|
+
export declare const ADDRESSES_QUERY = "\n query CustomerAddresses($id: ID!) {\n customer(id: $id) {\n id\n addresses { \n id\n fullName\n company\n streetLine1\n streetLine2\n city\n province\n postalCode\n country {\n code\n name\n }\n phoneNumber\n defaultShippingAddress\n defaultBillingAddress\n }\n }\n }\n";
|
|
25
|
+
/**
|
|
26
|
+
* The CHANNEL-SCOPED creation mutation. See the module docblock — core
|
|
27
|
+
* `createCustomer` is not an option, and this SDK exposes no path to it.
|
|
28
|
+
*/
|
|
29
|
+
export declare const CREATE_MUTATION = "\n mutation CreateChannelCustomer($input: CreateCustomerInput!, $password: String) {\n createChannelCustomer(input: $input, password: $password) {\n id\n emailAddress\n createdNew\n }\n }\n";
|
|
30
|
+
export declare const UPDATE_MUTATION = "\n mutation UpdateCustomer($input: UpdateCustomerInput!) {\n updateCustomer(input: $input) {\n ... on Customer { \n id\n createdAt\n updatedAt\n title\n firstName\n lastName\n emailAddress\n phoneNumber\n groups {\n id\n name\n }\n }\n ... on ErrorResult { errorCode message }\n }\n }\n";
|
|
31
|
+
export declare const INVITE_MUTATION = "\n mutation InviteCustomer($customerId: ID!, $mode: CustomerInviteMode) {\n inviteCustomer(customerId: $customerId, mode: $mode) {\n customerId\n emailAddress\n mode\n sentAt\n }\n }\n";
|
|
32
|
+
export declare const RESEND_INVITE_MUTATION = "\n mutation ResendCustomerInvitation($customerId: ID!, $mode: CustomerInviteMode) {\n resendCustomerInvitation(customerId: $customerId, mode: $mode) {\n customerId\n emailAddress\n mode\n sentAt\n }\n }\n";
|
|
33
|
+
export declare const ERASE_MUTATION = "\n mutation EraseCustomer(\n $customerId: ID!\n $reason: String!\n $force: Boolean\n $idempotencyKey: String\n ) {\n eraseCustomer(\n customerId: $customerId\n reason: $reason\n force: $force\n idempotencyKey: $idempotencyKey\n ) {\n customerId\n erasedAt\n ordersRetained\n documentsRetained\n forced\n reason\n }\n }\n";
|
|
34
|
+
export declare const ERASURE_QUERY = "\n query CustomerErasure($customerId: ID!) {\n customerErasure(customerId: $customerId) {\n customerId\n erasedAt\n ordersRetained\n documentsRetained\n forced\n reason\n }\n }\n";
|
|
35
|
+
export declare const ADD_TO_GROUP_MUTATION = "\n mutation AddCustomersToGroup($customerGroupId: ID!, $customerIds: [ID!]!) {\n addCustomersToGroup(customerGroupId: $customerGroupId, customerIds: $customerIds) {\n id\n name\n }\n }\n";
|
|
36
|
+
export declare const REMOVE_FROM_GROUP_MUTATION = "\n mutation RemoveCustomersFromGroup($customerGroupId: ID!, $customerIds: [ID!]!) {\n removeCustomersFromGroup(customerGroupId: $customerGroupId, customerIds: $customerIds) {\n id\n name\n }\n }\n";
|
|
37
|
+
export declare const CREATE_ADDRESS_MUTATION = "\n mutation CreateCustomerAddress($customerId: ID!, $input: CreateAddressInput!) {\n createCustomerAddress(customerId: $customerId, input: $input) {\n \n id\n fullName\n company\n streetLine1\n streetLine2\n city\n province\n postalCode\n country {\n code\n name\n }\n phoneNumber\n defaultShippingAddress\n defaultBillingAddress\n\n }\n }\n";
|
|
38
|
+
export declare const UPDATE_ADDRESS_MUTATION = "\n mutation UpdateCustomerAddress($input: UpdateAddressInput!) {\n updateCustomerAddress(input: $input) { \n id\n fullName\n company\n streetLine1\n streetLine2\n city\n province\n postalCode\n country {\n code\n name\n }\n phoneNumber\n defaultShippingAddress\n defaultBillingAddress\n }\n }\n";
|
|
39
|
+
export declare const DELETE_ADDRESS_MUTATION = "\n mutation DeleteCustomerAddress($id: ID!) {\n deleteCustomerAddress(id: $id) { success }\n }\n";
|
|
40
|
+
export interface CustomerGroupRef {
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
}
|
|
44
|
+
export interface Customer {
|
|
45
|
+
id: string;
|
|
46
|
+
createdAt: string;
|
|
47
|
+
updatedAt: string;
|
|
48
|
+
title: string | null;
|
|
49
|
+
firstName: string;
|
|
50
|
+
lastName: string;
|
|
51
|
+
emailAddress: string;
|
|
52
|
+
phoneNumber: string | null;
|
|
53
|
+
groups: CustomerGroupRef[];
|
|
54
|
+
/** Forward-compat: fields a later release adds arrive camelised and untouched. */
|
|
55
|
+
[extra: string]: unknown;
|
|
56
|
+
}
|
|
57
|
+
export interface CustomerAddress {
|
|
58
|
+
id: string;
|
|
59
|
+
fullName: string | null;
|
|
60
|
+
company: string | null;
|
|
61
|
+
streetLine1: string;
|
|
62
|
+
streetLine2: string | null;
|
|
63
|
+
city: string | null;
|
|
64
|
+
province: string | null;
|
|
65
|
+
postalCode: string | null;
|
|
66
|
+
country: {
|
|
67
|
+
code: string;
|
|
68
|
+
name: string;
|
|
69
|
+
} | null;
|
|
70
|
+
phoneNumber: string | null;
|
|
71
|
+
defaultShippingAddress: boolean | null;
|
|
72
|
+
defaultBillingAddress: boolean | null;
|
|
73
|
+
[extra: string]: unknown;
|
|
74
|
+
}
|
|
75
|
+
export interface CreateCustomerInput {
|
|
76
|
+
emailAddress: string;
|
|
77
|
+
firstName: string;
|
|
78
|
+
lastName: string;
|
|
79
|
+
title?: string;
|
|
80
|
+
phoneNumber?: string;
|
|
81
|
+
customFields?: Record<string, unknown>;
|
|
82
|
+
}
|
|
83
|
+
export interface UpdateCustomerInput {
|
|
84
|
+
title?: string;
|
|
85
|
+
firstName?: string;
|
|
86
|
+
lastName?: string;
|
|
87
|
+
phoneNumber?: string;
|
|
88
|
+
emailAddress?: string;
|
|
89
|
+
customFields?: Record<string, unknown>;
|
|
90
|
+
}
|
|
91
|
+
/** What `createChannelCustomer` answers. */
|
|
92
|
+
export interface CreatedCustomer {
|
|
93
|
+
id: string;
|
|
94
|
+
emailAddress: string;
|
|
95
|
+
/**
|
|
96
|
+
* `false` when a customer with this email already existed ON THIS CHANNEL and
|
|
97
|
+
* was returned instead. It is never another channel's customer — that is the
|
|
98
|
+
* whole point of the channel-scoped mutation.
|
|
99
|
+
*/
|
|
100
|
+
createdNew: boolean;
|
|
101
|
+
}
|
|
102
|
+
export type CustomerInviteMode = 'PASSWORD_SETUP' | 'MAGIC_LINK';
|
|
103
|
+
export interface CustomerInvitation {
|
|
104
|
+
customerId: string;
|
|
105
|
+
emailAddress: string;
|
|
106
|
+
mode: CustomerInviteMode;
|
|
107
|
+
sentAt: string;
|
|
108
|
+
}
|
|
109
|
+
/** The record of an erasure. See {@link CustomersModule.erase}. */
|
|
110
|
+
export interface CustomerErasureRecord {
|
|
111
|
+
customerId: string;
|
|
112
|
+
erasedAt: string;
|
|
113
|
+
/** Placed orders left standing — an order is a document fact. */
|
|
114
|
+
ordersRetained: number;
|
|
115
|
+
/**
|
|
116
|
+
* ORDER DOCUMENTS left standing: invoices and credit notes issued against
|
|
117
|
+
* this customer's orders on this channel.
|
|
118
|
+
*
|
|
119
|
+
* Accounts-receivable documents (`ar_invoice`, `ar_credit_note`) are NOT
|
|
120
|
+
* counted — a consolidated AR invoice spans several orders and is not
|
|
121
|
+
* reachable through the order join — so a B2B tenant's full document set is
|
|
122
|
+
* larger than this number.
|
|
123
|
+
*/
|
|
124
|
+
documentsRetained: number;
|
|
125
|
+
/** A SuperAdmin overrode the open-unpaid-order refusal. */
|
|
126
|
+
forced: boolean;
|
|
127
|
+
reason: string;
|
|
128
|
+
}
|
|
129
|
+
export interface EraseCustomerOptions extends Call {
|
|
130
|
+
/** Recorded on the erasure record. Required — it is the evidence it was honoured. */
|
|
131
|
+
reason: string;
|
|
132
|
+
/**
|
|
133
|
+
* Erase despite an open unpaid order. SuperAdmin only, refused with
|
|
134
|
+
* `CUSTOMER_ERASURE_FORCE_FORBIDDEN` otherwise, and recorded on the record.
|
|
135
|
+
*/
|
|
136
|
+
force?: boolean;
|
|
137
|
+
/** Your own request id, recorded for provenance. */
|
|
138
|
+
idempotencyKey?: string;
|
|
139
|
+
}
|
|
140
|
+
export interface CustomerAddressesModule {
|
|
141
|
+
list(customerId: string, options?: Call): Promise<CustomerAddress[]>;
|
|
142
|
+
create(customerId: string, input: Record<string, unknown>, options?: Call): Promise<CustomerAddress>;
|
|
143
|
+
update(input: {
|
|
144
|
+
id: string;
|
|
145
|
+
} & Record<string, unknown>, options?: Call): Promise<CustomerAddress>;
|
|
146
|
+
delete(id: string, options?: Call): Promise<void>;
|
|
147
|
+
}
|
|
148
|
+
export interface CustomerGroupsModule {
|
|
149
|
+
/** Add customers to a group. Emits one `customer.group_changed` per customer. */
|
|
150
|
+
add(groupId: string, customerIds: string[], options?: Call): Promise<CustomerGroupRef>;
|
|
151
|
+
remove(groupId: string, customerIds: string[], options?: Call): Promise<CustomerGroupRef>;
|
|
152
|
+
}
|
|
153
|
+
export interface CustomersModule {
|
|
154
|
+
list(options?: ListOptions): Promise<Page<Customer>>;
|
|
155
|
+
get(id: string, options?: Call): Promise<Customer | null>;
|
|
156
|
+
/**
|
|
157
|
+
* Create a customer ON THIS CHANNEL. Never creates credentials — `invite`
|
|
158
|
+
* does that.
|
|
159
|
+
*/
|
|
160
|
+
create(input: CreateCustomerInput, options?: Call & {
|
|
161
|
+
password?: string;
|
|
162
|
+
}): Promise<CreatedCustomer>;
|
|
163
|
+
update(id: string, input: UpdateCustomerInput, options?: Call): Promise<Customer>;
|
|
164
|
+
/**
|
|
165
|
+
* Mail the customer a way in: a set-password link, or a magic-link sign-in.
|
|
166
|
+
*
|
|
167
|
+
* Not the storefront's forgot-password screen, which mails a password RESET
|
|
168
|
+
* — something a customer who has never had an account did not ask for.
|
|
169
|
+
*/
|
|
170
|
+
invite(id: string, options?: Call & {
|
|
171
|
+
mode?: CustomerInviteMode;
|
|
172
|
+
resend?: boolean;
|
|
173
|
+
}): Promise<CustomerInvitation>;
|
|
174
|
+
/**
|
|
175
|
+
* GDPR erasure: scrub the person, keep the record.
|
|
176
|
+
*
|
|
177
|
+
* The customer row, their orders and their invoices remain — an invoice that
|
|
178
|
+
* cannot be reproduced is a different problem from a privacy one. Emits
|
|
179
|
+
* `customer.deleted`.
|
|
180
|
+
*
|
|
181
|
+
* What "the person" covers is enumerated BY COLUMN on the platform rather
|
|
182
|
+
* than remembered: every column of the commerce database classified as
|
|
183
|
+
* personal data is scrubbed or tombstoned, retained columns and their reasons
|
|
184
|
+
* are listed with it, and the plugins-side event log is redacted too. The
|
|
185
|
+
* classification is regenerated from the live schema and a build
|
|
186
|
+
* fails on any column it does not answer for — including columns on tables
|
|
187
|
+
* that name no customer at all, which is how an invoice email log kept an
|
|
188
|
+
* erased person's address.
|
|
189
|
+
*
|
|
190
|
+
* Refused while a retention hold is active (`RETENTION_HOLD_ACTIVE`), while
|
|
191
|
+
* the customer is party to an open unpaid order (`CUSTOMER_HAS_OPEN_ORDERS`;
|
|
192
|
+
* a SuperAdmin may pass `force`), while they hold a subscription that has not
|
|
193
|
+
* ended (`CUSTOMER_HAS_ACTIVE_SUBSCRIPTIONS`), when their login is shared with
|
|
194
|
+
* another store on the platform (`CUSTOMER_SHARED_ACROSS_STORES`), and when
|
|
195
|
+
* the tenant context names the platform root channel rather than the
|
|
196
|
+
* customer's own (`CUSTOMER_ERASURE_CHANNEL_INVALID`).
|
|
197
|
+
*
|
|
198
|
+
* `force` overrides the OPEN-ORDER refusal and nothing else. A live payment
|
|
199
|
+
* mandate will charge this person again next cycle, and a shared login belongs
|
|
200
|
+
* to a merchant who asked for nothing — neither is a SuperAdmin's to waive.
|
|
201
|
+
*
|
|
202
|
+
* Idempotent: erasing an already-erased customer returns the original record.
|
|
203
|
+
*
|
|
204
|
+
* Platform events emitted BEFORE the erasure lose the name and email too. That
|
|
205
|
+
* log lives in another service's database, which the erasure cannot reach, so
|
|
206
|
+
* the redaction is triggered there by the arrival of this erasure's own
|
|
207
|
+
* `customer.deleted` event: ingesting it queues the redaction in the same
|
|
208
|
+
* transaction, and a worker applies it afterwards, so this call RETURNS
|
|
209
|
+
* BEFORE the log has been rewritten. Two one-minute ticks sit in between (the
|
|
210
|
+
* relay, then the drain), so it is up to about two minutes. A periodic
|
|
211
|
+
* reconcile covers deletions ingested before that release shipped, and a
|
|
212
|
+
* redaction that keeps failing is parked and reported in the plugins log —
|
|
213
|
+
* nothing on the returned record reflects either.
|
|
214
|
+
*
|
|
215
|
+
* That event keeps its ids and its `external_refs`: it is the one moment an
|
|
216
|
+
* ESP, ERP, CRM or PSP can erase its own copy of the person, and blanking it
|
|
217
|
+
* would destroy the only keys to them.
|
|
218
|
+
*/
|
|
219
|
+
erase(id: string, options: EraseCustomerOptions): Promise<CustomerErasureRecord>;
|
|
220
|
+
/** The erasure record, or `null` if this customer has not been erased. */
|
|
221
|
+
erasure(id: string, options?: Call): Promise<CustomerErasureRecord | null>;
|
|
222
|
+
groups: CustomerGroupsModule;
|
|
223
|
+
addresses: CustomerAddressesModule;
|
|
224
|
+
}
|
|
225
|
+
export declare function createCustomersModule(transport: Transport): CustomersModule;
|