@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/README.md
CHANGED
|
@@ -115,6 +115,21 @@ channel. When the app's declarations later grow, the installation moves to `need
|
|
|
115
115
|
working** until `apps.approveConsent(id)` lands, so this is a notice, not an outage. `apps.uninstall(id)`
|
|
116
116
|
revokes every token immediately and keeps the row as provenance for what the app wrote.
|
|
117
117
|
|
|
118
|
+
Growth is granted only by NAMING it, and an approval should carry what the approver was shown:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
await admin.apps.approveConsent(installation.id, {
|
|
122
|
+
grantedPermissions: [...installation.grantedPermissions, 'UpdateProduct'],
|
|
123
|
+
// What was on screen. The engine refuses the approval if the app changed
|
|
124
|
+
// while it was being read, so a re-registration between the screen and the
|
|
125
|
+
// button cannot be ratified by somebody who never saw it.
|
|
126
|
+
acknowledgedConsentReasons: installation.pendingConsentReasons ?? [],
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Omit `grantedPermissions` and the installation keeps exactly what it had — the right default for an
|
|
131
|
+
app that grew an event type or a metafield rather than a permission.
|
|
132
|
+
|
|
118
133
|
```ts
|
|
119
134
|
// App side — the app authenticates as itself. No token handling.
|
|
120
135
|
const app = createAdminClient({
|
|
@@ -138,6 +153,25 @@ bearer, and refreshes that bearer a minute before it expires. The key never leav
|
|
|
138
153
|
the bearer is never yours to store. `privateKey` takes a PKCS#8 PEM, a JWK, or an already-imported
|
|
139
154
|
`CryptoKey` for a caller whose key lives in a KMS handle.
|
|
140
155
|
|
|
156
|
+
The assertion carries `aud` — the origin of the deployment it may be exchanged at — and the platform
|
|
157
|
+
verifies it strictly. The SDK derives it from the `urls.vendureUrl` you already configured, so there
|
|
158
|
+
is nothing to set. It matters because `iss` and `sub` name one app on one installation, and those ids
|
|
159
|
+
survive a database restore unchanged: without `aud`, an assertion minted for a staging restore of a
|
|
160
|
+
production dump verifies against production. A mismatch answers
|
|
161
|
+
`400 ASSERTION_AUDIENCE_MISMATCH` naming the origin that deployment expects.
|
|
162
|
+
|
|
163
|
+
**Signing by hand?** Read the audience from the authenticated capability manifest and put it in
|
|
164
|
+
`aud` verbatim — do not re-derive it. Through this SDK that is
|
|
165
|
+
`(await admin.tenant.capabilities()).assertionAudience`; straight off the wire
|
|
166
|
+
(`GET /capabilities`) the member is `assertion_audience`, since the SDK camelises response keys. A
|
|
167
|
+
`null` there means the plugins service cannot state one, which does not mean the exchange accepts an
|
|
168
|
+
assertion without `aud` — the two run as separate deployment resources, so ask the operator rather
|
|
169
|
+
than omitting the claim.
|
|
170
|
+
|
|
171
|
+
An assertion with no `aud` at all is still accepted **until 2026-12-01**; those exchanges answer 200
|
|
172
|
+
with `Deprecation`, `Sunset` and `Link` headers, and the platform logs `APP_ASSERTION_AUD_MISSING`
|
|
173
|
+
naming your app. After the sunset they are refused.
|
|
174
|
+
|
|
141
175
|
`apps.*` itself is a TENANT ADMIN's surface, not an app's: `InstallApps` is never grantable to an
|
|
142
176
|
app, because an installation is a minted credential and an app that could install one could grant
|
|
143
177
|
another app a subset of its own permissions with nobody consenting. An app reads its own state
|
|
@@ -147,8 +181,66 @@ Registering apps, managing their keys, suspension and per-tenant enablement live
|
|
|
147
181
|
`platform.apps.*` and need a human SuperAdmin session — an app that could register apps could
|
|
148
182
|
declare anything and enable itself.
|
|
149
183
|
|
|
150
|
-
`APP_MANIFEST_SCHEMA`
|
|
151
|
-
manifest before anyone tries to register it.
|
|
184
|
+
`APP_MANIFEST_SCHEMA` (and its `APP_MANIFEST_SCHEMA_SOURCE` text) is re-exported from THIS package,
|
|
185
|
+
so a build step can validate a manifest before anyone tries to register it. It is defined in
|
|
186
|
+
`@sixtyninecommerce/core`, which is a transitive dependency pnpm will not resolve for you — import it
|
|
187
|
+
from here.
|
|
188
|
+
|
|
189
|
+
The registry is deliberately STRICTER than that schema: it refuses a padded string, a duplicate list
|
|
190
|
+
entry, an unknown key inside a metafield definition, and a namespace naming another app. Validating
|
|
191
|
+
against the schema says "worth sending", never "certain to be accepted".
|
|
192
|
+
|
|
193
|
+
### An app's settings, and its own state
|
|
194
|
+
|
|
195
|
+
Two surfaces, and the difference between them is who owns the data.
|
|
196
|
+
|
|
197
|
+
`apps.installation.config` is the **settings form** the app declares and the merchant fills in. A
|
|
198
|
+
tenant admin with `InstallApps` reads and writes it; the owning app may READ its own and may never
|
|
199
|
+
write it — an app that could write its own settings could grant itself whatever its own code reads
|
|
200
|
+
out of them.
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
const { value } = await admin.apps.installation.config.get(installationId);
|
|
204
|
+
await admin.apps.installation.config.set(installationId, { endpoint: 'https://acme.test/hook' });
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
What you save is validated against the app's declared `configSchema`. A refusal is `CONFIG_INVALID`
|
|
208
|
+
with one `details[]` entry per failing JSON Pointer, so a form can mark every field at once. An app
|
|
209
|
+
that declares no schema has nothing to configure (`APP_DECLARES_NO_CONFIG`), and a schema using a
|
|
210
|
+
keyword or type the platform does not recognise refuses every save rather than half-checking it
|
|
211
|
+
(`CONFIG_SCHEMA_UNENFORCEABLE` — the app's author must re-register).
|
|
212
|
+
|
|
213
|
+
That covers both axes: a keyword the platform does not recognise, and one it recognises but cannot
|
|
214
|
+
apply to the level's declared type — `minLength` on an `integer`, `required` on a `string` — which
|
|
215
|
+
would constrain nothing. Declare each keyword on a type it applies to: `minLength`/`maxLength`/
|
|
216
|
+
`pattern` on a string, `minimum`/`maximum` on a number or integer, `minItems`/`maxItems` on an
|
|
217
|
+
array, `required` on an object. Reading never fails because the
|
|
218
|
+
schema moved: the stored value comes back as it was saved even where the current schema no longer
|
|
219
|
+
describes it, so you can show it and offer to clear it.
|
|
220
|
+
|
|
221
|
+
`customObjects` is the app's **own private state**, `(container, key)` documents that only an
|
|
222
|
+
installation token reaches. A human session — SuperAdmin included — is refused
|
|
223
|
+
(`CUSTOM_OBJECT_FORBIDDEN`), and one installation never sees another's rows.
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
await admin.customObjects.set('sync-state', 'cursor', { at: lastSyncedAt });
|
|
227
|
+
const page = await admin.customObjects.list('sync-state');
|
|
228
|
+
await admin.customObjects.delete('sync-state', 'cursor');
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Quotas are per installation per store: 128 KiB a document and 10,000 live objects. The row quota is
|
|
232
|
+
checked only when a NEW key is written, so a full installation can still shrink a value it already
|
|
233
|
+
has, and the merchant's settings row never counts against it. A container name is 1-64 characters,
|
|
234
|
+
starts with a lowercase letter or digit and uses only lowercase letters, digits, `-` and `_`; a key
|
|
235
|
+
is 1-200 characters with no control characters.
|
|
236
|
+
|
|
237
|
+
Both `value` payloads keep their keys exactly as you send them — the case conversion this SDK
|
|
238
|
+
applies to its own field names never reaches inside them, so a field named `order_ref` stays
|
|
239
|
+
`order_ref`.
|
|
240
|
+
|
|
241
|
+
Neither write is deduplicated by an `Idempotency-Key`. `customObjects.set` replaces a value by key,
|
|
242
|
+
so repeating it is the same as doing it once; the settings write is a human saving a form, where
|
|
243
|
+
replaying an earlier answer to a later edit would be wrong.
|
|
152
244
|
|
|
153
245
|
### Knowing what credential you hold
|
|
154
246
|
|
|
@@ -388,6 +480,271 @@ had before the registry.
|
|
|
388
480
|
Read with `ReadSettings` and written with `UpdateSettings` — the same grants that govern
|
|
389
481
|
`storefrontUrl` itself. A head an app installed (`installationId` set) is managed through that app's
|
|
390
482
|
declarations and is refused here with `HEAD_APP_OWNED`.
|
|
483
|
+
|
|
484
|
+
### Tax classes
|
|
485
|
+
|
|
486
|
+
Tax categories and their rates are **platform-wide**, shared by every store. A tax class is your
|
|
487
|
+
store's own name for how something is taxed — `standard`, `reduced`, `zero-rated` — mapped per
|
|
488
|
+
country onto the category that prices it.
|
|
489
|
+
|
|
490
|
+
```ts
|
|
491
|
+
await admin.tax.classes.upsert({ key: 'reduced', label: 'Verlaagd tarief' }); // kebab-case key
|
|
492
|
+
await admin.tax.mappings.set('reduced', 'NL', nlReducedCategoryId);
|
|
493
|
+
await admin.tax.mappings.set('reduced', '*', nlReducedCategoryId); // the fallback
|
|
494
|
+
|
|
495
|
+
const classes = await admin.tax.classes.list(); // a Page; the list is complete today
|
|
496
|
+
await admin.tax.classes.get('reduced'); // null when this store has no such key
|
|
497
|
+
await admin.tax.classes.delete('reduced'); // false when there was none
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
Resolution is **country → `*` → refuse**: the country the money is taxed in, then the fallback row,
|
|
501
|
+
then `TAX_CLASS_UNMAPPED` naming the country. `*` is a mapping like any other, set and removed the
|
|
502
|
+
same way, so "this class says nothing about Germany" and "this class charges Germany the fallback"
|
|
503
|
+
stay different states.
|
|
504
|
+
|
|
505
|
+
`upsert` is keyed on `key` and there is **no rename**: the key lives on product variants and on
|
|
506
|
+
every `tax_class.changed` a consumer has stored. Create a new class and delete the old — in that
|
|
507
|
+
order, because `delete` is refused with `TAX_CLASS_IN_USE` while variants still carry it. Refusals:
|
|
508
|
+
`TAX_CLASS_NOT_FOUND` (`NotFoundError`, and also the answer for another store's class),
|
|
509
|
+
`TAX_CLASS_KEY_TAKEN` / `TAX_CLASS_IN_USE` (`ConflictError`), `TAX_CLASS_UNMAPPED` and the
|
|
510
|
+
`INVALID_TAX_CLASS_*` codes (`ValidationError`).
|
|
511
|
+
|
|
512
|
+
Read with `ReadSettings` and written with `UpdateSettings` — deliberately not the tax-category
|
|
513
|
+
permissions, which govern the table every store shares.
|
|
514
|
+
|
|
515
|
+
### Order surcharges
|
|
516
|
+
|
|
517
|
+
A surcharge is an adjustment on a placed order that is not a line: a delivery charge, a negotiated
|
|
518
|
+
discount, a handling fee. Money is integer minor units, negative for a discount, and
|
|
519
|
+
`priceIncludesTax` is required because guessing the basis is a 21% error.
|
|
520
|
+
|
|
521
|
+
```ts
|
|
522
|
+
await admin.orders.surcharges.add(orderId, {
|
|
523
|
+
description: 'Bezorgtoeslag',
|
|
524
|
+
price: 500,
|
|
525
|
+
priceIncludesTax: true,
|
|
526
|
+
}, { taxClass: 'reduced' }); // the class decides the rate, per order
|
|
527
|
+
|
|
528
|
+
await admin.orders.surcharges.add(orderId, {
|
|
529
|
+
description: 'Kortingsafspraak',
|
|
530
|
+
price: -7522, // negative: a discount agreed after the order
|
|
531
|
+
priceIncludesTax: true,
|
|
532
|
+
taxRate: 21, // or state the percentage yourself
|
|
533
|
+
taxDescription: 'NL Standard VAT 21%',
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
await admin.orders.surcharges.update(orderId, surchargeId, input, { taxClass: 'reduced' });
|
|
537
|
+
await admin.orders.surcharges.remove(orderId, surchargeId);
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
`taxClass` is resolved **per order**, and the two halves are different questions: the order's
|
|
541
|
+
shipping **country** picks the class's mapping, and the order's tax **zone** picks that category's
|
|
542
|
+
rate. They can legitimately differ — a store only moves a country into its own zone by listing it in
|
|
543
|
+
its tax-exclusive countries — so map the country *and* give that zone a rate for the category, or the
|
|
544
|
+
write is refused (`TAX_CLASS_UNMAPPED`, `TAX_CLASS_RATE_MISSING`) rather than silently untaxed.
|
|
545
|
+
|
|
546
|
+
The order's own tax policy still wins: a tax-removed order and an EU reverse charge override
|
|
547
|
+
whatever the class resolved to, because the rate goes through the same tax-line strategy the order's
|
|
548
|
+
lines do. A per-line rate imported from an ERP is the one policy that does not carry over — it
|
|
549
|
+
belongs to a line the ERP sent, not to a charge added afterwards. The class chooses which rate is looked up, never what the line finally
|
|
550
|
+
says — which is also why sending `taxClass` together with `input.taxRate` or `input.taxDescription`
|
|
551
|
+
is refused. Omit it and the explicit-rate path is exactly what it always was.
|
|
552
|
+
|
|
553
|
+
`update` replaces the surcharge in place, so it keeps its id (a delivery charge stays linked to the
|
|
554
|
+
fulfillment that created it) and an omitted `sku` is left alone rather than cleared. **Tax is not
|
|
555
|
+
like `sku`**: it is a whole replacement, so omitting both `taxClass` and `input.taxRate` writes a
|
|
556
|
+
surcharge with no tax line and lowers the order total — re-send whichever of the two it should
|
|
557
|
+
carry. A `taxClass` is then re-resolved on every write, so the rate that applies now is the one
|
|
558
|
+
stored. All three need `UpdateOrder` and are refused once the order's invoice has been approved.
|
|
559
|
+
|
|
560
|
+
### Payment providers
|
|
561
|
+
|
|
562
|
+
Onboard the PSP account a store is paid through, place its card terminals, and ask whether it can
|
|
563
|
+
actually take money.
|
|
564
|
+
|
|
565
|
+
```ts
|
|
566
|
+
const { url, state } = await admin.payments.providers.startOnboarding('mollie', {
|
|
567
|
+
returnUrl: 'https://merchant.example/settings/payments',
|
|
568
|
+
}); // send the merchant to `url`
|
|
569
|
+
|
|
570
|
+
await admin.payments.providers.completeOnboarding('mollie', { state, code }); // on their return
|
|
571
|
+
await admin.payments.providers.activateMethod('mollie', 'pointofsale');
|
|
572
|
+
|
|
573
|
+
await admin.payments.providers.terminals.provision('mollie', { label: 'Counter 1' });
|
|
574
|
+
await admin.payments.providers.terminals.assign(terminalId, locationId);
|
|
575
|
+
await admin.payments.providers.terminals.unassign(terminalId); // still a terminal, just in a drawer
|
|
576
|
+
|
|
577
|
+
await admin.payments.providers.terminals.list('mollie'); // what the platform holds — a pure read
|
|
578
|
+
await admin.payments.providers.terminals.refresh('mollie'); // reconcile with the provider (a write)
|
|
579
|
+
|
|
580
|
+
const readiness = await admin.payments.providers.readiness('mollie', { locationId });
|
|
581
|
+
readiness.overall; // can this store take money at that counter?
|
|
582
|
+
readiness.blockers; // if not, the server's own evidence — one entry per failing gate
|
|
583
|
+
readiness.path; // 'connect' | 'legacy' | 'none'
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
**A connection belongs to the seller, not the store.** A PSP account belongs to the legal entity that
|
|
587
|
+
gets paid, so every store of one seller shares one connection: onboard once, and the money lands in
|
|
588
|
+
one place. A channel with no seller is refused with `SELLER_REQUIRED` rather than given a connection
|
|
589
|
+
keyed on nothing.
|
|
590
|
+
|
|
591
|
+
**Ask the question you are about to act on.** Pass `locationId` for a counter sale and the terminal
|
|
592
|
+
signals gate the verdict; pass `orderId` and `orderPayable` does. Signals outside your question never
|
|
593
|
+
gate `overall` and never produce a blocker — a web-only store has no terminal and is not broken. Two
|
|
594
|
+
of them read `false` when you did not ask (`terminalUsableForLocation`, `orderPayable`): there is no
|
|
595
|
+
location and no order for them to be true of, so don't render either as a problem unless you asked
|
|
596
|
+
the question it answers. Terminals belong to a provider connection, so on the `legacy` path a
|
|
597
|
+
`locationId` question says so rather than claiming a counter is ready. `partialSupported` never gates at all: it answers *may I take a deposit here*, not
|
|
598
|
+
*can this store be paid*.
|
|
599
|
+
|
|
600
|
+
**Two credential paths, both first-class.** `path` is `connect` for an account onboarded through the
|
|
601
|
+
platform and `legacy` for the store's own per-channel API key. There is no migration: a legacy store
|
|
602
|
+
is supported indefinitely, can be fully ready, and moves when it chooses to. The per-channel key
|
|
603
|
+
surface is deprecated in the schema and supported in the code; those are different statements.
|
|
604
|
+
|
|
605
|
+
`completeOnboarding` and `terminals.provision` take an `idempotencyKey`, so a retried request cannot
|
|
606
|
+
leave a second account or a second reader behind. Provisioning the same reader twice is the same
|
|
607
|
+
reader either way — the provider's id is its identity. `startOnboarding` deliberately takes none: its
|
|
608
|
+
response carries a link that can be single-use, an idempotency store keeps a response for 24 hours
|
|
609
|
+
and re-serves it, and retrying is safe anyway — a repeat replaces the pending attempt and answers
|
|
610
|
+
with a fresh url and state, which is what a merchant who lost the first link needs.
|
|
611
|
+
|
|
612
|
+
**One connection, every store of the seller.** `UpdatePaymentMethod` in ANY of a seller's stores can
|
|
613
|
+
disconnect and re-connect, which re-points revenue for all of them. That is the same sharing that
|
|
614
|
+
makes onboarding happen once, but it means the permission's blast radius is the seller, not the
|
|
615
|
+
store — worth knowing before granting it. Terminals hang off that one connection, so the same holds
|
|
616
|
+
for them: `terminals.assign`/`unassign` from one store can move a reader that serves a sibling
|
|
617
|
+
store's counter, and the `terminal.changed` event is filed under the store that made the change, so
|
|
618
|
+
the sibling's POS learns about it on its next read rather than from the event.
|
|
619
|
+
|
|
620
|
+
`terminals.list` is a pure read of what the platform holds; `terminals.refresh` reconciles with the
|
|
621
|
+
provider and is a WRITE — it records readers the merchant added in the provider's own dashboard,
|
|
622
|
+
updates the ones it knows, and marks a reader the provider no longer reports `inactive` so it stops
|
|
623
|
+
counting towards readiness. Rows are never deleted: a row is the evidence a reader existed and where
|
|
624
|
+
it stood.
|
|
625
|
+
|
|
626
|
+
`terminals.unassign(id)` is how a terminal comes off a location — `assign` requires a location id, so
|
|
627
|
+
there is no call with a meaningless argument. (Underneath, both are `assignProviderTerminal`, whose
|
|
628
|
+
`locationId` is nullable; a null and an omitted one mean the same thing to the server.)
|
|
629
|
+
|
|
630
|
+
Refusals: `PROVIDER_ALREADY_CONNECTED` (`ConflictError` — disconnect first; silently re-pointing a
|
|
631
|
+
store's money is not something a retry should do), `SELLER_REQUIRED`, `PROVIDER_NOT_CONNECTED`,
|
|
632
|
+
`PROVIDER_CREDENTIAL_REJECTED` and `TERMINAL_REJECTED` (`BusinessRuleError` — what the provider
|
|
633
|
+
itself refused, with its own message kept out of the response),
|
|
634
|
+
`PAYMENT_PROVIDER_NOT_REGISTERED`, `ONBOARDING_STATE_INVALID` and the `INVALID_*` inputs
|
|
635
|
+
(`ValidationError`), `TERMINAL_NOT_FOUND` (`NotFoundError`), and `LOCATION_NOT_IN_CHANNEL` when a
|
|
636
|
+
terminal or a readiness question names another channel's location — the same code the rest of the
|
|
637
|
+
platform uses for that one rule.
|
|
638
|
+
|
|
639
|
+
`LIVE_CONNECTION_REFUSED` is declared and classified but nothing throws it yet: the rule that a
|
|
640
|
+
sandbox store may hold only a `test` connection needs a way to identify a sandbox store, which
|
|
641
|
+
arrives with the sandbox flag. `readiness.mode` already reports which kind a store holds.
|
|
642
|
+
|
|
643
|
+
Reading needs `ReadPaymentMethod`, writing `UpdatePaymentMethod`.
|
|
644
|
+
|
|
645
|
+
### Documents
|
|
646
|
+
|
|
647
|
+
Invoices, credit notes and packing slips: issuing them, the numbered facts they were built from, and
|
|
648
|
+
your own extension blocks.
|
|
649
|
+
|
|
650
|
+
```ts
|
|
651
|
+
// Issue. `subjectId` is what the document is FOR: an order for an invoice, a
|
|
652
|
+
// FULFILLMENT for a packing slip. An invoice date is required, never assumed.
|
|
653
|
+
const invoice = await admin.documents.generate(orderId, 'invoice', {
|
|
654
|
+
invoicedOn: '2026-09-18',
|
|
655
|
+
extensions: [{ key: 'export-declaration', schemaVersion: 1, data: { hsCode: '8471.30' } }],
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
const data = await admin.documents.data(invoice.id, 'invoice');
|
|
659
|
+
data.numbering; // { scope: 'channel', scopeId, series, number }
|
|
660
|
+
data.totals?.totalWithTax; // the figures the PDF printed, in minor units (null on a lump-sum note)
|
|
661
|
+
data.reconciled; // false when the ORDER moved after the invoice was numbered
|
|
662
|
+
|
|
663
|
+
await admin.documents.invoices(orderId); // a Page; complete today
|
|
664
|
+
await admin.documents.creditNotes(orderId);
|
|
665
|
+
await admin.documents.previewInvoice(orderId, { invoicedOn: '2026-09-18' }); // PDF bytes, nothing stored
|
|
666
|
+
await admin.documents.sendInvoiceEmail({ orderId, recipientsTo: ['ap@buyer.example'] });
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
**`documentData` is the read to build on.** It returns what the document was numbered with —
|
|
670
|
+
numbering, issuer, recipient, lines, per-rate VAT, totals and the blocks it froze — so a vertical can
|
|
671
|
+
render its own document from the platform's own figures. Two fields say what a re-derivation would
|
|
672
|
+
hide: `reconciled` is `false` when the rebuilt figures no longer match `issuedTotalWithTax` (a
|
|
673
|
+
standard invoice derives its lines from the order, and an order can be modified after the invoice was
|
|
674
|
+
numbered), and `taxSummary` is `null` rather than `[]` on a lump-sum credit note that never stated a
|
|
675
|
+
per-rate split.
|
|
676
|
+
|
|
677
|
+
Numbering, the VAT summary and issuer identity stay platform-owned — there is no method that
|
|
678
|
+
allocates a number. `numbering.scope` is modelled for the per-legal-entity series multi-region will
|
|
679
|
+
add, so a consumer reading `scope`/`scopeId` keeps working when it lands.
|
|
680
|
+
|
|
681
|
+
#### Extension blocks
|
|
682
|
+
|
|
683
|
+
A block is a named, schema-validated island of **your** data drawn into the reserved region of a
|
|
684
|
+
document — after the summary table, before the footer.
|
|
685
|
+
|
|
686
|
+
```ts
|
|
687
|
+
await admin.documents.extensions.register({
|
|
688
|
+
key: 'export-declaration', // lower-case kebab, ≤ 64 chars
|
|
689
|
+
documentTypes: ['invoice', 'packing-slip'],
|
|
690
|
+
jsonSchema: {
|
|
691
|
+
type: 'object',
|
|
692
|
+
required: ['hsCode'],
|
|
693
|
+
properties: { hsCode: { type: 'string', minLength: 4 } },
|
|
694
|
+
},
|
|
695
|
+
templateFragment: { node: 'text', text: 'HS code {{ /hsCode }}' },
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
await admin.documents.extensions.list(); // the USABLE set: newest version per key
|
|
699
|
+
await admin.documents.extensions.delete('export-declaration'); // false when there was none
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
The schema grammar is **bounded**. Exactly fifteen JSON Schema keywords are accepted — thirteen of
|
|
703
|
+
them constrain the data, `title` and `description` are annotations — and any other is refused **by
|
|
704
|
+
name** at registration (`DOCUMENT_EXTENSION_SCHEMA_KEYWORD_UNSUPPORTED`, with the keyword and the
|
|
705
|
+
JSON Pointer in `error.details`, one entry per pointer).
|
|
706
|
+
|
|
707
|
+
A keyword must also sit on a **type it can constrain**, refused the same way and naming both:
|
|
708
|
+
`minLength`/`maxLength`/`pattern` on a `string`, `minimum`/`maximum` on a `number` or `integer`,
|
|
709
|
+
`minItems`/`maxItems`/`items` on an `array`, `properties`/`required`/`additionalProperties` on an
|
|
710
|
+
`object`, `enum` on the four scalar types; `type`, `title` and `description` anywhere. A PIN is a
|
|
711
|
+
`string` with `minLength`, not an `integer` with one. The fifteen keywords:
|
|
712
|
+
|
|
713
|
+
`type`, `title`, `description`, `properties`, `required`, `additionalProperties`, `items`, `enum`,
|
|
714
|
+
`minLength`, `maxLength`, `minimum`, `maximum`, `minItems`, `maxItems`, `pattern`.
|
|
715
|
+
|
|
716
|
+
Six types are accepted — `object`, `array`, `string`, `number`, `integer`, `boolean` — and
|
|
717
|
+
`additionalProperties` defaults to **false**, unlike JSON Schema, so a block cannot carry
|
|
718
|
+
unvalidated fields onto a document. A keyword that looks enforced but is not is worse than one that
|
|
719
|
+
is rejected while you are still editing.
|
|
720
|
+
|
|
721
|
+
The template fragment is allow-listed too. Each node names its kind in `node` — the five kinds are
|
|
722
|
+
`text`, `stack`, `columns`, `table` and `repeat` — and `{{ /json/pointer }}` placeholders are filled
|
|
723
|
+
from the block's data. Margins are bounded and never negative, a table's rows must all be the same
|
|
724
|
+
width, and a `repeat` goes inside a `stack` rather than being a table cell: each of those would
|
|
725
|
+
otherwise throw inside the PDF engine *after* the document had been numbered. Text is never
|
|
726
|
+
interpreted as document markup, and a placeholder resolving to nothing — or to an object or array —
|
|
727
|
+
is refused at **generation** (`DOCUMENT_EXTENSION_PLACEHOLDER_UNRESOLVED`) rather than printed as an
|
|
728
|
+
empty string or `[object Object]`.
|
|
729
|
+
|
|
730
|
+
**A document is immutable once numbered.** Generation freezes the data *and* the fragment onto the
|
|
731
|
+
document's own row, so re-rendering it after you changed or deleted the registration reproduces the
|
|
732
|
+
document that was issued — and a void & reissue carries the original's frozen blocks onto the
|
|
733
|
+
replacement for the same reason. Re-registering a key writes a new version; a generation naming a
|
|
734
|
+
stale `schemaVersion` is refused (`DOCUMENT_EXTENSION_VERSION_MISMATCH`), never coerced. At most ten
|
|
735
|
+
blocks per document, and an invalid, duplicate or over-long list refuses the whole document rather
|
|
736
|
+
than issuing a numbered one that silently dropped something — on `issuePartialCreditNote` that
|
|
737
|
+
refusal runs **before** the refund moves.
|
|
738
|
+
|
|
739
|
+
`delete` RETIRES a key rather than erasing it: the rows are the only record of what each version
|
|
740
|
+
meant, and a document frozen at version 1 must keep pointing at the schema that was version 1. A
|
|
741
|
+
retired key is gone from `list()` and unusable on a new document, and a later re-registration
|
|
742
|
+
continues the version sequence rather than minting a second version 1.
|
|
743
|
+
|
|
744
|
+
Registering needs `UpdateSettings`, listing `ReadSettings`, and `documentData` `ReadOrder`.
|
|
745
|
+
`idempotencyKey` is recorded on the wire and acts on nothing here: the Admin API is GraphQL and has
|
|
746
|
+
no header for it.
|
|
747
|
+
|
|
391
748
|
### Capabilities
|
|
392
749
|
|
|
393
750
|
`admin.tenant.capabilities()` answers what this tenant's channel can actually do — one read instead
|
package/dist/apps/assertion.d.ts
CHANGED
|
@@ -45,6 +45,19 @@ export declare function importAppPrivateKey(key: AppPrivateKey): Promise<AppCryp
|
|
|
45
45
|
export interface AssertionClaims {
|
|
46
46
|
/** The registered app's id — the platform checks it against the installation. */
|
|
47
47
|
appId: string;
|
|
48
|
+
/**
|
|
49
|
+
* The deployment this assertion may be exchanged at: the ORIGIN of its
|
|
50
|
+
* Vendure API, e.g. `https://engine.sixtyninecommerce.com`. Signed as `aud`
|
|
51
|
+
* and verified strictly.
|
|
52
|
+
*
|
|
53
|
+
* Required, deliberately. `iss` and `sub` name one app on one installation,
|
|
54
|
+
* but those ids survive a database restore unchanged — so without `aud` an
|
|
55
|
+
* assertion minted for a staging restore of a production dump verifies
|
|
56
|
+
* against production. `createAppCredential` derives it from the `vendureUrl`
|
|
57
|
+
* the client is already configured with; a caller signing by hand reads it
|
|
58
|
+
* from `assertion_audience` in the authenticated capability manifest.
|
|
59
|
+
*/
|
|
60
|
+
audience: string;
|
|
48
61
|
/**
|
|
49
62
|
* The installation this assertion may be exchanged at. Signed, and compared to
|
|
50
63
|
* the id in the exchange URL, so an app installed on two tenants cannot be
|
|
@@ -65,8 +78,22 @@ export declare const DEFAULT_ASSERTION_LIFETIME_SECONDS = 120;
|
|
|
65
78
|
* `jti` is a fresh uuid every time: the platform refuses a replayed one, so a
|
|
66
79
|
* cached assertion is worse than useless. `sub` is the installation id, which is
|
|
67
80
|
* what actually binds the assertion to one tenant — the path parameter alone
|
|
68
|
-
* would not, since the endpoint is unauthenticated. `aud`
|
|
69
|
-
*
|
|
70
|
-
*
|
|
81
|
+
* would not, since the endpoint is unauthenticated. `aud` names the DEPLOYMENT,
|
|
82
|
+
* which `iss` and `sub` cannot: all three ids survive a database restore, so an
|
|
83
|
+
* assertion minted for a staging restore of a production dump would otherwise
|
|
84
|
+
* verify against production.
|
|
85
|
+
*
|
|
86
|
+
* `aud` is normalized through `new URL(...).origin`, the SAME normalization the
|
|
87
|
+
* platform applies to its own `VENDURE_HOST`. That is what lets a deployment
|
|
88
|
+
* configured with a trailing slash still match an SDK configured without one:
|
|
89
|
+
* both sides reduce to the origin before comparing.
|
|
71
90
|
*/
|
|
72
91
|
export declare function signAppAssertion(key: AppCryptoKey, claims: AssertionClaims): Promise<string>;
|
|
92
|
+
/**
|
|
93
|
+
* Reduce a deployment URL to the origin the platform compares against.
|
|
94
|
+
*
|
|
95
|
+
* Exported so `createAppCredential` and a caller signing by hand cannot derive
|
|
96
|
+
* it two different ways. A URL that cannot be parsed is an actionable error
|
|
97
|
+
* here, not a mystery 400 from the exchange twenty lines later.
|
|
98
|
+
*/
|
|
99
|
+
export declare function assertionAudience(vendureUrl: string): string;
|
package/dist/apps/assertion.js
CHANGED
|
@@ -100,9 +100,15 @@ export const DEFAULT_ASSERTION_LIFETIME_SECONDS = 120;
|
|
|
100
100
|
* `jti` is a fresh uuid every time: the platform refuses a replayed one, so a
|
|
101
101
|
* cached assertion is worse than useless. `sub` is the installation id, which is
|
|
102
102
|
* what actually binds the assertion to one tenant — the path parameter alone
|
|
103
|
-
* would not, since the endpoint is unauthenticated. `aud`
|
|
104
|
-
*
|
|
105
|
-
*
|
|
103
|
+
* would not, since the endpoint is unauthenticated. `aud` names the DEPLOYMENT,
|
|
104
|
+
* which `iss` and `sub` cannot: all three ids survive a database restore, so an
|
|
105
|
+
* assertion minted for a staging restore of a production dump would otherwise
|
|
106
|
+
* verify against production.
|
|
107
|
+
*
|
|
108
|
+
* `aud` is normalized through `new URL(...).origin`, the SAME normalization the
|
|
109
|
+
* platform applies to its own `VENDURE_HOST`. That is what lets a deployment
|
|
110
|
+
* configured with a trailing slash still match an SDK configured without one:
|
|
111
|
+
* both sides reduce to the origin before comparing.
|
|
106
112
|
*/
|
|
107
113
|
export async function signAppAssertion(key, claims) {
|
|
108
114
|
const now = Math.floor((claims.now?.() ?? Date.now()) / 1000);
|
|
@@ -111,6 +117,7 @@ export async function signAppAssertion(key, claims) {
|
|
|
111
117
|
const payload = encodeJson({
|
|
112
118
|
iss: claims.appId,
|
|
113
119
|
sub: claims.installationId,
|
|
120
|
+
aud: assertionAudience(claims.audience),
|
|
114
121
|
iat: now,
|
|
115
122
|
exp: now + lifetime,
|
|
116
123
|
jti: randomId(),
|
|
@@ -119,6 +126,36 @@ export async function signAppAssertion(key, claims) {
|
|
|
119
126
|
const signature = await subtle().sign('Ed25519', key, new TextEncoder().encode(signingInput));
|
|
120
127
|
return `${signingInput}.${base64UrlEncode(new Uint8Array(signature))}`;
|
|
121
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Reduce a deployment URL to the origin the platform compares against.
|
|
131
|
+
*
|
|
132
|
+
* Exported so `createAppCredential` and a caller signing by hand cannot derive
|
|
133
|
+
* it two different ways. A URL that cannot be parsed is an actionable error
|
|
134
|
+
* here, not a mystery 400 from the exchange twenty lines later.
|
|
135
|
+
*/
|
|
136
|
+
export function assertionAudience(vendureUrl) {
|
|
137
|
+
let url;
|
|
138
|
+
try {
|
|
139
|
+
url = new URL(vendureUrl);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw new ValidationError({
|
|
143
|
+
code: 'APP_ASSERTION_AUDIENCE_INVALID',
|
|
144
|
+
message: `The app assertion audience must be an absolute URL (the deployment's Vendure origin), got "${vendureUrl}".`,
|
|
145
|
+
status: 400,
|
|
146
|
+
service: 'vendure',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
150
|
+
throw new ValidationError({
|
|
151
|
+
code: 'APP_ASSERTION_AUDIENCE_INVALID',
|
|
152
|
+
message: `The app assertion audience must be an http(s) URL, got "${vendureUrl}".`,
|
|
153
|
+
status: 400,
|
|
154
|
+
service: 'vendure',
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return url.origin;
|
|
158
|
+
}
|
|
122
159
|
/** A uuid for `jti`. `randomUUID` is available wherever `subtle` is. */
|
|
123
160
|
function randomId() {
|
|
124
161
|
const webcrypto = globalThis.crypto;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type Transport } from '@sixtyninecommerce/core';
|
|
2
|
+
import type { Call } from '../types.js';
|
|
3
|
+
/** An installation's settings, as the merchant has filled them in. */
|
|
4
|
+
export interface AppInstallationConfig {
|
|
5
|
+
installationId: string;
|
|
6
|
+
/** Null until the merchant has saved anything. */
|
|
7
|
+
value: unknown;
|
|
8
|
+
/** Null until the merchant has saved anything. */
|
|
9
|
+
updatedAt: string | null;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The settings form an app declares and a merchant fills in (spec §8.13).
|
|
13
|
+
*
|
|
14
|
+
* **Who may do what is asymmetric, on purpose.** A tenant admin with
|
|
15
|
+
* `InstallApps` reads and writes; the owning app may READ its own settings and
|
|
16
|
+
* may never write them (`CONFIG_FORBIDDEN`). An app that could write its own
|
|
17
|
+
* settings could grant itself whatever its own code reads out of them, which is
|
|
18
|
+
* the opposite of what asking the merchant to fill in a form is for.
|
|
19
|
+
*
|
|
20
|
+
* **What is stored is judged by the app's declaration, not by the caller.** The
|
|
21
|
+
* value is validated against the `configSchema` in the app's manifest — the
|
|
22
|
+
* platform's bounded grammar, refused at registration if it uses a keyword
|
|
23
|
+
* nothing enforces. A rejected save is `CONFIG_INVALID` with one `details[]`
|
|
24
|
+
* entry per failing JSON Pointer, so a form can mark every field at once
|
|
25
|
+
* instead of surfacing them one failed save at a time. An app that declares no
|
|
26
|
+
* schema has nothing to configure: writing settings to it is
|
|
27
|
+
* `APP_DECLARES_NO_CONFIG`.
|
|
28
|
+
*
|
|
29
|
+
* **A read never fails because the schema moved.** The stored value comes back
|
|
30
|
+
* as it was saved even where the app's current schema no longer describes it,
|
|
31
|
+
* so a settings screen can show what is there and offer to clear it. Changing
|
|
32
|
+
* the schema in a way that demands something new parks the installation at
|
|
33
|
+
* `needs_consent` — see `apps.approveConsent`.
|
|
34
|
+
*/
|
|
35
|
+
export interface AppInstallationConfigModule {
|
|
36
|
+
get(installationId: string, options?: Call): Promise<AppInstallationConfig>;
|
|
37
|
+
set(installationId: string, value: unknown, options?: Call): Promise<AppInstallationConfig>;
|
|
38
|
+
}
|
|
39
|
+
export declare function createAppConfigModule(transport: Transport): AppInstallationConfigModule;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { PLATFORM_PASSTHROUGH_KEYS } from '@sixtyninecommerce/core';
|
|
2
|
+
/**
|
|
3
|
+
* `value` is the merchant's saved settings, whose inner keys are the APP's
|
|
4
|
+
* declared field names. The transport would otherwise camelise them, so a field
|
|
5
|
+
* an app declared as `api_key_ref` would come back as `apiKeyRef` and never
|
|
6
|
+
* match the schema it is validated against.
|
|
7
|
+
*/
|
|
8
|
+
const PRESERVE = new Set([...PLATFORM_PASSTHROUGH_KEYS, 'value']);
|
|
9
|
+
export function createAppConfigModule(transport) {
|
|
10
|
+
const path = (installationId) => `/apps/installations/${encodeURIComponent(installationId)}/config`;
|
|
11
|
+
return {
|
|
12
|
+
async get(installationId, options) {
|
|
13
|
+
return transport.rest('plugins', { method: 'GET', path: path(installationId) }, { ...options, preserveKeys: PRESERVE });
|
|
14
|
+
},
|
|
15
|
+
async set(installationId, value, options) {
|
|
16
|
+
// No idempotency key: this is a human saving a form, and replaying an
|
|
17
|
+
// earlier answer to a later edit is the wrong outcome. The route is
|
|
18
|
+
// deliberately undecorated on the server for the same reason.
|
|
19
|
+
return transport.rest('plugins', { method: 'PUT', path: path(installationId), body: { value } }, { ...options, preserveKeys: PRESERVE });
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -10,6 +10,11 @@ import { type AppPrivateKey } from './assertion.js';
|
|
|
10
10
|
* Concurrent calls collapse onto ONE mint (`inFlight`). Without it, an app that
|
|
11
11
|
* fires ten requests at startup signs ten assertions and burns ten tokens, and
|
|
12
12
|
* every one of them is a round trip the caller waits for.
|
|
13
|
+
*
|
|
14
|
+
* The exchange carries a route budget of its own, per installation and calling
|
|
15
|
+
* address, so a 429 from it is HELD: nothing is sent again until the window the
|
|
16
|
+
* server named has passed, and calls that land inside it are refused here with
|
|
17
|
+
* the wait that is actually left.
|
|
13
18
|
*/
|
|
14
19
|
export interface AppCredentialConfig {
|
|
15
20
|
/** The registered app's id, which the assertion carries as `iss`. */
|