43 Commits

Author SHA1 Message Date
sdarbinyan
c2a56571af feat(bootstrap): fall back to built-in placeholder when marketplace unpublished
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
Adds published: boolean to the bootstrap wire contract. ConfigService
swaps to a new DEFAULT_BOOTSTRAP constant (all feature flags on, generic
branding/theme/pages) whenever the backend reports published: false, so
an unpublished marketplace renders a working demo instead of a blank or
broken page. Missing published field stays backward compatible (treated
as true). Documents the brand bootstrap wire shape for backend/ops use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:02:42 +04:00
sdarbinyan
55634b3b57 Merge improvements/fork-harvest into main
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
Fork-harvest brings: the ip-api.com geo fix, credential bundle scan,
mock gateways out of production, JIT compiler dropped (1.55->1.04 MB),
host hardening, provider-agnostic identity + VK/Yandex + account linking,
and the backend contracts consolidated into one BACKEND-INTEGRATION.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/backend/BACKEND-HANDOFF.md
#	docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md
2026-08-22 16:19:55 +04:00
sdarbinyan
846004e6d8 ci(deploy): make API-domain reconciliation opt-in, document the real host layout
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
The Reconcile tenant API domains step ran on every push to main. On the
production host that is actively harmful: api.gorbushka.market already has
a hand-written vhost, and configure-api-domain.sh writes its own file per
domain - so the step would hand nginx a second server block for a
server_name that already has one and re-run certbot against a live API,
once per deploy. Shipping frontend files needs none of it. Gate it behind
a workflow_dispatch input, off by default, for standing up a NEW base
domain.

This also shrinks the secrets a normal deploy requires to four
(DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_KEY, DEPLOY_KNOWN_HOSTS);
STOREFRONT_DOMAINS, CERTBOT_EMAIL and BACKEND_UPSTREAM are now read only
on the opt-in path.

Document the production host as it actually is: provisioned by hand before
server-setup.sh existed, per-domain vhosts rooted at
/var/www/dexarmarket/browser, which is now a symlink to
/srv/marketplaces/current/frontend. Before 2026-08-22 it pointed straight
at a pinned release with no `current` in between, so releases 14d46ce and
98c39f6 uploaded successfully and were never served.
2026-08-22 16:08:13 +04:00
sdarbinyan
d4959bd4da docs(e2e): clear the stale known-issue markers, root cause found
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
checkout-request-shape.spec.ts and checkout-idempotent-click.spec.ts were
flagged known-failing pending investigation; dda0a3d found and fixed the
actual cause (circular DI in apiHeadersInterceptor). Update the comments
and README so they no longer point at an unresolved mystery.
2026-08-21 22:45:47 +04:00
sdarbinyan
dda0a3d2df fix(auth): break circular DI that logged every returning session out
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
apiHeadersInterceptor injected @marketplaces/auth's AuthService to attach
a WebSessionID header. AuthService's own constructor makes a synchronous
GET /users/sessions/:id call to verify a persisted session, which runs
through this exact interceptor - Angular throws NG0200 (circular
dependency) mid-construction, silently swallowed by the package's
catchError(() => of(null)), read as "session invalid," and the cookie
gets cleared on every single page load. This is what was gating the
architecture-governance e2e job on Gitea (3 checkout tests failing on a
disabled QR button). Session-check requests are the identity mechanism
itself and never needed that header - skip AuthService injection for
them instead.

Also fixes mock-data.interceptor's session-check mock, which required
3 polls before reporting an id active with no way to represent a
returning session with an already-valid cookie - not the actual trigger
for this bug (useMockData is false in the dev config CI uses), but a
real gap in the mock's fidelity worth closing while in this file.
2026-08-21 22:44:04 +04:00
sdarbinyan
885f4d1299 Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-21 13:33:45 +04:00
98c39f6844 fix(auth): clarify admin login flow
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
Use admin-specific Telegram copy and define the missing credential API. Replace predictable bootstrap passwords with random one-time secrets.
2026-08-21 07:39:30 +04:00
14d46ceaa6 fix(admin): isolate login on admin host
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
Allow runtime request headers through API preflight and keep the storefront shell hidden while admin QR authentication gates backoffice.
2026-08-20 20:15:21 +04:00
2e41e216c0 Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-20 16:29:22 +04:00
bc74fa77d9 Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-20 16:12:57 +04:00
3c53a6a33e Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Failing after 6m33s
Deploy Frontend / deploy (push) Failing after 2m53s
2026-08-20 15:05:57 +04:00
8e58ee85f0 Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-20 14:54:44 +04:00
6d25172a13 Merge branch 'B2B'
Some checks failed
Deploy Frontend / deploy (push) Failing after 2m35s
Architecture Governance / architecture (push) Has been cancelled
2026-08-20 14:50:09 +04:00
4288e5cd44 Merge branch 'B2B'
Some checks failed
Deploy Frontend / deploy (push) Failing after 1m45s
Architecture Governance / architecture (push) Has been cancelled
2026-08-20 14:46:01 +04:00
e8c48043ed Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-20 14:43:02 +04:00
2602d0c838 Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-20 14:31:53 +04:00
3f550de6b2 Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-20 14:24:08 +04:00
sdarbinyan
217ab37496 Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-18 22:12:24 +04:00
sdarbinyan
c06ae56d88 Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-18 22:08:44 +04:00
sdarbinyan
8cdafbe62a Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-18 22:03:31 +04:00
sdarbinyan
9344f2702c Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-18 21:57:12 +04:00
sdarbinyan
4247a7f83f Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-18 21:44:10 +04:00
sdarbinyan
0f042fd384 Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-18 21:38:45 +04:00
sdarbinyan
c17d351cd1 Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-18 21:23:47 +04:00
sdarbinyan
ec4b01e1b4 Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-18 21:08:07 +04:00
sdarbinyan
c83d783ff7 Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-18 21:00:49 +04:00
sdarbinyan
62d3045f0c Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-18 17:10:42 +04:00
sdarbinyan
0df8d3d592 Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-18 14:30:03 +04:00
sdarbinyan
7fe5ac7cd4 Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-18 14:14:30 +04:00
sdarbinyan
1bdca917b3 Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-18 14:03:14 +04:00
sdarbinyan
8a68be797a Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-18 13:46:54 +04:00
sdarbinyan
1a198252b3 Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-18 13:28:58 +04:00
sdarbinyan
cee5048d74 Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-18 13:15:24 +04:00
sdarbinyan
00c7a62e51 Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-18 12:22:04 +04:00
sdarbinyan
3318b34f1e Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Deploy Frontend / deploy (push) Has been cancelled
2026-08-18 11:42:17 +04:00
sdarbinyan
0089285373 Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
2026-08-18 11:22:44 +04:00
sdarbinyan
7224bc56c2 merge: B2B into main
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
2026-08-18 02:08:09 +04:00
sdarbinyan
f079ef6f52 merge: B2B into main
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
2026-08-18 01:47:47 +04:00
sdarbinyan
a8f7ca31f9 merge: B2B into main
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
2026-08-18 01:33:57 +04:00
sdarbinyan
1ebfd206ce docs: platform super-admin Phase 1 design spec
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 20:14:01 +04:00
sdarbinyan
65ce2ef23c merge: B2B into main
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 18:53:03 +04:00
sdarbinyan
65663ad6ec merge: B2B into main
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 17:51:27 +04:00
sdarbinyan
3480efedd1 merge: B2B into main
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 16:18:31 +04:00
28 changed files with 1675 additions and 34 deletions

View File

@@ -13,6 +13,15 @@ on:
description: Branch or SHA to deploy
required: false
default: main
reconcile_api_domains:
description: >-
Also provision api.<base-domain> nginx vhosts and TLS. Off by default:
existing API domains are configured by hand, and re-running the helper
writes a second server block for a server_name that already has one.
Turn this on only when adding a NEW base domain.
type: boolean
required: false
default: false
concurrency:
group: deploy-frontend
@@ -82,7 +91,13 @@ jobs:
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
# Opt-in only. api.<base-domain> vhosts already exist and are hand-managed;
# the helper writes its own file per domain, so running it unconditionally
# would give nginx two server blocks for one server_name and re-run certbot
# against a live API on every single deploy. Frontend releases do not need
# this step - it is for standing up a NEW base domain.
- name: Reconcile tenant API domains
if: ${{ inputs.reconcile_api_domains }}
env:
HOST: ${{ secrets.DEPLOY_HOST }}
USER: ${{ secrets.DEPLOY_USER }}

373
docs/BRAND-BOOTSTRAP.md Normal file
View File

