@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
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` is exported from `@sixtyninecommerce/core`, so a build step can validate a
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
@@ -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` is deliberately absent:
69
- * there is no stable audience string a self-hosted platform could publish, and
70
- * `iss` + `sub` already name exactly one app on one installation.
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;
@@ -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` is deliberately absent:
104
- * there is no stable audience string a self-hosted platform could publish, and
105
- * `iss` + `sub` already name exactly one app on one installation.
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`. */