@dreamtree-org/landing-kit 0.7.2 → 0.9.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/MIGRATION.md CHANGED
@@ -118,3 +118,58 @@ Nothing changes unless you opt in:
118
118
  - `fillPage(template, page)` from `/server`: the template fill `prerenderPages` does, as a string.
119
119
  Keep a copy of the template (prerenderPages overwrites `landing.html`) and an SSR bundle to
120
120
  re-render pages with live data, e.g. CMS-driven pricing.
121
+
122
+ ## 0.8.0 — Platform admin console contract (additive)
123
+
124
+ Six products hand-roll a separate Platform console: procurementpro, firepro, venturepro, MerchantPro, PracticePro and fitweavepro. TaskPro and billpro bolt one onto the app instead. 0.8.0 starts replacing these copies with one console:
125
+
126
+ - **[PLATFORM-API.md](./PLATFORM-API.md)**: the HTTP contract, taken from procurementpro's `app/api/platform/**` (the reference console). Rows marked *shipped* are what procurementpro serves today. Rows marked *v1.1* are new (TOTP login, `GET /me`, audit, plans and subscriptions, settings, support access, domains); the console shows each one only when `GET /me` advertises it.
127
+ - **`@dreamtree-org/landing-kit/platform`**: `createPlatformClient` replaces procurementpro `components/platform/api.ts` (`pfetch`, `ApiError`) and the platform half of `components/csrf-fetch.tsx`. `createStepUp` replaces the retry logic in `components/platform/step-up.tsx`.
128
+
129
+ | Before (per product) | After |
130
+ |---|---|
131
+ | `components/platform/api.ts` `pfetch(url, { json })` | `api.request(path, { json })` / `api.get/post/patch/put/del` |
132
+ | `ApiError` | `PlatformApiError` (same `status`, `code`) |
133
+ | hard-coded `/api/platform/...` and `/platform/login` | `createPlatformClient({ basePath, loginPath })` |
134
+ | global `fetch` patch adding `x-csrf-token` from `pp_platform_csrf` | built into the client (`csrfCookie`, or any `*_platform_csrf`) |
135
+ | `StepUpProvider` retry loop | `createStepUp(api, ask)`; the provider keeps only the dialog |
136
+
137
+ Nothing changes for products that don't use it. The server half, `createPlatformApi` with the `./next` and `./express` adapters, ships in `@dreamtree-org/tenancy` 0.4.0. The React console (`PlatformConsole`, `PlatformShell`, `PlatformLogin`, views) ships next, on top of this client.
138
+
139
+ ### 0.8.0: the React console
140
+
141
+ `PlatformConsole` replaces procurementpro `components/platform/*`: shell, overview, companies, the new-company wizard, company detail, admins, step-up and login. firepro's copy is replaced the same way, including its security page.
142
+
143
+ | Before | After |
144
+ |---|---|
145
+ | `platform-shell.tsx` (hard-coded "Dreamtree" brand and nav) | `PlatformShell` on twreact `AppShell`. Brand comes from `dreamtree.brand.json`; nav comes from features and role. |
146
+ | `overview-view.tsx`, `companies-view.tsx`, `new-company-wizard.tsx`, `company-detail.tsx`, `platform-admins-view.tsx` | built-in views (same API calls, same flows) |
147
+ | `step-up.tsx` | `StepUpProvider` / `useSensitive` (password, or TOTP once enrolled) |
148
+ | `platform-login.tsx` | `PlatformLogin` (password, then the authenticator code) |
149
+ | `ui.tsx` (`StatusPill`, `OneTimeSecret`, `InspectResult`, ...) | exported from `./platform` |
150
+ | `jobs-view.tsx` | stays in the product for now, mounted with `pages={[{ path: 'jobs', feature: 'jobs', render }]}` and as a `companyTabs` entry |
151
+ | procurementpro's `@/components/common` (`PageHeader`, `DataTable`, `Pill`) | twreact `PageHeader`, `Table`, `StatusPill`, `EmptyState`, `ErrorState`, `LoadingState` |
152
+ | `/platform/companies/new`, `/platform/companies/[id]`, ... (one Next page each) | one `app/platform/(console)/[[...path]]/page.tsx` |
153
+
154
+ **New in the console:**
155
+
156
+ - Audit log page with CSV export.
157
+ - Security page for setting up an authenticator, plus the enrollment gate in front of the rest of the console.
158
+ - Role selection when adding an admin; reset an admin's authenticator.
159
+ - Credential-custodian "Reveal URL" with a reason, audited.
160
+
161
+ **Not in the console yet:** plans and subscriptions, platform settings, support access and domains. Their server endpoints come first (tenancy). The nav shows them only when `GET /me` advertises them.
162
+
163
+ ## 0.9.0 — store documents (additive)
164
+
165
+ A store's landing, listing and product pages, its header and footer, and its product card are now one
166
+ version-2 document (`validateStore` from `/schema`). Nothing changes for apps that keep version-1 tenant pages.
167
+
168
+ - To adopt it, run each stored v1 page through `migrateStore(page)`. It becomes `pages.home`, and the listing
169
+ and product pages get their default templates. `validateStore` migrates a v1 page itself, so an app can
170
+ switch over on its next save.
171
+ - Listing and product pages are templates whose blocks are either app **slots** (`filters`, `results`,
172
+ `gallery`, `buy_box`…, rendered by the app through `TemplateRegion`'s `slots`) or ordinary content sections.
173
+ Required slots cannot be hidden or removed.
174
+ - `safeHref` now rejects links containing a backslash, and registry lookups (`blockDef`, `templatePage`) ignore
175
+ inherited keys, so `__proto__` or `constructor` is an unknown type.
@@ -0,0 +1,213 @@
1
+ # Platform console HTTP contract (v1)
2
+
3
+ The shared Platform (super-administrator) console in `@dreamtree-org/landing-kit/platform` talks to the
4
+ server only through this contract. The server side is `createPlatformApi` in `@dreamtree-org/tenancy`,
5
+ mounted with `./next` (`app/api/platform/[...path]/route.ts`) or `./express` (`app.use('/api/platform', …)`).
6
+ A product that implements these endpoints itself gets the same console.
7
+
8
+ **Server status.** `@dreamtree-org/tenancy` 0.4.0 implements everything marked shipped, plus these v1.1 items: TOTP, throttle, rotation, `/me`, roles and audit. Plans, settings, support access and domains come next.
9
+
10
+ The contract was taken from procurementpro's `app/api/platform/**`, the reference console. Paths are
11
+ relative to the base path, `/api/platform` by default (`createPlatformClient({ basePath })`).
12
+
13
+ **Status column:**
14
+ - **shipped**: procurementpro serves it today, and the console depends on it.
15
+ - **v1.1**: new in this contract. The console hides the feature until the server reports it in `GET /me`.
16
+
17
+ ## Conventions
18
+
19
+ - **Body.** JSON in and out (`Content-Type: application/json`). The one exception is the CSV export.
20
+ - **Errors.** Any non-2xx response has the body `{ "error": string, "code"?: string }`. `error` is safe to
21
+ show to the user.
22
+ - It never contains a database URL. The server scrubs `mysql://…` and similar.
23
+ - It never contains a secret, a key or a password hash.
24
+ - **401** means there is no platform session. The console then goes to the login page.
25
+ - **403 with `code: "STEP_UP_REQUIRED"`** means the action needs a fresh confirmation.
26
+ 1. The console asks the admin to confirm: their password, or a TOTP code once TOTP is enrolled.
27
+ 2. It sends `POST /auth/confirm`.
28
+ 3. It retries the original request once.
29
+
30
+ Rows marked **step-up** below require this.
31
+ - **403 with `code: "FORBIDDEN"`** means the admin's role doesn't allow the action (v1.1 role map).
32
+ - **403 with `code: "TOTP_ENROLLMENT_REQUIRED"`** means the session hasn't been verified with an authenticator yet (v1.1).
33
+ - Until it is, only `GET /me`, `POST /me/totp`, `POST /auth/confirm` and `POST /auth/logout` work.
34
+ - The console shows the enrolment screen.
35
+ - **403 with `code: "CSRF_INVALID"`** means the CSRF header is missing or stale. Reload the page.
36
+ - **429 with `code: "RATE_LIMITED"`** means too many failed sign-in, code or step-up attempts.
37
+ - **501 with `code: "NOT_IMPLEMENTED"`** means this server has no implementation for that action. The console shows it as unavailable.
38
+ - **CSRF.** Every non-GET request carries the `x-csrf-token` header. Its value is the platform CSRF cookie
39
+ `<prefix>_platform_csrf`, which the server sets at login. The cookie prefix is per product, e.g. `pp`
40
+ for procurementpro.
41
+ - **Sessions are a separate identity realm from tenant users.**
42
+ - The session cookie is httpOnly: `<prefix>_platform_session`.
43
+ - Idle timeout is 15 minutes; the absolute limit is 8 hours.
44
+ - **v1.1:** the idle window slides only inside the privileged 15-minute tier and never past the absolute limit. The token is new at every login and rotates on step-up and TOTP enrollment.
45
+ - **Secrets.** One-time secrets (new passwords, a revealed DB URL) are returned exactly once, with
46
+ `Cache-Control: no-store`.
47
+ - **IDs.** All ids are opaque strings.
48
+ - **Timestamps.** ISO-8601 strings, or `null`.
49
+
50
+ ## Session and identity
51
+
52
+ | Method | Path | Body / query | 200 response | Notes | Status |
53
+ |---|---|---|---|---|---|
54
+ | POST | `/auth/login` | `{ email, password }` | `{ ok: true, mfa: "none" \| "required" \| "enroll", ticket?: string, needsFactorEnrollment: boolean }` | Sets the session and CSRF cookies when `mfa` is `"none"` or `"enroll"`. 401 for wrong credentials (same message whether or not the email exists). 429 when throttled. | shipped (`mfa`, `ticket`, 429: v1.1) |
55
+ | POST | `/auth/totp` | `{ ticket, code }` | `{ ok: true }` | Second factor after `mfa: "required"`. Sets the cookies. | v1.1 |
56
+ | POST | `/auth/logout` | — | `{ ok: true }` | Revokes the session and clears both cookies. | shipped |
57
+ | POST | `/auth/confirm` | `{ password }`, or `{ code }` once TOTP is enrolled | `{ ok: true }` + rotated cookies | Step-up, valid 5 minutes. A wrong secret answers 401 `STEP_UP_FAILED` (audited as `platform.stepup.failed`). | shipped (`code`, rotation: v1.1) |
58
+ | GET | `/me` | — | `PlatformMe` | Who is signed in, their permissions, and which optional features this server has. Open to enrollment-only sessions. | v1.1 |
59
+ | POST | `/me/totp` | `{ action: "begin" }` → `{ secret, otpauthUrl }`; `{ action: "verify", code }` → `{ ok: true, enrolled: true }` + rotated cookies | — | Enrol one's own authenticator. A lost device is reset by another admin (`admins/:id` `reset-totp`). **step-up**; the login itself counts for 5 minutes. | v1.1 |
60
+
61
+ `PlatformMe` has this shape:
62
+
63
+ ```json
64
+ {
65
+ "admin": { "id": "…", "email": "…", "roles": ["superadmin"], "totpEnrolled": true },
66
+ "permissions": ["companies.read", "companies.write", "admins.write", "jobs.write", "db.reveal", "plans.write", "settings.write", "support.grant"],
67
+ "access": { "console": true, "stepUpFresh": false },
68
+ "features": { "jobs": true, "plans": true, "settings": true, "support": true, "domains": true, "audit": true },
69
+ "app": { "key": "procurementpro", "name": "ProcurementPro", "companyLoginPath": "/c/:slug/login" }
70
+ }
71
+ ```
72
+
73
+ **Roles.** `superadmin` holds every permission. `support` holds `companies.read`, `admins.read`, `jobs.read`, `audit.read`, `plans.read` and `support.request`.
74
+
75
+ Until `/me` exists, the console falls back to the shipped feature set (overview, companies, admins, and
76
+ jobs when `GET /jobs` answers `enabled: true`).
77
+
78
+ ## Overview
79
+
80
+ | Method | Path | Response | Status |
81
+ |---|---|---|---|
82
+ | GET | `/overview` | `{ counts: { total, active, setup, suspended, archived, failedJobs, dbFailures }, attention: CompanyRow[], recent: AuditEvent[], provisioner: { ok, configured, principal?, error? } }` | shipped |
83
+
84
+ ## Platform admins
85
+
86
+ | Method | Path | Body | Response | Notes | Status |
87
+ |---|---|---|---|---|---|
88
+ | GET | `/admins` | — | `{ admins: PlatformAdmin[], me: string }` | `me` is the caller's admin id | shipped |
89
+ | POST | `/admins` | `{ email, roles? }` | `{ ok, email, password }` | One-time password. **step-up** | shipped (`roles`: v1.1) |
90
+ | POST | `/admins/:id` | `{ action: "reset-password" \| "disable" \| "enable" \| "reset-totp" }` or `{ action: "set-roles", roles }` | `{ ok, password? }` | You can't disable yourself, reset your own TOTP or change your own roles. The last active admin and the last superadmin are kept. **step-up** | shipped (`reset-totp`, `set-roles`: v1.1) |
91
+
92
+ ## Companies
93
+
94
+ | Method | Path | Body / query | Response | Notes | Status |
95
+ |---|---|---|---|---|---|
96
+ | GET | `/companies` | `?q=&status=` | `{ companies: CompanyRow[] }` | Newest first, at most 500 | shipped |
97
+ | POST | `/companies` | `{ displayName, slug, branch: { code, name }, database: { mode: "managed" } \| { mode: "external", url }, admin?: { fullName, email, phone? } }` | `{ company, job, admin? }` | Provisions the company. `admin.password` is one-time. | shipped |
98
+ | GET | `/companies/slug-available` | `?slug=` or `?name=` | `{ slug, available, reason? }` | | shipped |
99
+ | GET | `/companies/:id` | — | `{ company: CompanyDetail, users: CompanyUser[] }` | | shipped |
100
+ | PATCH | `/companies/:id` | `{ displayName?, slug?, status? }` | `{ ok, company }` | Changing `slug` or `status` needs **step-up** | shipped |
101
+ | GET | `/companies/:id/activity` | — | `{ jobs: ProvisioningJob[], events: AuditEvent[] }` | | shipped |
102
+ | POST | `/companies/:id/provision` | `{ database?, admin? }` | `{ mode, job, admin?, adminError? }` | Finishes a draft or failed setup | shipped |
103
+ | GET | `/companies/:id/admins` | — | `{ admins: CompanyUser[] }` | Read from the company's own database | shipped |
104
+ | POST | `/companies/:id/admins` | `{ fullName, email, phone? }` | `{ ok, email, password }` | One-time password | shipped |
105
+ | POST | `/companies/:id/admins/:userId` | `{ action: "reset-password" \| "disable" \| "enable" }` | `{ ok, password? }` | **step-up** | shipped |
106
+ | POST | `/companies/:id/branches` | `{ code, name }` | `{ ok, branch }` | Mirrored into the company database | shipped |
107
+ | PATCH | `/companies/:id/branches/:branchId` | `{ name?, status? }` | `{ ok }` | | shipped |
108
+ | GET | `/companies/:id/database` | — | `DatabaseSummary` (redacted: host and db name only) | | shipped |
109
+ | POST | `/companies/:id/database` | `{ action: "test" \| "sync" }` or `{ action: "replace", url }` | `{ ok, … }` | `replace` needs **step-up** | shipped |
110
+ | PUT | `/companies/:id/database` | `{ action: "reveal", reason }` | `{ url }` | Needs credential custodian + **step-up** + a reason of 8+ characters. The audit event is written before the URL is read. `no-store`. | shipped |
111
+ | POST | `/databases/inspect` | `{ url, companyId? }` | `InspectResult` | Checks a candidate database. Never echoes the URL. | shipped |
112
+ | GET | `/provisioning/:jobId` | — | `{ job }` | | shipped |
113
+ | POST | `/provisioning/:jobId` | — | `{ ok, job }` | Retry. 409 if the job can't be retried. **step-up** | shipped |
114
+
115
+ **Company statuses:** `draft`, `provisioning`, `provisioning_failed`, `active`, `maintenance`, `suspended`,
116
+ `archived`.
117
+
118
+ ## Background jobs
119
+
120
+ This whole section is optional. Without Redis or `@dreamtree-org/jobs`, `GET /jobs` answers
121
+ `{ enabled: false }` and the console hides **Jobs**.
122
+
123
+ | Method | Path | Body / query | Notes | Status |
124
+ |---|---|---|---|---|
125
+ | GET | `/jobs` | — | `{ enabled, definitions, queueNames, defaultTimezone, staleAfterSeconds, queues, queuesError, workers, stats24h }` | shipped |
126
+ | GET | `/jobs/stats` | `?days&company&schedule&tz` | Daily series | shipped |
127
+ | GET | `/jobs/runs` | `?status&job&company&schedule&from&to&cursor` | `{ runs, nextCursor }` | shipped |
128
+ | POST | `/jobs/runs` | `{ jobName, companyId?, branchId?, payload? }` | Run now. **step-up** | shipped |
129
+ | GET | `/jobs/runs/:id` | — | `{ run }` | shipped |
130
+ | POST | `/jobs/runs/:id` | `{ action: "retry" \| "cancel" }` | **step-up** | shipped |
131
+ | GET | `/jobs/runs/export` | same filters | `text/csv` (formula-injection safe). `X-Truncated: 1` when cut off. | shipped |
132
+ | GET | `/jobs/schedules` | `?company` | `{ schedules }` | shipped |
133
+ | POST | `/jobs/schedules` | `{ jobName, cron, timezone?, companyId?, branchId?, payload?, enabled? }` | **step-up** | shipped |
134
+ | GET / PATCH / DELETE | `/jobs/schedules/:id` | PATCH: any POST field | PATCH and DELETE need **step-up** | shipped |
135
+ | POST | `/jobs/schedules/:id` | `{ action: "run" }` | **step-up** | shipped |
136
+ | GET | `/jobs/schedules/preview` | `?cron&tz` | `{ next: string[] }` or 400 | shipped |
137
+ | POST | `/jobs/queues/:name` | `{ action: "pause" \| "resume" }` | **step-up** | shipped |
138
+ | POST | `/jobs/workers/:id` | `{ action: "pause" \| "resume" \| "shutdown" }` | **step-up** | shipped |
139
+
140
+ ## New in v1.1
141
+
142
+ Each feature here is advertised in `GET /me` → `features`. The console shows a feature only when it is
143
+ advertised.
144
+
145
+ ### Audit
146
+
147
+ | Method | Path | Body / query | Response / notes | Status |
148
+ |---|---|---|---|---|
149
+ | GET | `/audit` | `?q&action&actor&company&from&to&cursor` | `{ events: AuditEvent[], nextCursor }` | v1.1 |
150
+ | GET | `/audit/export` | same filters | `text/csv` | v1.1 |
151
+
152
+ ### Plans and subscriptions
153
+
154
+ | Method | Path | Body | Response / notes | Status |
155
+ |---|---|---|---|---|
156
+ | GET | `/plans` | — | `{ plans: Plan[] }` | v1.1 |
157
+ | POST | `/plans` | `Plan` without `id` | **step-up** | v1.1 |
158
+ | PATCH | `/plans/:id` | any `Plan` field | Archive with `{ status: "archived" }`; plans are never deleted. **step-up** | v1.1 |
159
+ | GET | `/companies/:id/subscription` | — | `{ subscription: Subscription \| null, history: Subscription[] }` | v1.1 |
160
+ | PUT | `/companies/:id/subscription` | `{ planId, status, startsAt?, endsAt?, seats?, notes? }` | **step-up** | v1.1 |
161
+
162
+ ### Platform settings
163
+
164
+ | Method | Path | Body | Response / notes | Status |
165
+ |---|---|---|---|---|
166
+ | GET | `/settings` | `?company=` (omit for platform defaults) | `{ families: SettingsFamily[] }`, with secret values masked | v1.1 |
167
+ | PUT | `/settings/:family` | `{ companyId?, values }` | Secret fields are write-only. **step-up** | v1.1 |
168
+
169
+ ### Support access
170
+
171
+ Support access goes through expiring grants, so being a superadmin gives no standing access to company
172
+ data (knowledge doc §5.8).
173
+
174
+ | Method | Path | Body | Response / notes | Status |
175
+ |---|---|---|---|---|
176
+ | GET | `/support-access` | `?company&status` | `{ grants: SupportGrant[] }` | v1.1 |
177
+ | POST | `/support-access` | `{ companyId, reason, minutes }` | Request a grant | v1.1 |
178
+ | POST | `/support-access/:id` | `{ action: "approve" \| "revoke" }` | **step-up** | v1.1 |
179
+
180
+ ### Domains
181
+
182
+ | Method | Path | Body | Response / notes | Status |
183
+ |---|---|---|---|---|
184
+ | GET | `/companies/:id/domains` | — | `{ domains: CompanyDomain[] }` | v1.1 |
185
+ | POST | `/companies/:id/domains` | `{ host }` | `{ domain, verification: { type: "TXT", name, value } }` | v1.1 |
186
+ | POST | `/companies/:id/domains/:domainId` | `{ action: "verify" \| "make-primary" \| "remove" }` | `remove` needs **step-up** | v1.1 |
187
+
188
+ ## Shapes
189
+
190
+ The TypeScript definitions are in `platform.d.ts`. The main ones:
191
+
192
+ ```ts
193
+ type CompanyRow = { id; slug; displayName; status; createdAt; branchCount; dbMode: 'managed'|'external'|null;
194
+ dbStatus; schemaVersion; lastCheckAt; lastCheckOk: 0|1|null };
195
+ type PlatformAdmin = { id; email; status: 'active'|'disabled'; roles: string[]; totpEnrolled; lastLoginAt; createdAt };
196
+ type AuditEvent = { id; at; actorEmail; action; companyId; companyName?; target; diff? };
197
+ ```
198
+
199
+ ## Audit actions
200
+
201
+ The server writes one `platform_audit_events` row per mutating call. Action names are dotted:
202
+
203
+ - `platform.login`, `platform.login.failed`, `platform.stepup`, `platform.stepup.failed`
204
+ - `platform.admin.create`, `platform.admin.reset-password`, `platform.admin.disable`
205
+ - `company.create`, `company.update`, `company.status`
206
+ - `company.admin.create`, `branch.create`
207
+ - `database.test`, `database.replace`, `database.credentials.reveal`
208
+ - `job.run`, `job.retry`, `schedule.create`
209
+ - `plan.update`, `subscription.update`, `settings.update`
210
+ - `support.grant.request`, `support.grant.approve`
211
+ - `domain.add`
212
+
213
+ Products may add their own actions under their app key.
package/README.md CHANGED
@@ -54,6 +54,7 @@ Every star ⭐, share, and contribution helps more than you know. Thank you for
54
54
  - `<LandingPage brand={brand} content={content} />` — a product's landing page is a content file.
55
55
  - `<AuthShell brand={brand} title="Welcome back">…</AuthShell>` (also available at `@dreamtree-org/landing-kit/auth` + `@dreamtree-org/landing-kit/auth.css`).
56
56
  - The built-in `brands` / `getBrand(id)` registry is **deprecated** (warns once, removed in 0.5.0).
57
+ - **Platform admin console** (`@dreamtree-org/landing-kit/platform`, 0.8.0+): one HTTP contract ([PLATFORM-API.md](./PLATFORM-API.md)) that every product's super-administrator console speaks, and its browser client (`createPlatformClient`, `createStepUp`). The server half is `createPlatformApi` in `@dreamtree-org/tenancy`. The shared React shell and views (overview, companies, jobs, admins, audit, plans, settings, support access) build on this client.
57
58
  - Plain CSS (`--dt-*` custom properties). It looks the same under Tailwind 3, Tailwind 4 (preflight or not) or no Tailwind. Dark mode via `[data-theme="dark"]` or `.dark`.
58
59
  - Built for prerendered, **un-hydrated** pages (fast and crawlable): `enhanceLanding()` wires the menu and the demo button in a few hundred bytes. It also works hydrated in an SPA or Next.js (`'use client'`, `onBookDemo`, `linkComponent`).
59
60
  - Responsive from 320px up. The nav folds into a menu below 1024px. Checked with Playwright at 320/375/768/1024/1440, light and dark.
@@ -201,6 +202,32 @@ import '@dreamtree-org/landing-kit/styles.css';
201
202
  - `newSection(type)` / `sectionDefaults(type)` start a section with valid defaults; `defaultPage({ name, tagline })`
202
203
  is a neutral starting page.
203
204
 
205
+ ### Store documents (0.9.0): landing, listing and product pages as one document
206
+
207
+ A store's whole storefront design is one version-2 document, validated with `validateStore` from `/schema`:
208
+
209
+ ```js
210
+ { version: 2,
211
+ pages: { home: PageDoc /* the landing page above */,
212
+ listing: { template: 'classic' | 'topbar' | 'editorial', settings, blocks },
213
+ product: { template: 'classic' | 'immersive' | 'compact', settings, blocks } },
214
+ chrome: { announcement, header, footer }, // CHROME_FIELDS
215
+ card: { image_ratio, badges, spec_line, compare, quick_add, mrp, hover } } // CARD_FIELDS
216
+ ```
217
+
218
+ - `STORE_PAGES[page]` lists each page's `settings` (field defs, so a builder generates the form), `templates`
219
+ (setting values + default block order) and `slots`: the commerce blocks the app renders (results, filters,
220
+ gallery, buy box…). `required` slots can be moved, never removed or hidden, so a page always sells.
221
+ - Between the slots a page may place content sections from `CONTENT_BLOCKS` (promo banner, FAQ, USP strip…),
222
+ validated with the same rules as landing-page sections. At most `MAX_BLOCKS` (12) blocks per page.
223
+ - A version-1 landing page passed to `validateStore` is migrated (`migrateStore`): it becomes `pages.home`
224
+ and everything else takes its defaults. `isLegacyPage(doc)` tells them apart.
225
+ - Render a template page with `<TemplateRegion blocks slots theme resolveMedia />`: `slots[type]` is the app's
226
+ component for a commerce block (`{ block, props }`; return null to place it yourself), content sections render
227
+ like `TenantPage`.
228
+ - Helpers: `templatePage(page, template)`, `newBlock(page, type)`, `blockDef(page, type)`, `defaultStore`,
229
+ `defaultChrome`, `defaultCard`, `storeMediaRefs(doc)` (every `{ media_id }` to check before storing), `validateFields`.
230
+
204
231
  ### `<AuthShell>` & `@dreamtree-org/landing-kit/auth`
205
232
 
206
233
  The shared auth frame for login, signup, password reset, consent and platform-admin sign-in (`desktop [ brand panel 40% | form card ]`, `mobile [ logo · form card · footer · legal links ]`). Import from `@dreamtree-org/landing-kit` or the lightweight `@dreamtree-org/landing-kit/auth` subpath (with `@dreamtree-org/landing-kit/auth.css` or `styles.css`).
@@ -381,6 +408,75 @@ and render `<DemoDialog open={open} onClose={…} onSubmit={…} brand={brand} /
381
408
  `<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdScript(buildJsonLd({ site: origin, … })) }} />`,
382
409
  and in `app/robots.ts` / `app/sitemap.ts` use `buildRobots` / `buildSitemap` with `site: origin`, or return Next's metadata objects from the same data.
383
410
 
411
+ **Platform admin console (`./platform`).** The console speaks only [PLATFORM-API.md](./PLATFORM-API.md), mounted at `/api/platform` by default:
412
+
413
+ ```js
414
+ import { createPlatformClient, createStepUp } from '@dreamtree-org/landing-kit/platform';
415
+
416
+ const api = createPlatformClient({ basePath: '/api/platform', loginPath: '/platform/login' }); // CSRF header from *_platform_csrf
417
+ const sensitive = createStepUp(api, () => askPasswordDialog()); // resolves { password } | { code } | null
418
+ const { companies } = await api.get('companies', { q: 'acme' });
419
+ const { password } = await sensitive(() => api.post(`admins/${id}`, { action: 'reset-password' }));
420
+ ```
421
+
422
+ A 401 sends the browser to `loginPath`.
423
+
424
+ **The React console (`PlatformConsole`, `PlatformLogin`).**
425
+
426
+ It's built on `@dreamtree-org/twreact-ui` ^2, an optional peer that only `./platform` needs. Your Tailwind setup has to scan the console too.
427
+
428
+ Tailwind 3 (`tailwind.config.js`):
429
+
430
+ ```js
431
+ content: [
432
+ './node_modules/@dreamtree-org/twreact-ui/dist/**/*.{js,mjs}',
433
+ './node_modules/@dreamtree-org/landing-kit/dist/platform.{js,cjs}',
434
+ ]
435
+ ```
436
+
437
+ Tailwind 4: add `@source "../node_modules/@dreamtree-org/landing-kit/dist/platform.js";` instead.
438
+
439
+ Next.js App Router:
440
+
441
+ ```tsx
442
+ // app/platform/(console)/[[...path]]/console.tsx ('use client')
443
+ 'use client';
444
+ import Link from 'next/link';
445
+ import { usePathname, useRouter } from 'next/navigation';
446
+ import { PlatformConsole } from '@dreamtree-org/landing-kit/platform';
447
+
448
+ export default function Console({ me }) {
449
+ const router = useRouter();
450
+ return (
451
+ <PlatformConsole
452
+ brand={brand}
453
+ initialMe={me}
454
+ pathname={usePathname()}
455
+ navigate={router.push}
456
+ linkAs={Link}
457
+ pages={[{ path: 'jobs', label: 'Background jobs', feature: 'jobs', render: () => <JobsView /> }]}
458
+ companyTabs={[{ id: 'settings', label: 'Settings', render: ({ company }) => <CompanySettings id={company.id} /> }]}
459
+ />
460
+ );
461
+ }
462
+
463
+ // app/platform/login/page.tsx
464
+ <PlatformLogin brand={brand} redirectTo="/platform" /> // plus '@dreamtree-org/landing-kit/auth.css'
465
+ ```
466
+
467
+ A Vite or Express SPA can render `<PlatformConsole brand={brand} />` with no router props; it uses `window.location` and `history`. Serve `index.html` for `/platform/*`.
468
+
469
+ Built-in pages:
470
+
471
+ - Overview
472
+ - Companies (list, 4-step wizard, detail with Overview / Branches / Database / Admins / Activity)
473
+ - Audit log
474
+ - Platform admins
475
+ - Security (authenticator setup)
476
+
477
+ Product pages come in through `pages`; procurementpro's jobs console is one. A session without a verified authenticator only sees the setup screen.
478
+ A 403 `STEP_UP_REQUIRED` is retried once after `auth/confirm`. Other failures throw `PlatformApiError` (`status`, `code`, and a message that is safe to show).
479
+
384
480
  ## What it deliberately does NOT do
385
481
 
386
482
  - No product copy, prices, ratings or customer counts. Every word comes from your content file.
@@ -0,0 +1,97 @@
1
+ # landing-kit — AI assistant reference
2
+
3
+ `@dreamtree-org/landing-kit` is the shared public face and admin entry point of every Dreamtree Pro product:
4
+
5
+ - the marketing site
6
+ - the sign-in frame
7
+ - the Platform (super-administrator) console contract
8
+
9
+ Read this before writing any landing page, login screen or platform admin code in a Pro app. Don't hand-roll any of the three.
10
+
11
+ ## Entry points
12
+
13
+ | Import | What | Runs in |
14
+ |---|---|---|
15
+ | `@dreamtree-org/landing-kit` | `LandingPage`, `MarketingLayout`, `Header`, `Hero`, `FeatureSection`, `Faq`, `DemoCta`, `DemoDialog`, `AuthShell`, `toLandingBrand`, `TenantPage` | React (`'use client'`) |
16
+ | `@dreamtree-org/landing-kit/auth` | `AuthShell`, `toLandingBrand`, `DemoAccounts` only (a small bundle for login pages) | React |
17
+ | `@dreamtree-org/landing-kit/platform` | `createPlatformClient`, `createStepUp`, `PlatformApiError`, `enabledFeatures`, `formatDate`, `timeAgo` | Browser, any framework |
18
+ | `@dreamtree-org/landing-kit/server` | `landingRoutes`, `prerenderPages`, `sessionCheck`, `fillPage` | Express, Node (CJS-safe) |
19
+ | `@dreamtree-org/landing-kit/seo` | JSON-LD, robots, sitemap, `llms.txt` builders | Anywhere |
20
+ | `@dreamtree-org/landing-kit/schema` | `validatePage`, `validateTheme`, `themeVars` for tenant pages | Anywhere |
21
+ | `@dreamtree-org/landing-kit/enhance` | `enhanceLanding()` for prerendered pages | Browser |
22
+ | `@dreamtree-org/landing-kit/styles.css`, `/auth.css` | Plain CSS on `--dt-*` variables | |
23
+
24
+ ## Hard rules
25
+
26
+ 1. **Brand comes from the product repo.** Use `toLandingBrand(require('./dreamtree.brand.json'), copy)`. `getBrand(id)` is deprecated, so don't add new calls to it.
27
+ 2. **No product copy, prices or icons inside the package.** Words live in the product's `content.js`. Icons are props.
28
+ 3. **Sign-in screens use `AuthShell`.** Tenant users get `variant="tenant"` (the default). Super-administrators get `variant="platform"`.
29
+ 4. **Platform admin is a separate identity realm.**
30
+ - It has its own cookie (`<prefix>_platform_session`), its own CSRF cookie (`<prefix>_platform_csrf`) and its own login at `/platform/login`.
31
+ - Never reuse the tenant session, and never give a tenant role platform powers.
32
+ 5. **Platform console code talks only through the contract.**
33
+ - The contract is `PLATFORM-API.md`, shipped in this package.
34
+ - Never call `fetch('/api/platform/...')` by hand. Use `createPlatformClient()`.
35
+ - Never hand-roll a step-up retry. Use `createStepUp()`.
36
+ - The server side is `createPlatformApi` from `@dreamtree-org/tenancy` (`./next` or `./express`). Don't write `app/api/platform/**` routes per product.
37
+
38
+ ## Platform console client
39
+
40
+ ```js
41
+ import { createPlatformClient, createStepUp, isStepUpRequired, PlatformApiError } from '@dreamtree-org/landing-kit/platform';
42
+
43
+ const api = createPlatformClient({
44
+ basePath: '/api/platform', // where the contract is mounted
45
+ loginPath: '/platform/login', // where a 401 sends the browser
46
+ // csrfCookie: 'pp_platform_csrf' // default: any *_platform_csrf cookie
47
+ });
48
+
49
+ const { counts, attention } = await api.get('overview');
50
+ const { companies } = await api.get('companies', { q: 'acme', status: 'active' });
51
+
52
+ // Sensitive actions (rows marked step-up in PLATFORM-API.md):
53
+ const sensitive = createStepUp(api, async (reason) => openConfirmDialog(reason.message)); // → { password } | { code } | null
54
+ const { password } = await sensitive(() => api.post(`admins/${id}`, { action: 'reset-password' }));
55
+ ```
56
+
57
+ - **Paths are relative to `basePath`.** Write `'companies'`, not `'/api/platform/companies'`.
58
+ - **Errors.** Failures throw `PlatformApiError` with `status`, `code` and a `message` that is safe to show. Show the message as it is. Never show a raw response body.
59
+ - **Cancelled step-up.** A step-up the admin cancels rejects with `code: 'CANCELLED'`. Treat it as a no-op, not an error toast.
60
+ - **Enrollment gate.** A 403 `TOTP_ENROLLMENT_REQUIRED`, or `me.access.console === false`, means show the authenticator enrolment screen (`POST me/totp` with `begin`, then `verify`), not an error.
61
+ - **One-time secrets.** New passwords and revealed DB URLs come back exactly once. Show them in a copy-once panel, and never log them or put them in state that persists.
62
+ - **Feature gating.** Call `GET me` (v1.1 servers) and pass the result to `enabledFeatures(PLATFORM_FEATURES, me)`. That decides which nav items to show: jobs, plans, audit, support, settings. Older servers fall back to overview, companies, jobs and admins.
63
+
64
+ ## React console
65
+
66
+ - **Mounting.** Mount `<PlatformConsole brand pathname navigate linkAs pages companyTabs initialMe />` once, under `/platform`. Don't write per-route console pages in a product.
67
+ - **Sign-in.** `<PlatformLogin brand redirectTo="/platform" />` is the sign-in page.
68
+ - **Tailwind.** Tailwind must scan `node_modules/@dreamtree-org/landing-kit/dist/platform.*` and twreact-ui's dist. If it doesn't, the console renders unstyled.
69
+ - **Product additions.** Product-only screens (background jobs, company settings families) go in `pages` and `companyTabs`. Gate them with `feature` or `permission`; don't fork the console.
70
+ - **Inside a page or tab.** Use `usePlatform()` (client, `can`, `href`, `Link`) and `useSensitive()` for anything that needs step-up.
71
+
72
+ ## Console features (PLATFORM-API.md)
73
+
74
+ - **Shipped** (procurementpro is the reference):
75
+ - Overview: counts, needs attention, recent audit
76
+ - Companies: list, 4-step new-company wizard, detail with overview, branches, database (test, sync, replace, reveal), admins, jobs and activity
77
+ - Background jobs: runs, schedules, workers and queues
78
+ - Platform admins
79
+ - **v1.1:**
80
+ - TOTP sign-in and enrolment, login throttle, `GET /me` with a role-based permission list
81
+ - Global audit viewer and CSV export
82
+ - Plans and per-company subscriptions
83
+ - Platform and company settings (secret fields are write-only)
84
+ - Support-access grants
85
+ - Company domains
86
+
87
+ ## Security invariants (do not weaken)
88
+
89
+ - **What the browser never sees:** database credentials, encryption keys or password hashes. The one exception is the audited, custodian-only `PUT companies/:id/database {action:"reveal"}`.
90
+ - **Server-side authorization on every endpoint.** Hiding a button is not access control.
91
+ - **Step-up is required for:**
92
+ - status and slug changes
93
+ - DB replace and reveal
94
+ - every admin action
95
+ - every job mutation
96
+ - plan, settings and grant approval changes
97
+ - **Superadmin is not standing access to company business data.** Support access goes through expiring grants that the company approves.