@@ -0,0 +1,373 @@
# Brand bootstrap — full JSON reference
What one JSON document must contain to turn this codebase into a live, branded marketplace. Frontend is Angular 22, multi-tenant, one bundle for every domain — a brand is 100% config, zero code or rebuild. Source of truth for wire shape: [`bootstrap-config.model.ts`](../src/app/shared/models/config/bootstrap-config.model.ts) and its per-section models in the same folder. Backend contract: [`BACKEND-INTEGRATION.md`](backend/BACKEND-INTEGRATION.md). Deploy/domain/TLS mechanics: [`DEPLOYMENT.md`](DEPLOYMENT.md).
## How it works
1. Request arrives at `https://<any-domain>`.
2. nginx forwards the verified `Host` to the API as `X-Storefront-Host`. **Tenant identity comes only from this header — never from a client-supplied field.**
3. SPA calls `GET /bootstrap` (also proxied through `api.<base-domain>`).
4. Backend resolves tenant from the host, returns this JSON. Frontend renders entirely from it — theme, nav, pages, feature flags, locales.
5. One backend, many brands: each `Marketplace` row + its `MarketplaceDomain` rows is a brand. No per-brand deploy.
Acceptance check used in CI: `curl -fsS https://api.<domain>/bootstrap | jq -e 'type=="object"'`.
## Minimal path to a new brand
1. Backend: create a `Marketplace` row (`docs/backend/BACKEND-INTEGRATION.md` §11) and at least one `MarketplaceDomain` (`type: 'production'`).
2. Point the domain's DNS A record at the server.
3. TLS: either it's a `*.yourapex.com` subdomain (wildcard, zero extra work — [`DEPLOYMENT.md`](DEPLOYMENT.md) §4.1) or a customer's own domain (`add-domain.sh`, §4.4, or the `sync-domains.sh` reconciler, §4.2).
4. `configure-api-domain.sh` for the base domain — creates `api.<domain>` (backend proxy, CORS, cert). One API hostname per base domain; subdomains reuse it.
5. Backend returns a populated bootstrap JSON for that `Host`. Nothing to redeploy on the frontend side.
6. Verify: `curl -I https://<domain>/health` (nginx, expect 200) and `curl -fsS https://api.<domain>/bootstrap | jq .` (backend, expect the object below).
---
## Full annotated example
```json
{
"schemaVersion": "1.0.0",
"generatedAt": "2026-08-22T00:00:00Z",
"tenant": {
"id": "tenant-acme-001",
"slug": "acme",
"code": "ACME",
"host": "shop.acme.com",
"name": "Acme Marketplace",
"websiteBaseUrl": "https://shop.acme.com",
"builderBaseUrl": "https://builder.shop.acme.com",
"backofficeBaseUrl": "https://backoffice.shop.acme.com",
"defaultLocale": "en",
"supportedLocales": ["en", "ru"],
"defaultCurrency": "USD",
"supportedCurrencies": ["USD", "EUR"],
"timezone": "America/New_York",
"documentationUrl": "https://docs.shop.acme.com"
},
"branding": {
"brandName": "Acme",
"legalName": "Acme Commerce LLC",
"slogan": "Everything, delivered",
"logoUrl": "https://cdn.acme.com/logo.svg",
"logoCompactUrl": "https://cdn.acme.com/logo-compact.svg",
"faviconUrl": "https://cdn.acme.com/favicon.ico",
"appIconUrl": "https://cdn.acme.com/icon-192.png",
"supportEmail": "support@acme.com",
"supportPhone": "+1-555-000-0000"
},
"theme": {
"themeId": "acme-light",
"mode": "light",
"palette": {
"primary": "#1a56db",
"secondary": "#7e8a97",
"accent": "#60a5fa",
"success": "#10b981",
"warning": "#f59e0b",
"danger": "#ef4444",
"info": "#3b82f6",
"textPrimary": "#111827",
"textSecondary": "#6b7280",
"backgroundPrimary": "#ffffff",
"backgroundSecondary": "#f9fafb",
"border": "#e5e7eb"
},
"typography": {
"primaryFontFamily": "Inter, sans-serif",
"headingFontFamily": "Inter, sans-serif",
"baseFontSize": 16
},
"spacing": { "unit": 4, "scale": [0, 4, 8, 12, 16, 24, 32, 48] },
"borderRadiusScale": { "sm": "6px", "md": "10px", "lg": "14px", "xl": "20px" },
"shadows": {
"sm": "0 2px 8px rgba(0,0,0,0.1)",
"md": "0 4px 12px rgba(0,0,0,0.15)",
"lg": "0 12px 32px rgba(26,86,219,0.2)"
},
"iconSet": "default"
},
"company": {
"companyName": "Acme Commerce LLC",
"registrationNumber": "0000000000",
"taxId": "00-0000000",
"address": {
"country": "USA",
"region": "NY",
"city": "New York",
"street": "5th Ave 1",
"postalCode": "10001"
},
"contacts": {
"email": "support@acme.com",
"phone": "+1-555-000-0000",
"telegram": "@acme_support",
"website": "https://acme.com"
}
},
"featureFlags": {
"wishlist": true, "compare": true, "reviews": true, "blog": false,
"chat": false, "analytics": true, "notifications": true,
"coupons": true, "loyalty": false, "giftCards": false, "invoices": true
},
"apiEndpoints": {
"bootstrap": { "path": "/bootstrap", "method": "GET", "timeoutMs": 10000 },
"website": {}, "builder": {}, "backoffice": {}
},
"localization": {
"defaultLocale": "en",
"supportedLocales": ["en", "ru"],
"currencyByLocale": { "en": "USD", "ru": "RUB" },
"dictionaries": [
{ "locale": "en", "dictionaryUrl": "/assets/i18n/en.json", "version": "1.0.0" },
{ "locale": "ru", "dictionaryUrl": "/assets/i18n/ru.json", "version": "1.0.0" }
]
},
"seo": {
"default": { "title": "Acme", "description": "Everything, delivered", "robots": "index,follow" },
"byPageKey": {
"home": { "title": "Acme - Home", "description": "Everything, delivered", "canonicalUrl": "https://shop.acme.com/", "robots": "index,follow" }
}
},
"permissions": {
"definitions": [
{ "key": "builder.pages.edit", "description": "Edit pages in builder" },
{ "key": "backoffice.products.read", "description": "Read products in backoffice" }
],
"roles": [
{ "role": "builder_admin", "permissions": ["builder.pages.edit"] },
{ "role": "backoffice_manager", "permissions": ["backoffice.products.read"] }
]
},
"header": { "showLogo": true, "showSearch": true, "showCategories": true, "showCart": true, "sticky": true, "layout": "default" },
"layout": { "type": "default" },
"navigation": {
"header": [
{ "id": "nav-home", "labelKey": "nav.home", "route": "/", "icon": "home", "order": 1 },
{ "id": "nav-search", "labelKey": "nav.search", "route": "/search", "icon": "search", "order": 2 },
{ "id": "nav-cart", "labelKey": "nav.cart", "route": "/cart", "icon": "cart", "order": 3 }
],
"footer": [
{ "id": "footer-about", "labelKey": "nav.about", "route": "/about-us", "order": 1 },
{ "id": "footer-privacy", "labelKey": "nav.privacy", "route": "/privacy-policy", "order": 2 }
]
},
"footer": {
"paymentIcons": [{ "src": "/assets/images/visa-logo.svg", "alt": "Visa", "width": 40, "height": 28 }],
"copyrightText": { "en": "© 2026 Acme. All rights reserved.", "ru": "© 2026 Acme. Все права защищены." },
"legalPageKeys": ["about-us", "privacy-policy", "terms-of-service"]
},
"catalog": {
"layout": "grid",
"navigationMode": "default",
"defaultSort": "relevance",
"availableSorts": ["relevance", "latest", "price_asc", "price_desc", "rating", "popular", "discount"],
"enabledFilters": ["price", "availability", "rating", "brand", "category"],
"showBreadcrumbs": true, "showCategoryBanner": true, "showRatings": true,
"showDiscounts": true, "showAvailability": true, "suggestionsEnabled": true, "searchHistoryEnabled": true
},
"productPage": {
"rating": { "enabled": true },
"reviews": { "enabled": true, "pageSize": 5, "showSummary": true },
"questions": { "enabled": true, "pageSize": 5 },
"tabs": { "enabled": true, "items": ["description", "specifications", "reviews", "questions", "delivery", "warranty"] },
"relatedProducts": { "enabled": true }
},
"userExperience": {
"wishlist": { "enabled": true, "headerBadgeEnabled": true },
"compare": { "enabled": true, "maxItems": 4, "hideIdenticalDefault": false, "highlightDifferencesDefault": true },
"recentlyViewed": { "enabled": true, "maxItems": 12, "widgetEnabled": true },
"share": { "enabled": true },
"continueBrowsing": { "enabled": true },
"savedSearches": { "enabled": true, "maxItems": 10 }
},
"features": {
"wishlist": true, "compare": true, "reviews": true, "comments": true,
"questions": true, "recommendations": true, "recentlyViewed": true,
"searchHistory": true, "recentlySearched": true, "ratings": true,
"share": true, "brands": true, "manufacturers": true,
"availability": true, "discounts": true, "badges": true
},
"widgetRegistry": { "manifestUrl": "https://api.acme.com/widget-manifest.json" },
"staticPages": {
"about-us": {
"route": "/about-us",
"title": { "en": "About Us", "ru": "О компании" },
"html": { "en": "<h2>About Us</h2><p>...</p>", "ru": "<h2>О компании</h2><p>...</p>" }
}
},
"pages": [
{
"id": "page-home", "key": "home", "title": "Home",
"route": { "path": "/", "exact": true },
"layout": { "type": "default" },
"seoKey": "home", "visible": true,
"sections": [
{
"id": "section-hero", "type": "hero", "order": 1,
"layout": { "strategy": "hero", "columns": 1, "gap": "1.5rem", "align": "stretch" },
"visibility": { "desktop": true, "tablet": true, "mobile": true },
"visible": true,
"widgets": [
{
"id": "widget-hero-main", "type": "hero", "version": "1.0.0", "order": 1,
"padding": "0.5rem 0",
"visibility": { "desktop": true, "tablet": true, "mobile": true },
"visible": true,
"props": {
"title": { "en": "Welcome to Acme", "ru": "Добро пожаловать в Acme" },
"subtitle": { "en": "Everything, delivered", "ru": "Всё, с доставкой" },
"ctaLabel": { "en": "Start Shopping", "ru": "Начать покупки" }
}
}
]
}
]
}
],
"modules": { "sellerManagement": { "enabled": false } }
}
```
---
## Field reference
### `tenant` (required) — [`tenant.model.ts`](../src/app/shared/models/config/tenant.model.ts)
Identity and locale/currency defaults. `host` must exactly match the domain nginx forwards — mismatches are how tenant leakage bugs happen. `websiteBaseUrl` / `builderBaseUrl` / `backofficeBaseUrl` are the three surfaces this same brand can present (storefront, page builder, admin backoffice) — each gets its own subdomain or host.
### `branding` (required) — [`branding.model.ts`](../src/app/shared/models/config/branding.model.ts)
Everything a human sees as "this is the brand": name, logo variants, favicon, support contact. `logoCompactUrl` is used where header space is tight (mobile, collapsed nav).
### `theme` (required) — [`theme.model.ts`](../src/app/shared/models/config/theme.model.ts)
Full design-token set: color palette, typography, spacing scale, border radii, shadows. Consumed by [`theme-css-vars.mapper.ts`](../src/app/theme/mappers/theme-css-vars.mapper.ts) → CSS custom properties at runtime. `mode` is `light` or `dark`; ship a matching palette for whichever `themeId` you pick.
### `company` (required) — [`company.model.ts`](../src/app/shared/models/config/company.model.ts)
Legal/registration data for invoices, footer legal text, compliance pages. Not user-facing branding — this is the registered entity behind the brand.
### `featureFlags` (required) — [`feature-flags.model.ts`](../src/app/shared/models/config/feature-flags.model.ts)
Coarse on/off switches for major product areas (wishlist, blog, chat, loyalty, gift cards, invoices...). Distinct from `features` below — this set gates bigger surfaces.
### `features` (optional) — [`features-config.model.ts`](../src/app/shared/models/config/features-config.model.ts)
Finer-grained per-marketplace toggles (comments, recommendations, badges, etc). Omit any key to fall back to `DEFAULT_MARKETPLACE_FEATURES_CONFIG` (all `true`).
### `apiEndpoints` (required) — [`api-endpoints.model.ts`](../src/app/shared/models/config/api-endpoints.model.ts)
Per-surface endpoint overrides. `bootstrap` itself is always required; `website`/`builder`/`backoffice` may stay empty objects to use defaults.
### `localization` (required) — [`localization.model.ts`](../src/app/shared/models/config/localization.model.ts)
Locale list, default, per-locale currency, and dictionary URLs (`/assets/i18n/<locale>.json` or a CDN URL). Every locale in `tenant.supportedLocales` needs an entry here.
### `seo` (required) — [`seo.model.ts`](../src/app/shared/models/config/seo.model.ts)
Default meta tags plus per-`pageKey` overrides, consumed by [`seo.service.ts`](../src/app/services/seo.service.ts).
### `permissions` (required) — [`permissions.model.ts`](../src/app/shared/models/config/permissions.model.ts)
Role → permission-key map used by frontend guards. The frontend never hardcodes role logic beyond hiding affordances — see `BACKEND-INTEGRATION.md` §4.6; the authoritative check still happens server-side per request.
### `header` (optional) — [`header-config.model.ts`](../src/app/shared/models/config/header-config.model.ts)
Which header elements show (`showSearch`, `showCart`, `showRegion`, ...) and `layout` (`default` | `centered`). Omit to use `DEFAULT_HEADER_CONFIG`.
### `catalog` (optional) — [`catalog-config.model.ts`](../src/app/shared/models/config/catalog-config.model.ts)
Product-listing behavior: layout, sort options, enabled filters, which badges/breadcrumbs show.
### `layout` (optional) — [`layout.model.ts`](../src/app/shared/models/config/layout.model.ts)
Top-level page shell type.
### `navigation` (required) — [`navigation.model.ts`](../src/app/shared/models/config/navigation.model.ts)
Header and footer link lists, each entry `{ id, labelKey, route, icon?, order }`. `labelKey` resolves against the locale dictionaries in `localization`.
### `footer` (optional) — [`footer-config.model.ts`](../src/app/shared/models/config/footer-config.model.ts)
Payment-method icons, per-locale copyright text, legal page keys to link.
### `productPage` (optional) — [`product-page-config.model.ts`](../src/app/shared/models/config/product-page-config.model.ts)
Reviews, questions, tabs, related-products behavior on the PDP.
### `userExperience` (optional) — [`user-experience-config.model.ts`](../src/app/shared/models/config/user-experience-config.model.ts)
Wishlist, compare, recently-viewed, share, saved-searches — limits and toggles.
### `pages` (required) — [`page.model.ts`](../src/app/shared/models/config/page.model.ts)
The actual page tree. Each page has a route, layout, and a `sections[]` list; each section has `layout` (`hero` | `grid` | `carousel` | ...), responsive `visibility`, and `widgets[]`. Each widget references a `type` + `version` resolved against the widget manifest (see `widgetRegistry`) and carries its own `props` (usually per-locale strings). This is what the page builder edits and what [`section-engine.service.ts`](../src/app/dynamic-renderer/section-engine/section-engine.service.ts) renders.
### `staticPages` (optional) — [`static-page.model.ts`](../src/app/shared/models/config/static-page.model.ts)
Simple route → per-locale `{ title, html }` pages (about, privacy, terms, contacts) that don't need the full section/widget builder.
### `widgetRegistry` (optional) — [`widget-registry.model.ts`](../src/app/shared/models/config/widget-registry.model.ts)
URL to the widget manifest — the catalog of widget types/versions this brand's `pages[].sections[].widgets[]` are allowed to reference. See [`widget-manifest.service.ts`](../src/app/widgets/registry/widget-manifest.service.ts).
### `modules` (optional) — [`platform-modules.model.ts`](../src/app/shared/models/config/platform-modules.model.ts)
Platform-level capability gates that introduce a whole new scope (currently just `sellerManagement`), not a simple toggle. Absent or `undefined` = every module disabled, and existing marketplaces that never send this field behave exactly as before (ADR-011). A disabled module must add zero new routes/menus/API calls.
### `seller` (optional, backend-resolved only) — [`seller.model.ts`](../src/app/shared/models/config/seller.model.ts)
Present only when `modules.sellerManagement.enabled` is `true` **and** the request resolves beneath a specific seller. The frontend never decides this itself — same rule as tenant resolution (ADR-001): the backend resolves scope from the verified host/session, never from a client-supplied field.
---
## Required vs optional at a glance
| Required | Optional (sensible defaults exist) |
|---|---|
| `schemaVersion`, `generatedAt` | `features` |
| `tenant` | `header` |
| `branding` | `catalog` |
| `theme` | `layout` |
| `company` | `footer` |
| `featureFlags` | `productPage` |
| `apiEndpoints` | `userExperience` |
| `localization` | `staticPages` |
| `seo` | `widgetRegistry` |
| `permissions` | `modules` |
| `navigation` | `seller` (backend-resolved, never client-set) |
| `pages` | |
## Going live — checklist
- [ ] `Marketplace` row created (§11 of `BACKEND-INTEGRATION.md`), `lifecycleState` progressed to `production_ready`
- [ ] `MarketplaceDomain` row(s) added, `type: 'production'`
- [ ] DNS A record → server IP
- [ ] TLS: wildcard subdomain (no action) or `add-domain.sh` / reconciler for a custom domain
- [ ] `api.<base-domain>` configured (`configure-api-domain.sh`) — CORS echoes the exact storefront origin, never `*` with credentials
- [ ] Backend returns full bootstrap JSON for that `Host` — validate with `curl -fsS https://api.<domain>/bootstrap | jq .`
- [ ] `curl -I https://<domain>/health``200`
- [ ] Every locale in `tenant.supportedLocales` has a `localization.dictionaries[]` entry and a `localization.currencyByLocale` entry
- [ ] `navigation.header`/`footer` routes match real routes; `staticPages`/`pages[].route` keys line up with `legalPageKeys`

