create-cartbase 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -3
  3. package/dist/index.js +94 -0
  4. package/package.json +18 -4
  5. package/template/app/CLAUDE.md +18 -0
  6. package/template/app/docs/BUILD-A-STOREFRONT.md +216 -0
  7. package/template/app/docs/README.md +77 -0
  8. package/template/app/docs/auth.md +105 -0
  9. package/template/app/docs/carts.md +376 -0
  10. package/template/app/docs/categories.md +194 -0
  11. package/template/app/docs/checkout.md +611 -0
  12. package/template/app/docs/collections.md +167 -0
  13. package/template/app/docs/components.md +1090 -0
  14. package/template/app/docs/consent.md +81 -0
  15. package/template/app/docs/content.md +126 -0
  16. package/template/app/docs/customers.md +269 -0
  17. package/template/app/docs/deploy.md +192 -0
  18. package/template/app/docs/gift-cards.md +153 -0
  19. package/template/app/docs/integrations.md +137 -0
  20. package/template/app/docs/menus.md +73 -0
  21. package/template/app/docs/metaobjects.md +126 -0
  22. package/template/app/docs/orders.md +221 -0
  23. package/template/app/docs/platform.md +126 -0
  24. package/template/app/docs/products.md +300 -0
  25. package/template/app/docs/redirects.md +50 -0
  26. package/template/app/docs/regions.md +206 -0
  27. package/template/app/docs/reviews.md +223 -0
  28. package/template/app/docs/search.md +218 -0
  29. package/template/app/docs/subscriptions.md +148 -0
  30. package/template/app/next.config.ts +34 -0
  31. package/template/app/package.json +25 -0
  32. package/template/app/postcss.config.cjs +6 -0
  33. package/template/app/smoke.mjs +158 -0
  34. package/template/app/src/app/checkout/checkout-page-client.tsx +66 -0
  35. package/template/app/src/app/checkout/page.tsx +51 -0
  36. package/template/app/src/app/globals.css +42 -0
  37. package/template/app/src/app/layout.tsx +113 -0
  38. package/template/app/src/app/order/[id]/confirmed/page.tsx +77 -0
  39. package/template/app/src/app/page.tsx +25 -0
  40. package/template/app/src/app/products/[handle]/page.tsx +58 -0
  41. package/template/app/src/app/providers.tsx +54 -0
  42. package/template/app/src/app/search/page.tsx +20 -0
  43. package/template/app/src/lib/browser-client.ts +35 -0
  44. package/template/app/src/lib/cart-actions.ts +47 -0
  45. package/template/app/src/lib/config.ts +21 -0
  46. package/template/app/src/lib/server-client.ts +25 -0
  47. package/template/app/tailwind.config.cjs +9 -0
  48. package/template/app/tsconfig.json +41 -0
  49. package/template/app/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,81 @@
