@tanqory/dev-mcp 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +94 -2
- package/bin/tanqory-dev-mcp.mjs +16 -0
- package/bundled/examples/blank/config/settings.json +3 -0
- package/bundled/examples/blank/locales/en.json +22 -0
- package/bundled/examples/blank/locales/th.json +22 -0
- package/bundled/examples/blank/regions/footer.json +35 -0
- package/bundled/examples/blank/regions/header.json +13 -0
- package/bundled/examples/blank/src/bundle.ts +15 -0
- package/bundled/examples/blank/src/components/parts.tsx +32 -0
- package/bundled/examples/blank/src/entry.client.tsx +6 -0
- package/bundled/examples/blank/src/entry.server.tsx +13 -0
- package/bundled/examples/blank/src/sections/chrome.tsx +132 -0
- package/bundled/examples/blank/src/sections/commerce.tsx +242 -0
- package/bundled/examples/blank/src/sections/content.tsx +111 -0
- package/bundled/examples/blank/src/sections/editorial.tsx +107 -0
- package/bundled/examples/blank/src/settings.ts +15 -0
- package/bundled/examples/blank/src/styles/theme.css +195 -0
- package/bundled/examples/blank/src/templates.ts +14 -0
- package/bundled/examples/blank/src/theme.tsx +76 -0
- package/bundled/examples/blank/template.json +24 -0
- package/bundled/examples/blank/templates/404.json +14 -0
- package/bundled/examples/blank/templates/account.json +14 -0
- package/bundled/examples/blank/templates/article.json +14 -0
- package/bundled/examples/blank/templates/blog.json +14 -0
- package/bundled/examples/blank/templates/cart.json +14 -0
- package/bundled/examples/blank/templates/collection.json +14 -0
- package/bundled/examples/blank/templates/index.json +36 -0
- package/bundled/examples/blank/templates/page.contact.json +18 -0
- package/bundled/examples/blank/templates/page.json +14 -0
- package/bundled/examples/blank/templates/product.json +18 -0
- package/bundled/examples/blank/templates/search.json +14 -0
- package/bundled/examples/blank/tests/theme.test.ts +49 -0
- package/bundled/theme-abi/contracts/render-context.v2.d.ts +849 -0
- package/bundled/theme-abi/contracts/theme-abi-v2.md +1833 -0
- package/bundled/theme-abi/generated/capabilities.json +80 -0
- package/bundled/theme-abi/generated/field-types.json +869 -0
- package/bundled/theme-abi/generated/limits.json +173 -0
- package/bundled/theme-abi/schemas/conformance-report.v2.schema.json +482 -0
- package/bundled/theme-abi/schemas/theme-artifact.v1.schema.json +230 -0
- package/bundled/theme-abi/schemas/theme-manifest.v2.schema.json +254 -0
- package/bundled/theme-conformance/docs/storefront-tier.md +250 -0
- package/bundled/theme-conformance/schema/conformance-report.v2.schema.json +482 -0
- package/bundled/theme-conformance/schema/storefront-report.v1.schema.json +110 -0
- package/bundled/theme-conformance/schema/x-tanqory-ai.v0.schema.json +70 -0
- package/bundled/theme-kit/docs/headless-api.md +220 -0
- package/bundled/theme-kit/generated/kit-strings.json +180 -0
- package/bundled/theme-kit/types/headless/index.d.ts +36 -0
- package/bundled/theme-kit/types/headless/theme.d.ts +99 -0
- package/bundled/theme-kit/types/sdk/schema.d.ts +197 -0
- package/bundled/versions.json +8 -0
- package/package.json +53 -3
- package/src/bm25.mjs +69 -0
- package/src/browser.mjs +123 -0
- package/src/cli.mjs +127 -0
- package/src/devserver.mjs +82 -0
- package/src/docs.mjs +329 -0
- package/src/index.mjs +33 -0
- package/src/kit.mjs +150 -0
- package/src/manifest.mjs +82 -0
- package/src/prompts.mjs +97 -0
- package/src/scaffold.mjs +471 -0
- package/src/schemas.mjs +127 -0
- package/src/server.mjs +595 -0
- package/src/theme.mjs +91 -0
|
@@ -0,0 +1,1833 @@
|
|
|
1
|
+
# Theme Runtime ABI v2 / Theme Spec vNext
|
|
2
|
+
|
|
3
|
+
Status: **proposed, rev 2** (owner: Agent B; arbitration: J). Implements ADR-002, ADR-010,
|
|
4
|
+
ADR-011. Normative companions: `render-context.v2.d.ts`, `theme-manifest.v2.schema.json`.
|
|
5
|
+
Packaging: `theme-artifact-v1.md`. Security semantics the ABI expresses:
|
|
6
|
+
`capability-security-v1.md` (cites `Capability` from the `.d.ts`). Release/projection:
|
|
7
|
+
`site-release-v1.md`. Review resolution: Appendix A.
|
|
8
|
+
|
|
9
|
+
The ABI **owns** three things the other contracts reference (J A1): capability identifiers,
|
|
10
|
+
the limits table, and the `ctx` shape.
|
|
11
|
+
|
|
12
|
+
What changes against v1 (`packages/theme-spec`, `theme-contract.json 1.0.0`):
|
|
13
|
+
|
|
14
|
+
| v1 | v2 | Why |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| `ctx.store.apiBase` + `token`; theme fetches GraphQL | `ctx.data` capability-scoped client; **no `fetch`** | publishable key never verified; cross-store reads; store-api in hot path |
|
|
17
|
+
| theme parses the URL | platform resolves `ctx.route` | one router, interception, canonical URLs |
|
|
18
|
+
| theme sets CSP/HSTS/cache-control | platform owns them; theme adds hashes/tightens; `ctx.cache.hint()` | headers are a security boundary |
|
|
19
|
+
| `header/template/footer` hard-coded; theme resolves `groups` | regions theme-declared; platform merges shared regions into `ctx.content.page` | editor contract must not assume Nova |
|
|
20
|
+
| Vite manifest passed through | neutral `client-manifest.json` with digests/SRI | provider portability; SRI |
|
|
21
|
+
| `LIMITS` 10 MiB / 25 MiB / 50 ms, never fired | §8 two-column table (budget / kill), enforced build **and** load | nothing measures today |
|
|
22
|
+
| 22 unversioned canvas messages | 9 versioned (`v: 2`) | editor uses 10 of 22 |
|
|
23
|
+
| `worker_threads` host | workerd-compatible isolate; may be **multi-tenant per artifact** | ADR-003, A7 |
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 1. Entry module
|
|
28
|
+
|
|
29
|
+
`server/entry.mjs` — single ESM file, zero `import`/`export … from`/`require`/`importScripts`
|
|
30
|
+
(builder inlines everything; loader refuses otherwise). Exports:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
export async function render(request: Request, ctx: RenderContext): Promise<Response>
|
|
34
|
+
export const manifest: ThemeManifest // == manifest.json bytes minus trailing LF, after JCS
|
|
35
|
+
export async function renderSection?(input, ctx): Promise<Response> // preview profile only
|
|
36
|
+
export function migrate?(doc: unknown, from: number, to: number): unknown
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- `render` runs once per request; body MAY be a `ReadableStream`. Once the first byte is written,
|
|
40
|
+
status and headers are final.
|
|
41
|
+
- Module top level runs once per isolate (budgeted, §8). No I/O there. Do not bundle fixture data
|
|
42
|
+
into module scope (nova's `sample-store.json` import is the forbidden pattern).
|
|
43
|
+
- **Multi-tenant isolates (A7).** One isolate MAY serve many stores of the same `artifactDigest`.
|
|
44
|
+
Theme code MUST NOT retain anything derived from `ctx`/`request` past the render that received
|
|
45
|
+
it: no module-level caches keyed by store, no memoised snapshots, nothing on `globalThis`.
|
|
46
|
+
Per-render memoisation belongs on an object created inside `render`. Suite 9 enforces.
|
|
47
|
+
- `renderSection`: preview profile only, lives in `preview/entry.mjs`; never loaded on serve.
|
|
48
|
+
- `migrate`: pure, synchronous; run by the platform in the sandbox at release composition (§7.4).
|
|
49
|
+
- Throw ⇒ platform error page for that release; isolate recycled; attributed to `releaseId`.
|
|
50
|
+
- **POST (B6; actions-route rulings).** A `POST` reaches the theme **only as a valid or
|
|
51
|
+
failed native form post** on a theme route (§2.1). `/_tq/*` and the intercepted paths are
|
|
52
|
+
never routed to the theme. For any other request on a theme route whose method is not `GET` or
|
|
53
|
+
`HEAD`, the gateway applies the **decision order** below. The first matching row decides; each
|
|
54
|
+
status has exactly one condition.
|
|
55
|
+
|
|
56
|
+
| # | Condition (checked in this order) | Answer | Limit charged? | Theme called? |
|
|
57
|
+
|---|---|---|---|---|
|
|
58
|
+
| 1 | The method is not `POST`, **or** the theme declares no forms (`manifest.forms` absent or empty) | `405`, `Allow: GET, HEAD` (plus `POST` when the theme declares forms), platform page, `x-tq-error: invalid_argument` | no | no |
|
|
59
|
+
| 2 | The **same-origin signal** is absent (defined below the table) | `200` render of the route with `ctx.request.formError = 'origin'`; the body is not read; no cookie is set on the response | no | yes |
|
|
60
|
+
| 3 | A rate limit is exceeded: the per-prefix limit, then the per-shopper limit (capability-security §2.4, "Order of checks") | `429`, `Retry-After: <seconds until the window resets>`, platform page, `x-tq-error: rate_limited` | yes | no |
|
|
61
|
+
| 4 | `content-type` is not `application/x-www-form-urlencoded` | `415`, platform page, `x-tq-error: invalid_argument` | yes | no |
|
|
62
|
+
| 5 | The body is larger than 8 KiB (8192 bytes); reading stops at the limit | `413`, platform page, `x-tq-error: invalid_argument` | yes | no |
|
|
63
|
+
| 6 | The CSRF check fails: cookie missing, `_tq_csrf` missing, or mismatch (§4.1) | `200` render of the route with `ctx.request.formError = 'csrf'` | yes | yes |
|
|
64
|
+
| 7 | `_tq_form` is missing or does not name a form in `manifest.forms` | `400`, platform page, `x-tq-error: invalid_argument` | yes | no |
|
|
65
|
+
| 8 | None of the above: a valid form post | render of the route with `ctx.request.form` and `ctx.request.formId` | yes | yes |
|
|
66
|
+
|
|
67
|
+
- **The same-origin signal** (security §2.4 step 2) is read from request headers only. It is
|
|
68
|
+
present when `Sec-Fetch-Site` is `same-origin`. When the browser sent no `Sec-Fetch-Site`
|
|
69
|
+
at all, it is present when `Origin` equals the request's own origin. In every other case it
|
|
70
|
+
is absent.
|
|
71
|
+
- **Rows 1 and 2 charge nothing.** They are decided from the request line and headers alone,
|
|
72
|
+
with no limiter state read or written. A page on another store is same-site with this one,
|
|
73
|
+
so the browser attaches the shopper's cookies to a post it triggers; charging that post
|
|
74
|
+
would let any store spend another store's shopper's allowance. From row 3 on, the request
|
|
75
|
+
carries the signal and is charged to its own shopper and prefix.
|
|
76
|
+
- **Row 2 sets no cookie**, neither `__Host-tq-sid` nor `__Host-tq-csrf`. Every later row may
|
|
77
|
+
set them, under security §2.6's rule for `private, no-store` responses.
|
|
78
|
+
- **A first visit with no cookie yet** that posts a form from the store's own page carries the
|
|
79
|
+
signal, passes rows 1 to 5, and stops at row 6: `formError: 'csrf'`. That response sets the
|
|
80
|
+
cookies, so the next post succeeds.
|
|
81
|
+
- **A store on hold** is answered `503` before this table (site-release §4.3.1).
|
|
82
|
+
- All eight rows are `Cache-Control: private, no-store`. In rows 2 and 6 the status is the one
|
|
83
|
+
the theme returns for that page, normally `200`.
|
|
84
|
+
- A request stopped at row 3 spends no store budget and delivers no body to the theme. It is
|
|
85
|
+
**not** a `formError` render, so rate limiting adds no `formError` value. `/_tq/actions/*`
|
|
86
|
+
reports the same condition as `429` with `Retry-After` and the `rate_limited` wire code
|
|
87
|
+
(§4).
|
|
88
|
+
- Each status and each `formError` value has exactly one row.
|
|
89
|
+
- **Form POST (owner approval 2026-10-07; actions-route rulings).**
|
|
90
|
+
- **Markup.** The theme marks a form **only** with `<form data-tq-form="<formId>" method="post">`.
|
|
91
|
+
It renders **neither** `_tq_form` **nor** `_tq_csrf`: the gateway injects both hidden fields
|
|
92
|
+
at response time, after its cache (§4.1). Conformance suite 2 rejects a theme that renders
|
|
93
|
+
either.
|
|
94
|
+
- **Target.** The form posts to the **same path it was rendered on**: it has no `action`
|
|
95
|
+
attribute, or an `action` equal to the current path. The gateway routes that `POST` to the
|
|
96
|
+
theme's render for that route (§2.1), so the page that showed the form also handles it.
|
|
97
|
+
A form may not post to another path or to another origin; suite 2 rejects that.
|
|
98
|
+
- **Gateway checks, before the isolate.** The decision order above: rate limit, method,
|
|
99
|
+
content type, origin, size, CSRF, declared form. The platform pages in rows 1, 2, 3, 5 and 7
|
|
100
|
+
never call the theme.
|
|
101
|
+
- **Valid post.** The gateway strips `_tq_form` and `_tq_csrf`, drops every field not in
|
|
102
|
+
`manifest.forms[formId].fields`, and exposes `ctx.request.formId: string` with
|
|
103
|
+
`ctx.request.form: Readonly<Record<string,string>>` (repeated keys: last wins).
|
|
104
|
+
`ctx.actions` is defined with the server subset (§4).
|
|
105
|
+
- **Bad CSRF token or bad origin (rows 6 and 2).** The request is **not** refused. It renders
|
|
106
|
+
as a normal page for that route: `ctx.request.form` and `ctx.request.formId` are absent,
|
|
107
|
+
`ctx.request.formError` is `'csrf'` (token missing, cookie missing or mismatch) or
|
|
108
|
+
`'origin'` (the same-origin signal is absent, row 2), and `ctx.actions` is
|
|
109
|
+
`undefined`. The theme can show "please try again"; nothing was submitted. The response is
|
|
110
|
+
`Cache-Control: private, no-store`.
|
|
111
|
+
- `form`, `formId` and `formError` are all absent on GET. `formError` is absent on a valid
|
|
112
|
+
POST. The raw body is never exposed: `request.body` is `null`.
|
|
113
|
+
- Rate limits are owned by capability-security (§2.4, §2.6). This document restates none of
|
|
114
|
+
their numbers. What the browser receives when one is hit is row 3.
|
|
115
|
+
|
|
116
|
+
## 2. `RenderContext`
|
|
117
|
+
|
|
118
|
+
Full type: `render-context.v2.d.ts`.
|
|
119
|
+
|
|
120
|
+
| Field | Owner | Notes |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `abi` | host | `"2.<minor>"`; `engines["tanqory-abi"]` checked at build and load (§9). |
|
|
123
|
+
| `mode` | host | `serve` · `preview` · `check`. |
|
|
124
|
+
| `request` | host | **Constructed from an allowlist** (B3; security B-2 verbatim): `accept accept-language user-agent host traceparent sec-ch-ua sec-ch-ua-mobile sec-ch-ua-platform x-tq-country x-tq-market x-tq-locale`. No other request header is ever present — in particular not `cookie`, `authorization`, `referer`, `cf-connecting-ip`, `x-forwarded-*`. The real `Request` passed to `render()` is built from the same list, with a `null` body. Its `url`, and `ctx.request.url`, are built by the gateway and are never the raw request URL (§2.4). `hasCart`/`isSignedIn` booleans only. `form?` / `formId?` = the gateway-parsed, field-filtered form on a valid declared-form POST; `formError?` (`'csrf' | 'origin'`) on a form POST that failed those checks (§1). `hasCart` is true only when a `__Host-tq-cart` cookie is present and its tag verifies (§2.3). |
|
|
125
|
+
| `route` | host | `{kind, handle?, blogHandle?, template, params, canonical}`; `canonical` is defined in §2.4. Kinds: `home product collection collections page blog article search cart customer policy not_found custom:*`. Resolved by the host with the fixed algorithm in §2.1. `/_tq/*` and the platform-intercepted paths never reach the isolate. |
|
|
126
|
+
| `locale` | host | `language country market currency availableLanguages timeZone`. `locale.currency` is the market's configured currency. It is **not** guaranteed to be the currency of any amount in `ctx.data`: a market the platform cannot price from stored state is answered in the shop's base currency (storefront-query-v1 §2.3.6). A theme MUST format every amount from that amount's own `Money.currencyCode` and MUST NOT format with `locale.currency`. `language` is the shopper's chosen language when the store publishes it, else the store's default language; `availableLanguages` are the store's published languages. Optional `markets` (alpha.12): the store's active markets `{id, currency, countries, languages}`, ordered by `id`, absent when the gateway does not know them — see "Market and language selection" below. |
|
|
127
|
+
| `consent` | host | Read-back of the platform consent cookie (`known analytics marketing functional region?`). |
|
|
128
|
+
| `content` | host | `{revision, contentVersion, page, settings}`; validated, migrated, shared regions merged, `disabled` nodes removed. |
|
|
129
|
+
| `assets` | host | `{base, manifest}`; `base = https://<asset-origin>/a/<artifactDigest>/client`, **or** the public origin of §2.4 (`https://<canonical host>`, no path) when the gateway serves assets from the shop's own origin (§2.5, Asset-1). |
|
|
130
|
+
| `signal` | host | Aborted on wall-clock, data-limit breach after first byte, client disconnect. |
|
|
131
|
+
| `data` | host | §3. |
|
|
132
|
+
| `actions` | host | §4; `undefined` on an unverified POST. |
|
|
133
|
+
| `cache.hint()` | theme→host | §5, including `vary`. |
|
|
134
|
+
| `log` | theme→host | Structured, sampled, ≤ 50 lines / 8 KiB per render. |
|
|
135
|
+
| `now()` | host | Frozen per request — a **host wrapper** (A4). The runtime clocks are **not** frozen: `Date.now()` and `performance.now()` are coarsened to 100 µs (security B-1). "One clock per render" is a conformance guarantee: suite 3 fails a theme whose render calls `Date.now()`/`performance.now()`. |
|
|
136
|
+
| `release` | host | `{id, artifactDigest}`. |
|
|
137
|
+
| `capabilities` | host | `manifest.capabilities.server ∩ capPlan` (the store grant carried in the signed projection, A6). |
|
|
138
|
+
|
|
139
|
+
**Market and language selection (alpha.12; storefront-query rev 2.23 §8.8).** The gateway — never
|
|
140
|
+
the theme — resolves `ctx.locale` from the store's own markets and languages (the platform-only
|
|
141
|
+
localization read, storefront-query §8.8), and the same values go into every token it mints for the
|
|
142
|
+
request (`mkt`, `loc`) and into the cart's pricing country, so page prices, cart and checkout follow
|
|
143
|
+
one market.
|
|
144
|
+
|
|
145
|
+
- **The shopper's choice.** A selector links to the current URL with `?country=<CC>` (ISO 3166-1
|
|
146
|
+
alpha-2) and/or `?locale=<language>` (`?language=` is accepted as the same parameter). The gateway
|
|
147
|
+
honours a value only when the store has it — a country one of its markets serves, a language it
|
|
148
|
+
publishes — and then remembers it in the platform cookie `__Host-tq-loc` (§2.3; `Path=/`,
|
|
149
|
+
`Secure`, `HttpOnly`, `SameSite=Lax`, one year), so later pages without the parameter keep the
|
|
150
|
+
choice. An unknown value is ignored, never an error. A theme never reads or writes that cookie.
|
|
151
|
+
- **Without a choice:** the store's default market (the market in the store's base currency, else
|
|
152
|
+
the first) and the store's default language. `Accept-Language` does not choose the language.
|
|
153
|
+
- **`locale.country`** is the chosen country, else the default market's first country; with no
|
|
154
|
+
market at all it is the gateway's own default.
|
|
155
|
+
- A store with no active market uses the store's base currency for `locale.currency`.
|
|
156
|
+
|
|
157
|
+
**Personalisation marking (B3; security B-2; cache rulings Cache-1..4).** A render is
|
|
158
|
+
**personalised** when it does any of the following:
|
|
159
|
+
|
|
160
|
+
- **Reads a per-client request header.** That is every allowlisted header except the three that
|
|
161
|
+
are part of the cache key's base identity (`host`, `x-tq-market`, `x-tq-locale`). So reading
|
|
162
|
+
`accept`, `accept-language`, `user-agent`, `traceparent`, `sec-ch-ua`, `sec-ch-ua-mobile`,
|
|
163
|
+
`sec-ch-ua-platform` or `x-tq-country` personalises, through `request.headers` or
|
|
164
|
+
`ctx.request.headers`, and whether or not the header was present.
|
|
165
|
+
- **Reads `ctx.locale.country`.** It is the same value as `x-tq-country`: the shopper's country
|
|
166
|
+
as the gateway resolved it. Reading it personalises even on a gateway that derives it only
|
|
167
|
+
from the keyed market, because a theme cannot know how a given gateway derives it. On such a
|
|
168
|
+
gateway the declared `vary` dimension is constant and costs nothing (§5.1).
|
|
169
|
+
- **Reads `ctx.request.hasCart`, `ctx.request.isSignedIn` or `ctx.consent`.**
|
|
170
|
+
- **Receives shopper data from `ctx.data`.** `data.cart.*` and `data.customer.current()` always
|
|
171
|
+
do. `data.snapshot()` does so on exactly two route kinds: it includes the cart when
|
|
172
|
+
`route.kind` is `cart`, and the customer when it is `customer`. On every other kind
|
|
173
|
+
`snapshot()` contains no cart or customer data and is public. This says which calls return
|
|
174
|
+
shopper data. How the gateway decides is capability-security §4.6a, and it may be more
|
|
175
|
+
cautious: it treats a render as personalised whenever its token carried a `cart` or `cust`
|
|
176
|
+
claim that a data call could have consumed.
|
|
177
|
+
- **Calls any `ctx.actions` method**, including the two allowed in a GET render.
|
|
178
|
+
- **Hints `private`.**
|
|
179
|
+
|
|
180
|
+
Reading `host`, `ctx.locale.market`, `ctx.locale.language` or anything else in `ctx` does not
|
|
181
|
+
personalise.
|
|
182
|
+
|
|
183
|
+
A personalised render is never stored under the public cache key, whatever the theme returned.
|
|
184
|
+
It is either served `Cache-Control: private, no-store`, or, where the consumed input is a
|
|
185
|
+
cacheable `vary` dimension that the theme declared (§5.1), stored under that variant.
|
|
186
|
+
`cache.hint` may only **tighten**. Shopper data and actions are never varyable: they always
|
|
187
|
+
force `private`. The host tracks reads through getters; destructuring counts as a read, and so
|
|
188
|
+
does serialising `ctx`. A render for which the host has no read record is treated as
|
|
189
|
+
personalised.
|
|
190
|
+
|
|
191
|
+
### 2.1 Route resolution and template selection (platform)
|
|
192
|
+
|
|
193
|
+
**Route resolution.** The host maps the request path to a `route.kind` in this fixed order. The
|
|
194
|
+
first match wins, and the same order applies to `GET`, `HEAD` and form `POST`s.
|
|
195
|
+
|
|
196
|
+
1. **Reserved:** `/_tq/*` → the platform (`/_tq/data/*`, `/_tq/actions/*`, `/_tq/csrf`, and —
|
|
197
|
+
storefront-query rev 2.22 — the password-gate unlock `POST /_tq/password`), and
|
|
198
|
+
`/assets/*` → the platform's same-origin asset route (§2.5). Never the theme. Each reserved
|
|
199
|
+
route applies its own method rule (§4, §4.1, §2.5); the decision order in §1 is for theme
|
|
200
|
+
routes only.
|
|
201
|
+
**Password gate (security §4.6b, v1.10.43).** When the store is closed behind a password, the
|
|
202
|
+
platform answers its lock screen (`401`, `noindex`, `private, no-store`) for every path of a
|
|
203
|
+
live host — theme routes, `/assets/*`, the reserved and the intercepted routes alike — except
|
|
204
|
+
`/_tq/fonts/*`, `POST /_tq/password` and `/robots.txt`, until the visitor holds the platform's
|
|
205
|
+
`__Host-tq-gate` cookie. A theme never renders the lock screen, never sees the gate state, the
|
|
206
|
+
verifier or the cookie, and has nothing to declare for it. Preview hosts are gated by their
|
|
207
|
+
preview token alone.
|
|
208
|
+
2. **Platform-intercepted:** `/checkout*`, `/orders*`, `/api/v1/*` → the Checkout BFF and the
|
|
209
|
+
commerce API. Never the theme. These three prefixes are the whole list; `/account*` is not
|
|
210
|
+
one of them (step 4).
|
|
211
|
+
3. **Manifest custom routes:** each `manifest.customRoutes[<key>].path` pattern, e.g.
|
|
212
|
+
`/lookbook/:season`. A match gives `kind = "custom:<key>"`, with the `:param` captures in
|
|
213
|
+
`route.params`. When several patterns match, the one with more literal segments wins; ties
|
|
214
|
+
go to the key that sorts first (byte order). `theme check` rejects a custom path under a
|
|
215
|
+
reserved or intercepted prefix or under a built-in prefix below.
|
|
216
|
+
4. **Built-in route kinds:** `/` → `home`; `/products/<handle>` → `product`; `/collections` →
|
|
217
|
+
`collections`; `/collections/<handle>` → `collection`; `/pages/<handle>` → `page`;
|
|
218
|
+
`/blogs/<blog>` and `/blogs/<blog>/tagged/<tag>` → `blog` (the decoded tag is
|
|
219
|
+
**`route.params.tag`**; absent without a tag); `/blogs/<blog>/<article>` → `article`; `/search` → `search`; `/cart` → `cart`; `/policies/<handle>` → `policy`;
|
|
220
|
+
**`/account` and `/account/*` → `customer`**.
|
|
221
|
+
5. **Otherwise:** `not_found`. A built-in match whose resource does not exist is also rendered
|
|
222
|
+
with the `not_found` template and status 404.
|
|
223
|
+
|
|
224
|
+
**Merchant URL redirects** are not a step in this order and never pre-empt steps 1–4. The
|
|
225
|
+
gateway consults them **only where the request would otherwise be answered 404**: at step 5,
|
|
226
|
+
before the `not_found` render, and for a built-in route whose resource is missing. If one
|
|
227
|
+
applies, the gateway answers the redirect itself and the theme is not called. A theme never
|
|
228
|
+
sees a redirect document and cannot create, read or control one. Where they are read is
|
|
229
|
+
site-release §4.3.3; allowed targets and status codes are defined by capability-security and
|
|
230
|
+
are not restated here.
|
|
231
|
+
|
|
232
|
+
`route.params` keys the platform sets for built-in kinds: `tag` (blog tag filter), `path`
|
|
233
|
+
(the part after `/account/`, without a leading slash; absent for `/account`), plus two taken
|
|
234
|
+
from the query string:
|
|
235
|
+
|
|
236
|
+
| `route.params` key | Query key | Route kinds |
|
|
237
|
+
|---|---|---|
|
|
238
|
+
| `page` | `page` | `collection`, `collections`, `blog`, `search` |
|
|
239
|
+
| `sort` | `sort` | `collection`, `search` |
|
|
240
|
+
| `q` | `q` | `search` |
|
|
241
|
+
|
|
242
|
+
Each is the decoded query value as received, and absent when the query key is absent. On any
|
|
243
|
+
other kind the key is not set. `q` is the search query as received: decoded and untrimmed. It is
|
|
244
|
+
**text typed by the shopper**, so a theme must treat it as untrusted and escape it wherever it
|
|
245
|
+
prints it. The gateway does not shorten it. The data plane bounds what it searches for
|
|
246
|
+
(storefront-query-v1 §1.3: a query longer than 1,024 code units is refused with
|
|
247
|
+
`invalid_argument`, and a valid one is cut to its first 256 code points). Custom routes add their `:param` captures. Handles are not
|
|
248
|
+
params: they are `route.handle` and `route.blogHandle`.
|
|
249
|
+
|
|
250
|
+
A form `POST` follows the same steps, so `/pages/contact` (kind `page`) or a custom route
|
|
251
|
+
receives the post for the form it rendered (§1).
|
|
252
|
+
|
|
253
|
+
**The `customer` kind (account shell).** `/account` and `/account/*` on the storefront host are
|
|
254
|
+
**not** intercepted. They resolve to `customer`, a **theme-rendered shell page** that holds
|
|
255
|
+
`<iframe data-tq-slot="account">` (§4). The shell never receives customer PII: it gets only
|
|
256
|
+
`ctx.route` (the sub-path is in `route.params.path`), the redacted `data.customer.current()`,
|
|
257
|
+
and the Account BFF URL for the iframe `src`. Everything a customer signs in to, reads or edits
|
|
258
|
+
is rendered inside the frame by the Account BFF, on the origin the platform owns entirely
|
|
259
|
+
(`account.<host-root>`). **Normative:** a theme with no `routes.customer` gets the platform's
|
|
260
|
+
minimal shell for `/account*` — a platform page that holds only the account slot iframe. This is
|
|
261
|
+
the same rule as step 1 of template selection below, stated for this kind.
|
|
262
|
+
|
|
263
|
+
**Template selection.**
|
|
264
|
+
|
|
265
|
+
1. `templateSuffix` → `templates["<kind>.<suffix>"]` if present; else `routes[kind]`; else the
|
|
266
|
+
platform's minimal page for that kind.
|
|
267
|
+
2. For each region in the template: load (`shared` → store-level doc; `template` → per-template),
|
|
268
|
+
migrate (§7.4), validate, strip disabled, assemble `PageDoc`.
|
|
269
|
+
|
|
270
|
+
### 2.2 Client asset manifest (neutral)
|
|
271
|
+
|
|
272
|
+
`client/client-manifest.json`: `Record<logicalName, ClientAssetEntry>` with `{file, kind,
|
|
273
|
+
isEntry?, css?, imports?, size, digest, integrity}`. **0 or 1** `isEntry` script (S6: zero-JS
|
|
274
|
+
themes have none; `manifest.client.hydration: "none"`). Every emitted asset URL must be under
|
|
275
|
+
`assets.base` and carry `integrity`. `file`, `css[]` and `imports[]` are relative to `client/`
|
|
276
|
+
(= `assets.base`) and never begin with `client/`. The artifact path is `client/<file>` and the
|
|
277
|
+
URL is `${assets.base}/<file>` (theme-artifact-v1 §1.1).
|
|
278
|
+
|
|
279
|
+
### 2.3 Platform cookies (never visible to theme code)
|
|
280
|
+
|
|
281
|
+
The gateway owns every cookie. None reaches the isolate, and theme JS cannot read them
|
|
282
|
+
(`HttpOnly`).
|
|
283
|
+
|
|
284
|
+
| Cookie | Defined by | Purpose |
|
|
285
|
+
|---|---|---|
|
|
286
|
+
| `__Host-tq-cart` | **this document** | The shopper's cart. The cookie value is `<cartId>.<version>.<tag>`, where `tag` is defined by security §2.6 (`K_cookie`, label `tq-cart-v1`, over `cartId` and the canonical host). A cookie whose tag does not verify is treated as absent: `request.hasCart` is false and no home-cell call is made. `Cart.id` stays theme-visible and is not a credential. `Secure; HttpOnly; SameSite=Lax; Path=/`, no `Domain`. Set by the gateway when `cart.create` or a cart mutation succeeds. **Normative:** the gateway resolves it into the cart identity for **both** `data.cart.*` (in render and via `/_tq/data/cart.*`) and `actions.cart.*`; no cart id is ever an argument. `request.hasCart` is true only when the cookie is present, well-formed and its tag verifies. |
|
|
287
|
+
| `__Host-tq-loc` | **this document** (alpha.12) | The shopper's market / language choice (§2 "Market and language selection"). Value `c.<CC>~l.<language>` (either part may be absent). Not a credential and not signed: every value is re-checked against the store's markets and languages on each request, and an unknown one is ignored. `Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=31536000`, no `Domain`. Set by the gateway only on a page response to a request that carried an honoured `?country=` / `?locale=`. |
|
|
288
|
+
| `__Host-tq-csrf` | capability-security (CSRF) | CSRF secret; see §4.1. |
|
|
289
|
+
| `__Host-tq-sid` | capability-security §2.6 (shopper identity) | Anonymous shopper id for per-shopper rate limiting. It is minted and authenticated by the gateway; its format and key are security's and are not restated here. Not exposed in `ctx` in any form. |
|
|
290
|
+
| customer session | capability-security §3.4 | Never visible to theme code; `request.isSignedIn` is the only signal. |
|
|
291
|
+
|
|
292
|
+
### 2.4 URLs the theme receives
|
|
293
|
+
|
|
294
|
+
Three values carry the request's URL: `ctx.request.url`, the `url` of the `Request` passed to
|
|
295
|
+
`render`, and `ctx.route.canonical`. **None of them is the raw request URL.** The gateway builds
|
|
296
|
+
each from values it has verified, so a theme cannot be made to print an attacker-chosen scheme,
|
|
297
|
+
port or host.
|
|
298
|
+
|
|
299
|
+
**The public origin** is `https://` followed by the **canonical host**: the verified host of the
|
|
300
|
+
signed `rel:<host>` value (site-release §4.1) — lowercase, A-labels for international names, no
|
|
301
|
+
port, no trailing dot. It is the same whether the request arrived over `http`, on another port,
|
|
302
|
+
with a trailing dot or in another letter case.
|
|
303
|
+
|
|
304
|
+
| Part | `ctx.request.url` and `Request.url` (always identical) | `ctx.route.canonical` |
|
|
305
|
+
|---|---|---|
|
|
306
|
+
| Scheme | `https` | `https` |
|
|
307
|
+
| Host | the canonical host | the canonical host |
|
|
308
|
+
| Port | never present | never present |
|
|
309
|
+
| Path | the path the gateway resolved the route from (§2.1), which is also the `path` of the cache key. Dot segments are resolved as URL parsing does; nothing else is rewritten here | the one canonical path of the route: for a resource, its path by handle, with each segment encoded one way |
|
|
310
|
+
| Query | the query string exactly as received, or absent | never present |
|
|
311
|
+
| Fragment | never present | never present |
|
|
312
|
+
|
|
313
|
+
- `request.headers.get('host')` is the canonical host as well, with no port.
|
|
314
|
+
- `ctx.route.canonical` is safe to print in `<link rel="canonical">`: nothing in it comes from the
|
|
315
|
+
request except which route matched. It carries no query, so a theme that wants a canonical URL
|
|
316
|
+
for page 2 appends its own validated `page`.
|
|
317
|
+
- The raw query in `ctx.request.url` is the one part a client controls. A theme must treat it as
|
|
318
|
+
untrusted input. Whether it is reduced to a canonical form is a separate, pending proposal
|
|
319
|
+
(appendix row Cache-7); this section does not assume it.
|
|
320
|
+
- **Local development, conformance and e2e.** A test gateway often listens on
|
|
321
|
+
`https://<host>:<port>`. The port is a transport detail and still never appears in these
|
|
322
|
+
values. A non-production gateway may be **configured** with a different public origin (scheme
|
|
323
|
+
and port) for the hosts it serves; that is deploy configuration, is never read from the
|
|
324
|
+
request, and a production gateway does not set it.
|
|
325
|
+
- A store with several hosts gets the canonical host of the request in `ctx.route.canonical`,
|
|
326
|
+
not the store's primary domain. `shop.primaryDomain.url` is available from `ctx.data` for a
|
|
327
|
+
theme that prefers it.
|
|
328
|
+
|
|
329
|
+
### 2.5 Assets served from the shop's origin (Asset-1)
|
|
330
|
+
|
|
331
|
+
A gateway MAY set `assets.base` to the public origin of §2.4 instead of a separate asset
|
|
332
|
+
origin. No digest appears in an asset URL then: a client-manifest `file` `assets/<file>` is
|
|
333
|
+
fetched as `https://<canonical host>/assets/<file>`. The rules below apply on **every**
|
|
334
|
+
storefront host, whichever `assets.base` the gateway is configured with.
|
|
335
|
+
|
|
336
|
+
- **Reserved prefix.** `/assets/*` is reserved (§2.1 step 1), matched like `/_tq/*`: on the
|
|
337
|
+
normalised path, without regard to letter case (`/ASSETS/x`, `//assets/x`, `/%61ssets/x`).
|
|
338
|
+
No manifest custom route, built-in kind or merchant redirect is ever matched under it; the
|
|
339
|
+
gateway ignores a custom route there and `theme check` rejects one.
|
|
340
|
+
- **Which artifact.** `/assets/<file>` (exactly one segment after `assets`, `<file>` matching
|
|
341
|
+
`[A-Za-z0-9._-]+`) names the artifact path `client/assets/<file>` of the release the gateway
|
|
342
|
+
would render a document from **for the same host at the same moment**: the current
|
|
343
|
+
`rel:<host>` value obtained and verified through the document read path (site-release §4.3 —
|
|
344
|
+
the same read cache and coordinator reads, the same signature check against the same pinned
|
|
345
|
+
trust root, the same host and channel binding, expiry and acceptance rule), or, where a
|
|
346
|
+
document would fall back, that value's signed `lkg`. Nothing else is read to choose it: no
|
|
347
|
+
other key, no unsigned value, nothing in the URL.
|
|
348
|
+
- **The same refusals as a document**, decided before any asset byte is read: a host not in the
|
|
349
|
+
projection ⇒ the platform `404`; a store on hold ⇒ `503` (security §4.6a); an expired preview
|
|
350
|
+
value with nothing else verified ⇒ `404`. **Channel gating is the page's:** a preview release's
|
|
351
|
+
files are served only where its pages would be (today: the editor origin configured,
|
|
352
|
+
security §4.8; a preview-token or password gate (security §4.6b) for pages applies to
|
|
353
|
+
`/assets/*` in the same place), and never with a `public` cache policy. A release whose `rev:<artifactDigest>` or
|
|
354
|
+
`rev:build:<buildId>` record is present, or whose artifact fails authentication
|
|
355
|
+
(security §6.4 steps 2–4, theme-artifact-v1 §7), is never an asset source: the answer is the
|
|
356
|
+
document's fallback — the signed `lkg` (`x-tq-degraded: lkg`) or the maintenance `503` with
|
|
357
|
+
`x-tq-degraded: no_lkg`. A revocation or `rel:` take-down therefore stops asset answers at the
|
|
358
|
+
same moment it stops documents. Copies already held by browsers are not recalled.
|
|
359
|
+
- **File integrity.** `client/assets/<file>` must be a leaf of the verified statement's
|
|
360
|
+
`predicate.files`, else `404`. A leaf larger than **4 MiB** is not served (`503`, never
|
|
361
|
+
opened): §8 has no per-file client row, `clientTotalRawBytes` bounds all client files
|
|
362
|
+
together, and the largest single-file hard limit §8 sets is `ssrEntryRawBytes` (4 MiB). A
|
|
363
|
+
missing object, a stored size other than the signed size, or a registry failure ⇒ `503`,
|
|
364
|
+
`private, no-store`, before any byte. A type a browser may paint progressively (`.jpg` `.jpeg`
|
|
365
|
+
`.png` `.webp` `.avif` `.gif` `.ico` `.svg`) is read and verified **whole** before the first
|
|
366
|
+
byte (bounded by the cap): a mismatch is a `503`, so no decoder ever paints a tampered prefix.
|
|
367
|
+
Every other type is streamed through a sha256 digest with a length check, and the **last chunk
|
|
368
|
+
is withheld until both match**. A difference is discovered after the `200` head was sent, so
|
|
369
|
+
the body **errors** instead of completing: no client or cache holds a complete wrong file and
|
|
370
|
+
a script never runs. A browser may keep a truncated entry; the edge never resumes one — it
|
|
371
|
+
serves no `Range` requests from this route (Asset-2). A `HEAD` is answered from the signed size
|
|
372
|
+
once the registry confirms an object of that size, without reading it. The edge publisher
|
|
373
|
+
refuses to publish an artifact with any `client/` file over the cap
|
|
374
|
+
(`client_file_too_large`), so a signed artifact cannot carry a file that would answer `503`
|
|
375
|
+
forever; `theme check` should refuse it earlier. A gateway MAY keep verified bytes in a per-location cache keyed by
|
|
376
|
+
artifact digest, signed sha256 and path — **never by host** — and MUST verify an entry the
|
|
377
|
+
same way on every read and drop one that fails.
|
|
378
|
+
- **Methods.** `GET` and `HEAD` only (a `HEAD` answer carries the `GET` headers and no body);
|
|
379
|
+
anything else ⇒ `405`, `Allow: GET, HEAD`. No request body is read; no cookie is read or set.
|
|
380
|
+
- **Caching and preconditions.** `ETag` is the strong validator `"<sha256 of the signed leaf,
|
|
381
|
+
hex>"`; there is no `Last-Modified`, so `If-Modified-Since` and `If-Unmodified-Since` are
|
|
382
|
+
ignored. In RFC 9110 §13.2.2 order: `If-Match` with no tag matching (strong comparison) ⇒
|
|
383
|
+
`412`, `private, no-store`, no body — `If-Match` never yields a `304`; then `If-None-Match`
|
|
384
|
+
matching (weak comparison) ⇒ `304`. `Cache-Control: public, max-age=31536000, immutable` **only**
|
|
385
|
+
on a `200` of a content-hashed name: `<stem>-<h8><ext>` (theme-artifact-v1 §1.1) **where `<h8>`
|
|
386
|
+
equals the first 8 hex characters of the signed leaf's sha256**; the `304` for it carries the
|
|
387
|
+
same `max-age` without `immutable`. Every other name: `public, max-age=300`, revalidated with
|
|
388
|
+
the ETag. On a preview host `public` is `private`. Every degraded answer is
|
|
389
|
+
`private, no-store` (§5.2).
|
|
390
|
+
- **Type — a closed list.** `Content-Type` comes **only** from a fixed extension list (`.js`
|
|
391
|
+
`.mjs` `.css` `.json` `.svg` `.png` `.jpg` `.jpeg` `.webp` `.avif` `.gif` `.ico` `.woff2`
|
|
392
|
+
`.woff` `.ttf` `.otf` `.wasm`) — never from registry metadata, never sniffed. A signed file
|
|
393
|
+
with any other extension is **not served** (`404`); in particular `.html`, `.htm`, `.xhtml` and
|
|
394
|
+
`.xml` never are. Because these files share the shop's origin, every answer carries the §5
|
|
395
|
+
platform security headers (`x-content-type-options: nosniff`, `x-frame-options: DENY`,
|
|
396
|
+
`strict-transport-security`, `referrer-policy`, `cross-origin-opener-policy`,
|
|
397
|
+
`permissions-policy`; `x-robots-tag: noindex, nofollow` on preview), and every answer that is
|
|
398
|
+
not a script carries a response CSP so a file opened directly can never run as a page of the
|
|
399
|
+
shop: `default-src 'none'; style-src 'unsafe-inline'; sandbox` for `.svg`,
|
|
400
|
+
`default-src 'none'; sandbox` for the rest. A script (`.js`, `.mjs`) carries
|
|
401
|
+
`content-security-policy: default-src 'none'`: a browser ignores a response CSP when the file
|
|
402
|
+
is loaded by `<script>` or `import` (the page's policy governs those), but it **is** the policy
|
|
403
|
+
of any worker started from that URL, so such a worker can fetch, import and connect nowhere
|
|
404
|
+
(Asset-2).
|
|
405
|
+
- **No workers from theme code.** The document CSP carries `worker-src 'none'` (§5). A request
|
|
406
|
+
that fetches a Service Worker script — `Service-Worker: script`, or
|
|
407
|
+
`Sec-Fetch-Dest: serviceworker` — is refused with `404` on **every** path of a storefront host,
|
|
408
|
+
before anything is resolved or rendered: `/assets/*`, a theme route that answers JavaScript,
|
|
409
|
+
and `/_tq/*` (in the reserved-route form) alike. A shop origin never serves a service worker.
|
|
410
|
+
- **Document CSP.** With a same-origin `assets.base` the `<asset origin>` source is not added
|
|
411
|
+
to `script-src` or `style-src-elem` (§5); `'self'` covers it.
|
|
412
|
+
|
|
413
|
+
## 3. `ctx.data`
|
|
414
|
+
|
|
415
|
+
- No URL, token or query string; every method is a persisted query shipped per ABI minor;
|
|
416
|
+
**every minor's query set within a major is retained** (S5). Results cached by
|
|
417
|
+
`(store, dataVersion, query, args)`. A data answer older than the render's `dataVersion` makes
|
|
418
|
+
the response not cacheable and sets `x-tq-degraded: data_stale` (storefront-query-v1 §7.5).
|
|
419
|
+
- `dataVersion` is opaque to themes. It is the store's published read-model version
|
|
420
|
+
(storefront-query-v1 §7): it advances only when a theme-visible answer could differ, at most
|
|
421
|
+
once per 5 s per store. Use it in cache hints only; never parse it, compare it or put it in
|
|
422
|
+
markup.
|
|
423
|
+
- **URL forms.** `Policy.url` is an absolute URL on the shop's primary domain. `MenuItem.url` is
|
|
424
|
+
an origin-relative path beginning with `/`, or an absolute `https:` URL for external links.
|
|
425
|
+
- **Pixels.** In ABI 2.x the data plane returns an empty list from `shop.pixels()` and in
|
|
426
|
+
`snapshot.pixels`: merchant pixel code is not delivered to themes (storefront-query-v1 §2.2.4)
|
|
427
|
+
until the owner decides the merchant-pixel execution model. A theme MUST render correctly with
|
|
428
|
+
`pixels` empty and MUST NOT assume a non-empty list on any host.
|
|
429
|
+
- **Opt-in answers (additive, 2026-10-13; storefront-query rev 2.19).** Optional members a theme
|
|
430
|
+
receives only when it asks: `Collection.filters` (facets — `catalog.collection(h, {facets: true})`
|
|
431
|
+
or `snapshot({facets: true})` on the collection route), `Product.collections` (the collections a
|
|
432
|
+
product belongs to — `catalog.product(h, {collections: true})` or
|
|
433
|
+
`snapshot({productCollections: true})` on the product route), and a search `products` order
|
|
434
|
+
(`catalog.search({…, sortKey, reverse})`, and the `sort` route param on the search route).
|
|
435
|
+
Absent ⇒ the answer is exactly as before. A theme MUST render when they are absent (the conformance
|
|
436
|
+
fixture does not compute them); a data plane older than this revision refuses the request
|
|
437
|
+
members as `invalid_argument`, so the plane ships before any theme that sends them. Inventory quantities: only
|
|
438
|
+
`ProductVariant.quantityAvailable`, under the stock-levels bullet below (rev 2.21).
|
|
439
|
+
- **Shop social links and card brands (additive, 2026-10-13; storefront-query rev 2.20).**
|
|
440
|
+
`Shop.socialLinks` (`ShopSocialLink[]`: `{platform, url}`) and
|
|
441
|
+
`Shop.paymentSettings.acceptedCardBrands` (`CardBrand[]`, names only, for payment marks). Not
|
|
442
|
+
opt-in: carried by `shop.get` and `snapshot.shop` whenever the store has them, absent otherwise. A
|
|
443
|
+
theme MUST render when either is absent and MUST fall back for a `platform` it has no icon for.
|
|
444
|
+
Nothing else of a store's payment configuration is in the ABI.
|
|
445
|
+
- **Stock levels (additive, 2026-10-13; storefront-query rev 2.21, security v1.10.42).**
|
|
446
|
+
`ProductVariant.quantityAvailable` — an integer 0–999 (999 = "999 or more") — on every variant a
|
|
447
|
+
`data:catalog` / `data:search` / `data:products.recommendations` answer (and `snapshot`) carries,
|
|
448
|
+
**only** when all three hold: the release holds the opt-in capability **`data:inventory`**
|
|
449
|
+
(declared in `capabilities.server`, and in `capabilities.browser` for hydrated reads; granted at
|
|
450
|
+
install like any declared capability, so a third-party theme gets it through the normal
|
|
451
|
+
capability review); the merchant turned on **"Show stock levels on your online store"** (store
|
|
452
|
+
setting `storefront.showInventoryLevels`, default **off**); and the variant's inventory is tracked
|
|
453
|
+
and it does not continue selling when out of stock. Otherwise the member is **absent** — never
|
|
454
|
+
`0` for "unknown". The receiver strips it from every answer of a release without
|
|
455
|
+
`data:inventory`. `data:inventory` adds no op and costs nothing; a call without `data:catalog`
|
|
456
|
+
is still refused. A theme MUST render no stock count when the member is absent; low-stock
|
|
457
|
+
wording ("Only 3 left") is the theme's, from a threshold of its own. `CartLine.merchandise`
|
|
458
|
+
never carries it. `availableForSale` is unchanged.
|
|
459
|
+
- `snapshot()` is the one-call path. It **never throws** for a denied capability: the primary
|
|
460
|
+
resource is returned as `null` (search: `result: null`), refs whose capability is denied are
|
|
461
|
+
omitted, and `snapshot.denied: Capability[]` lists every capability that caused an omission.
|
|
462
|
+
Individual methods still throw `CapabilityError` (independent assessment 2026-10-07).
|
|
463
|
+
- **Every member of a denied primary resource is null.** For an article that is
|
|
464
|
+
`{ kind: 'article', article: null, blog: null }`; for the collections list it is
|
|
465
|
+
`{ kind: 'collections', collections: null }`.
|
|
466
|
+
- **`shop`, `menus` and `pixels` are never null.** `data:shop` and `data:localization` are
|
|
467
|
+
implicit: they are always in the effective capability set of a query token (security §2.2),
|
|
468
|
+
so they can never appear in `denied`.
|
|
469
|
+
- **Ref kinds** are exactly the resource field types of the registry: product, collection,
|
|
470
|
+
menu, page, blog and article. `policy` and `metaobject` are not ref kinds.
|
|
471
|
+
- **`snapshot.unavailable`** lists the parts (`'cart'`, `'customer'`) that are `null` because
|
|
472
|
+
the store's home cell could not be reached, as opposed to absent data or a denied
|
|
473
|
+
capability. It is absent when nothing was unavailable.
|
|
474
|
+
- **Counting (S7).** One method call = one data call unless memoised (same op+args in this
|
|
475
|
+
render). A *round* is the set of calls started before any of them resolves —
|
|
476
|
+
`Promise.all([...])`, concurrently suspended React subtrees, or any calls issued in the same
|
|
477
|
+
microtask turn are **one** round; a call issued after an earlier result is a new round. Proxied
|
|
478
|
+
`network:` calls count as data calls *and* sub-requests. Streaming does not reset counters.
|
|
479
|
+
- **Breach (S7).** `DataLimitError` thrown at the call site. If the response body has not
|
|
480
|
+
started: the theme may catch and degrade. If it has started: the host aborts `signal`, truncates
|
|
481
|
+
the body, marks the response **not cacheable**, sets `x-tq-degraded: data_limit`, and counts a
|
|
482
|
+
breach toward quarantine (§8).
|
|
483
|
+
- Capabilities: `data:shop data:localization` implicit; `data:catalog data:content data:cart`
|
|
484
|
+
default-granted; `data:search data:customer data:metaobjects data:products.recommendations
|
|
485
|
+
data:inventory` opt-in. Missing ⇒ `CapabilityError` synchronously. Browser ceiling (A10): `data:metaobjects`
|
|
486
|
+
is **server-only** (metaobject reads are the merchant's structured data and must not be
|
|
487
|
+
enumerable from shopper JS); `data:products.recommendations` and `data:inventory` **are**
|
|
488
|
+
browser-mintable.
|
|
489
|
+
- **Customer ceiling — conservative default pending owner decision D6 (A2).** `data.customer.
|
|
490
|
+
current()` returns only `{id, firstName, lastName, emailMasked, defaultAddress{city,country},
|
|
491
|
+
isLoggedIn}`. `orders()/order()/addresses()` and full email/phone are **removed**; `/account*`
|
|
492
|
+
resolves to the theme's `customer` shell (§2.1), and the Account BFF renders the account
|
|
493
|
+
content inside the account slot iframe. `loginUrl()/logoutUrl()/
|
|
494
|
+
accountUrl()` are pure getters on `data.customer` (no capability, allowed in GET render).
|
|
495
|
+
- Any render that touched `data:cart` / `data:customer` / `consent` is forced `private`.
|
|
496
|
+
- In conformance the data comes from a fixture behind the query stub, with the same interface,
|
|
497
|
+
deterministic data and call counters. The theme is rendered with `mode: 'serve'` or
|
|
498
|
+
`'preview'`, never `'check'` (§10).
|
|
499
|
+
|
|
500
|
+
## 4. `ctx.actions`
|
|
501
|
+
|
|
502
|
+
- **During GET render (A3):** only `cart.create` and `consent.set` (idempotent, `via:"ssr"`
|
|
503
|
+
token). Any other method throws `ActionNotAllowedInRender`. Rationale: a render-time mutation
|
|
504
|
+
runs on every page view with no shopper intent.
|
|
505
|
+
- **During a valid form POST:** the full `server` subset (`cart.*`, `consent.set`,
|
|
506
|
+
`form.submit`).
|
|
507
|
+
- **No customer actions (security owns the customer ceiling).** There is no
|
|
508
|
+
`/_tq/actions/customer.*` and no customer-credential action anywhere in this ABI. Login,
|
|
509
|
+
logout, registration and password recovery happen **only** inside the Account BFF, reached
|
|
510
|
+
through the account slot below (typically on the `/account*` shell page, §2.1). `ctx.actions` has no
|
|
511
|
+
`customer` member. `data.customer.current()` stays as a read, and the URL getters on
|
|
512
|
+
`data.customer` only return Account BFF URLs. The capability id `actions:customer` is
|
|
513
|
+
reserved and grants nothing in theme code.
|
|
514
|
+
- **Account slot (owner approval 2026-10-07).** A theme that wants account UI inside its own
|
|
515
|
+
pages renders `<iframe data-tq-slot="account" src="<data.customer.accountUrl(…)>"
|
|
516
|
+
sandbox="allow-scripts allow-forms allow-same-origin">`. The frame is served from the
|
|
517
|
+
**Account BFF origin**, and CSP `frame-src` lists only that origin (§5). Login, orders,
|
|
518
|
+
addresses and PII render inside the frame. Customer PII never passes through theme JS, so D6
|
|
519
|
+
still holds. The gateway rejects (conformance suite 2 fails) a `data-tq-slot="account"` iframe
|
|
520
|
+
whose `src` is not an Account BFF URL or whose `sandbox` differs.
|
|
521
|
+
- **From the browser:** `POST /_tq/actions/<ns>.<name>` (JSON) and `GET /_tq/data/<op>?…`, same
|
|
522
|
+
origin, under the **browser subset** (`manifest.capabilities.browser`). **`/_tq/actions/*`
|
|
523
|
+
accepts only `actions:cart` and `actions:consent` operations.** `actions:form.submit` is
|
|
524
|
+
**never browser-mintable**: `form.submit` runs only from a valid native form POST (§1), and a
|
|
525
|
+
browser call to `/_tq/actions/form.submit` (or any `customer.*`) is refused `403` with
|
|
526
|
+
`not_browser_mintable`. For data, the ceiling is `data:shop/localization/catalog/content/
|
|
527
|
+
search/cart/products.recommendations`; `data:customer` and `data:metaobjects` are excluded.
|
|
528
|
+
`theme check` and the manifest schema fail a manifest that lists an excluded id (A10).
|
|
529
|
+
`@tanqory/theme-abi/client` wraps both routes (§4.2); the wire shape is ABI, and a theme may
|
|
530
|
+
call the routes itself.
|
|
531
|
+
- **Browser wire encoding** (independent assessment 2026-10-07; types in `.d.ts` `Wire*`):
|
|
532
|
+
- `GET /_tq/data/<op>` — `<op>` is the `DataClient` path (`catalog.product`, `cart.get`, …).
|
|
533
|
+
Arguments are a single query parameter `a` = base64url (no padding) of the **JCS**
|
|
534
|
+
canonical JSON of the positional argument array (`[]` ⇒ parameter omitted). One canonical
|
|
535
|
+
form per call, so the cache key is stable; any other query parameter ⇒ `invalid_argument`.
|
|
536
|
+
An argument that has no canonical form, such as a string with an unpaired surrogate, is
|
|
537
|
+
also `invalid_argument` (JCS rule: theme-artifact-v1 §4).
|
|
538
|
+
**Size cap:** the canonical JSON is at most `BROWSER_DATA_ARGS.maxBytes` = 3072 bytes, so
|
|
539
|
+
the `a` value is at most `BROWSER_DATA_ARGS.maxEncodedChars` = 4096 characters (one limit
|
|
540
|
+
stated twice: 3072 bytes encode to exactly 4096 characters). Over it ⇒ `400`
|
|
541
|
+
`invalid_argument`. An `a` that is present but empty, or that is not the canonical
|
|
542
|
+
encoding of its own bytes, is also `400` `invalid_argument`.
|
|
543
|
+
- `POST /_tq/actions/<ns>.<name>` — `content-type: application/json`, body
|
|
544
|
+
`{"v":2,"args":<positional array>}`, ≤ 8 KiB.
|
|
545
|
+
- Response (both) — `WireEnvelope`: `{ok:true, data, dataVersion}` or
|
|
546
|
+
`{ok:false, error:{code, message?, requestId}, data?}` (e.g. a version conflict returns the
|
|
547
|
+
current cart in `data`). `code` reuses capability-security §6.4a (`capability_denied`,
|
|
548
|
+
`preview_channel`, `not_allowed_in_render`, `budget_exhausted`, `limit_subrequests`) plus
|
|
549
|
+
`rate_limited invalid_argument unknown_op cart_version_conflict csrf_failed unavailable
|
|
550
|
+
not_browser_mintable`. `invalid_argument` also covers wrong method (`405`), oversized body
|
|
551
|
+
(`413`) and wrong content type (`415`); the HTTP status tells them apart.
|
|
552
|
+
HTTP status mirrors `ok` (2xx / 4xx / 5xx).
|
|
553
|
+
- **`429` and `Retry-After`.** Two codes are `429`: `rate_limited` (a shopper or network
|
|
554
|
+
limit) and `budget_exhausted` (the store's budget). On `/_tq/actions/*` and `/_tq/data/*`
|
|
555
|
+
alike, both always carry a `Retry-After` header in whole seconds, the same as a
|
|
556
|
+
rate-limited native form post (§1 row 3). When the data plane refuses for budget and gives
|
|
557
|
+
no figure, the gateway sends 60. No other code carries the header. The value is in the
|
|
558
|
+
header only, never in the envelope (security §2.4).
|
|
559
|
+
- **Order of checks on every `/_tq/*` route** (security §2.4, v1.10.13). (0) Hold ⇒ `503`
|
|
560
|
+
`unavailable`. (1) The shopper cookie's tag is verified. (2) **The same-origin signal.**
|
|
561
|
+
(3) The per-prefix limit, then (4) the per-shopper limit ⇒ `429` `rate_limited`. (5) The
|
|
562
|
+
CSRF token, on actions only ⇒ `403` `csrf_failed`. (6) The store budget ⇒ `429`
|
|
563
|
+
`budget_exhausted`. (7) The action or read.
|
|
564
|
+
- **The same-origin signal on `/_tq/*`.** It is present when `Sec-Fetch-Site` is
|
|
565
|
+
`same-origin`. When the browser sent no `Sec-Fetch-Site` at all, it is present on a `GET`
|
|
566
|
+
that carries the header `x-tq-csrf-fetch: 1` (`CSRF.fetchHeader`), and on a `POST` whose
|
|
567
|
+
`Origin` equals the request's own origin. `GET /_tq/csrf` needs the header in every case
|
|
568
|
+
(§4.1). **All three routes require the signal, `/_tq/data/*` included.** Without it the
|
|
569
|
+
answer is `403` `csrf_failed`, at once: no limit of any kind is charged, no cookie is set,
|
|
570
|
+
and a stored copy of a public data response is not served either.
|
|
571
|
+
- **What is charged.** A request that carries the signal and is then refused by a limit or by
|
|
572
|
+
the CSRF check has been counted against its own shopper and prefix. The store budget is
|
|
573
|
+
charged after the CSRF check, so a refused attempt does not spend it.
|
|
574
|
+
- **A caller that does not use the reference client** sends `x-tq-csrf-fetch: 1` on every
|
|
575
|
+
`/_tq/` `GET` itself. Without it the call works only in browsers that send `Sec-Fetch-Site`.
|
|
576
|
+
- `GET /_tq/data/consent.get` (owner approval 2026-10-07) returns
|
|
577
|
+
`ConsentReadout = ConsentState & { bannerRequired: boolean }`. Browser-mintable under
|
|
578
|
+
`actions:consent` (no `data:` capability needed); always served
|
|
579
|
+
`Cache-Control: private, no-store` with `Vary: Cookie`.
|
|
580
|
+
|
|
581
|
+
- Cart mutations require `version`; stale ⇒ `{ok:false, reason:'cart_version_conflict', cart}`.
|
|
582
|
+
- **Why a cart write was refused (alpha.13, Cart-1).** A write the home cell refuses resolves
|
|
583
|
+
`{ok:false, reason:'invalid'}` (a refused `cart.create` is the failure envelope with
|
|
584
|
+
`invalid_argument`). When the platform recognises the reason, it adds an optional
|
|
585
|
+
`refusal: CartRefusal` — on the `CartResult`, or as `error.refusal` of the `cart.create`
|
|
586
|
+
failure envelope. The one code today is `cart_limit_exceeded`: the write would put more of one
|
|
587
|
+
product in the cart than the merchant's "Maximum quantity per product" allows; `maxQuantity`
|
|
588
|
+
is that limit when known. A refusal carries no free text and nothing about stock; the theme
|
|
589
|
+
words it in the shopper's language. Absent `refusal`, or a `code` the theme does not know, means
|
|
590
|
+
"refused, reason not given" exactly as before, so a theme built for an earlier alpha is
|
|
591
|
+
unaffected. `form.submit` results and every other failure are unchanged.
|
|
592
|
+
- `form.submit(formId, fields)`: `formId ∈ manifest.forms`, fields ⊆ declared allowlist, ≤ 8 KiB,
|
|
593
|
+
rate-limited per capability-security §2.4, destination = merchant inbox via platform Notify. Runs only from a valid native
|
|
594
|
+
form POST (§1); never from the browser.
|
|
595
|
+
|
|
596
|
+
### 4.1 CSRF model (owner approval 2026-10-07; actions-route rulings)
|
|
597
|
+
|
|
598
|
+
Signed double-submit, enforced by the **gateway** on every `POST` (native form posts and
|
|
599
|
+
`/_tq/actions/*`). The same-origin signal (§1, §4) is a separate check that comes first.
|
|
600
|
+
|
|
601
|
+
**Derivation is security's, by reference.** The cookie `__Host-tq-csrf` holds a random secret,
|
|
602
|
+
and the token is an HMAC bound to that secret and the host. The exact construction, encoding and
|
|
603
|
+
cookie attributes are defined in capability-security-v1 (CSRF) and are not restated here. An
|
|
604
|
+
earlier draft of this section carried its own formula; it is withdrawn.
|
|
605
|
+
|
|
606
|
+
What this document fixes is where the token travels:
|
|
607
|
+
|
|
608
|
+
| Name | Rule |
|
|
609
|
+
|---|---|
|
|
610
|
+
| Cookie `__Host-tq-csrf` | Set by the gateway on an HTML response when absent. `HttpOnly`, so theme JS cannot read it. |
|
|
611
|
+
| Header `x-tq-csrf` | Carries the token on `fetch` calls to `/_tq/actions/*`. The reference client sets it (§4.2); a theme that calls the route itself must set it too. |
|
|
612
|
+
| Meta `<meta name="tq-csrf" content="<token>">` | Injected by the gateway into `<head>`. This is where the client wrapper reads the token. |
|
|
613
|
+
| Form fields `_tq_form`, `_tq_csrf` | **Both** injected by the gateway into every `<form data-tq-form="<formId>" method="post">`: `<input type="hidden" name="_tq_form" value="<formId>">` and `<input type="hidden" name="_tq_csrf" value="<token>">`. The theme renders neither (suite 2 fails if it does). Both are stripped from the parsed body before the isolate (§1). |
|
|
614
|
+
|
|
615
|
+
**Injection happens after the gateway's cache.** The body the theme rendered holds no token, so
|
|
616
|
+
it is cacheable in the shared cache exactly as its `CacheHint` allows, and the token does not
|
|
617
|
+
personalise the render or the cache key. The gateway adds the meta tag and hidden fields to each
|
|
618
|
+
response on the way out. The **final response to the browser** is then `Cache-Control: private,
|
|
619
|
+
no-cache`, because it carries a per-shopper token and possibly `Set-Cookie` (§5).
|
|
620
|
+
|
|
621
|
+
**Failure handling.**
|
|
622
|
+
- `/_tq/actions/*`: `403` with `csrf_failed`. The caller obtains a fresh token and retries
|
|
623
|
+
once; how the reference client does that is §4.2.
|
|
624
|
+
A request without the same-origin signal gets the same answer, `403` `csrf_failed`, on
|
|
625
|
+
`/_tq/data/*` as well as on actions and on the token endpoint. There
|
|
626
|
+
is deliberately no separate wire code. A same-origin caller never fails that check, so a
|
|
627
|
+
second code would only be seen by a cross-origin caller or behind a misconfigured host, and
|
|
628
|
+
neither needs to learn which check stopped it. Telling the two apart is for the gateway's
|
|
629
|
+
logs, keyed by `requestId`, not for the wire. Native forms differ (`formError: 'origin'` or `'csrf'`, §1) because
|
|
630
|
+
there the theme renders the result.
|
|
631
|
+
- Native form post: not refused. It renders as a normal page with `ctx.request.formError`
|
|
632
|
+
(§1). A first visit with no cookie yet is the `'csrf'` case.
|
|
633
|
+
|
|
634
|
+
**Token endpoint: `GET /_tq/csrf`.** Security specifies it (§2.6, B-19); this section owns its
|
|
635
|
+
wire names and shapes. It returns the token for the caller's current `__Host-tq-csrf` cookie,
|
|
636
|
+
so a client can get a fresh token without rendering a page.
|
|
637
|
+
|
|
638
|
+
| | |
|
|
639
|
+
|---|---|
|
|
640
|
+
| Request | `GET /_tq/csrf`, no query string, with the header `x-tq-csrf-fetch: 1` (`CSRF.fetchHeader`), required here even when `Sec-Fetch-Site` is `same-origin`. No body |
|
|
641
|
+
| Success | `200`, `content-type: application/json`, body exactly `{"token":"<43 base64url characters>"}` (`CsrfTokenResponse`). It is **not** a `WireEnvelope` |
|
|
642
|
+
| No valid cookie | The gateway mints one, sets it on this response, and returns its token |
|
|
643
|
+
| Header missing or not `1`, or `Sec-Fetch-Site` present and not `same-origin` | `403` `csrf_failed`. Decided before any limit: nothing is charged and no cookie is set |
|
|
644
|
+
| Any query string | `400` `invalid_argument` |
|
|
645
|
+
| Any method other than `GET` (including `HEAD` and `OPTIONS`) | `405` `invalid_argument`, `allow: GET`. No preflight is answered |
|
|
646
|
+
| Over the per-prefix or per-shopper limit (checked after the row above) | `429` `rate_limited` with `Retry-After`. A call that carries the signal is charged as one `/_tq/data` GET. It spends no store budget |
|
|
647
|
+
| Store on hold | `503` `unavailable`, before every other check |
|
|
648
|
+
| Failure body | The failure form of `WireEnvelope`: `{ok:false, error:{code, requestId}}` |
|
|
649
|
+
| Headers, every answer | `cache-control: private, no-store`, `vary: Cookie`, `x-content-type-options: nosniff`, `cross-origin-resource-policy: same-origin`. No `access-control-*` header |
|
|
650
|
+
|
|
651
|
+
The meta tag stays: the gateway still injects `<meta name="tq-csrf">` into every HTML response.
|
|
652
|
+
The endpoint is the way to **refresh** a token; the meta tag is the free way to get the first.
|
|
653
|
+
|
|
654
|
+
A theme that renders a `_tq_form` or `_tq_csrf` field itself is a conformance failure (suite 2).
|
|
655
|
+
At runtime the gateway fails that render with `form_field_forbidden`; that code is not yet in
|
|
656
|
+
security's closed ABI error list (§6.4a).
|
|
657
|
+
|
|
658
|
+
A CSRF token is **not** a credential: it does not defend against theme code or an XSS in it.
|
|
659
|
+
The browser capability ceiling does (security §2.6).
|
|
660
|
+
|
|
661
|
+
### 4.2 The reference browser client, `@tanqory/theme-abi/client`
|
|
662
|
+
|
|
663
|
+
**It is a real deliverable of the ABI package, and it is optional for themes.** The wire shape
|
|
664
|
+
in §4 is the contract; a theme may call `/_tq/*` itself. The client exists because the encoding,
|
|
665
|
+
the body cap and the CSRF handling are easy to get subtly wrong, and every theme would
|
|
666
|
+
otherwise write them again. (Nova v2 carries its own 95-line copy, with its own JCS encoder,
|
|
667
|
+
because the package did not ship one.)
|
|
668
|
+
|
|
669
|
+
**Surface.**
|
|
670
|
+
|
|
671
|
+
```ts
|
|
672
|
+
export function createAbiClient(options?: {
|
|
673
|
+
fetch?: typeof fetch // default: globalThis.fetch
|
|
674
|
+
document?: Document // default: globalThis.document
|
|
675
|
+
}): AbiClient
|
|
676
|
+
|
|
677
|
+
export interface AbiClient {
|
|
678
|
+
/** GET /_tq/data/<op>. Resolves with `data` of an ok envelope. Rejects with AbiWireError otherwise. */
|
|
679
|
+
data<T = unknown>(op: string, args?: readonly unknown[], init?: { signal?: AbortSignal }): Promise<T>
|
|
680
|
+
/** POST /_tq/actions/<ns>.<name>. Resolves with the WHOLE envelope, ok or not, so that `data` is
|
|
681
|
+
* readable on a conflict. Rejects with AbiWireError only when no envelope was received. */
|
|
682
|
+
action<T = unknown>(name: `${string}.${string}`, args?: readonly unknown[], init?: { signal?: AbortSignal }): Promise<ActionResult<T>>
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** The envelope as received, plus three facts the client adds. They are not part of the wire body. */
|
|
686
|
+
export type ActionResult<T> = WireEnvelope<T> & {
|
|
687
|
+
readonly status: number // HTTP status of the response returned
|
|
688
|
+
readonly retryAfterSeconds?: number // from Retry-After, when present
|
|
689
|
+
readonly csrfRefreshed?: true // the client fetched a new token for this call
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
export class AbiWireError extends Error {
|
|
693
|
+
readonly status: number // HTTP status; 0 when no response arrived
|
|
694
|
+
readonly code: WireErrorCode | 'invalid_envelope' | 'network'
|
|
695
|
+
readonly requestId?: string
|
|
696
|
+
readonly retryAfterSeconds?: number // from Retry-After, when present
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** base64url, no padding, of JCS(args); '' for an empty array. Exported so a theme can build cache keys. */
|
|
700
|
+
export function encodeDataArgs(args: readonly unknown[]): string
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
`retryAfterSeconds` is the header parsed as a non-negative whole number of seconds. A missing
|
|
704
|
+
header, or a value in any other form, leaves it `undefined`. The exact rule:
|
|
705
|
+
|
|
706
|
+
- The header value, after trimming optional whitespace, must be **one or more ASCII digits and
|
|
707
|
+
nothing else**. An HTTP-date, a sign, a decimal point, an exponent or an empty value ⇒
|
|
708
|
+
`undefined`.
|
|
709
|
+
- The result is a safe integer from 0 to `RETRY_AFTER_MAX_SECONDS` = **3600**. A larger value,
|
|
710
|
+
however many digits it has, is **clamped** to 3600. It is never `Infinity` and never dropped.
|
|
711
|
+
- Why clamp and not drop: a huge value still means "wait a long time". Dropping it would hand
|
|
712
|
+
the caller `undefined`, which reads as "no guidance" and invites an immediate retry.
|
|
713
|
+
- Why 3600: the gateway's windows are 60 seconds and its default is 60 (§4), so an hour is far
|
|
714
|
+
above anything it sends, and `3600 * 1000` is far below the largest delay a timer accepts.
|
|
715
|
+
A caller can pass the value to a timer without checking it.
|
|
716
|
+
|
|
717
|
+
It is set **whenever the response
|
|
718
|
+
that is returned carries the header**, on `ActionResult` and on `AbiWireError` alike, whatever
|
|
719
|
+
the code. The gateway promises the header only on the two `429` codes (§4); the client does
|
|
720
|
+
not drop it when something else, such as a `503`, carries one.
|
|
721
|
+
|
|
722
|
+
**What it does.**
|
|
723
|
+
|
|
724
|
+
| Concern | Behaviour |
|
|
725
|
+
|---|---|
|
|
726
|
+
| URLs | Relative, built from the package's route constants. Always same-origin; `credentials: 'same-origin'`; `accept: application/json`; **`redirect: 'error'` on every request**: the data GET, the action POST and the token fetch. No `/_tq/*` route is specified to redirect, so the client treats a redirect as a failure (`network`, `status: 0`) instead of re-sending a body and a token to wherever it points. This does not rely on the page's CSP |
|
|
727
|
+
| Names | `op` and action names are not checked against a list; the server answers `unknown_op`. Their **shape** is checked, because the name becomes part of a URL: dot-separated segments of ASCII letters and digits, each starting with a letter, at least two segments for an action, and at most `WIRE_NAME_MAX_LENGTH` = 64 characters in all. Any other name is refused locally as `unknown_op`, and no request is sent. No operation of this ABI has a name that long, so the server's answer for one is `unknown_op` as well |
|
|
728
|
+
| `data` arguments | `?a=` + `encodeDataArgs(args)`; the parameter is omitted for no arguments. An argument with no canonical form is refused locally as `invalid_argument`. So is an `a` value longer than `BROWSER_DATA_ARGS.maxEncodedChars` (§4). No request is sent |
|
|
729
|
+
| `action` body | `{"v":2,"args":[…]}` with `content-type: application/json`. A body over `ACTION_BODY_MAX_BYTES` is refused locally as `invalid_argument`. So is an argument that is not a JSON value: `NaN`, an infinity, `undefined`, a function, a `bigint`, or a string with an unpaired surrogate. Nothing is coerced to `null`. No request is sent |
|
|
730
|
+
| Local refusals | Every local refusal rejects with `AbiWireError`, `status: 0`, for `action` as well as for `data`: no envelope exists to return |
|
|
731
|
+
| What it reads from the page | Exactly one thing: the `content` of `<meta name="tq-csrf">`, at the time of each action. It reads no cookie, no storage and no `location`, and it never parses HTML |
|
|
732
|
+
| Header it sets | `x-tq-csrf-fetch: 1` on **every `GET`**: each `data` call and the token fetch. `x-tq-csrf` on actions, when it has a token. Nothing else beyond the two content headers. An action `POST` needs no extra header: the browser sends `Origin` itself |
|
|
733
|
+
| CSRF token | Actions only. First token from the page's meta tag; a fresh one from `GET /_tq/csrf`, at most one fetch per call. Steps and limits are below the table. **`data` never reads the meta tag, never sends `x-tq-csrf` and never calls `GET /_tq/csrf`**, whatever answer it gets, a `403` `csrf_failed` included |
|
|
734
|
+
| `csrf_failed` on `data` | Rejects with `AbiWireError`, `code: 'csrf_failed'`, `status: 403`. Never retried, and it never triggers a token fetch: a token cannot supply a missing same-origin signal |
|
|
735
|
+
| `not_browser_mintable` | Never retried. It means the theme called an operation the browser may not use (`form.submit`, any customer operation, a server-only data op). `action` returns the envelope; `data` rejects |
|
|
736
|
+
| `rate_limited`, `budget_exhausted` | Never retried automatically. `retryAfterSeconds` carries the server's value: on the result for `action`, on the error for `data` |
|
|
737
|
+
| Unknown error code | A failure envelope whose `code` is not a `WireErrorCode` is treated as no envelope: `AbiWireError` with `code: 'invalid_envelope'`, keeping `status` and `requestId`, and naming the received code in `message` |
|
|
738
|
+
| Abort | When `init.signal` aborts, the call rejects with the signal's own reason (normally an `AbortError`), unchanged. It is not an `AbiWireError` and not `network`: a caller must be able to tell its own cancel from a failure |
|
|
739
|
+
| Every other error | Returned or thrown as received. No automatic retry |
|
|
740
|
+
| State | None, apart from a refreshed CSRF token. No caching and no batching |
|
|
741
|
+
|
|
742
|
+
**The CSRF token, step by step.**
|
|
743
|
+
|
|
744
|
+
*Where the token comes from.*
|
|
745
|
+
|
|
746
|
+
1. **First use: the meta tag.** The client sends the `content` of `<meta name="tq-csrf">`. That
|
|
747
|
+
costs nothing: the gateway has already put it in the page.
|
|
748
|
+
2. **No token at all** (no document, or no tag): the client fetches the endpoint **before**
|
|
749
|
+
sending the action. A data GET is cheaper than a counted action attempt that is certain to
|
|
750
|
+
fail. This is the call's one token fetch; step 4 does not fetch again.
|
|
751
|
+
3. The endpoint is not used on every call. Doing so would add one counted request to every
|
|
752
|
+
action for no gain.
|
|
753
|
+
|
|
754
|
+
*What happens on `csrf_failed`.*
|
|
755
|
+
|
|
756
|
+
4. The client remembers the token it sent. If this call has not fetched a token yet, it fetches
|
|
757
|
+
one: `GET /_tq/csrf` with `x-tq-csrf-fetch: 1`, `accept: application/json`,
|
|
758
|
+
`credentials: 'same-origin'`, `cache: 'no-store'`, `redirect: 'error'`.
|
|
759
|
+
5. The fetch **succeeded** only if the status is `200` and the body is a JSON object whose
|
|
760
|
+
`token` is a string of 43 base64url characters. Anything else is a failed fetch: `403`,
|
|
761
|
+
`404`, `429`, `503`, a network error, a redirect, another body.
|
|
762
|
+
6. It retries the action **only if** the fetched token differs from the one it sent. The token
|
|
763
|
+
is a pure function of the cookie and the host (security §2.6), so the same token would fail
|
|
764
|
+
again and only spend another counted attempt.
|
|
765
|
+
7. `csrfRefreshed: true` means: for this call the client **obtained** a token from the endpoint
|
|
766
|
+
(the fetch succeeded, step 5) **and** then sent or re-sent the action with it. It is set
|
|
767
|
+
whatever that action's outcome. A second `csrf_failed` is returned to the caller.
|
|
768
|
+
It is never set after a fetch that failed, in step 2 or in step 4: no token was fetched.
|
|
769
|
+
8. If the fetch failed or the token is unchanged, nothing is retried. The **first**
|
|
770
|
+
`csrf_failed` envelope is returned, without `csrfRefreshed`. A `429` on the token fetch is
|
|
771
|
+
not reported separately: the caller sees the original `csrf_failed`. In step 2, where there
|
|
772
|
+
is no first envelope, a failed fetch means the action is sent without a token and the
|
|
773
|
+
server's answer is returned, without `csrfRefreshed`.
|
|
774
|
+
|
|
775
|
+
- **One mechanism, no fallback.** The page fetch is removed. The client never requests a
|
|
776
|
+
document and never uses `DOMParser`. Against a gateway that answers `404` on `/_tq/csrf`, the
|
|
777
|
+
fetch has failed (step 5) and the original envelope is returned. No gateway without the
|
|
778
|
+
endpoint is deployed, so no second path is kept for one.
|
|
779
|
+
- **What it costs.** A refreshed call spends two attempts of the shopper's action limit (§4,
|
|
780
|
+
order of checks) and one data GET. That is why it happens once per call and never for an
|
|
781
|
+
unchanged token.
|
|
782
|
+
- **One fetch at a time.** Calls that need a token while a fetch is in flight wait for it and
|
|
783
|
+
use its result. They do not start their own. The shared fetch is not tied to any caller's
|
|
784
|
+
`signal`: a caller that aborts rejects with its own reason and the others carry on.
|
|
785
|
+
- **Which token is used afterwards.** The client keeps the fetched token together with the
|
|
786
|
+
page's meta value it saw at that moment. While the page's meta tag still holds that old
|
|
787
|
+
value, or there is no tag, the remembered token is sent. Once the tag holds anything else (a
|
|
788
|
+
theme that swaps the document head on client-side navigation), the tag wins again and the
|
|
789
|
+
remembered token is dropped.
|
|
790
|
+
|
|
791
|
+
**Two rules for the implementation.**
|
|
792
|
+
|
|
793
|
+
- **Safe to bundle on the server.** The module touches `fetch` and `document` only as members of `globalThis` or of the injected options, and only inside a
|
|
794
|
+
call. It never names them as free identifiers. A server bundle that happens to include the
|
|
795
|
+
module therefore still passes the suite 1 scan (§11).
|
|
796
|
+
- **Small and dependency-free.** It uses the package's own JCS encoder and route constants. Its
|
|
797
|
+
size counts toward the theme's client budget. No figure has been measured yet.
|
|
798
|
+
|
|
799
|
+
**The JCS encoder is a public export.** `@tanqory/theme-abi/jcs` exports
|
|
800
|
+
`canonicalJson(value: unknown): string`, which returns the RFC 8785 form and throws a
|
|
801
|
+
`TypeError` (the package's `CanonicalJsonError`) for a value that has none. What is refused,
|
|
802
|
+
and the one case that is absent rather than refused, is theme-artifact-v1 §4: the
|
|
803
|
+
unpaired-surrogate rule and the table "Inputs that are not JSON values". It is the one encoder behind `encodeDataArgs`, and it must reproduce
|
|
804
|
+
`vectors/artifact-vector-1`. The module is pure ECMAScript, so it is safe in a server bundle,
|
|
805
|
+
a browser bundle and Node. Build-service, the edge and any theme carrying a private encoder
|
|
806
|
+
move to it the next time they touch that code; until then each private copy must pass the
|
|
807
|
+
same vector.
|
|
808
|
+
|
|
809
|
+
## 5. Response contract
|
|
810
|
+
|
|
811
|
+
Platform-owned, set/overridden on every theme response:
|
|
812
|
+
|
|
813
|
+
| Header | Rule |
|
|
814
|
+
|---|---|
|
|
815
|
+
| `content-security-policy` | Platform base: `default-src 'self'`; `script-src 'self' <asset origin> + script hashes`; **`style-src-elem 'self' <asset origin> + style hashes`**; **`style-src-attr 'none'`**; `connect-src 'self'`; **`worker-src 'none'`** (Asset-2); `img/media/font-src https: data: blob:`; **`frame-src <account BFF origin>`** (only that origin); `object-src 'none'`; `base-uri 'self'`; `form-action 'self'`; `frame-ancestors 'none'` on live hosts, `frame-ancestors <editor origin(s)>` (from platform config) on preview hosts. **No `'unsafe-inline'` in any directive** (owner approval 2026-10-07). **Where the allowances come from (edge review, corrected ruling; keeps owner approval #1). The host never computes a hash for theme-emitted inline content.** (a) **Inline scripts:** the theme asserts `x-tq-csp-script-hash: sha256-…` per render (repeatable). The host accepts a value **only if it appears in the signed statement's `predicate.inlineScriptHashes`** (security B-8), a list of CSP source expressions `'sha256-…'` fixed at build time from the theme's `src/inline/*.js` files (theme-artifact-v1 §1.2, §2, §6: exact bytes hashed, emitted verbatim, at most 32, `</script` and `<!--` rejected). The header carries the bare token `sha256-…`; the host adds the quotes before comparing. Any other value is dropped and logged, so that block does not run. Dynamic executable inline scripts are therefore impossible. Per-request data goes in `<script type="application/json">`, which is non-executable and needs no hash. (b) **Inline styles:** the theme asserts `x-tq-csp-style-hash: sha256-…` per render (repeatable), because settings-driven CSS variables differ per store. The host bounds them with two **fixed host constants** (not limits-table rows, so they never bump `limitsVersion`): at most **16** style hashes per render, and at most **16 KiB** of inline `<style>` bytes in total per render. Exceeding either is the ABI error **`csp_style_budget`** (security §6.4a; the render fails, it is not silently trimmed). (c) **External** script and stylesheet files are allowed by origin (`assets.base`), with integrity from the signed file list (`predicate.files`, enforced in the page by SRI). The gateway merges accepted hashes into the policy and strips both headers. The theme may tighten the policy; loosening is ignored and logged. **Theme `style="…"` attributes are not supported** (blocked by `style-src-attr 'none'`). Use classes, or CSS variables set in a hashed `<style>` block. Third-party iframes (e.g. video embeds) are not possible under this `frame-src`. |
|
|
816
|
+
| HSTS, `x-content-type-options`, `referrer-policy`, `permissions-policy`, COOP, `x-frame-options` | Platform values; theme values dropped. **Framing** (capability-security §4.8, B-10): live hosts send `x-frame-options: DENY` with `frame-ancestors 'none'`; preview hosts send **no** `x-frame-options` and rely on `frame-ancestors <editor origin(s)>`. `permissions-policy` is the exact string security §4.8 defines, on both (referenced, not restated here). A theme can never set or widen any of these. |
|
|
817
|
+
| `cache-control`, `cdn-cache-control`, `surrogate-*`, `vary`, `age`, `etag` | Dropped from the theme. Derived from `ctx.cache.hint()`, which may only tighten the defaults below, so the effective clamps are `maxAge ≤ 60` and `swr ≤ 600`; `private` ⇒ `private, no-store`; declared `vary` dimensions are **appended** to the base cache key as defined in §5.1. SiteRelease owns that base key: `host\|path\|market\|locale\|releaseId\|dataVersion` — `x-tq-market`/`x-tq-locale` are base identity, not `VaryKey`s (B3). Defaults: 200 → `s-maxage=60, swr=600`; 404 from the `not_found` template → `s-maxage=60`; ≥ 500, cart/customer/consent touched, data-limit breach, or undeclared personalisation → `no-store`. **These values govern the shared cache entry** (the body the theme rendered). The gateway injects the CSRF meta tag and hidden form fields **after** that cache (§4.1), so whenever a token was injected the final browser response is `Cache-Control: private, no-cache`; a form POST that failed CSRF or origin checks is `private, no-store`. |
|
|
818
|
+
| `set-cookie` | Dropped. |
|
|
819
|
+
| `location` | 3xx only; same-origin or a URL returned by a `*Url()` getter; else 500. |
|
|
820
|
+
| `x-tq-release`, `x-tq-cache`, `x-tq-trace`, `x-tq-degraded` | Platform-added; theme `x-tq-*` dropped. `x-tq-cache` is `HIT` or `MISS` on a response the gateway looked up in its shared cache, and absent when the request bypassed the cache. `x-tq-degraded` values are listed in §5.2. |
|
|
821
|
+
| `content-type` | Theme-set; allowed: `text/html`, `application/xml`, `text/xml`, `application/rss+xml`, `application/atom+xml`, `image/svg+xml`, `text/plain`, `application/json`, `application/manifest+json`, `text/css` (S6). Anything else → 500. |
|
|
822
|
+
|
|
823
|
+
### 5.1 Shared cache rules (cache rulings Cache-1, Cache-5, Cache-6)
|
|
824
|
+
|
|
825
|
+
**The values in the table above are upper bounds.** Caching is an optimisation. A gateway may
|
|
826
|
+
store a response for less time than `s-maxage`, may ignore `stale-while-revalidate`, or may not
|
|
827
|
+
store it at all; each of those is conformant. It must never store a response the rules forbid.
|
|
828
|
+
|
|
829
|
+
**How long an entry may be served.** The normal bound is `s-maxage` plus
|
|
830
|
+
`stale-while-revalidate`. **Degraded serving is governed solely by capability-security §4.6a**:
|
|
831
|
+
which conditions allow it, for how long, and how the response is marked. This document restates
|
|
832
|
+
none of those bounds.
|
|
833
|
+
|
|
834
|
+
**What may be stored** is defined by capability-security §4.6a and is repeated here only so the
|
|
835
|
+
two documents can be read together: a `200` response, or a `404` from the `not_found` template,
|
|
836
|
+
to a `GET`, on the live channel, from the current verified release, with no `Set-Cookie` and no
|
|
837
|
+
`x-tq-degraded`, that is not personalised. A `404` may be stored for at most `s-maxage=60`; it
|
|
838
|
+
is safe because `dataVersion` is in the key, so creating the missing resource changes the key.
|
|
839
|
+
A gateway that answers every non-200 with `no-store` is conformant (the values are upper
|
|
840
|
+
bounds). A redirect, a `≥ 500`, a preview render and any `POST` are never stored.
|
|
841
|
+
|
|
842
|
+
**HEAD.** A `HEAD` request may be answered from the stored `GET` entry for the same key, with the
|
|
843
|
+
body dropped. On a miss the gateway renders and does **not** store the result (only `GET`
|
|
844
|
+
renders are stored). The theme sees
|
|
845
|
+
`request.method` as it was sent.
|
|
846
|
+
|
|
847
|
+
**`vary` dimensions.** `CacheHint.vary` accepts every `VaryKey`, but a key makes a response
|
|
848
|
+
cacheable only if it is a **cacheable dimension**: one whose full value has a small, fixed set
|
|
849
|
+
of normal forms, and where the value the theme reads is exactly the value in the key.
|
|
850
|
+
|
|
851
|
+
| `VaryKey` | Cacheable dimension in 2.x? | Normal form in the key |
|
|
852
|
+
|---|---|---|
|
|
853
|
+
| `x-tq-country` (also covers `ctx.locale.country`) | **yes** | `country=<CC>`: two uppercase ASCII letters as the gateway resolved them, or `ZZ` when unknown. The gateway sets this header itself; a client cannot supply it |
|
|
854
|
+
| `accept-language`, `user-agent`, `sec-ch-ua`, `sec-ch-ua-mobile`, `sec-ch-ua-platform` | no | none. The raw value is client-controlled and unbounded, and the theme reads the raw value. Declaring one of these still yields `private, no-store` |
|
|
855
|
+
| `hasCart`, `isSignedIn`, `consent` | no | none. capability-security §4.6a rules that shopper state is never a key dimension. Reading one, or declaring it, means the render is not stored |
|
|
856
|
+
|
|
857
|
+
- **Key suffix.** When a stored response declared cacheable dimensions, its key is the base key
|
|
858
|
+
followed by `|v=` and the normal forms, sorted by dimension name and joined with `&`. The one
|
|
859
|
+
dimension in 2.x is named `country`, so the only suffix is `|v=country=<CC>`, for example
|
|
860
|
+
`|v=country=TH`. A response that declared none is stored under the base key with no suffix.
|
|
861
|
+
The key itself, and how the gateway finds a variant before rendering (the vary record), are
|
|
862
|
+
defined in site-release §4.3.2.
|
|
863
|
+
- **Bound on variants.** A base key has at most one variant per country code plus `ZZ`, about
|
|
864
|
+
250. That bound comes from the dimension itself, so no separate cap is defined. Any dimension
|
|
865
|
+
added later must state its own bound here.
|
|
866
|
+
- **Undeclared reads.** A render that read a personalising input without declaring it is
|
|
867
|
+
`private, no-store`. So is one that declared a non-cacheable key.
|
|
868
|
+
- `accept` and `traceparent` are not `VaryKey`s and can never be declared.
|
|
869
|
+
|
|
870
|
+
**Query strings.** A request with any query string is neither served from nor stored in the
|
|
871
|
+
shared cache (site-release §4.3.2). A canonical-query rule is proposed and not adopted; see
|
|
872
|
+
appendix row Cache-7.
|
|
873
|
+
|
|
874
|
+
### 5.2 `x-tq-degraded` values
|
|
875
|
+
|
|
876
|
+
**The single normative list is in site-release §4.3.1** ("`x-tq-degraded` values — one owner
|
|
877
|
+
per value"). It names every value, the contract that defines it, and its rank when several
|
|
878
|
+
apply. This document does not repeat that table.
|
|
879
|
+
|
|
880
|
+
This document defines one of those values: **`data_limit`**, set when a data limit is breached
|
|
881
|
+
after the response body has started (§3, "Breach"). Every response carrying `x-tq-degraded` is
|
|
882
|
+
`private, no-store` and is not stored (capability-security §4.6a).
|
|
883
|
+
|
|
884
|
+
## 6. Theme developer freedom (non-goals)
|
|
885
|
+
|
|
886
|
+
Nothing prescribed about DOM, CSS, framework, hydration (or none), client routing, section/
|
|
887
|
+
region taxonomy, naming, images, fonts, i18n library. Regions, templates (beyond `home`/
|
|
888
|
+
`not_found`), components, presets, categories, icons, roles, forms are theme-declared. Platform
|
|
889
|
+
vocabularies: route kinds, capabilities, field-type registry, content-doc shape, the canvas
|
|
890
|
+
protocol (§7.3). Nova-isms → `x-nova`. DOM hooks the platform reads: `data-tq-node="<id>"` (§7.3),
|
|
891
|
+
`data-tq-form="<formId>"` on native forms (§4.1) and `data-tq-slot="account"` (§4). The one
|
|
892
|
+
styling constraint is CSP: no `style=""` attributes and no unhashed inline `<style>`. Executable
|
|
893
|
+
inline scripts must be fixed at build time (§5).
|
|
894
|
+
|
|
895
|
+
## 7. Manifest, content documents, editor contract
|
|
896
|
+
|
|
897
|
+
### 7.1 Manifest
|
|
898
|
+
|
|
899
|
+
Normative: `theme-manifest.v2.schema.json`. `capabilities` is `{server[], browser[]}` — both
|
|
900
|
+
**required and explicit**, `browser ⊆ server`, browser never contains `data:customer`,
|
|
901
|
+
`actions:customer`, `actions:form.submit`; the builder copies both verbatim into the signed
|
|
902
|
+
statement and the runtime uses the statement's copy, never the manifest file (A1, security B-6);
|
|
903
|
+
`forms{}` declares `form.submit` targets; `client.hydration: none|full|partial` (S6);
|
|
904
|
+
`minContentVersion` + `migrations[]` (S5); `x-*` ignored, size-counted. Nova maps as `regions =
|
|
905
|
+
{header: shared, main: template, footer: shared}`, 86 components, 19 templates; `stats/warnings/
|
|
906
|
+
generatedFrom/sharedAttributes/contentSlots/source/sourceHash` → `x-nova`.
|
|
907
|
+
|
|
908
|
+
### 7.2 Content documents
|
|
909
|
+
|
|
910
|
+
Node = `{type, id, settings?, blocks?, disabled?}` (same id pattern as today). Docs:
|
|
911
|
+
|
|
912
|
+
```jsonc
|
|
913
|
+
{ "contentVersion": 3, "region": "main", "sections": [ … ] } // RegionDoc
|
|
914
|
+
{ "contentVersion": 3, "template": "product.bundle", "regions": { "main": { "sections": [] } }, "meta": {} } // TemplateDoc
|
|
915
|
+
{ "contentVersion": 3, "values": { "colorScheme": "light" } } // Settings
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
v2→v3 mapping: `area` → region id; `groups.<slot>: "<name>"` → shared region doc; `override` →
|
|
919
|
+
template-scoped doc (impossible if the theme declares that region `shared`; extraction reports
|
|
920
|
+
it). Platform validation before render: unknown `type` → node dropped + `content.invalid_node`;
|
|
921
|
+
unknown setting key → dropped; disallowed block → dropped; `min/max` → render proceeds, editor
|
|
922
|
+
warns.
|
|
923
|
+
|
|
924
|
+
### 7.3 Editor contract
|
|
925
|
+
|
|
926
|
+
From the artifact: `manifest.json`, `defaults/`, `locales/`, `preview/entry.mjs`. Editor-only:
|
|
927
|
+
inserter, settings panel, field-type→control mapping (lives with the registry), outline, DnD,
|
|
928
|
+
undo, draft persistence, AI assist.
|
|
929
|
+
|
|
930
|
+
**The canvas.** The editor frames a **preview host** (`preview-<channel>.<base>`; its preview
|
|
931
|
+
token's subject is the preview channel id, not a Theme uuid — A6) and renders the theme there
|
|
932
|
+
with `ctx.mode === 'preview'`. Preview mode intersects capabilities with `{data:shop,
|
|
933
|
+
data:localization, data:catalog, data:content, data:search, data:cart,
|
|
934
|
+
data:products.recommendations, data:inventory, actions:cart, actions:consent,
|
|
935
|
+
preview:renderSection}` (= security `PREVIEW_CAPS`); a preview token carrying customer scope is
|
|
936
|
+
`403 preview_channel`.
|
|
937
|
+
|
|
938
|
+
The canvas protocol below is what the editor and the gateway implement (sources: storefront-edge
|
|
939
|
+
`core/editor-bridge.client.mjs`, `core/editor-bridge.ts`, `core/section-preview.ts`; editor
|
|
940
|
+
`apps/editor/src/editor/components/tanqory-template-context.tsx`, `src/lib/canvas-live.ts`,
|
|
941
|
+
`src/editor/preview-channel.ts`; reference theme consumer Nova V3 `lib/abi/canvas-live.ts`).
|
|
942
|
+
It has three parties: the **editor** (parent window), the **bridge** (platform script inside the
|
|
943
|
+
canvas page) and the **theme** (its client code in the same page). Only the editor and the bridge
|
|
944
|
+
speak `postMessage`; the theme talks to the bridge through DOM events on `window`. Paragraphs
|
|
945
|
+
marked **(normative)** are requirements on a theme or on a host; the rest describes platform
|
|
946
|
+
behaviour a theme can rely on.
|
|
947
|
+
|
|
948
|
+
#### 7.3.1 The bridge (platform)
|
|
949
|
+
|
|
950
|
+
- **Injection.** On a preview host with a configured editor origin, the gateway inserts
|
|
951
|
+
`<script type="module" src="/_tq/editor-bridge.js"></script>` immediately after `<head …>` of
|
|
952
|
+
every HTML page it renders, and serves that path (same origin, so `script-src 'self'` admits
|
|
953
|
+
it). A live host never carries the tag and answers the path `404`. Preview pages are never
|
|
954
|
+
stored in the shared HTML cache. The theme ships nothing for selection or outlines.
|
|
955
|
+
- **Install.** The bridge does nothing when the page is not framed or no editor origin is
|
|
956
|
+
configured. Otherwise it listens, then dispatches `tq:canvas-bridge` on `window` once.
|
|
957
|
+
- **Trust (normative for hosts).** An editor message is honoured only when
|
|
958
|
+
`event.source === window.parent` **and** `event.origin` is one of the exact configured editor
|
|
959
|
+
origins (`https://host[:port]`, no wildcard, the same list `frame-ancestors` uses, security
|
|
960
|
+
§4.8). Everything else is ignored silently. Outgoing messages go to `window.parent` only and
|
|
961
|
+
never with `targetOrigin '*'`: once a trusted message has arrived they are addressed to its
|
|
962
|
+
origin; before that, to each configured origin in turn (the browser delivers only the one
|
|
963
|
+
that matches the parent).
|
|
964
|
+
- **Overlay.** Outlines are drawn in a layer of the bridge's own
|
|
965
|
+
(`<div data-tq-canvas-overlay aria-hidden="true">`, `position: fixed`, `pointer-events: none`)
|
|
966
|
+
styled through CSSOM, which `style-src-attr 'none'` does not govern. The theme's elements are
|
|
967
|
+
never written to. A section label comes from `data-tq-section-type` or `data-tq-type` when the
|
|
968
|
+
theme sets one (optional).
|
|
969
|
+
|
|
970
|
+
#### 7.3.2 Node identity: `data-tq-node` (normative)
|
|
971
|
+
|
|
972
|
+
- Every section and block the theme renders from `ctx.content` MUST carry
|
|
973
|
+
`data-tq-node="<node id>"` on its outermost element, where `<node id>` is the content node's
|
|
974
|
+
`id` (§7.2). A block's element MUST be inside its section's element. Conformance suite 2
|
|
975
|
+
checks that every `data-tq-node` value is a node of the rendered documents.
|
|
976
|
+
- The element MAY be `display: contents`; its box is then the union of its children's boxes.
|
|
977
|
+
- When the theme re-renders a node live (§7.3.4), the new element MUST carry the same
|
|
978
|
+
`data-tq-node`; the bridge re-finds the selection by id after every `tq:canvas` event.
|
|
979
|
+
- `data-tq-section-id` is read as a legacy alias of `data-tq-node`. A theme uses `data-tq-node`.
|
|
980
|
+
- The bridge treats the outermost element carrying a node attribute as the **section** and any
|
|
981
|
+
node element inside it as a **block** of that section.
|
|
982
|
+
|
|
983
|
+
#### 7.3.3 Messages
|
|
984
|
+
|
|
985
|
+
No message carries a version field. All are plain objects with a string `type`.
|
|
986
|
+
|
|
987
|
+
**Editor → canvas (through the bridge).**
|
|
988
|
+
|
|
989
|
+
| `type` | Payload | Sent when | Bridge does |
|
|
990
|
+
|---|---|---|---|
|
|
991
|
+
| `tanqory-preview-select` | `{sectionId: string \| null, blockId: string \| null}` | a node is selected in the editor (`null` clears) | outlines the block (or the section) and its section, scrolls the section's top into view below any sticky/fixed top bar, posts `tanqory-selection-rect`. An unknown `sectionId` clears the selection |
|
|
992
|
+
| `tq:set-content` | `{doc: SectionNode[]}` (below) | after every `tq:ready`, after any structural change, after undo/redo and after a server-side reload of the template | dispatches `tq:canvas` |
|
|
993
|
+
| `tanqory-preview-update-section` | `{sectionId: string, settings: object, blocks: Record<blockId, {type, settings?, …}> \| null, order: string[] \| null}` | a section's settings or blocks changed (debounced ~150 ms per section) | dispatches `tq:canvas` |
|
|
994
|
+
| `tanqory-preview-reorder-sections` | `{order: string[]}` (section ids) | sections reordered | dispatches `tq:canvas` |
|
|
995
|
+
| `tanqory-preview-remove-section` | `{sectionId: string}` | a section removed | dispatches `tq:canvas` |
|
|
996
|
+
| `tanqory-preview-insert-section` | `{sectionId, afterSectionId: string \| null, sectionType, settings, blocks: Record<blockId, {type, settings}>, order: string[]}` | a section added or duplicated | dispatches `tq:canvas` |
|
|
997
|
+
|
|
998
|
+
`SectionNode` = `{type: string, id: string, settings: object, blocks?: {type: string, id: string,
|
|
999
|
+
settings: object}[]}`: the template being edited, in order, including unsaved edits. Settings
|
|
1000
|
+
objects are complete for the node, not diffs. The five types from `tq:set-content` down are the
|
|
1001
|
+
**content types**; a message of any other type (for example the editor's `tq:theme-settings`)
|
|
1002
|
+
is not forwarded to the theme.
|
|
1003
|
+
|
|
1004
|
+
**Canvas → editor (bridge only).**
|
|
1005
|
+
|
|
1006
|
+
| `type` | Payload | Sent when |
|
|
1007
|
+
|---|---|---|
|
|
1008
|
+
| `tq:ready` | `{pages: [], live: string[]}` | once after `DOMContentLoaded` plus one task (so module scripts, the theme's hydration entry among them, have run), and again whenever the theme's announced `live` set changes after that. `pages` is reserved and always empty. `live` = the content types the theme applies (§7.3.4) |
|
|
1009
|
+
| `tanqory-section-selected` | `{sectionId}` | a click in the canvas lands in a section and not in a block of it |
|
|
1010
|
+
| `tanqory-block-selected` | `{sectionId, blockId}` | the click lands in a block |
|
|
1011
|
+
| `tanqory-selection-rect` | `{rect: {top, left, width, height} \| null}` | after every selection change and on every scroll / resize frame while something is selected; viewport (client) pixels of the canvas window; `null` when cleared or the node has no box |
|
|
1012
|
+
| `tq-preview-height` | `{height: number}` | on a section preview page only (§7.3.5): after `tq:ready`, on `load`, and whenever the document height changes (whole CSS pixels, `max(documentElement, body).scrollHeight`) |
|
|
1013
|
+
|
|
1014
|
+
A click inside a node selects it; when the click target is inside a link or a submit control the
|
|
1015
|
+
bridge prevents the navigation (the canvas is for editing). Mouse hover draws a hover outline;
|
|
1016
|
+
nothing is sent for it.
|
|
1017
|
+
|
|
1018
|
+
**Editor side (normative for the editor).** The editor posts only to its canvas iframe at the
|
|
1019
|
+
origin of the URL it framed, and accepts canvas messages only from that frame's window at that
|
|
1020
|
+
origin.
|
|
1021
|
+
|
|
1022
|
+
#### 7.3.4 Live edit: the theme handshake
|
|
1023
|
+
|
|
1024
|
+
DOM events on `window` (`CustomEvent`), same document, never cross-origin:
|
|
1025
|
+
|
|
1026
|
+
| Event | Direction | `detail` |
|
|
1027
|
+
|---|---|---|
|
|
1028
|
+
| `tq:canvas-bridge` | bridge → theme | none. The bridge is listening |
|
|
1029
|
+
| `tq:canvas` | bridge → theme | the editor's message object, unchanged, already origin-checked; only content types |
|
|
1030
|
+
| `tq:canvas-live` | theme → bridge | `{types: string[]}`: the content types this theme applies live |
|
|
1031
|
+
|
|
1032
|
+
Ordering: the bridge dispatches `tq:canvas-bridge` when it installs, which may be before or after
|
|
1033
|
+
the theme's client code runs. `tq:ready` goes out after `DOMContentLoaded`; a theme that announces
|
|
1034
|
+
synchronously during its module evaluation is already in it. A later announcement that changes
|
|
1035
|
+
the set makes the bridge post `tq:ready` again, which makes the editor push `tq:set-content` again.
|
|
1036
|
+
Each trusted content message is dispatched as `tq:canvas` synchronously, and the bridge then
|
|
1037
|
+
re-finds and repaints the selection.
|
|
1038
|
+
|
|
1039
|
+
**A theme that applies edits live (normative when it announces anything):**
|
|
1040
|
+
|
|
1041
|
+
1. Its client code, on a preview render, adds a `tq:canvas` listener on `window`, then dispatches
|
|
1042
|
+
`tq:canvas-live` with exactly the types it applies, and dispatches it again on every
|
|
1043
|
+
`tq:canvas-bridge`. The bridge keeps only content types.
|
|
1044
|
+
2. It never listens for or posts these `postMessage` types itself; `tq:canvas` is its only input.
|
|
1045
|
+
3. Announcing `tanqory-preview-update-section` is a promise: the editor then keeps the canvas
|
|
1046
|
+
frame after a save that changed only settings (it does not reload). So the theme MUST apply
|
|
1047
|
+
every such message to the section whose node id is `sectionId`: merge `settings` into the
|
|
1048
|
+
node's settings, and when `blocks` is not `null` replace the node's blocks with `blocks` taken
|
|
1049
|
+
in `order` (all keys of `blocks` when `order` is `null`), and re-render that section without
|
|
1050
|
+
a reload.
|
|
1051
|
+
4. Announcing `tq:set-content` means: for every node of `doc` (and its blocks) whose id the page
|
|
1052
|
+
renders with the **same `type`**, apply its settings and blocks as in 3.
|
|
1053
|
+
5. A node id the page does not render is ignored, never inserted. A node whose `type` changed is
|
|
1054
|
+
left for the reload.
|
|
1055
|
+
6. Unknown message types and unknown members are ignored.
|
|
1056
|
+
|
|
1057
|
+
A theme MAY also apply the three structural types; the editor does not rely on it. Any
|
|
1058
|
+
structural change, a theme-settings save, a slot switch, or a canvas whose `tq:ready.live` lacks
|
|
1059
|
+
`tanqory-preview-update-section` makes the editor reload the canvas from the saved draft on the
|
|
1060
|
+
next save. **A theme that announces nothing** still gets selection, scrolling, outlines and
|
|
1061
|
+
click-to-select; its canvas reloads after each save.
|
|
1062
|
+
|
|
1063
|
+
#### 7.3.5 Add section previews: `?tq_section_preview=<type>`
|
|
1064
|
+
|
|
1065
|
+
On a preview host only, `GET /?tq_section_preview=<type>` (exactly one such parameter, `<type>`
|
|
1066
|
+
matching `^[a-z0-9][a-z0-9_-]{0,63}$`) renders the page with `ctx.content.page` holding **one**
|
|
1067
|
+
node: id `tq-section-preview`, type `<type>`, settings and blocks from
|
|
1068
|
+
`manifest.components[<type>].presets[0]` (block ids `tq-section-preview-<i>`, nested
|
|
1069
|
+
`…-<i>-<j>`; `asset:client/assets/<file>` values resolved to the release's asset URL for signed
|
|
1070
|
+
files only). It is placed in the first page region whose manifest region is not `shared` and
|
|
1071
|
+
whose `accepts` admits `<type>` (else the page's first region); no other region is present, so
|
|
1072
|
+
headers and footers are absent. A type that is not a manifest section (unknown, or a `block`)
|
|
1073
|
+
renders the page as usual. Nothing else comes from the URL. The bridge posts `tq-preview-height`
|
|
1074
|
+
for this page; the editor shows the rendered frame only once that height arrives, and a static
|
|
1075
|
+
card from the manifest entry otherwise.
|
|
1076
|
+
|
|
1077
|
+
**(normative)** Every section that an editor may insert MUST render on its own from its
|
|
1078
|
+
`presets[0]` with a non-zero height, without its header/footer regions and without data the
|
|
1079
|
+
preset does not supply.
|
|
1080
|
+
|
|
1081
|
+
### 7.4 Field-type registry & content versioning
|
|
1082
|
+
|
|
1083
|
+
- `tanqory-field-types@1` is **canonical in Appendix B** of this document (22 ids: today's 20 core
|
|
1084
|
+
+ **`json`** + **`list`**); `@tanqory/theme-abi` exports it as `FIELD_TYPES_V1` and must equal
|
|
1085
|
+
Appendix B byte-for-byte after JCS. Aliases `image_picker link_list checkbox color_background`
|
|
1086
|
+
are errors naming the replacement. The manifest *schema* checks only the id pattern;
|
|
1087
|
+
membership and per-type constraints are `validateManifest()` checks against the registry.
|
|
1088
|
+
`url`, `image` and `video` values obey Appendix B's **URL rule** (scheme allowlist, no `//`,
|
|
1089
|
+
`/\`, `javascript:`, `data:`, `vbscript:`, `file:`), enforced on defaults, presets and content.
|
|
1090
|
+
- Registry bumps are additive; **every registry version within an ABI major stays supported**
|
|
1091
|
+
(S5); removal only at an ABI major.
|
|
1092
|
+
- `contentVersion`/`minContentVersion`/`migrations[]`: on install of an artifact whose
|
|
1093
|
+
`contentVersion` exceeds a store's docs, the platform runs the chain in the sandbox. Docs older
|
|
1094
|
+
than `minContentVersion` are first brought up through the **previously installed artifact's**
|
|
1095
|
+
chain (platform-chained; storefront-control keeps the artifact history per installation), then
|
|
1096
|
+
this artifact's. Output = new ContentRevision; failure blocks install, not shoppers.
|
|
1097
|
+
When absent, `minContentVersion` equals `contentVersion`; no migration chain is required.
|
|
1098
|
+
`minContentVersion > contentVersion` is invalid (`migration_range_invalid`).
|
|
1099
|
+
|
|
1100
|
+
## 8. Limits — one table, two columns (A4)
|
|
1101
|
+
|
|
1102
|
+
Baseline (nova `feat/nova-ui-rebuild`, 2026-10-06): SSR **1,667,801 B raw / 312,273 gz /
|
|
1103
|
+
231,538 br**; client initial ≈ **296 KB gz** (entry 145 + react 67 + tanqory 47 + CSS 37);
|
|
1104
|
+
manifest 218 KB; 86 components; 19 templates; max 31 attrs/component. PoC cold render on a
|
|
1105
|
+
laptop: 14–24 ms CPU.
|
|
1106
|
+
|
|
1107
|
+
One row per export key of `@tanqory/theme-abi/limits` (`Key` = the `LIMITS` property and the
|
|
1108
|
+
conformance-report measurement `name`). `—` = no value at that level.
|
|
1109
|
+
|
|
1110
|
+
| Key | Limit | Class | Budget (warn / slow flag) | Hard (kill / reject) | Rationale |
|
|
1111
|
+
|---|---|---|---|---|---|
|
|
1112
|
+
| `cpuPerRenderMs` | CPU per render | runtime | 50 ms (`slow_render`) | 200 ms — kill ⇒ LKG + `x-tq-degraded: lkg`, counts a breach | PoC 14–24 ms; 50 ms is the paid-Workers envelope; 200 ms kills a loop before it hurts neighbours |
|
|
1113
|
+
| `moduleInitCpuMs` | Module init CPU | runtime | 100 ms → artifact flagged `slow_init` | 500 ms ⇒ quarantine | cold-start tax paid per isolate |
|
|
1114
|
+
| `wallClockPerRenderMs` | Wall-clock per render (serve) | runtime | 1500 ms | 5000 ms | covers data latency; `signal` aborts |
|
|
1115
|
+
| `wallClockPerRenderPreviewMs` | Wall-clock per render (preview) | runtime · preview-only | — | 15000 ms | editor canvas; measured by suite 6 |
|
|
1116
|
+
| `memoryBytes` | Isolate memory | runtime · rule-6 exempt | — | 128 MiB | Workers fixed; enforced by the runtime isolate limit |
|
|
1117
|
+
| `responseBytes` | Response bytes (streamed, counted) | runtime | 512 KiB | 2 MiB | cache stores ≤ 2 MiB |
|
|
1118
|
+
| `dataCallsPerRender` | Data calls per render | runtime | 1 | 4 | `snapshot()` + sidebar + recommendations |
|
|
1119
|
+
| `subRequestsPerRender` | Sub-requests per render (data + `network:` + asset probes) | runtime | 2 | 8 | security §2.4 |
|
|
1120
|
+
| `sequentialRounds` | Sequential data rounds | runtime | 1 | 2 | each round is a cross-region hop |
|
|
1121
|
+
| `networkCalls` | `network:` calls per render | runtime | 0 | 2 | default none |
|
|
1122
|
+
| `logLinesPerRender` | Log lines per render | runtime | — | 50 | attribution without flooding |
|
|
1123
|
+
| `logBytesPerRender` | Log bytes per render | runtime | — | 8 KiB | |
|
|
1124
|
+
| `clientInitialTransferredGzipBytes` | Client initial bytes actually fetched by the hydrated page (gz) | runtime · budget-only | 250 KiB | — | measured by suite 5; mirrors the `clientInitialGzipBytes` budget; never refuses |
|
|
1125
|
+
| `ssrEntryRawBytes` | SSR `server/entry.mjs`, raw | static | 2 MiB | 4 MiB | 2.4× nova; parse time scales with raw |
|
|
1126
|
+
| `ssrEntryGzipBytes` | SSR `server/entry.mjs`, gzip | static | 512 KiB | 1 MiB | 3.3× nova |
|
|
1127
|
+
| `clientInitialGzipBytes` | Client initial (entry + imports + css, gz) | static | 250 KiB | 400 KiB | nova 296 KB; mobile LCP |
|
|
1128
|
+
| `clientTotalRawBytes` | Client total raw | static | 10 MiB | 25 MiB | storage |
|
|
1129
|
+
| `manifestBytes` | Manifest (incl. `x-*`) | static | 256 KiB | 512 KiB | editor loads it per open |
|
|
1130
|
+
| `components` | Components per manifest | static | 128 | 256 | nova 86 |
|
|
1131
|
+
| `attributesPerComponent` | Attributes per component | static | — | 64 | nova max 31 |
|
|
1132
|
+
| `presetsPerComponent` | Presets per component | static | — | 16 | nova max 6 |
|
|
1133
|
+
| `blocksPerNode` | Blocks per content node | static | — | 64 | editor ergonomics |
|
|
1134
|
+
| `nestingDepth` | Content-node nesting depth | static | — | 3 | editor ergonomics |
|
|
1135
|
+
| `templates` | Templates per manifest | static | — | 128 | |
|
|
1136
|
+
| `regions` | Regions per manifest | static | — | 32 | |
|
|
1137
|
+
| `customRoutes` | Custom routes per manifest | static | — | 16 | |
|
|
1138
|
+
| `forms` | Forms per manifest | static | — | 16 | |
|
|
1139
|
+
| `locales` | Locales per artifact | static | — | 50 | translates has 50 locales |
|
|
1140
|
+
| `networkCallTimeoutMs` | Per-call timeout for `network:` calls | host-policy | — | 2000 ms | enforced by the egress proxy |
|
|
1141
|
+
| `quarantineBreaches` | Breaches that trigger quarantine | host-policy | — | 20 | ⇒ `ROLLBACK kind=QUARANTINE` CAS to the control-chosen `lkgReleaseId` (SiteRelease §1.9), per `(store, releaseId)` — never "generation-1" (ADR-012) |
|
|
1142
|
+
| `quarantineWindowMs` | Window for the breach count | host-policy | — | 60000 ms | |
|
|
1143
|
+
| `quarantineErrorRatePercent` | Error rate that triggers quarantine | host-policy | — | 5 % | |
|
|
1144
|
+
| `quarantineErrorRateSampleRequests` | Sample size for the error-rate rule | host-policy | — | 500 | |
|
|
1145
|
+
|
|
1146
|
+
**Classes** (clarification from the strict signing gate):
|
|
1147
|
+
- **runtime** — measured by the conformance runner per render: CPU, wall-clock, memory,
|
|
1148
|
+
response size, data calls, sub-requests, rounds, `network:` calls, logs, module init, and
|
|
1149
|
+
suite 5's transferred client bytes. The edge also enforces the hard values while serving.
|
|
1150
|
+
- **static** — checked on the artifact's bytes and on manifest and content structure by
|
|
1151
|
+
`validateManifest()` / suite 1. The runner reports the measured maximum (for `blocksPerNode`
|
|
1152
|
+
and `nestingDepth`, the maximum across `defaults/` and presets) as a suite 1 measurement.
|
|
1153
|
+
- **host-policy** — enforced by the host/edge, not a property of the theme. Conformance never
|
|
1154
|
+
measures these rows, and a report omits them.
|
|
1155
|
+
|
|
1156
|
+
**CPU rows in conformance** (capability-security §4.4, v1.10.8). The runtime's own termination
|
|
1157
|
+
is the security control; conformance only checks that a theme fits. The figure is elapsed time
|
|
1158
|
+
measured inside the isolate. It is an upper bound on CPU, never an under-estimate, and may be
|
|
1159
|
+
used in exactly one way:
|
|
1160
|
+
- An **attempt** is a fresh isolate that renders every sample, including the first, cold one.
|
|
1161
|
+
- A row passes if, **in at least one of at most three attempts, every sample is within the
|
|
1162
|
+
limit**. It is never the best single sample taken across attempts.
|
|
1163
|
+
- The check report records the method, every sample of every attempt, the attempt count and the
|
|
1164
|
+
machine's load average, and states that the value is an upper bound.
|
|
1165
|
+
|
|
1166
|
+
The rule in full (clarification, Cpu-1):
|
|
1167
|
+
|
|
1168
|
+
| Question | Answer |
|
|
1169
|
+
|---|---|
|
|
1170
|
+
| What is a sample | One elapsed-time figure per counted render (`cpuPerRenderMs`) or per isolate start (`moduleInitCpuMs`), measured inside the isolate |
|
|
1171
|
+
| Which samples | Every counted render of the run, in order, in every isolate the run uses. The first render of each isolate is the **cold** sample and is a sample like any other. No sample is dropped, and none is weighted |
|
|
1172
|
+
| Statistic | None. There is no p95, mean or minimum. The test is "every sample of one attempt ≤ the hard limit" |
|
|
1173
|
+
| The **only** breach | In none of the attempts was every sample within the limit. The row is then `hard_exceeded` and suite 4 fails on the check for that row |
|
|
1174
|
+
| What is **not** a breach | One sample over the limit, cold or warm, in an attempt that is followed by a passing attempt |
|
|
1175
|
+
| Retries | Attempt 1 is the run itself. Another attempt is made only when a CPU row has no passing attempt yet: fresh isolates, the same renders in the same order. At most three attempts in total. The **whole** sample set is repeated, never one render |
|
|
1176
|
+
| A render that never answers | The runner kills the isolate. That sample is the time at which it gave up, which is over any limit, and it ends that isolate's part of the attempt. This is the one case in which the runner itself reports `limit_cpu` for a render |
|
|
1177
|
+
|
|
1178
|
+
- **Elapsed time never produces `limit_cpu` by itself.** Neither the shim nor the runner turns a
|
|
1179
|
+
slow sample into an ABI error value, and suite 4's per-render breach check (a render answered
|
|
1180
|
+
with a `limit_*` value) never fires for a render that completed. A render that took 279 ms and
|
|
1181
|
+
returned its page is a sample of 279 ms and nothing else. Deciding per render would bypass the
|
|
1182
|
+
attempt rule, because that check has no retry.
|
|
1183
|
+
- **The cold sample is inside every attempt**, by definition. A theme whose first render is over
|
|
1184
|
+
the limit every time therefore fails, and should: its first request on every new isolate
|
|
1185
|
+
would be over the limit in production too. A theme whose first render is over the limit once,
|
|
1186
|
+
from machine noise, passes on the next attempt.
|
|
1187
|
+
- **What the verdict means on a runtime that meters CPU.** Workers for Platforms enforces the
|
|
1188
|
+
real per-request CPU limit itself; that is the control, here and in security §4.4. The
|
|
1189
|
+
conformance verdict is a fit check made with an upper bound. Passing it does not promise that
|
|
1190
|
+
the runtime will never stop a render, and a render stopped in production is served from LKG
|
|
1191
|
+
(the `cpuPerRenderMs` row). A runtime that reports true CPU time uses that figure for these
|
|
1192
|
+
two rows instead of elapsed time, with the same table.
|
|
1193
|
+
- **What the report says.** `runner.cpuMeasurement` and each CPU row's `method` are
|
|
1194
|
+
`wall-clock-in-isolate`. That value names this rule and no other; a different rule needs a
|
|
1195
|
+
different value and therefore a new report format.
|
|
1196
|
+
|
|
1197
|
+
This applies to `cpuPerRenderMs` and `moduleInitCpuMs`. It changes how two rows are measured,
|
|
1198
|
+
not the table, so `limitsVersion` is unchanged.
|
|
1199
|
+
|
|
1200
|
+
**Signing rule 6** (theme-artifact-v1 §2.1) applies to `runtime` and `static` rows only, with
|
|
1201
|
+
these exceptions:
|
|
1202
|
+
- `memoryBytes` keeps its exemption: it may be `not_measured` (runtime-enforced).
|
|
1203
|
+
- Preview-only rows (`wallClockPerRenderPreviewMs`) are exempt while suite 6 is `not_applicable`.
|
|
1204
|
+
- Budget-only rows (`clientInitialTransferredGzipBytes`) never refuse.
|
|
1205
|
+
|
|
1206
|
+
`host-policy` rows are never required in a report.
|
|
1207
|
+
|
|
1208
|
+
**Export shape** (`@tanqory/theme-abi/limits`; types in `render-context.v2.d.ts` `LimitSpec`):
|
|
1209
|
+
|
|
1210
|
+
```ts
|
|
1211
|
+
interface LimitSpec {
|
|
1212
|
+
readonly class: 'runtime' | 'static' | 'host-policy'
|
|
1213
|
+
readonly budget: number | null // warn / slow flag; null where the table shows "—"
|
|
1214
|
+
readonly hard: number | null // kill / reject; null ONLY for budget-only rows
|
|
1215
|
+
readonly profile?: 'preview' // preview-only rows (rule 6 exempt while suite 6 is n/a)
|
|
1216
|
+
readonly rule6Exempt?: true // memoryBytes only
|
|
1217
|
+
}
|
|
1218
|
+
LIMITS: { limitsVersion: '2.1' } & Record<LimitKey, LimitSpec>
|
|
1219
|
+
```
|
|
1220
|
+
|
|
1221
|
+
Units: time in ms, sizes in bytes, everything else a plain count or percent.
|
|
1222
|
+
|
|
1223
|
+
Nesting depth (blocks ≤ 3, `list` items ≤ 2), `browser ⊆ server`, and all referential-integrity
|
|
1224
|
+
rules are **semantic checks performed by `validateManifest()`** in `@tanqory/theme-abi`, not by
|
|
1225
|
+
the JSON Schema — JSON Schema cannot express recursion depth or cross-property subsets. The schema
|
|
1226
|
+
is necessary, not sufficient; `theme check` runs both.
|
|
1227
|
+
|
|
1228
|
+
Budget column = measured in build (`conformance-report.json`) and logged at runtime; hard column
|
|
1229
|
+
= rejected at build/load or killed at runtime. Clocks: `Date.now()` and `performance.now()`
|
|
1230
|
+
coarsened to 100 µs; `now()` frozen per request as a conformance guarantee (§2). Same table
|
|
1231
|
+
exported from `@tanqory/theme-abi/limits`; hard values may only move up within a major. Adding a
|
|
1232
|
+
row, or changing a row's `class`, bumps `limitsVersion`. **Current: `'2.1'`** (Lim-1 added
|
|
1233
|
+
`clientInitialTransferredGzipBytes`). Hosts accept `{'2.1'}` only: no artifact was ever signed
|
|
1234
|
+
for production under `'2.0'`.
|
|
1235
|
+
|
|
1236
|
+
## 9. Versioning and compatibility
|
|
1237
|
+
|
|
1238
|
+
- ABI semver `major.minor`; theme declares `engines["tanqory-abi"]`. Gateway loads majors **N and
|
|
1239
|
+
N-1**; N-2 refused at load (`abi_unsupported`), pointer not flipped.
|
|
1240
|
+
- Minor bumps additive; a theme built for 2.3 runs on 2.7; reverse refused by `engines`.
|
|
1241
|
+
- **All field-type registry versions and all persisted-query sets within a major stay live** (S5).
|
|
1242
|
+
- Deprecation: one full minor with `theme.deprecation` logs; removal at next major; N-1 major
|
|
1243
|
+
supported ≥ 12 months after N ships.
|
|
1244
|
+
- Canvas protocol (§7.3): no version field. Within a major it changes only additively (a new
|
|
1245
|
+
message type, a new optional member); receivers ignore what they do not know. Renaming or
|
|
1246
|
+
removing a type or a member is a major change.
|
|
1247
|
+
|
|
1248
|
+
## 10. Conformance
|
|
1249
|
+
|
|
1250
|
+
`@tanqory/theme-conformance` is one runner, used on a laptop, in CI, in the builder Job, for
|
|
1251
|
+
preview smoke and for the production synthetic check, with the same limits table everywhere.
|
|
1252
|
+
|
|
1253
|
+
**One shim, the same bytes** (capability-security §4.2, v1.10.8; rulings B-17).
|
|
1254
|
+
|
|
1255
|
+
- **"Same shim" means the same bytes are executed.** Every render that a check report counts,
|
|
1256
|
+
for a suite verdict or for a measured row, is produced by the **production host shim**: the
|
|
1257
|
+
exact bytes the edge deploys, in the edge's own bundle form, identified by `shimDigest`. The
|
|
1258
|
+
check report names that digest (theme-artifact-v1 §2.1).
|
|
1259
|
+
- **Output parity is not certification.** Showing that another program renders the same output
|
|
1260
|
+
on fixture routes is a useful regression check. It says nothing about limits, clock use,
|
|
1261
|
+
retained state or personalisation under the production shim, and it certifies nothing.
|
|
1262
|
+
- **The shim has no test mode.** Whatever it counts or reports is always on, identical in
|
|
1263
|
+
certification and in production. It leaves the isolate only through a host channel that theme
|
|
1264
|
+
code cannot read and that the gateway strips. Anything else a suite needs is measured
|
|
1265
|
+
**outside** the isolate: at the caller, at the outbound and query stubs, at the log sink, in
|
|
1266
|
+
the browser.
|
|
1267
|
+
- **The host channel (Shim-7).** One response header, **`x-tq-host-record`**
|
|
1268
|
+
(`HOST_RECORD.header`), set by the shim on every response the isolate returns, after the
|
|
1269
|
+
theme's own headers, so a value the theme set under that name is replaced. Its value is
|
|
1270
|
+
`base64url(JCS(record))`, at most `HOST_RECORD.maxBytes` = 16384 characters. The record is an
|
|
1271
|
+
object with `"v": 1`; its members are the shim package's exported type `HostRecordV1`, and
|
|
1272
|
+
that type is the definition. Rules the ABI fixes for every version:
|
|
1273
|
+
- **Names and counts only.** The record carries names of globals, mechanism names, counters,
|
|
1274
|
+
elapsed times and ABI error codes. It never carries an argument, a URL, a header value, a
|
|
1275
|
+
cookie, data returned to the theme, or anything from another request.
|
|
1276
|
+
- **Per render, never shared.** A render's record holds only events attributed to that render
|
|
1277
|
+
(§11, "Events in overlapping renders"). Isolate-scope events go in the isolate part of the
|
|
1278
|
+
record, which carries counts only.
|
|
1279
|
+
- **Too large.** If the record would exceed the cap, the shim sends `{"v":1,"truncated":true}`
|
|
1280
|
+
plus the counters. A runner treats a truncated record as an incomplete result for that
|
|
1281
|
+
render; the edge counts it in a metric.
|
|
1282
|
+
- **Stripped by the gateway**, from the response the isolate returns, **before** the shared
|
|
1283
|
+
cache stores it and before anything reaches the shopper. A cached response therefore never
|
|
1284
|
+
carries a record, and a cache hit never replays one. Conformance reads the header at the
|
|
1285
|
+
same point, from the isolate's response.
|
|
1286
|
+
- **The theme cannot tell it is being certified.** A render that a check report counts is made
|
|
1287
|
+
with `ctx.mode` set to `'serve'`, or `'preview'` for suite 6, exactly as the edge sets it. It
|
|
1288
|
+
is never `'check'`. Fixture data comes from the query stub outside the isolate, not from a
|
|
1289
|
+
mode the theme can read. The value `'check'` stays in the type for now and is deprecated;
|
|
1290
|
+
removing it is a type change proposed separately.
|
|
1291
|
+
- **Runtime compatibility date.** The date decides part of the global surface, so it is part
|
|
1292
|
+
of what is certified. This document owns the value: **`2025-09-01`**, with no compatibility
|
|
1293
|
+
flags. `@tanqory/theme-abi` exports it as `RUNTIME_COMPATIBILITY_DATE` and
|
|
1294
|
+
`RUNTIME_COMPATIBILITY_FLAGS`. Conformance, build-service's check and the edge's theme
|
|
1295
|
+
isolates read those constants; none writes the date as a literal. The check report records
|
|
1296
|
+
the date, and the publisher refuses an artifact whose report records a different one. Why
|
|
1297
|
+
this date: Appendix C.4.
|
|
1298
|
+
- **Binding.** The publisher uploads an artifact with a shim only if that artifact's check
|
|
1299
|
+
report names that `shimDigest`. The signer refuses a production-key signature over a report
|
|
1300
|
+
that was not produced this way (theme-artifact-v1 §2.1 rules 9 to 11).
|
|
1301
|
+
- **Status of earlier results.** A result obtained on a harness that is a different program
|
|
1302
|
+
from the production shim certifies behaviour under that harness only. It may be described as
|
|
1303
|
+
"passed on the conformance harness" and as nothing more.
|
|
1304
|
+
|
|
1305
|
+
The global scope a theme sees, and how the shim removes everything else, is §11 and Appendix C.
|
|
1306
|
+
What the one shim must expose so that every suite can be measured on it is a design note,
|
|
1307
|
+
`_program/proposals/single-production-shim.md`; it is not part of this contract.
|
|
1308
|
+
|
|
1309
|
+
1. **Static** — artifact/manifest schemas; registry membership (incl. alias rejection); referential
|
|
1310
|
+
integrity (templates→regions→components→children/parents, routes→templates, preset settings
|
|
1311
|
+
keys must exist in the component's attributes — `preset_setting_unknown`, `forms` fields,
|
|
1312
|
+
`browser ⊆ server ∩ ceiling`, `minContentVersion ≤ contentVersion` —
|
|
1313
|
+
`migration_range_invalid`); entry has no `import`/`require`/
|
|
1314
|
+
`importScripts` and no free identifier outside §11; **golden snapshot** of the isolate's
|
|
1315
|
+
`globalThis` property list: every §11 allowed name present, no B-1 denied name *reachable*
|
|
1316
|
+
(deleted or tripwired — see preamble); the snapshot is recorded per runtime version;
|
|
1317
|
+
byte limits; client manifest digests match; `defaults/` validate; `locales/` parse.
|
|
1318
|
+
2. **Render fixtures** — every `routes[kind]` and template under `mode: 'check'`: expected
|
|
1319
|
+
status; per-content-type assertions (S6): `text/html` → parses, every `<script>`/`<link>` under
|
|
1320
|
+
`assets.base` with `integrity`, every `data-tq-node` ∈ doc, every executable inline `<script>`
|
|
1321
|
+
is matched by an asserted `x-tq-csp-script-hash` that is in the artifact's
|
|
1322
|
+
`inlineScriptHashes` (`<script type="application/json">` needs none), **no `style=""`
|
|
1323
|
+
attribute, every inline `<style>` matched by an `x-tq-csp-style-hash`** within the bounds
|
|
1324
|
+
(16 hashes, 16 KiB; over ⇒ `csp_style_budget`),
|
|
1325
|
+
every `<form data-tq-form>` names a declared form, posts same-origin and contains **no**
|
|
1326
|
+
`_tq_form`/`_tq_csrf` input (the gateway injects them), every
|
|
1327
|
+
`data-tq-slot="account"` iframe has an Account BFF `src` and the exact sandbox,
|
|
1328
|
+
`<html lang>` = `locale.language`, canonical present; `application/rss+xml`/`*xml` → well-formed
|
|
1329
|
+
XML; `image/svg+xml` → parses, no `<script>`; `application/json` → parses. Counters recorded.
|
|
1330
|
+
3. **No-network / no-ambient** — tripwires on `fetch` outside a declared `network:` origin,
|
|
1331
|
+
`WebSocket`, `caches`, `indexedDB`, `XMLHttpRequest`, `eval`/`new Function`, `Atomics`, Node
|
|
1332
|
+
globals, and on clock reads inside render (A4). The **clock reads** counted (H4) are exactly:
|
|
1333
|
+
`Date.now()`; `performance.now()`; `new Date()` with no argument; `Date()` called without
|
|
1334
|
+
`new`; and `Intl.DateTimeFormat.prototype.format` and `formatToParts` called with no
|
|
1335
|
+
argument or with `undefined`. Each of these reads the current time; no other built-in does
|
|
1336
|
+
under §11.
|
|
1337
|
+
4. **Limits** — every hard limit against fixtures: 5 renders per fixture, and the p95 is
|
|
1338
|
+
compared with the limit. The two CPU rows (`cpuPerRenderMs`, `moduleInitCpuMs`) use the
|
|
1339
|
+
attempt rule in §8 instead of a p95.
|
|
1340
|
+
5. **Hydration parity** — only when `client.hydration ≠ none` (S6). Headless Chromium: zero
|
|
1341
|
+
hydration-mismatch/console errors; no **script, style, connect (fetch/XHR/WebSocket/
|
|
1342
|
+
EventSource), frame or beacon** request outside `assets.base` and **`/_tq/*`** (A3).
|
|
1343
|
+
**Image, media and font** requests may go elsewhere only when the exact URL appears, for that
|
|
1344
|
+
render, in any of three independent sources: (a) `ctx.data` results; (b) `ctx.content.page`
|
|
1345
|
+
(component settings in the page doc); (c) **theme settings** — merchant-chosen `image`/`video`
|
|
1346
|
+
values in `ctx.content.settings` (the coordinator's "`ctx.settings`"; there is no separate
|
|
1347
|
+
top-level field). Consistent with `img/media/font-src https:` in §5 (independent assessment
|
|
1348
|
+
2026-10-07).
|
|
1349
|
+
Post-hydration DOM equals SSR DOM except for changes to **exactly these attributes**:
|
|
1350
|
+
`aria-*`, `data-hydrated`, `data-tq-hydrated`. Any other attribute change, or any element or
|
|
1351
|
+
text difference, fails suite 5.
|
|
1352
|
+
**Client slots** (Nova port, e.g. a consent banner shown after `/_tq/data/consent.get`): the
|
|
1353
|
+
server render may emit empty placeholders `<div data-tq-client-slot="<name>"></div>`. In the
|
|
1354
|
+
comparison the slot element itself must match (same tag, attributes and position), and its
|
|
1355
|
+
descendants are excluded. A slot that has server-rendered children fails. Client-inserted
|
|
1356
|
+
content anywhere outside a declared slot still fails.
|
|
1357
|
+
**Late requests and deferred work** (suite 5 implementation). The page is observed until a
|
|
1358
|
+
quiet window of **≥ 3 s** with no network activity. Recording then continues for a **2 s
|
|
1359
|
+
grace period**. Any request or CSP violation recorded after the window makes the page
|
|
1360
|
+
**`incomplete`**, or **`fail`** if that request is disallowed by the rules above, whatever
|
|
1361
|
+
API caused it. The runner also tracks deferred-scheduling APIs, and any callback still
|
|
1362
|
+
pending when the window closes makes the page `incomplete`: `setTimeout`/`setInterval`,
|
|
1363
|
+
`requestAnimationFrame`, `requestIdleCallback`, `AbortSignal.timeout`,
|
|
1364
|
+
`scheduler.postTask`/`scheduler.yield`, `MessageChannel` self-loops,
|
|
1365
|
+
`IntersectionObserver`/`ResizeObserver`/`MutationObserver` callbacks, animation, transition
|
|
1366
|
+
and media events, Web Animations, and `WebSocket`/`EventSource`. A `Worker` started by theme
|
|
1367
|
+
code is disallowed (§11) and fails.
|
|
1368
|
+
6. **Preview profile** — `renderSection` per component (first preset) ≤ 200 ms CPU; the §7.3
|
|
1369
|
+
canvas protocol in a headless browser: `tq:ready` arrives, and each content type the theme
|
|
1370
|
+
announces in `tq:canvas-live` changes the DOM when sent as `tq:canvas`, without a reload.
|
|
1371
|
+
7. **Determinism & cache safety** — two renders of one fixture are byte-identical (streaming
|
|
1372
|
+
collected) **on the same runtime version with the same `ctx`**; cross-runtime-version equality
|
|
1373
|
+
is NOT required — the output hash is recorded per runtime version in the report; then (B3) the fixture is rendered with **two header variants** per personalising
|
|
1374
|
+
key (`accept-language`, `user-agent`, `sec-ch-*`, `x-tq-country`, `hasCart`, `isSignedIn`,
|
|
1375
|
+
`consent`): if the bodies differ, the hint must carry that key in `vary` or `private`; a
|
|
1376
|
+
differing body with neither fails.
|
|
1377
|
+
8. **Negative** — hostile fixture artifacts (Node import, CPU loop, 10 MiB body, `set-cookie`,
|
|
1378
|
+
loosened CSP, `vary`-less personalisation, `actions.cart.addLines` in GET, browser cap listing
|
|
1379
|
+
`data:customer`) must be rejected. This suite tests the **runner itself** and is executed by
|
|
1380
|
+
the runner as a self-check before suites 1–7 and 9; its result is part of
|
|
1381
|
+
`conformance-report.json`. The **signer** does not re-run it. The runner's identity and
|
|
1382
|
+
version are recorded **inside the report** (`runner.name`, `runner.version`,
|
|
1383
|
+
`runner.gitHead`, `runner.workerd`, `runner.chromium`, plus `determinism.runtimeVersion`;
|
|
1384
|
+
the trusted-runner list keys on name + version + gitHead; shape and verifier rule in
|
|
1385
|
+
`theme-artifact-v1.md` §2.1) and bound to the statement only
|
|
1386
|
+
through the canonical predicate field `checkReportDigest` — there is no separate
|
|
1387
|
+
`predicate.conformance` field. The signer refuses a report whose runner build it does not
|
|
1388
|
+
trust.
|
|
1389
|
+
9. **A-then-B tenancy (A7/B4)** — one isolate renders store A fixtures then store B's (distinct
|
|
1390
|
+
unique markers); B's output, headers and log must contain no A-derived byte; module-level
|
|
1391
|
+
state is snapshotted before/after and must be structurally unchanged.
|
|
1392
|
+
|
|
1393
|
+
Output: `conformance-report.json` (every measured number, plus `runner.name`, `runner.version`,
|
|
1394
|
+
`runner.workerd`; shape and verifier rule in `theme-artifact-v1.md` §2.1; its digest is
|
|
1395
|
+
`checkReportDigest` in the signed statement). A suite may be `not_applicable` only as §10.5 and
|
|
1396
|
+
§10.6 allow (hydration `none`, no preview entry). The signer refuses to sign without a passing report from a trusted builder
|
|
1397
|
+
and a trusted runner build.
|
|
1398
|
+
|
|
1399
|
+
### Not provable here
|
|
1400
|
+
|
|
1401
|
+
Acceptance **A** (a theme other than Nova) is proved only by shipping a non-React theme through
|
|
1402
|
+
the suite — the suite proves the ABI is implementable, not that it is pleasant. **B/C/D**
|
|
1403
|
+
(developer/marketplace/AI onto the pipeline) are not conformance properties. **E** is proved by
|
|
1404
|
+
site-release's ledger tests, not here. **F/G** (edge latency, cost) need edge measurements.
|
|
1405
|
+
Security **H13–H19** (cluster isolation, builder egress, admission) are cluster tests the runner
|
|
1406
|
+
cannot execute. Suite 9 cannot prove absence of Spectre-class leakage — only absence of
|
|
1407
|
+
*programmatic* retention; the cross-tenant read risk is owner question §10.1 of the security
|
|
1408
|
+
contract.
|
|
1409
|
+
|
|
1410
|
+
## 11. Allowed globals in the isolate (exhaustive; S1)
|
|
1411
|
+
|
|
1412
|
+
**The allowlist is the boundary** (capability-security §4.2, v1.10.8). The exact, normative list
|
|
1413
|
+
of names is **Appendix C.1**. The production shim removes every other name before the theme
|
|
1414
|
+
module is evaluated, by the procedure in Appendix C.2. The two paragraphs below describe the
|
|
1415
|
+
same set in prose and add the per-object rules (`navigator`, `performance`, `crypto`, clocks);
|
|
1416
|
+
where prose and Appendix C.1 differ, Appendix C.1 wins.
|
|
1417
|
+
|
|
1418
|
+
**ECMAScript.** Exactly the 59 language names in Appendix C.1 are present, and they are
|
|
1419
|
+
unmodified apart from the clock rule below. This paragraph does not repeat the list, so that it
|
|
1420
|
+
cannot disagree with it. In groups: the value names (`globalThis`, `undefined`, `NaN`,
|
|
1421
|
+
`Infinity`); `Object`, `Boolean`, `Symbol`, `Number`, `BigInt`, `String`, `RegExp`, `Array`,
|
|
1422
|
+
`Math`, `Date`, `JSON`, `Reflect`, `Proxy`, `Promise`, `Iterator`, `Intl`; every error
|
|
1423
|
+
constructor including `AggregateError` and `SuppressedError`; `ArrayBuffer`, `DataView` and the
|
|
1424
|
+
twelve typed arrays, `Float16Array` among them; `Map`, `Set`, `WeakMap`, `WeakSet`;
|
|
1425
|
+
`DisposableStack` and `AsyncDisposableStack`; and the ten global functions (`parseInt` …
|
|
1426
|
+
`unescape`).
|
|
1427
|
+
|
|
1428
|
+
**`Function` is not allowed.** The name `Function` is not on the list and is removed like any
|
|
1429
|
+
other unlisted name. `eval` is removed too. The constructor is also unusable by every other
|
|
1430
|
+
route: it can still be reached as `(function () {}).constructor`, but the runtime has code
|
|
1431
|
+
generation from strings disabled, so calling it throws. Functions themselves, and
|
|
1432
|
+
`Function.prototype` methods such as `call`, `apply` and `bind`, work normally.
|
|
1433
|
+
|
|
1434
|
+
**Other language intrinsics that are removed.** `SharedArrayBuffer`, `Atomics`, `WebAssembly`,
|
|
1435
|
+
`WeakRef` and `FinalizationRegistry` are part of the language or shipped with it, and none is on
|
|
1436
|
+
the list.
|
|
1437
|
+
|
|
1438
|
+
`Date`, and `Date.now()`, are present but coarsened to 100 µs.
|
|
1439
|
+
|
|
1440
|
+
**Web Platform (exactly this set).** `console` (→ `ctx.log`, sampled); `queueMicrotask setTimeout
|
|
1441
|
+
clearTimeout setInterval clearInterval` (clamped to the wall budget); `structuredClone atob btoa`;
|
|
1442
|
+
`TextEncoder TextDecoder TextEncoderStream TextDecoderStream`; `URL URLSearchParams`; `Headers
|
|
1443
|
+
Request Response FormData Blob File`; `ReadableStream ReadableStreamDefaultReader
|
|
1444
|
+
ReadableStreamBYOBReader WritableStream WritableStreamDefaultWriter TransformStream
|
|
1445
|
+
ByteLengthQueuingStrategy CountQueuingStrategy CompressionStream DecompressionStream`;
|
|
1446
|
+
`AbortController AbortSignal`; `Event EventTarget CustomEvent`; `DOMException`; `crypto`
|
|
1447
|
+
(`getRandomValues`, `randomUUID`, `subtle.digest`, `subtle.importKey`/`sign`/`verify` for
|
|
1448
|
+
HMAC with theme-generated keys only; see "How `crypto` is reduced" below); `performance.now()` (coarsened to 100 µs);
|
|
1449
|
+
`navigator.userAgent === "Tanqory-Theme/2"` (no other `navigator` member).
|
|
1450
|
+
|
|
1451
|
+
**How `crypto` is reduced (Shim-7).** The shim replaces `globalThis.crypto` with a frozen object
|
|
1452
|
+
whose only members are `getRandomValues`, `randomUUID` and `subtle`; `subtle` is a frozen
|
|
1453
|
+
object whose only members are `digest`, `importKey`, `sign` and `verify`. `digest` accepts
|
|
1454
|
+
`SHA-1`, `SHA-256`, `SHA-384` and `SHA-512` only. `importKey` accepts only format `'raw'` with
|
|
1455
|
+
algorithm `HMAC`; `sign` and `verify` accept only `HMAC` keys from that `importKey`. Anything
|
|
1456
|
+
else rejects the returned promise with a `DOMException` named `NotSupportedError`, as WebCrypto
|
|
1457
|
+
does for an unsupported algorithm; nothing throws synchronously. Every other member is absent
|
|
1458
|
+
(`typeof crypto.subtle.encrypt === 'undefined'`). The reduction is not new: this section always
|
|
1459
|
+
said so, and no shim has enforced it yet. It is in scope for v2.
|
|
1460
|
+
|
|
1461
|
+
**Events in overlapping renders (Shim-7).** One isolate may run several renders at once, for
|
|
1462
|
+
several stores. Tripwire hits (H3), clock reads (H4), pending timers (H9) and every other
|
|
1463
|
+
counted event are attributed as follows:
|
|
1464
|
+
- To a render only when it is the **only** render active in the isolate at the moment of the
|
|
1465
|
+
event. A timer counts toward the render that was the only active one when the timer was
|
|
1466
|
+
created.
|
|
1467
|
+
- Otherwise, and for every event outside any render (module top level, a timer that outlived
|
|
1468
|
+
its render), to the **isolate**. An isolate-scope event appears as a count in the isolate
|
|
1469
|
+
part of the next record the shim sends, never inside a render's part.
|
|
1470
|
+
- An event is never attributed to more than one render, and never to a render that did not
|
|
1471
|
+
cause it.
|
|
1472
|
+
- Certification renders one request at a time, so there every event is attributed. Suite 3
|
|
1473
|
+
fails on an isolate-scope tripwire hit as it does on a render-scope one: it is the theme's
|
|
1474
|
+
code either way.
|
|
1475
|
+
|
|
1476
|
+
**Everything else is absent.** For a name that is not in Appendix C.1, `typeof name` is
|
|
1477
|
+
`'undefined'` in theme code. The shim achieves that by deleting the name, by shadowing it, or,
|
|
1478
|
+
for the egress names, with a **tripwire**: an accessor that **records** the access and
|
|
1479
|
+
**returns `undefined`**, so a call such as `fetch(…)` fails with `TypeError` and the attempt
|
|
1480
|
+
is still seen. A throwing getter is **non-conforming**, because it breaks `typeof` feature
|
|
1481
|
+
detection. The shim reports which mechanism applied to each name (Appendix C.2).
|
|
1482
|
+
|
|
1483
|
+
**Absent** (security B-1 deny list, adopted verbatim; presence fails `theme check` and the
|
|
1484
|
+
conformance golden snapshot): `SharedArrayBuffer`, `Atomics`, `WebAssembly` (namespace and every
|
|
1485
|
+
member), `performance.measureUserAgentSpecificMemory`, `eval`, `Function` constructor (code
|
|
1486
|
+
generation from strings disabled), `fetch`, `WebSocket`, `XMLHttpRequest`, `EventSource`,
|
|
1487
|
+
`navigator.sendBeacon`, `caches`, `process`, `require`, `module`, `Buffer`, `__dirname`,
|
|
1488
|
+
`__filename`. Additionally absent under this ABI (stricter than B-1): `indexedDB localStorage
|
|
1489
|
+
Worker importScripts setImmediate global Deno Bun scheduler WeakRef FinalizationRegistry`, every
|
|
1490
|
+
Node builtin, every Cloudflare binding (`env`, KV/R2/D1, `caches.default`). A `network:<origin>`
|
|
1491
|
+
capability injects a proxy shim under the name `fetch` restricted to the declared origins —
|
|
1492
|
+
without it the identifier does not exist.
|
|
1493
|
+
|
|
1494
|
+
**Dynamic `import()`.** B-1 permits relative specifiers resolving to `predicate.files`. This ABI
|
|
1495
|
+
is stricter: `server/entry.mjs` is a single file and **any** `import()` fails suite 1 at build and
|
|
1496
|
+
the loader's static scan at load; `data:`, `blob:`, `http(s):` and bare specifiers would reject
|
|
1497
|
+
with `TypeError` at runtime as a last line. Code-splitting the server bundle is a 2.x candidate
|
|
1498
|
+
once the loader verifies multi-file predicates.
|
|
1499
|
+
|
|
1500
|
+
**Not portable, therefore not ABI:** `HTMLRewriter` (Cloudflare-only) — a theme that needs
|
|
1501
|
+
streaming HTML rewriting bundles its own; `URLPattern` (absent on Fastly) — not guaranteed, bundle
|
|
1502
|
+
a polyfill; **`WebAssembly`** — `WebAssembly.compile(bytes)` is blocked on Workers and module
|
|
1503
|
+
imports are banned by this ABI, so WASM has **no supported path in v2.0**; a theme referencing
|
|
1504
|
+
`WebAssembly` fails suite 1. Revisit if the runtime vendor (D2) exposes a sanctioned module form.
|
|
1505
|
+
No `env`, no `import.meta.env`.
|
|
1506
|
+
|
|
1507
|
+
**`then`-lock (normative; owner approval 2026-10-07).** Before evaluating theme code, the host
|
|
1508
|
+
defines `Object.prototype.then` as a non-writable, non-configurable data property whose value is
|
|
1509
|
+
`undefined`, and freezes `Promise.prototype.then` and `Promise.prototype.constructor`.
|
|
1510
|
+
Consequences for themes: `'then' in {}` is `true`; assigning `obj.then = …` on an object without
|
|
1511
|
+
an own `then` throws `TypeError` (theme modules are ESM, so always strict); Promise resolution of
|
|
1512
|
+
plain objects is unchanged (`then` reads as `undefined`, so they are not thenables). Defining an
|
|
1513
|
+
own `then` is unaffected: object literals `{ then() {} }`, class methods, `Object.defineProperty`
|
|
1514
|
+
and `JSON.parse` still create one. The conformance runner and every production loader apply it
|
|
1515
|
+
identically, before module evaluation, and suite 1's golden snapshot records it.
|
|
1516
|
+
|
|
1517
|
+
## Open questions (owner)
|
|
1518
|
+
|
|
1519
|
+
1. **Streaming vs cached byte-identity.** Streaming SSR and the cache coexist only if the
|
|
1520
|
+
gateway buffers up to 2 MiB per render while streaming to the client. Accept the edge memory
|
|
1521
|
+
cost, or cache only from the first complete render?
|
|
1522
|
+
2. **`network:` in 2.0 at all.** It is an exfiltration channel and makes "store-api out of the
|
|
1523
|
+
hot path" unprovable for themes that use it. Ship 2.0 with none and revisit in 2.1?
|
|
1524
|
+
3. **Customer data ceiling (D6).** The redacted profile is the conservative default (A2). Do
|
|
1525
|
+
themes ever get orders/addresses, or is `/account*` permanently Account-BFF-rendered?
|
|
1526
|
+
4. **CPU budget vs vendor (D2).** 50/200 ms assumes paid Workers / self-hosted workerd. On WfP
|
|
1527
|
+
the per-script `cpu_ms` is billed; confirm after PoC 1 reports nova p95 under load.
|
|
1528
|
+
5. **Query API surface ownership.** Does E accept the `DataClient` method list as the v2.0
|
|
1529
|
+
persisted-query set, or return a counter-list before builder fixtures are written?
|
|
1530
|
+
|
|
1531
|
+
---
|
|
1532
|
+
|
|
1533
|
+
## Appendix A — Review resolution
|
|
1534
|
+
|
|
1535
|
+
| Item | Resolution | Where |
|
|
1536
|
+
|---|---|---|
|
|
1537
|
+
| A1 | Capability set = `Capability` type; manifest `capabilities: {server, browser}` (both REQUIRED, B-6); browser ceiling encoded in schema pattern; `network:` only egress | `.d.ts`, schema, §4, §7.1 |
|
|
1538
|
+
| A2 | `customer.current()` redacted; `orders/order/addresses` removed; `/account*` intercepted; `loginUrl/logoutUrl/accountUrl` are `data` getters; marked conservative default pending D6 | `.d.ts`, §3, OQ3 |
|
|
1539
|
+
| A3 | GET render: only `cart.create`, `consent.set`; `/_tq/actions/*`, `/_tq/data/<op>`; suite 5 allows `/_tq/*` | §4, §10.5 |
|
|
1540
|
+
| A4 | Two-column table; CPU 50/200; init 100 slow_init / 500 quarantine; sub-requests 8; N=20/60 s; `performance.now` 100 µs; `now()` conformance guarantee stated | §8, §2 |
|
|
1541
|
+
| A5 | DSSE statement + provenance, CycloneDX SBOM, six-field `inputDigest`, leaves from `predicate.files`, `signature.json`/SPDX removed | `theme-artifact-v1.md`, artifact schema |
|
|
1542
|
+
| A6 | Preview-token subject = channel id; `capPlan` from signed projection; `rel:<host>` is site-release's | §2 (capabilities row), §7.3 |
|
|
1543
|
+
| A7/B4 | Multi-tenant isolates allowed; retention forbidden; suite 9 A-then-B | §1, §10.9, `.d.ts` |
|
|
1544
|
+
| B3 | Request constructed from allowlist; `CacheHint.vary ⊆ VaryKey`; personalisation marking forces `private` unless varied; suite 7 two-variant check | §2, §5, §10.7, `.d.ts` |
|
|
1545
|
+
| B5 | Measurements moved to `conformance-report.json`; `artifact.json` keeps `{limitsVersion, passed}`; same-digest put is a no-op | artifact schema, `theme-artifact-v1.md` §2, §5 |
|
|
1546
|
+
| B6 | Gateway enforces Origin/Sec-Fetch-Site + double-submit on every POST; `actions` undefined otherwise | §1, `.d.ts` |
|
|
1547
|
+
| S1 | HTMLRewriter/URLPattern non-ABI; WASM unsupported; WeakRef/FinalizationRegistry removed; runner pins compat date and deletes leaked Node globals | §10 preamble, §11 |
|
|
1548
|
+
| S3 | `manifest` export == file bytes minus trailing LF; loader compares after evaluation | §1, `theme-artifact-v1.md` §7 |
|
|
1549
|
+
| S4 | Smoke render no longer gates the flip; `rev:<digest>` check; size caps before parsing | `theme-artifact-v1.md` §7 |
|
|
1550
|
+
| S5 | All registry versions + query sets within a major; `minContentVersion` + platform-chained migration | §7.4, §9, schema |
|
|
1551
|
+
| S6 | `isEntry` 0–1; `client.hydration`; per-content-type suite 2; rss/atom/svg content types | §2.2, §5, §7.1, §10 |
|
|
1552
|
+
| S7 | Round defined; breach-after-first-byte semantics; `loginUrl` on `data` | §3 |
|
|
1553
|
+
| Suggestions | `json` + `list` field types; `buildFlags.preview` required; "Not provable here" | §7.4, artifact schema, §10 |
|
|
1554
|
+
| Sec. B-1 | Deny list adopted verbatim; `Date.now()` coarsened (supersedes "frozen"); golden `globalThis` snapshot in suite 1. Stricter here: no dynamic `import()` at all; suite 3 forbids clock calls in render | §11, §2, §10.1 |
|
|
1555
|
+
| Sec. B-2 | Header allowlist adopted verbatim (`sec-ch-prefers-color-scheme`, `save-data` dropped); `data.cart`/`data.customer` reads mark personalised; hint may only tighten; `VaryKey` ⊆ allowlist | §2, §5, `.d.ts` |
|
|
1556
|
+
| Sec. B-6 | `browser` required and explicit; `browser ⊆ server`; statement copy is authoritative | §7.1, schema, `.d.ts` |
|
|
1557
|
+
| Seam 1 | Quarantine target = control-chosen `lkgReleaseId` (SiteRelease §1.9), "generation-1" removed | §8 |
|
|
1558
|
+
| Seam 3 / A10 | Browser ceiling: `data:metaobjects` excluded, `data:products.recommendations` included — schema pattern, `BrowserCapability`, §3/§4 prose; negative sample verified | §3, §4, schema, `.d.ts` |
|
|
1559
|
+
| Re-review | Preview intersection = security `PREVIEW_CAPS` (adds `data:products.recommendations`, `preview:renderSection`); `403 preview_channel`; `data:products.recommendations` stays opt-in (§3) | §7.3, §3 |
|
|
1560
|
+
| Conf-1 | §11 restructured: all ECMA-262 intrinsics allowed (only `eval`/`Function` ctor removed), Web Platform set enumerated exactly, everything else absent | §11 |
|
|
1561
|
+
| Conf-2 | Determinism = byte-identical on the SAME runtime version with the same ctx; cross-version not required; hash recorded per runtime version | §10.7, §10.1 |
|
|
1562
|
+
| Conf-3 (superseded by Impl-1) | Forbidden globals deleted where permitted, else tripwire; runner accepts either, records which (`globals.enforcement`) | §10 preamble, §10.1, §11 |
|
|
1563
|
+
| Conf-4 | Suite 8 executed by the runner as self-check; signer checks runner identity+version (in the report, see Impl-7), never re-runs it | §10.8 |
|
|
1564
|
+
| Impl-1 | Tripwire = recording accessor returning `undefined` (`typeof fetch === 'undefined'`, call ⇒ `TypeError`, matches security H43); throwing getter non-conforming | §10 preamble, §11 |
|
|
1565
|
+
| Impl-2 (superseded by Impl-7) | `predicate.conformance = {runner, version}`; report bound only by canonical `checkReportDigest` | §10.8 |
|
|
1566
|
+
| Impl-7 (field names superseded by E2E-2) | No `predicate.conformance` field (artifact §6 list stays authoritative). Runner identity is recorded in `conformance-report.json` as `runner.name`, `runner.version` and `runtimeVersion`, and bound to the statement through `checkReportDigest`. No behaviour change | §10, §10.8 |
|
|
1567
|
+
| IA-1 | Suite 5's off-origin ban covers only script, style, connect, frame and beacon requests. Image, media and font URLs are allowed when they appear in `ctx.data`, `ctx.content.page`, or theme settings (`ctx.content.settings`, which holds merchant `image`/`video` values; there is no top-level `ctx.settings`). Resolves the conflict with `img/media/font-src https:`. Source: independent assessment 2026-10-07; settings source per correction | §10.5 |
|
|
1568
|
+
| IA-2 | `snapshot()` never throws for a denied capability: the resource is `null` (search `result: null`), denied refs are omitted, and `denied: Capability[]` is added. Source: independent assessment 2026-10-07 | §3, `.d.ts` `PageSnapshot` |
|
|
1569
|
+
| IA-3 | Registry 2: `text_alignment` adds `start`/`end`, plus optional `ctx.locale.direction`. Registry 1 is unchanged. Source: independent assessment 2026-10-07 | Appendix B.2, `.d.ts` `LocaleInfo` |
|
|
1570
|
+
| IA-4 | Browser wire encoding: `GET /_tq/data/<op>?a=<base64url(JCS(args))>`, POST `{v:2,args}`, a `WireEnvelope` with §6.4a codes. CSRF cookie/header names and token carriage are **TBD** pending an owner decision. Source: independent assessment 2026-10-07 | §4, `.d.ts` `Wire*` |
|
|
1571
|
+
| Then-lock (PROPOSAL, superseded by Owner-5) | §11 then-lock is recorded as a **proposal pending owner approval**, not normative. It would lock `Object.prototype.then` to `undefined` (non-writable, non-configurable) and freeze `Promise.prototype.then`/`.constructor`. This changes theme-observable behaviour (`'then' in {}`; strict-mode assignment throws), so it needs approval. No behaviour changes until approved | §11 |
|
|
1572
|
+
| Owner-1 (script-hash acceptance refined by Csp-2) | CSP adds `style-src-elem 'self' <asset origin> + style hashes` and `style-src-attr 'none'`. A per-render `x-tq-csp-style-hash` mirrors the script-hash path. There is **no `'unsafe-inline'`** anywhere, and theme `style=""` attributes are unsupported (use classes or CSS variables in a hashed `<style>`). Suite 2 asserts this. Source: owner approval 2026-10-07 | §5, §6, §10.2, `.d.ts` `CspHashHeader` |
|
|
1573
|
+
| Owner-2 | Native forms: `<form data-tq-form="<id>">` with hidden `_tq_form`. The gateway parses urlencoded only, caps at 8 KB before the isolate, filters to `manifest.forms[id].fields`, and exposes `ctx.request.form`/`formId`; the raw body is never exposed. CSRF model specified (§4.1): `__Host-tq-csrf` HttpOnly cookie secret, HMAC token bound to store+host, `x-tq-csrf` header, `<meta name="tq-csrf">` and hidden `_tq_csrf` injected after the cache, so renders are not personalised. Rate limits unchanged. Replaces the IA-4 TBD. Source: owner approval 2026-10-07 | §1, §2, §4.1, `.d.ts` `RequestInfo.form`, `CSRF`; schema `forms` |
|
|
1574
|
+
| Owner-3 | `asset:client/assets/<file>` is allowed for `image`/`video` in theme defaults and presets only (registry 2). It must be listed in `predicate.files` and is resolved against `assets.base` before render. Merchant content cannot use it. Source: owner approval 2026-10-07 | Appendix B URL rule, B.2; artifact §6 |
|
|
1575
|
+
| Owner-4 | (a) Account slot `<iframe data-tq-slot="account" sandbox="allow-scripts allow-forms allow-same-origin">` from the Account BFF origin; `frame-src` lists only that origin, and PII never enters theme JS (D6 holds). (b) `GET /_tq/data/consent.get` → `ConsentState & {bannerRequired}`, browser-mintable under `actions:consent`, `private, no-store, Vary: Cookie`. Source: owner approval 2026-10-07 | §4, §5, `.d.ts` `ConsentReadout`, `accountUrl` |
|
|
1576
|
+
| Owner-5 | §11 then-lock promoted to normative text and applied identically by the runner and every loader. Source: owner approval 2026-10-07 | §11 |
|
|
1577
|
+
| Owner-2a | Clarification of Owner-2 (Nova port, additive, no new approval): the theme writes only `<form data-tq-form="<id>" method="post">`. The gateway injects both `_tq_form` and `_tq_csrf` after the cache, and suite 2 rejects a theme that renders either. On a verified POST both are stripped, `_tq_form` is validated, and `ctx.request.formId` and `ctx.request.form` are exposed. Both are absent on GET and on unverified POSTs | §1, §4.1, §10.2, `.d.ts` `RequestInfo`, `CSRF` |
|
|
1578
|
+
| Url-1 | Wording fix: a bare `/` (home page) is a valid site-relative `url`. Rule: exactly `/`, or `/` followed by a character that is neither `/` nor `\` | Appendix B URL rule, `url` row |
|
|
1579
|
+
| Hyd-1 | Additive clarification (suite 5 implementation): the hydration DOM comparison may differ only in `aria-*`, `data-hydrated` and `data-tq-hydrated` attributes. Any other attribute, element or text difference fails | §10.5 |
|
|
1580
|
+
| Hyd-2 | Additive clarification (Nova port): `<div data-tq-client-slot="<name>"></div>` placeholders. The slot element must match, its descendants are excluded, it must have no server-rendered children, and client-inserted content outside a slot still fails | §10.5 |
|
|
1581
|
+
| Hyd-3 | Suite 5 late-request rule: after the ≥ 3 s quiet window there is a 2 s grace period. A request or CSP violation recorded late makes the page `incomplete` (or `fail` if disallowed), whatever API caused it. Deferred-scheduling callbacks still pending at window close (the listed timer, observer, animation, media, socket and scheduler APIs) make it `incomplete`. A theme `Worker` fails. Source: suite 5 implementation | §10.5 |
|
|
1582
|
+
| Lim-1 | §8 table rebuilt as one row per export key, with a `Class` column (`runtime` / `static` / `host-policy`). Signing rule 6 covers runtime and static rows only. `memoryBytes` stays exempt, preview-only rows are exempt while suite 6 is n/a, and budget-only rows never refuse. `clientInitialTransferredGzipBytes` is added as a 250 KiB **budget only**. The orphaned Quarantine row now sits inside the table as four host-policy rows. The export gains `class` (and `profile`/`rule6Exempt`), and `hard` may be `null` for budget-only rows. The package's table-parsing test must follow the new header. Source: strict signing gate | §8, `.d.ts` `LimitSpec`, artifact §2.1 rule 6 |
|
|
1583
|
+
| Lim-2 | `limitsVersion` bumped `'2.0'` → `'2.1'` (Lim-1 added a row). Predicate and report examples now show `"2.1"`. The host's supported set is `{"2.1"}`: `"2.0"` is not accepted, because no artifact was ever signed for production under it | §8 / artifact §2.1, §6, §7 step 3 |
|
|
1584
|
+
| Csp-1 (WITHDRAWN, superseded by Csp-2) | The first edge-review ruling had the host compute inline hashes over emitted bytes. It was discarded: that would also bless injected inline blocks and defeat the CSP | — |
|
|
1585
|
+
| Csp-2 | Reconciles ABI §5 with capability-security :340 (corrected ruling; keeps owner approval #1). The host never hashes theme-emitted inline content. Scripts: a theme-asserted `x-tq-csp-script-hash` is accepted only if it is in the signed `predicate.inlineScriptHashes` (build-time list), so dynamic executable inline scripts are impossible and per-request data uses `<script type="application/json">`. Styles: theme-asserted `x-tq-csp-style-hash`, bounded by the host, never `unsafe-inline` (final bounds in Csp-3). External files: by origin, integrity from the signed file list. **Open consequence:** merchant pixels from `data.shop.pixels()` are dynamic executable code and can no longer be inlined by a theme; this needs a ruling (e.g. gateway-injected, platform-hashed). | §5, §6, §10.2, `.d.ts`; artifact §2, §6 |
|
|
1586
|
+
| Csp-3 | Security B-8/B-9. `predicate.inlineScriptHashes` holds CSP source expressions `'sha256-…'`; the header carries the bare token. Style-hash bounds are **fixed host constants** in §5, not limits rows (no `limitsVersion` bump): ≤ 16 hashes and ≤ 16 KiB inline `<style>` per render. Exceeding either is ABI error `csp_style_budget`. That code is **not yet in security's closed §6.4a list** (13 codes) and must be added there by the security owner | §5, §10.2, `.d.ts` |
|
|
1587
|
+
| Csp-4 | §5 now references the `src/inline/*.js` convention (theme-artifact-v1 §1.2) that produces `inlineScriptHashes` | §5 |
|
|
1588
|
+
| Act-1 | No `/_tq/actions/customer.*` and no customer-credential action. Login, logout, registration and recovery go only through the Account BFF (slot or `/account*`). `ctx.actions.customer` removed; its URL getters moved to `data.customer`. `customer.current` stays a read. `actions:customer` is a reserved id. Source: edge PR #10, ruling 1 (gap 3) | §4, `.d.ts` |
|
|
1589
|
+
| Act-2 | A native form POST with a bad CSRF token or bad origin is no longer `403`. It renders as a normal page with `ctx.request.formError: 'csrf' \| 'origin'`, no `form`/`formId`, `ctx.actions` undefined, response `private, no-store`. The no-cookie first visit is the `'csrf'` case. `415`/`413`/`400` stay platform pages. Source: ruling 2 (gaps 5, 8) | §1, §4.1, `.d.ts` `RequestInfo.formError` |
|
|
1590
|
+
| Act-3 | Form target defined: a form posts to the same path it was rendered on (no `action`, or `action` = current path). No other path or origin; suite 2 rejects it. A POST reaches the theme only as a form post. Source: ruling 3 (gap 6) | §1 |
|
|
1591
|
+
| Act-4 | Route resolution algorithm written out, in order: `/_tq/*` → platform-intercepted (`/checkout*`, `/orders*`, `/api/v1/*`) → manifest `customRoutes` (most literal segments, then key order) → built-in kinds, including `customer` for `/account*` (the theme-rendered account shell, Act-10) → `not_found`. Form POSTs follow the same order. Source: ruling 4 (gap 7), corrected by Act-10 | §2.1 |
|
|
1592
|
+
| Act-5 | `/_tq/actions/*` accepts only `actions:cart` and `actions:consent` ops. `actions:form.submit` is never browser-mintable; a browser call gets `403 not_browser_mintable` (new `WireErrorCode`, matching security H23). Source: ruling 5 (gaps 2, 4) | §4, `.d.ts` |
|
|
1593
|
+
| Act-6 | CSRF derivation is security's, by reference; this document's own formula is withdrawn. Only token carriage stays here. Source: ruling 6 (gap 1) | §4.1 |
|
|
1594
|
+
| Act-7 (cookie format superseded by B-14) | Platform cookies listed. `__Host-tq-cart` (opaque `<cartId>.<version>`) is defined here and backs `request.hasCart`. `__Host-tq-sid` and `__Host-tq-csrf` are security's, by reference. Source: ruling 7 (gap 11) | §2.3 |
|
|
1595
|
+
| Act-8 | HTML caching: hidden fields and the meta token are injected after the gateway's cache. The shared body is cacheable per `CacheHint`; the final browser response is `private, no-cache` when a token was injected. Source: ruling 8 (gap 10) | §4.1, §5 |
|
|
1596
|
+
| Act-9 | Smaller README gaps. Sizes are 8 KiB = 8192 bytes for both forms and actions (gap 9). `invalid_argument` covers `405`/`413`/`415`, told apart by status (gap 8). `form_field_forbidden` needs adding to security's §6.4a list (gap 13). Rate-limit windows stay with security (gap 12) | §1, §4, §4.1 |
|
|
1597
|
+
| Act-10 | Ruling on the `customer` route kind (owner-approved account-slot model, Owner-4/5). `/account` and `/account/*` are **not** platform-intercepted. They resolve to built-in kind `customer`: a theme-rendered shell holding `<iframe data-tq-slot="account">`, which never receives PII. The platform owns the BFF origin `account.<host-root>`. Resolution order: `/_tq/*` → intercepted (`/checkout*`, `/orders*`, `/api/v1/*`) → custom routes → built-in kinds incl. `customer` → `not_found`. `/orders*` and `/api/v1/*` stay intercepted (confirmed by the coordinator) | §2.1, §3, §4, `.d.ts`, schema |
|
|
1598
|
+
| Act-11 | §1 decision order for a non-GET/HEAD request on a theme route, as one numbered table: rate limit `429` → method / no declared forms `405` → content type `415` → origin (`formError: 'origin'`) → size `413` → CSRF (`formError: 'csrf'`) → undeclared form `400` → valid. Each status has one condition. The `405` case is now exact: a method other than `POST`, or a `POST` to a theme that declares no forms. Source: edge implementation of Act-1..10, item 1 | §1 |
|
|
1599
|
+
| Act-12 | Rate-limited native form post defined (security v1.9.4 §2.4, H60 g): `429`, `Retry-After`, platform page, `x-tq-error: rate_limited`, `private, no-store`, theme not called. It is not a `formError` render, so **no new `formError` value and no type change**. `/_tq/actions/*` uses the same `429` + `Retry-After` + `rate_limited`. Source: item 5 | §1 row 1, §4 |
|
|
1600
|
+
| Act-13 | Route params named: `route.params.tag` for `/blogs/<blog>/tagged/<tag>`, `route.params.path` for `/account/*`, plus `page` and `sort`. Handles stay in `route.handle` / `route.blogHandle`. Source: item 3 | §2.1, `.d.ts` `RouteInfo.params` |
|
|
1601
|
+
| Act-14 | Confirmed normative as written: the platform minimal shell for `/account*` when a theme has no `routes.customer`; and the cart cookie identity feeding `data.cart.*` as well as `actions.cart.*`. Source: item 4 | §2.1, §2.3 |
|
|
1602
|
+
| Act-15 (NOT EDITED — needs a decision) | Where the Account BFF origin comes from at the edge, and what the `data.customer` URL getters return when it is absent, is not defined in any contract. Both are real decisions, not clarifications: the source must be a trusted per-host value, and an "absent" result needs a type change (`string` → `string \| null`). No text was changed. Source: item 2 | §4 (unchanged) |
|
|
1603
|
+
| Cache-1 | `vary` defined. A `VaryKey` is cacheable only if its full value has a small fixed set of normal forms and the theme reads exactly that value. In 2.x that is `x-tq-country` alone (`country=<CC>` or `ZZ`), key suffix `\|v=…`, bounded at about 250 variants by construction. The five client-controlled headers stay accepted in the type but are not cacheable. `hasCart` / `isSignedIn` / `consent` are left to capability-security. No type change. Source: edge PR #11, gap 2 | §5.1, `.d.ts` `VaryKey` |
|
|
1604
|
+
| Cache-2 | `accept` and `traceparent` are personalising when read, like every allowlisted header except `host`, `x-tq-market`, `x-tq-locale`. Neither is a `VaryKey`. Source: gap 3 | §2 |
|
|
1605
|
+
| Cache-3 | `ctx.locale.country` is the same value as `x-tq-country` and reading it personalises, even where the gateway derives it from the keyed market; there the declared dimension is constant. Source: gap 4 | §2, §5.1 |
|
|
1606
|
+
| Cache-4 | Data marks the render by what the data plane answered, not by method name. `snapshot()` includes the cart only on route kind `cart` and the customer only on `customer`; elsewhere it is public. Any `ctx.actions` call, and a render with no read record, are personalised. Source: gap 5 | §2 |
|
|
1607
|
+
| Cache-5 | The §5 cache values are upper bounds. A 404 from the `not_found` template may be stored for up to 60 s; always answering non-200 with `no-store` is conformant (confirmed by Cache-11). `x-tq-cache` is `HIT` / `MISS`, absent on bypass. Source: gaps 6, 8, 9 | §5, §5.1 |
|
|
1608
|
+
| Cache-6 | `HEAD` may be answered from the stored `GET` entry; a `HEAD` miss renders and is not stored. Source: gap 10 | §5.1 |
|
|
1609
|
+
| Cache-7 (NOT EDITED — proposal) | Query strings. Written up as `_program/proposals/abi-canonical-query.md` for the owner; nothing changed here. Until it is approved a request with a query string bypasses the cache (security §4.6a). Source: gap 1 | — |
|
|
1610
|
+
| Cache-8 (404 part withdrawn by Cache-11) | Aligned with capability-security §4.6a, which owns what may be stored. The data-marking rule points at §4.6a's token-claim test instead of naming a mechanism | §5.1, §2 |
|
|
1611
|
+
| Cache-9 (stale bounds superseded by Cache-10) | Aligned with security §4.6a: `hasCart`, `isSignedIn` and `consent` are not key dimensions | §5.1 |
|
|
1612
|
+
| Cache-10 | Correction (security v1.9.7, site-release rev 4.13). §5.1 states only the normal bound, `s-maxage` + `swr`; degraded serving is governed solely by capability-security §4.6a and no bound is restated here. The variant suffix is written exactly as site-release §4.3.2 has it: `\|v=country=<CC>`, dimension name `country`; the key and the vary-record lookup are site-release's. Query strings: not cached, per site-release §4.3.2 | §5.1 |
|
|
1613
|
+
| Cache-11 | Two clarifications. (1) 404: this document and capability-security §4.6a (`:414`) now say the same thing: a `404` from the `not_found` template **may** be stored, up to `s-maxage=60`; the Cache-8 statement that it is never stored is withdrawn. (2) `CacheHint` clamps: hints may only tighten the defaults, so the effective clamps are `maxAge ≤ 60` and `swr ≤ 600`; the earlier `86400` / `604800` figures were never reachable and are removed from the text and the `.d.ts` comments. No type or schema change | §5, §5.1, `.d.ts` `CacheHint` |
|
|
1614
|
+
| Rte-1 | `page` and `sort` route params listed: query keys `page` and `sort`; `page` on `collection`, `collections`, `blog`, `search`; `sort` on `collection`, `search`. Confirms the edge's choice and matches the canonical-query proposal. Clarification | §2.1 |
|
|
1615
|
+
| X-5 | `dataVersion` wording from storefront-query-v1 §15: opaque read-model version; a data answer older than it makes the response not cacheable with `x-tq-degraded: data_stale`. Clarification | §3, `.d.ts` |
|
|
1616
|
+
| X-6 (table superseded by Deg-1) | `x-tq-degraded` gains `home_cell` and `data_stale`, owned by storefront-query-v1. New §5.2 lists every value with its owner. One deviation from the requested text: cache treatment is by reference to capability-security §4.6a (`private, no-store` for every degraded response) instead of `no-store` for `data_stale`. Clarification | §5.2 |
|
|
1617
|
+
| X-7 | `shop.pixels()` and `snapshot.pixels` are empty in ABI 2.x until the merchant-pixel decision. This also settles the open consequence recorded in Csp-2. Clarification, no type change | §3, `.d.ts` |
|
|
1618
|
+
| X-8 | `PageSnapshot.unavailable?: readonly ('cart' \| 'customer')[]` added. **Type change (additive, optional)** | §3, `.d.ts` |
|
|
1619
|
+
| X-10 | URL forms: `Policy.url` absolute on the primary domain; `MenuItem.url` origin-relative or absolute `https:`. Clarification | §3, `.d.ts` |
|
|
1620
|
+
| X-14 | Took the alternative the request offers: `shop`, `menus`, `pixels` are never null because `data:shop` is implicit, and `policy` / `metaobject` are not ref kinds. So no widening of those types; storefront-query-v1 §9.1 must be revised to match. A denied primary resource has every member null (`article` and `blog` both null). To make that true for the collections list, `collections` becomes nullable. **Type change (one field widened to `\| null`)** | §3, `.d.ts` |
|
|
1621
|
+
| X-18 | Themes format every amount from its own `Money.currencyCode`, never from `locale.currency`. Clarification of types, but a **new obligation on themes** that conformance will check | §2, `.d.ts` |
|
|
1622
|
+
| Redir-1 | Merchant URL redirects (site-release rev 4.14 §4.3.3). They are not a route-resolution step and never pre-empt steps 1–4. The gateway consults them only where the answer would otherwise be 404, and answers them itself. The theme never sees or controls one. Targets and status codes are capability-security's, by reference | §2.1 |
|
|
1623
|
+
| Deg-1 | One degraded-value list. Site-release §4.3.1 is the normative table (every value, one owner, a rank); §5.2 here is now a reference to it and keeps only the definition of this document's own value, `data_limit`. Supersedes the table added by X-6 | §5.2 |
|
|
1624
|
+
| B-14 | Security v1.10.2. `__Host-tq-cart` is `<cartId>.<version>.<tag>` with the tag defined by security §2.6; a cookie whose tag fails is treated as absent, so `request.hasCart` is false and no home-cell call is made. `Cart.id` is theme-visible and not a credential. `__Host-tq-sid` is gateway-minted and authenticated, by reference. §1 row 1 now cites security §2.4's order of checks, and every restated rate-limit number is removed. No type change | §1, §2, §2.3, §4, `.d.ts` comments |
|
|
1625
|
+
| Url-2 | URLs the theme receives are defined. `ctx.request.url` and `Request.url` are identical: `https://` + the canonical `rel:` host, no port, the resolved path, the query as received, no fragment. `ctx.route.canonical` is `https://` + canonical host + the route's one canonical path, with no query and no fragment. The `host` header is the canonical host. A non-production gateway may be configured with another public origin; nothing is read from the request. Clarification of the contract with no type change; the edge must change for non-canonical requests. Source: cache security review | §2.4, §2, `.d.ts` |
|
|
1626
|
+
| X-23 | `q` named as a route param for kind `search`: the query as received, decoded and untrimmed, absent when the key is absent. Shopper-controlled text; themes must escape it. The gateway does not shorten it; the data-plane bounds are storefront-query-v1 §1.3. Matches §2.4 and the canonical-query proposal's set. Documentation only, no type change | §2.1, `.d.ts` `RouteInfo.params` |
|
|
1627
|
+
| Jcs-1 | One canonical-JSON rule across contracts. JCS means RFC 8785, and a value with no canonical form (an unpaired surrogate in any string or key) is refused, never escaped. Defined in theme-artifact-v1 §4; this document refers to it for the manifest export, the field-type registry and the `/_tq/data` argument encoding | §4, artifact §4 |
|
|
1628
|
+
| Shim-1 | One shim, the same bytes (security v1.10.8, B-17). Every render a check report counts comes from the production shim bytes named by `shimDigest`; output parity is a regression check only; the shim has no test mode; earlier results are "passed on the conformance harness" and nothing more. **Behaviour change** for conformance, build-service and the edge | §10 |
|
|
1629
|
+
| Shim-2 | The allowlist is the boundary. Appendix C.1 is the exact list (99 names, the same set the runner already used); C.2 is the removal procedure, computed from the live global object up to but excluding `Object.prototype`; C.3 says a new runtime global is removed automatically. Clarification of §11, and a **behaviour change in the production shim**, which must remove about 50 names it leaves today (`WeakRef`, `FinalizationRegistry`, `self`, `MessageChannel`, `reportError` among them) | §11, Appendix C |
|
|
1630
|
+
| Shim-3 | Compatibility date ruled: `2025-09-01`, no flags, exported as `RUNTIME_COMPATIBILITY_DATE` / `RUNTIME_COMPATIBILITY_FLAGS`; no literal elsewhere; recorded in the check report; publisher refuses a mismatch. Measured reasoning in Appendix C.4. **New package exports**; conformance must move from `2026-10-01` | §10, Appendix C.4 |
|
|
1631
|
+
| Shim-4 | CPU rows keep the wall-clock proxy with the attempt rule: a fresh isolate per attempt, every sample including the cold one, pass if one of at most three attempts has every sample within the limit. Method, samples, attempt count and load average are recorded. `limitsVersion` unchanged. **Behaviour change** in conformance and the signing gate | §8, §10.4 |
|
|
1632
|
+
| Shim-6 | Consequence of "no test mode": a counted render uses `ctx.mode` `'serve'` or `'preview'`, never `'check'`, so a theme cannot detect certification. `'check'` is deprecated in the type; removing it is a **proposed type change**, not made here. **Behaviour change** for the conformance runner | §10, §3, `.d.ts` |
|
|
1633
|
+
| Shim-7 | Seven choices left by the production shim package (storefront-edge PR #34). (A) The six egress names are always tripwired accessors, present or not in the runtime; the setter records and discards. Adopted, with the setter rule added. (B) `globalThis.constructor` is removed; intended. (C) An event is attributed to a render only when it is the only active render, else to the isolate as a count; never to several renders. **Changed**: #34 attributed to every active render. (D) The 1.0 ms / 5 % bounds are not in any contract; they are design note §6.1's one-time decision rule, now given a measurement method there. (E) Clock reads counted are listed exactly, including argument-less `Intl.DateTimeFormat` format/formatToParts and `Date()` without `new`. Adopted. (F) Host channel: header `x-tq-host-record`, base64url(JCS), ≤ 16384, `{"v":1}` with members from the package's `HostRecordV1`; names and counts only; stripped by the gateway before the shared cache. (G) `crypto` reduction specified and in scope for v2. **Clarifications, plus one new constant (`HOST_RECORD`). ABI version unchanged.** | §10, §10.3, §11, C.2, `render-context.v2.d.ts` |
|
|
1634
|
+
| Glob-1 | §11 ECMAScript prose rewritten so it cannot disagree with Appendix C.1. It no longer lists `Function` as present: the name is removed, and the constructor is unusable by any route because code generation from strings is disabled. Checked name by name: the old prose also omitted `SuppressedError`, `DisposableStack`, `AsyncDisposableStack` and `Float16Array`, and wrongly said `Function` and `eval` were the only intrinsics removed. The Web Platform paragraph already matched. Clarification; the package already follows C.1 | §11 |
|
|
1635
|
+
| Client-1 | `@tanqory/theme-abi/client` is a real, optional deliverable of the ABI package, now specified: `createAbiClient`, `data`, `action`, `AbiWireError`, `encodeDataArgs`; it reads only the `tq-csrf` meta; sets only `x-tq-csrf`; refreshes the token once by re-fetching the current document; never retries `not_browser_mintable` or `rate_limited`; safe to include in a server bundle. Clarification of a named but undefined deliverable; **a new package export** | §4.2, §4, §4.1 |
|
|
1636
|
+
| Client-2 | Six differences between the §4.2 client (theme-abi PR #10) and the edge at `8ddd141`, plus the choices §4.2 left open. (1) `action` resolves with `ActionResult` = the envelope plus `status`, `retryAfterSeconds`, `csrfRefreshed`; client-side additions, the wire body is unchanged. **Type change, client.** (2) `budget_exhausted` is `429` with `Retry-After`, as the edge already does. **Clarification.** (3) The `a` cap is in the contract as `BROWSER_DATA_ARGS` (3072 bytes, 4096 characters), and the client refuses locally. **Type change (new constant); the limit itself is what the edge enforces.** (4) An origin failure stays `csrf_failed`; no new code. **Clarification; edge unchanged.** (5) Order of checks stated; still one refresh, never for an unchanged token, single-flight, surfaced as `csrfRefreshed`. **Behaviour change, client.** (6) The refresh fetches the document path with no query, `cache: 'no-store'`. **Behaviour change, client.** Open choices: remembered token wins until the page's tag changes (changed); no token, failed fetch or no `DOMParser` ⇒ no retry (adopted); non-JSON action arguments refused locally (adopted); unknown code ⇒ `invalid_envelope` (adopted, keeps `requestId`); abort rejects with the signal's reason, not `network` (changed); name shape checked locally (changed). `canonicalJson` becomes a public export, `@tanqory/theme-abi/jcs`. **Type change, package.** | §4, §4.1, §4.2, `render-context.v2.d.ts` |
|
|
1637
|
+
| Client-3 | Token endpoint adopted (security v1.10.12 §2.6, B-19, H87). `GET /_tq/csrf` with `x-tq-csrf-fetch: 1` is a reserved route; the name is confirmed as `CSRF.fetchHeader`, the route as `PLATFORM_ROUTES.csrf`, the success body as `CsrfTokenResponse`. The ABI adds the wire details security leaves open: failure bodies are the failure envelope, a query string is `400`, any other method is `405` with `allow: GET`. The client's one refresh is now a fetch of this endpoint. **The page fetch from Client-2 items 5 and 6 is withdrawn**: no document request, no `DOMParser`, no `location`, no fallback, and a `404` is a failed fetch. Kept from Client-2: no retry for an unchanged token, one shared fetch, `csrfRefreshed`, the first `csrf_failed` envelope returned when the fetch fails or is rate-limited, the remembered-token rule. New: first token from the meta tag; with no token at all the endpoint is fetched before the action. `Retry-After` on `429` now stated for `/_tq/data/*` as well, with the gateway's 60 when the data plane gives no figure. `ActionResult` and `AbiWireError` are unchanged. **Behaviour change (client, edge); type change (two constants, one type).** | §2.1, §4, §4.1, §4.2, `render-context.v2.d.ts` |
|
|
1638
|
+
| Client-4 | Five points the §4.2 text left open, as implemented in theme-abi PR #10; all adopted. (1) `csrfRefreshed` is set only when a token was obtained from the endpoint and then used; never after a failed fetch. (2) `undefined` in an array is refused: ruled in theme-artifact-v1 §4 (Jcs-2). (3) `retryAfterSeconds` is set whenever the returned response carries `Retry-After`, on `ActionResult` and `AbiWireError` alike. The client changes: it stops dropping the header on other codes. (4) Names are at most `WIRE_NAME_MAX_LENGTH` = 64 characters; longer is refused locally as `unknown_op`. (5) `data` never fetches a token. **Clarifications, plus one new package constant (4).** | §4.2, `render-context.v2.d.ts` |
|
|
1639
|
+
| Client-5 | Three items from the alpha.7 review. (1) `retryAfterSeconds`: digits only, a safe integer from 0 to `RETRY_AFTER_MAX_SECONDS` = 3600, larger values clamped, never `Infinity`. (2) `canonicalJson` refuses a cycle and nesting beyond 128: ruled in theme-artifact-v1 §4 (Jcs-3). (3) `redirect: 'error'` on the data GET and the action POST as well as the token fetch; a redirect is `network`. **Clarifications for the contract; client changes for all three; two new package constants (`RETRY_AFTER_MAX_SECONDS`, `CANONICAL_JSON_MAX_DEPTH`).** | §4.2, `render-context.v2.d.ts` |
|
|
1640
|
+
| Client-6 | Security v1.10.13 adopted (§2.4 order of checks, §2.6 sid rule, B-20, H88). The same-origin signal is checked **before** the rate limits on every `/_tq/*` route and on native form posts; a request without it is charged to nothing and sets no cookie. (1) `/_tq/data/*` now requires the signal: `403` `csrf_failed` without it. The client sends `x-tq-csrf-fetch: 1` on every `/_tq/` `GET`. `CSRF.fetchHeader` keeps its name; no alias. (2) §1 decision table rewritten: `405` first, then the origin row, then the rate limit; a "Limit charged?" column added. The `405` row is placed first by this contract so that every uncharged decision precedes every charged one; security's text does not state where `405` sits. (3) `GET /_tq/csrf`: refusal for a missing signal is decided before any limit. **Behaviour change (edge and client). No type or constant changes; two d.ts comments updated.** | §1, §4, §4.1, §4.2, `render-context.v2.d.ts` |
|
|
1641
|
+
| Cpu-1 | CPU rows in conformance spelled out after an intermittent `limits:breach:<route>` / `limit_cpu` on a 279 ms cold sample (storefront-edge e2e). Sample, statistic (none), the only breach, retries (whole sample set, fresh isolates, at most three), and the report value are stated as a table. New explicit sentence: elapsed time never produces `limit_cpu` by itself, and the per-render breach check never fires for a completed render. The cold sample stays inside every attempt. Relation to a runtime that meters CPU stated. **Clarification**: the rule of security §4.4 (v1.10.8) and Shim-4 is unchanged; no report format, schema or `limitsVersion` change. A runner that still derives `limit_cpu` from elapsed time is not conforming. | §8 |
|
|
1642
|
+
| Frame-1 | Aligned with capability-security §4.8 (B-10). Live hosts: `frame-ancestors 'none'` + `x-frame-options: DENY` (was `'self'`). Preview hosts: `frame-ancestors <editor origin(s)>` from platform config, no `x-frame-options`. `permissions-policy` is security's exact string, by reference. Themes cannot set or widen these | §5 |
|
|
1643
|
+
| E2E-2 | Runner identity fields renamed to what the runner emits: `runner.workerd` (+ `determinism.runtimeVersion`) replaces `runtimeVersion` in §10.8/§10. `not_applicable` is allowed only for suites 5/6. Verifier rule lives in artifact §2.1. Supersedes the field names in Impl-7. Source: e2e proof 2026-10-08, gap 2 | §10.8, §10 |
|
|
1644
|
+
| E2E-3 | `asset:` names the final self-hashed file (`<stem>-<h8><ext>`, artifact §1.1). The build never renames self-hashed files or rewrites references. Source: e2e proof 2026-10-08, gap 3 | Appendix B.2 |
|
|
1645
|
+
| PR4-2/3 | Runner identity includes `gitHead` and `chromium`, and the trusted list keys on name + version + gitHead. Client-manifest paths are relative to `client/` (see artifact PR4-3). Source: build-service PR #4 | §10.8, §2.2 |
|
|
1646
|
+
| Impl-5 | URL rule: `url` = `https:`/`http:`/`mailto:`/`tel:` or single-`/` path; `image` = `https:` only; trim + case-insensitive scheme; `//`, `javascript:`, `data:`, `vbscript:`, `file:` rejected; no asset-reference form in registry 1 | §7.4, Appendix B |
|
|
1647
|
+
| Impl-6 | Site-relative path = `/` + a character that is neither `/` nor `\` (`//host`, `/\host` rejected); `video` = `https:` only, same as `image` | §7.4, Appendix B |
|
|
1648
|
+
| Conf-5 | Canonical field-type registry v1 written as Appendix B; `@tanqory/theme-abi` exports `FIELD_TYPES_V1` equal to it | §7.4, Appendix B |
|
|
1649
|
+
| Asset-1 | Assets may be served from the shop's own origin (owner direction 2026-10-11: no separate assets domain, no digest in asset URLs). `assets.base` may be the §2.4 public origin; `/assets/*` joins `/_tq/*` as a reserved prefix on every storefront host (custom routes there are ignored by the gateway and rejected by `theme check`). An asset path resolves against the artifact of the host's **verified** current release — the `rel:` value from the document read path, same signature check, same pinned trust root, same host/channel binding — and gets the same hold, take-down, revocation (`rev:`, `rev:build:`) and LKG rules as a document; nothing else chooses the artifact. Preview releases are gated exactly like their pages and never `public`. File bytes are checked against the signed leaf. `immutable` (1 year) only on a `200` of a content-hashed `<stem>-<h8><ext>` name whose `<h8>` matches the signed sha256; other names `public, max-age=300` + strong ETag (the signed sha256); `If-None-Match` ⇒ `304` (no `immutable`), failed `If-Match` ⇒ `412`; `GET`/`HEAD` only. Type only from a closed extension list (never registry metadata); anything else, including `.html`/`.xml`, is `404`. Every answer carries the §5 security headers; `.svg` gets `default-src 'none'; style-src 'unsafe-inline'; sandbox`, other non-scripts `default-src 'none'; sandbox`. Replaces the canary hand-deploy (storefront-edge PR #37), which read `rel:<host>` unverified and served every file `immutable`. **Behaviour change** for the edge and `theme check`; no type change | §2, §2.1, §2.5, §5 |
|
|
1650
|
+
| Asset-2 | Workers and the cost of an asset answer (review of storefront-edge PR #40). (M1) Corrects Asset-1's reasoning, which was inverted: a response CSP on a script is ignored for `<script>`/`import` loads and **is** the policy of a worker started from it, so `.js`/`.mjs` answers now carry `default-src 'none'`. The document CSP adds `worker-src 'none'`, and a Service Worker script fetch (`Service-Worker: script` or `Sec-Fetch-Dest: serviceworker`) is refused `404` on every storefront path — theme routes and `/_tq/*` included, since a theme route answering JavaScript had the same exposure. (L1) Per-asset cap 4 MiB (largest per-file hard limit in §8; `clientTotalRawBytes` is an aggregate). Progressively painted types (jpg, jpeg, png, webp, avif, gif, ico, svg) are verified whole before the first byte (mismatch ⇒ `503`); every other type is verified by a streaming digest + length check with the last chunk withheld, so a same-size mismatch errors the body after a `200` head — no one holds a complete wrong file, though a browser may keep a truncated entry, which the edge never resumes with `Range`; a size mismatch, missing object or registry failure is still `503`. `HEAD` answers from the registry head alone. The edge publisher refuses an artifact with a `client/` file over the cap (`client_file_too_large`). Optional per-location cache of verified bytes keyed by artifact digest + signed sha256 + path, never host, re-verified on every read. **Behaviour change** for the edge and the conformance runner's CSP (`worker-src 'none'`); no type change | §2.5, §5 |
|
|
1651
|
+
| Locale-1 | The gateway resolved every store as `US` / `USD` / the visitor's `Accept-Language` (live QA 2026-10-16: a Thai store answered `<html lang="en">`; the country switch did nothing). `ctx.locale` now comes from the store's markets and languages (storefront-query rev 2.23 §8.8); the shopper's choice is `?country=` / `?locale=` and is kept in `__Host-tq-loc`; optional `LocaleInfo.markets` lets a selector show each country's currency. Additive (alpha.12): no existing field changed meaning | §2, `.d.ts` `LocaleInfo.markets`, `MarketInfo` |
|
|
1652
|
+
| Seo-1 | Optional `Shop.seo {title?, description?, image?}` — the home page's SEO settings (Online store → Preferences), projected by storefront-query rev 2.23 §2.2.1; each member only when set. Themes fall back to their own store data when absent. Additive (alpha.12) | §3, `.d.ts` `Shop.seo`, `ShopSeo` |
|
|
1653
|
+
| Cart-1 | A cart write refused by the merchant's add-to-cart limit ("Maximum quantity per product") reached the theme as a bare `reason: 'invalid'`, so a storefront could only say "invalid" (QA run-4, 2026-10-18). Optional `refusal?: CartRefusal {code: 'cart_limit_exceeded', maxQuantity?}` on the `invalid` `CartResult` and on the `cart.create` failure envelope (`error.refusal`). No free text: the theme localises. Unknown codes are ignored. Additive (alpha.13): no existing field changed meaning; `CartResult`'s third member is split into `invalid` (which may carry `refusal`) and `rate_limited \| unavailable` | §4, `.d.ts` `CartResult`, `CartRefusal`, `WireEnvelope` |
|
|
1654
|
+
| Editor-1 | §7.3 described a canvas bridge "v2" (`postMessage` `{v: 2, type}` with `ready`, `select`, `rect`, `height`, `set-content`, `patch`, `settings`, `navigate`) that no editor, gateway or theme implemented (searched 2026-10-19: onlinestore `main` and `feat/nova-v3`, storefront-edge `main`, theme-abi; conformance suite 6 only skips it). A theme written from it could not live-edit (theme-dev audit 2026-10-19 §5, §8 fix 8b). §7.3 now documents the protocol in production: the gateway's editor bridge on preview hosts, the `tanqory-preview-*` / `tq:set-content` editor messages, `tq:ready` / selection / `tanqory-selection-rect` / `tq-preview-height` from the canvas, the theme handshake `tq:canvas` / `tq:canvas-live` / `tq:canvas-bridge`, `data-tq-node` as the node hook, and `?tq_section_preview=<type>` rendering `presets[0]`. The v2 message table and the `v: 2` / `v: 1` migration note are withdrawn; there is one protocol. Normative for themes: `data-tq-node` on every section and block (already required by §6 and suite 2), the announce/apply rules when a theme announces live types, and a standalone `presets[0]` render. **Documentation of shipped behaviour; no type, schema or package change.** | §6, §7.3, §9, §10.6 |
|
|
1655
|
+
| Seam 6 | Base cache key `host\|path\|market\|locale\|releaseId\|dataVersion` is SiteRelease's; `vary` appends; market/locale are not `VaryKey`s | §5, `.d.ts` |
|
|
1656
|
+
| Kept as confirmed | Merkle canonicalisation; `artifact.json` signed separately; capabilities in signed descriptor; `ctx.route` + platform regions; `data-tq-node`; no cookies in isolate; `private` on cart/customer; write-once storage | — |
|
|
1657
|
+
|
|
1658
|
+
---
|
|
1659
|
+
## Appendix B — Field-type registry `tanqory-field-types@1` (canonical)
|
|
1660
|
+
|
|
1661
|
+
Source of truth for `engines["tanqory-field-types"] = "1"`. `@tanqory/theme-abi` exports this
|
|
1662
|
+
table as `FIELD_TYPES_V1`; `validateManifest()` checks every `FieldSpec.type` against `id`, every
|
|
1663
|
+
constraint key against `universal ∪ constraints`, and the `default` against `value`. Registry
|
|
1664
|
+
bumps are additive within an ABI major (§9).
|
|
1665
|
+
|
|
1666
|
+
Universal constraint keys (every type): `label info default placeholder group visible_if`.
|
|
1667
|
+
`dynamic` is allowed only where `dynamicBindable` is true. `resolve` only where `resource` is set.
|
|
1668
|
+
|
|
1669
|
+
JSON shape of one entry:
|
|
1670
|
+
|
|
1671
|
+
```jsonc
|
|
1672
|
+
{ "id": "text", "control": "text-input", "value": "string",
|
|
1673
|
+
"constraints": [], "dynamicBindable": true, "dynamicSourceTypes": ["single_line_text_field"],
|
|
1674
|
+
"aiGenerate": true, "media": null, "resource": null, "doc": "…" }
|
|
1675
|
+
```
|
|
1676
|
+
|
|
1677
|
+
| id | control | value (JSON type of the stored setting) | extra constraints | dynamic (source types) | ai | doc |
|
|
1678
|
+
|---|---|---|---|---|---|---|
|
|
1679
|
+
| `text` | text-input | `string` | — | yes (`single_line_text_field`) | yes | Single-line text. |
|
|
1680
|
+
| `textarea` | textarea | `string` | — | yes (`multi_line_text_field`) | yes | Multi-line plain text, no markup. |
|
|
1681
|
+
| `richtext` | rich-text | `string` (sanitised HTML) | — | yes (`rich_text_field`) | yes | Formatted text. |
|
|
1682
|
+
| `html` | textarea | `string` (raw HTML) | — | no | **no** | Author-trusted; never bind shopper input. |
|
|
1683
|
+
| `number` | number-input | `number` | `min max step` | yes (`number_integer`, `number_decimal`) | yes | Numeric input. |
|
|
1684
|
+
| `range` | slider | `number` | `min max step unit` | no | yes | Slider; declare min/max/step. |
|
|
1685
|
+
| `boolean` | switch | `boolean` | — | no (`boolean` source listed for parity) | yes | On/off. |
|
|
1686
|
+
| `color` | color | `string` (`#rrggbb` / `#rrggbbaa`) | — | no | yes | Colour. |
|
|
1687
|
+
| `select` | select | `string` ∈ `options[].value` | `options` (REQUIRED) | no | yes | Dropdown. |
|
|
1688
|
+
| `radio` | segmented | `string` ∈ `options[].value` | `options` (REQUIRED) | no | yes | Segmented choice. |
|
|
1689
|
+
| `text_alignment` | segmented | `"left" \| "center" \| "right"` | — | no | yes | Editor supplies the three choices. |
|
|
1690
|
+
| `url` | link-picker | `string`: scheme `https:` `http:` `mailto:` `tel:`, or a site-relative path: exactly `/`, or `/` + a character that is neither `/` nor `\` (see URL rule below) | — | yes (`url`) | yes | A link. |
|
|
1691
|
+
| `image` | media-picker (`media: image`) | `string`: `https:` URL only (see URL rule below) | — | yes (`file_reference`, `image`) | yes | Media-library picker. |
|
|
1692
|
+
| `video` | media-picker (`media: video`) | `string`: `https:` URL only (see URL rule below) | — | no | yes | Hosted video; external URL → `url`. |
|
|
1693
|
+
| `collection` | resource-picker (`resource: collection`) | `string` (handle) | `resolve` | no | yes | Collection handle. |
|
|
1694
|
+
| `product` | resource-picker (`resource: product`) | `string` (handle) | `resolve` | no | yes | Product handle. |
|
|
1695
|
+
| `page` | resource-picker (`resource: page`) | `string` (handle) | `resolve` | no | yes | Page handle. |
|
|
1696
|
+
| `blog` | resource-picker (`resource: blog`) | `string` (handle) | `resolve` | no | yes | Blog handle. |
|
|
1697
|
+
| `article` | resource-picker (`resource: article`) | `string` (`<blog>/<article>` handle) | `resolve` | no | yes | Article handle. |
|
|
1698
|
+
| `menu` | resource-picker (`resource: menu`) | `string` (handle) | `resolve` | no | yes | Store menu handle. |
|
|
1699
|
+
| `json` | json-editor | `object` (≤ 16 KiB serialised) | `schema` (JSON Schema 2020-12, ≤ 8 KiB) | no | yes | Free-form structured value validated against `schema` when present. |
|
|
1700
|
+
| `list` | list | `array` of `items.value` (≤ 64 entries; `items` depth ≤ 2) | `items` (REQUIRED), `min max` (length) | no | yes | Ordered values of one element spec. |
|
|
1701
|
+
|
|
1702
|
+
**URL rule (`url`, `image`, `video`).** The value is trimmed, then its scheme is compared
|
|
1703
|
+
case-insensitively. `url` accepts `https:`, `http:`, `mailto:`, `tel:`, or a site-relative path
|
|
1704
|
+
that is **exactly `/`** (the home page) **or** starts with `/` followed by a character that is
|
|
1705
|
+
neither `/` nor `\` (browsers treat `//host` and `/\host` as protocol-relative). `image` and `video` accept `https:` only. Everything
|
|
1706
|
+
else is rejected — including `//…`, `/\…`, `javascript:`, `data:`, `vbscript:`, `file:`, and
|
|
1707
|
+
scheme-less relative paths. Registry 1 defines no platform asset-reference form; registry 2
|
|
1708
|
+
adds `asset:` for `image`/`video` (B.2). The rule applies at
|
|
1709
|
+
`validateManifest()` (defaults, presets) and at content validation before render (§7.2) — a
|
|
1710
|
+
rejected value is dropped like an unknown key.
|
|
1711
|
+
|
|
1712
|
+
Rejected ids (error names the replacement): `image_picker→image`, `link_list→menu`,
|
|
1713
|
+
`checkbox→boolean`, `color_background→color`. Editor-only types that a theme **cannot** declare
|
|
1714
|
+
(`inline_richtext color_scheme color_scheme_group font_picker product_list collection_list
|
|
1715
|
+
metaobject video_url header paragraph`) are not in the registry; `product_list`/`collection_list`
|
|
1716
|
+
are expressed as `list` of `product`/`collection`.
|
|
1717
|
+
|
|
1718
|
+
### B.2 Registry `tanqory-field-types@2` (additive over 1)
|
|
1719
|
+
|
|
1720
|
+
Independent assessment 2026-10-07. Registry 1 above is **unchanged**; a manifest declaring
|
|
1721
|
+
`engines["tanqory-field-types"] = "2"` gets every registry-1 entry verbatim except:
|
|
1722
|
+
|
|
1723
|
+
| id | change in 2 |
|
|
1724
|
+
|---|---|
|
|
1725
|
+
| `text_alignment` | value `"left" \| "center" \| "right" \| "start" \| "end"`. `start`/`end` resolve against `ctx.locale.direction` (`ltr` ⇒ left/right, `rtl` ⇒ right/left; absent ⇒ `ltr`). |
|
|
1726
|
+
| `image`, `video` | Also accept **`asset:client/assets/<file>`** (owner approval 2026-10-07): `<file>` matches the artifact path allowlist (`[A-Za-z0-9._-]+`, no `/`, no `..`), and `client/assets/<file>` must appear in the signed statement's `predicate.files`. `<file>` is the **final, self-hashed** name (`<stem>-<h8><ext>`, theme-artifact-v1 §1.1). The build never renames a self-hashed file and never rewrites a reference, so an author references assets by their self-hashed names. **Valid only in theme `defaults/` and `presets`** (artifact-authored). In merchant content (ContentRevisions, editor saves, AI writes) it is rejected like any other non-`https:` value. The platform resolves it to `${assets.base}/assets/<file>` **before render**, so `ctx.content` and `snapshot.refs` only ever contain `https:` URLs and theme code never sees `asset:`. Scheme match is case-sensitive and lowercase (`asset:`). |
|
|
1727
|
+
|
|
1728
|
+
`ctx.locale.direction?: 'ltr' \| 'rtl'` is an optional ABI 2.x context field (additive minor);
|
|
1729
|
+
the host supplies it for every ABI version, so registry-1 themes may read it too.
|
|
1730
|
+
|
|
1731
|
+
---
|
|
1732
|
+
|
|
1733
|
+
## Appendix C — Global allowlist and runtime (normative)
|
|
1734
|
+
|
|
1735
|
+
### C.1 The allowed names
|
|
1736
|
+
|
|
1737
|
+
`@tanqory/theme-abi` exports this list as `ALLOWED_GLOBALS` (two arrays, `ecmascript` and
|
|
1738
|
+
`webPlatform`). The shim and the conformance runner build from that export; no other copy
|
|
1739
|
+
exists. A name is a string-keyed property reachable from the global object.
|
|
1740
|
+
|
|
1741
|
+
**ECMAScript (59 names)**
|
|
1742
|
+
|
|
1743
|
+
`globalThis` `undefined` `NaN` `Infinity` `Object` `Boolean` `Symbol` `Error`
|
|
1744
|
+
`EvalError` `RangeError` `ReferenceError` `SyntaxError` `TypeError` `URIError` `AggregateError` `SuppressedError`
|
|
1745
|
+
`Number` `BigInt` `Math` `Date` `String` `RegExp` `Array` `Int8Array`
|
|
1746
|
+
`Uint8Array` `Uint8ClampedArray` `Int16Array` `Uint16Array` `Int32Array` `Uint32Array` `Float16Array` `Float32Array`
|
|
1747
|
+
`Float64Array` `BigInt64Array` `BigUint64Array` `ArrayBuffer` `DataView` `Map` `Set` `WeakMap`
|
|
1748
|
+
`WeakSet` `Promise` `Proxy` `Reflect` `JSON` `Iterator` `Intl` `parseInt`
|
|
1749
|
+
`parseFloat` `isNaN` `isFinite` `encodeURI` `encodeURIComponent` `decodeURI` `decodeURIComponent` `escape`
|
|
1750
|
+
`unescape` `DisposableStack` `AsyncDisposableStack`
|
|
1751
|
+
|
|
1752
|
+
**Web Platform (40 names)**
|
|
1753
|
+
|
|
1754
|
+
`console` `queueMicrotask` `setTimeout` `clearTimeout` `setInterval` `clearInterval` `structuredClone` `atob`
|
|
1755
|
+
`btoa` `TextEncoder` `TextDecoder` `TextEncoderStream` `TextDecoderStream` `URL` `URLSearchParams` `Headers`
|
|
1756
|
+
`Request` `Response` `FormData` `Blob` `File` `ReadableStream` `ReadableStreamDefaultReader` `ReadableStreamBYOBReader`
|
|
1757
|
+
`WritableStream` `WritableStreamDefaultWriter` `TransformStream` `ByteLengthQueuingStrategy` `CountQueuingStrategy` `CompressionStream` `DecompressionStream` `AbortController`
|
|
1758
|
+
`AbortSignal` `Event` `EventTarget` `CustomEvent` `DOMException` `crypto` `performance` `navigator`
|
|
1759
|
+
|
|
1760
|
+
`Function` (the constructor) and `eval` are not on the list; `Function.prototype` and function
|
|
1761
|
+
values are unaffected. `navigator`, `performance` and `crypto` are reduced to the members §11
|
|
1762
|
+
names. Adding or removing a name is an ABI minor change.
|
|
1763
|
+
|
|
1764
|
+
### C.2 How everything else is removed
|
|
1765
|
+
|
|
1766
|
+
The shim runs this once, at module evaluation, **before the theme module is imported**:
|
|
1767
|
+
|
|
1768
|
+
1. **Collect** every string-keyed property name on the global object and on each object in its
|
|
1769
|
+
prototype chain, **up to but not including `Object.prototype`**. Symbol-keyed properties are
|
|
1770
|
+
left alone: they are not reachable as names.
|
|
1771
|
+
2. **The removal set** is every collected name that is not in C.1.
|
|
1772
|
+
3. **For each name in the removal set:** if it is an own, configurable property of the global
|
|
1773
|
+
object, delete it. Otherwise define an own property on the global object that shadows it, a
|
|
1774
|
+
non-writable, non-configurable `undefined`.
|
|
1775
|
+
**The six egress names** (`fetch`, `WebSocket`, `XMLHttpRequest`, `EventSource`, `caches`,
|
|
1776
|
+
`connect`) are handled differently, always, whether or not the runtime has them and whether
|
|
1777
|
+
or not they could be deleted: each is an own, non-configurable accessor on the global object.
|
|
1778
|
+
Its getter records the access and returns `undefined`; its setter records the access and
|
|
1779
|
+
discards the value, so an assignment neither throws nor takes effect. The mechanism reported
|
|
1780
|
+
is `tripwired`. Exception: when the theme holds a `network:` capability, `fetch` is the proxy
|
|
1781
|
+
of §11 instead.
|
|
1782
|
+
**`constructor` is removed like any other name.** It is an own property of the global
|
|
1783
|
+
object's prototype (the runtime's global-scope class), not in C.1, so
|
|
1784
|
+
`globalThis.constructor` is `undefined`. That is intended: it names the runtime.
|
|
1785
|
+
`Object.prototype.constructor` is not touched, so `({}).constructor === Object` still holds.
|
|
1786
|
+
4. **Verify.** For every name in the removal set, `typeof globalThis[name]` must be
|
|
1787
|
+
`'undefined'`. A name for which that cannot be achieved is `unenforced`.
|
|
1788
|
+
5. **Report**, through the host channel, the names removed and the mechanism used for each
|
|
1789
|
+
(`deleted`, `shadowed`, `tripwired`), and any `unenforced` name.
|
|
1790
|
+
|
|
1791
|
+
Nothing on `Object.prototype` or on a shared prototype such as `EventTarget.prototype` is
|
|
1792
|
+
deleted: `addEventListener` stops being a global, and `new EventTarget().addEventListener`
|
|
1793
|
+
still works.
|
|
1794
|
+
|
|
1795
|
+
A shim that removes only a fixed list of names is not conformant. The removal set is computed
|
|
1796
|
+
from the live global object, so it needs no list of forbidden names.
|
|
1797
|
+
|
|
1798
|
+
### C.3 When the runtime adds a global
|
|
1799
|
+
|
|
1800
|
+
A new runtime version, or a later compatibility date, may add names. Because the removal set is
|
|
1801
|
+
computed at start, **a new name is removed automatically and never reaches a theme.**
|
|
1802
|
+
|
|
1803
|
+
- The shim's report lists it among the removed names. Shim qualification compares that list
|
|
1804
|
+
with the last recorded one and flags the difference for review.
|
|
1805
|
+
- An `unenforced` name fails shim qualification: that runtime version is not used until the
|
|
1806
|
+
shim can remove the name.
|
|
1807
|
+
- Making a new name available to themes means adding it to C.1, which is an ABI minor change
|
|
1808
|
+
followed by the normal landing order.
|
|
1809
|
+
|
|
1810
|
+
### C.4 Why the compatibility date is `2025-09-01`
|
|
1811
|
+
|
|
1812
|
+
Measured on workerd 1.20261006.1 with the same probe under both dates in use (2026-10-09):
|
|
1813
|
+
|
|
1814
|
+
| | `2025-09-01` | `2026-10-01` |
|
|
1815
|
+
|---|---|---|
|
|
1816
|
+
| String-keyed names reachable from the global object | 164 | 176 |
|
|
1817
|
+
| Names in C.1 that are missing | none | none |
|
|
1818
|
+
| Extra names, all of which C.2 must remove | — | `process`, `Buffer`, `global`, `setImmediate`, `clearImmediate`, `Performance`, `PerformanceEntry`, `PerformanceMark`, `PerformanceMeasure`, `PerformanceObserver`, `PerformanceObserverEntryList`, `PerformanceResourceTiming`, plus more members on `performance` |
|
|
1819
|
+
| `WeakRef`, `FinalizationRegistry` | present (removed by C.2) | present (removed by C.2) |
|
|
1820
|
+
|
|
1821
|
+
- The later date gives a theme **nothing this ABI allows**, and adds twelve names this ABI
|
|
1822
|
+
forbids: the Node compatibility globals and the performance-timeline classes.
|
|
1823
|
+
- With C.2 in place a theme sees the same names under either date. The earlier date leaves less
|
|
1824
|
+
for the shim to remove, so a shim defect exposes less.
|
|
1825
|
+
- The edge's local workerd build (2026-08-15) refuses `2026-10-01` outright; `2025-09-01` runs
|
|
1826
|
+
on both binaries in use.
|
|
1827
|
+
- The probe compared global names and a set of behaviours. It did not test every behaviour a
|
|
1828
|
+
compatibility date can change, so a change that adds no name could still exist.
|
|
1829
|
+
|
|
1830
|
+
Changing the date later follows the same landing order as a shim change.
|
|
1831
|
+
|
|
1832
|
+
|
|
1833
|
+
|