View File

@@ -37,6 +37,21 @@ host; tenant subdomains do not create additional API DNS names.
nginx root is `/srv/marketplaces/current/frontend`. Activation is a symlink swap, so no request is ever served from a half-written directory, and a rollback is a symlink change rather than a rebuild.
**On the current production host there is one extra hop.** That server predates
`server-setup.sh` and was provisioned by hand, so instead of the catch-all vhost
it has per-domain configs (`gorbushka.conf`, `dexarmarket.conf`,
`gorbushka-admin.conf`, `gorbushka-landing.conf`) whose `root` is
`/var/www/dexarmarket/browser`. That path is itself a symlink:
```
/var/www/dexarmarket/browser -> /srv/marketplaces/current/frontend
```
so the release/`current` model above still holds and the workflow needs no
per-host special-casing. Until 2026-08-22 `browser` pointed straight at one
pinned release directory with no `current` in between, which is why two
successfully-uploaded releases sat unserved.
---
## 3. First-time setup
@@ -104,18 +119,26 @@ The output is the `DEPLOY_KNOWN_HOSTS` secret. Pinning it means a rebuilt or imp
### 3.4 Add CI secrets
Required for every deploy:
| Secret | Value |
|---|---|
| `DEPLOY_HOST` | server IP or hostname |
| `DEPLOY_USER` | `deploy` |
| `DEPLOY_SSH_KEY` | contents of the **private** key file |
| `DEPLOY_KNOWN_HOSTS` | output of `ssh-keyscan -H <server-ip>` |
Required **only** when running the workflow with `reconcile_api_domains` on
(§4.6) — a normal release deploy never reads these:
| Secret | Value |
|---|---|
| `STOREFRONT_DOMAINS` | space-separated full hosts, e.g. `gorbushka.market store1.example.com` |
| `CERTBOT_EMAIL` | operations email used for Let's Encrypt |
| `BACKEND_UPSTREAM` | optional; defaults to `https://127.0.0.1:445` |
Before deploying, point each base domain's shared API hostname at the server.
For `gorbushka.market` and `store1.gorbushka.market`, only
When that step does run, point each base domain's shared API hostname at the
server first. For `gorbushka.market` and `store1.gorbushka.market`, only
`api.gorbushka.market` is required. The workflow deduplicates
`STOREFRONT_DOMAINS` by base domain and deliberately stops before release
activation if DNS, certificate issuance, nginx validation, or the JSON
@@ -197,7 +220,24 @@ For a single domain, outside the reconciler:
sudo bash add-domain.sh shop.example.com --email ops@example.com --with-www
```
### 4.5 Verify
### 4.5 API domains in CD are opt-in
The deploy workflow's **Reconcile tenant API domains** step is gated behind the
`reconcile_api_domains` input and is **off for push-triggered deploys**.
`configure-api-domain.sh` writes `/etc/nginx/sites-available/api.<domain>` and
enables it. The API vhosts on the current production host were created by hand
under different filenames (`gorbushka-api.conf`), so running the helper there
produces a *second* server block for a `server_name` that already has one, and
re-runs certbot against a live API — on every deploy. Shipping frontend files
needs none of that.
Turn it on from the workflow-dispatch form only when standing up a **new** base
domain. Before the first such run, reconcile the naming: either delete the
hand-made vhost and let the helper own the name, or leave the step off and keep
managing API domains manually.
### 4.6 Verify
```bash
curl -I https://shop.example.com/health
@@ -219,6 +259,11 @@ sudo systemctl reload nginx
Only the last 5 releases are retained. Older ones need a rebuild from the tag.
The production host reaches releases through `/var/www/dexarmarket/browser ->
/srv/marketplaces/current/frontend` (§2), so moving `current` is all a rollback
needs there too — do not repoint `browser` at a release directly, or the next
deploy's swap will silently stop taking effect.
---
## 6. Operational checks

View File