1
+ # Consent — Consent Mode v2 banner config
2
+
3
+ The store's CMP configuration for the built-in Consent Mode v2 banner
4
+ (consent-management card). Defaults are **always applied server-side**, so
5
+ the payload is complete and renderable even for an unconfigured store
6
+ (compliant built-in modal, BG + EN copy shipped).
7
+
8
+ ## Storefront wiring (the trap that matters)
9
+
10
+ - Mount `<ConsentInit>` as the **first child of `<body>`** — it sets the
11
+ synchronous Consent Mode v2 DEFAULT and must **never wait on this fetch**
12
+ (async default = first-hit consent race). Resolve this config server-side
13
+ (RSC) and inline it into the document.
14
+ - Render the built-in banner only when `enabled && mode === "builtin"`.
15
+ - `mode: "external"` = the merchant's CMP owns the UI and must write the
16
+ same `_1c_consent` cookie (or call `setConsent()`) — all Cartbase-side tag
17
+ gating works off that one seam.
18
+ - Choices persist 12 months in the cookie. Rybbit (platform analytics)
19
+ stays outside consent by design.
20
+ - Pair with [integrations.md](integrations.md): `tracking.consent_required`
21
+ mirrors `enabled` here — tags mount only through the consent gate when
22
+ true.
23
+
24
+ ## GET /api/store/consent — the CMP config
25
+
26
+ - **Purpose**: everything the banner needs to render, per locale.
27
+ - **Auth**: anon (`x-client-id`).
28
+ - **Request**: no params.
29
+ - **Response** — the EXACT allowlist (nothing else will ever appear here):
30
+
31
+ ```jsonc
32
+ {
33
+ "consent": {
34
+ "enabled": true,
35
+ "mode": "builtin", // "builtin" | "external"
36
+ "layout": "modal", // "modal" (blocking) | "banner-bottom" (non-blocking)
37
+ "privacy_href": "/cookies",
38
+ "reject_on_first_layer": false,
39
+ "copy": {
40
+ "bg": {
41
+ "title": "Преди да продължиш",
42
+ "body": "…",
43
+ "privacy_link_label": "…",
44
+ "accept_label": "…", "settings_label": "…", "reject_label": "…",
45
+ "settings_title": "…",
46
+ "accept_all_label": "…", "save_label": "…", "reject_all_label": "…",
47
+ "necessary_label": "…", "necessary_description": "…",
48
+ "analytics_label": "…", "analytics_description": "…",
49
+ "ads_label": "…", "ads_description": "…"
50
+ },
51
+ "en": { /* same 16 keys — every field always present per locale */ }
52
+ }
53
+ }
54
+ }
55
+ ```
56
+
57
+ - **Errors**: `400 missing_client_id` only — the payload itself always
58
+ succeeds (a corrupt/missing stored config degrades to defaults, never to
59
+ a broken banner).
60
+ - **SDK**: `consent.getConsent(client)`
61
+ - **Components**: `<ConsentInit>` + `<ConsentBanner copy={copy[locale]}
62
+ layout privacyHref rejectOnFirstLayer>` (consent family; reference impl
63
+ `src/components/storefront/consent/`).
64
+ - **Settings**: admin → Settings → Consent (enabled/mode/layout/
65
+ privacy_href/copy per locale).
66
+
67
+ ```bash
68
+ # Complete, renderable config — even on an unconfigured store the defaults
69
+ # make every documented key present.
70
+ BODY=$(curl -sf "$BASE/api/store/consent" -H "x-client-id: $CLIENT_ID")
71
+ echo "$BODY" | grep -q '"consent"'
72
+ echo "$BODY" | grep -q '"mode"'
73
+ echo "$BODY" | grep -q '"layout"'
74
+ echo "$BODY" | grep -q '"privacy_href"'
75
+ echo "$BODY" | grep -q '"reject_on_first_layer"'
76
+ echo "$BODY" | grep -q '"analytics_label"'
77
+ echo "$BODY" | grep -q '"ads_description"'
78
+ # Missing tenant header → the standard 400.
79
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/store/consent")
80
+ test "$STATUS" = 400
81
+ ```
@@ -0,0 +1,126 @@
1
+ # Content — pages + blogs
2
+
3
+ Shopify Storefront `Page` / `Article` shapes **verbatim** so storefront code
4
+ ports 1:1. **PUBLISHED only** — drafts and deleted rows 404 (lib status
5
+ filter AND the anon RLS policy). Stored HTML is server-sanitized on write —
6
+ safe to render raw (`dangerouslySetInnerHTML`).
7
+
8
+ Path convention (`src/lib/content/paths.ts`, shared with menus + redirects):
9
+ `/pages/<handle>`, `/blogs/<handle>`, `/blogs/<blog>/<post>`. Handle renames
10
+ create URL redirects automatically — wire [redirects.md](redirects.md) into
11
+ your 404 handler and old content URLs keep working.
12
+
13
+ Policy pages every store carries (`privacy-policy`, `terms-of-service`,
14
+ `refund-policy`, `shipping-policy`) are seeded as **drafts** — they 404 here
15
+ until the merchant publishes them.
16
+
17
+ > Content is admin-authored; the shared dev tenant seeds no published
18
+ > pages/blogs, so the executable blocks prove the 404 contract and the
19
+ > happy-path shapes are pinned by `tests/store/content-store.test.ts` and
20
+ > `tests/contract/sdk-content.contract.test.ts` (which create fixtures with
21
+ > admin auth).
22
+
23
+ ## GET /api/store/pages/:handle — one page
24
+
25
+ - **Purpose**: render a static page (`/pages/<handle>`).
26
+ - **Auth**: anon (`x-client-id`).
27
+ - **Request**: path handle only.
28
+ - **Response**:
29
+
30
+ ```jsonc
31
+ {
32
+ "page": {
33
+ "id": "cpage_<hex>",
34
+ "handle": "about-us",
35
+ "title": "About us",
36
+ "body": "<h2>…</h2>", // sanitized HTML — render raw
37
+ "bodySummary": "…", // stripped, ~160-char word-boundary cut
38
+ "seo": {
39
+ "title": "About us", // fallback: title
40
+ "description": "…" // fallback: bodySummary
41
+ },
42
+ "publishedAt": "ISO-8601",
43
+ "createdAt": "ISO-8601",
44
+ "updatedAt": "ISO-8601"
45
+ }
46
+ }
47
+ ```
48
+
49
+ - **Errors**: `404 not_found` — unknown handle, draft, or deleted.
50
+ - **SDK**: `content.getPage(client, handle)`
51
+ - **Components**: static page template; footer policy links.
52
+ - **Settings**: page status (draft/published) in admin → Content → Pages.
53
+
54
+ ```bash
55
+ # Unknown handle → the clean 404 contract (drafts behave identically).
56
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
57
+ "$BASE/api/store/pages/doc-no-such-page-$RUN" -H "x-client-id: $CLIENT_ID")
58
+ test "$STATUS" = 404
59
+ curl -s "$BASE/api/store/pages/doc-no-such-page-$RUN" -H "x-client-id: $CLIENT_ID" \
60
+ | grep -q '"code":"not_found"'
61
+ ```
62
+
63
+ ## GET /api/store/blogs/:handle/posts — blog listing
64
+
65
+ - **Purpose**: the blog index (`/blogs/<handle>`), paginated.
66
+ - **Auth**: anon (`x-client-id`).
67
+ - **Request**: query `{limit? (≤100, default 20), offset?}`. Published
68
+ only, `published_at` desc.
69
+ - **Response**:
70
+
71
+ ```jsonc
72
+ {
73
+ "blog": { "handle": "news", "title": "News" },
74
+ "articles": [ /* Article[] — the exact shape below */ ],
75
+ "count": 12,
76
+ "offset": 0,
77
+ "limit": 20
78
+ }
79
+ ```
80
+
81
+ - **Errors**: `404 not_found` (unknown blog handle) ·
82
+ `400 validation_failed` (limit/offset out of range).
83
+ - **SDK**: `content.listBlogPosts(client, blogHandle, query?)`
84
+ - **Components**: blog index template.
85
+
86
+ ```bash
87
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
88
+ "$BASE/api/store/blogs/doc-no-such-blog-$RUN/posts" -H "x-client-id: $CLIENT_ID")
89
+ test "$STATUS" = 404
90
+ ```
91
+
92
+ ## GET /api/store/blogs/:handle/posts/:postHandle — one article
93
+
94
+ - **Purpose**: render one post (`/blogs/<blog>/<post>`).
95
+ - **Auth**: anon (`x-client-id`).
96
+ - **Response** — Shopify `Article` verbatim:
97
+
98
+ ```jsonc
99
+ {
100
+ "article": {
101
+ "id": "bpost_<hex>",
102
+ "handle": "hello-world",
103
+ "title": "Hello world",
104
+ "contentHtml": "<p>…</p>", // sanitized HTML — render raw
105
+ "content": "…", // plain-text version
106
+ "excerpt": "…", // author-provided; falls back to truncated content
107
+ "image": { "url": "https://…", "altText": null }, // null when no hero image
108
+ "tags": ["news"],
109
+ "author": { "name": "Maria" }, // null when unset
110
+ "publishedAt": "ISO-8601",
111
+ "seo": { "title": "…", "description": "…" }, // fallbacks: title / excerpt
112
+ "blog": { "handle": "news", "title": "News" }
113
+ }
114
+ }
115
+ ```
116
+
117
+ - **Errors**: `404 not_found` — unknown blog OR unknown/draft post.
118
+ - **SDK**: `content.getBlogPost(client, blogHandle, postHandle)`
119
+ - **Components**: article template; article cards reuse the listing shape.
120
+
121
+ ```bash
122
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
123
+ "$BASE/api/store/blogs/doc-no-such-blog-$RUN/posts/doc-no-post-$RUN" \
124
+ -H "x-client-id: $CLIENT_ID")
125
+ test "$STATUS" = 404
126
+ ```
@@ -0,0 +1,269 @@
1
+ # Customers — profile, addresses, documents
2
+
3
+ The signed-in customer surface. Every endpoint here requires a **customer
4
+ session**: `authorization: Bearer <supabase jwt>` (minted by the
5
+ passwordless flow — see [auth.md](auth.md) — or a client-side supabase
6
+ password sign-in) **plus** the `x-client-id` tenant header the storefront
7
+ always sends. Missing/invalid JWT → `401 {code: "unauthenticated"}` on every
8
+ route below.
9
+
10
+ Lazy registration: `/customers/me` resolves the customer by
11
+ (client_id, JWT email) and **creates the row on first authenticated call**
12
+ (has_account=true, `account_status` per the store's approval policy) — a
13
+ fresh login never 404s on "me".
14
+
15
+ `customer.account_status` (`pending` | `approved`) is on every customer
16
+ payload; when the store's B2B approval policy is on, hide B2B content until
17
+ `approved`.
18
+
19
+ > The executable blocks below prove the **auth boundary** (401 contract) —
20
+ > the docs harness is anonymous, so happy paths are shown as `jsonc` shapes
21
+ > and are pinned by `tests/contract/customers.contract.test.ts` and
22
+ > `tests/store/customer-accounts-*.test.ts` with real sessions.
23
+
24
+ ## POST /api/store/customers — guest-to-registered conversion
25
+
26
+ - **Purpose**: bind a password sign-up to a customer row. Flow:
27
+ `supabase.auth.signUp({email, password})` client-side → this call with the
28
+ same email. NOT needed for the passwordless code flow (verify already
29
+ lazy-creates the customer).
30
+ - **Auth**: Bearer JWT (+ `x-client-id`). Body email MUST equal the JWT
31
+ email.
32
+ - **Request**:
33
+
34
+ ```jsonc
35
+ // POST /api/store/customers
36
+ {
37
+ "email": "maria@example.com", // must match the session email
38
+ "first_name": "Maria", // optional
39
+ "last_name": "Petrova", // optional
40
+ "phone": "+359888123456", // optional
41
+ "company_name": "Acme OOD" // optional
42
+ }
43
+ ```
44
+
45
+ - **Response**: `{customer}` — the full customer with `addresses` embedded
46
+ (empty on first create). See the shape under `/customers/me` below.
47
+ - **Errors**: `401 unauthenticated` · `403 email_mismatch` (body email ≠
48
+ session email) · `400 validation_failed`.
49
+ - **SDK**: `customers.createCustomer(client, input)`
50
+ - **Components**: account registration form (account pages family).
51
+ - **Settings**: store approval policy decides `account_status` of a NEW row
52
+ (`pending` when B2B approval is required, else `approved`).
53
+
54
+ ```bash
55
+ # No session → the documented 401 contract (the auth boundary, executable).
56
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/store/customers" \
57
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
58
+ -d '{"email": "doc-'$RUN'@doc.test"}')
59
+ test "$STATUS" = 401
60
+ ```
61
+
62
+ ## GET /api/store/customers/me — the signed-in customer
63
+
64
+ - **Purpose**: hydrate account state after login / on account pages.
65
+ Lazy-creates the customer row on first call for this (tenant, email).
66
+ - **Auth**: Bearer JWT (+ `x-client-id`).
67
+ - **Request**: no params.
68
+ - **Response**:
69
+
70
+ ```jsonc
71
+ {
72
+ "customer": {
73
+ "id": "uuid",
74
+ "client_id": "uuid",
75
+ "email": "maria@example.com",
76
+ "first_name": "Maria",
77
+ "last_name": "Petrova",
78
+ "phone": "+359888123456",
79
+ "company_name": null,
80
+ "company_eik": null, // Bulgarian company id (ЕИК) — invoice checkout
81
+ "vat_number": null,
82
+ "has_account": true,
83
+ "account_status": "approved", // "pending" | "approved" — B2B gating
84
+ "tags": [], // admin-only labels; read-only here
85
+ "metadata": null,
86
+ "created_by": null,
87
+ "created_at": "ISO-8601",
88
+ "updated_at": "ISO-8601",
89
+ "deleted_at": null,
90
+ "addresses": [ /* CustomerAddress[], created_at asc — shape below */ ]
91
+ }
92
+ }
93
+ ```
94
+
95
+ - **Errors**: `401 unauthenticated` · `400 missing_client_id`.
96
+ - **SDK**: `customers.getMe(client)`
97
+ - **Components**: account dashboard / header account state.
98
+ - **Settings**: approval policy (`account_status` on lazy create).
99
+
100
+ ```bash
101
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/store/customers/me" \
102
+ -H "x-client-id: $CLIENT_ID")
103
+ test "$STATUS" = 401
104
+ # The error envelope carries the stable code:
105
+ curl -s "$BASE/api/store/customers/me" -H "x-client-id: $CLIENT_ID" \
106
+ | grep -q '"code":"unauthenticated"'
107
+ ```
108
+
109
+ ## POST /api/store/customers/me — update own profile
110
+
111
+ - **Purpose**: profile edit (names, phone, company/VAT master data).
112
+ - **Auth**: Bearer JWT (+ `x-client-id`).
113
+ - **Request** (all optional; nullable fields clear with `null`):
114
+
115
+ ```jsonc
116
+ {
117
+ "first_name": "Maria",
118
+ "last_name": "Petrova",
119
+ "phone": "+359888123456",
120
+ "company_name": "Acme OOD",
121
+ "company_eik": "123456789", // loose format — foreign B2B customers exist
122
+ "vat_number": "BG123456789",
123
+ "metadata": {}
124
+ // "tags" are admin-only — the server STRIPS them from this body.
125
+ }
126
+ ```
127
+
128
+ - **Response**: `{customer}` — the refreshed customer (shape above).
129
+ - **Errors**: `401 unauthenticated` · `400 validation_failed`.
130
+ - **SDK**: `customers.updateMe(client, input)`
131
+ - **Components**: account profile form; invoice-details step in checkout.
132
+ - **Settings**: `company_eik`/`vat_number` feed invoice-required checkout
133
+ (order-documents).
134
+
135
+ ## GET /api/store/customers/me/addresses — list addresses
136
+
137
+ - **Purpose**: the address book. Returns **every** address (created_at asc)
138
+ — the envelope is nominal: `offset` is always 0 and `limit` equals
139
+ `count`; no query params are read.
140
+ - **Auth**: Bearer JWT (+ `x-client-id`).
141
+ - **Response**:
142
+
143
+ ```jsonc
144
+ {
145
+ "addresses": [
146
+ {
147
+ "id": "uuid",
148
+ "client_id": "uuid",
149
+ "customer_id": "uuid",
150
+ "address_name": "Home",
151
+ "first_name": "Maria",
152
+ "last_name": "Petrova",
153
+ "company": null,
154
+ "address_1": "ul. Ivan Vazov 1",
155
+ "address_2": null,
156
+ "city": "Sofia",
157
+ "country_code": "bg", // stored lower-case
158
+ "province": null,
159
+ "postal_code": "1000",
160
+ "phone": "+359888123456",
161
+ "is_default_billing": false,
162
+ "is_default_shipping": true,
163
+ "metadata": null,
164
+ "created_at": "ISO-8601",
165
+ "updated_at": "ISO-8601",
166
+ "deleted_at": null
167
+ }
168
+ ],
169
+ "count": 1,
170
+ "offset": 0,
171
+ "limit": 1
172
+ }
173
+ ```
174
+
175
+ - **Errors**: `401 unauthenticated`.
176
+ - **SDK**: `customers.listAddresses(client)`
177
+ - **Components**: address book; checkout address picker.
178
+
179
+ ## POST /api/store/customers/me/addresses — create address
180
+
181
+ - **Purpose**: add to the address book. `is_default_billing` /
182
+ `is_default_shipping: true` clears the flag on every sibling first (one
183
+ default per kind).
184
+ - **Auth**: Bearer JWT (+ `x-client-id`).
185
+ - **Request**: all fields optional — same keys as the address shape above
186
+ (minus id/customer_id/timestamps). `country_code` is lower-cased.
187
+ - **Response**: `{customer}` — the **full refreshed customer** (addresses
188
+ embedded), NOT the created address alone; find it in
189
+ `customer.addresses`.
190
+ - **Errors**: `401 unauthenticated` · `400 validation_failed`.
191
+ - **SDK**: `customers.createAddress(client, input)`
192
+
193
+ ```bash
194
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
195
+ "$BASE/api/store/customers/me/addresses" \
196
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
197
+ -d '{"city": "Sofia"}')
198
+ test "$STATUS" = 401
199
+ ```
200
+
201
+ ## GET /api/store/customers/me/addresses/:id — one address
202
+
203
+ - **Purpose**: read one owned address (edit-form hydrate).
204
+ - **Auth**: Bearer JWT (+ `x-client-id`).
205
+ - **Response**: `{address}` — ONE address object (the only address
206
+ endpoint that returns `{address}` instead of `{customer}`).
207
+ - **Errors**: `401 unauthenticated` · `404 not_found` — unknown OR another
208
+ customer's address; ownership violations 404, they never 403.
209
+ - **SDK**: `customers.getAddress(client, addressId)`
210
+
211
+ ## POST /api/store/customers/me/addresses/:id — update address
212
+
213
+ - **Purpose**: partial update; default flags clear siblings.
214
+ - **Auth**: Bearer JWT (+ `x-client-id`).
215
+ - **Request**: same keys as create, nullable to clear.
216
+ - **Response**: `{customer}` — full refreshed customer.
217
+ - **Errors**: `401 unauthenticated` · `404 not_found` ·
218
+ `400 validation_failed`.
219
+ - **SDK**: `customers.updateAddress(client, addressId, input)`
220
+
221
+ ## DELETE /api/store/customers/me/addresses/:id — delete address
222
+
223
+ - **Purpose**: soft-delete an owned address.
224
+ - **Auth**: Bearer JWT (+ `x-client-id`).
225
+ - **Response**: `{customer}` — full refreshed customer (address gone).
226
+ - **Errors**: `401 unauthenticated` · `404 not_found`.
227
+ - **SDK**: `customers.deleteAddress(client, addressId)`
228
+
229
+ ## GET /api/store/customers/me/documents — my documents (invoices)
230
+
231
+ - **Purpose**: the account "Invoices" page — the customer's issued
232
+ (non-void) order documents, `issued_at` desc. Ownership is INNER-join
233
+ enforced (session customer id AND client_id) — cross-customer reads are
234
+ impossible (adversarial-tested).
235
+ - **Auth**: Bearer JWT (+ `x-client-id`).
236
+ - **Request**: query `{limit? (≤200, default 20), offset?, doc_type?}`.
237
+ - **Response**:
238
+
239
+ ```jsonc
240
+ {
241
+ "documents": [
242
+ {
243
+ "id": "uuid",
244
+ "order_id": "order_…",
245
+ "order_display_id": 1042,
246
+ "doc_type": "invoice",
247
+ "number": "0000000042",
248
+ "issued_at": "ISO-8601",
249
+ "pdf_url": "https://…/invoice.pdf",
250
+ "due_date": null,
251
+ "paid_at": "ISO-8601"
252
+ }
253
+ ],
254
+ "count": 1,
255
+ "offset": 0,
256
+ "limit": 20
257
+ }
258
+ ```
259
+
260
+ - **Errors**: `401 unauthenticated` · `400 validation_failed`.
261
+ - **SDK**: `customers.listMyDocuments(client, query?)`
262
+ - **Components**: account documents/invoices list.
263
+ - **Settings**: order-documents issuing config (doc types, numbering).
264
+
265
+ ```bash
266
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
267
+ "$BASE/api/store/customers/me/documents" -H "x-client-id: $CLIENT_ID")
268
+ test "$STATUS" = 401
269
+ ```
@@ -0,0 +1,192 @@
1
+ # Deploy & hosting
2
+
3
+ Cartbase builds and hosts your storefront. You send the app's source files
4
+ — one snapshot of the whole project — and Cartbase turns it into a
5
+ running site: first on a **permanent preview URL**, then, when you press
6
+ **Publish** (or call the publish endpoint), on the store's live domain.
7
+ You never touch build servers, DNS, or hosting configuration; the platform
8
+ provisions all of it on your first deploy.
9
+
10
+ Two URLs exist per store, both created automatically:
11
+
12
+ | Surface | URL | What serves there |
13
+ |---|---|---|
14
+ | Preview | `https://preview--{store-slug}.cartbase.net` | The newest deploy, always — the link never changes |
15
+ | Live | `https://{store-slug}.cartbase.net` | The last **published** deploy, and nothing else |
16
+
17
+ A failed build never replaces what's live or what's on preview — the
18
+ previous successful deploy keeps serving. Custom domains for the live site
19
+ attach in the admin (Settings → Domains) and don't change anything on this
20
+ page.
21
+
22
+ **These are Admin API endpoints** — they operate the store, so they
23
+ authenticate with an admin session (sign-in cookie), not the storefront
24
+ headers the rest of this corpus uses. In practice you'll deploy through
25
+ the admin UI (**Storefront** in the sidebar), the Cartbase CLI, or an
26
+ agent connection — all three are wrappers over exactly these calls.
27
+ Users who belong to several stores name the target store with an
28
+ `x-cartbase-store: <client-id>` header.
29
+
30
+ ## What a deploy is
31
+
32
+ A deploy is a **full snapshot**: the complete file tree of your storefront
33
+ app, uploaded in one request. Snapshot semantics cut both ways — a file
34
+ you stop sending is removed from the deployed site. There is no
35
+ partial-update mode; your working directory is the truth, every time.
36
+
37
+ Bundle rules (enforced server-side; a violation fails the whole deploy
38
+ with a readable `400` before anything is built):
39
+
40
+ - ≤ 1500 files, ≤ 3 MB per file, ≤ 25 MB total (base64-decoded sizes)
41
+ - `package.json` must exist at the bundle root — the app must be a
42
+ buildable Next.js project (`next build`)
43
+ - File content travels base64-encoded (`content_base64`), so binary
44
+ assets — images, fonts — work
45
+ - Never include: `node_modules/` (dependencies install at build time),
46
+ `.next/` (build output is generated), `.git/`, any `.env*` file
47
+ - Not allowed in storefront bundles: `middleware.*`, `app/api/` or
48
+ `pages/api/` routes, `vercel.json` — a storefront is pages plus the
49
+ `@cartbase/storefront` package; server seams and hosting config are
50
+ platform territory. The CORS proxy from the runbook is a
51
+ `next.config` rewrite, which is fine.
52
+
53
+ Error codes you can hit: `empty_bundle`, `bundle_too_large`,
54
+ `invalid_bundle_file`, `invalid_path`, `blocked_path`, `duplicate_path`,
55
+ `invalid_encoding`, `file_too_large`, `missing_package_json`.
56
+
57
+ ## Environment — provided, not configured
58
+
59
+ Hosted storefronts receive the three runbook inputs automatically at
60
+ build time; do **not** put them in the bundle (`.env*` files are blocked
61
+ anyway):
62
+
63
+ | Variable | Value |
64
+ |---|---|
65
+ | `NEXT_PUBLIC_CARTBASE_URL` | The store's API origin |
66
+ | `NEXT_PUBLIC_CARTBASE_CLIENT_ID` | The store's client id |
67
+ | `NEXT_PUBLIC_CARTBASE_PUBLISHABLE_KEY` | The store's publishable key, when one exists |
68
+
69
+ Only these public values ever reach a storefront build — secret keys are
70
+ never injected, so code that expects one is a design error.
71
+
72
+ ## Deploy to preview
73
+
74
+ `POST /api/admin/storefront/deploys` — the one ingestion door. The first
75
+ call on a store also provisions its hosting (takes a few extra seconds);
76
+ every later call is just a deploy.
77
+
78
+ ```bash
79
+ # doc-noexec — admin-session auth; run from an authenticated context.
80
+ curl -s -X POST "$BASE/api/admin/storefront/deploys" \
81
+ -H "content-type: application/json" \
82
+ -d '{
83
+ "files": [
84
+ { "path": "package.json", "content_base64": "<base64>" },
85
+ { "path": "app/layout.js", "content_base64": "<base64>" },
86
+ { "path": "app/page.js", "content_base64": "<base64>" }
87
+ ],
88
+ "message": "homepage copy update"
89
+ }'
90
+ ```
91
+
92
+ Response `201`:
93
+
94
+ ```json
95
+ {
96
+ "deploy": {
97
+ "id": "sfd_01J…",
98
+ "target": "preview",
99
+ "status": "building",
100
+ "url": "https://preview--my-store.cartbase.net",
101
+ "message": "homepage copy update",
102
+ "file_count": 3,
103
+ "created_at": "2026-08-03T22:41:00.000Z"
104
+ }
105
+ }
106
+ ```
107
+
108
+ `message` (≤ 500 chars) is your label in the deploy history — write what
109
+ changed. `source` (`api` | `cli` | `mcp` | `admin` | `agent`) tags where
110
+ the deploy came from; omit it unless you're building tooling.
111
+
112
+ Statuses move `queued → building → ready` (or `failed` / `canceled`).
113
+ Poll the overview endpoint until the deploy you created is `ready`, then
114
+ open the preview URL. Builds of a small app land in roughly a minute;
115
+ `failed` rows carry a readable `error_message`.
116
+
117
+ ## Overview — hosting status + deploy history
118
+
119
+ `GET /api/admin/storefront` returns the hosting record and the deploy
120
+ ledger, newest first:
121
+
122
+ ```bash
123
+ # doc-noexec — admin-session auth.
124
+ curl -s "$BASE/api/admin/storefront"
125
+ ```
126
+
127
+ ```json
128
+ {
129
+ "storefront": {
130
+ "id": "sfp_01J…",
131
+ "status": "ready",
132
+ "production_url": "https://my-store.cartbase.net",
133
+ "preview_url": "https://preview--my-store.cartbase.net",
134
+ "last_error": null,
135
+ "created_at": "…"
136
+ },
137
+ "deploys": [
138
+ {
139
+ "id": "sfd_01J…",
140
+ "target": "preview",
141
+ "status": "ready",
142
+ "message": "homepage copy update",
143
+ "url": "https://preview--my-store.cartbase.net",
144
+ "source": "cli",
145
+ "file_count": 3,
146
+ "total_bytes": 1930,
147
+ "error_message": null,
148
+ "promoted_deploy_id": null,
149
+ "created_at": "…",
150
+ "updated_at": "…"
151
+ }
152
+ ]
153
+ }
154
+ ```
155
+
156
+ `storefront` is `null` until the store's first deploy. `status:
157
+ "provisioning"` / `"failed"` (with `last_error`) describe hosting setup,
158
+ not builds; a failed provisioning resumes automatically on the next
159
+ deploy attempt.
160
+
161
+ ## Publish — and rollback
162
+
163
+ `POST /api/admin/storefront/publish` promotes a **ready preview deploy**
164
+ to the live domain. This is the only path to production — nothing
165
+ deploys straight to live, no matter who's asking.
166
+
167
+ ```bash
168
+ # doc-noexec — admin-session auth.
169
+ # Bare POST publishes the newest ready preview:
170
+ curl -s -X POST "$BASE/api/admin/storefront/publish"
171
+
172
+ # Naming an older deploy id IS the rollback mechanism:
173
+ curl -s -X POST "$BASE/api/admin/storefront/publish" \
174
+ -H "content-type: application/json" \
175
+ -d '{ "deploy_id": "sfd_01H…older…" }'
176
+ ```
177
+
178
+ Response `201` is a new ledger row with `target: "production"` and
179
+ `promoted_deploy_id` pointing at the preview it promoted. Publishing is
180
+ near-instant — the snapshot was already built; the live domain switches
181
+ to it without a rebuild.
182
+
183
+ Rollback is not a separate feature: publish any earlier `ready` deploy
184
+ from the history and the live site is that snapshot again. Roll forward
185
+ the same way.
186
+
187
+ ## Workflow summary
188
+
189
+ 1. Build locally against your store (the [runbook](BUILD-A-STOREFRONT.md)).
190
+ 2. Deploy → check the permanent preview URL.
191
+ 3. Iterate — every deploy replaces preview, live is untouched.
192
+ 4. Publish when it's right; the deploy history is your undo button.