@@ -37,6 +37,8 @@ New endpoints use `/api/v2/...`; legacy endpoints (documented in `../../BACKEND-
One deployed bundle serves **every** customer domain; there is no per-tenant build. The chain: `TenantResolverService` reads the browser hostname → `ApiConfigService` uses one API host per base domain (`example.com` and `store1.example.com` both use `api.example.com`) → nginx validates the browser origin and forwards the full storefront hostname as `X-Storefront-Host` → the backend resolves the tenant from that trusted header, **never** from the shared API `Host`, and treats the frontend-supplied hostname as an untrusted hint, deriving real scope from the authenticated session. A tenant must never read another tenant's data — return `403`, not an empty result. Bootstrap carries only what's needed before app start (branding, languages, homepage layout, navigation, enabled widgets, footer pages); never products, orders, cart, or users.
**`published: boolean`** — required top-level field on the bootstrap response (2026-08-22). `true` once the marketplace has a `publishedRevision` (§11); `false` while it has none — every other field may then be anything, including a partial/placeholder row, since the frontend ignores the rest of the body and renders its own built-in all-features-on placeholder instead (`ConfigService`, see [Brand-bootstrap design](../superpowers/specs/2026-08-22-frontend-default-bootstrap-design.md)). Field absent (old backend) is read as `true` for backward compatibility — do not omit it once built.
### 1.2 Auth — read before writing any endpoint
Auth lives in `@marketplaces/auth` (published from vitanovaPackages; see `../PACKAGES-USAGE.md`). Two mechanisms exist client-side:
@@ -46,6 +48,20 @@ Auth lives in `@marketplaces/auth` (published from vitanovaPackages; see `../PAC
**The critical gap:** the session API has no concept of "admin." The frontend only chooses where to *store* the result. **Every admin endpoint must independently verify authorization server-side** — client-side guards are UI convenience, never security. Admin requests carry `AdminWebSessionID: <sessionId>` (and `Authorization: Bearer <token>` once admin JWTs exist) on paths containing `/admin/`, `/backoffice/`, `/builder/`, `/media/`.
**Admin credential (login/password) auth — required, not yet built.** `admin.gorbushka.market` can authenticate via Telegram today; login/password is not implemented, so the frontend must not validate or embed admin credentials, and the Ed25519 `/admin-login` page is not production-ready (its challenge/verify endpoints don't exist). Tenant identity comes only from nginx's trusted `X-Storefront-Host` — never from the login body.
```http
POST /api/identity/v1/session { login, password }
-> { accessToken (short JWT), refreshToken (rotating opaque), expiresAt,
mustChangePassword, user: { id, login, displayName, roles[], tenantId } }
POST /api/identity/v1/session/refresh
DELETE /api/identity/v1/session
POST /api/identity/v1/session/change-password { currentPassword, newPassword }
GET /api/identity/v1/session/permissions
```
Errors: `400` malformed; `401 INVALID_CREDENTIALS` (one generic message for unknown login and wrong password); `403 TENANT_DISABLED` / `TENANT_MISMATCH`; `429 RATE_LIMITED` with `Retry-After`. While `mustChangePassword` is true, every non-auth admin endpoint returns `403 PASSWORD_CHANGE_REQUIRED`. Provisioning: random one-time bootstrap password (never `{slug}2026$`), store only an Argon2id hash with a unique salt, never log passwords/refresh tokens/authorization headers/session ids, rate-limit by tenant+login+source IP with backoff, rotate refresh tokens and revoke the full family on reuse, audit login success/failure + password change + refresh reuse + logout + lockout. nginx must preserve `proxy_set_header X-Storefront-Host $storefront_host; proxy_set_header Origin "";` and, for `Origin: https://admin.gorbushka.market`, resolve `$storefront_host` to `gorbushka.market`; API upstream stays `https://127.0.0.1:445`.
### 1.3 Infrastructure state (dev server `213.21.246.138`, user `seto`)
| Thing | State |
@@ -418,7 +434,8 @@ Write: `POST /companies/{id}/projects`, `/projects/{id}/stores`, `/stores/{id}/p
Append here whenever a section changes. Newest first.
- **2026-08-22** — Consolidated the entire `docs/backend/` set into this one file per the single-doc rule. No contract content changed; the former per-phase files are removed.
- **2026-08-22** — Added `published: boolean` to the bootstrap response contract (§1.1): frontend now renders a built-in generic placeholder (all feature flags on) for any marketplace with no published revision, decided from this one field rather than HTTP status. See [Brand-bootstrap design](../superpowers/specs/2026-08-22-frontend-default-bootstrap-design.md).
- **2026-08-22** — Consolidated the entire `docs/backend/` set into this one file per the single-doc rule; folded in the admin credential (login/password) auth handoff (§1.2). No contract content changed; the former per-phase files are removed.
- **2026-08-21** — Harvest additions (`FH-*`) folded in across §4§13, from the parallel-platform review ([ADR-0006](../context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md)): atomic reservation, inventory journal, idempotency constraints, session model, origin allowlist, secret envelope, order public token, revision immutability/clone/preview, tenant resolution hardening, server-side content validation, digital code pools, order-manager contour, provider-agnostic identity + VK/Yandex + Telegram migration, host hardening.
- **2026-08-18** — RoutingContext + Company/Project/PaymentPoint hierarchy added (partner provisioning); backend ownership answered (separate developer).
- **2026-08-17** — Payment chain freeze lifted (Sprint 0.1); FX source decided in-house.

View File

@@ -0,0 +1,596 @@
# Frontend Default Bootstrap Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** When a marketplace has no published revision, the frontend renders a built-in, all-features-on generic placeholder instead of a broken/empty page — decided from one explicit `published: boolean` field on the `/bootstrap` response, not from HTTP status.
**Architecture:** Add `published` to `BootstrapConfig`. Add one new frontend-only constant `DEFAULT_BOOTSTRAP: BootstrapConfig`, composed from existing `DEFAULT_HEADER_CONFIG` / `DEFAULT_MARKETPLACE_FEATURES_CONFIG` / `DEFAULT_PLATFORM_MODULES_CONFIG` plus a hardcoded generic shell for the sections with no existing default (`tenant`, `branding`, `theme`, `company`, `featureFlags`, `apiEndpoints`, `localization`, `seo`, `permissions`, `navigation`, `footer`, `pages`, `staticPages`). `ConfigService.loadBootstrap()` swaps its cached snapshot to `DEFAULT_BOOTSTRAP` whenever the fetched response has `published === false`. No provider changes.
**Tech Stack:** Angular 22, RxJS, Jasmine/Karma (existing `.spec.ts` pattern in this repo).
## Global Constraints
- `published` missing/undefined on a response must be treated as `true` (backward compatible — matches the existing pattern for `modules`/ADR-011).
- Fallback is a whole-object swap — no field-level merging with the real response.
- Fallback triggers only on the explicit `published: false` signal, never on HTTP failure (existing `catchError` behavior in `ConfigService` is untouched).
- `DEFAULT_BOOTSTRAP.featureFlags` and `.features` must have every flag `true`.
- Reuse `DEFAULT_HEADER_CONFIG`, `DEFAULT_MARKETPLACE_FEATURES_CONFIG`, `DEFAULT_PLATFORM_MODULES_CONFIG` as-is — do not redefine their values inline.
---
### Task 1: Add `published` to the `BootstrapConfig` contract
**Files:**
- Modify: `src/app/shared/models/config/bootstrap-config.model.ts`
- Modify: `src/assets/mock/bootstrap/bootstrap.json` (add `"published": true` so the existing mock keeps behaving as "already live")
**Interfaces:**
- Produces: `BootstrapConfig.published: boolean` — consumed by Task 3 (`ConfigService`).
- [ ] **Step 1: Add the field to the interface**
In `src/app/shared/models/config/bootstrap-config.model.ts`, add `published` right after `generatedAt`:
```ts
export interface BootstrapConfig {
schemaVersion: string;
generatedAt: string;
published: boolean;
tenant: TenantConfig;
branding: BrandingConfig;
theme: ThemeConfig;
company: CompanyConfig;
featureFlags: FeatureFlagsConfig;
features?: MarketplaceFeaturesConfig;
apiEndpoints: ApiEndpointsConfig;
localization: LocalizationConfig;
seo: SeoConfig;
permissions: PermissionsConfig;
header?: HeaderConfig;
catalog?: CatalogConfig;
layout?: PlatformLayoutConfig;
navigation: NavigationConfig;
footer?: FooterConfig;
productPage?: ProductPageConfig;
userExperience?: UserExperienceConfig;
pages: PageConfig[];
staticPages?: StaticPagesConfig;
widgetRegistry?: WidgetRegistryConfig;
modules?: PlatformModulesConfig;
seller?: SellerConfig;
}
```
- [ ] **Step 2: Update the mock fixture**
In `src/assets/mock/bootstrap/bootstrap.json`, add `"published": true,` as the line right after `"generatedAt": "2026-07-03T00:00:00Z",` (line 3).
- [ ] **Step 3: Compile check**
Run: `npx tsc --noEmit -p tsconfig.json`
Expected: no new errors referencing `bootstrap-config.model.ts` or `bootstrap.json` (the mock file isn't type-checked, but any TS consumer that builds a `BootstrapConfig` object literal without `published` will now fail — confirms the field is wired through).
- [ ] **Step 4: Commit**
```bash
git add src/app/shared/models/config/bootstrap-config.model.ts src/assets/mock/bootstrap/bootstrap.json
git commit -m "feat: add published field to BootstrapConfig contract"
```
---
### Task 2: Add the `DEFAULT_BOOTSTRAP` constant
**Files:**
- Create: `src/app/shared/models/config/default-bootstrap.const.ts`
- Modify: `src/app/shared/models/config/index.ts` (export the new file)
- Test: `src/app/shared/models/config/default-bootstrap.const.spec.ts`
**Interfaces:**
- Consumes: `BootstrapConfig` (Task 1), `DEFAULT_HEADER_CONFIG` from `./header-config.model`, `DEFAULT_MARKETPLACE_FEATURES_CONFIG` from `./features-config.model`, `DEFAULT_PLATFORM_MODULES_CONFIG` from `./platform-modules.model`.
- Produces: `DEFAULT_BOOTSTRAP: BootstrapConfig` — consumed by Task 3 (`ConfigService`).
- [ ] **Step 1: Write the failing test**
Create `src/app/shared/models/config/default-bootstrap.const.spec.ts`:
```ts
import { DEFAULT_BOOTSTRAP } from './default-bootstrap.const';
describe('DEFAULT_BOOTSTRAP', () => {
it('is marked unpublished', () => {
expect(DEFAULT_BOOTSTRAP.published).toBe(false);
});
it('has every feature flag turned on', () => {
Object.values(DEFAULT_BOOTSTRAP.featureFlags).forEach(value => {
expect(value).toBe(true);
});
});
it('has every optional MarketplaceFeaturesConfig flag turned on', () => {
expect(DEFAULT_BOOTSTRAP.features).toBeDefined();
Object.values(DEFAULT_BOOTSTRAP.features!).forEach(value => {
expect(value).toBe(true);
});
});
it('has at least one page with a hero section', () => {
expect(DEFAULT_BOOTSTRAP.pages.length).toBeGreaterThan(0);
const heroSection = DEFAULT_BOOTSTRAP.pages[0].sections.find(s => s.type === 'hero');
expect(heroSection).toBeDefined();
});
it('has a generic brand name, not a real tenant name', () => {
expect(DEFAULT_BOOTSTRAP.branding.brandName).toBe('Marketplace');
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `ng test --include='**/default-bootstrap.const.spec.ts' --watch=false`
Expected: FAIL — `Cannot find module './default-bootstrap.const'`
- [ ] **Step 3: Write the constant**
Create `src/app/shared/models/config/default-bootstrap.const.ts`:
```ts
import { BootstrapConfig } from './bootstrap-config.model';
import { DEFAULT_HEADER_CONFIG } from './header-config.model';
import { DEFAULT_MARKETPLACE_FEATURES_CONFIG } from './features-config.model';
import { DEFAULT_PLATFORM_MODULES_CONFIG } from './platform-modules.model';
/**
* Whole-object fallback rendered whenever the backend reports
* `published: false` for the resolved marketplace (no published revision
* yet). Every feature flag is on so it doubles as a full-surface product
* demo. See docs/superpowers/specs/2026-08-22-frontend-default-bootstrap-design.md.
*/
export const DEFAULT_BOOTSTRAP: BootstrapConfig = {
schemaVersion: '1.0.0',
generatedAt: new Date(0).toISOString(),
published: false,
tenant: {
id: 'tenant-default-unpublished',
slug: 'default',
code: 'DEFAULT',
host: 'default.local',
name: 'Marketplace',
websiteBaseUrl: 'https://marketplace.local',
builderBaseUrl: 'https://builder.marketplace.local',
backofficeBaseUrl: 'https://backoffice.marketplace.local',
defaultLocale: 'en',
supportedLocales: ['en'],
defaultCurrency: 'USD',
supportedCurrencies: ['USD'],
timezone: 'UTC',
},
branding: {
brandName: 'Marketplace',
legalName: 'Marketplace',
slogan: 'Your store, coming soon',
logoUrl: '/icons/icon-192x192.png',
logoCompactUrl: '/icons/icon-192x192.png',
faviconUrl: '/favicon.ico',
appIconUrl: '/icons/icon-192x192.png',
supportEmail: 'support@marketplace.local',
},
theme: {
themeId: 'default-light',
mode: 'light',
palette: {
primary: '#497671',
secondary: '#a1b4b5',
accent: '#a7ceca',
success: '#10b981',
warning: '#f59e0b',
danger: '#ef4444',
info: '#3b82f6',
textPrimary: '#1e3c38',
textSecondary: '#667a77',
backgroundPrimary: '#ffffff',
backgroundSecondary: '#f5f5f5',
border: '#d3dad9',
},
typography: {
primaryFontFamily: 'DM Sans, sans-serif',
headingFontFamily: 'DM Sans, sans-serif',
baseFontSize: 16,
},
spacing: { unit: 4, scale: [0, 4, 8, 12, 16, 24, 32, 48] },
borderRadiusScale: { sm: '8px', md: '12px', lg: '16px', xl: '22px' },
shadows: {
sm: '0 2px 8px rgba(0,0,0,0.1)',
md: '0 4px 12px rgba(0,0,0,0.15)',
lg: '0 12px 32px rgba(73,118,113,0.2)',
},
iconSet: 'default',
},
company: {
companyName: 'Marketplace',
address: { country: '', city: '' },
contacts: { email: 'support@marketplace.local' },
},
featureFlags: {
wishlist: true,
compare: true,
reviews: true,
questions: true,
comments: true,
recommendations: true,
blog: true,
chat: true,
analytics: true,
notifications: true,
coupons: true,
loyalty: true,
giftCards: true,
invoices: true,
},
features: DEFAULT_MARKETPLACE_FEATURES_CONFIG,
apiEndpoints: {
bootstrap: { path: '/bootstrap', method: 'GET', timeoutMs: 10000 },
website: {},
builder: {},
backoffice: {},
},
localization: {
defaultLocale: 'en',
supportedLocales: ['en'],
currencyByLocale: { en: 'USD' },
dictionaries: [{ locale: 'en', dictionaryUrl: '/assets/i18n/en.json', version: '1.0.0' }],
},
seo: {
default: { title: 'Marketplace', description: 'Your store, coming soon', robots: 'noindex,nofollow' },
byPageKey: {
home: { title: 'Marketplace - Home', description: 'Your store, coming soon', robots: 'noindex,nofollow' },
},
},
permissions: { definitions: [], roles: [] },
header: DEFAULT_HEADER_CONFIG,
navigation: {
header: [
{ id: 'nav-home', labelKey: 'nav.home', route: '/', icon: 'home', order: 1 },
{ id: 'nav-search', labelKey: 'nav.search', route: '/search', icon: 'search', order: 2 },
{ id: 'nav-cart', labelKey: 'nav.cart', route: '/cart', icon: 'cart', order: 3 },
],
footer: [
{ id: 'footer-about', labelKey: 'nav.about', route: '/about-us', order: 1 },
{ id: 'footer-contacts', labelKey: 'nav.contacts', route: '/contacts', order: 2 },
],
},
footer: {
paymentIcons: [],
copyrightText: { en: '© 2026 Marketplace. All rights reserved.' },
legalPageKeys: ['about-us', 'privacy-policy', 'terms-of-service'],
},
staticPages: {
'about-us': {
route: '/about-us',
title: { en: 'About Us' },
html: { en: '<h2>About Us</h2><p>This marketplace has not published its storefront yet.</p>' },
},
'privacy-policy': {
route: '/privacy-policy',
title: { en: 'Privacy Policy' },
html: { en: '<h2>Privacy Policy</h2><p>Placeholder content until publish.</p>' },
},
'terms-of-service': {
route: '/terms-of-service',
title: { en: 'Terms of Service' },
html: { en: '<h2>Terms of Service</h2><p>Placeholder content until publish.</p>' },
},
},
pages: [
{
id: 'page-home',
key: 'home',
title: 'Home',
route: { path: '/', exact: true },
layout: { type: 'default' },
seoKey: 'home',
visible: true,
sections: [
{
id: 'section-hero',
type: 'hero',
order: 1,
layout: { strategy: 'hero', columns: 1, gap: '1.5rem', align: 'stretch' },
visibility: { desktop: true, tablet: true, mobile: true },
visible: true,
widgets: [
{
id: 'widget-hero-main',
type: 'hero',
version: '1.0.0',
order: 1,
padding: '0.5rem 0',
visibility: { desktop: true, tablet: true, mobile: true },
visible: true,
props: {
title: { en: 'Welcome to Marketplace' },
subtitle: { en: 'This storefront has not been published yet' },
ctaLabel: { en: 'Learn more' },
},
},
],
},
{
id: 'section-categories',
type: 'categories',
order: 2,
layout: { strategy: 'grid', columns: 1, gap: '1.5rem', align: 'stretch' },
visibility: { desktop: true, tablet: true, mobile: true },
visible: true,
widgets: [
{
id: 'widget-categories-root',
type: 'categories',
version: '1.0.0',
order: 1,
padding: '0.25rem 0',
visibility: { desktop: true, tablet: true, mobile: true },
visible: true,
props: { title: 'Categories', source: 'root', emptyMessage: 'No categories available' },
},
],
},
],
},
],
modules: DEFAULT_PLATFORM_MODULES_CONFIG,
};
```
- [ ] **Step 4: Export it from the barrel file**
In `src/app/shared/models/config/index.ts`, add one line (alphabetical position, after `catalog-config.model`):
```ts
export * from './default-bootstrap.const';
```
- [ ] **Step 5: Run test to verify it passes**
Run: `ng test --include='**/default-bootstrap.const.spec.ts' --watch=false`
Expected: PASS (5 specs)
- [ ] **Step 6: Commit**
```bash
git add src/app/shared/models/config/default-bootstrap.const.ts src/app/shared/models/config/default-bootstrap.const.spec.ts src/app/shared/models/config/index.ts
git commit -m "feat: add DEFAULT_BOOTSTRAP placeholder config"
```
---
### Task 3: Swap to `DEFAULT_BOOTSTRAP` in `ConfigService` when unpublished
**Files:**
- Modify: `src/app/core/config/config.service.ts`
- Test: `src/app/core/config/config.service.spec.ts` (new file — none exists today)
**Interfaces:**
- Consumes: `DEFAULT_BOOTSTRAP` (Task 2), `BootstrapConfig.published` (Task 1), existing `CONFIG_PROVIDER` token / `ConfigProvider.loadBootstrap()`.
- Produces: no new public method — `loadBootstrap()` and `getBootstrapSnapshot()` keep their existing signatures; behavior changes only in which object ends up cached.
- [ ] **Step 1: Write the failing tests**
Create `src/app/core/config/config.service.spec.ts`:
```ts
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { ConfigService } from './config.service';
import { CONFIG_PROVIDER } from './config-provider.token';
import { ConfigProvider } from './config-provider.interface';
import { BootstrapConfig, DEFAULT_BOOTSTRAP } from '../../shared/models/config';
function makeRealBootstrap(overrides: Partial<BootstrapConfig> = {}): BootstrapConfig {
return { ...DEFAULT_BOOTSTRAP, published: true, tenant: { ...DEFAULT_BOOTSTRAP.tenant, name: 'Acme' }, ...overrides };
}
describe('ConfigService', () => {
let provider: jasmine.SpyObj<ConfigProvider>;
function setup(response: BootstrapConfig): ConfigService {
provider = jasmine.createSpyObj<ConfigProvider>('ConfigProvider', ['loadBootstrap']);
provider.loadBootstrap.and.returnValue(of(response));
TestBed.configureTestingModule({
providers: [ConfigService, { provide: CONFIG_PROVIDER, useValue: provider }],
});
return TestBed.inject(ConfigService);
}
it('caches the real response when published is true', done => {
const real = makeRealBootstrap();
const service = setup(real);
service.loadBootstrap().subscribe(result => {
expect(result.tenant.name).toBe('Acme');
expect(service.getBootstrapSnapshot()).toEqual(real);
done();
});
});
it('swaps to DEFAULT_BOOTSTRAP when published is false', done => {
const draft = makeRealBootstrap({ published: false });
const service = setup(draft);
service.loadBootstrap().subscribe(result => {
expect(result).toEqual(DEFAULT_BOOTSTRAP);
expect(service.getBootstrapSnapshot()).toEqual(DEFAULT_BOOTSTRAP);
done();
});
});
it('treats a missing published field as published (backward compatible)', done => {
const legacy = makeRealBootstrap();
delete (legacy as Partial<BootstrapConfig>).published;
const service = setup(legacy);
service.loadBootstrap().subscribe(result => {
expect(result.tenant.name).toBe('Acme');
expect(result).not.toEqual(DEFAULT_BOOTSTRAP);
done();
});
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `ng test --include='**/config.service.spec.ts' --watch=false`
Expected: FAIL on the "swaps to DEFAULT_BOOTSTRAP" spec — `result` currently equals `draft` (unpublished, unswapped), not `DEFAULT_BOOTSTRAP`.
- [ ] **Step 3: Implement the swap**
Replace the body of `src/app/core/config/config.service.ts` with:
```ts
import { Injectable, inject, signal } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { catchError, map, shareReplay, tap } from 'rxjs/operators';
import { BootstrapConfig, DEFAULT_BOOTSTRAP } from '../../shared/models/config';
import { CONFIG_PROVIDER } from './config-provider.token';
@Injectable({ providedIn: 'root' })
export class ConfigService {
private readonly provider = inject(CONFIG_PROVIDER);
private bootstrapSnapshot: BootstrapConfig | null = null;
private bootstrap$?: Observable<BootstrapConfig>;
private readonly revisionState = signal(0);
readonly bootstrapRevision = this.revisionState.asReadonly();
loadBootstrap(forceRefresh: boolean = false): Observable<BootstrapConfig> {
if (this.bootstrapSnapshot && !forceRefresh && this.bootstrap$) {
return this.bootstrap$;
}
if (!this.bootstrap$ || forceRefresh) {
this.bootstrap$ = this.provider.loadBootstrap().pipe(
map(config => (config.published === false ? DEFAULT_BOOTSTRAP : config)),
tap(config => {
this.bootstrapSnapshot = config;
this.revisionState.update(value => value + 1);
}),
shareReplay(1),
catchError(error => {
this.bootstrap$ = undefined;
this.bootstrapSnapshot = null;
return throwError(() => error);
})
);
}
return this.bootstrap$;
}
getBootstrapSnapshot(): BootstrapConfig | null {
return this.bootstrapSnapshot;
}
applyBootstrapOverride(next: BootstrapConfig): void {
const cloned = JSON.parse(JSON.stringify(next)) as BootstrapConfig;
this.bootstrapSnapshot = cloned;
this.bootstrap$ = of(cloned);
this.revisionState.update(value => value + 1);
}
}
```
The only change from the current file: the `map` operator inserted before `tap`, and the `DEFAULT_BOOTSTRAP` import. `config.published === false` (strict) rather than `!config.published` is deliberate — it makes `undefined`/missing explicitly fall through to "treat as published," matching the backward-compatibility constraint.
- [ ] **Step 4: Run tests to verify they pass**
Run: `ng test --include='**/config.service.spec.ts' --watch=false`
Expected: PASS (3 specs)
- [ ] **Step 5: Run the full unit suite to check for regressions**
Run: `ng test --watch=false`
Expected: PASS, no new failures (existing consumers of `ConfigService` only rely on `loadBootstrap()`/`getBootstrapSnapshot()`, unchanged signatures).
- [ ] **Step 6: Commit**
```bash
git add src/app/core/config/config.service.ts src/app/core/config/config.service.spec.ts
git commit -m "feat: fall back to DEFAULT_BOOTSTRAP when marketplace is unpublished"
```
---
### Task 4: E2E smoke test for the unpublished placeholder
**Files:**
- Modify: `e2e/smoke.spec.ts` (existing Playwright smoke suite)
**Interfaces:**
- Consumes: Playwright route interception (`page.route`), `DEFAULT_BOOTSTRAP.branding.brandName` (Task 2) as the assertion target.
- [ ] **Step 1: Read the existing smoke spec to match its conventions**
Run: `cat e2e/smoke.spec.ts` (or open the file) — confirm the existing pattern for intercepting `/bootstrap` if one exists, and the base URL fixture used by other specs in this file.
- [ ] **Step 2: Write the failing test**
Add to `e2e/smoke.spec.ts`:
```ts
test('renders the placeholder home page when the marketplace is unpublished', async ({ page }) => {
await page.route('**/bootstrap', route =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ schemaVersion: '1.0.0', generatedAt: new Date().toISOString(), published: false }),
})
);
await page.goto('/');
await expect(page.getByText('Welcome to Marketplace')).toBeVisible();
});
```
- [ ] **Step 3: Run it to verify it fails**
Run: `npx playwright test e2e/smoke.spec.ts -g "unpublished"`
Expected: FAIL — either the route interception payload is rejected client-side (schema mismatch) or the text isn't found, since Task 13 aren't wired in yet if this task runs standalone. If Tasks 13 are already merged, this should already pass; if it fails for a reason other than "text not found" (e.g. a network error), fix the intercepted payload shape first, not the app code.
- [ ] **Step 4: Confirm it passes against the real implementation**
Run: `npx playwright test e2e/smoke.spec.ts -g "unpublished"`
Expected: PASS, once Tasks 13 are committed.
- [ ] **Step 5: Commit**
```bash
git add e2e/smoke.spec.ts
git commit -m "test: add e2e smoke test for unpublished-marketplace placeholder"
```
---
## Self-review notes
- **Spec coverage:** §1 (backend `published` field) → Task 1. §2 (whole-object swap, `DEFAULT_BOOTSTRAP` composed from existing `DEFAULT_*` constants) → Task 2. §2 (`ConfigService` trigger point) → Task 3. §3 (error handling: missing field = published, HTTP failure unchanged) → covered by Task 3 Step 1 test 3 and by leaving `catchError` untouched. §4 (testing) → Tasks 24 cover unit + E2E; schema-shape check is TypeScript compilation itself (Task 2 Step 3 must compile against `BootstrapConfig`).
- **Backend-side resolution logic** (how the backend decides `published` from `MarketplaceRevision.status`) is explicitly out of scope per the spec — not a frontend-repo task.

View File

@@ -0,0 +1,137 @@
# Platform Super-Admin — Phase 1 Design
**Status:** Approved
**Date:** 2026-08-15
**Audience:** Internal admin & risk team ("super puper user")
## Purpose
A cross-tenant view for internal admin/risk staff: see every project (store/tenant) on the
platform, drill into one, and review its access list, audit log, admin edit history, and
purchase history. Read-only in this phase.
Editing project data / impersonating a store's admin ("edit all", with a per-change "notify
this store's admin" toggle) is explicitly **out of scope** for this phase — see
[Phase 2](#phase-2-out-of-scope-here) below. Phase 1 exists first because Phase 2's edit and
notify plumbing depends on the tenant-context switch this phase builds.
## Non-goals (Phase 1)
- No editing of any tenant's data.
- No impersonation of a store's admin.
- No "notify store admin" mechanism (that's a Phase 2 concern, tied to edit actions that
don't exist yet).
- No real backend — this repo is frontend-only; the backend contract is specified here for
whoever owns that service, not implemented here.
## Architecture
- New top-level feature module: `src/app/features/platform-admin/`.
- New route tree `/platform-admin/**`, own shell/layout. **Not** nested under any tenant's
`/admin/**` — a project is not "logged into" the way a store admin is.
- New `platformAdminAuthGuard` (parallel to, but sharing no state with, `adminAuthGuard` in
`core/admin-auth/admin-auth.guard.ts`).
- `PlatformAuthService` — session/login state for the super-admin, backed by a
`PlatformAuthGateway` interface: `login(credentials)`, `logout()`, `session()`.
- `PlatformAuthLocalGateway` — dev-only implementation. Reads the expected credential from
a **git-ignored** local file (`platform-auth.local-secret.ts`, added to `.gitignore`),
never committed, never present in a production build path.
- `PlatformAuthApiGateway` — later swap-in once the backend endpoint exists; same
interface, no caller changes needed.
## Data model
```ts
interface PlatformProjectSummary {
id: UUID;
name: string;
slug: string;
host: string;
status: 'active' | 'suspended';
createdAt: number;
adminCount: number;
lastActivityAt: number | null;
}
interface PlatformProjectAccessEntry {
userId: UUID;
displayName: string;
telegramUsername: string;
roleId: string; // maps to existing AdminRole / ROLE_PERMISSIONS
}
type PlatformProjectHistoryEntry =
| { kind: 'access'; tenantId: UUID; actorLabel: string; timestamp: number; summary: string }
| { kind: 'edit'; tenantId: UUID; actorLabel: string; timestamp: number; summary: string }
| { kind: 'purchase'; tenantId: UUID; actorLabel: string; timestamp: number; summary: string };
```
- `PlatformProjectSummary[]` is produced by `PlatformProjectsGateway.list()`, which aggregates
the existing `TenantConfig` fixture list plus derived stats. Mock gateway now; real
aggregation is a backend concern later.
- `PlatformProjectAccessEntry` reuses the existing `AdminRole` / `ROLE_PERMISSIONS` shape from
`core/auth/models/permission.model.ts` — no new role system.
- `PlatformProjectHistoryEntry` is a discriminated union covering all three history types the
user asked for (access/audit, admin edit history, purchase history). Mock gateway simulates
aggregation from existing per-tenant sources (e.g. the pattern in
`AdminDashboardHistoryService`, `admin-transactions`); real aggregation is a backend concern.
- Every super-admin **view** into a project also writes its own `kind: 'access'` entry
(`platform.viewedProject`) — the risk team needs to know who looked at what, not just what
changed.
## Components / pages
- `PlatformProjectsListPageComponent` — table of all projects: name, status, admin count,
last activity. Search/filter by status.
- `PlatformProjectDetailPageComponent` — project overview stats, then tabs:
- **Access** — `PlatformProjectAccessEntry[]` for that tenant.
- **Audit Log** — `history` filtered to `kind: 'access'`.
- **Edit History** — `history` filtered to `kind: 'edit'`.
- **Purchase History** — `history` filtered to `kind: 'purchase'`.
- All read-only in this phase.
## Security
- `platformAdminAuthGuard` denies unless the session carries `platform.superadmin`. Like the
existing `AdminPermissionsService`, the frontend check is defense-in-depth only — real
enforcement must happen server-side once the backend endpoint exists. This is called out
explicitly so it's never mistaken for the source of truth.
- No credential is ever hardcoded in committed source. Dev-only credential lives in a
git-ignored local file; production auth goes through the real backend endpoint below.
- Session timeout for platform-admin: 15 minutes idle (shorter than regular tenant-admin
sessions — higher-privilege session, smaller blast radius if a session is left open).
- Every super-admin action (including read-only views) is itself audit-logged.
- After implementation, run `/security-audit` on this feature specifically before it ships.
### Backend contract (for whoever owns that service — not implemented in this repo)
Add to `BACKEND-API-REFERENCE.md`:
- `POST /platform-admin/auth` — verifies a hashed credential server-side, returns a session
token scoped to `platform.superadmin`. Never a plaintext credential check in a client-shipped
artifact.
- `GET /platform-admin/projects` — returns `PlatformProjectSummary[]`.
- `GET /platform-admin/projects/:id/history` — returns `PlatformProjectHistoryEntry[]` for
that tenant, paginated.
## Testing
- Unit tests: `platformAdminAuthGuard`, `PlatformProjectsGateway` (mock), history-aggregation
mapping logic.
- No E2E in this phase — no real backend to exercise end-to-end yet.
## Phase 2 (out of scope here)
A separate spec/plan cycle, once Phase 1 ships:
- Full edit / impersonation: super-admin acts as a tenant's admin across every existing admin
module (products, orders, categories, settings, etc.), reusing those modules under a
tenant-context switch.
- Per-edit-action **"notify this store's admin about this change"** checkbox, **default
unchecked**. Uses the existing in-app notification pattern (the one behind
`admin-order-watcher.service.ts`'s unread-badge flow) so the affected tenant's admin sees it
in their notification feed. Unchecked-by-default matters: some super-admin edits are
discreet technical fixes where alerting the store admin would be noise or a reputational
concern, not every edit should ping them.
- This phase needs the tenant-context switch and audit-logging plumbing this Phase 1 spec
establishes, which is why it's sequenced after.

View File

@@ -0,0 +1,85 @@
# Frontend default bootstrap (unpublished-marketplace placeholder)
**Date:** 2026-08-22
**Status:** approved (decided by project owner in-session, no further review requested)
## Problem
Production `/bootstrap` has no fallback today. A marketplace with no published revision either 404s or returns whatever partial row the backend has — frontend has nothing sane to render. Need a placeholder that shows immediately for any brand before its first publish, with every feature switched on so it doubles as a full product demo.
## Decision
Whole-object fallback, decided client-side from one explicit backend signal.
### 1. Backend contract change
Add one required top-level field to the `/bootstrap` response:
```ts
interface BootstrapConfig {
schemaVersion: string;
generatedAt: string;
published: boolean; // NEW — false until MarketplaceRevision.status = 'published'
tenant: TenantConfig;
...
}
```
`published` mirrors whether the marketplace has a `publishedRevision` (see `MarketplaceRevision.status` in `BACKEND-INTEGRATION.md` §11) — not `lifecycleState` directly, since a marketplace can be `live` while a *new* draft revision sits unpublished. Backend still returns full real `tenant`/`branding`/etc when `published: true`; when `false` it may return anything or the last-known real data — frontend ignores every other field in that case (see §2).
### 2. Frontend: whole-object swap
New constant, colocated with the other `DEFAULT_*` config constants:
```ts
// src/app/shared/models/config/default-bootstrap.const.ts
export const DEFAULT_BOOTSTRAP: BootstrapConfig = {
schemaVersion: '1.0.0',
generatedAt: new Date(0).toISOString(),
published: false,
tenant: { /* generic placeholder — brandName 'Marketplace', no real domain */ },
branding: { brandName: 'Marketplace', ... },
theme: { /* the existing default-light palette from bootstrap.json */ },
featureFlags: { wishlist: true, compare: true, reviews: true, blog: true, chat: true,
analytics: true, notifications: true, coupons: true, loyalty: true,
giftCards: true, invoices: true }, // everything ON
features: DEFAULT_MARKETPLACE_FEATURES_CONFIG, // reused, already all-true
header: DEFAULT_HEADER_CONFIG, // reused
modules: DEFAULT_PLATFORM_MODULES_CONFIG, // reused (sellerManagement off — real module gate, not a feature flag)
navigation: { /* hardcoded generic nav */ },
pages: [ /* hardcoded generic home page, hero+categories+featured, same shape as bootstrap.json */ ],
staticPages: { /* generic about/privacy/terms/contacts */ },
...
};
```
`ConfigService.loadBootstrap()` gains one check after the provider emits:
```ts
tap(config => {
const resolved = config.published ? config : DEFAULT_BOOTSTRAP;
this.bootstrapSnapshot = resolved;
this.revisionState.update(v => v + 1);
}),
```
No change to `ApiBootstrapProvider`, `MockBootstrapProvider`, or the `ConfigProvider` interface — the swap is a `ConfigService`-only concern, so it applies uniformly regardless of provider mode.
### 3. Error handling
- `published` missing/undefined from an old backend response → treat as `true` (backwards compatible: existing marketplaces that never send the field keep behaving exactly as today, same pattern already used for `modules`/ADR-011).
- Actual HTTP failure (network error, 5xx) stays a hard error — `catchError` behavior unchanged, no fallback. Fallback is only for the *known* "not published yet" case, not for "backend unreachable." (Matches your earlier answer: explicit signal, not HTTP-status-driven.)
### 4. Testing
- `ConfigService` unit test: `published: false` response → snapshot equals `DEFAULT_BOOTSTRAP`.
- `ConfigService` unit test: `published: true` → snapshot equals the real response, untouched.
- `ConfigService` unit test: `published` absent → snapshot equals the real response (back-compat).
- `DEFAULT_BOOTSTRAP` itself: a schema-shape test (it must satisfy `BootstrapConfig` — TypeScript already enforces this at compile time, so this is really just "does it compile").
- One E2E smoke: a marketplace with no revision renders the placeholder home page without erroring.
## Out of scope (explicitly deferred)
- Field-level merge (real brand name + placeholder theme) — rejected in favor of simpler whole-object swap.
- Any admin-panel UI for previewing/editing the default — not asked for.
- Backend implementation of `published` resolution logic — backend team's own call once they build the service; this spec only fixes the wire contract.

View File

@@ -29,8 +29,8 @@ BASE_URL=https://staging.example.com npm run e2e
| `currency-switch.spec.ts` | `160 RUB` must not silently become `160 USD` on a currency switch — Track Q Q4, and the regression guard `../docs/backend/BACKEND-INTEGRATION.md` §5 exists to close. Written **before** the checkout money-truth rewrite (F10F16 in the frontend backlog), specifically so that rewrite has a net under it. |
| `smoke.spec.ts` | App boots, storefront renders, no console errors on first paint. |
| `admin-dev-bypass.spec.ts` | `?devBypassAdmin=true` actually reaches the admin shell without a Telegram login (Track Q F59). |
| `checkout-request-shape.spec.ts` | ⚠️ **Currently failing, known issue, not resolved (2026-08-21).** The checkout request-shape assertions are correct on paper; the customer-session fake this test relies on doesn't work right now for a reason not yet found — see the `fakeCustomerSession` comment in the file. Do not trust a green *or* red run of this specific test as a verdict on checkout correctness until it's root-caused. |
| `checkout-idempotent-click.spec.ts` | ⚠️ Same known issue as above (Track Q F62) - fails the same way, for the same unresolved reason. |
| `checkout-request-shape.spec.ts` | The amount actually charged must be computed server-side, never sent by the client (`PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md` §5.2). Was red for a real bug, not a harness issue — root-caused 2026-08-21, see the `fakeCustomerSession` comment and `api-headers.interceptor.ts`. |
| `checkout-idempotent-click.spec.ts` | Double-clicking checkout sends exactly one checkout-session request (Track Q F62). Same root cause and fix as above. |
## Adding a test

View File

@@ -20,9 +20,8 @@ test('double-clicking checkout sends exactly one checkout-session request', asyn
window.localStorage.setItem('marketplace_cart', JSON.stringify([item]));
}, FAKE_ITEM);
// KNOWN ISSUE, NOT RESOLVED (2026-08-21) - see checkout-request-shape.spec.ts's
// fakeCustomerSession comment. This test currently fails the same way:
// the session check never fires despite the cookie being present.
// Root-caused and fixed 2026-08-21 - see checkout-request-shape.spec.ts's
// fakeCustomerSession comment and api-headers.interceptor.ts.
await context.addCookies([{ name: 'webSessionID', value: 'e2e-fake-session', url: 'http://localhost:4200' }]);
await page.route('**/users/sessions/**', route =>
route.fulfill({

View File

@@ -81,19 +81,14 @@ async function seedCart(page: Page): Promise<void> {
}
async function fakeCustomerSession(page: Page, context: import('@playwright/test').BrowserContext): Promise<void> {
// KNOWN ISSUE, NOT RESOLVED (2026-08-21): this test currently fails.
// Traced with page.on('request'): the customer-session check
// (AuthService.checkSession -> getStoredWebSessionID) never fires at all
// once Angular bootstraps on this page, even though the cookie is
// confirmed present via context.cookies() and via document.cookie read
// from a plain (non-Angular) page on the same origin immediately before.
// Switching { domain, path } to { url } here did not fix it - kept anyway
// since it is the more correct form regardless. Something in the app's
// own bootstrap/DI path is not seeing a cookie that unambiguously exists
// in the browser; root cause not yet found. Do not trust a green run of
// this specific test until this is root-caused - the checkout REQUEST
// SHAPE assertions this test makes are still correct on paper, just
// currently unverifiable through this harness.
// Root-caused and fixed 2026-08-21 (see api-headers.interceptor.ts):
// apiHeadersInterceptor injected AuthService to attach a WebSessionID
// header, but AuthService's own constructor makes the exact
// GET /users/sessions/:id call this interceptor runs on, which threw
// NG0200 (circular dependency) mid-construction on every page load -
// swallowed silently, read as "session invalid," cookie cleared
// immediately. The { url } cookie form below is unrelated to that bug but
// is still the more correct form, so it stays.
await context.addCookies([
{
name: 'webSessionID',

View File

@@ -32,4 +32,22 @@ test.describe('smoke', () => {
expect(errors, `console errors on first paint: ${errors.join('\n')}`).toEqual([]);
});
test('renders the placeholder home page when the marketplace is unpublished', async ({ page }) => {
// This suite runs against the mock-data build (see comment at the top of
// playwright.config.ts), so MockBootstrapProvider fetches this static
// asset rather than a live /bootstrap endpoint - that's the URL to
// intercept here, not the real API path.
await page.route('**/assets/mock/bootstrap/bootstrap.json', route =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ schemaVersion: '1.0.0', generatedAt: new Date().toISOString(), published: false }),
})
);
await page.goto('/');
await expect(page.getByText('Welcome to Marketplace')).toBeVisible();
});
});

View File

@@ -59,7 +59,7 @@ server {
add_header Access-Control-Allow-Origin \$cors_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, AdminWebSessionID, X-Requested-With" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, AdminWebSessionID, WebSessionID, Currency, X-Language, X-Region, X-Requested-With" always;
add_header Vary "Origin" always;
if (\$request_method = OPTIONS) { return 204; }

View File

@@ -10,6 +10,8 @@
<p>{{ 'app.serverError' | translate }}</p>
<button class="retry-btn" (click)="retryConnection()">{{ 'app.retryConnection' | translate }}</button>
</div>
} @else if (isAdminHost && !isAdminRoute()) {
<app-telegram-login mode="admin" />
} @else if (isAdminRoute()) {
<router-outlet></router-outlet>
<app-telegram-login mode="admin" />

View File

@@ -28,6 +28,8 @@ import { TelegramLoginComponent } from './components/telegram-login/telegram-log
})
export class App implements OnInit {
protected title = '';
readonly isAdminHost = typeof window !== 'undefined'
&& window.location.hostname.toLowerCase().startsWith('admin.');
isHomePage = signal(true);
isAdminRoute = signal(false);
checkingServer = signal(true);

View File

@@ -9,8 +9,8 @@
<app-icon name="lock" [size]="40" />
</div>
<h2>{{ 'auth.loginRequired' | translate }}</h2>
<p class="login-desc">{{ 'auth.loginDescription' | translate }}</p>
<h2>{{ (mode === 'admin' ? 'auth.adminLoginRequired' : 'auth.loginRequired') | translate }}</h2>
<p class="login-desc">{{ (mode === 'admin' ? 'auth.adminLoginDescription' : 'auth.loginDescription') | translate }}</p>
@if (status() === 'checking') {
<div class="login-status checking">
@@ -22,7 +22,7 @@
<svg class="tg-icon" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
</svg>
{{ 'auth.loginWithTelegram' | translate }}
{{ (mode === 'admin' ? 'auth.adminLoginWithTelegram' : 'auth.loginWithTelegram') | translate }}
</button>
<!-- @if (loginUrl()) {
@@ -64,7 +64,7 @@
}
</div>
<p class="login-note">{{ 'auth.loginNote' | translate }}</p>
<p class="login-note">{{ (mode === 'admin' ? 'auth.adminLoginNote' : 'auth.loginNote') | translate }}</p>
}
</div>
</div>

View File

@@ -0,0 +1,57 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { ConfigService } from './config.service';
import { CONFIG_PROVIDER } from './config-provider.token';
import { ConfigProvider } from './config-provider.interface';
import { BootstrapConfig, DEFAULT_BOOTSTRAP } from '../../shared/models/config';
function makeRealBootstrap(overrides: Partial<BootstrapConfig> = {}): BootstrapConfig {
return { ...DEFAULT_BOOTSTRAP, published: true, tenant: { ...DEFAULT_BOOTSTRAP.tenant, name: 'Acme' }, ...overrides };
}
describe('ConfigService', () => {
let provider: jasmine.SpyObj<ConfigProvider>;
function setup(response: BootstrapConfig): ConfigService {
provider = jasmine.createSpyObj<ConfigProvider>('ConfigProvider', ['loadBootstrap']);
provider.loadBootstrap.and.returnValue(of(response));
TestBed.configureTestingModule({
providers: [ConfigService, { provide: CONFIG_PROVIDER, useValue: provider }],
});
return TestBed.inject(ConfigService);
}
it('caches the real response when published is true', done => {
const real = makeRealBootstrap();
const service = setup(real);
service.loadBootstrap().subscribe(result => {
expect(result.tenant.name).toBe('Acme');
expect(service.getBootstrapSnapshot()).toEqual(real);
done();
});
});
it('swaps to DEFAULT_BOOTSTRAP when published is false', done => {
const draft = makeRealBootstrap({ published: false });
const service = setup(draft);
service.loadBootstrap().subscribe(result => {
expect(result).toEqual(DEFAULT_BOOTSTRAP);
expect(service.getBootstrapSnapshot()).toEqual(DEFAULT_BOOTSTRAP);
done();
});
});
it('treats a missing published field as published (backward compatible)', done => {
const legacy = makeRealBootstrap();
delete (legacy as Partial<BootstrapConfig>).published;
const service = setup(legacy);
service.loadBootstrap().subscribe(result => {
expect(result.tenant.name).toBe('Acme');
expect(result).not.toEqual(DEFAULT_BOOTSTRAP);
done();
});
});
});

View File

@@ -1,7 +1,7 @@
import { Injectable, inject, signal } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { catchError, shareReplay, tap } from 'rxjs/operators';
import { BootstrapConfig } from '../../shared/models/config';
import { catchError, map, shareReplay, tap } from 'rxjs/operators';
import { BootstrapConfig, DEFAULT_BOOTSTRAP } from '../../shared/models/config';
import { CONFIG_PROVIDER } from './config-provider.token';
@Injectable({ providedIn: 'root' })
@@ -21,6 +21,7 @@ export class ConfigService {
if (!this.bootstrap$ || forceRefresh) {
this.bootstrap$ = this.provider.loadBootstrap().pipe(
map(config => (config.published === false ? DEFAULT_BOOTSTRAP : config)),
tap(config => {
this.bootstrapSnapshot = config;
this.revisionState.update(value => value + 1);

View File

@@ -1154,6 +1154,10 @@ export const en: Translations = {
auth: {
loginRequired: 'Login required',
loginDescription: 'Please log in via Telegram to proceed with your order',
adminLoginRequired: 'Admin panel sign-in',
adminLoginDescription: 'Sign in through Telegram with an administrator account',
adminLoginWithTelegram: 'Sign in as administrator',
adminLoginNote: 'The admin panel will open after verification',
checking: 'Checking...',
loginWithTelegram: 'Log in with Telegram',
orScanQr: 'Or scan the QR code',

View File

@@ -1154,6 +1154,10 @@ export const hy: Translations = {
auth: {
loginRequired: 'Պահանջվում է մուտք',
loginDescription: 'Պատվերի համար մուտք գործեք Telegram-ով',
adminLoginRequired: 'Մուտք կառավարման վահանակ',
adminLoginDescription: 'Մուտք գործեք Telegram-ով՝ ադմինիստրատորի հաշվով',
adminLoginWithTelegram: 'Մուտք գործել որպես ադմինիստրատոր',
adminLoginNote: 'Ստուգումից հետո կբացվի կառավարման վահանակը',
checking: 'Ստուգում...',
loginWithTelegram: 'Մուտք Telegram-ով',
orScanQr: 'Կամ սքանավորեք QR կոդը',

View File

@@ -1154,6 +1154,10 @@ export const ru: Translations = {
auth: {
loginRequired: 'Требуется авторизация',
loginDescription: 'Для оформления заказа войдите через Telegram',
adminLoginRequired: 'Вход в панель управления',
adminLoginDescription: 'Войдите через Telegram с аккаунтом администратора',
adminLoginWithTelegram: 'Войти как администратор',
adminLoginNote: 'После подтверждения откроется панель управления',
checking: 'Проверка...',
loginWithTelegram: 'Войти через Telegram',
orScanQr: 'Или отсканируйте QR-код',

View File

@@ -1153,6 +1153,10 @@ export interface Translations {
auth: {
loginRequired: string;
loginDescription: string;
adminLoginRequired: string;
adminLoginDescription: string;
adminLoginWithTelegram: string;
adminLoginNote: string;
checking: string;
loginWithTelegram: string;
orScanQr: string;

View File

@@ -38,6 +38,21 @@ function getAnonymousSessionId(): string {
return id;
}
/**
* @marketplaces/auth's AuthService checks for a persisted session in its own
* constructor (a synchronous HTTP call to GET /users/sessions/:id before the
* constructor returns). If this interceptor injects AuthService for that
* exact call, Angular sees AuthService requesting itself mid-construction
* and throws NG0200 (circular dependency) - silently, since it's swallowed
* by TelegramSessionApiService's catchError(() => of(null)), which reads as
* "session invalid" and logs the user straight back out on every load.
* These endpoints are the identity mechanism itself (the session id is
* already the URL/body), so they never needed a WebSessionID header from an
* existing session in the first place - skipping AuthService injection here
* is correct, not a workaround.
*/
const AUTH_SESSION_PATH = '/users/sessions';
export const apiHeadersInterceptor: HttpInterceptorFn = (req, next) => {
const apiConfig = inject(ApiConfigService);
if (!apiConfig.isApiRequest(req.url)) {
@@ -46,12 +61,10 @@ export const apiHeadersInterceptor: HttpInterceptorFn = (req, next) => {
const locationService = inject(LocationService);
const languageService = inject(LanguageService);
const authService = inject(AuthService);
const regionId = locationService.regionId();
const lang = languageService.currentLanguage();
const currency = languageService.currentCurrency();
const session = authService.session();
let headers = req.headers;
@@ -62,7 +75,12 @@ export const apiHeadersInterceptor: HttpInterceptorFn = (req, next) => {
headers = headers.set('X-Language', LANG_HEADER_MAP[lang] ?? lang.toUpperCase());
}
headers = headers.set('Currency', currency || 'RUB');
if (!req.url.includes(AUTH_SESSION_PATH)) {
const authService = inject(AuthService);
const session = authService.session();
headers = headers.set('WebSessionID', session?.sessionId || getAnonymousSessionId());
}
return next(req.clone({ headers }));
};

View File

@@ -741,10 +741,16 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
const userSessionMatch = url.match(/\/users\/sessions\/([^/?]+)$/);
if (userSessionMatch && req.method === 'GET') {
const webSessionID = decodeURIComponent(userSessionMatch[1]);
// An id never seen via POST /users/sessions didn't start a fresh QR
// login in this session - it's a cookie carried over from an earlier
// visit (AuthService.checkSession's one-shot check on page load), which
// a real backend would already recognize. Only ids the mock itself put
// through the polling flow need the checks>=3 gate below.
const isReturningSession = !mockWebSessionChecks.has(webSessionID);
const checks = (mockWebSessionChecks.get(webSessionID) ?? 0) + 1;
mockWebSessionChecks.set(webSessionID, checks);
if (checks >= 3) {
if (isReturningSession || checks >= 3) {
return respond({
webSessionID,
status: true,

View File

@@ -24,6 +24,7 @@ import { WidgetRegistryConfig } from './widget-registry.model';
export interface BootstrapConfig {
schemaVersion: string;
generatedAt: string;
published: boolean;
tenant: TenantConfig;
branding: BrandingConfig;
theme: ThemeConfig;

View File

@@ -0,0 +1,30 @@
import { DEFAULT_BOOTSTRAP } from './default-bootstrap.const';
describe('DEFAULT_BOOTSTRAP', () => {
it('is marked unpublished', () => {
expect(DEFAULT_BOOTSTRAP.published).toBe(false);
});
it('has every feature flag turned on', () => {
Object.values(DEFAULT_BOOTSTRAP.featureFlags).forEach(value => {
expect(value).toBe(true);
});
});
it('has every optional MarketplaceFeaturesConfig flag turned on', () => {
expect(DEFAULT_BOOTSTRAP.features).toBeDefined();
Object.values(DEFAULT_BOOTSTRAP.features!).forEach(value => {
expect(value).toBe(true);
});
});
it('has at least one page with a hero section', () => {
expect(DEFAULT_BOOTSTRAP.pages.length).toBeGreaterThan(0);
const heroSection = DEFAULT_BOOTSTRAP.pages[0].sections.find(s => s.type === 'hero');
expect(heroSection).toBeDefined();
});
it('has a generic brand name, not a real tenant name', () => {
expect(DEFAULT_BOOTSTRAP.branding.brandName).toBe('Marketplace');
});
});

View File

@@ -0,0 +1,226 @@
import { BootstrapConfig } from './bootstrap-config.model';
import { DEFAULT_HEADER_CONFIG } from './header-config.model';
import { DEFAULT_MARKETPLACE_FEATURES_CONFIG } from './features-config.model';
import { DEFAULT_PLATFORM_MODULES_CONFIG } from './platform-modules.model';
/**
* Whole-object fallback rendered whenever the backend reports
* `published: false` for the resolved marketplace (no published revision
* yet). Every feature flag is on so it doubles as a full-surface product
* demo. See docs/superpowers/specs/2026-08-22-frontend-default-bootstrap-design.md.
*/
export const DEFAULT_BOOTSTRAP: BootstrapConfig = {
schemaVersion: '1.0.0',
generatedAt: new Date(0).toISOString(),
published: false,
tenant: {
id: 'tenant-default-unpublished',
slug: 'default',
code: 'DEFAULT',
host: 'default.local',
name: 'Marketplace',
websiteBaseUrl: 'https://marketplace.local',
builderBaseUrl: 'https://builder.marketplace.local',
backofficeBaseUrl: 'https://backoffice.marketplace.local',
defaultLocale: 'en',
supportedLocales: ['en'],
defaultCurrency: 'USD',
supportedCurrencies: ['USD'],
timezone: 'UTC',
},
branding: {
brandName: 'Marketplace',
legalName: 'Marketplace',
slogan: 'Your store, coming soon',
logoUrl: '/icons/icon-192x192.png',
logoCompactUrl: '/icons/icon-192x192.png',
faviconUrl: '/favicon.ico',
appIconUrl: '/icons/icon-192x192.png',
supportEmail: 'support@marketplace.local',
},
theme: {
themeId: 'default-light',
mode: 'light',
palette: {
primary: '#497671',
secondary: '#a1b4b5',
accent: '#a7ceca',
success: '#10b981',
warning: '#f59e0b',
danger: '#ef4444',
info: '#3b82f6',
textPrimary: '#1e3c38',
textSecondary: '#667a77',
backgroundPrimary: '#ffffff',
backgroundSecondary: '#f5f5f5',
border: '#d3dad9',
},
typography: {
primaryFontFamily: 'DM Sans, sans-serif',
headingFontFamily: 'DM Sans, sans-serif',
baseFontSize: 16,
},
spacing: { unit: 4, scale: [0, 4, 8, 12, 16, 24, 32, 48] },
borderRadiusScale: { sm: '8px', md: '12px', lg: '16px', xl: '22px' },
shadows: {
sm: '0 2px 8px rgba(0,0,0,0.1)',
md: '0 4px 12px rgba(0,0,0,0.15)',
lg: '0 12px 32px rgba(73,118,113,0.2)',
},
iconSet: 'default',
},
company: {
companyName: 'Marketplace',
address: { country: '', city: '' },
contacts: { email: 'support@marketplace.local' },
},
featureFlags: {
wishlist: true,
compare: true,
reviews: true,
questions: true,
comments: true,
recommendations: true,
blog: true,
chat: true,
analytics: true,
notifications: true,
coupons: true,
loyalty: true,
giftCards: true,
invoices: true,
},
features: DEFAULT_MARKETPLACE_FEATURES_CONFIG,
apiEndpoints: {
bootstrap: { path: '/bootstrap', method: 'GET', timeoutMs: 10000 },
website: {},
builder: {},
backoffice: {},
},
localization: {
defaultLocale: 'en',
supportedLocales: ['en'],
currencyByLocale: { en: 'USD' },
dictionaries: [{ locale: 'en', dictionaryUrl: '/assets/i18n/en.json', version: '1.0.0' }],
},
seo: {
default: { title: 'Marketplace', description: 'Your store, coming soon', robots: 'noindex,nofollow' },
byPageKey: {
home: { title: 'Marketplace - Home', description: 'Your store, coming soon', robots: 'noindex,nofollow' },
},
},
permissions: { definitions: [], roles: [] },
header: DEFAULT_HEADER_CONFIG,
navigation: {
header: [
{ id: 'nav-home', labelKey: 'nav.home', route: '/', icon: 'home', order: 1 },
{ id: 'nav-search', labelKey: 'nav.search', route: '/search', icon: 'search', order: 2 },
{ id: 'nav-cart', labelKey: 'nav.cart', route: '/cart', icon: 'cart', order: 3 },
],
footer: [
{ id: 'footer-about', labelKey: 'nav.about', route: '/about-us', order: 1 },
{ id: 'footer-contacts', labelKey: 'nav.contacts', route: '/contacts', order: 2 },
],
},
footer: {
paymentIcons: [],
copyrightText: { en: '© 2026 Marketplace. All rights reserved.' },
legalPageKeys: ['about-us', 'privacy-policy', 'terms-of-service'],
},
staticPages: {
'about-us': {
id: 'about-us',
slug: 'about-us',
route: '/about-us',
title: { en: 'About Us' },
html: { en: '<h2>About Us</h2><p>This marketplace has not published its storefront yet.</p>' },
},
'privacy-policy': {
id: 'privacy-policy',
slug: 'privacy-policy',
route: '/privacy-policy',
title: { en: 'Privacy Policy' },
html: { en: '<h2>Privacy Policy</h2><p>Placeholder content until publish.</p>' },
},
'terms-of-service': {
id: 'terms-of-service',
slug: 'terms-of-service',
route: '/terms-of-service',
title: { en: 'Terms of Service' },
html: { en: '<h2>Terms of Service</h2><p>Placeholder content until publish.</p>' },
},
},
pages: [
{
id: 'page-home',
key: 'home',
title: 'Home',
route: { path: '/', exact: true },
layout: { type: 'default' },
seoKey: 'home',
visible: true,
sections: [
{
id: 'section-hero',
type: 'hero',
order: 1,
layout: { strategy: 'hero', columns: 1, gap: '1.5rem', align: 'stretch' },
visibility: { desktop: true, tablet: true, mobile: true },
visible: true,
widgets: [
{
id: 'widget-hero-main',
type: 'hero',
version: '1.0.0',
order: 1,
padding: '0.5rem 0',
visibility: { desktop: true, tablet: true, mobile: true },
visible: true,
props: {
title: { en: 'Welcome to Marketplace' },
subtitle: { en: 'This storefront has not been published yet' },
ctaLabel: { en: 'Learn more' },
},
},
],
},
{
id: 'section-categories',
type: 'categories',
order: 2,
layout: { strategy: 'grid', columns: 1, gap: '1.5rem', align: 'stretch' },
visibility: { desktop: true, tablet: true, mobile: true },
visible: true,
widgets: [
{
id: 'widget-categories-root',
type: 'categories',
version: '1.0.0',
order: 1,
padding: '0.25rem 0',
visibility: { desktop: true, tablet: true, mobile: true },
visible: true,
props: { title: 'Categories', source: 'root', emptyMessage: 'No categories available' },
},
],
},
],
},
],
modules: DEFAULT_PLATFORM_MODULES_CONFIG,
};

View File

@@ -3,6 +3,7 @@ export * from './bootstrap-config.model';
export * from './branding.model';
export * from './catalog-config.model';
export * from './company.model';
export * from './default-bootstrap.const';
export * from './feature-flags.model';
export * from './features-config.model';
export * from './footer-config.model';

View File

@@ -1,6 +1,7 @@
{
"schemaVersion": "1.0.0",
"generatedAt": "2026-07-03T00:00:00Z",
"published": true,
"tenant": {
"id": "tenant-default-001",
"slug": "default",