145 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
d44565fae9 docs(backend): consolidate all backend contracts into one file
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Collapses the entire docs/backend/ set - Phase 1-10, Track A/S, the
partner API, the two handoffs, the frontend surface inventory, and the
harvest requirements - into a single source of truth,
docs/backend/BACKEND-INTEGRATION.md.

Every contract's entities, endpoints, and invariants are preserved,
reorganised by domain rather than by sprint. The nine release
invariants, the FH-* harvest mechanisms, the RBAC/audit/secrets
cross-cutting rules, the tenant-routing infra contract, the 15
acceptance tests, build order, dev setup, and open decisions are all in
the one file, with a change log (§14) at the bottom.

The file opens with the maintenance rule: any new backend need, contract
change, or shipped item updates this file in the same change - the
affected section and the change log. No new backend .md files.

Inbound links from BACKEND-API-REFERENCE, the ADRs, the fork docs,
DEPLOYMENT, PACKAGES-USAGE, the delivery plan, and e2e/README are
repointed at the single doc (section anchors collapse to the file; the
prose section refs remain as context). Also recorded the rule in the
repo CLAUDE.md.

17 backend docs removed, 1 added. No implementation changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:15:58 +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
52fb52888f docs(backend): consolidated harvest requirements as one buildable file
HARVEST-BACKEND-REQUIREMENTS.md - single index the backend builds the
fork harvest from, so the FH-* mechanisms are not scattered across nine
phase contracts:

- the nine release invariants (the acceptance gate)
- every FH-* requirement with its exact mechanism and phase-contract
  reference, grouped by area (correctness core, sessions/access,
  tenancy/publish/content, identity, operations)
- 15 acceptance tests mapped to the invariant each guards
- build order, and what the frontend already delivered so the backend
  builds to a known target rather than guessing

Linked from the backend README index.

No implementation changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 12:09:38 +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
b4772d10c7 docs(editor): mark editor publish as backend-blocked local-only (FH-E.5)
Audited all 21 localStorage users against the "localStorage as source of
truth" objection. It was already false almost everywhere:

- Every admin facade (products, categories, orders, moderation,
  dashboard) uses localStorage only for view preferences - viewMode,
  density, visibleColumns, expandedIds, sort. Entity CRUD goes through
  the API gateways.
- currency-rates.service already removed its localStorage-typed rates.
- language, location region, search history, the anonymous session id,
  admin preferences - all legitimate cache/preference.
- The editor already shows an "unsaved local draft restored" banner
  (draftRestored -> save bar), which is the recovery-cache indicator this
  item called for.

One real gap remains and it is backend-blocked: project-editor
publish() applies config to the in-memory runtime and saves the draft to
localStorage, then declares itself published - no server round-trip,
because the PHASE-9 §5 revision API does not exist yet. Marked precisely
in publish() with the required behaviour (await the server, only then
mark published) and cross-referenced to the contract.

Nothing to rip out: the codebase was already at the target state
everywhere the backend exists to support it.

262 tests pass. Boundaries green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:41:14 +04:00
sdarbinyan
1c87a53f02 feat(identity): account-linking UI + Telegram-as-identity surface (FH-4.7, FH-4.6, FH-4.8)
FH-4.7 - AccountIdentitiesComponent under
features/website/account/identities/. Lists linked identities from
GET /me/identities, offers attach buttons only for OAuth providers not
already linked (reusing SocialLoginButtonComponent), detaches through
unlink(). Refuses to detach the last remaining identity - it is the only
way back in - with the control disabled and an explanatory title, matching
the backend's last-identity 409. Loading / error / ready states; a load
failure surfaces an error rather than rendering an empty account, and a
slot carries the identity-conflict message from PHASE-8 §2.3. 6 unit tests.

Not wired into a route: the storefront has no customer account area yet
and no live OAuth application to authorize against (FH-0.1). This is the
surface both depend on, buildable and tested now.

FH-4.6 (client + contract) - the gateway now separates the two provider
sets. SocialProvider (vk | yandex) is what has an OAuth authorize
redirect; ExternalIdentityProvider (adds telegram | max) is what can be
listed and unlinked. unlink() widened to the latter so Telegram detaches
through the same path as VK, with no second code path. The dev local
gateway seeds a Telegram identity so the linking screen is exercisable
before any real provider exists.

PHASE-8 §2.6 specifies the backend migration: a Telegram login writes an
ExternalIdentity row under the same uniqueness and identity-conflict rule
as VK, appears in /me/identities, is removable subject to the
last-identity 409, and keeps customer (marketplace_session) and admin
(bo_session) sessions as distinct cookies - closing the shared
customer/admin Telegram session the audit flagged. The identity row and
the messaging BotConversationBinding stay separate records.

FH-4.8 - PHASE-8 §3 now states email/phone OTP's position explicitly:
recovery when a linked messenger is unreachable and an addable second
factor, never the primary login, and one more identity on the same
customer rather than a parallel account.

262 tests pass. Build green, boundaries and cycles green, bundle scan clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:36:36 +04:00
sdarbinyan
771dce9e29 perf(build): drop the JIT compiler from production, 1.55 MB -> 1.04 MB (FH-3.4)
Built with --stats-json and read the esbuild metafile instead of guessing.
@angular/compiler was 495,831 bytes of a 1.5 MB main chunk - the JIT
compiler, in an AOT production build, 33% of everything an anonymous
visitor downloads.

src/main.ts imported it deliberately, with a comment explaining that
@marketplaces/auth shipped plain tsc output carrying no Ivy metadata, so
Angular JIT-compiled its classes at runtime and bootstrap threw without
it.

That comment was stale. The package is 0.2.0, built with ng-packagr,
module: dist/fesm2022/marketplaces-auth.mjs - proper Angular Package
Format, partial-compiled (ɵɵngDeclareInjectable), linked at consumer
build time. Nothing needs JIT.

Verified against the production bundle served statically, not just a
green build: the failure mode this guarded was a runtime throw, so a
successful compile proves nothing. Angular 22.0.8 bootstrapped, the
router resolved /ru, and the app rendered its own "server unavailable"
screen - meaning DI, HttpClient and the full interceptor chain ran. That
chain injects AuthService from @marketplaces/auth, the exact class the
old comment named. Zero JIT or compiler errors; the only console output
was the expected 404s from having no backend behind a static server.

Initial bundle 1.55 MB -> 1.04 MB raw, 323.58 kB -> 215.45 kB transfer.
Budget ratchet lowered 1.6MB -> 1.1MB, which is now also what stops the
import being re-added.

Next lever, deliberately not taken here: i18n/ru.ts is 290 kB, eager,
while en/hy are already lazy. TranslateService has the loader plumbing
and languageGuard already awaits a preload, so it is mechanically small -
but it adds a round-trip before first paint for the majority language,
which is a product tradeoff, not a cleanup. Roughly a 750 kB bundle once
someone decides.

256 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 16:10:34 +04:00
sdarbinyan
a35953d90f refactor(di): keep mock gateways out of production builds (FH-E.6)
21 DI tokens selected their implementation like this:

  factory: () => (environment.useMockData ? inject(XLocal) : inject(XApi))

That reads as a toggle and is not one. Naming both classes in the factory
keeps both reachable, so every mock shipped regardless of the flag - and
`useMockData` is false in both environment files, so none of them were
ever the selected implementation in the first place. Verified: a fixture
string from partner-hierarchy-local.gateway.ts was present in a
production bundle.

Token factories now inject the API gateway unconditionally. Mock
overrides move to src/app/mock-gateway.providers.ts, swapped for a
production copy that imports nothing, via the same fileReplacements
mechanism mock-data.interceptor.production.ts already uses. Dev behaviour
is unchanged - flip useMockData in environment.ts exactly as before.

useExisting rather than useClass: the local gateways are already
providedIn: 'root' singletons, and an app-level provider for the token
wins over its tree-shakable default.

scan-bundle.sh gains two patterns so this cannot come back: any
*LocalGateway class name, and known fixture literals. Verified in both
directions - clean against the real dist, exit 1 against a planted
OfferLocalGateway.

Result: zero LocalGateway classes and zero fixtures in the production
bundle, down from 21 classes and 75 kB of source. Initial bundle is
unchanged at 1.55 MB because these all sat in lazy chunks; the win is
that production can no longer serve seeded fixtures as real data, not
bytes off the critical path.

Not addressed here: MediaRepository is still bound to MockMediaRepository
unconditionally in app.config.ts. That one cannot be deleted - no real
implementation exists yet - so it is a missing API gateway, not dead
weight. Tracked separately.

256 tests pass. Build green, boundaries and cycles green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:59:11 +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
sdarbinyan
a3808842c1 feat(deploy): host hardening on the frontend server (FH-D.3)
server-setup.sh configured ufw and stopped there, which leaves SSH open
to unlimited password guessing and the kernel on defaults that are wrong
for an internet-facing host.

Adds three drop-in files, so a re-run replaces its own config and never
edits a distro file in place:

  /etc/ssh/sshd_config.d/10-marketplaces-hardening.conf
      password and keyboard-interactive auth off, root key-only,
      no agent/X11 forwarding, MaxAuthTries 3, 30s login grace
  /etc/fail2ban/jail.d/marketplaces.local
      sshd, nginx-http-auth, nginx-bad-request; 5 in 10m, 1h ban
  /etc/sysctl.d/99-marketplaces-hardening.conf
      no redirects or source routing, rp_filter, SYN cookies,
      forwarding off, restricted kernel pointers and dmesg

Both accounts on the host are key-only by construction - the deploy user
is created with no password at all - so disabling password auth cannot
lock anyone out. It only closes guessing against a credential nobody
intended to exist.

The sshd block runs `sshd -t` first and removes its own drop-in if the
test fails. A bad sshd config that takes effect on a remote box is how
people lock themselves out permanently.

DEPLOYMENT.md §3.2 documents all three plus the post-provision checks.

Not copied from the reference implementation: its hardcoded server IP.
Kept as-is because ours is already better: add-domain.sh pre-checks the
DNS A record and runs nginx -t before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:17:02 +04:00
sdarbinyan
cf17b0b6c6 feat(identity): provider-agnostic social login, VK ID + Yandex ID (FH-4.1, FH-4.2)
The VK-only scaffolding had a shape problem worth fixing before anything
was built on it: completeCallback(code, codeVerifier) took the PKCE
verifier from the client, which forces the browser to generate and hold
it. We are a confidential client - a browser-held verifier buys nothing
and adds a place to steal it from.

Replaces the four vk-id-* files with a provider-agnostic surface:

  getAuthorizeUrl(provider, returnTo?)
  listIdentities()
  unlink(provider)

completeCallback is gone entirely. The backend mints and stores state and
code_verifier single-use for 10 minutes, handles the provider's callback
itself, issues the session cookie and redirects. VK and Yandex differ
only in a path segment, because everything that actually differs between
them - PKCE handling, VK's device_id, Yandex's Basic-auth exchange -
lives backend-side.

vk-id-login becomes social-login-button with a provider input; adding
Yandex to the UI is an input value, not new code. Adds yandex_id to
ExternalIdentityProvider, plus optional email/phone/displayName since VK
frequently returns no email.

social-identity-gateway.spec.ts (5 tests) asserts the requests carry no
code_verifier and no client_secret, so reintroducing a browser-held
verifier fails the build rather than passing review.

PHASE-8 §2 rewritten to match: the four endpoints, backend-owned state
and verifier, UNIQUE (provider, providerUserId) with conflict routed to
controlled resolution rather than a silent rebind, per-tenant OAuth app
config under the Track S §4.2 envelope, and both providers' full endpoint
sets. Two things recorded there because they are expensive to discover
later: VK's callback returns device_id alongside code and the token
exchange fails without it, and both providers validate redirect_uri
against an exact registered list - which a multi-tenant platform cannot
satisfy without a central identity host (FH-0.1, still undecided).

256 tests pass. Build green, boundaries and cycles green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:15:26 +04:00
sdarbinyan
f9e09b1757 docs(backend): harvest platform mechanisms into the contracts (Wave 2, FH-E.1-E.4)
Writes the 14 harvested mechanisms from FORK-ANALYSIS-2026-08-21.md into
the backend contracts. Each section is dated 2026-08-21 and tagged FH-*
so any wording traces back to why it is worded that way.

The through-line: several contracts stated correctness as behaviour
("the webhook must be idempotent"). Behaviour written as an if-statement
gets deleted by a refactor and the failure mode is a double charge. These
sections restate it as schema and mechanism.

PHASE-3  3.1 conditional-write reservation, 409 on zero rows, cart-wide
             rollback, 15 min TTL
         3.2 InventoryMovement append-only journal with resultingAvailable
         6   bulk import idempotent by SKU, rollback while unsold
         6a  digital code pools, revealed only when paid
PHASE-7  5   unique constraints for payment idempotency and webhook
             replay, insert-first handling, signature over raw body,
             24h poll as reconciliation not primary
TRACK-S  2.1 session model - 32 bytes stored as SHA-256 only, HttpOnly,
             one cookie per contour, Argon2id params, mandatory TOTP
         2.2 origin allowlist ahead of routing on every cookie mutation
         4.2 AES-256-GCM envelope for stored secrets, HMAC fingerprints
         8a  order manager as a separate contour, scoped by membership
             rows rather than by configuration
PHASE-9  5.1 revision immutability, version = max+1, pointer flipped
             in-transaction, operational state does not travel
         5.2 clone carry / no-carry list, inventory to zero
         5.3 signed read-only preview, non-GET 404s while previewing
         6   host normalization, verifiedAt required, cache invalidation
PHASE-10 3a  server re-runs the editor's validation, clamp-and-fallback
PHASE-2  3.1 order publicToken, snapshot completeness, never updated

FH-2.12 rejected on the merits: our marketplace lifecycle state machine
is richer than theirs, adopting it would be a downgrade. Recorded in the
TODO so it is not raised again.

Also adds BACKEND-HANDOFF.md sections 0 and 0a - nine falsifiable
invariants as a release gate, each cross-referenced to the contract that
specifies it, plus PR and release discipline. And ADR-0006 recording what
we take, what we reject, what we keep because ours is better, and the
organizational question it deliberately does not settle.

No implementation changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:12:05 +04:00
sdarbinyan
e8fc8480fe docs: trim stale instructions from completed harvest items
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 10:37:10 +04:00
sdarbinyan
6e47d01c32 ci: ratchet the bundle budget and scan builds for credentials (FH-3.3, FH-3.5)
Bundle budget was warning-only: initial warning 700 kB, error 1.8 MB.
Measured today the initial bundle is 1.55 MB raw / 324.58 kB transfer -
up from the 1.15 MB measured on 11 August, so it had been growing with
nothing to stop it.

Lowers maximumError to 1.6 MB. That is a ratchet, not a target: just
above today's size so the bundle cannot grow, with the 700 kB warning
left in place as the goal. Lower it each time the number comes down.

Adds scripts/ci/scan-bundle.sh (npm run scan:bundle), run in CI after
the build. Seven patterns: both provider auth headers, the partner ID
shape, client_secret, private key blocks, AWS keys, Telegram bot
tokens. The legacy payment code that put credentials in the browser is
already deleted; this is what stops it coming back. Verified in both
directions - clean against the real dist, exit 1 against a planted
credential.

Measurement also corrected two assumptions recorded in the harvest
TODO: admin and editor code is already lazy-loaded, so the initial
bundle is main alone rather than a deployable-split problem; and mock
gateway fixtures do reach production chunks, which is now filed as
FH-E.6 with the cause identified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 10:36:37 +04:00
sdarbinyan
04272ae673 fix(geo): stop calling ip-api.com from the browser (FH-1.1)
detectLocation() fetched http://ip-api.com over plaintext from an HTTPS
storefront. Browsers block mixed active content, so the request never
completed and auto-detect only ever took its error branch - region
detection has been dead in production, not merely insecure. The attempt
also handed every visitor's IP to a third party from the page itself.

Geo now resolves through the tenant API at {baseUrl}/geo/resolve, the
same base /regions already uses. The server reads the client IP; the
browser sends nothing and receives no third-party payload.

The endpoint is specified in BACKEND-API-REFERENCE.md and is not built
yet. Until it ships the client falls back to the manual region picker -
identical to the behaviour production already had.

Adds location.service.spec.ts: geo goes to the tenant API, no request
leaves that origin or uses http://, failure degrades to the manual
picker, and detection is not retried once attempted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 10:30:27 +04:00
sdarbinyan
bc5f7c7a64 refactor: delete dead legacy payment code from ApiService
Verified zero remaining callers for each before deleting (grepped
src/app for every method name individually), not assumed from the
earlier commit's dead-code note.

Deleted from api.service.ts:
- createPayment() - legacy direct QR creation (POST {qrBaseUrl}/qr)
- createCartPayment() - legacy /cart payment creation, client-sent amount
- createPaymentIntent() - superseded by @marketplaces/payment's gateway
- checkCartPaymentStatus(), checkCartCardPaymentStatus(), checkPaymentStatus()
  - legacy QR/card status polls, superseded by the same gateway
- resolvePaymentQrId/resolvePaymentQrUrl/resolvePaymentLink/
  resolveBankPaymentUrl - QrCreateResponse field-normalization helpers,
  no longer had a caller once the methods above were gone
- Types: QrCreateRequest, QrCreateResponse, CartPaymentRequest,
  PaymentIntentRequest, QrDynamicStatusResponse
- Fields: qrBaseUrl, cartPaymentPartnerId - no longer read by anything
- Imports: HttpHeaders, environment - no longer used in this file

Did NOT touch createOrder() or createCheckoutSession() - both still have
live callers in cart.component.ts, confirmed before deciding what to keep.

cart.component.ts: corrected the createPaymentIntent() comment, which
referenced the deleted method/helper names, to name what actually got
deleted instead of what was merely "dead as of that commit."

docs/backend/FRONTEND-API-SURFACE-COMPLETE.md: moved the 4 now-deleted
QR/card endpoints out of the "still live" legacy table into a dated removal
note - the doc's own premise is "every endpoint this codebase currently
calls," so it was wrong to leave them listed as called once they weren't.
Legacy-undocumented count corrected 15->11, total 97->93.

Verified: production build succeeds (no dangling references), 247/247 unit
tests, arch:check clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 10:27:59 +04:00
sdarbinyan
d27c10dd17 feat: start @marketplaces/payment implementation
package.json already had @marketplaces/payment added (uncommitted) when this
started. Wired it in.

- app.config.ts: provideMarketplacesPayment(). apiUrl is environment.qrApiUrl
  with its trailing /api stripped - found and fixed a real bug while wiring
  this: qrApiUrl already ends in /api, and the package's default
  paymentsPath is '/api/v1/payments', so passing qrApiUrl unchanged would
  have silently doubled the path to .../api/api/v1/payments. Confirmed by
  reading the package's baseUrl() concatenation directly, not guessed.
  marketplaceDomain is a plain closure (not TenantResolverService) since
  provideMarketplacesPayment runs outside the injector.

- cart.component.ts: createPaymentIntent() and startPolling() now go through
  MARKETPLACES_PAYMENT_GATEWAY instead of api.service.ts's
  createPaymentIntent/checkCartPaymentStatus/checkCartCardPaymentStatus (our
  own earlier inferred contract, now superseded by the package's real,
  published one - POST/GET {qrApiUrl}/api/v1/payments). Deliberately did NOT
  swap to the package's own <mp-payment> UI component - that has a different
  UX paradigm entirely (window.open for redirects instead of an iframe
  popup, client-side QR generation instead of an external image service) and
  replacing the existing, already-tested 769-line popup state machine
  wholesale is a separate, much larger change than "wire the new package
  in." Only the I/O layer moved; the surrounding state machine (paymentStatus,
  checkoutInFlight, timeout/success/error handling, bank-iframe UX) is
  untouched.

  Response shape differs from the legacy provider: the package's
  PaymentStatus is a fixed union (created/pending/authorized/paid/failed/
  cancelled/expired), not a free-form string+code pair - simplified the
  status-check conditionals accordingly and added 'authorized' as a second
  success state (PaymentResult's own status union), which the legacy check
  didn't have. The package also carries no TTL/expiry field on its response,
  unlike the legacy provider's qrTTL - polling duration now falls back to
  PAYMENT_MIN_POLL_SECONDS alone; flagged in a comment.

- api.service.ts's createPaymentIntent and its QrCreateResponse-based
  resolvePaymentQrId/resolvePaymentQrUrl/resolvePaymentLink/
  resolveBankPaymentUrl helpers are now dead code. Left in place rather than
  deleted in the same pass that adds a new external dependency, so a revert
  doesn't also need to resurrect deleted code.

Verified: production build succeeds, 247/247 unit tests, arch:check clean.

E2E: 2 of 7 tests currently fail
(checkout-request-shape.spec.ts, checkout-idempotent-click.spec.ts), and
this is disclosed honestly rather than hidden. Root cause, confirmed by
tracing real network requests: the customer-session cookie fake these tests
rely on stops working somewhere between the cookie being demonstrably
present in the browser (context.cookies(), and document.cookie read from a
plain page on the same origin) and Angular's own AuthService reading it -
the session-check request never fires at all. This reproduces with or
without this session's payment changes (checkout-idempotent-click.spec.ts
doesn't touch payment creation and fails the same way), so it is not a
regression introduced here, but it is unresolved. Tried switching
context.addCookies from {domain,path} to {url} form (the standard fix for
this class of Playwright cookie issue) - did not fix it, kept anyway as the
more correct form. Documented as a known, unresolved issue directly in both
spec files and e2e/README.md rather than deleting or silently marking the
tests skip - the request-shape assertions those tests make are still
correct, they are just currently unverifiable through this harness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 09:37:16 +04:00
sdarbinyan
fd3ca85929 docs: fork analysis + improvement harvest spec and todo
Deep analysis of marketplaces-main.zip (hub.numus.cc/numus/marketplaces).

Findings:
- Not a fork of us. Separate platform monorepo (NestJS + Postgres +
  2 Angular apps + infra) sharing an older dexarmarket ancestor.
- Our repo is vendored inside it as reference/parallel-frontend/,
  SHA-256 pinned, dated 11 Aug 2026, classified "reference, not production".
- Zero VK/Yandex/OAuth code anywhere in their source. Their only
  customer login is Telegram, proxied to an external service.
- They lead on backend truth and ops; we lead on frontend depth,
  tests, e2e, and framework currency.

Three of their audit findings are still live in our code and are
defects, not just posture: plaintext ip-api.com call from an HTTPS
origin (mixed content, region detect silently dead), unvalidated
bypassSecurityTrustResourceUrl on a bank URL rendered in an iframe,
and provider credentials plus a partner ID literal in the bundle.

Adds:
- docs/FORK-ANALYSIS-2026-08-21.md - full comparison, their audit of
  us assessed line by line, and a VK ID + Yandex ID design.
- docs/superpowers/specs/2026-08-21-fork-harvest-design.md - working
  brief, five lanes, four waves, scope and rejection rules.
- docs/FORK-HARVEST-TODO.md - 42 items with effort, dependencies and
  acceptance criteria. Improvements only; nothing regresses our
  Angular version, test count, or architecture governance.

No implementation changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 09:31:54 +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
92f1c884c9 fix(proxy): strip upstream browser origin
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
nginx owns the validated CORS response; the live :445 backend rejects requests when the browser Origin is forwarded.
2026-08-20 16:29:05 +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
9cd56586fb fix(api): share base-domain API host
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Tenant subdomains route through api.<base-domain>; nginx forwards the exact storefront host derived from the validated browser origin.
2026-08-20 16:12:37 +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
e5949c3967 fix(deploy): provision tenant API domains
Some checks failed
Architecture Governance / architecture (push) Failing after 6m16s
Reconcile TLS, exact CORS, and backend proxying before release activation so every storefront uses its derived API host.
2026-08-20 15:04:10 +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
66a0ccfdb8 ci: restore standard deploy runner
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
2026-08-20 14:54:33 +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
20e03d4340 ci: run Angular 22 on Node 24
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
2026-08-20 14:49:58 +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
03c750cae7 ci: target emergency deploy runner
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
2026-08-20 14:45:40 +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
358996cbf2 ci: stream releases over ssh
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
2026-08-20 14:42:29 +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
640360d63c fix(api): derive host from storefront domain
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Every storefront, including nested subdomains, uses its matching api.<hostname> endpoint.
2026-08-20 14:31:22 +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
f4ea4c7af8 fix(api): route tenants through origin gateway
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
2026-08-20 14:23:12 +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
bbf12cad33 docs: add §0 - auth + payment authorization was missing entirely
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Direct question caught a real gap: "everything is there? payment auth?"
The census only grepped src/app/ - auth moved into the external
@marketplaces/auth package this session, and its HTTP calls were never
captured. Seven real endpoints were silently absent from a doc that called
itself "complete":

- 3 Telegram QR/session endpoints (POST/GET/DELETE .../users/sessions) -
  live today, customer and admin login share them, which is exactly why
  every admin endpoint must independently verify authorization server-side
- 4 ed25519 admin challenge/response endpoints - specified in
  BACKEND-HANDOFF.md §3 and the package's own auth-api.model.ts, but not
  built server-side. Client shows backend-unavailable until they exist.

Added §0.3 stating plainly what actually connects auth to payment: there is
no separate payment login. Checkout, order pricing (§20), and partner
credentials (§16) each ride on whichever of the two sessions above is
active, or on the partner API's own separate signed-request auth (§6 of
that contract - unrelated to Telegram/ed25519, already built, not a gap).
The real payment gap is §1 (QR/card creation and polling, undocumented
anywhere), not auth.

Counts corrected: 51->54 specified, 90->97 total. Added item 0 to the
action list, ahead of everything else: the ed25519 endpoints are the single
most serious open issue named anywhere in docs/backend/, and every other
item on the list assumes a working admin session to authorize against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:12:22 +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
2149e6435a docs: final backend handoff - revision endpoints, response-shape additions, close-out
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Updates FRONTEND-API-SURFACE-COMPLETE.md to cover everything built after it
was first generated - this is the final version for this pass, not a
snapshot mid-way through.

Added, none of which existed in the doc before:

- §18: the 4 marketplace revision endpoints (create draft/validate/
  publish/rollback) - built this session, missing from the census entirely.
  Flags the same draft/validated/preview/published ambiguity already
  documented in the model itself, so backend sees it without having to
  read source.
- §19: RoutingContext as an optional field addition on GET
  /api/admin/v2/orders/{id} - not a new endpoint, a response-shape ask.
- §20: order pricing-breakdown fields (unitPriceMinor, lineTotalMinor,
  priceSnapshotId, discountMinor, fxQuoteId, deliveryMinor) feeding the new
  total-formula panel. States plainly that the panel shows nothing rather
  than a wrong number while these are absent.
- §21: 8 optional dashboard-metrics fields (GMV, conversion, moderation
  queue, etc.) - explicitly labelled a genuine ask, not a confirmed
  contract, since no spec exists for this endpoint at all (§15).
- §22: states directly that the frontend's new double-click guard does not
  replace backend idempotency enforcement and was never meant to - closes
  one UI race, does nothing for a retried request or a duplicate webhook.

Counts updated (47->51 specified, 86->90 total; +3 response-shape asks that
aren't new endpoints). Closing section states the one item genuinely
blocked pending a live backend (F60, the full acceptance-path E2E) so
nobody mistakes "frontend backlog complete" for "nothing left to build."

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:08:41 +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
de6bef8e9a ci: coverage floor + gate; CI never ran a single test before this (F64)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Delivery plan Q9: "set a justified coverage floor and CI gate. Deliberately
unset today." Both parts of that were still true - karma.conf.js had no
check thresholds, and architecture-governance.yml built and checked
boundaries but never invoked `ng test` at all.

Floor set 5 points below the measured level right after this session's
facade-test pass (43.3%/29.0%/34.1%/43.7% statements/branches/functions/
lines): 40/25/30/40. A deliberate floor, not an aspiration - meant to be
ratcheted up as coverage grows, and to fail a PR that drops below it
rather than silently accept a lower number.

Verified the gate actually fails, not just logs a warning: set
statements to an impossible 99% locally, confirmed `npm run test:coverage`
exits 1 (my first attempt at this check was wrong - piping through `tail`
meant the $? I read back was tail's exit code, not npm's; fixed by
capturing it directly). Restored the real floor and confirmed a clean
exit 0 before committing.

CI changes: added the Setup Chrome + coverage-gated test step
karma.conf.js's CHROME_BIN needs (its fallback is a Windows path, useless
on ubuntu-latest), plus an E2E step. Structurally verified locally (no
tabs, step count, manual read-through) - a real GitHub Actions run on
Anthropic's infrastructure could not be executed from this session, so
this is not confirmed end-to-end the way the coverage gate itself was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:03:29 +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
e5ed1c96e5 fix: checkout double-click created two sessions; F59/F62 E2E coverage
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
E2E found a real, pre-existing bug, not a test artifact: isCheckoutDisabled
only checked terms/auth/delivery-selection, never whether a checkout was
already in flight. A double-click (or any rapid repeat click) fired two
handler calls before showPaymentPopup's change detection had a chance to
cover the button, producing two separate POST /api/v2/storefront/checkout
requests for one click.

Fixed with checkoutInFlight, set synchronously at the top of checkout()
before anything async happens, checked in isCheckoutDisabled. Released in
both closePaymentPopup() (every retry/close path routes through it) and
setPaymentError() directly, since the popup can stay open to show an error
rather than closing - relying on only one of those would leave a failed
attempt unable to retry.

Track Q coverage (F59, F62):

- admin-dev-bypass.spec.ts - proves ?devBypassAdmin=true (already shipped
  in app.ts, gated by @marketplaces/auth's isDevMode() check at runtime)
  actually gets an E2E run into the admin shell without a Telegram login.
  This was the missing piece behind Q2's note that past "verified live"
  admin claims were code-inspection only.
- checkout-idempotent-click.spec.ts - the frontend-testable half of Q5
  ("repeat webhook and double-click create exactly one order"). The
  webhook-idempotency half is a backend contract
  (PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §6.3) this suite can't exercise
  without a live backend.

One own test bug fixed en route, not shipped: the idempotency test's first
draft waited on label[for="terms-checkbox"], which does not exist in the
markup (the checkbox and its text share a plain clickable wrapper, no
label/for). checkout-request-shape.spec.ts already had the correct fallback
(dispatchEvent('click') on the input directly) for exactly this reason -
this test just hadn't copied it.

Verified: 237/237 unit tests, arch:check clean, 7/7 E2E, production build
succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:57:09 +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
90bd05aa98 test: users facade + cart service coverage (F63 complete)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Closes out F63 - every domain named in the delivery plan's Q7 gap now has
facade-level coverage.

admin-users.facade.spec.ts (14 tests): sessions/audit dialog targeting (a
revokeSession must reload the CURRENTLY open user's sessions, not whichever
user happened to be open first), invite() trims and rejects whitespace-only
email, roleName() falls back to the raw id rather than rendering blank for
an unmapped role.

cart.service.spec.ts (23 tests) - this had zero coverage despite feeding
totalWithDelivery and allRequiredDeliveriesSelected directly into
cart.component.ts's checkout gate and the offers/qty payload sent to
POST /api/v2/storefront/checkout, the exact contract this session's F14-F16
rewired. A regression here would silently let checkout proceed with a
missing delivery selection, or compute the wrong total. Covers the delivery-
requirement matrix explicitly (digital items never require selection,
deliverySelectionRequired: false overrides having options present, a
selection satisfies the requirement once made) and the quantity-to-zero ->
line-removal behavior in updateQuantity.

Verified: 237/237 unit tests (37 new since the last commit), arch:check
clean, 5/5 E2E, production build succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:44:08 +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
f8063b320e test: facade coverage for orders, moderation, transactions, monitoring, products (F63 partial)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
None of these 5 facades had a single test before this - the highest-risk
gap flagged in Q7 of the delivery plan: real business logic (dashboard stat
computation, health scoring, bulk actions, draft save routing) with zero
coverage on domains that just got real backends behind them (Block 3).

Each spec stubs the gateway TOKEN via jasmine.createSpyObj, not a concrete
class - the correct pattern per this session's earlier fix to
admin-analytics.facade.spec.ts / admin-order-watcher.service.spec.ts, so
these don't repeat that same latent bug.

66 new tests, several written specifically to catch a real regression
class rather than pad a count:

- averageRating/averageOrder must be null/0 for an empty set, never NaN
  or a divide-by-zero artifact
- a zero rating must not drag down an average across other real ratings
- a customer with 2+ orders counts as returning; 1 order does not
- moderationHealthPercent is 100 for an empty queue by convention, not 0
- toggleSelection/toggleAll must not produce duplicate ids
- saveDraft must route to createProduct vs updateProduct correctly and
  must do nothing when no draft is loaded - a real prior bug class
  (silently creating a duplicate on a no-op save)
- an events-load failure in AdminMonitoringFacade must not block queues/
  webhooks from loading independently

One own mistake caught before commit, not after: the first updateDraft
test asserted the updatedAt timestamp changed after two synchronous calls
- both landed in the same millisecond, so the ISO string was correctly
identical and the assertion was the bug, not the facade. Replaced with an
assertion that actually matches what's worth testing (a valid timestamp
gets stamped, not that two back-to-back calls differ).

Verified: 205/205 unit tests (66 new), arch:check clean, production build
succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:38:42 +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
ec949b5a19 feat: order total-formula panel, dashboard metrics, revision-API core (F53/F55/F56)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Checked F49-F56 against current source before writing anything; three
resolved to "already done, nothing to build":

  F49 (ed25519 admin auth flip) - lives entirely in the external
      @marketplaces/auth package plus a live backend; nothing in this repo.
  F50 (HttpOnly session cookie) - HttpOnly can only be set via a
      Set-Cookie response header. No frontend code can ever set one via
      document.cookie. 100% backend, always was.
  F51 (route permission guards) - already deliberately paused by prior
      work with a stated reason ("could lock an admin out without warning"
      across 27 routes, no live backend to verify against). Respected that
      judgment rather than overriding it blind.
  F54 (wire mock requestRefund) - already resolved by the Block 3 gateway
      swap; the facade calls gateway.requestRefund(), which now hits the
      real endpoint. No mock left to wire.

Built:

- AdminOrder gains optional per-line pricing fields (unitPriceMinor,
  lineTotalMinor, priceSnapshotId, discountMinor) plus fxQuoteId and
  routing, per PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5.3/§6.5. All
  optional - most orders today have none of it, and absence must render
  as "not available", never a fabricated 0.
- OrderTotalFormulaComponent: pure presentational panel reconstructing
  total = sum(unitPrice*qty) - discounts + delivery, wired into order
  detail. Refuses to show a partial breakdown - every line must carry
  unitPriceMinor or the panel says so plainly instead of half-computing.
- AdminDashboardMetrics gains 8 optional fields per
  PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md §7's target list (GMV, paid
  orders, conversion, payment failure rate, moderation queue, low stock,
  unmatched events, integration health). Status is 'unknown' when a field
  is absent, not 'healthy' - a successful fetch with no field present is
  not the same as a confirmed-healthy metric, same pattern the existing
  'images-without-alt' check already used.
- Marketplace revision gateway core (F52/F55's shared dependency):
  models/marketplace-revision.model.ts, interface, api+local gateways,
  token. Additive only - the 724-line project-editor facade's actual
  localStorage-to-API rewire is NOT done here; that is a separate,
  larger, stateful change (autosave/undo/redo all currently synchronous)
  that deserves its own verified pass, not a rushed retrofit.

One real contract ambiguity surfaced and resolved with a stated
assumption, not silently: §5 describes 4 pipeline stages
(draft/validated/preview/published) but only 3 write endpoints
(validate/publish/rollback). Modeled validate() as moving straight to
'preview' - the state publish() actually requires - treating 'validated'
as a value the caller may never observe. Documented in the model's own
comment for backend to confirm.

Local gateway enforces the pipeline order rather than being a permissive
stub: can't publish a draft, can't re-validate a previewed revision,
rollback only from published, rollback creates a new revision rather
than mutating the old one.

Verified: 152/152 unit tests (13 new), arch:check clean, production
build succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:23:43 +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
9134a7ff63 docs: master API surface list, all 86 frontend endpoints in one doc
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Generated directly from source (every this.http.get/post/patch/put/delete
call across core/, features/admin/, api.service.ts) rather than written
from memory - a census, not a design doc.

47 already match an existing Phase/Track contract exactly. 24 are inferred
from this codebase's own REST conventions with no contract doc stating them
- each flagged in source at its call site, not just in this doc, so backend
sees the reasoning next to the code. 15 are legacy endpoints
(/category, /cart, /qr, /websession, ...) with no contract anywhere,
still live today.

Biggest concrete gap surfaced: three full admin domains (transactions,
monitoring, moderation) have real UI and real gateways calling
/api/admin/v2/{resource} by convention, with zero backend contract written
for any of them.

One real bug found and fixed while building this, not just flagged:
connector-api.gateway.ts's replay() called
POST /api/admin/v2/integrations/dead-letter/{id}/replay, omitting the
{connectorId} segment the contract's own path requires
(PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md §7). Fixed the interface, both
gateway implementations, and the doc entry in the same pass - no callers
existed yet, so this shipped without ever being exercised by a UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:08:05 +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
a116c4f592 feat: Partner hierarchy backoffice page (F41-F44 partial)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
New nav entry, route, page. Renders the four-level tree
(Company/Project/Store/PaymentPoint) via AdminPartnerHierarchyFacade on top
of the core built in the previous commit - TEST/LIVE toggle, suspend/activate,
disable behind a confirmation (terminal + cascading, per contract §2), and a
read-only credentials list stating plainly that only the public key is ever
held.

Reload-after-action rather than local patch on suspend/activate/disable:
disable cascades to descendants server-side, so a local patch would leave
children showing a status they no longer have.

Company id is a constant for now (DEFAULT_COMPANY_ID) - company creation is
out of band per contract §4.3, and there is no company-selector UI yet.

Verified: 139/139 unit tests, arch:check clean, production build succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:00:46 +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
7e7a015ff6 feat: partner hierarchy core - models, gateways, contract invariants
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Foundation for Block 5 (F41-F45): the frontend surface the partner
provisioning API needs and has nothing for today. Core only - no UI yet, so
this is additive and changes no existing behavior.

- models/provisioning-node.model.ts    four fixed levels, node status rules,
                                       PaymentPointConfig, tree helpers
                                       (childLevelOf/ancestorsOf/childrenOf)
- models/partner-credential.model.ts   public-key-only credential shape
- services/*-gateway.interface.ts      hierarchy read/create/status/disable
                                       plus credential register/rotate/revoke
- services/*-api.gateway.ts            /api/partner/v1/... per contract §4, §6
- services/*-local.gateway.ts          in-memory, seeded four-level tree
- services/*-gateway.token.ts          environment.useMockData ? local : api

Two deliberate choices worth stating:

1. PartnerCredential has no private-key field at all, and cannot grow one by
   accident - the partner generates the keypair, we hold only the public
   half (§6.1). looksLikePrivateKey() exists purely so a UI can refuse a
   paste of the wrong half before it reaches a log.

2. The local gateway ENFORCES the §2 invariants rather than being a
   permissive stub: externalReference uniqueness per (companyId,
   environment, level), server-computed path, inherited immutable
   environment/companyId, cascading disable, and disabled-is-terminal. A
   mock that accepts what the real backend rejects would let the UI ship a
   flow the backend then refuses - the invariants are the point, not the
   data.

24 new tests covering exactly those invariants (cascade stops outside the
subtree, disabled cannot be reactivated, same reference allowed at a
different level, lookup scoped by environment, revoked credential cannot
rotate).

Also checked and deliberately NOT done: F40 (delete mock-data.interceptor +
src/assets/mock). Both are still load-bearing. mock-data.interceptor mocks
the LEGACY endpoints (/category, /items, /cart, /qr) still served by
ApiService and untouched by the gateway swap. src/assets/mock feeds
mock-backoffice-data.provider, mock-bootstrap.provider (gated by
useMockBootstrapOnLocal, currently true - it is what makes local dev render
at all), and widget-manifest.service's fallbackManifestUrl, which is not
mock-gated and runs in production. F40 belongs after the legacy-endpoint
retirement (Track N), not here.

Verified: 139/139 unit tests (24 new), arch:check clean, production build
succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:10:30 +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
ffd57f2d18 feat: real API gateways for all remaining local-only domains (F17-F39)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Block 3 of the frontend backlog. 18 gateways were hardcoded to their local
(mock/localStorage) implementation with no seam to a real backend at all -
this closes that gap for everything with a documented contract to build
against.

10 core gateways, token now resolves environment.useMockData ? local : api,
same pattern already proven on fx-quote earlier this session:

  permission, analytics, cart (server-cart), finance, vk-id (identity),
  connector (integrations), marketplace (registry), offer, seller,
  mall-content

8 admin gateways, same pattern:

  orders, products, users, transactions, monitoring, moderation,
  notifications, dashboard-metrics

Endpoints came from the matching contract doc where one exists (Phase 1-10,
Track A, Track S - cited per file). Three domains have no dedicated contract
doc yet (transactions, monitoring, moderation) - those gateways call the
established /api/admin/v2/{resource} convention used throughout the rest of
docs/backend/, flagged in each file's own comment as inferred rather than
specified, for whoever writes that contract to confirm or correct.

One real fix along the way: offer-api.gateway.ts's publish() translates a
422 + details[] response (contract §7's actual failure mode) into the
interface's { ok: false, errors } shape, rather than letting an HTTP error
leak past a caller that expects a value back.

Media repository (mock-media-repository.service.ts) deliberately NOT
swapped - no backend contract exists for it anywhere in docs/backend/, and
inventing endpoint shapes with zero grounding is worse than leaving it mock.

Regression this surfaced, fixed as part of the same change: three spec files
stubbed a gateway's concrete Local class directly via useValue. That worked
by accident while the token unconditionally resolved to the local class; once
the token became conditional on useMockData, those specs silently injected
the real (unmocked) API gateway instead and failed. Fixed by providing the
token instead of the class - the pattern the ADMIN_CATEGORIES_GATEWAY entry
in the same spec file already used correctly, because categories was
already token-swapped before this session:
  - admin-analytics.facade.spec.ts (orders/products/moderation gateways)
  - admin-order-watcher.service.spec.ts (orders gateway, 3 call sites)

Verified: 115/115 unit tests, 5/5 E2E, arch:check clean, production build
succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:29:50 +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
fc53a3b7f5 feat: server-authoritative checkout, no client-computed amount
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
F14-F16 of the frontend backlog. Contract: PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5.2.

The highest-priority change in Phase 1: `POST /cart` sent `amount` computed
client-side (this.convertTotal(this.totalWithDelivery())) and the backend was
asked to trust it. Replaced with two calls:

1. POST /api/v2/storefront/checkout - offer ids + qty only. Returns
   checkoutSessionId and the server-computed total.
2. POST /api/v2/storefront/payments/intents - references checkoutSessionId
   only. Same response shape as before (qrId/qrUrl/bankUrl/qrTTL via the
   existing resolvePaymentQrId/resolvePaymentLink/resolveBankPaymentUrl
   helpers) - this replaces how the charged amount is determined, not the
   QR/card provider polling flow, which Phase 1 does not redesign.

merchantReference (PARTNER-PROVISIONING-API-CONTRACT.md's RoutingContext
field) is sent on the payment intent, generated the same way the old orderId
was - our own correlation id, now with a name that matches what it is.

api.service.ts: CheckoutSessionRequest/Response and PaymentIntentRequest
types added, old CartPaymentRequest/createCartPayment left in place (Phase 7
reconciliation and any other caller may still reference the shape) but no
longer called from checkout.

offerId uses item.itemID: this codebase has no distinct Offer entity yet
(Phase 3, Product/Offer split, not shipped in this model) - itemID is the
same catalog identifier every other endpoint already keys off. Flagged in a
code comment for whoever ships Phase 3 to revisit.

Dead code removed as a consequence, not a separate pass: buildPaymentItems,
getPaymentUserId, getPaymentDescription (no other caller once the old
payload was gone), the ConfigService/TenantResolverService injects that
existed only for getPaymentDescription, and the now-orphaned
cart.paymentDescriptionFallback i18n key in all three locales.

Verification: cart.component.ts has no unit spec (no src/app/pages/cart/
*.spec.ts exists) - this session's E2E suite is the only coverage the
checkout request shape has. Added checkout-request-shape.spec.ts, scoped
narrowly to the request/response contract rather than a full add-to-cart
UI journey: seeds cart state directly into localStorage, fakes the customer
session via cookie + intercepted session-check, intercepts both new
endpoints and asserts on the captured request bodies. Confirms concretely:
no `amount` or `price` field ever leaves the client, offers carry the right
offerId/qty, and the payment intent correctly threads checkoutSessionId
through.

Verified: 5/5 E2E green, 115/115 unit tests green, arch:check clean,
production build succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:14:19 +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
14467cc6fb feat: FX-quote-backed currency conversion, delete admin rate editor
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
F10-F12 of the frontend backlog. Contract: PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §3.

Removed the failure mode §5 of that contract exists to close: rates were
typed once by an admin into Settings, persisted to localStorage, seeded from
a hardcoded DEFAULT_RATES table (USD: 0.011, AMD: 4.3) that never updated and
drifted from market. Nothing recorded which rate produced a displayed price
or when.

- currency-rates.service.ts   now fetches through FX_QUOTE_GATEWAY instead of
                              reading admin-typed/localStorage numbers. Stays
                              synchronous at the call site (getRate/convert) -
                              rewriting every consuming template to `| async`
                              is a separate, larger change (F13, not this
                              commit). Before a quote has loaded for a pair,
                              getRate returns 1 rather than a fabricated
                              market rate; isRateReady() lets a caller that
                              cares distinguish the two. ensureFreshQuote()
                              added for checkout to await before charging,
                              per contract §3.2's stale-quote policy.
- language.service.ts        setCurrency() now triggers a quote fetch instead
                              of just flipping the display signal.
- cart.component.ts           openPaymentPopup() awaits ensureFreshQuote()
                              before computing the charged amount.
- admin-settings-page.*        currency-rate editor deleted (F11) - card,
                              component state, and the three orphaned i18n
                              keys it was the only consumer of.

Two real bugs surfaced fixing this, neither cosmetic:

1. fx-quote-local.gateway.ts had CurrencyRatesService.convert() as its rate
   source. That is now circular - CurrencyRatesService depends on
   FX_QUOTE_GATEWAY, and under useMockData:true this gateway IS
   FX_QUOTE_GATEWAY. Would have recursed the moment mock FX data was
   exercised. Fixed by giving the local gateway its own static mock table -
   the correct home for those numbers now: explicitly labelled dev/mock data,
   only wired in behind useMockData, never presented as a live rate.

2. currency-convert.pipe.ts memoized its result on (amount, from, to) alone.
   That was already latently wrong - rates could change via the old
   setRate() without the pipe re-evaluating for an already-rendered price -
   but never surfaced because rates never changed mid-session in practice.
   Async quote loading made it concrete and reproducible: a price rendered
   before its quote arrived stayed wrong forever, because none of the three
   cached inputs ever changed again on their own. Fixed with a ratesVersion
   counter on the service, bumped on every quote arrival, included in the
   pipe's cache key.

Both found and fixed via the E2E suite (docs from the prior commit) actually
exercising the real code path: GET /api/v2/pricing/fx-quote intercepted with
a contract-shaped response rather than flipping the whole app into mock mode,
so the test runs the real FxQuoteApiGateway, not a stand-in for it.

Verified: 3/3 E2E green, 115/115 unit tests green, arch:check clean,
production build succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:03:02 +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
21443d34a0 feat: stand up E2E harness, fix a real bootstrap bug it found
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Track Q Q1/Q4 (docs/PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md). No E2E existed
before this. Playwright chosen - no existing test runner preference, and it
needs zero extra infra beyond the dev server this repo already has.

- playwright.config.ts, package.json e2e/e2e:ui/e2e:report scripts
- e2e/smoke.spec.ts       app boots, no console errors (network 404s from the
                          absent backend are filtered - expected, not a bug)
- e2e/currency-switch.spec.ts   Track Q Q4: switching currency must change
                          the displayed price VALUE, not just the label next
                          to it. Written specifically so the upcoming
                          checkout money-truth rewrite (frontend backlog
                          F10-F16, which replaces client-side FX math with a
                          server-computed total) has a regression net under
                          it before that rewrite starts.

The first run found a real, current bug: @marketplaces/auth ships plain tsc
output (dist/index.js), not Angular Package Format, so it carries no compiled
Ivy DI metadata. Any class-based provider from it - not just the Ed25519
Noop stub, AuthService itself hit the same failure - forces Angular to
JIT-compile at runtime, which throws immediately when @angular/compiler
isn't loaded. That breaks app bootstrap outright, for real users, not just
this test.

Fixed here with the minimum honest scope:
- src/main.ts: import '@angular/compiler' before bootstrap, so JIT works
  everywhere the package is injected, not just at one call site
- src/app/app.config.ts: useFactory instead of useClass for the Noop
  Ed25519 provider, since it has zero constructor deps and doesn't need
  Angular to derive metadata for it at all
- angular.json: raised the initial-bundle hard-error budget 1.5MB -> 1.8MB,
  because the compiler import made a correct build refuse to complete. A
  build that fails outright is worse than a bundle that's honestly larger
  than it should be.

The real fix belongs in the vitanovaPackages auth repo: publish via
ng-packagr so consumers get Ivy-compiled output and none of this is
necessary. Do not remove the compiler import until that ships - see the
comment left in main.ts.

Also fixed a genuine test defect while getting this to a real green: the
page renders duplicate .currency-option elements (desktop/mobile variants of
the same selector), so the first attempt at this test clicked into a hidden
duplicate and silently no-opped. Scoped the click to .currency-dropdown.open
and added an explicit poll for the DOM to reflect the new currency before
reading it back, rather than trusting a fixed timeout.

Verified: 3/3 E2E green, 115/115 unit tests green, arch:check clean,
production build succeeds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:46:43 +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
c104f313ce feat: real FX quote gateway, swapped in behind useMockData
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
FxQuoteApiGateway calls GET /api/v2/pricing/fx-quote per
PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §3.1. Deliberately thin - no caching or
retry here, since the caller decides what an expired quote means (re-fetch vs
block checkout) and baking that into the gateway would hide the decision.

Token now resolves to the real gateway whenever useMockData is false, matching
the existing pattern in runtime-provider-strategy.service.ts.

Additive only: nothing that reads prices today was rewired to this gateway yet.
That rewiring touches checkout's charge-amount computation and is deferred -
see the session summary for why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:28:56 +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
92e2ee5f49 feat: normalize API errors and handle 429 rate limiting
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Nothing in the frontend parsed the backend error envelope, and nothing
anywhere handled 429 - a rate-limited backend surfaced as a generic failure
with no retry and no user-visible explanation. core/error-handling and
core/interceptors were empty directories.

- api-error.model.ts    typed envelope per BACKEND-API-REFERENCE.md section 5,
                        plus a status-to-code fallback so a response with no
                        envelope still arrives as a usable ApiError
- api-error.mapper.ts   total function: HTML bodies, empty bodies and
                        differently-shaped JSON all produce an ApiError rather
                        than throwing inside the error path
- api-error.interceptor bounded retry on 429 honouring Retry-After (seconds or
                        HTTP-date), idempotent methods only - replaying a POST
                        after a 429 can double-submit, and that call belongs to
                        the caller that knows whether it holds an idempotency key
- rate-limit-notifier   signal-based state so the UI can say "throttled,
                        resumes in N seconds" instead of "something went wrong";
                        self-clearing, because a banner outliving the throttle
                        trains users to ignore it

A 429 carrying no delay hint defaults to a non-zero wait so callers cannot
busy-loop the endpoint that just asked them to stop.

14 mapper tests. Suite 115/115 green, boundaries pass, build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:15:13 +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
a2204c641b fix: force LF on systemd unit files
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
A CRLF checkout leaves a trailing carriage return in every unit value, which
systemd reads as part of the value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:21:50 +04:00
sdarbinyan
c721120e85 ci: make TLS provisioning dynamic as domains are added
Adding a domain was a manual per-domain script run. With domains arriving
continuously that does not hold, so certificate issuance is now automatic.

HTTP already needed no work: the nginx catch-all serves any Host and the SPA
resolves its tenant from that header. Only TLS needed a name-by-name step.

Two mechanisms:

- setup-wildcard-tls.sh issues one DNS-01 wildcard for *.<apex>, after which a
  new tenant subdomain is live over HTTPS with zero certificate work.
- sync-domains.sh reconciles tenant-owned domains against a desired list on a
  10-minute timer: issues what is missing, skips certificates with >30 days
  left, skips names already covered by the wildcard, waits out unpropagated
  DNS, and caps issuance per run so a bad source cannot burn the weekly ACME
  budget.

Safety properties worth stating: a failed fetch of the desired list aborts the
run rather than reading as "remove every domain"; removing a domain disables
its server block but keeps the certificate, so re-adding is instant; malformed
hostnames are rejected before reaching certbot or an nginx server_name.

The source is pluggable - a file today, the Phase 9 domain registry once it
exists, whose MarketplaceDomain statuses already match what this needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:21:39 +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
28861953c8 ci: add frontend CD pipeline, server provisioning, TLS scripts
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
There was no CD: pushing to main deployed nothing, and deploys were a manual
copy onto the server. This adds the missing half.

- .github/workflows/deploy.yml - build, upload to a per-commit release
  directory, swap the symlink atomically, reload nginx, verify over HTTP.
  The swap only happens after the upload is verified to contain index.html,
  so a failed deploy leaves the previous release serving.
- scripts/deploy/server-setup.sh - idempotent one-time provisioning: nginx,
  certbot, ufw, and a key-only deploy user whose sole sudo right is
  "systemctl reload nginx".
- scripts/deploy/add-domain.sh - per-domain server block plus TLS issuance,
  run once a domain's A record resolves to the server.
- docs/DEPLOYMENT.md - setup order, required CI secrets, rollback, limits.

Also adds .gitattributes: the shell scripts were being checked out with CRLF
endings, which makes bash fail on the shebang line on Linux.

Host keys are pinned via DEPLOY_KNOWN_HOSTS rather than trusted on first use.
No credentials are committed; all four deploy secrets are supplied by CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 11:42:06 +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
71da5a8d80 docs: partner provisioning API contract, routing context, Track P
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
A partner integration request landed for programmatic merchant-hierarchy
management (Company/Project/Store/PaymentPoint). Built the answer generically:
partner-specific behaviour is a PartnerProfile config row, and no partner name
appears in any entity, field, endpoint or status value.

New:
- docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md - hierarchy, idempotency,
  node-scoped public-key credentials, TEST/LIVE partition, routing context
- docs/context/adrs/ADR-0003-generic-partner-provisioning-api.md

Amended, because the schema impact must land before Phase 1 is implemented:
- Phase 1 gains RoutingContext on CheckoutSession/PaymentIntent/Payment,
  frozen at checkout-session creation and immutable after
- Phase 7 gains routing on Refund/ReconciliationRecord, plus the rule that
  seller settlement splits happen after routing, never as a hierarchy level
- Phase 9 gains Company/Project above Marketplace and PaymentPoint below it,
  with a backfill sequence for existing marketplaces
- Track S gains partner credentials: public key only, node-scoped authority,
  rotation with overlap, immediate revoke, audit coverage

Also: Track P (P1-P10) in the delivery plan, and backend ownership closed as
answered across the contract set.

Card payment was checked, not added - qr and card both already ship in
cart.component.ts with separate create paths and status pollers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 11:22:24 +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
551a22a245 fix: install shared packages over git, unbreaking CI
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
The Verdaccio registry introduced earlier is unreachable from CI (listens on
127.0.0.1:4873 behind a firewall allowing only 80/443/SSH), which broke the
architecture-governance workflow - its npm ci step could no longer resolve
@marketplaces/auth.

Packages are now published to git release branches (release/auth,
release/payment in vitanovaPackages) whose root is the package itself, and
installed with git+<repo>#release/auth. No registry, token, tunnel, or CI
secret - anonymous git read is enough.

- package.json: git dependency; .npmrc removed (no scope mapping needed)
- vitanovaPackages release.yml rebuilt to force-push release branches
- ADR-0001 amended with the distribution change and why the registry lost
- BACKEND-HANDOFF: added the multi-tenancy section (hostname -> tenantKey ->
  per-tenant bootstrap config), corrected the install and deploy notes, and
  recorded that no CD pipeline exists
- PACKAGE-EXTRACTION / PACKAGES-USAGE rewritten for the git-branch flow

Verified: npm ci, arch:check:boundaries, ng build, 103/103 tests, all with
no credentials configured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 02:08:05 +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
f6045a07b2 docs: backend handoff, package usage guide, finalized CI/CD
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
- docs/backend/BACKEND-HANDOFF.md: single entry point for a backend dev -
  reading order, verified infrastructure state (nginx running, Postgres
  inactive, no API on :8080, no TLS, no DNS automation, no CI runner),
  auth surface, and the day-one setup that is still outstanding
- docs/PACKAGES-USAGE.md: install, required DI providers, full exported
  API for both auth mechanisms, and how to ship a package change
- PACKAGE-EXTRACTION.md now covers build/release/infra only and points at
  the usage guide; CI section reflects the two real workflows

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 01:46:58 +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
2e4bb4ae00 feat: publish @marketplaces/auth to private registry, drop local package copy
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Stood up Verdaccio (Docker, on the dev server) as a private npm registry
since no public registry/NPM_TOKEN exists yet. Published @marketplaces/auth
and @marketplaces/payment there, removed the local packages/ staging copy
from this repo, and switched marketplaces to install @marketplaces/auth
0.1.0 as a real npm dependency through the registry.

- .npmrc scopes @marketplaces to the Verdaccio registry (no token committed;
  each installer/CI supplies its own via npm login or an env-injected token)
- Verified: fresh npm install, ng build, arch:check:boundaries, and full
  test suite (103/103) all pass against the registry-installed package
- Registry is reachable only via SSH tunnel today (firewall allows 80/443/
  SSH only); public/CI access is a follow-up decision, documented in
  docs/PACKAGE-EXTRACTION.md

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 01:32:20 +04:00
sdarbinyan
14c72d1a6a feat: extract auth into @marketplaces/auth package, add backoffice admin provisioning spec
- ADR-0001: decision to extract auth/payment into shared @marketplaces/* packages
- Scaffold packages/auth, packages/payment; @marketplaces/auth now holds the real
  telegram (customer+admin QR/session) and ed25519 (future admin challenge/response)
  auth implementation, pushed to sources.vitanova.network/sdarbinyan/vitanovaPackages
- Rewire ~30 call sites to import from @marketplaces/auth; delete migrated originals
  from core/auth, core/admin-auth, services/, models/
- Replace environment coupling with AUTH_API_URL/TELEGRAM_BOT_USERNAME injection
  tokens and isDevMode(); wired as file:packages/auth pending registry publish
- Add TRACK-S §8: bootstrap per-marketplace admin login + marketplace-scoped
  sub-admin invite/role endpoints
- Build, arch:check:boundaries, and full test suite (103/103) all green

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 01:05:16 +04:00
sdarbinyan
23060261c7 feat: Track S frontend - permission core + Audit & Security section
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
core/permissions (SessionPermissions/AuditEvent models, gateway/token,
requiresScope() CanActivateFn) against docs/backend/
TRACK-S-SECURITY-RBAC-CONTRACT.md §1-3. PermissionLocalGateway grants
PLATFORM_OWNER/'*' unconditionally - this matches TODAY'S REAL behavior
(GAPS-AND-IMPROVEMENTS.md: admin role model is decorative, every
authenticated admin has full access) rather than faking enforcement that
doesn't exist. requiresScope() is correspondingly a no-op against the
mock, by design - it must not create a false sense of security before a
real backend exists.

New features/admin/audit (Audit & Security nav section, missing from
admin nav today) - facade + page, empty state until real audit events
exist.

Scope: deliberately NOT retrofitting requiresScope() onto the 14 existing
live admin routes in this pass - a blanket guard rollout risks locking an
admin out without warning and needs its own verified pass, not a bundled
change alongside nine other phases. This is the single most serious
security gap this session's audit found; closing it for real is Track S's
own dedicated follow-up once a real backend exists to enforce against.

This closes out the full "do all phases" push: 10 phases + 2 tracks, each
with a real mock-gateway-backed swappable seam, several with genuinely new
backoffice UI. Every core/* module here binds via the same DI-token
pattern established for the 9 admin domains at the start of this session -
a real backend is a token swap per module, not a rewrite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 00:08:28 +04:00
sdarbinyan
be167d110e feat: Track A frontend - analytics event pipeline core + real call sites
core/analytics (AnalyticsEvent model, gateway/token/mock, AnalyticsService
wrapper) against docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1. isSynthetic
is derived from the build environment at the service layer, never
client-settable at a call site - matches the contract's §6 requirement
that synthetic traffic be inseparable-by-accident from production data
once a real backend exists.

Wired into real, live interaction points (additive only, no existing
logic touched): product_view + add_to_cart in
product-details-container.component.ts, checkout_started + payment_started
in pages/cart/cart.component.ts. This is the actual event-firing
infrastructure the plan calls "the single largest remaining backend
effort" (§3.1) - the frontend side (call sites) is real now; the mock
gateway just doesn't persist anywhere yet.

Not wired: search/category_view/seller_view/cart_view/payment_success/
payment_failed/order_created - follow-up call sites once this pattern is
reviewed, to avoid a much larger unreviewed diff in one push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 00:05:46 +04:00
sdarbinyan
34f79b0303 feat: Phase 10 frontend - Gorbushka-class content module core (models only)
core/content-modules against docs/backend/PHASE-10-CONTENT-MODULES-
CONTRACT.md §1-2: Shop/ShopCategory/MallService/Floor/SchemePin/
RentListing/Lead models + gateway/token, seeded empty.

Scope: models/gateway seam only, no admin UI (mall scheme/floor/pin
editor, rent listing management). This is explicitly the lowest-priority
phase in the delivery plan - only after Commerce Core is real - so it
gets the smallest build in this push, matching that priority rather than
spending equal effort on every phase regardless of sequence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 00:02:14 +04:00
sdarbinyan
5d585ff8ff feat: Phase 9 frontend - Marketplaces registry + Domains & Releases (combined page)
core/marketplace-registry (Marketplace/MarketplaceDomain/
LifecycleAdvanceResult models + gateway/token) derives a single-row
registry from the current tenant's own live bootstrap config and current
hostname, since the platform runs one tenant per deployment today with no
registry anywhere. New features/admin/marketplaces page + nav entry.
Against docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md.

Scope simplification: combined "Marketplaces" and "Domains & Releases"
(two separate backoffice sections in the plan) into one page with two
sections, to keep pace through this push - splitting them into dedicated
routes is a small follow-up once there's real multi-marketplace data to
justify two separate list views. Onboarding wizard (8 steps), Hostinger
DNS automation, and the full lifecycle-advance state machine are not
built - registry/domain/lifecycle read-only display only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 00:01:09 +04:00
sdarbinyan
0e16eecda6 feat: Phase 8 frontend - Customer identity core + standalone VK ID login button
core/identity (Customer/ExternalIdentity/ContactChannel models + VkIdGateway/
token/mock) and a standalone VkIdLoginComponent, per Sprint 0.1's "VK ID
first" decision and v3.1 §14. Against
docs/backend/PHASE-8-IDENTITY-MESSAGING-CONTRACT.md §1-2.

Deliberately not wired into TelegramLoginComponent's dialog - that's the
live, working QR-login surface for both customer and admin auth
(components/telegram-login/), and splicing a second provider into it
needs a real VK OAuth app to test against, not a mock bolt-on next to
nine other phases. The button is a standalone, ready-to-place component;
integrating it into checkout/login flows is follow-up work once VK
credentials exist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:58:48 +04:00
sdarbinyan
a4f44dbb58 feat: Phase 7 frontend - Refund/Reconciliation core + Payments & Finance section
New core/finance (Refund/ReconciliationRecord/Settlement models + gateway/
token, mock backed, seeded empty) and features/admin/finance (facade +
page: reconciliation queue with resolve action, settlements placeholder).
New /backoffice/finance route + nav entry, nav i18n key in all 3 languages.
Against docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md.

Note: does not wire AdminOrdersLocalGateway's existing mock
requestRefund(id) method into this new Refund flow yet - that's a small
follow-up once Phase 2's real Order backend exists to refund against.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:56:59 +04:00
sdarbinyan
e3a70f5e65 feat: Phase 6 frontend - server-cart core, mock-gateway backed (scoped)
core/cart against docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §2-5:
ServerCart/ServerCartLine/CheckoutSession/DeliveryOption models + gateway/
token, in-memory mock implementation.

Deliberately does not touch pages/cart/cart.component.ts or
services/cart.service.ts (the live localStorage/Telegram-CloudStorage
cart) or features/website/checkout/ (still an empty directory) - same
judgment as Phases 1/3/5: this is real money/payment-adjacent flow and
deserves a dedicated, verified rewiring pass once a real backend exists,
not a bundled swap alongside nine other phases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:54:51 +04:00
sdarbinyan
475d75781f feat: Phase 5 frontend - Seller gateway core, mock-backed (scoped)
core/sellers/services against docs/backend/PHASE-5-SELLER-PORTAL-
CONTRACT.md §3: SellerGateway operating on the EXISTING Seller domain
type (core/sellers/models/seller.model.ts - deliberately not a new
competing shape; GAPS-AND-IMPROVEMENTS.md already flags two rival seller
shapes and a third would make that worse). Adds SellerUser/SellerRole.
Seeded empty - Seller Management has zero real sellers today (flag off
by default).

Scope note: this session's largest deferral. A real Seller Portal is a
separate self-service app surface (/api/seller/v1/*, its own auth, its
own layout) per the contract - building that alongside 9 other phases in
one push risks a shallow, unreviewed seller-facing app. The gateway core
here is the swappable seam; the actual portal deserves its own focused
pass once a real backend and the unified-orders Fulfillment model
(Phase 2) are further along.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:53:24 +04:00
sdarbinyan
6ac52b1c50 feat: Phase 4 frontend - Integrations backoffice section (mock-gateway backed)
New core/integrations (Connector/DeadLetterEntry models + gateway/token)
and features/admin/integrations (facade + page: connector table with
status/lag/errors/backlog/unmatched, pause/resume). Seeded empty per
Sprint 0.1's "no fixed partner list" decision - the section is ready to
populate the moment the first real connector is onboarded against
docs/backend/PHASE-4-CONNECTOR-FRAMEWORK-CONTRACT.md §7. New /backoffice/
integrations route + nav entry, nav i18n key in all 3 languages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:51:38 +04:00
sdarbinyan
5f23c6e5aa feat: Phase 3 frontend - Offer/InventoryRecord core, mock-gateway backed
core/offers module against docs/backend/PHASE-3-CATALOG-OFFER-FULFILLMENT-
CONTRACT.md §2-3, §7. OfferLocalGateway derives one Offer per existing
AdminProduct (sellerId defaults to 'marketplace-owned' when absent - same
convention AdminProduct.sellerId already documents) so the shape is real
without touching the live admin Products domain.

Scope note: this is the core swappable seam only (model + gateway + token),
same judgment as Phase 1's pricing core - deferred is the actual Product/
Offer split UI (multi-seller product page, admin lookup-by-SKU screen),
which is the single largest structural change in the whole programme per
the delivery plan and needs its own dedicated pass against a real backend,
not a bundled mock-data rewire of the working Products admin surface.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:49:24 +04:00
sdarbinyan
580d228484 feat: Phase 2 frontend - Notification Center (mock-gateway backed)
New features/admin/notifications module against
docs/backend/PHASE-2-ORDERS-NOTIFICATIONS-CONTRACT.md §6: model, gateway
interface + local mock (derives notifications from the existing real
ADMIN_ORDERS_GATEWAY so the shape is genuine), facade, page (unread
filter, event-type filter, mark-read/mark-all-read). New /backoffice/
notifications route + nav entry (nav i18n key added in all 3 languages;
page body copy is plain English - see scope note below).

Existing AdminOrder model/facade/mock-gateway were already solid and
real (just gained a DI token this session) - Order/OrderLine/Fulfillment
canonical-model rework from the Phase 2 contract is deferred; today's
AdminOrder shape is close enough to build the Notification Center against
without a disruptive rewrite of an already-working admin surface.

Scope note (applies going forward for this "do all phases" push): new
page body text uses plain English instead of the full TranslatePipe/
i18n-key system. Multiplying every new string across en/ru/hy + the
Translations type for every phase isn't sustainable at this pace: nav
labels (few, highly visible) still get real i18n keys; page content
does not. Flagged for a follow-up i18n pass before any of this ships.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:47:13 +04:00
sdarbinyan
b19fd77a60 feat: Phase 1 pricing core - Money/FxQuote/PriceSnapshot, mock-gateway backed
First frontend build against docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md:
- Money type (amountMinor + currency, no float math) with add/subtract/
  multiply helpers respecting per-currency minor-unit decimals.
- FxQuote model + FX_QUOTE_GATEWAY token, mirroring the DI-seam pattern
  already used for the 9 admin domains. FxQuoteLocalGateway derives a
  quote from the existing CurrencyRatesService so the shape is real even
  before a backend rate source exists (Sprint 0.1: FX is ours in-house).
  source: 'local-mock' is explicit and distinct from the eventual real
  backend's 'internal' - swapping the token when the real endpoint ships
  requires zero caller changes.
- PriceSnapshot/CheckoutLine/CheckoutResult models per contract §4-5.

Scope note: this does NOT yet rewire the live cart/checkout payment flow
(pages/cart/cart.component.ts) onto this module - that flow handles real
money against a live payment provider, and rewiring it deserves its own
focused pass with explicit verification, not a bundled mega-change. The
core module is ready for that pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:34:00 +04:00
sdarbinyan
2e09369345 docs: Track A/S contracts + backend index tying the full v3.1 contract set together
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
- Track A: analytics event pipeline (traffic/catalog/commerce/operational/
  quality events), synthetic-traffic separation enforced server-side by
  environment/token, never a client-settable flag.
- Track S: 17-role/3-scope RBAC enforcement, audit log, secrets, rate
  limiting, step-up auth - closes this session's most serious finding
  (admin role model is decorative today, any authenticated admin has
  full access regardless of assigned role).
- docs/backend/README.md: index of all 12 contract docs (Phases 1-10 +
  2 tracks) in build order, plus what's deliberately excluded (namespace
  migration, per-connector adapters, extra payment providers) and the
  one thing still genuinely open across all of them - backend ownership.
- Cross-linked from BACKEND-API-REFERENCE.md and the delivery plan so the
  index is discoverable from either entry point.

This closes out documentation for every phase/track in
PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md that doesn't require a further business
decision. Nothing left undocumented on our side pending only implementation
and backend-ownership assignment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:09:04 +04:00
sdarbinyan
ec6760ac65 docs: backend contracts for Phases 8-10 (identity/messaging, tenant registry, content modules)
- Phase 8: Customer/ExternalIdentity/ContactChannel, VK ID OAuth 2.1/PKCE
  built first per Sprint 0.1 ("do all after vk"), then OTP, then MAX/
  Telegram bot linking, then the Notification Orchestrator + Delivery
  Conversation State Machine. Hard rule carried through: bots never touch
  financial statuses, only delivery fields via a dedicated Delivery Service.
- Phase 9: Marketplace/MarketplaceDomain/MarketplaceFeatureSet/
  MarketplaceRevision, full Hostinger DNS automation sequence (snapshot
  before change, never touch MX/SPF/DKIM/DMARC/CAA), lifecycle state
  machine that must expose its own blocker on every transition, publish
  model with orders/payments/inventory explicitly excluded from revisions.
- Phase 10: Gorbushka-class content entities (Shop/Service/Floor/
  SchemePin/RentListing/News/Lead/MallSettings), explicitly lowest
  priority and gated on Commerce Core being real first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:05:39 +04:00
sdarbinyan
707db6d43c docs: backend contracts for Phases 5-7 (seller portal, server cart, reconciliation)
- Phase 5: Seller Portal from scratch (zero backend bytes exist today) -
  SellerOrganization/SellerUser/SellerMarketplaceMembership, all endpoints
  scoped server-side to the unified-orders Fulfillment model from Phase 2.
- Phase 6: server-owned Cart/CartLine/CheckoutSession, extending Phase 1's
  server-authoritative-amount contract into the cart itself. Replaces
  localStorage/Telegram-CloudStorage cart persistence.
- Phase 7: Refund and ReconciliationRecord entities, settlement contract.
  Flags additional payment providers (wallets/BNPL) as still an open
  business decision - not blocking, schema is provider-agnostic already.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:04:11 +04:00
sdarbinyan
c91b75c036 docs: backend contracts for Phases 2-4 (orders/notifications, catalog/offer, connectors)
Continues the Phase 1 contract doc with the same wire-contract-only style.
All three build on Sprint 0.1's answered decisions - no further business
input needed to start implementation once backend ownership is confirmed:

- Phase 2: canonical Order/OrderLine/Fulfillment/OrderEvent per the unified
  multi-seller decision (one Order, per-seller Fulfillment groups), event
  bus, Notification Center contract.
- Phase 3: Product/Offer split, InventoryRecord, publish-time executability
  validation (the mechanism behind "no branch may distinguish an inspector
  from a normal buyer").
- Phase 4: generic config-driven connector framework per the "no fixed
  marketplace list" decision - onboarding a new partner is configuration
  against a fixed pipeline, not a bespoke integration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:02:52 +04:00
sdarbinyan
8d9eb97e9e docs: remove superseded super-admin Phase 1 design doc
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
superuser.md (already tracked from prior session) supersedes
2026-08-15-platform-super-admin-design.md. Committing the prior
session's uncommitted rename.
2026-08-17 22:43:09 +04:00
sdarbinyan
821fecf5d3 docs: record Sprint 0.1 decisions across delivery plan, gap analysis, backend contract
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
User answered 8 of 9 Sprint 0.1 blocking decisions (2026-08-17); backend
ownership stays open pending a clearer re-ask. Recorded and propagated:

- Payment chain unfrozen -> BACKEND-API-REFERENCE.md §7 and the Phase 1
  contract doc's status banner both updated; Phase 1/6/7 unblocked.
- No fixed external-marketplace list -> Phase 4's connector framework
  respecified as config-driven/generic; Sprint 4.2 retired as "per named
  marketplace," replaced with a generic onboarding runbook.
- FX rate source: ours, in-house, as the default (not just a fallback) ->
  Phase 1 contract's `source` field can read "internal" as the normal case.
- VK ID before OTP -> Phase 8 sprints resequenced (VK ID now 8.2, OTP 8.3).
- Multi-seller orders: unified -> Phase 3.3, Phase 5.2, and Z16 updated to
  the resolved model (one Order, per-seller Fulfillment groups).
- "Fixed 5-second payment" claim: confirmed non-issue, PAYMENT_POLL_INTERVAL_MS
  is already 5000 (real polling cadence, not an artificial delay).
- API namespace: new endpoints only (/api/v2/...), no forced migration of
  legacy endpoints.
- Document version: v3.1 is canonical.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 22:08:23 +04:00
sdarbinyan
54cd089e80 fix: WCAG AA contrast remediation on border/status colors (Z8, user-authorized)
Darkened --border-color and --success/--warning/--error/--info-color in
all three theme files, hue-preserving, computed against WCAG 2.1 formulas:
- border-color: 1.24-1.42:1 -> >=3.0:1 (non-text/UI-component minimum)
- status colors: 2.15-3.76:1 -> >=4.5:1 (plain-text minimum)
--primary-color/--secondary-color/--accent-color/gradients untouched -
only semantic feedback tokens changed. lavero's success-color now diverges
from primary-color (they only happened to share a hex before; semantic
status vs. brand identity are different concerns).

Also verified/corrected during this pass, doc was stale not code:
- Footer "Contacts" is not a code gap - Footer Builder + static-page CMS
  already resolve any authored page generically via pageKey
- Checkout payment-description fallback already tries brandName -> hostname
  -> i18n-translated fallback (en/ru/hy), not a hardcoded RU string

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 22:04:14 +04:00
sdarbinyan
634e3faf3d docs: correct Z6 finding - Product/Organization JSON-LD already shipped
SeoService.setJsonLd() already injects real application/ld+json for
Product (per-item) and Organization (site default) - the gap doc's
"confirmed absent" claim was stale. Noted the one real remaining gap
(BreadcrumbList/ItemList schema) as future net-new scope rather than
implementing it now. Sitemap generation remains backend-only, unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 21:50:59 +04:00
sdarbinyan
6ca672987e refactor: extract shared app-breadcrumb component (Z14)
Only breadcrumb logic anywhere in the storefront was a local signal +
inline markup inside catalog-container. Extracted a generic
shared/ui/breadcrumb component (rootLabel/items/ariaLabel inputs,
rootClick/itemClick outputs) and repointed catalog-container onto it,
removing the now-dead inline SCSS block. Future breadcrumb usages
(product detail, admin) have something to reuse instead of duplicating.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 21:49:54 +04:00
sdarbinyan
bf367fc5fe docs: correct search-model duplication finding - three shapes, not two
Re-verified GAPS-AND-IMPROVEMENTS.md's "duplicate search models" item:
core/search/models/search.model.ts was already a re-export shim (fine),
but core/search/models/search-state.model.ts is a real second copy, and
features/search/facade/search.facade.ts has a third, private
LegacySearchState interface with the same fields again. Documented as its
own scoped task rather than fixed here - reconciling three shapes on the
catalog rendering path needs full consumer tracing first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 21:38:53 +04:00
sdarbinyan
d24ba38479 docs: mark 7 GAPS-AND-IMPROVEMENTS.md items fixed, verified against current source
Doc was 4 days stale relative to source - each item below was independently
verified against the current file/line during this session's Track Z sweep,
not just marked off the todo list:
- Ed25519 auth-error body-code bug
- Dark mode selector (now actually fixed this session)
- Site Layout selector fallback
- setItemMeta() wiring
- og:locale dynamic locale
- stars.component.scss token usage
- AdminRole duplication
- PRODUCT_DATA_PROVIDER/CATEGORY_REPOSITORY dead mock branch
- sellerId UUID typing

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 21:37:04 +04:00
sdarbinyan
288c7ac33f fix: wire dark-mode theme tokens for all three tenant themes (Z1)
theme-engine.service.ts already sets [data-theme-mode] on the root, but
no CSS anywhere consumed it - picking Dark/System never changed anything
visually. Adds a structural dark override block per theme (dexar/lavero/
novo): bg/text/border/shadow tokens only. Brand colors (primary/secondary/
accent/gradients) are left untouched - a distinct dark-mode brand palette
is a design decision for the theme owner, not made here.

Also verified during this pass, no change needed (GAPS-AND-IMPROVEMENTS.md
was stale on these):
- og:locale already reads languageService.currentLanguage() dynamically
- SeoService.setItemMeta() is already called from product-details-container
- stars.component.scss already uses var(--border-color), no literal hex
- sellerId is already typed UUID, no bare-string field remains

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 21:35:13 +04:00
sdarbinyan
ffaa6d2a1c refactor: rename storefront CategoryApiModel; correct stale auth-error doc; add Phase 1 backend contract
- models/category.model.ts: Category -> CategoryApiModel, disambiguated
  from core/categories/models/category-domain.model.ts's Category (admin
  domain shape). Removes a dead unused import in item.utils.ts along the
  way. Only live consumer was services/api.service.ts, updated in place.
- BACKEND-API-REFERENCE.md §5: corrected two rows documenting the
  TOKEN_EXPIRED/INVALID_SIGNATURE auth-error bug as still open - the fix
  (reading error.error.code before falling back to HTTP status) is
  already in auth.service.ts. Doc was stale, not the code.
- Sprint 0.2 audit: AdminRole duplication and the
  PRODUCT_DATA_PROVIDER/CATEGORY_REPOSITORY dead mock branches were
  already resolved in a prior pass - verified, no code change needed.
- docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md: new wire contract
  for Money/FxQuote/PriceSnapshot/payment state machine, so backend can
  start Phase 1 the moment the frozen payment chain is unblocked.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 21:31:11 +04:00
sdarbinyan
3c72c37e31 docs: v3.1 gap analysis + delivery plan; add DI seams to 9 admin gateways
Adds InjectionToken + factory for Orders, Products, Users, Transactions,
Monitoring, Moderation (mirrors existing Categories/Dashboard pattern) and
repoints their facades plus the derived Analytics/Customers facades and
admin-order-watcher off the mock LocalGateway class directly. No behavior
change today - still resolves to the mock - but a real backend can now be
bound per domain with zero facade edits.

Docs: full gap analysis of Product Plan v3.1 against current repo state,
and a phased delivery plan (10 phases, 34 sprints, 5 tracks) breaking every
identified gap into scoped, sequenced work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 21:24: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
687891cbaf 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:13:10 +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
e818dc6fc0 docs: add admin method toggle + error handling to email/phone login spec
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 18:52:52 +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
aedc05110c docs: design spec + backend ask for email/phone OTP login (item 4)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 17:51:15 +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
sdarbinyan
0d1d468307 feat: admin product views column (always 0 until backend tracks it)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 16:18:20 +04:00
sdarbinyan
3056be53db docs: implementation plan for admin product views column (item 1)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 16:11:09 +04:00
sdarbinyan
7f3a22abb8 docs: design spec for admin product views column (item 1)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 15:58:49 +04:00
sdarbinyan
fbc51c4866 merge: main into B2B (doc updates)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 04:37:52 +04:00
sdarbinyan
10b2ad9023 docs: backend FX/notifications gaps, add CHANGELOG for recent work
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 04:37:42 +04:00
sdarbinyan
7f9bb6aae6 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 04:34:59 +04:00
sdarbinyan
1ab689e056 fix: unreadCount badge falls back to timestamp when ack pointer scrolls off page
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 04:33:25 +04:00
sdarbinyan
f1ee199d92 fix: AdminOrderWatcherService reacts to auth state, not component lifecycle
Previous fix (1032891) stopped polling via AdminLayoutComponent's
DestroyRef, but logout() never navigates or destroys the component -
the watcher kept polling and toasting indefinitely after logout.
Now polling starts/stops off AdminAuthService.isAuthenticated() directly,
following the same effect() pattern already used by AdminDashboardFacade.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 04:25:51 +04:00
sdarbinyan
1032891d26 fix: address final review findings (order-notification watcher robustness)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 04:18:20 +04:00
sdarbinyan
9ccd807a55 feat: editable new-order poll interval in admin settings
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 04:04:32 +04:00
sdarbinyan
35b1c7ed27 feat: wire order watcher into admin topbar bell (badge + panel)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:59:23 +04:00
sdarbinyan
28f39a31f6 feat: AdminOrderWatcherService polls for new orders and toasts
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:52:55 +04:00
sdarbinyan
5ed4936898 feat: UserNotificationService supports click-to-navigate toasts
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
2026-08-15 03:47:46 +04:00
sdarbinyan
55e4938a57 docs: spec update - reuse existing topbar bell instead of new sidebar badge
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:43:07 +04:00
sdarbinyan
cfee91355b docs: implementation plan for admin purchase notifications (item 7)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 03:20:32 +04:00
sdarbinyan
0d0f4e5f9c docs: design spec for admin purchase notifications (item 7)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 02:08:36 +04:00
sdarbinyan
0221c905f0 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 02:01:18 +04:00
sdarbinyan
0cadc1a642 fix: delivery-selector price/currency not converting on currency switch
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Component read item.currency (source) as both the display label and the
conversion target, so amounts never actually converted - only the label
technically matched. Now converts deliveryPrice/selectedDeliveryTotal via
CurrencyRatesService and labels with the shopper's selected currency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 02:01:08 +04:00
sdarbinyan
8b01685b48 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 01:46:08 +04:00
sdarbinyan
4510eb769a feat: client-side currency conversion with admin-configurable rates
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
- CurrencyRatesService: RUB-based rates, persisted via localStorage
- CurrencyConvertPipe: impure pipe converting item price to selected currency
- Admin settings page: editable currency rates form
- Applied conversion to product-card, product-information, quick-view-dialog,
  delivery-information, compare-table, cart totals + payment payload

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 01:45:12 +04:00
sdarbinyan
414e86bfb5 Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
2026-08-13 17:20:38 +04:00
sdarbinyan
d8c078ad5a fix: review findings from full-diff audit (7 fixed)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
- popularSearches sent translated display text as the actual search
  query instead of the canonical term - useSuggestion() now prefers
  target.query.q when present.
- CartService.addItem() dedup guard resolved immediately instead of
  awaiting the real in-flight add; now tracks the pending Promise per
  itemID so concurrent callers await the actual result.
- addItem()'s Promise never rejected on failure (resolve() in both
  next/error branches) - now rejects on error; buyNow() catches and
  shows an error toast instead of navigating on a failed add.
- Quick View had no stale-response guard - a slower earlier request
  could overwrite a faster later one. Added a request-generation
  counter.
- cart autoSubmitPurchase() set paymentStatus to null synchronously
  right after firing the async submit call, blanking the success
  screen while the request was still in flight. Removed the
  redundant/harmful line.
- Order terminal-status guard (cancelled/refunded can't be reopened)
  lived only in the page component. Moved enforcement into the
  gateway (single write path) via a shared TERMINAL_ORDER_STATUSES
  const, so no future caller can bypass it.
- TranslatePipe's per-instance memoization cache had no eviction,
  so bindings with volatile params (pagination counts) grew it
  unbounded for the component's lifetime. Capped at 50 entries.

Not changed: the dark-mode color override was flagged as clobbering
admin branding, but it's the exact palette explicitly requested this
session for the global dark default - not a bug.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 17:20:00 +04:00
sdarbinyan
07367d3183 Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
2026-08-13 13:01:42 +04:00
sdarbinyan
570e3f3c36 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-13 03:17:24 +04:00
sdarbinyan
5d47101714 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-13 02:48:32 +04:00
325 changed files with 17034 additions and 2950 deletions

11
.gitattributes vendored Normal file
View File

@@ -0,0 +1,11 @@
* text=auto
# Anything executed by a Linux shell must keep LF endings. A CRLF checkout
# makes bash fail with "\r: command not found" on the very first line.
*.sh text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
# systemd chokes on trailing CR in unit values.
*.service text eol=lf
*.timer text eol=lf

View File

@@ -17,7 +17,7 @@ jobs:
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 24
cache: npm cache: npm
- name: Install Dependencies - name: Install Dependencies
@@ -26,5 +26,34 @@ jobs:
- name: Enforce Boundaries - name: Enforce Boundaries
run: npm run arch:check run: npm run arch:check
# Was entirely missing before 2026-08-18: this workflow built and
# checked boundaries but never ran a single test. karma.conf.js's
# CHROME_BIN fallback is a Windows path, which the ubuntu-latest
# runner doesn't have - browser-actions/setup-chrome supplies one
# and CHROME_BIN below points at it explicitly.
- name: Setup Chrome
id: setup-chrome
uses: browser-actions/setup-chrome@v1
- name: Unit tests with coverage gate
env:
CHROME_BIN: ${{ steps.setup-chrome.outputs.chrome-path }}
run: npm run test:coverage
# The production build is what enforces the bundle budget. The initial
# bundle sits at ~1.55 MB raw against a 700 kB target, so the error
# threshold is a ratchet, not the goal: it is set just above today's
# size so the bundle cannot grow while we work it back down. Lower the
# ratchet in angular.json every time it comes down.
- name: Build - name: Build
run: npm run build run: npm run build
# Stops payment credentials returning to the browser bundle. See
# scripts/ci/scan-bundle.sh for what it looks for and why.
- name: Scan bundle for credentials
run: npm run scan:bundle
- name: E2E
run: |
npx playwright install --with-deps chromium
npm run e2e

196
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,196 @@
name: Deploy Frontend
# Multi-tenant: one bundle serves every customer domain, so a single deploy
# updates all of them at once. There is no per-tenant build or per-tenant deploy.
on:
push:
branches:
- main
workflow_dispatch:
inputs:
ref:
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
cancel-in-progress: false # never abandon a half-finished release swap
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.ref || github.ref }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- name: Install dependencies
run: npm ci
- name: Enforce boundaries
run: npm run arch:check
- name: Build
run: npm run build -- --configuration production
- name: Resolve build output
id: dist
run: |
set -euo pipefail
# @angular/build:application emits into dist/<name>/browser.
# Fall back to the flat layout so this survives a builder change.
if [ -d dist/dexarmarket/browser ]; then
DIR=dist/dexarmarket/browser
elif [ -f dist/dexarmarket/index.html ]; then
DIR=dist/dexarmarket
else
echo "no build output found under dist/dexarmarket" >&2
ls -R dist || true
exit 1
fi
test -f "$DIR/index.html" || { echo "$DIR has no index.html" >&2; exit 1; }
echo "dir=$DIR" >> "$GITHUB_OUTPUT"
echo "Deploying from $DIR ($(find "$DIR" -type f | wc -l) files)"
- name: Configure SSH
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
set -euo pipefail
test -n "$DEPLOY_SSH_KEY" || { echo "secret DEPLOY_SSH_KEY is empty" >&2; exit 1; }
test -n "$DEPLOY_KNOWN_HOSTS" || { echo "secret DEPLOY_KNOWN_HOSTS is empty" >&2; exit 1; }
mkdir -p ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
# Pinned host key, so a MITM or a rebuilt server fails the deploy
# instead of being trusted silently.
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 }}
STOREFRONT_DOMAINS: ${{ secrets.STOREFRONT_DOMAINS }}
CERTBOT_EMAIL: ${{ secrets.CERTBOT_EMAIL }}
BACKEND_UPSTREAM: ${{ secrets.BACKEND_UPSTREAM }}
run: |
set -euo pipefail
test -n "$HOST" || { echo "secret DEPLOY_HOST is empty" >&2; exit 1; }
test -n "$USER" || { echo "secret DEPLOY_USER is empty" >&2; exit 1; }
test -n "$STOREFRONT_DOMAINS" || { echo "secret STOREFRONT_DOMAINS is empty" >&2; exit 1; }
test -n "$CERTBOT_EMAIL" || { echo "secret CERTBOT_EMAIL is empty" >&2; exit 1; }
BACKEND_UPSTREAM="${BACKEND_UPSTREAM:-https://127.0.0.1:445}"
[[ "$CERTBOT_EMAIL" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]] || {
echo "CERTBOT_EMAIL is invalid" >&2; exit 1;
}
[[ "$BACKEND_UPSTREAM" =~ ^https?://[A-Za-z0-9.:-]+$ ]] || {
echo "BACKEND_UPSTREAM is invalid" >&2; exit 1;
}
declare -A API_BASE_DOMAINS=()
for storefront in $STOREFRONT_DOMAINS; do
[[ "$storefront" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ ]] || {
echo "invalid storefront domain: $storefront" >&2; exit 1;
}
IFS=. read -ra labels <<< "$storefront"
label_count=${#labels[@]}
take=2
tld=${labels[label_count-1]}
second_level=${labels[label_count-2]}
if (( label_count >= 3 && ${#tld} == 2 && ${#second_level} <= 3 )); then
take=3
fi
start=$((label_count - take))
base_domain=$(IFS=.; echo "${labels[*]:start}")
API_BASE_DOMAINS["$base_domain"]=1
done
SSH="ssh -i ~/.ssh/deploy_key -o BatchMode=yes"
for domain in "${!API_BASE_DOMAINS[@]}"; do
$SSH "$USER@$HOST" sudo /usr/local/sbin/marketplaces-configure-api-domain \
--domain "$domain" --email "$CERTBOT_EMAIL" --upstream "$BACKEND_UPSTREAM"
done
- name: Upload release
env:
HOST: ${{ secrets.DEPLOY_HOST }}
USER: ${{ secrets.DEPLOY_USER }}
SRC: ${{ steps.dist.outputs.dir }}
run: |
set -euo pipefail
RELEASE="${GITHUB_SHA::12}"
echo "RELEASE=$RELEASE" >> "$GITHUB_ENV"
SSH="ssh -i ~/.ssh/deploy_key -o BatchMode=yes"
$SSH "$USER@$HOST" "mkdir -p /srv/marketplaces/releases/$RELEASE/frontend"
tar -C "$SRC" -czf - . | $SSH "$USER@$HOST" \
"tar -xzf - -C /srv/marketplaces/releases/$RELEASE/frontend"
- name: Activate release
env:
HOST: ${{ secrets.DEPLOY_HOST }}
USER: ${{ secrets.DEPLOY_USER }}
run: |
set -euo pipefail
ssh -i ~/.ssh/deploy_key -o BatchMode=yes "$USER@$HOST" bash -euo pipefail <<EOSSH
BASE=/srv/marketplaces
REL="\$BASE/releases/$RELEASE"
test -f "\$REL/frontend/index.html" || { echo "upload incomplete, refusing to swap" >&2; exit 1; }
# ln -T onto a temp name then mv: the swap is atomic, so no request
# is ever served from a half-updated root.
ln -sfnT "\$REL" "\$BASE/current.new"
mv -Tf "\$BASE/current.new" "\$BASE/current"
sudo /bin/systemctl reload nginx
# Keep the last 5 releases so a rollback is a symlink change.
ls -1dt "\$BASE"/releases/*/ | tail -n +6 | xargs -r rm -rf
echo "active: \$(readlink -f \$BASE/current)"
EOSSH
- name: Verify
env:
HOST: ${{ secrets.DEPLOY_HOST }}
USER: ${{ secrets.DEPLOY_USER }}
run: |
set -euo pipefail
ssh -i ~/.ssh/deploy_key -o BatchMode=yes "$USER@$HOST" \
'curl -fsS -o /dev/null -w "health=%{http_code}\n" http://127.0.0.1/health &&
curl -fsS -o /dev/null -w "index=%{http_code}\n" http://127.0.0.1/'
- name: Report
if: always()
run: |
if [ "${{ job.status }}" = "success" ]; then
echo "Deployed ${GITHUB_SHA::12} to ${{ secrets.DEPLOY_HOST }}" >> "$GITHUB_STEP_SUMMARY"
else
echo "Deploy of ${GITHUB_SHA::12} FAILED. The previous release is still active — the symlink only moves after a successful upload." >> "$GITHUB_STEP_SUMMARY"
fi

5
.gitignore vendored
View File

@@ -2,6 +2,7 @@
# Compiled output # Compiled output
/dist /dist
packages/*/dist
/tmp /tmp
/out-tsc /out-tsc
/bazel-out /bazel-out
@@ -74,3 +75,7 @@ docs/context/schema/route.schema.json
docs/context/schema/strategy.schema.json docs/context/schema/strategy.schema.json
docs/context/schema/work-state.schema.json docs/context/schema/work-state.schema.json
docs/context/schema/workspace.schema.json docs/context/schema/workspace.schema.json
# Playwright artifacts
/test-results
/playwright-report

View File

@@ -2,6 +2,8 @@
One document, everyone reads it: product, backend, frontend, QA. It answers three questions for every domain — **what does the frontend already call**, **what shape does it send/expect**, and **is it real or mocked today**. Generated from the actual Angular frontend source (this repo has no backend code — it is a pure client consuming an external API), cross-checked against the frontend's own tolerant adapters, not aspirational. One document, everyone reads it: product, backend, frontend, QA. It answers three questions for every domain — **what does the frontend already call**, **what shape does it send/expect**, and **is it real or mocked today**. Generated from the actual Angular frontend source (this repo has no backend code — it is a pure client consuming an external API), cross-checked against the frontend's own tolerant adapters, not aspirational.
**For what doesn't exist yet:** this doc describes the live surface only. The full set of forward-looking wire contracts for Product Plan v3.1 (money/FX, orders, catalog/offer split, connectors, seller portal, identity, tenant registry, RBAC, analytics — 10 phases + 2 tracks) lives in [docs/backend/](docs/backend/README.md).
Maturity tags used throughout: Maturity tags used throughout:
| Tag | Meaning | | Tag | Meaning |
@@ -115,6 +117,45 @@ Storage: `localStorage['ed25519AdminToken']` (access), `localStorage['ed25519Adm
**Open decision (business, not technical — ask a human):** whether Mechanism A is retired outright in favor of Mechanism B at cutover, or both run in parallel gated by role/tenant config. **Open decision (business, not technical — ask a human):** whether Mechanism A is retired outright in favor of Mechanism B at cutover, or both run in parallel gated by role/tenant config.
### 2c. Email/phone OTP login — customer (NOT IMPLEMENTED, proposed)
**Gap:** customer storefront login/checkout requires Telegram (Mechanism A) — shoppers without Telegram have no way to identify themselves. Raised as a real usability problem, not a hypothetical.
**Ask:** a third, independent auth mechanism (coexists with 2a/2b, replaces neither):
```
POST /auth/otp/request
Body: { "identifier": "user@example.com" } // or E.164 phone, e.g. "+79991234567"
Response: { "requestId": "...", "expiresAt": "2026-08-15T10:15:00Z" }
```
```
POST /auth/otp/verify
Body: { "requestId": "...", "code": "482913" }
Response (on success): {
"sessionId": "...", "userId": 8823771, "username": null,
"displayName": "user@example.com", "active": true, "expires": "2026-08-15T11:15:00Z"
}
```
The success response must be shaped identically to the existing `AuthSession` (`sessionId, userId, username, displayName, active, expires`, §2a's client model) — this lets every existing downstream consumer (guards, session signals, cart/checkout) work unchanged regardless of which mechanism produced the session.
Rate limiting/expiry, explicit so nothing is left to guesswork: 60s resend cooldown per identifier between `/request` calls; code expires 10 minutes after issuance; `requestId` allows up to 5 verify attempts before it's invalidated (consumed on success, on the 5th wrong attempt, or on expiry) — not single-use-per-attempt, so one mistyped digit doesn't force a full 60s wait for a new code.
**Error responses must use the existing envelope** (§5), with these codes on `/verify` (the client maps each to distinct UX — see the design doc):
| `error.code` | HTTP status | Meaning |
|---|---|---|
| `VALIDATION_FAILED` | 422 | Malformed identifier (`error.details[0]` names the field). |
| `RATE_LIMITED` | 429 | Resend cooldown not yet elapsed. |
| `CODE_EXPIRED` | 410 | 10-minute window passed. |
| `CODE_INVALID` | 401 | Wrong code, attempts remain on this `requestId`. |
| `REQUEST_NOT_FOUND` | 404 | `requestId` unknown, exhausted (5 wrong attempts), or expired. |
Admin can toggle which login methods (Telegram/Email/Phone) are shown to shoppers — this is a client-only UI gate (Admin Settings, `LocalStorageService`-persisted), not a backend flag; all endpoints stay available regardless of the toggle state.
See `docs/superpowers/specs/2026-08-15-email-phone-login-design.md` for the full design. No client code exists yet — nothing to build against a 404.
--- ---
## 3. Bootstrap — the runtime config document ## 3. Bootstrap — the runtime config document
@@ -204,7 +245,7 @@ No cursor/keyset pagination exists anywhere. No server-side page-size cap is enf
## 5. Error model ## 5. Error model
**The frontend does not currently parse any backend error envelope for any real endpoint** — no interceptor inspects error responses; every caller reacts at the raw `HttpErrorResponse.status`/`.message` level. The one partial exception (Ed25519 admin auth) derives its error code from **HTTP status only**, ignoring any body field, which is itself a known bug (see below). Everything in this section is therefore a **recommended envelope to adopt going forward**, not something already wired end-to-end — apply it to new endpoints and treat the frontend gaps below as follow-up work, not something this doc can silently paper over. **The frontend does not currently parse any backend error envelope for any real endpoint** — no interceptor inspects error responses; every caller reacts at the raw `HttpErrorResponse.status`/`.message` level. The one partial exception (Ed25519 admin auth) now reads `error.error.code` from the body when present (`authErrorCodeFromBackendCode()`), falling back to HTTP status only when no body code is sent. Everything in this section is therefore a **recommended envelope to adopt going forward**, not something already wired end-to-end — apply it to new endpoints and treat the frontend gaps below as follow-up work, not something this doc can silently paper over.
### The envelope ### The envelope
@@ -242,8 +283,8 @@ No cursor/keyset pagination exists anywhere. No server-side page-size cap is enf
| 503 (infra down) | `SERVICE_UNAVAILABLE` | Same "backend unavailable, retry" screen as 500, on the Ed25519 flow only. | | 503 (infra down) | `SERVICE_UNAVAILABLE` | Same "backend unavailable, retry" screen as 500, on the Ed25519 flow only. |
| 503 (maintenance) | `MAINTENANCE_MODE` (+`maintenanceUntil`) | **No maintenance-mode concept exists in the frontend at all today.** Same HTTP status as infra-down 503 — `error.code` is the only way to distinguish them. | | 503 (maintenance) | `MAINTENANCE_MODE` (+`maintenanceUntil`) | **No maintenance-mode concept exists in the frontend at all today.** Same HTTP status as infra-down 503 — `error.code` is the only way to distinguish them. |
| 403 (tenant disabled) | `TENANT_DISABLED` | **No handling exists.** No code path today distinguishes "tenant exists but is disabled" from any other 403. | | 403 (tenant disabled) | `TENANT_DISABLED` | **No handling exists.** No code path today distinguishes "tenant exists but is disabled" from any other 403. |
| 401 (token expired) | `TOKEN_EXPIRED` | **Known bug, not just a gap:** the client has a dedicated "Session expired" screen wired and ready, but `toAuthErrorShape()` only reaches it via a no-refresh-token-present client-side branch — a *real* backend 401 on `/refresh` always renders the generic "Unauthorized" screen instead, because the mapping function ignores any body code and derives purely from HTTP status. Fix requires the backend to send `error.code: "TOKEN_EXPIRED"` **and** a small frontend change to prefer it. | | 401 (token expired) | `TOKEN_EXPIRED` | **Fixed**`toAuthErrorShape()` (`core/auth/services/auth.service.ts`) now reads `error.error.code` via `authErrorCodeFromBackendCode()` before falling back to HTTP status. A backend 401 on `/refresh` sending `error.code: "TOKEN_EXPIRED"` reaches the dedicated "Session expired" screen. |
| 401 (bad signature) | `INVALID_SIGNATURE` | Same bug class as above — dedicated screen exists, unreachable from a real HTTP response for the identical reason. | | 401 (bad signature) | `INVALID_SIGNATURE` | **Fixed**, same mechanism — reaches the dedicated screen when the backend sends `error.code: "INVALID_SIGNATURE"`. |
**Every admin backoffice list page** (Users/Orders/Monitoring/Moderation/Transactions/Products/Categories/Analytics/Customers/Dashboard) shares one generic pattern: a boolean `error` signal → "Something went wrong" + retry button. None of them branch on status or `code` today — every status above collapses into the same generic UI until facades are individually updated. **Every admin backoffice list page** (Users/Orders/Monitoring/Moderation/Transactions/Products/Categories/Analytics/Customers/Dashboard) shares one generic pattern: a boolean `error` signal → "Something went wrong" + retry button. None of them branch on status or `code` today — every status above collapses into the same generic UI until facades are individually updated.
@@ -266,6 +307,18 @@ Base: `ApiConfigService.getBaseUrl()`. Headers on every call (`apiHeadersInterce
| `/items/{id}/questiion` | POST | `{ question, sessionID, timestamp }` | `{ message }`**literal typo `questiion`, preserve it, matches the client** | | `/items/{id}/questiion` | POST | `{ question, sessionID, timestamp }` | `{ message }`**literal typo `questiion`, preserve it, matches the client** |
| `/purchase-email` | POST | `{ email, phone?, telegramUserId, items[] }` | `{ message }` | | `/purchase-email` | POST | `{ email, phone?, telegramUserId, items[] }` | `{ message }` |
| `/regions` | GET | — | `Region[]` — client falls back **silently** to 6 hardcoded regions on any error | | `/regions` | GET | — | `Region[]` — client falls back **silently** to 6 hardcoded regions on any error |
| `/geo/resolve` | GET | — | `GeoIpResponse`**not built yet**, see below |
**`/geo/resolve` — new, required.** Resolves the *caller's* IP to a coarse location so the storefront can pre-select a region. The server reads the client IP (behind the proxy, so honour `X-Forwarded-For` with `trustProxy`); the browser sends nothing and receives no third-party payload.
Response is the existing `GeoIpResponse` shape (`src/app/models/location.model.ts`): `{ city, country, countryCode, region?, timezone?, lat?, lon? }`.
Rules:
- City-level precision only. Do not return coordinates finer than the city centroid, and do not persist the lookup against a customer record — this runs for anonymous visitors.
- Any failure returns a non-2xx. The client already treats every error as "stay on the manual picker", so a degraded geo provider must never block the storefront.
- Rate-limit per IP; it is an unauthenticated endpoint.
This replaces a direct browser call to `http://ip-api.com`, which leaked every visitor's IP to a third party and — being plaintext on an HTTPS origin — was blocked as mixed content, so region auto-detect never actually worked in production. Until this endpoint ships the client silently falls back to the manual region picker, which is the same behaviour production has had all along.
### 6.1 Products — the tolerance contract ### 6.1 Products — the tolerance contract
@@ -332,7 +385,7 @@ WebSessionID: 3f1c2a0e-…
{ "qrId": "QR-77f0", "nspkurl": "https://qr.nspk.ru/AD10…", "status": "created", "qrExpirationDate": "2026-07-26T04:10:00Z" } { "qrId": "QR-77f0", "nspkurl": "https://qr.nspk.ru/AD10…", "status": "created", "qrExpirationDate": "2026-07-26T04:10:00Z" }
``` ```
**Payments are frozen** — this call chain is explicitly out of scope for changes; document only, don't modify. **Payments were frozen; unfrozen 2026-08-17** (Sprint 0.1 decision, see `docs/PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md`). This call chain is now in scope for the Phase 1 rework specified in `docs/backend/BACKEND-INTEGRATION.md` — the server-authoritative-amount contract there replaces the client-trusted `amount`/`price` fields described below.
--- ---
@@ -422,6 +475,7 @@ Worth knowing explicitly, so nobody assumes a gateway swap will "just work" for
- **User experience (wishlist/compare/recently-viewed/saved-searches)** — fully denormalized objects in `localStorage`, guest-first. A DI token exists for a future authenticated repository, but nothing is bound to it — comment in code notes it "can be switched to authenticated repository later." - **User experience (wishlist/compare/recently-viewed/saved-searches)** — fully denormalized objects in `localStorage`, guest-first. A DI token exists for a future authenticated repository, but nothing is bound to it — comment in code notes it "can be switched to authenticated repository later."
- **Diagnostics** — inspects runtime/bootstrap/widget state locally; the one live-ish probe is a `/ping` health check. - **Diagnostics** — inspects runtime/bootstrap/widget state locally; the one live-ish probe is a `/ping` health check.
- **Cart contents** — see §7, real payment/order calls exist, cart *state* never round-trips to a backend. - **Cart contents** — see §7, real payment/order calls exist, cart *state* never round-trips to a backend.
- **Currency conversion / display rates** — `CurrencyRatesService` holds RUB-based conversion rates in-memory, admin-editable via Admin Settings, persisted to `localStorage` only. `CurrencyConvertPipe` applies them client-side wherever a storefront price is rendered. The `Currency` request header (§6) is still sent on every call, but nothing round-trips a rate from the backend — see §12.7.
--- ---
@@ -523,7 +577,30 @@ Separately, `createCartPayment()` (payment-gateway charge creation) still sends
``` ```
`telegramUserId` may be `null` for a non-Telegram web session - decide whether to also accept an email address as an alternative identifier (the frontend has no email capture on this flow today, so that would need a small frontend addition too). Once this ships, the frontend's localStorage fallback becomes purely a resilience path rather than the common case, and could optionally sync any locally-queued subscriptions on next successful call. `telegramUserId` may be `null` for a non-Telegram web session - decide whether to also accept an email address as an alternative identifier (the frontend has no email capture on this flow today, so that would need a small frontend addition too). Once this ships, the frontend's localStorage fallback becomes purely a resilience path rather than the common case, and could optionally sync any locally-queued subscriptions on next successful call.
### 12.6 Trending search terms ### 12.7 Currency conversion / FX rates
**Gap:** the backend has no per-currency pricing — it sends prices in one base currency (`RUB`) regardless of the `Currency` header (§6), and there's no exchange-rate endpoint. Client fix already shipped: admin manually enters a RUB-based rate per supported currency (Admin Settings → Currency rates), and every storefront price display converts client-side via that static, admin-typed number. Rates never update themselves and can drift from the real market rate.
**Ask:** this was raised as a real accounting concern (bank settlement totals not reconciling against order counts) — two options, not mutually exclusive:
1. Backend returns prices already converted per the `Currency` header (removes client-side conversion entirely, most correct).
2. Backend exposes a live/periodically-updated FX-rate endpoint (e.g. pegged to Rapira or another exchange) that the frontend polls instead of relying on an admin-typed static number — smaller change, keeps pricing display client-side but removes the manual-entry drift.
Either way, the *authoritative* amount charged (`createCartPayment`'s `amount`, §12.3) must be computed/validated server-side against whichever rate source is authoritative — a client-side conversion (current or future) must never be trusted for the actual charge amount.
### 12.8 Admin purchase notifications depend on Orders CRUD being real
**Gap:** `AdminOrderWatcherService` (new — polls for new orders to toast/badge the admin) polls `AdminOrdersGateway.loadOrders()` (§8), which is bound to the mock `AdminOrdersLocalGateway` — a static, 24-row in-memory seed with no create path (see §8's gateway table, "Orders … MOCK-ONLY, no seam"). No genuinely new order can ever appear today, so the feature is functionally inert until Orders CRUD gets a real backend (§10 step 6).
**Ask:** nothing new beyond what §10/§11 already ask for — once a real `AdminOrdersApiGateway` is bound, this feature starts working with no additional frontend change. Flagging here only so nobody spends time debugging "why doesn't the notification ever fire" against the mock.
### 12.9 Admin product view counts
**Gap:** Admin Products (§8) runs on a fully separate mock domain from the storefront's live catalog — `AdminProduct.visits` is a new field added to support a "Views" column in Admin Products, but the mock gateway always defaults it to `0` because there is no real tracking source available to the admin domain today. This is unrelated to the storefront's `Item.visits` field (§6, `/items/{id}`), which is live-wired but never displayed anywhere in the UI.
**Ask:** two options, not mutually exclusive:
1. Once admin Products gets a real backend (§10 step 4), include a per-product view/visit count in the response.
2. Bridge `AdminProduct.visits` to the storefront's already-live `Item.visits` by product id, if a unified product identity exists between the storefront and admin domains — smaller change than building new tracking infrastructure.
### 12.10 Trending search terms
**Gap:** `SearchTrendingService.loadTrending()` is a stub returning `of(null)` - no trending-searches endpoint exists. It already degrades gracefully (UI hides the trending section rather than showing an error), so this is purely a missing-feature gap, not a bug. **Gap:** `SearchTrendingService.loadTrending()` is a stub returning `of(null)` - no trending-searches endpoint exists. It already degrades gracefully (UI hides the trending section rather than showing an error), so this is purely a missing-feature gap, not a bug.

27
CHANGELOG.md Normal file
View File

@@ -0,0 +1,27 @@
# Changelog
Recent work, newest first. Scoped to what changed and why — see `BACKEND-API-REFERENCE.md` for the backend-dependency detail on anything marked "depends on backend."
## 2026-08-15 — Admin purchase notifications
Admin gets notified when a new order lands: a toast (click-to-navigate) plus an unread badge + order list on the topbar bell icon (previously an unused "no notifications" placeholder). Poll-based — the backend has no WebSocket/SSE, so this follows the same polling pattern already used for payment status.
- `AdminOrderWatcherService` (new) polls `AdminOrdersGateway.loadOrders()` on an admin-editable interval (default 15s, editable in Admin Settings), diffs against persisted "last notified"/"last acknowledged" order pointers, and fires toasts only for genuinely new orders — never spams on first load.
- `UserNotificationService` gained an optional click-to-navigate `route` so a toast can jump straight to the order detail page.
- Reactively stops/starts polling off `AdminAuthService.isAuthenticated()` — a first attempt at this (tying it to component destruction) turned out not to work because logout never navigates or destroys the admin shell; caught and corrected before merge.
- **Depends on backend:** the mock `AdminOrdersLocalGateway` has no create path, so this feature is functionally inert until Orders CRUD gets a real backend gateway. See `BACKEND-API-REFERENCE.md` §12.8.
- Design/plan: `docs/superpowers/specs/2026-08-15-admin-purchase-notifications-design.md`, `docs/superpowers/plans/2026-08-15-admin-purchase-notifications.md`.
## 2026-08-14 — Currency conversion (client-side)
Switching currency (RUB/USD/EUR/AMD) now actually converts displayed prices, instead of just swapping the currency label next to an unchanged number.
- `CurrencyRatesService` (new) holds RUB-based conversion rates, admin-editable in Admin Settings → Currency rates, persisted to `localStorage`.
- `CurrencyConvertPipe` (new) applies the selected currency's rate wherever a storefront price renders: product cards, product detail page, quick-view dialog, delivery pricing, compare table, cart line items and totals, delivery selector.
- Admin backoffice screens intentionally keep showing raw stored-currency values — that's the existing convention for every other admin price display.
- Fixed a follow-on bug: the cart's delivery-selector component was using the item's *source* currency as both the display label and the conversion target, so the amount never actually converted (only the label matched) — same number shown in every currency.
- **Depends on backend:** rates are a manually-typed admin number today, not a live exchange rate, because the backend doesn't return per-currency pricing. See `BACKEND-API-REFERENCE.md` §12.7 for the two proposed backend directions (server-side conversion, or a live FX-rate endpoint) — raised because bank settlement totals weren't reconciling against order counts, which points at a real pricing-accuracy gap, not just a display one.
---
Earlier history: `git log`.

View File

@@ -6,16 +6,16 @@ Findings only — nothing in this document has been fixed as part of writing it.
## As a Customer / End User ## As a Customer / End User
1. **Ed25519 admin-auth "session expired" and "invalid signature" recovery screens are dead UI.** Both are fully built and wired, but `toAuthErrorShape()` (`core/auth/services/auth.service.ts:110-118`) derives the error code from HTTP status only, never a body-level code — so a real backend 401 always shows the generic "Unauthorized" screen instead. Also an [Engineering](#as-backend--api-engineer) and [Backend](#as-backend--api-engineer) item. 1. **FIXED (verified 2026-08-17).** ~~Ed25519 admin-auth "session expired"/"invalid signature" screens were dead UI~~`toAuthErrorShape()` now reads `error.error.code` via `authErrorCodeFromBackendCode()` before falling back to HTTP status. See `BACKEND-API-REFERENCE.md` §5.
2. **Dark mode selector does nothing.** The light/dark/system dropdown saves correctly, but no CSS anywhere reads the `data-theme-mode` attribute it sets — picking anything but Light changes nothing visually. 2. **FIXED (2026-08-17).** ~~Dark mode selector did nothing~~ — structural dark overrides (bg/text/border/shadow) now wired for all three tenant themes under `[data-theme-mode="dark"]`. Brand colors intentionally unchanged pending a theme-owner-approved dark palette.
3. **"Site Layout" selector (Theme section) has no effect.** `layout.type` is edited but page rendering only ever reads each page's own `layout`, never the top-level selector. 3. **FIXED (verified 2026-08-17).** ~~"Site Layout" selector had no effect~~`SectionEngineService.resolveLayoutType()` now falls back to `bootstrap.layout.type` when a page has no layout of its own.
4. **Footer "Contacts" link has nothing behind it.** No static-page content exists for it at all in the bootstrap data (unlike other footer legal pages, which are populated). 4. **Not a code gap (re-verified 2026-08-17), a content gap.** The mechanism is already fully generic: `features/project-editor/sections/footer-section.component.ts`'s Footer Builder lets an admin create any static page via the CMS and link it into a footer column by `pageKey`, resolved by `FooterResolverService`. "Contacts" just has no authored static page yet on whichever tenant's bootstrap this was checked against — that's a per-tenant content task, not a frontend fix.
5. **Product pages get no per-product SEO.** `SeoService.setItemMeta(item)` — the method that would set per-product Open Graph/canonical tags — exists but is **never called anywhere in the codebase**. Every product page ships only the site-wide default meta tags. 5. **FIXED (verified 2026-08-17).** ~~Product pages got no per-product SEO~~ `SeoService.setItemMeta(item)` is called from `product-details-container.component.ts`.
6. **`og:locale` is hardcoded to `'ru_RU'`** in both SEO meta-tag code paths, regardless of the active locale — a real gap for EN/HY visitors' social-share previews. 6. **FIXED (verified 2026-08-17).** ~~`og:locale` was hardcoded to `ru_RU`~~ — reads `languageService.currentLanguage()` via `OG_LOCALE_MAP` at both call sites.
7. **No structured data (JSON-LD) and no sitemap generation exist anywhere** — confirmed absent, not partially built. Sitemap is backend-only work; JSON-LD would need net-new frontend code. 7. **FIXED (verified 2026-08-17) on JSON-LD; sitemap remains backend-only work.** ~~No structured data (JSON-LD) exists anywhere~~`SeoService.setJsonLd()` injects a real `<script type="application/ld+json">` for `Product` (per-item, via `setItemMeta()`) and `Organization` (site default, via `resetToDefaults()`). No JSON-LD exists yet for `BreadcrumbList` or `ItemList`/category pages — smaller net-new addition if wanted. Sitemap generation is still backend-only, unchanged.
8. **Checkout's payment-description fallback is a hardcoded Russian string** (`'Покупка на Маркетплейсе'`) used as a last resort when no brand name or hostname is available — single-tenant-framed wording in a multi-tenant product. 8. **FIXED (verified 2026-08-17).** ~~Checkout's payment-description fallback was a hardcoded Russian string~~`getPaymentDescription()` (`pages/cart/cart.component.ts:613`) already tries `branding.brandName`, then hostname, and only falls to `i18n.t('cart.paymentDescriptionFallback')` last, translated in all 3 languages (`i18n/{en,ru,hy}.ts`).
9. **Brand color contrast fails WCAG AA.** `--border-color` measures 1.241.42:1 against a 3:1 UI-component requirement in every theme; `--success`/`--warning`/`--error`/`--info-color` fail 4.5:1 when used as plain text. Real palette colors, not a token bug — see [Accessibility](#as-accessibility-reviewer). 9. **FIXED (2026-08-17), user-authorized.** ~~Brand color contrast failed WCAG AA~~ `--border-color` and `--success`/`--warning`/`--error`/`--info-color` darkened, hue-preserving, in all three theme files to clear 3:1 (border, non-text) and 4.5:1 (status colors, plain text). See [Accessibility](#as-accessibility-reviewer).
10. **`stars.component.scss:10` uses a literal hex color** (`#cdd6d5`) with no design token behind it — any future palette change will silently miss this one glyph. 10. **FIXED (verified 2026-08-17).** ~~`stars.component.scss:10` used a literal hex color~~ — now uses `var(--border-color)`.
11. **No multi-vendor cart handling exists.** Checkout is one inline flow producing exactly one order from one payment popup; a cart with items from multiple sellers has no defined behavior (relevant the moment Seller Management ships beyond its current disabled-by-default placeholder). 11. **No multi-vendor cart handling exists.** Checkout is one inline flow producing exactly one order from one payment popup; a cart with items from multiple sellers has no defined behavior (relevant the moment Seller Management ships beyond its current disabled-by-default placeholder).
--- ---
@@ -50,21 +50,21 @@ Findings only — nothing in this document has been fixed as part of writing it.
See [BACKEND-API-REFERENCE.md](BACKEND-API-REFERENCE.md) for the full contract. Structural gaps worth flagging here specifically: See [BACKEND-API-REFERENCE.md](BACKEND-API-REFERENCE.md) for the full contract. Structural gaps worth flagging here specifically:
1. **Only 2 of 11 admin gateway domains (Categories, Dashboard-metrics) have a DI-token seam.** The other 9 — Orders, Products, Users, Transactions, Monitoring, Moderation, plus derived Customers/Analytics — inject their mock gateway class directly. A token has to be added to each before any real backend can be bound, independent of how easy that domain's actual endpoint is to build. 1. **Only 2 of 11 admin gateway domains (Categories, Dashboard-metrics) have a DI-token seam.** The other 9 — Orders, Products, Users, Transactions, Monitoring, Moderation, plus derived Customers/Analytics — inject their mock gateway class directly. A token has to be added to each before any real backend can be bound, independent of how easy that domain's actual endpoint is to build.
2. **`AdminRole` is defined twice with unrelated shapes** (auth string-union vs. a Users-page display interface) — needs a naming reconciliation before the real role table is built. 2. **FIXED (verified 2026-08-17).** ~~`AdminRole` was defined twice with unrelated shapes~~ — only one `AdminRole` export exists (`core/auth/models/permission.model.ts`); the Users-page shape is `AdminUserRoleRecord` with a disambiguating comment.
3. **Two unrelated `Category` types exist**, both fed by the same `/category` response, both still in active use. 3. **Two unrelated `Category` types exist**, both fed by the same `/category` response, both still in active use.
4. **Duplicate search models** exist under two different module paths. 4. **Worse than previously stated (re-verified 2026-08-17): three overlapping `SearchState`-shaped types, not two.** `core/search/models/search.model.ts` is already a clean re-export shim (fixed), but `core/search/models/search-state.model.ts` is a genuine second copy consumed by `catalog-container.component.ts`, and `features/search/facade/search.facade.ts` additionally defines its own private `LegacySearchState` interface with the same fields again. Reconciling all three touches the highest-traffic storefront surface (catalog rendering) — needs its own careful pass with full consumer tracing, not a quick rename.
5. **The error envelope is entirely a proposal** — no interceptor in the app inspects error response bodies today; every error reaction happens at the raw HTTP-status level. Adopting an envelope is a net-new build for both sides, not a preservation of existing behavior. 5. **The error envelope is entirely a proposal** — no interceptor in the app inspects error response bodies today; every error reaction happens at the raw HTTP-status level. Adopting an envelope is a net-new build for both sides, not a preservation of existing behavior.
6. **429 (rate limiting) has zero client-side handling anywhere** — no interceptor, facade, or component references it. If the backend rate-limits, today's frontend has no graceful path for that response. 6. **429 (rate limiting) has zero client-side handling anywhere** — no interceptor, facade, or component references it. If the backend rate-limits, today's frontend has no graceful path for that response.
7. **No API versioning scheme has been decided** — no version segment, no version header, anywhere in the client. 7. **No API versioning scheme has been decided** — no version segment, no version header, anywhere in the client.
8. **Centralized error-handling scaffolding exists but was never built.** `src/app/core/error-handling/`, `src/app/core/guards/`, and `src/app/core/interceptors/` each contain only a `.gitkeep` file — someone planned a shared error-handling layer, and every caller still handles failures ad hoc at the call site instead. Worth building once real backends start returning the error envelope in [BACKEND-API-REFERENCE.md](BACKEND-API-REFERENCE.md), rather than adding another one-off handler per facade. 8. **Centralized error-handling scaffolding exists but was never built.** `src/app/core/error-handling/`, `src/app/core/guards/`, and `src/app/core/interceptors/` each contain only a `.gitkeep` file — someone planned a shared error-handling layer, and every caller still handles failures ad hoc at the call site instead. Worth building once real backends start returning the error envelope in [BACKEND-API-REFERENCE.md](BACKEND-API-REFERENCE.md), rather than adding another one-off handler per facade.
9. **Admin Reports and Seller Management pages have zero data wiring of any kind** — not even a mock gateway call. Reports reuses `AdminAnalyticsFacade` (itself mock-derived) for its numbers; Seller Management is a static placeholder page with no `HttpClient` reference anywhere. Neither is currently a "swap the gateway" job — Reports inherits whatever Analytics becomes, Seller Management has no data layer to swap yet. 9. **Admin Reports and Seller Management pages have zero data wiring of any kind** — not even a mock gateway call. Reports reuses `AdminAnalyticsFacade` (itself mock-derived) for its numbers; Seller Management is a static placeholder page with no `HttpClient` reference anywhere. Neither is currently a "swap the gateway" job — Reports inherits whatever Analytics becomes, Seller Management has no data layer to swap yet.
10. **Two per-domain provider tokens have a dead mock branch, silently.** `PRODUCT_DATA_PROVIDER` and `CATEGORY_REPOSITORY` always resolve to the real API implementation regardless of `useMockData` — there is no mock class bound to either token. Anyone toggling mock mode expecting storefront products/categories to mock out will be surprised; only Bootstrap, Backoffice-widget-data, and Admin-Categories actually respect the mock/api switch. 10. **FIXED (verified 2026-08-17).** ~~`PRODUCT_DATA_PROVIDER`/`CATEGORY_REPOSITORY` had a dead mock branch~~ — both tokens' factories now resolve directly to the real API implementation with the dead switch removed, documented inline as intentional.
--- ---
## As Accessibility Reviewer ## As Accessibility Reviewer
1. **Brand color contrast genuinely fails WCAG AA** — see [Product Owner item 9 above](#as-product-owner--business) for the numbers. This requires a theme-owner sign-off before any fix ships, since it changes brand appearance, not just token values. 1. **FIXED (2026-08-17), user-authorized.** ~~Brand color contrast failed WCAG AA~~ — see [Product Owner item 9 above](#as-product-owner--business). Applied a hue-preserving darkening of the failing tokens rather than a redesign; a distinct dark-mode-specific status palette (introduced alongside dark mode this session) has not been separately contrast-checked and remains open.
2. **No screen-reader software testing has ever been performed on this codebase** — every existing accessibility verification (including in this review) is automated accessibility-tree inspection, never a real NVDA/VoiceOver session. Recommend at least one manual pass on the highest-traffic flows (checkout, product page, admin login) before treating any part of the app as accessibility-verified end to end. 2. **No screen-reader software testing has ever been performed on this codebase** — every existing accessibility verification (including in this review) is automated accessibility-tree inspection, never a real NVDA/VoiceOver session. Recommend at least one manual pass on the highest-traffic flows (checkout, product page, admin login) before treating any part of the app as accessibility-verified end to end.
3. **Known past pattern worth re-checking elsewhere:** a raw `<textarea>` (no dedicated shared textarea component exists in the codebase) previously shipped without its `aria-label`/label association wired correctly in one place (Seller Management's Message field, since fixed). Any other raw `<textarea>` usage in the app should be checked for the same gap, since the shared `app-input` component handles this automatically but plain textareas do not. 3. **Known past pattern worth re-checking elsewhere:** a raw `<textarea>` (no dedicated shared textarea component exists in the codebase) previously shipped without its `aria-label`/label association wired correctly in one place (Seller Management's Message field, since fixed). Any other raw `<textarea>` usage in the app should be checked for the same gap, since the shared `app-input` component handles this automatically but plain textareas do not.
@@ -76,7 +76,7 @@ See [BACKEND-API-REFERENCE.md](BACKEND-API-REFERENCE.md) for the full contract.
2. **`catalog.navigationMode`** renders an intentional placeholder — confirmed not a bug, but the alternate nav UIs it implies (mega-menu, top-carousel, left-nav) don't exist yet if ever wanted. 2. **`catalog.navigationMode`** renders an intentional placeholder — confirmed not a bug, but the alternate nav UIs it implies (mega-menu, top-carousel, left-nav) don't exist yet if ever wanted.
3. **Angular 22 upgrade is researched but not started** (~23.5 days estimated, needs a dependency fix and Node version bump first). Explicitly recommended as its own dedicated session, never bundled with feature work. 3. **Angular 22 upgrade is researched but not started** (~23.5 days estimated, needs a dependency fix and Node version bump first). Explicitly recommended as its own dedicated session, never bundled with feature work.
4. **`MarketplaceRef` and `TenantConfig` both represent "a marketplace" from two different vantage points** — a deliberate, documented distinction today, but worth consolidating if a third marketplace-shaped type is ever proposed. 4. **`MarketplaceRef` and `TenantConfig` both represent "a marketplace" from two different vantage points** — a deliberate, documented distinction today, but worth consolidating if a third marketplace-shaped type is ever proposed.
5. **`sellerId` fields are typed as bare `string` instead of the `UUID` alias** used everywhere else in the newer sellers domain — zero functional impact, pure convention drift, cheap to fix opportunistically. 5. **FIXED (verified 2026-08-17).** ~~`sellerId` fields were typed as bare `string`~~`core/sellers/models/seller-scope.model.ts` and all other sellers-domain usages type it `UUID`.
6. **No shared breadcrumb component exists anywhere** — the only breadcrumb logic in the entire storefront is one local signal inside the catalog container, duplicated conceptually wherever a future breadcrumb might be needed. 6. **No shared breadcrumb component exists anywhere** — the only breadcrumb logic in the entire storefront is one local signal inside the catalog container, duplicated conceptually wherever a future breadcrumb might be needed.
7. **Bootstrap `apiEndpoints.{website,builder,backoffice}` are empty objects in the mock today** — meaning no builder or backoffice CRUD path exists as a literal anywhere in the client. Any concrete path documented for those domains is a proposal until this is populated. 7. **Bootstrap `apiEndpoints.{website,builder,backoffice}` are empty objects in the mock today** — meaning no builder or backoffice CRUD path exists as a literal anywhere in the client. Any concrete path documented for those domains is a proposal until this is populated.

View File

@@ -49,6 +49,10 @@
{ {
"replace": "src/app/interceptors/mock-data.interceptor.ts", "replace": "src/app/interceptors/mock-data.interceptor.ts",
"with": "src/app/interceptors/mock-data.interceptor.production.ts" "with": "src/app/interceptors/mock-data.interceptor.production.ts"
},
{
"replace": "src/app/mock-gateway.providers.ts",
"with": "src/app/mock-gateway.providers.production.ts"
} }
], ],
"styles": [ "styles": [
@@ -59,7 +63,7 @@
{ {
"type": "initial", "type": "initial",
"maximumWarning": "700kB", "maximumWarning": "700kB",
"maximumError": "1.5MB" "maximumError": "1.1MB"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",

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`

286
docs/DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,286 @@
# Deployment — server provisioning, CD, TLS
Frontend deployment plus API-domain edge configuration. The backend service is a
separate developer's responsibility. API hostnames are separate reverse proxies
and return `502` until their configured upstream exists (production currently
defaults to `https://127.0.0.1:445`).
**Multi-tenant, one bundle.** Every customer domain is served by the same build. The SPA resolves its tenant from the `Host` header ([BACKEND-HANDOFF §1a](backend/BACKEND-INTEGRATION.md)). One deploy updates every domain simultaneously — there is no per-tenant build and no per-tenant deploy.
The SPA derives one API origin from the storefront's base domain:
`example.com`, `store1.example.com`, and `www.example.com` all use
`api.example.com`. Tenant identity still comes from the complete storefront
host; tenant subdomains do not create additional API DNS names.
---
## 1. Files
| Path | Purpose |
|---|---|
| `scripts/deploy/server-setup.sh` | One-time server provisioning. Idempotent. Run as root. |
| `scripts/deploy/add-domain.sh` | Attach one domain + issue TLS. Run per domain, as root, after DNS resolves. |
| `scripts/deploy/configure-api-domain.sh` | Configure shared `api.<base domain>` TLS, storefront-origin CORS, backend proxy, and JSON bootstrap verification. |
| `.github/workflows/deploy.yml` | CD: build → upload → atomic swap → verify. Triggers on push to `main`. |
---
## 2. Layout on the server
```
/srv/marketplaces/
├── releases/
│ ├── a1b2c3d4e5f6/frontend/ <- one directory per deployed commit
│ └── ... (last 5 kept)
└── current -> releases/a1b2c3d4e5f6
```
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
### 3.1 Generate a CI deploy key
On your machine, **not** on the server:
```bash
ssh-keygen -t ed25519 -C "ci@marketplaces" -f ./marketplaces_deploy -N ""
```
Two files result. `marketplaces_deploy.pub` goes to the server; `marketplaces_deploy` (private) goes into CI secrets and nowhere else.
### 3.2 Provision the server
Copy `scripts/deploy/` to the server and run:
```bash
sudo bash server-setup.sh --pubkey "$(cat marketplaces_deploy.pub)"
```
This installs nginx + certbot, creates a **key-only** `deploy` user with no
password, writes the catch-all nginx config, opens 80/443/OpenSSH in ufw, and
installs a root-owned, argument-validating API-domain helper. The deploy user may
run that helper and reload nginx, but cannot replace the helper.
It also applies host hardening (added 2026-08-21, FH-D.3) — three drop-in files,
so a re-run replaces its own config and never edits a distro file in place:
| File | Effect |
|---|---|
| `/etc/ssh/sshd_config.d/10-marketplaces-hardening.conf` | Password and keyboard-interactive auth off, root key-only, no agent/X11 forwarding, `MaxAuthTries 3`, 30 s login grace |
| `/etc/fail2ban/jail.d/marketplaces.local` | `sshd`, `nginx-http-auth`, `nginx-bad-request` jails — 5 failures in 10 min, 1 h ban |
| `/etc/sysctl.d/99-marketplaces-hardening.conf` | No redirects or source routing, reverse-path filtering, SYN cookies, forwarding off, restricted kernel pointers and dmesg |
Both accounts on this host are key-only by construction, so disabling password
auth cannot lock anyone out — it only closes unlimited guessing against a
credential nobody intended to exist. The script runs `sshd -t` before reloading
and removes its own drop-in if the test fails, because a bad sshd config taking
effect on a remote box is how people lock themselves out permanently.
Confirm after provisioning:
```bash
sudo fail2ban-client status sshd
sudo sshd -T | grep -E 'passwordauthentication|permitrootlogin|maxauthtries'
```
Verify before continuing:
```bash
curl -I http://<server-ip>/health
```
Expect `200`. A placeholder page is served until the first real deploy.
### 3.3 Capture the host key
```bash
ssh-keyscan -H <server-ip>
```
The output is the `DEPLOY_KNOWN_HOSTS` secret. Pinning it means a rebuilt or impersonated server fails the deploy instead of being trusted silently.
### 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` |
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
`/bootstrap` check fails.
### 3.5 Deploy
Push to `main`, or run the workflow manually with a ref. The workflow refuses to swap the symlink unless the uploaded release contains an `index.html`, so a failed upload leaves the previous release serving.
---
## 4. Domains and TLS — dynamic by default
Domains arrive continuously: one today, five tomorrow. Nothing here requires a person per domain.
**HTTP already needs zero configuration.** The nginx catch-all serves *any* `Host`, and the SPA resolves its tenant from that header. Point a domain's A record at the server and it works over port 80 immediately. Only TLS needs a certificate per name — that is the whole problem this section solves.
Two mechanisms, used together:
### 4.1 Wildcard — tenants on our own apex
One certificate covers every `<slug>.<apex>`. A new tenant subdomain is then live over HTTPS the moment DNS resolves, with **no certificate work at all**.
```bash
sudo bash setup-wildcard-tls.sh \
--apex marketplaces.example.com \
--email ops@example.com \
--dns cloudflare --creds /root/cloudflare.ini
```
Wildcards require DNS-01 validation, so certbot must write a `_acme-challenge` TXT record. With a provider plugin (`cloudflare`, `route53`) renewal is unattended. `--dns manual` works but prompts for a TXT record at **every** renewal — fine to prove the setup out, not acceptable as a steady state.
**Hostinger has no certbot plugin.** If DNS lives there: either move DNS to a provider that has one (Cloudflare is free, minutes of work), or drive issuance from the [Phase 9](backend/BACKEND-INTEGRATION.md) domain-automation API once it exists.
### 4.2 Reconciler — tenants on their own domains
A wildcard cannot cover a customer's own domain. `sync-domains.sh` runs on a 10-minute timer and reconciles the live set against a desired list:
- issues certificates for domains that lack one
- skips domains whose certificate has more than 30 days left
- skips subdomains already covered by `WILDCARD_APEX`
- leaves domains alone while their DNS has not propagated yet, and retries next tick
- disables server blocks for domains removed from the source — **without deleting the certificate**, so re-adding one later is instant
- caps issuance per run, so a misconfigured source cannot burn the weekly ACME budget in a single pass
Configure `/etc/marketplaces/domains.env`:
```bash
DOMAINS_SOURCE=file:/etc/marketplaces/domains.txt
CERTBOT_EMAIL=ops@example.com
MAX_ISSUE_PER_RUN=10
```
Then:
```bash
sudo systemctl enable --now marketplaces-domains.timer
sudo /srv/marketplaces/bin/sync-domains.sh --dry-run # see the plan, change nothing
```
Adding a domain becomes: append a line to `/etc/marketplaces/domains.txt` (or add the row in the backend registry), point DNS, wait one tick.
### 4.3 Backend-driven, once Phase 9 ships
Point the reconciler at the registry instead of a file and the loop closes — `MarketplaceDomain` already carries exactly the statuses this needs (`planned → dns_pending → ssl_pending → active → failed`):
```bash
DOMAINS_SOURCE=https://api.example.com/api/admin/v2/domains
DOMAINS_API_TOKEN=...
```
The script accepts a bare JSON array of hostnames, or objects with `domain` + `status`, in which case it acts only on `active` rows. **A fetch failure aborts the run rather than reading as "remove every domain."**
### 4.4 One-off
For a single domain, outside the reconciler:
```bash
sudo bash add-domain.sh shop.example.com --email ops@example.com --with-www
```
### 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
sudo certbot certificates
journalctl -u marketplaces-domains.service --since "1 hour ago"
```
---
## 5. Rollback
```bash
ssh deploy@<server-ip>
ls -1dt /srv/marketplaces/releases/*/ # newest first
ln -sfnT /srv/marketplaces/releases/<sha> /srv/marketplaces/current.new
mv -Tf /srv/marketplaces/current.new /srv/marketplaces/current
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
```bash
curl -I http://<host>/health # 200 from nginx
readlink -f /srv/marketplaces/current # which commit is live
sudo nginx -t # config valid
systemctl status nginx certbot.timer # both active
sudo tail -f /var/log/nginx/marketplaces.error.log
```
---
## 7. Known limits
- **`/api/` 502s until the backend runs.** Expected. nginx proxies to `127.0.0.1:8080`; nothing listens there yet.
- **No staging environment.** `main` goes straight to production. Adding one means a second server plus a `staging` branch trigger.
- **No smoke test beyond HTTP 200.** The verify step confirms nginx serves the shell, not that the app boots. A real check needs the E2E harness from Track Q.
- **Caching.** `index.html` is `no-store`; hashed assets are `immutable` for a year. A deploy therefore takes effect on the next page load, with no cache purge.

View File

@@ -0,0 +1,392 @@
# Fork Analysis — `marketplaces-main.zip` (hub.numus.cc/numus/marketplaces)
**Date:** 2026-08-21
**Artifact analysed:** `C:\Users\darbi\Downloads\marketplaces-main.zip` (4.48 MB, 13 MB extracted)
**Analysed against:** this repo, branch `B2B`, HEAD `92f1c88`
**Canonical repo of the archive:** `ssh://git@hub.numus.cc:2222/numus/marketplaces.git`, tag `handoff-baseline-2026-08-11`
---
## 0. Verdict in five lines
1. This is **not a fork of our repo**. It is a **separate monorepo** — NestJS backend + PostgreSQL + two Angular apps + real infrastructure — that shares an *older* common ancestor with us (`dexarmarket`, Angular 21.2.18).
2. They **received our code on 11 Aug 2026**, audited it, and parked it verbatim under `reference/parallel-frontend/` with a SHA-256 fingerprint. They explicitly ruled it **not production**, and wrote a document listing what they will and will not take from us.
3. They are ahead of us in exactly one dimension, and it is the decisive one: **they have a backend, a database, RBAC, payments, tenancy by Host, publish/rollback revisions, and a deploy runbook that exists.** We have contracts describing all of that and 22 mock gateways.
4. We are ahead of them in exactly one dimension, and they admit it in writing: **frontend depth and editor UX** (530 `.ts` vs 189, 158 components, 30 spec files, boundary checker, Angular 22). Their backoffice is 45 files and still ships `mock-data.service.ts`.
5. **VK and Yandex login do not exist in their code.** Zero references, backend and frontend. Section 8 covers what they actually have and gives the design to add VK ID + Yandex ID on our side.
---
## 1. What the archive actually contains
```
marketplaces/
├── platform-api/ NestJS 11 + Fastify + Prisma + PostgreSQL 17 (44 .ts, ~4 800 LOC)
├── backoffice/ Angular 21.2.18 admin + order-manager portal (45 .ts)
├── marketplaces/ Angular 21.2.18 runtime storefront (189 .ts)
├── infra/ Docker Compose, Nginx, backup, domain automation (21 files)
├── docs/ 11 canonical documents, ~1 090 lines, Russian
└── reference/
└── parallel-frontend/ ← OUR REPO, verbatim, 791 files
```
### 1.1 Lineage — read this carefully
- `marketplaces/package.json` is named **`dexarmarket`**, Angular **21.2.18**, with brand configs `dexar` / `novo` / `lavero`.
- Our `package.json` is named **`dexarmarket`**, Angular **22.0.8**.
- `reference/parallel-frontend/package.json` is **our current code**, Angular 22.0.8, with our `arch:check` scripts.
Common ancestor. They branched earlier and went backend-first; we stayed frontend and went deep. `reference/parallel-frontend/SOURCE_MANIFEST.md` records:
> Source: `marketplaces-main.zip`, received 11 August 2026. SHA-256 `4d2d990416f573791b09df9ca8c02266432b5ddf213b9ed38c0bced1f19d854f`. 745 files in `src/`, 26 in `public/`, 1 in `tools/`.
They stripped our sprint reports and internal task docs and replaced them with their own summary. Our source they copied unchanged.
### 1.2 They are not "overtaking us" — they were handed the platform mandate
`docs/DEVELOPER_HANDOFF.md` is a **handover-to-a-new-developer document**, with a priority ladder that puts our work last:
> 1. Security, tenant isolation and financial correctness.
> 2. `docs/PRODUCT_SPECIFICATION.md`.
> 3. Real models and invariants of `platform-api`.
> 4. Existing confirmed production scenarios.
> 5. **UI/UX patterns of the parallel implementation.**
That is the political read: our repo has been reclassified from "the product" to "the design reference". Everything below assumes we want that reversed or renegotiated.
---
## 2. Inventory comparison
| | **Them (archive)** | **Us (`B2B` @ 92f1c88)** |
|---|---|---|
| Backend | NestJS 11 / Fastify, 44 files, running | None. 17 contract docs in `docs/backend/` |
| Database | PostgreSQL 17, Prisma, 36 models, 3 migrations | None |
| Storefront | Angular 21.2.18, 189 `.ts` | Angular 22.0.8, 530 `.ts`, 158 components |
| Backoffice | Angular 21.2.18, 45 `.ts`, still mock-backed | 14 admin modules, 22 local + 22 API gateway pairs |
| Auth (admin) | Email + Argon2id + mandatory TOTP, HttpOnly cookie, server sessions | `admin-auth.guard.ts` + dev bypass |
| Auth (customer) | Telegram QR via external `USERAUTH_API_URL`, server session, HttpOnly cookie | Telegram, client-side |
| RBAC | 5 roles enforced server-side + per-marketplace membership | Client-side permission model |
| Payments | Vitanova + NUMUS adapters, encrypted per-tenant credentials, HMAC webhooks, idempotency, poll fallback | FX/pricing gateways, payment contracts, no server |
| Multi-tenancy | Host → verified `MarketplaceDomain` → tenant, 30 s cache, 404 on unknown | Bootstrap-driven runtime config |
| Publish | Immutable `MarketplaceRevision` snapshots, atomic publish, rollback-as-new-revision | Draft/publish UI, local persistence |
| Infra | Compose (internal + egress networks), Nginx templates, certbot, WAL archiving, backup timers, restore check, fail2ban, sysctl/ssh hardening | `scripts/deploy/*.sh`, GH Actions deploy, wildcard TLS |
| Tests | 9 spec files, 25 tests total, **no e2e at all** | 30 spec files, Playwright e2e, coverage floor in CI |
| Arch governance | None | `check-boundaries.mjs`, madge cycles, `architecture-governance.yml` |
| Bundle | storefront 648 kB (48 kB over) | 1.15 MB (452 kB over a 700 kB budget) — *their measurement of us* |
---
## 3. Their backend, in detail — the part worth studying
### 3.1 Tenant resolution (`common/tenant.service.ts`)
- `normalizeHost()` lowercases, strips trailing dot, strips port.
- Looks up `MarketplaceDomain` by `hostname` **unique index**, requires `verifiedAt != null`, requires `status = ACTIVE`.
- 30-second in-process cache keyed by hostname, with `invalidate(hostname?)`.
- Unknown host → `404`, never a fallback tenant.
- **Preview:** HMAC-signed token `base64url(payload).base64url(hmac)` carrying `{marketplaceId, expiresAt, nonce}`, 15 min TTL, delivered as a `storefront_preview` cookie. A global Fastify `onRequest` hook returns `404 Preview mode is read-only` for any non-GET on `/api/v1/*` while that cookie is present. Cheap, clean, and something we do not have.
### 3.2 Admin auth (`auth/admin-auth.service.ts`)
- Argon2id (`memoryCost 65536, timeCost 3, parallelism 1`), TOTP **mandatory** — first login without TOTP returns a signed 10-minute `setupToken` + `otpauth://` URI and refuses to issue a session until TOTP is confirmed.
- Sessions are random 32 bytes, stored as **SHA-256 hash only**, 12 h TTL, with `ipAddress` + `userAgent`.
- `authenticate()` rejects if `revokedAt`, expired, user inactive, **or TOTP not enabled**.
- Password change requires ≥16 chars and revokes every live session in the same transaction.
- Role weights: `ORDER_MANAGER 0 < VIEWER 1 < CONTENT_MANAGER 2 < ADMIN 3 < OWNER 4`; `hasAccess()` checks weight **and** marketplace scope.
- Manager portal uses a **separate cookie** (`manager_session`) and a separate guard, pinned to one marketplace slug.
### 3.3 CSRF / origin control (`main.ts`, `auth/admin-origins.ts`)
A global hook rejects any non-GET on `/api/admin/*` or `/api/manager/*` whose `Origin` header is not in `ADMIN_ORIGIN` + `ADMIN_ORIGINS`. CORS `origin` is the same allowlist with `credentials: true`. Rate limit 120/min per IP; multipart capped at 1 file / 10 MB / 4 fields; body limit 12 MB; `trustProxy: true`; global `ValidationPipe({ whitelist, forbidNonWhitelisted, transform })`.
### 3.4 Checkout (`checkout/checkout.service.ts`) — the atomicity pattern
```sql
UPDATE "MarketplaceInventory"
SET "reserved" = "reserved" + $qty, "updatedAt" = NOW()
WHERE "marketplaceId" = $mp AND "variantId" = $variant
AND ("onHand" - "reserved") >= $qty
RETURNING "id"
```
Empty result → `409 Insufficient stock`. This single conditional UPDATE inside a Prisma transaction is the whole oversell defence: no read-then-write race, no advisory locks. Then a `StockReservation` (15 min), one `InventoryMovement` per line with `reason: 'checkout_reservation'`, and an `Order` carrying a full `productSnapshot` per item. Price comes only from the server-side snapshot; the browser's price is never read. `publicToken` is `randomBytes(24).base64url` — no sequential IDs leak. Digital codes are decrypted into the response **only** when order status is `PAID`/`PROCESSING`/`FULFILLED`.
### 3.5 Payments (`payments/payment.service.ts`)
- `Payment.idempotencyKey` is a **unique column**; a repeat POST with the same key returns the existing payment, and a key reused across a different order/marketplace → `409`.
- Provider credentials live in `PaymentCredential.encryptedConfig`, AES-256-GCM (`v1.iv.tag.ciphertext`, base64url) with a 32-byte `FIELD_ENCRYPTION_KEY`. Decrypted only inside the service, never serialized into a response.
- NUMUS webhook: requires `eventId`/`eventType`/`timestamp`/`signature` headers, validates the envelope (`schemaVersion === 1`), HMAC-verifies the **raw body**, then inserts into `PaymentWebhookEvent` with `@@unique([provider, eventKey])`. A Prisma `P2002` collision returns `{accepted: true, duplicate: true}` — replay is a no-op by construction, not by an `if`.
- Vitanova webhook: same shape, per-marketplace `webhookSecret` overriding the global one, `eventKey` falling back to `sha256(rawBody)`.
- `pollPending(50)` is the reconciliation fallback for the last 24 h; failures are swallowed so the next tick retries.
- `checkoutUrl` passes through `safeHttpsUrl()` before it is ever returned to a browser.
### 3.6 Publish / revisions (`admin/admin.service.ts`)
`publish()` materializes the draft into a full snapshot, computes `version = max(version) + 1`, writes an immutable `MarketplaceRevision`, and flips `publishedRevision` in the same transaction. Clone copies theme/categories/offers/variants/category links and **forces inventory to zero**, drops domains/customers/orders/secrets, and creates an OWNER membership for the actor. Category cloning is a topological walk that throws `Category tree contains a cycle`.
### 3.7 Config validation (`common/storefront-config.ts`)
The section schema is validated **server-side**, per section type, with hard clamps: max 40 sections; id regex; per-type height ranges (`categoryRail 98360`, `productRail 390760`, hero fallback 312); colour must match `#rrggbb` or fall back; URLs accepted only if local `/path` or `https://`; product/category ID lists deduped and capped at 24 UUIDs. This is exactly the "validation engine" they praised in our editor — except theirs runs where it actually binds.
### 3.8 Infrastructure (`infra/`)
- Compose with **two networks**: `platform` (`internal: true`, no egress — Postgres lives here) and `egress` (API + worker + migrate only).
- API bound to `127.0.0.1:3000` only. `no-new-privileges` on every service.
- Postgres 17.7 with `wal_level=replica`, `archive_mode=on`, `archive_timeout=300`, archive command copying WAL into a backup volume.
- `migrate` is a separate one-shot service; `api` and `worker` both `depends_on: migrate: service_completed_successfully`.
- Healthchecks: the API container hits its own `/health/ready`.
- systemd timers: `marketplaces-backup`, `marketplaces-domain-sync`, `marketplaces-thumbnails` (path-triggered), plus `*-healthcheck` timers per app.
- `provision-domain.sh` refuses to run unless the domain's A record already resolves to the server IP, then certbot webroot, then a **manual** review step before Nginx reload.
- Hardening set we do not have: `fail2ban/jail.local`, `sshd` hardening drop-in, `sysctl` hardening, `docker/daemon.json`, scoped sudoers per deploy role, `restore-check.sh`.
---
## 4. What they wrote about us (`docs/PARALLEL_IMPLEMENTATION_AUDIT.md`)
Their measurements of our code, 11 Aug 2026:
- Production build passes on Node 24.18.1.
- **Initial bundle ~1.15 MB against a 700 kB budget — 452 kB over.**
- Boundary + cycle checks pass.
- 57 unit tests pass; **5 spec files total**; "checkout, RBAC, publishing, orders and admin CRUD flows are not meaningfully covered".
- `npm audit --omit=dev`: **0 production vulnerabilities** — better than all three of their own packages.
Their blocking objections:
| Their objection | Is it fair? |
|---|---|
| Mock/localStorage repositories as production implementation | **Fair.** 22 local gateways, 19 files touching `localStorage`. |
| Publishing config through localStorage | **Fair** for the modules that still do it. |
| Admin JWT / refresh token in localStorage | Fair as of the snapshot. |
| Admin session cookie set by JS and readable via `document.cookie` | **Fair and serious.** |
| Shared Telegram session for customer *and* admin | **Fair and serious.** |
| Client-side `authorization-key`, `userid-value`, partner ID | **Fair and serious** — payment credentials in the browser. |
| Direct call to `http://ip-api.com` from an HTTPS storefront | Fair — mixed content plus a third-party geo leak. |
| Unconditional `bypassSecurityTrustResourceUrl` on a bank URL | **Fair.** Redirect targets must be backend-allowlisted. |
| Storefront + editor + backoffice in one deployable bundle | Fair, and it is also why our bundle is 452 kB over. |
| Hardcoded fallback regions / provider URLs / brands in components | Fair. |
What they said they **want** from us (their P1 list, their order): editor information architecture; the section-editor schema; the validation engine with blockers/warnings/notices; undo/redo + dirty state + change summary; device preview and per-device media; searchable product/category pickers with SKU/price/stock; the media-library interaction model; semantic design tokens; the admin IA; and **our boundary checker and ADR tooling**.
That list is our leverage. It is also a precise statement of which of our modules are worth hardening first.
---
## 5. Differences that matter, ranked by consequence
1. **Truth ownership.** Their price, stock, tenant and payment truth is server-side and provably so. Ours is a contract document. Every argument about "who is ahead" reduces to this one.
2. **Session model.** They: server-stored, hashed, HttpOnly, revocable, TOTP-gated, separate cookies per contour. Us: client-held.
3. **Idempotency.** They: unique constraints doing the work (`Payment.idempotencyKey`, `PaymentWebhookEvent(provider,eventKey)`). Us: zero `idempot*` anywhere in the codebase.
4. **Deployability.** They: Compose + migrations + healthchecks + WAL + restore check + domain automation. Us: shell scripts and GH Actions, with no database to migrate.
5. **Frontend depth.** Us: 2.8× their storefront file count, 158 components, dynamic renderer, widget system, theme system, i18n, 30 spec files, Playwright e2e, boundary governance. Them: a 45-file backoffice with `mock-data.service.ts` still in it.
6. **Test posture.** They have **no e2e whatsoever** and 25 unit tests across the whole platform. Their own `VERIFICATION.md` says the count "is insufficient to conclude production readiness". We have e2e plus a CI coverage floor. This is a real gap on their side and worth naming out loud.
7. **Framework currency.** We are on Angular 22 / TS 6.0.3; both of their apps are on 21.2.18 with 78 fixable high findings in production dependencies.
---
## 6. What we should take — concrete, ordered
### P0 — take these regardless of how the org question resolves
1. **Conditional-UPDATE stock reservation.** Adopt the pattern verbatim in `backend/BACKEND-INTEGRATION.md`: reserve via `WHERE (onHand - reserved) >= qty RETURNING id`, empty result = 409. It removes a whole class of race conditions and it is one line of SQL.
2. **Idempotency as a unique constraint, not application logic.** `Payment.idempotencyKey UNIQUE`, `PaymentWebhookEvent @@unique([provider, eventKey])`, P2002 → `{duplicate: true}`. Push this into `backend/BACKEND-INTEGRATION.md` as a schema requirement, not a behavioural note.
3. **Move every credential out of the browser.** Their audit is right about `authorization-key` / `userid-value` / partner ID. Mirror `PaymentCredential.encryptedConfig` (AES-256-GCM, versioned `v1.iv.tag.ct`) in our contract and delete the client-side header path.
4. **HttpOnly server sessions, separate cookie per contour** (`bo_session`, `manager_session`, `marketplace_session`). Kill the JS-set cookie and the shared Telegram session for admin + customer. This is our single worst finding in their audit.
5. **Origin allowlist hook for all admin mutations.** Twelve lines in `main.ts`; kills CSRF for cookie-authenticated mutations. Mirror in `backend/BACKEND-INTEGRATION.md`.
6. **Backend-side config validation.** Our validation engine is better than theirs, but it runs in the browser. The server must re-run it. Their clamp-and-fallback ergonomics are right: never reject a colour, clamp it; never accept a non-https URL, blank it.
### P1 — take into our own architecture
7. **Signed preview token + read-only preview enforcement.** HMAC token, 15 min, `storefront_preview` cookie, global hook rejecting non-GET. We have preview UI and no preview safety.
8. **Immutable revisions with `version = max+1`, rollback-as-new-revision.** Never rewrite history; `publishedRevision` is an integer pointer flipped in-transaction.
9. **Clone semantics.** Copy design + catalog assignments, force inventory to 0, never copy domains/customers/orders/secrets. Their topological category walk with cycle detection is worth copying line for line.
10. **`MarketplaceAuthCredential` table.** They have it and do not use it. It is exactly the right home for per-tenant VK/Yandex OAuth app credentials — see §8.
11. **Two-network Compose split** (`internal: true` for the data network) and API bound to loopback. Makes "the database is not reachable from the internet" structural rather than a firewall promise.
12. **WAL archiving + `restore-check.sh` + a scheduled restore drill.** We have deploy automation and no proven restore.
### P2 — process, not code
13. Their **`DEVELOPER_HANDOFF.md` §7 "inviolable invariants"** list is a better acceptance gate than anything currently in our delivery plan. Nine lines, each falsifiable. Adopt it as the header of our own handoff doc.
14. Their **PR policy**: one functional area per PR; mandatory purpose, screenshots, API changes, migrations, test evidence, security impact, rollback plan; never change payment/inventory/order state machines inside a redesign PR.
15. Their **status discipline**: "a local build or the existence of a UI does not mean production readiness". Every release records version, migration, healthcheck, smoke, audit, rollback.
---
## 7. Ideas worth stealing (product-level)
- **`ORDER_MANAGER` as a fully separate contour** — separate URL, separate shell, separate cookie, separate login, pinned to one marketplace, cannot see catalog/design/domains/payment settings. Genuinely good product thinking: the people who touch orders all day are not admins, and giving them their own small app removes an entire permissions surface.
- **`FulfillmentMode: MANUAL | CODE_POOL` + a `DigitalCode` pool** with `AVAILABLE/RESERVED/ASSIGNED/REVOKED`, encrypted values, `valueHash` unique per `(marketplace, variant)`, and codes revealed only after payment. We have no digital-goods story at all; this is a complete one in one table.
- **Marketplace status machine** `DRAFT → DOMAIN_PENDING → READY → ACTIVE → SUSPENDED`, with `DOMAIN_PENDING` as a real state rather than an error condition.
- **Per-tenant delivery options priced in minor units on the offer**, validated at checkout ("select a delivery option for each physical product").
- **`InventoryMovement` as an append-only journal** with `reason`, `referenceType`, `referenceId`, `actorId` — every stock change explainable after the fact. This directly answers the "we do not trust your numbers" complaint in the v3.1 plan.
- **CSV marketplace import** (`POST /marketplaces/import`, `dryRun` default true) — bulk tenant creation as a first-class operation.
- **Their §22 acceptance scenarios** (15 of them) are a ready-made e2e suite. Scenario 3 (two concurrent purchases of the last unit) and scenario 10 (replayed webhook) are the two tests that would catch the most expensive possible bugs. Write those two this sprint regardless of anything else in this document.
---
## 8. VK ID and Yandex login — what is actually there, and how we add it
### 8.1 Finding: they do not have it
Exhaustive search of the archive (`*.ts`, `*.html`, `*.md`, `*.json`, `*.prisma`, `*.sql`, `*.yml`, `*.conf`, env examples), excluding `node_modules` and excluding our own code under `reference/`:
- `vk` / `vkontakte` / `vkid`**0 hits** in source. The only matches anywhere are inside `package-lock.json` integrity hashes and two Armenian/English FAQ content pages.
- `yandex`**0 hits** in source; the same two content pages only.
- `oauth`**0 hits** in their code. The single `oauth`-adjacent file in the whole archive is **ours**: `reference/parallel-frontend/src/app/core/auth/services/auth.service.ts`.
- The Prisma schema has **no** `ExternalIdentity`, no `provider` column on `Customer`, and no social tables. Customer identity is `@@unique([marketplaceId, telegramUserId])` — Telegram only.
**Their only customer login is Telegram**, and it is not even self-hosted: `CustomerAuthService` proxies to an external service at `USERAUTH_API_URL` (`https://users.vitanova.network:456`), creates a web session, polls `/users/sessions/{id}` until `status` is confirmed, then upserts a `Customer` and issues its own 30-day session cookie.
So there is nothing to copy from them here. But their **session-issuing half is the right shape**, and it is what VK/Yandex should terminate into.
### 8.2 What we already have
| File | State |
|---|---|
| [vk-id-gateway.interface.ts](src/app/core/identity/services/vk-id-gateway.interface.ts) | `getAuthorizeUrl()`, `completeCallback(code, codeVerifier)` |
| [vk-id-api.gateway.ts](src/app/core/identity/services/vk-id-api.gateway.ts) | Real HTTP client → `/api/identity/v1/vk/authorize`, `/vk/callback` |
| [vk-id-local.gateway.ts](src/app/core/identity/services/vk-id-local.gateway.ts) | Mock |
| [vk-id-gateway.token.ts](src/app/core/identity/services/vk-id-gateway.token.ts) | DI seam via `environment.useMockData` |
| [vk-id-login.component.ts](src/app/components/vk-id-login/vk-id-login.component.ts) | Button component |
| [customer-identity.model.ts](src/app/core/identity/models/customer-identity.model.ts) | `ExternalIdentityProvider = 'vk_id' \| 'telegram' \| 'max'` |
| [backend/BACKEND-INTEGRATION.md](backend/BACKEND-INTEGRATION.md) | §2 defines the VK ID contract |
We have the scaffolding and the contract. Yandex is absent everywhere except one mention of *Yandex Market* as a possible marketplace connector in the gap analysis — a different thing entirely.
### 8.3 Design — one provider-agnostic social login, VK ID and Yandex ID as instances
**Principle (already in our contract — keep it):** the OAuth code exchange happens entirely backend-side. No client secret, no access token, and no `code_verifier` ever reaches the browser.
**Change to make:** our current interface passes `codeVerifier` from the client, which forces the browser to generate and store the PKCE verifier. We are a confidential client — the backend should own the verifier. Recommended surface:
```
GET /api/identity/v1/{provider}/authorize
-> 302 to the provider, OR { url } for the client to navigate to.
Backend generates state + code_verifier and stores both in a
short-lived HttpOnly cookie (or server-side, keyed by state),
10 min TTL, single use.
GET /api/identity/v1/{provider}/callback?code=…&state=…[&device_id=…]
-> backend validates state, exchanges code + stored verifier,
fetches the profile, resolves/links Customer, issues the
marketplace session cookie, 302 back into the storefront.
POST /api/identity/v1/{provider}/unlink (authenticated)
GET /api/identity/v1/me/identities (authenticated) -> linked providers
```
`{provider}``vk` | `yandex` (later `telegram`, `max`). One controller, one service, a per-provider strategy object. The frontend keeps exactly one gateway interface:
```ts
export type SocialProvider = 'vk' | 'yandex';
export interface SocialIdentityGateway {
getAuthorizeUrl(provider: SocialProvider, returnTo?: string): Observable<string>;
listIdentities(): Observable<ExternalIdentity[]>;
unlink(provider: SocialProvider): Observable<void>;
}
```
`VkIdGateway` collapses into it, `completeCallback()` disappears from the frontend entirely (the backend handles the callback and redirects), and `vk-id-login.component` becomes `social-login-button` with a provider input. Add `'yandex_id'` to `ExternalIdentityProvider` in `customer-identity.model.ts`.
**Provider specifics** — confirm exact parameter and scope names against the live provider docs before implementing; both providers have revised their flows recently.
*VK ID* — OAuth 2.1, **PKCE mandatory**, S256.
- Authorize: `https://id.vk.com/authorize``client_id`, `redirect_uri`, `response_type=code`, `code_challenge`, `code_challenge_method=S256`, `state`, `scope` (typically `vkid.personal_info email phone`).
- The callback returns a **`device_id` alongside `code`**, and it is required for the token exchange. Missing it makes every exchange fail; this is the single most common VK ID integration bug.
- Token: `POST https://id.vk.com/oauth2/auth``grant_type=authorization_code`, `code`, `code_verifier`, `device_id`, `client_id`, `redirect_uri`.
- Profile: `POST https://id.vk.com/oauth2/user_info` with the access token → stable `user_id`, name, optional email/phone.
- Logout: `https://id.vk.com/oauth2/logout` — call it on unlink so the provider session is not left dangling.
*Yandex ID* — OAuth 2.0, PKCE supported; use it.
- Authorize: `https://oauth.yandex.ru/authorize``response_type=code`, `client_id`, `redirect_uri`, `state`, `code_challenge`, `code_challenge_method=S256`.
- Token: `POST https://oauth.yandex.ru/token``grant_type=authorization_code`, `code`, `code_verifier`, HTTP Basic auth with `client_id:client_secret`.
- Profile: `GET https://login.yandex.ru/info?format=json` with header `Authorization: OAuth <access_token>``id` (stable), `login`, `default_email`, `default_phone`, `psuid`, avatar id.
- Yandex returns an email in most cases; VK often will not. Do not make email a required field on `Customer`.
**Data model** — add to whatever schema we land on. Their `MarketplaceAuthCredential` is the right precedent for the credentials half.
```prisma
model ExternalIdentity {
id String @id @default(uuid()) @db.Uuid
customerId String @db.Uuid
provider String // 'vk_id' | 'yandex_id' | 'telegram' | 'max'
providerUserId String
email String?
phone String?
displayName String?
verifiedAt DateTime @default(now())
lastUsedAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
@@unique([provider, providerUserId]) // one provider account -> one customer
@@index([customerId])
}
```
Plus, per tenant, an encrypted OAuth app config using the same envelope as their `FieldEncryptionService`:
```
MarketplaceAuthCredential { marketplaceId, provider, encryptedConfig, active }
encryptedConfig = { clientId, clientSecret, scopes[], redirectUri }
```
**Five rules that decide whether this ships correctly:**
1. **Redirect URI vs. multi-tenant domains.** VK and Yandex both validate `redirect_uri` against an exact registered list. With N tenant domains you cannot register N URIs per app, and you cannot let tenants supply their own. Use **one central identity host** (e.g. `id.<platform-domain>`) as the only registered callback, carry the origin tenant inside the signed `state`, and 302 back to the tenant domain with a short-lived signed one-time handoff token that the tenant's API exchanges for the session cookie. Decide this before writing any code — retrofitting it is expensive.
2. **`@@unique([provider, providerUserId])`, plus a decision on per-tenant customer separation.** Their platform isolates `Customer` per marketplace even for the same Telegram ID. Decide explicitly whether one VK account across two of our storefronts is one customer or two. Their answer is *two*; that is the safer default for data protection and the one our `Customer.marketplaceId` already implies.
3. **Identity conflict is not an upsert.** Our PHASE-8 §2 already says this: if `providerUserId` is already bound to a different `Customer`, route to controlled resolution — never silently rebind. Enforce it with the unique index so the database refuses, rather than trusting the service layer.
4. **`state` is single-use and bound to the browser.** Store `{ state, codeVerifier, marketplaceId, returnTo, expiresAt }` server-side or in a signed HttpOnly cookie; delete on first use. Reject unknown/expired/replayed `state` with a generic error.
5. **The session that comes out is our normal session.** VK/Yandex end where Telegram ends: a random 32-byte token, stored as SHA-256, HttpOnly + Secure + SameSite=Lax, per-marketplace, revocable. Social login is an *entry path*, not a session format.
**Build order:** provider-agnostic backend endpoints + `ExternalIdentity` table → VK ID (v3.1 names it the primary social login) → Yandex ID (a second instance of the same strategy, roughly a day once VK works) → migrate Telegram onto `ExternalIdentity` so it becomes one provider among several rather than the schema's only key → account-linking UI (`/me/identities`, link/unlink) → email/phone OTP as recovery.
---
## 9. What we must not copy from them
- **Angular 21.2.18** with 78 open high findings in production dependencies, in both apps. We are on 22.0.8 with a clean production audit. Do not regress.
- **`mock-data.service.ts` in the backoffice** — they still ship one while telling us mocks are disqualifying.
- **25 unit tests and zero e2e.** Their own verification doc concedes this is not sufficient.
- **`ORDER_MANAGER_MARKETPLACE_SLUG` pinned by environment variable** (default `'dexar'`, `'novo'` in the example env). Manager scope should come from membership rows, not an env string.
- **Server IP hardcoded in `provision-domain.sh`** (`109.120.134.244`), and the `sslip.io` staging hosts baked into the committed env example.
- Their **section schema** is narrower than ours (5 section types vs our widget system). Take their *server-side validation discipline*, not their schema.
---
## 10. Recommended next actions
| # | Action | Why now |
|---|---|---|
| 1 | Write the two e2e tests from their §22: concurrent purchase of the last unit, and a replayed webhook | Highest bug-cost coverage per hour, and they have neither |
| 2 | Remove client-held payment credentials and JS-set admin cookies | Their audit's most serious finding, and it is correct |
| 3 | Fold their invariant list (§7 of their handoff) into our own handoff doc as a signed acceptance gate | Turns their strongest document into our shared standard |
| 4 | Decide the central-identity-host question in §8.3 rule 1 | Blocks VK ID, and it is a one-way door |
| 5 | Implement provider-agnostic social identity, then VK ID, then Yandex ID | v3.1 §14 names VK ID the primary social login; nobody has it yet, including them |
| 6 | Cut the storefront bundle below budget by splitting storefront / editor / backoffice deployables | 452 kB over, and it is the one performance criticism that is objectively measured |
| 7 | Take the position explicitly that the two codebases merge as *their backend + our frontend* | Their handoff doc already ranks our work fifth; unchallenged, that becomes the plan of record |
---
## Appendix — where things live in the archive
| Concern | Path |
|---|---|
| Tenant by Host, preview tokens | `platform-api/src/common/tenant.service.ts` |
| Admin auth, TOTP, RBAC weights | `platform-api/src/auth/admin-auth.service.ts` |
| Origin allowlist / CSRF hook | `platform-api/src/main.ts`, `src/auth/admin-origins.ts` |
| Stock reservation SQL | `platform-api/src/checkout/checkout.service.ts` |
| Idempotency, webhooks, polling | `platform-api/src/payments/payment.service.ts` |
| Field encryption (AES-256-GCM) | `platform-api/src/common/field-encryption.service.ts` |
| Server-side section validation | `platform-api/src/common/storefront-config.ts` |
| Publish / rollback / clone | `platform-api/src/admin/admin.service.ts` |
| Customer (Telegram) sessions | `platform-api/src/storefront/customer-auth.service.ts` |
| Data model, 36 entities | `platform-api/prisma/schema.prisma` |
| Compose, networks, WAL | `infra/compose.yml` |
| Domain provisioning, backup, restore check | `infra/scripts/` |
| Host hardening (fail2ban, sshd, sysctl) | `marketplaces/infra/server/` |
| Their audit of our code | `docs/PARALLEL_IMPLEMENTATION_AUDIT.md` |
| Their target spec (479 lines) | `docs/PRODUCT_SPECIFICATION.md` |
| Their handoff + invariants | `docs/DEVELOPER_HANDOFF.md` |
| Our code, verbatim | `reference/parallel-frontend/` |

304
docs/FORK-HARVEST-TODO.md Normal file
View File

@@ -0,0 +1,304 @@
# Fork Harvest — TODO
**Branch:** `improvements/fork-harvest` (from `B2B` @ `92f1c88`)
**Design:** [2026-08-21-fork-harvest-design.md](superpowers/specs/2026-08-21-fork-harvest-design.md)
**Source analysis:** [FORK-ANALYSIS-2026-08-21.md](FORK-ANALYSIS-2026-08-21.md)
Improvements only. Nothing here regresses our Angular version, test count, or architecture governance.
**Effort:** S ≤ half a day · M ≤ 2 days · L > 2 days
**Lane:** A frontend · B backend contract · C `@marketplaces/auth` package · D infra/ops · E process
---
## Wave 0 — Decide first (blocks Wave 4)
- [ ] **FH-0.1 — Decide the central identity host** · L · Lane C · *blocker*
VK ID and Yandex ID both validate `redirect_uri` against an exact registered list. We cannot register one per tenant domain, and we cannot let tenants supply their own.
**Decision needed:** single central callback host (e.g. `id.<platform-domain>`) as the only registered URI, tenant carried inside signed `state`, 302 back to the tenant domain with a short-lived signed handoff token the tenant API exchanges for a session cookie.
**Also decide:** is one VK account across two of our storefronts one `Customer` or two? Their platform says two; our `Customer.marketplaceId` already implies two.
**Done when:** an ADR exists in `docs/context/adrs/` and both questions have a recorded answer.
- [ ] **FH-0.2 — Confirm the server-priced checkout path covers every live flow** · S · Lane A · *blocks FH-1.3*
`api.service.ts` already has a server-priced checkout session method. Confirm no production flow still depends on `createPayment(payload, headers)` before deleting the header path.
**Done when:** every caller of the legacy header path is enumerated and has a replacement.
---
## Wave 1 — Live defects with a security benefit (Lane A, this sprint)
- [x] **FH-1.1 — Kill the plaintext third-party geo call** · S · Lane A · **done 2026-08-21**
Now `GET {tenantApiBase}/geo/resolve`, same base as `/regions`. Server reads the client IP; nothing leaves our infrastructure. Endpoint specified in [BACKEND-API-REFERENCE.md](../BACKEND-API-REFERENCE.md) §6 — **not built yet**, and until it is the client falls back to the manual picker, which is what production has effectively had all along. Covered by `src/app/services/location.service.spec.ts` (4 tests, one of which fails the build on any off-origin or plaintext request from this service).
*Was:* `location.service.ts:75` called `http://ip-api.com/json/?fields=…` from an HTTPS origin. Mixed active content is blocked, so `detectLocation()` only ever took its error branch — auto-detect was dead in production, not merely insecure — and the attempt still leaked every visitor's IP to a third party.
- [ ] **FH-1.2 — Stop blindly trusting the bank redirect URL** · M · Lane A
`src/app/pages/cart/cart.component.ts:485``bypassSecurityTrustResourceUrl(bankUrl)` with no validation, rendered into a popup iframe. Most acquirer 3-D Secure pages send `X-Frame-Options: DENY`, so the popup is blank for those banks. Their spec: card checkout navigates the current tab, no intermediate popup.
**Do:** accept only an `https:` URL whose origin the backend returned in the payment response (backend allowlist, per their `safeHttpsUrl()`); navigate the current tab instead of framing.
**Done when:** a non-https or non-allowlisted URL is refused with a visible payment error; a test covers both the accepted and the refused case.
- [x] **FH-1.3 — Remove provider credentials from the browser** · M · Lane A · **landed via the `@marketplaces/payment` migration**
The legacy payment surface on `ApiService` was deleted wholesale in that work. `grep -ri "authorization-key\|userid-value\|web-97ec" src/` now returns nothing. Keep FH-3.5 (bundle secret scan) to stop it coming back.
*Was:* `api.service.ts:675` set `authorization-key` and `userid-value` headers client-side, and `api.service.ts:143` shipped a partner ID literal in the bundle. Their audit's most serious finding, and it was correct.
- [ ] **FH-1.4 — Send `Idempotency-Key` on payment creation** · S · Lane A
Zero `idempot*` anywhere in our codebase. Their API requires the header and rejects a key reused across a different order.
**Do:** generate one key per checkout attempt, stable across retries and across a double-click, sent on payment creation.
**Done when:** the existing `checkout-idempotent-click.spec.ts` asserts both requests carry the *same* key.
---
## Wave 2 — Contract hardening (Lane B, parallel with Wave 1)
Each item is normative text plus an acceptance scenario in `backend/BACKEND-INTEGRATION.md`, so it becomes a delivery gate rather than a wish.
- [x] **FH-2.1 — Conditional-UPDATE stock reservation** · S · `backend/BACKEND-INTEGRATION.md`
**Written 2026-08-21:** PHASE-3 §3.1 — the conditional `UPDATE … WHERE (available - reserved) >= qty RETURNING id`, 409 on zero rows, whole-cart rollback, 15 min TTL.
`UPDATE … SET reserved = reserved + $qty WHERE (onHand - reserved) >= $qty RETURNING id`; empty result → `409`. Reservation TTL 15 min. Price read only from the server-side snapshot, never from the request.
**Acceptance:** two concurrent purchases of the last unit produce exactly one payable order.
- [x] **FH-2.2 — Idempotency as unique constraints** · S · `backend/BACKEND-INTEGRATION.md`
**Written 2026-08-21:** PHASE-7 §5 — unique constraints on `payment.idempotency_key` and `(provider, event_key)`, insert-first webhook handling, `sha256(rawBody)` fallback key, signature over the raw body, 24 h poll as reconciliation.
`Payment.idempotencyKey UNIQUE`; a key reused against a different order/marketplace → `409`. `PaymentWebhookEvent @@unique([provider, eventKey])`; duplicate insert → `{accepted: true, duplicate: true}`. `eventKey` falls back to `sha256(rawBody)`. Signature verified against the **raw** body. Status poll as a 24-hour reconciliation fallback.
**Acceptance:** a replayed webhook neither completes the order twice nor moves stock twice.
- [x] **FH-2.3 — Session and credential model** · M · `backend/BACKEND-INTEGRATION.md`
**Written 2026-08-21:** TRACK-S §2.1 — 32 random bytes stored as SHA-256 only, HttpOnly/Secure/SameSite, one cookie per contour, Argon2id params, mandatory TOTP with a single-use enrolment token, password change revokes all sessions in-transaction.
Server-stored sessions; random 32 bytes; **stored as SHA-256 hash only**; HttpOnly + Secure + SameSite; revocable; a distinct cookie per contour (`bo_session` / `manager_session` / `marketplace_session`). Argon2id `memoryCost 65536, timeCost 3, parallelism 1`. TOTP mandatory, gated by a signed 10-minute setup token. Password change ≥16 chars and revokes every live session in the same transaction. Role weights `ORDER_MANAGER 0 < VIEWER 1 < CONTENT_MANAGER 2 < ADMIN 3 < OWNER 4`, checked together with marketplace scope.
**Acceptance:** a CONTENT_MANAGER cannot read an unassigned marketplace through a direct API call.
- [x] **FH-2.4 — Origin allowlist for admin mutations** · S · `backend/BACKEND-INTEGRATION.md`
**Written 2026-08-21:** TRACK-S §2.2 — origin allowlist ahead of routing on every admin/platform/manager mutation, same list for CORS.
Global hook: any non-GET on an admin/manager path whose `Origin` is not in the configured allowlist → `403`. CORS uses the same allowlist with `credentials: true`.
**Acceptance:** a cross-origin POST with a valid session cookie is refused.
- [x] **FH-2.5 — Tenant by verified Host only** · S · `backend/BACKEND-INTEGRATION.md`
**Written 2026-08-21:** PHASE-9 §6 — normalization specified, `verifiedAt` required, cache with explicit invalidation, proxy header trust, no public endpoint accepts `marketplaceId`.
Normalize host (lowercase, strip trailing dot, strip port) → unique `hostname` row → require `verifiedAt` and `ACTIVE`. Short cache with explicit invalidation. Unknown host → `404`, never a fallback tenant. The public API never accepts a `marketplaceId` from the browser.
**Acceptance:** an unknown Host returns 404 and leaks no other tenant's data.
- [x] **FH-2.6 — Signed preview token, read-only preview** · S · `PHASE-9-…`
**Written 2026-08-21:** PHASE-9 §5.3 — HMAC preview token, 15 min, HttpOnly cookie, every non-GET 404s while preview is active, `noindex`.
HMAC-signed token carrying `{marketplaceId, expiresAt, nonce}`, 15-minute TTL, `storefront_preview` cookie. Global hook returns `404 Preview mode is read-only` for any non-GET while that cookie is present. Preview is not indexable.
**Acceptance:** a mutation attempted in preview mode is refused.
- [x] **FH-2.7 — Immutable revisions, rollback, clone** · M · new section, `PHASE-9-…`
**Written 2026-08-21:** PHASE-9 §5.15.2 — `version = max+1` unique per marketplace, materialized snapshot, pointer flipped in-transaction, rollback as a new revision, clone carry/no-carry list, inventory to zero, topological category walk.
`version = max(version) + 1`, immutable snapshot row, `publishedRevision` pointer flipped in the same transaction. Rollback creates a new revision; history is never rewritten. Clone copies design + catalog assignments, **forces inventory to 0**, never copies domains/customers/orders/secrets, and walks the category tree topologically with explicit cycle detection.
**Acceptance:** rollback restores the chosen revision and leaves live inventory untouched.
- [x] **FH-2.8 — Append-only inventory journal** · S · `backend/BACKEND-INTEGRATION.md`
**Written 2026-08-21:** PHASE-3 §3.2 — `InventoryMovement` append-only with reason, reference, actor, and `resultingAvailable` written at the time.
Every stock change writes `reason`, `referenceType`, `referenceId`, `actorId`, resulting balance. Direct answer to the v3.1 "we cannot explain your numbers" complaint.
**Acceptance:** any current quantity is reconstructible from the journal alone.
- [x] **FH-2.9 — Per-tenant encrypted credentials** · S · `PHASE-1` / `PHASE-7`
**Written 2026-08-21:** TRACK-S §4.2 — `v1.iv.tag.ciphertext` AES-256-GCM envelope, per-value IV, decrypt only in-service, HMAC fingerprints for display, backend-built allowlisted redirect URLs.
AES-256-GCM, versioned envelope `v1.iv.tag.ciphertext` (base64url), 32-byte key from the environment. Decrypted only inside the service; never serialized into any response. Redirect/callback URLs built backend-side and allowlisted.
**Acceptance:** no credential appears in any API response, JS bundle, or browser storage.
- [x] **FH-2.10 — Server-side storefront config validation** · M · `backend/BACKEND-INTEGRATION.md`
**Written 2026-08-21:** PHASE-10 §3a — server re-runs the editor rules, clamp-and-fallback ergonomics, structural violations 400, limits published as one schema, referential checks as publish blockers.
The server re-runs our editor's validation. Clamp-and-fallback ergonomics: clamp out-of-range numbers rather than rejecting; blank a URL that is not local `/path` or `https://` rather than erroring; fall back an invalid colour. Cap sections per page and IDs per list.
**Acceptance:** a hand-crafted API call cannot store a config the editor would have refused.
- [x] **FH-2.11 — Digital goods** · M · `PHASE-3-…`
**Written 2026-08-21:** PHASE-3 §6a — `FulfillmentMode`, `DigitalCode` states, `valueHash` unique per (marketplace, offer), codes revealed only when paid.
`FulfillmentMode: MANUAL | CODE_POOL`. `DigitalCode` pool with `AVAILABLE/RESERVED/ASSIGNED/REVOKED`, encrypted value, `valueHash` unique per `(marketplace, variant)`. Codes revealed only when the order is `PAID`/`PROCESSING`/`FULFILLED`.
**Acceptance:** an unpaid order never returns a code.
- [~] **FH-2.12 — Marketplace status machine** · **rejected 2026-08-21 — ours is better**
Theirs is `DRAFT → DOMAIN_PENDING → READY → ACTIVE → SUSPENDED`. PHASE-9 §2 already carries `draft → configured → content_ready → domains_planned → staging_live → qa_passed → production_ready → live → paused/archived`, plus a lifecycle endpoint that must name the specific blocker preventing the next transition. Adopting theirs would be a downgrade. Recorded so it does not get raised again.
- [x] **FH-2.13 — Order public token, not sequential IDs** · S · `backend/BACKEND-INTEGRATION.md`
**Written 2026-08-21:** PHASE-2 §3.1 — `publicToken` ≥24 random bytes for every customer-facing route, tenant-scoped lookup, snapshot completeness, snapshots never updated in place.
Orders are addressed publicly by a random `base64url` token. Order line items carry an immutable snapshot of name, SKU, price, currency, delivery, and contact data at purchase time.
- [x] **FH-2.14 — Order-manager as a separate contour** · M · `TRACK-S-…`
**Written 2026-08-21:** TRACK-S §8a — separate URL, shell, login and cookie; scope from membership rows not configuration; endpoints refuse rather than hide; PII masking and audited reveal.
Separate URL, shell, cookie, and login; scoped to assigned marketplaces via **membership rows, not an environment variable** (their env-pinned slug is the one part not to copy). No visibility into catalog, design, domains, payment settings, or platform users. PII masked in lists, revealed in detail only with permission, and both export and reveal are logged.
- [x] **FH-2.15 — Bulk import: idempotency and rollback** · S · **done 2026-08-21**
The validate-then-apply half already existed — PHASE-3 §6 has the preview of validation errors and a separate apply step, which is equivalent to their `dryRun`. What was missing and is now written: the import is **idempotent by SKU/external key** so re-running a file updates rather than duplicates, a row-level error never publishes a partial result, and an applied import is rollback-able only while none of its products have appeared on a paid order.
---
## Wave 3 — Proof (Lane A)
- [ ] **FH-3.1 — E2E: concurrent purchase of the last unit** · M · `e2e/`
Their §22 scenario 3. Two sessions race for the final unit; exactly one payable order results, the other gets a clean out-of-stock state.
- [ ] **FH-3.2 — E2E: replayed webhook** · M · `e2e/`
Their §22 scenario 10. The same provider event delivered twice does not complete the order twice or move stock twice.
- [x] **FH-3.3 — Bundle budget as a blocking CI check** · S · **done 2026-08-21**
`maximumError` on the initial bundle lowered `1.8MB → 1.6MB` in `angular.json`, and again to `1.1MB` once FH-3.4 landed. Measured today: **1.55 MB raw / 324.58 kB transfer** — worse than the 1.15 MB they measured on 11 Aug, so this had been growing unwatched. The threshold is a **ratchet**, not the target: set just above today's size so the bundle cannot grow, with the 700 kB warning left in place as the goal. Lower it every time the number comes down. CI now runs the production build (`npm run build` already defaults to production).
- [~] **FH-3.4 — Get the initial bundle down** · L · **1.55 MB → 1.04 MB on 2026-08-21; not yet at target**
**Premise corrected after measuring.** Admin, editor, catalog, cart and the `en`/`hy` locales are *already* lazy chunks — nothing admin-shaped ships to an anonymous visitor. The whole initial bundle is `main` alone, so this was never a "split the deployables" job.
Built with `--stats-json` and read the esbuild metafile rather than guessing. Composition of the 1.5 MB `main`:
| bytes | what |
|---:|---|
| 495,831 | `@angular/compiler` |
| 290,589 | `src/app/i18n/ru.ts` |
| 162,915 | `@angular/core` |
| 82,209 | `@angular/router` |
| 53,354 | `@angular/common` |
**`@angular/compiler` — 33% of the bundle — was the JIT compiler, in an AOT production build.** `src/main.ts` imported it explicitly, with a comment explaining that `@marketplaces/auth` shipped plain `tsc` output carrying no Ivy metadata, so Angular JIT-compiled its classes at runtime and bootstrap threw without it.
That comment was **stale**. The package is `0.2.0`, built with ng-packagr, `module: dist/fesm2022/marketplaces-auth.mjs` — proper Angular Package Format, partial-compiled (`ɵɵngDeclareInjectable`), linked at consumer build time. No JIT needed.
Removed the import. Verified against the **production** bundle served statically, not just a successful build — the failure mode it guarded was a runtime throw, so a green build proves nothing. Angular 22.0.8 bootstrapped, the router resolved `/ru`, and the app rendered its own "server unavailable" screen, which means DI, HttpClient and the whole interceptor chain ran. That chain injects `AuthService` from `@marketplaces/auth` — the exact class named in the old comment. Zero JIT/compiler errors; the only console output was the expected 404s from having no backend.
**Result: 1.55 MB → 1.04 MB raw, 323.58 kB → 215.45 kB transfer.** Ratchet lowered `1.6MB → 1.1MB`, which is now also the guard against the import being re-added.
**Remaining path to the 700 kB target.** The next lever is `i18n/ru.ts` at 290 kB — the default locale, eager, while `en`/`hy` are lazy. `TranslateService` already has the loader plumbing and `languageGuard` already awaits a preload before route activation, so making `ru` lazy is mechanically small. It is deliberately **not** done here: it adds a round-trip before first paint for the majority language, which is a product tradeoff rather than a cleanup. Needs a decision, then it is roughly a 750 kB bundle.
Also outstanding: `qrcode` (23.7 kB), pulled in by `@marketplaces/auth`, is not ESM and causes an optimizer bailout — a fix for the package repo.
- [x] **FH-3.5 — Bundle secret scan in CI** · S · **done 2026-08-21**
`scripts/ci/scan-bundle.sh`, wired as `npm run scan:bundle` and a CI step after Build. Seven patterns: both provider auth headers, the partner ID shape, `client_secret`, private key blocks, AWS keys, Telegram bot tokens. Verified in both directions — clean against the real `dist/`, and fails with exit 1 against a planted credential.
---
## Wave 4 — Identity: VK ID + Yandex ID (Lane C, `@marketplaces/auth`)
Nothing to copy from the archive — it has zero VK/Yandex/OAuth code. We take the session-issuing shape of their Telegram flow and terminate both providers into it.
The **client half and the contract are done** (2026-08-21). What remains is backend implementation, and registering the OAuth applications — which is what FH-0.1 gates.
- [x] **FH-4.1 — Provider-agnostic social identity surface** · M · **done 2026-08-21**
Landed as `social-identity-gateway.interface.ts` / `-api.gateway.ts` / `-local.gateway.ts` / `-gateway.token.ts` under `src/app/core/identity/services/`, with the four `vk-id-*` files deleted and `vk-id-login` replaced by `social-login-button` taking a `provider` input. `'yandex_id'` added to `ExternalIdentityProvider`. Covered by `social-identity-gateway.spec.ts` (5 tests), which asserts the request carries no `code_verifier` or `client_secret` — so re-adding a browser-held verifier fails the build rather than passing review.
Collapse `VkIdGateway` into `SocialIdentityGateway`:
```ts
export type SocialProvider = 'vk' | 'yandex';
export interface SocialIdentityGateway {
getAuthorizeUrl(provider: SocialProvider, returnTo?: string): Observable<string>;
listIdentities(): Observable<ExternalIdentity[]>;
unlink(provider: SocialProvider): Observable<void>;
}
```
Touches: `src/app/core/identity/services/vk-id-gateway.interface.ts`, `vk-id-api.gateway.ts`, `vk-id-local.gateway.ts`, `vk-id-gateway.token.ts`, `src/app/components/vk-id-login/` → `social-login-button`. Add `'yandex_id'` to `ExternalIdentityProvider` in `core/identity/models/customer-identity.model.ts`.
- [x] **FH-4.2 — Move PKCE ownership to the backend** · S · **done 2026-08-21**
`completeCallback()` is gone from the frontend entirely. PHASE-8 §2.12.2 rewritten: `/authorize` mints and stores `{state, codeVerifier, marketplaceId, returnTo, expiresAt}` single-use for 10 minutes, `/callback` is a backend GET that exchanges, links, issues the session cookie and redirects. `returnTo` validated against the tenant's own origin.
Today `completeCallback(code, codeVerifier)` forces the browser to generate and hold the verifier. We are a confidential client. Backend generates `state` + `code_verifier`, stores them single-use for 10 minutes, handles the callback, and redirects. `completeCallback()` leaves the frontend entirely.
Contract endpoints: `GET /api/identity/v1/{provider}/authorize`, `GET /api/identity/v1/{provider}/callback`, `POST /{provider}/unlink`, `GET /me/identities`. Update `backend/BACKEND-INTEGRATION.md` §2.
- [~] **FH-4.3 — `ExternalIdentity` model** · S · Lane B · **contract written 2026-08-21, awaiting backend**
PHASE-8 §1 and §2.3: `UNIQUE (provider, providerUserId)`, conflict routes to controlled resolution rather than rebinding, optional email/phone/displayName, per-tenant OAuth app config under the Track S §4.2 envelope.
```prisma
@@unique([provider, providerUserId]) // one provider account -> one customer
```
Conflict is **not** an upsert: a `providerUserId` already bound to a different `Customer` routes to controlled resolution. The unique index makes the database refuse a silent rebind. Per-tenant OAuth app config stored encrypted (same envelope as FH-2.9): `{ clientId, clientSecret, scopes[], redirectUri }`.
- [~] **FH-4.4 — VK ID** · M · **contract written 2026-08-21, awaiting backend**
PHASE-8 §2.5 carries the full endpoint set and the `device_id` trap.
OAuth 2.1, PKCE mandatory (S256). Authorize `https://id.vk.com/authorize`; token `POST https://id.vk.com/oauth2/auth`; profile `POST https://id.vk.com/oauth2/user_info`; logout `https://id.vk.com/oauth2/logout` on unlink.
**Trap to write into the contract:** the callback returns `device_id` alongside `code`, and the token exchange fails without it. This is the most common VK ID integration bug.
VK often does not return an email — email must stay optional on `Customer`.
- [~] **FH-4.5 — Yandex ID** · S · **contract written 2026-08-21, awaiting backend**
PHASE-8 §2.5. On the client it is a `provider` input, not new code.
OAuth 2.0 with PKCE. Authorize `https://oauth.yandex.ru/authorize`; token `POST https://oauth.yandex.ru/token` with HTTP Basic `client_id:client_secret`; profile `GET https://login.yandex.ru/info?format=json` with header `Authorization: OAuth <token>` → `id`, `login`, `default_email`, `default_phone`, `psuid`.
A second strategy object against the same surface — roughly a day once VK works.
*Confirm exact parameter and scope names against live provider docs; both providers revised their flows recently.*
- [~] **FH-4.6 — Migrate Telegram onto `ExternalIdentity`** · M · **client + contract done 2026-08-21; backend write path pending**
Client: the gateway now separates the two provider sets — `SocialProvider` (`vk`/`yandex`, has an OAuth authorize) vs `ExternalIdentityProvider` (adds `telegram`/`max`, listable and unlinkable). `unlink()` takes the wider type, so the linking UI detaches Telegram through the same path as VK. The dev local gateway seeds a Telegram identity so the surface is exercisable now.
Contract: PHASE-8 §2.6 — Telegram login writes an `ExternalIdentity` row under the same uniqueness/conflict rule as VK, appears in `/me/identities`, is removable subject to the last-identity `409`, and — the audit finding — keeps customer (`marketplace_session`) and admin (`bo_session`) sessions as separate cookies so a Telegram customer never satisfies an admin guard. Identity row vs messaging `BotConversationBinding` kept distinct.
Backend still owns: the actual write-on-login and the session split enforcement. Telegram login itself lives in `@marketplaces/auth`.
- [x] **FH-4.7 — Account linking UI** · M · **done 2026-08-21**
`AccountIdentitiesComponent` (`src/app/features/website/account/identities/`): lists linked identities from `GET /me/identities`, offers attach buttons only for OAuth providers not yet linked (reusing `SocialLoginButtonComponent`), detaches through `unlink()`, disables the detach control on the last remaining identity with an explanatory title, and has a slot for the §2.3 conflict message. Loading / error / ready states, error surfaced rather than shown as an empty account. 6 unit tests. Not yet wired into a route — the storefront has no customer account area and no live OAuth app (FH-0.1) — but fully built and tested behind that.
- [x] **FH-4.8 — Email/phone OTP repositioned as recovery** · S · **done 2026-08-21**
PHASE-8 §3 now states it explicitly: OTP is a way back in when a linked messenger is unreachable and a second factor a customer may add, never the front-and-centre first login option, and one more `ExternalIdentity`/`ContactMethod` on the same customer rather than a parallel account. The [email/phone spec](superpowers/specs/2026-08-15-email-phone-login-design.md) stays valid; only its priority relative to VK ID moves.
---
## Continuous — Ops (Lane D)
- [ ] **FH-D.1 — Proven restore drill** · M
We have deploy automation and no proven restore. Add a restore-check script and schedule it. Their `restore-check.sh` + WAL archiving (`wal_level=replica`, `archive_mode=on`, `archive_timeout=300`) is the model.
**Done when:** a restore into a clean environment has been executed and its result recorded.
- [ ] **FH-D.2 — Database unreachable from the internet, structurally** · S · Lane B/D
Data network `internal: true`; API bound to loopback only; `no-new-privileges` on every service. Makes it a property of the topology rather than a firewall promise.
- [x] **FH-D.3 — Host hardening we lack** · M · **done 2026-08-21**
Three drop-in files in `scripts/deploy/server-setup.sh`, documented in [DEPLOYMENT.md](DEPLOYMENT.md) §3.2: sshd hardening (password and keyboard-interactive auth off, root key-only, `MaxAuthTries 3`, 30 s grace, no forwarding), fail2ban (`sshd`, `nginx-http-auth`, `nginx-bad-request`; 5 failures in 10 min, 1 h ban), and sysctl (no redirects or source routing, rp_filter, SYN cookies, forwarding off, restricted kernel pointers and dmesg). The script runs `sshd -t` before reloading and removes its own drop-in if the test fails — a bad sshd config taking effect remotely is how people lock themselves out permanently.
Scoped sudoers was already in place. *Kept ours where ours is better:* `add-domain.sh` pre-checks the DNS A record and runs `nginx -t` before and after; ufw was already configured. Their hardcoded server IP deliberately not copied.
---
## Continuous — Process (Lane E)
- [x] **FH-E.1 — Adopt the nine invariants as an acceptance gate** · S · **done 2026-08-21**
Now `backend/BACKEND-INTEGRATION.md` §0, ahead of everything else, each one cross-referenced to the contract section that specifies it. Framed as a release gate: violate one and it does not ship, regardless of what else is finished.
- [x] **FH-E.2 — PR policy** · S · **done 2026-08-21**
`backend/BACKEND-INTEGRATION.md` §0a, with the expand/contract migration rule alongside it.
- [x] **FH-E.3 — Release discipline** · S · **done 2026-08-21**
`backend/BACKEND-INTEGRATION.md` §0a. A release records version, migrations, healthcheck, smoke, dependency audit, and the rollback path actually available.
- [x] **FH-E.4 — ADR for the harvest** · S · **done 2026-08-21**
[ADR-0006](context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md). Records what we take, what we reject, what we keep because ours is better, and the one organizational question it deliberately does not settle.
- [x] **FH-E.6 — Keep mock gateways out of production builds** · M · **done 2026-08-21**
21 token factories now `inject(XApiGateway)` unconditionally; mock overrides moved to `src/app/mock-gateway.providers.ts`, swapped for a production copy that imports nothing via `fileReplacements`. Zero `*LocalGateway` classes and zero fixtures in the production bundle, down from 21 classes and 75 kB of source. `scan-bundle.sh` gained two patterns so it cannot return, verified in both directions. Dev behaviour unchanged — flip `useMockData` in `environment.ts` as before.
Worth noting for whoever picks up FH-E.5: `useMockData` is `false` in **both** environment files, so none of these mocks were ever the selected implementation. They were pure weight.
**Not fixed here:** `MediaRepository` is still bound to `MockMediaRepository` unconditionally in `app.config.ts`. That one cannot simply be deleted — no real implementation exists — so it is a missing API gateway, not dead weight.
Measured 2026-08-21: mock seed data reaches the production bundle. `ptr_local`, a fixture literal from `partner-hierarchy-local.gateway.ts`, is present in a built lazy chunk. Cause: 21 DI tokens use `factory: () => (environment.useMockData ? inject(XLocalGateway) : inject(XApiGateway))`, and referencing both branches keeps both classes reachable, so the optimizer cannot drop the mock. 75 kB of local-gateway source, plus its fixtures, ships to users.
This is the concrete form of their strongest objection — "mock repositories as production implementation" — and it is mechanical to fix. The pattern to copy is already in this repo: `mock-data.interceptor.production.ts` swapped in via `fileReplacements`.
**Done when:** `scan-bundle.sh` can gate on mock fixture markers and pass.
- [x] **FH-E.5 — Reduce `localStorage` to cache, never truth** · M · **audited + gap marked 2026-08-21**
Audited all 21 `localStorage` users. The premise — "localStorage is your source of truth" — turned out **already false** across the app:
- Every admin facade (products, categories, orders, moderation, dashboard) uses `localStorage` for **view preferences only** — `viewMode`, `density`, `visibleColumns`, `expandedIds`, `sort`. Entity CRUD goes through the API gateways. That is cache, not truth.
- `currency-rates.service.ts` already removed its localStorage-typed rates (its own comment records it).
- `language`, `location` region, `search-history`, `api-headers` anonymous session id, `admin-preferences` — all legitimate preference/cache.
- The editor already surfaces an **"unsaved local draft restored"** banner (`draftRestored` → save bar), which is the recovery-cache indicator this item asked for.
**One real gap, and it is backend-blocked:** the project editor's `publish()` applies config to the in-memory runtime and saves the draft to localStorage, then declares itself published — no server round-trip, because the PHASE-9 §5 revision API does not exist yet. Marked precisely in `publish()` with the required behaviour (await the server, only then mark published) and cross-referenced to the contract. Cannot be finished on the frontend alone; the contract for the fix is already written.
Net: nothing to rip out — the codebase was already at the target state everywhere the backend exists to support it.
---
## Scoreboard
| Wave | Done | Contract written, awaiting backend | Open | Blocked by |
|---|---:|---:|---:|---|
| 0 — Decide | 0 | — | 2 | needs a person, not a session |
| 1 — Live defects | 2 | — | 2 | FH-1.2 / FH-1.4 sit in files another session owns |
| 2 — Contracts | 14 | — | 0 | 1 rejected (FH-2.12) |
| 3 — Proof | 3 | 1 | 1 | see note below |
| 4 — Identity | 4 | 3 | 1 | OAuth apps, which FH-0.1 gates |
| Ops | 1 | — | 2 | — |
| Process | 6 | — | 0 | — |
| **Total** | **30** | **4** | **8** | 1 rejected |
**Landed 2026-08-21**
- **Wave 1** — FH-1.1 (geo off `ip-api.com`, 4 new tests), FH-1.3 (credentials out of the browser, via the `@marketplaces/payment` migration).
- **Wave 2** — all 14 remaining contract items written into `docs/backend/`, tagged `FH-*` and dated so each traces back to the analysis. FH-2.12 rejected on the merits: our lifecycle state machine is richer than theirs.
- **Wave 3** — FH-3.3 (bundle budget ratcheted to a blocking error at 1.6 MB, measured 1.55 MB), FH-3.5 (`scripts/ci/scan-bundle.sh`, in CI, verified in both directions).
- **Wave 4** — FH-4.1 and FH-4.2 complete on the client and in the contract; FH-4.34.5 specified and waiting on backend plus registered OAuth applications.
- **Process** — FH-E.1E.4, including [ADR-0006](context/adrs/ADR-0006-harvest-mechanisms-from-the-parallel-platform.md).
Test count over the session: 247 -> 256 (+4 geo, +5 social identity). Initial bundle 1.55 MB -> 1.04 MB. Build green, boundary and cycle checks green.
**On FH-3.1 / FH-3.2 — reclassified, not skipped**
Both are backend races: two transactions competing for the last unit, and the same provider event arriving twice. Playwright against mocked routes cannot prove either — a test that mocks both sides of a race proves only that the mock behaved. `checkout-idempotent-click.spec.ts` already says this in its own header and covers the genuinely frontend-testable half.
So the acceptance criteria now live where they bind, as normative text in PHASE-3 §3.1 and PHASE-7 §5, and the e2e work they imply is **backend integration testing**, not frontend e2e. What *is* worth doing on our side first: the existing checkout e2e specs have a known-failing session setup (documented in-file, dated 2026-08-21) — a green suite is the prerequisite for anything built on top of it.
**Next**
1. **FH-0.1** — the central identity host, and the one-customer-or-two question. One-way door, gates the remaining Wave 4 work, needs a decision from a person.
2. **FH-1.2 / FH-1.4** — bank URL validation and `Idempotency-Key`. Both live in `cart.component.ts` / the payment package; pick up once that work settles.
3. **FH-E.6** — mock fixtures reach production chunks. The fix is mechanical but touches 21 DI token files plus `app.config.ts`, which another session currently owns — deliberately deferred rather than merged into a busy tree. Plan: move mock selection out of the token factories into one dev-only provider array swapped by `fileReplacements`, the same mechanism `mock-data.interceptor.production.ts` already uses.
4. **Fix the e2e session setup**, then revisit what proof is worth adding.

View File

@@ -0,0 +1,58 @@
# @marketplaces/auth & @marketplaces/payment — build, version, release, infrastructure
See [ADR-0001](context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md) for why. This doc is the how. For *consuming* the packages (install, DI providers, exported API), see [PACKAGES-USAGE.md](PACKAGES-USAGE.md).
## Current state
Working end to end with no credentials. `marketplaces` has no local copy of either package and no `.npmrc` — it installs `@marketplaces/auth` directly over git. A fresh clone plus `npm install` builds and tests green on any machine or CI runner.
## 1. Source repo
[sources.vitanova.network/sdarbinyan/vitanovaPackages](https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git) — npm workspaces monorepo, `packages/auth` + `packages/payment`, source on `main`.
## 2. How releases work
npm cannot install a subdirectory of a git repo, so each package is published to its own **release branch** where the repo root *is* the package: `release/auth`, `release/payment`. Each contains only `package.json`, the built `dist/`, and a generated README.
```
"@marketplaces/auth": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/auth"
```
This was chosen over a registry because it needs **nothing**: no npm registry, no token, no tunnel, no CI secret. Anonymous git read is the only requirement, which is what makes CI and fresh clones work unattended.
`release/*` branches are generated and force-pushed. Never commit to them by hand.
## 3. Versioning
[Changesets](https://github.com/changesets/changesets). A PR that changes a package adds a changeset file (`npx changeset` at the repo root — pick package, bump type, one-line description). `ci.yml` rejects PRs without one.
## 4. CI/CD (vitanovaPackages)
- **`ci.yml`** — on PRs and non-main pushes: install, build, test, require a changeset.
- **`release.yml`** — on push to `main`, two jobs:
- `release-branches` (matrix over `auth`/`payment`): builds each package and force-pushes its output to `release/<pkg>`. Skips cleanly when nothing changed.
- `version-pr`: opens/updates a "Version Packages" PR when unreleased changesets exist. Merging it bumps versions on `main`, which re-triggers the release.
Only the checkout token is needed — no secrets to configure.
Workflows use GitHub Actions syntax; Gitea/Forgejo Actions are compatible. Other CI needs translating (steps are: install, build, test, force-push a branch).
## 5. The Verdaccio registry (superseded, still running)
A private Verdaccio instance runs on the dev server: Docker container `verdaccio`, port 4873, config and storage at `/srv/marketplaces/verdaccio/`, registry user `marketplaces-ci`. It holds `@marketplaces/auth@0.1.0` and `@marketplaces/payment@0.1.0`.
**Nothing uses it.** It was the original plan, but it listens on `127.0.0.1:4873` and the server firewall allows only 80/443/SSH — so no CI runner and no developer could reach it without an SSH tunnel, which defeats the point. The git-release-branch approach (§2) replaced it.
Keep it or remove it; no code or workflow depends on it. To reach it manually:
```bash
ssh -L 4873:127.0.0.1:4873 seto@213.21.246.138
```
Making it the primary path again would need a reverse proxy through nginx plus TLS (no certificate exists on that box), or an open port carrying credentials over plain HTTP — neither is done, and neither is necessary now.
## 6. Migration status
**Auth: done.** `@marketplaces/auth` holds the real implementation — `telegram/` (live QR/session auth, customer + admin) and `ed25519/` (challenge/response admin auth, backend not shipped). Environment coupling was replaced with `AUTH_API_URL`/`TELEGRAM_BOT_USERNAME` injection tokens; `environment.production` became Angular's `isDevMode()`. `AdminPermissionsService` and `requireAdminPermission` stayed in `marketplaces` (`core/admin-auth/`) — they read this app's mock Users domain, not a portable auth concern. All ~30 call sites import from the package; the old in-app auth files are deleted. Build, boundary checks, and 103/103 tests pass.
**Payment: not started.** `core/finance`/`core/pricing` still live in `marketplaces`. `@marketplaces/payment` is published as an empty scaffold and is not a dependency of anything.

126
docs/PACKAGES-USAGE.md Normal file
View File

@@ -0,0 +1,126 @@
# Using `@marketplaces/auth` and `@marketplaces/payment`
How to install and consume the shared packages in `marketplaces` or any other project. For *why* they exist see [ADR-0001](context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md); for how they are built and released see [PACKAGE-EXTRACTION.md](PACKAGE-EXTRACTION.md).
## 1. Install
Nothing to set up. The packages are installed straight over git from release branches in [vitanovaPackages](https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git), where the repo root *is* the package:
```json
"@marketplaces/auth": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/auth"
```
That is already in `marketplaces`' `package.json`, so a fresh clone plus `npm install` just works — **no npm registry, no auth token, no SSH tunnel, no CI secret.** Anonymous git read is the only requirement.
To add it to another project:
```bash
npm install "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/auth"
```
**On pinning:** a branch ref tracks the tip, so `npm install` can pick up a new build. That is deliberate while the package churns. For reproducible installs, replace `#release/auth` with a commit SHA. See [ADR-0001](context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md) on blast radius.
## 2. Required providers
`@marketplaces/auth` has no knowledge of any specific app's environment config. It reads two injection tokens, both provided by the consuming app in `app.config.ts`:
```ts
import { AUTH_API_URL, TELEGRAM_BOT_USERNAME } from '@marketplaces/auth';
import { environment } from '../environments/environment';
export const appConfig: ApplicationConfig = {
providers: [
{ provide: AUTH_API_URL, useValue: environment.authApiUrl },
{ provide: TELEGRAM_BOT_USERNAME, useValue: environment.telegramBot },
// ...
]
};
```
| Token | Required | Meaning |
|---|---|---|
| `AUTH_API_URL` | yes | Base URL of the auth backend, e.g. `https://api.example.com`. Both auth mechanisms build their endpoints from this. |
| `TELEGRAM_BOT_USERNAME` | no | Bot username for QR/deep-link login URLs. Falls back to a default if absent. |
Missing `AUTH_API_URL` produces `NG0201: No provider found for InjectionToken @marketplaces/auth AUTH_API_URL` at the first injection — including in unit tests, where any `TestBed` that constructs a component touching auth must provide it:
```ts
TestBed.configureTestingModule({
providers: [{ provide: AUTH_API_URL, useValue: 'https://test.local' }],
});
```
## 3. What is in the package
Two independent auth mechanisms. They deliberately share no state — a customer QR scan never authenticates an admin session or vice versa (distinct cookies, signals, guards, interceptors).
### `telegram/` — live today
Telegram QR/session auth against `{AUTH_API_URL}/users/sessions`. One backend endpoint set, used by both customer and admin login; only *storage* differs.
| Export | What it is |
|---|---|
| `AuthService` | Customer session. Signals: `session`, `status`, `isAuthenticated`, `showLoginDialog`, `displayName`. Methods: `checkSession()`, `createWebSession()`, `requestLogin()`, `hideLogin()`, `logout()`, `onTelegramLoginComplete()`, `getTelegramAppLoginUrl()`. Cookie `webSessionID`, `SameSite=Lax`. |
| `AdminAuthService` | Admin session. Same signal/method shape plus `getAdminToken()`/`setAdminTokens()`/`clearAdminTokens()` (reserved for when the backend issues admin JWTs) and `devBypassLogin()` (no-ops outside dev mode). Cookie `adminSessionID`, `SameSite=Strict`. |
| `TelegramSessionApiService` | Thin HTTP client + response normalization. Holds no state, writes no cookies. |
| `adminAuthGuard` | `CanActivateFn` — allows if the admin session is authenticated, otherwise opens the login dialog. |
| `adminAuthHeadersInterceptor` | Attaches `AdminWebSessionID` (and `Authorization: Bearer` when a token exists) to admin-gated paths only (`/admin/`, `/backoffice/`, `/builder/`, `/media/`). Never touches customer requests. |
| `AuthSession`, `WebSessionStart`, `AuthStatus`, `AdminAuthStatus` | Wire/state types. |
Typical usage:
```ts
import { AuthService, AdminAuthService, adminAuthGuard, adminAuthHeadersInterceptor } from '@marketplaces/auth';
// routes
{ path: 'backoffice', canActivate: [adminAuthGuard], loadComponent: ... }
// http
provideHttpClient(withInterceptors([adminAuthHeadersInterceptor, ...]))
// component
private readonly auth = inject(AuthService);
readonly isLoggedIn = this.auth.isAuthenticated; // signal
```
**Security note:** the Telegram session API has no concept of "admin." The frontend cannot distinguish an admin Telegram session from a regular one — it only decides *where to store* the result. Real admin authorization must be enforced server-side on every admin request. See [TRACK-S](backend/BACKEND-INTEGRATION.md).
### `ed25519/` — prepared, backend not shipped
Challenge/response admin auth: `GET /api/admin/auth/challenge` → sign nonce with a device-local non-extractable Ed25519 key → `POST /api/admin/auth/verify` → JWT pair. Calling these today 404s/connection-errors, which surfaces as the `backend-unavailable` error screen. Nothing is mocked.
| Export | What it is |
|---|---|
| `AuthFacade` | The surface components should use. `isAuthenticated`, `status`, `role`, `loginPhase`, `lastError`; `login(redirectTo?)`, `logout(redirectTo?)`, `restoreSession()`, `can(permission)`. |
| `Ed25519AuthService` | Low-level flow orchestrator (exported under this name so it doesn't collide with the telegram `AuthService`). |
| `SessionService` | JWT/refresh pair + derived claims, auto-refresh before expiry. |
| `Ed25519KeypairService` | WebCrypto Ed25519 keypair in IndexedDB. Private key is non-extractable and never leaves the device. |
| `PermissionService` | Derives permissions from the JWT `role` claim. UI-only gate. |
| `JwtService` | Decode only, never verification — the frontend has no trusted key; signature checking is the backend's job on every request. |
| `Ed25519VerificationService` / `NoopEd25519VerificationService` | Abstract seam + fail-closed default binding. |
| `AdminRole`, `Permission`, `ROLE_PERMISSIONS`, `AuthChallenge`, `AuthTokenPair`, `JwtClaims`, `AuthError`, `AuthErrorCode`, … | Types and wire contracts. |
Bind the verification seam in `app.config.ts`:
```ts
{ provide: Ed25519VerificationService, useClass: NoopEd25519VerificationService },
```
## 4. What deliberately stayed in the app
`AdminPermissionsService` and `requireAdminPermission` live in `marketplaces` (`src/app/core/admin-auth/`), not in the package. They read this app's mock Users domain to derive a permission set — app-specific, not a portable auth concern. If another project needs permission gating it should use the package's `PermissionService` (JWT-claim-driven) instead.
## 5. `@marketplaces/payment`
Published at `0.1.0` but **scaffold only** — no implementation yet, nothing exported, and `marketplaces` does not depend on it. `core/finance` and `core/pricing` still live in the app. Payment business logic is server-side by design (see [Phase 1](backend/BACKEND-INTEGRATION.md) and [Phase 7](backend/BACKEND-INTEGRATION.md)); the eventual package is a thin client for FX/pricing/checkout gateways.
## 6. Making a change to a package
1. Clone [vitanovaPackages](https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git).
2. Edit under `packages/auth/src` (or `packages/payment/src`), export from `index.ts`.
3. `npx changeset` at the repo root — pick the package and bump type, write one line about the change.
4. Commit, push, open a PR to `main`. CI builds, tests, and rejects the PR if the changeset is missing.
5. On merge, CI rebuilds and force-pushes `release/auth` / `release/payment`, and opens a "Version Packages" PR if there are unreleased changesets.
6. In `marketplaces`, run `npm update @marketplaces/auth`, then the build + test suite before merging.
Never commit to a `release/*` branch — they are generated and force-pushed. Never edit `node_modules/@marketplaces/*` — overwritten on every install.

View File

@@ -0,0 +1,479 @@
# Product Plan v3.1 — Delivery Plan (Phases → Sprints → Todos)
Companion to [PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md](PRODUCT-PLAN-v3.1-GAP-ANALYSIS.md). Every gap identified there is assigned here exactly once. Wire contracts for every `[BE]`/`[BOTH]` phase and track below are written up in [docs/backend/](backend/README.md) — hand that directory to whoever builds the backend.
**No calendar dates.** The plan itself (§12) refuses invented dates and fixes *sequence + exit criteria* instead. This document does the same. Sprints are ordered units of work, not two-week promises. Sizes are relative: **S** / **M** / **L** / **XL**.
**Ownership tags:** `[FE]` this repo · `[BE]` backend/platform service · `[BOTH]` coordinated contract change · `[DEC]` decision, no code.
**Deviation from the plan's own order, and why:** the plan sequences P0-C (external ingestion) before P0-D (catalog integrity). We swap them. External order ingestion maps `externalSKU → internal offer` (§5.1), and `Offer` does not exist yet — ingestion has nothing to map onto until the Product/Offer split ships. Everything else follows the plan's ordering.
---
## Phase map
| Phase | Name | Plan ref | Gate |
|---|---|---|---|
| **0** | Unblock & seams | — | Decisions answered; every admin domain swappable |
| **1** | Money & payment truth | P0-A, §2.3 §3.3 §3.8 §7 | An order total is explainable from data |
| **2** | Orders canonical + notifications | P0-B, §2.8 §2.10 §3.5 | Paid order appears and notifies without refresh |
| **3** | Catalog integrity + fulfillment | P0-D, §2.1 §2.4 §3.6 | Any published offer is genuinely buyable and fulfillable |
| **4** | External order ingestion | P0-C, §5 §3.7 | External purchase lands in Orders, no duplicates |
| **🚦** | **PRODUCTION LAUNCH GATE** | §3 LAUNCH BLOCKERS, §13.2 | All P0 closed and evidenced |
| **5** | Seller Portal | P1-A, §2.2 | Seller runs own offers and orders in scoped UI |
| **6** | Server cart + checkout session | P1-B, §2.5 §2.6 | Client price never trusted; repeat-safe |
| **7** | Payments hardening + reconciliation | P1-C, §2.7 §7.3 | Internal vs provider matched, mismatches visible |
| **8** | Identity & messaging | §2.9 §3.4 §14 | VK/MAX/Telegram linked; bot collects delivery |
| **9** | Tenant registry, domains, releases | P2-A, §4.3 §8 | New marketplace launched with no hardcode |
| **10** | Tenant content modules (Gorbushka) | P2-B, §11 | Content tenant on same runtime/backoffice |
**Parallel tracks** (start early, run across phases): **A** Analytics pipeline · **S** Security/RBAC/audit · **P** Partner provisioning API (P1P3 gate Phase 1) · **Q** QA & E2E · **N** API namespace migration · **Z** Pre-existing repo debt.
---
## Phase 0 — Unblock & seams
Nothing downstream can be honestly estimated until this closes. Two sprints: one is other people answering questions, one is work we can do today with no answers.
### Sprint 0.1 — Decisions `[DEC]`
**Answered 2026-08-17.** Kept as a record — the reasoning behind each answer still governs how later phases get built.
- [x] **Backend ownership — answered 2026-08-18.** A separate backend developer implements against `docs/backend/`. This repository's team owns the frontend and the contract set itself, which is why the contracts are the primary handoff artifact rather than a side deliverable.
- [x] **Unfreeze the payment chain — YES.** `BACKEND-API-REFERENCE.md §7`'s do-not-modify note no longer applies. Phases 1, 6, 7 are unblocked to proceed.
- [x] **External marketplaces — no fixed list.** User: connectors must onboard "our new ones, partners, new, etc." as they arrive — i.e. the platform's own future partner integrations, not a fixed enumeration of named third-party marketplaces to build against up front. **Consequence for Phase 4:** build the Sprint 4.1 connector framework generic/config-driven (auth, mapping, retry, dead-letter as pluggable per-connector config) so a new partner is an onboarding, not a code change. Sprint 4.2 ("one sprint per named marketplace") is retired as written — replaced by a generic "add connector" runbook, sized once the framework exists, not per-name up front.
- [x] **FX rate source — build our own, as a safety gate.** User: "not yet, lets handle from our side, if they dont" — no external provider is committed yet. Backend owns FX computation in-house as the authoritative source; the `source` field in the Phase 1 contract stays provider-agnostic and can point at an internal computed rate as legitimately as an external adapter. This *is* the "configured fallback" the contract doc's §3.2 already describes — now the default, not the fallback.
- [x] **§14 vs. email/phone OTP — VK ID first, then everything else.** User: "do all after vk." Delivery-plan Phase 8 sprint order changes: 8.3 (VK ID) now precedes 8.2 (OTP) — see Phase 8 below.
- [x] **Multi-seller orders — unified**, judgment call as instructed. One `Order` per checkout regardless of seller count, split into per-seller `Fulfillment` groups internally (matches §2.8's "canonical Order regardless of source" and §2.5's cart-level seller-grouping requirement without introducing parallel parent orders). Applies to Phase 3's `Offer` model, Phase 5's Seller Portal order view (scoped to that seller's fulfillment groups within the shared order), and closes the three-document disagreement flagged in Z16.
- [x] **"Fixed 5-second payment" claim — resolved as a non-issue.** User: "make polling 5 secs." Checked `config/constants.ts`: `PAYMENT_POLL_INTERVAL_MS` is already `5000`. This is a poll *cadence* against real provider status each tick, not an artificial fixed-delay-then-success — stays compliant with the plan's §3.2 prohibition. No code change needed; confirmed and left as-is.
- [x] **API namespace migration — adopt for new endpoints only, no forced migration.** User: unclear on the question, deferred to "what's recommended," noted "APIs are our domains" (i.e. we control the surface, lower urgency to force a big-bang rename). Recommendation taken: `backend/BACKEND-INTEGRATION.md` already specifies all-new endpoints under the `/api/v2/...` namespace family. Legacy endpoints (`/cart`, `/orders`, `/items`, etc.) stay as-is until a dedicated migration sprint is scheduled — not blocking Phase 1.
- [x] **Document version — v3.1 is canonical.** The source file's internal "3.0" version block is stale/wrong; all our docs treat v3.1 as authoritative going forward.
**Exit:** all nine answered in writing.
### Sprint 0.2 — Seams and type reconciliation `[FE]` — runs regardless of answers
- [ ] Add DI tokens to the 9 admin domains that have none: Orders, Products, Users, Transactions, Monitoring, Moderation (+ derived Customers, Analytics). **M** — hard prerequisite for every `[BE]` swap in Phases 17.
- [ ] Reconcile `AdminRole` — defined twice with unrelated shapes (auth string-union vs. Users-page display interface). **S**
- [ ] Reconcile the two `Category` types, both fed by the same `/category` response, both in use. **S**
- [ ] Resolve `SellerConfig` (bootstrap) vs. `Seller`/`SellerBranding` (domain) — pick one or document the mapping. Blocks Phase 5. **S**
- [ ] Build the feature-flag / capability-guard service an existing ADR already promises; migrate the hand-rolled `sellerManagement.enabled` check onto it. **S**
- [ ] Build the centralized error-handling layer (`core/error-handling/`, `core/interceptors/` are `.gitkeep`-only today): error-envelope interceptor + 429 handling. **M** `[BOTH]` — envelope shape needs backend agreement.
- [ ] Fix `toAuthErrorShape()` to read a body-level code, not HTTP status alone — the built "session expired" / "invalid signature" screens are currently dead UI. **S**
- [ ] Bind mock implementations to `PRODUCT_DATA_PROVIDER` and `CATEGORY_REPOSITORY`, or delete the dead mock branch. Today both silently ignore `useMockData`. **S**
**Exit:** any admin domain can be pointed at a real backend by swapping one provider.
---
## Phase 1 — Money & payment truth (P0-A)
Closes §3.3 and §3.8, and half of the §13.1 acceptance table. The single highest-value phase: it is what makes totals explainable to a bank.
### Sprint 1.1 — Money model `[BOTH]`
- [ ] `Money = { amountMinor: int, currency }` end to end. Kill float arithmetic in `CurrencyRatesService.convert()`. **L**
- [ ] Currency minor-units + rounding rules table (RUB/USD/EUR/AMD at minimum). **M**
- [ ] Delete browser-owned rates: remove `currencyRates.v1` from `localStorage` and the hardcoded `DEFAULT_RATES` fallbacks (`USD: 0.011`, `AMD: 4.3`). **S**
- [ ] Remove the admin-typed rate editor from Admin Settings once a real source exists. **S**
### Sprint 1.2 — FX quote + rate source `[BE]` + `[FE]`
- [ ] `FxQuote { base, quote, rate, source, observedAt, expiresAt, quoteId }` entity + endpoint. **M**
- [ ] Rate-source adapter behind an interface; concrete provider pluggable (§7.1). **M**
- [ ] Stale/outlier quote rules; checkout **blocks** or uses an explicitly configured fallback. **M**
- [ ] `PriceBook`: offer base currency + allowed display/checkout currencies per tenant. **M**
### Sprint 1.3 — Price snapshot + server-authoritative amount `[BOTH]` — needs the freeze lifted
- [ ] `PriceSnapshot { offerId, amount, currency, fxQuoteId, capturedAt }`, immutable. **L**
- [ ] Server computes and validates the charged amount. Stop trusting `CartPaymentRequest.amount` and the per-item `price[]` array from the browser. **L** — the plan's §2.5 headline requirement.
- [ ] Old orders never recalculated when a rate updates. **S**
- [ ] Backoffice "total formula" panel: lines × qty discounts + delivery + fees, plus the FX quote used (§7.2). **M**
- [ ] `PriceHistory` on offer price and stock, with author/source (§2.1). **M**
### Sprint 1.4 — Payment timeline `[BE]` + `[FE]`
- [ ] Explicit state machines: `PaymentIntent` (created→pending→authorized/paid→failed/cancelled), `Payment` (received→confirmed→captured/settled→refunded), `Order` (pending_payment→paid→processing→fulfilled). **L**
- [ ] Persist `provider event id`, `provider timestamp`, `receivedAt`, `processedAt` per transition. **M**
- [ ] Webhook entrypoint with signature verification + idempotency (§2.7). **L**
- [ ] Idempotency keys on checkout, payment and order creation. Zero `idempot*` exists today. **M**
- [ ] Replace client-polled status signals with server truth; keep polling only as a UI fallback. **M**
- [ ] Keep the current honest behaviour: no artificial delay. Already compliant — protect it with a test. **S**
**Exit criteria (plan's own):** currency converts correctly; payment timeline reconstructable from provider events; every total explainable from `SKU/qty/delivery/discount/FX`.
---
## Phase 2 — Orders canonical + notifications (P0-B)
### Sprint 2.1 — Canonical order model `[BOTH]`
- [ ] `Order` header: `marketplaceId, source, customer, currency, subtotal, discounts, delivery, total, paymentStatus, orderStatus`. **L**
- [ ] `OrderLine` with `offerId, sellerId, skuSnapshot, titleSnapshot, qty, unitPriceMinor, lineTotalMinor, priceSnapshotId`. **M**
- [ ] `OrderEvent` timeline: created, paid, seller notified, accepted, fulfilled, cancelled, refunded (§2.8). Closes our own "Real order audit trail" TODO. **M**
- [ ] Real `AdminOrdersApiGateway` replacing the 24-row static seed with no create path. **L** `[BE]`
- [ ] Admin order actions: assign, resend notification, replay sync, cancel/refund by permission, comment, export. **M**
- [ ] `OrderContactSnapshot` — name/contacts frozen at order time, immune to later profile edits (§2.9). **S**
### Sprint 2.2 — Event bus + Notification Center `[BE]` + `[FE]`
- [ ] Platform event bus emitting `order.created`, `order.paid`, `payment.failed`, `webhook.error`, `stock.low`, `oversell`, `refund.requested/completed`, `external_order.imported`. **L**
- [ ] `Notification` entity: `unread/read`, `severity`, `marketplaceId`, entity type/id, **deep link**. **M**
- [ ] `DeliveryAttempt` log per external channel — a Telegram/email failure must never lose the internal notification (§2.10). **M**
- [ ] Backoffice Notifications section: unread queue, incidents, filter by marketplace and event type. Missing entirely from our nav today. **M**
- [ ] Repoint `AdminOrderWatcherService` from polling to the event stream. Feature is already built and inert — this is what switches it on. **S**
**Exit:** a paid order appears in backoffice without manual refresh, with deep link and seller/source.
---
## Phase 3 — Catalog integrity + fulfillment (P0-D)
Biggest structural change in the whole programme. Everything about multi-seller commerce hangs off it.
### Sprint 3.1 — Product / Offer split `[BOTH]`
- [ ] Introduce `Offer/Listing { id, marketplaceId, sellerId, variantId, sellerSku, priceMinor, currency, stockPolicy, status, publishedAt }`. **XL** — does not exist in any form today.
- [ ] Move price, stock, currency and status off `Product` onto `Offer`. **L**
- [ ] Formalise `Product` / `Variant` / `SKU` / `Category` (with `attributesSchema`, SEO) as content-only. **L**
- [ ] Unify the admin mock product domain with the live storefront `Item` domain — two unrelated shapes today. **L**
- [ ] Offer lookup in backoffice by internal SKU, seller SKU, product ID or external mapping (§2.1 "готово, когда"). **M**
### Sprint 3.2 — Lifecycle, import, inventory `[BOTH]`
- [ ] `draft → moderation → published → paused/archived` for both product and offer; wire the existing mock Moderation module to it. **M**
- [ ] Bulk import CSV/API: required-field validation, **error preview before apply**. Nothing exists (current "bulk" is Admin Categories edit actions only). **L**
- [ ] `InventoryRecord`: `available` / `reserved` / `sold` counted separately. **L**
- [ ] Reservations at checkout or pre-payment per strategy, with TTL. **M**
- [ ] Idempotent upsert for seller feed stock updates; repeat webhook must not double-decrement. **M**
- [ ] Oversell → dedicated incident queue, never silently hidden (§2.4). **M**
### Sprint 3.3 — Fulfillment + executability `[BOTH]`
- [ ] `Fulfillment` entity: manual / warehouse / pickup / digital; `status, assignedTo, issuedAt/shippedAt`, evidence where applicable. One `fulfil*` reference exists in the entire codebase today. **L**
- [ ] Publish-time executability validation — an offer that cannot actually be fulfilled cannot be published (§3.6). **M**
- [ ] Explicit test proving there is **no** inspector-detection branch anywhere: same production flow for every buyer (§3.6, §10.2, §13.2 last item). **S**
- [ ] Multi-seller cart grouping by seller and fulfillment rules — currently undefined behaviour (§2.5). **M****Sprint 0.1 decision (2026-08-17): unified.** One `Order` per checkout regardless of seller count; group lines into per-seller `Fulfillment` entries internally, no parallel parent orders.
**Exit:** any published, available offer really passes order → fulfillment.
---
## Phase 4 — External order ingestion (P0-C)
Zero percent built today. **Sprint 0.1 decision (2026-08-17): no fixed marketplace list** — connectors onboard "our new ones, partners, new, etc." as they arrive, not a pre-named enumeration. Sprint 4.2 is retired as originally written ("one sprint per named marketplace") and replaced with a generic onboarding runbook — Sprint 4.1's framework is now the deliverable that matters, sized to be genuinely config-driven rather than one-off per provider.
### Sprint 4.1 — Connector framework `[BE]`
- [ ] `Connector` + `ConnectorCredentialRef` in secret storage, scoped per marketplace/seller. **M**
- [ ] Inbound: webhook where the provider supports it, polling fallback with cursor/since. **L**
- [ ] `RawExternalEvent` — persist the raw payload before parsing, for traceability. **S**
- [ ] Normalizer: external payload → canonical `ExternalOrderEvent` → internal `Order`. **L**
- [ ] `ExternalOrderMapping`: `externalSellerId / externalProductId / externalSKU → internal seller/offer`. **L**
- [ ] Idempotency on `source + externalOrderId/eventId`; a repeat must not create a duplicate order. **M**
- [ ] Exponential retry, `DeadLetter`, manual replay from backoffice. **M**
- [ ] **Unmatched queue** for events with no SKU mapping. **M**
- [ ] Status/fulfillment push back to the external marketplace where its API allows (§5.2 step 8). **M**
- [ ] **Config-driven adapter contract** — a new partner connector is authored as configuration (auth type, field mapping, rate limits) against the Sprint 4.1 framework, not a bespoke integration each time. **L** — this is what "no fixed list" requires structurally.
### Sprint 4.2 — Connector onboarding runbook `[BE]` — repeats per new partner, no longer named up front
- [ ] Generic onboarding checklist against the Sprint 4.1 framework: auth, endpoint mapping, rate limits, sandbox verification. **M each**, sized down from **L** now that the framework absorbs the bespoke work.
### Sprint 4.3 — Connector observability `[FE]` + `[BE]`
- [ ] Backoffice **Integrations** section (missing from our nav): connectors, payment providers, FX sources, messaging. **M**
- [ ] Per-connector health: last success, lag, errors, rate limit, backlog, unmatched mapping. **M**
- [ ] Trace id on every connector error, visible in backoffice (§5.2 SLA). **S**
- [ ] SLA instrumentation: webhook 99% under 60s; polling ≤ interval + 60s; **0** duplicate orders. **M**
**Exit:** an external purchase creates/updates an order automatically, never duplicates, and notifies the responsible manager.
---
## 🚦 PRODUCTION LAUNCH GATE
Per §3 "LAUNCH BLOCKERS" and the §13.2 checklist. Do not schedule a launch before every line is green **and evidenced by a test, not an assertion**.
- [ ] All P0 closed and confirmed by tests
- [ ] Production analytics collecting real events (Track A)
- [ ] Catalog contains only genuinely available/publishable offers
- [ ] Seller permissions verified (Phase 5 or enforced-empty)
- [ ] cart → checkout → payment → order end-to-end smoke passed
- [ ] Webhook signatures, idempotency, retry verified
- [ ] External connector reconciliation passed
- [ ] FX source live, stale-quote policy verified
- [ ] Notification delivery + fallback verified
- [ ] Refund flow + reconciliation smoke passed
- [ ] Domains/SSL/health checks green (Phase 9)
- [ ] Backup/rollback exists
- [ ] Audit enabled (Track S)
- [ ] **No branch anywhere alters commerce flow based on who the buyer appears to be**
---
## Phase 5 — Seller Portal (P1-A)
A placeholder page with a `false` flag and zero backend bytes today. Note: the enabled code path has **never been exercised even once** — every prior verification ran with the flag at its real value.
### Sprint 5.1 — Seller foundation `[BOTH]`
- [ ] `SellerOrganization`, `SellerUser`, `SellerMarketplaceMembership`, `SellerIntegration`. **L**
- [ ] Onboarding: organisation, credentials/profile, contacts, marketplace applications, moderation status. **L**
- [ ] Backoffice **Sellers** section (missing from nav): organisations, applications, roles, status, listings, integration health. **L**
### Sprint 5.2 — Seller working surfaces `[FE]` + `[BE]`
- [ ] Catalog: create/edit products & offers, media, attributes, submit for moderation, bulk import. **L**
- [ ] Prices & Stock: mass edit, API/feed sync, change history, sync errors. **L**
- [ ] Orders: new, confirm, pick/issue/ship, cancel, return, SLA, comments. Per the unified-orders decision (Sprint 0.1), this view is scoped to *this seller's* `Fulfillment` group within each shared `Order`, not a separate seller-owned order. **L**
- [ ] Finance: accruals, commissions, refunds, settlement/payout register, report export. **L**
- [ ] Team: `SELLER_OWNER`, `SELLER_CATALOG_MANAGER`, `SELLER_ORDER_MANAGER`, `SELLER_FINANCE_VIEWER`, `SELLER_VIEWER`. **M**
- [ ] Integrations: API credentials, webhook/feed status, external SKU mapping, sync logs. **M**
### Sprint 5.3 — Seller isolation `[BE]` + `[Q]`
- [ ] A seller cannot see another seller's products, orders, customers, finance or API keys — enforced backend-side, tested. **M**
- [ ] Bank/payment detail changes: step-up auth + audit event + approval when maker/checker is on. **M**
- [ ] Seller staff permissions verified backend-side regardless of UI visibility. **M**
- [ ] First-ever fixture test of the seller-management enabled state. **S**
---
## Phase 6 — Server cart + checkout session (P1-B)
Partly pulled forward into Sprint 1.3 (server-authoritative amount). This phase completes the move.
### Sprint 6.1 — Server cart `[BOTH]`
- [ ] `Cart` / `CartLine` server-side, keyed on `offerId`. Replaces `localStorage` + Telegram CloudStorage. **L**
- [ ] Idempotent add/update/remove; quantity validated against stock and seller rules. **M**
- [ ] Price-refresh: cart surfaces price changes before checkout and requires explicit confirmation when the total moved. **M**
- [ ] Guest cart via session token; authenticated cart bound to customer account. **M**
- [ ] Expiration: inactive carts cleared, reservations released on TTL. **S**
### Sprint 6.2 — Checkout session `[BOTH]`
- [ ] `CheckoutSession` entity. `features/website/checkout/` is an empty directory today; checkout lives in a 751-line cart popup. **XL**
- [ ] Server re-validates offers and stock at checkout start. **M**
- [ ] Contact requirements enforced by tenant policy: email and/or phone verifiable (§2.6 step 4). **M**
- [ ] Clear total breakdown shown to the customer. **M**
- [ ] `PaymentIntent` via provider adapter; repeat click must not create a second intent. **M**
- [ ] Guest-checkout on/off per tenant policy (§6.2). **S**
- [ ] `DeliveryOption` entity. **M**
---
## Phase 7 — Payments hardening + reconciliation (P1-C)
### Sprint 7.1 — Refunds `[BOTH]`
- [ ] `Refund` as a first-class operation with reason, actor and order-line linkage. `requestRefund(id)` is a mock method today. **L**
- [ ] Partial refunds; `refunded / partially_refunded` states. **M**
### Sprint 7.2 — Reconciliation `[BE]` + `[FE]`
- [ ] `ReconciliationRecord`; match on `providerPaymentId` / merchant reference / amount+currency fallback (§7.3). **L** — zero `reconcil*` in the codebase today.
- [ ] Classify: unmatched, duplicate, amount mismatch, status mismatch. **M**
- [ ] Backoffice **Payments & Finance** section (missing from nav): payments, refunds, reconciliation queue, unmatched events, settlements. **L**
- [ ] Controlled resolution with full audit trail. **M**
- [ ] Settlements / payout register. **L** — zero `settlement*` today.
### Sprint 7.3 — Provider breadth `[DEC]` + `[BOTH]`
- [ ] Decide additional providers beyond the current QR/card flow (wallets, BNPL) — open business question. **DEC**
- [ ] Provider adapter interface so a new provider is a plug-in, not a rewrite. **M**
---
## Phase 8 — Identity & messaging (§2.9, §3.4, §14)
**Sprint 0.1 decision (2026-08-17): VK ID first, then everything else** ("do all after vk"). Order below is resequenced accordingly — VK ID moved ahead of OTP.
### Sprint 8.1 — Customer identity core `[BOTH]`
- [ ] `Customer`, `ExternalIdentity`, `ContactMethod`, `Verification`, `Consent`. **L**
- [ ] Telegram demoted from sole identity to one provider among several. **M**
- [ ] `emailVerifiedAt` / `phoneVerifiedAt` / `telegramLinkedAt`. **S**
- [ ] Backoffice **Customers** on real data: profiles, verified contacts, orders, consent. **M**
- [ ] Sensitive profile changes logged. **S**
### Sprint 8.2 — VK ID `[BOTH]` — new in v3.1, now first per Sprint 0.1
- [ ] OAuth 2.1/PKCE completed **backend-side**; link external identity to `Customer`. **L**
- [ ] VK ID as the primary storefront social login. **M**
- [ ] Repeat login must never create a duplicate customer. **M**
- [ ] Identity-conflict handling → controlled resolution, never overwrite an existing binding (§14.3). **M**
### Sprint 8.3 — Email/phone OTP `[BOTH]` — after VK ID
- [ ] Implement the approved [email/phone login spec](superpowers/specs/2026-08-15-email-phone-login-design.md). **L**
- [ ] Position it as recovery/fallback per v3.1 §14, not as the primary path. **S**
### Sprint 8.4 — MAX + Telegram bot channels `[BOTH]` — new in v3.1
- [ ] `ContactChannel`, `BotConversationBinding`, `MessagingConsent`. **L**
- [ ] MAX bot-assisted linking: one-time code, TTL, single-use, bound to marketplace + browser session. **L**
- [ ] Provider secrets never reach the frontend; all bot updates handled idempotently. **M**
- [ ] Bot adapters (VK / MAX / Telegram) normalised into one `MessagingEvent` keyed to `orderId`. **L**
### Sprint 8.5 — Notification Orchestrator + delivery conversation `[BE]` — new in v3.1
- [ ] Orchestrator routes `order.paid` to the customer's chosen channel; the backoffice notification always fires regardless. **L**
- [ ] Channel choice in checkout ("where should we send confirmation?"), recorded in `OrderContactSnapshot`; linking flow must not lose the cart or checkout session. **M**
- [ ] Delivery Conversation State Machine: `not_started → awaiting_customer → details_received → manager_assigned/auto_confirmed → shipment_planned → completed`. **L**
- [ ] Bot collects city/address/recipient/phone/time window/comment; backend validates and snapshots into the order. **L**
- [ ] **The bot must never change financial statuses** — delivery fields only, via Delivery Service. **M**
- [ ] Follow-up rules per tenant; after N attempts hand off to a manager, no infinite spam. **M**
- [ ] Manager handoff view: message history, current conversation state, accept handoff. **M**
- [ ] Messenger unavailability creates a `DeliveryAttempt` error and triggers fallback — never blocks the order. **M**
---
## Phase 9 — Tenant registry, domains, releases (P2-A)
### Sprint 9.1 — Marketplace Registry `[BOTH]`
- [ ] `Marketplace`, `MarketplaceDomain`, `MarketplaceFeatureSet`, `MarketplaceRevision`. **L**
- [ ] Backoffice **Marketplaces** section (missing from nav): registry, type, status, domains, currencies, feature set, responsible manager. **L**
- [ ] Onboarding wizard, all 8 steps of §4.3 (card → feature set → domains → design → roles → integrations → staging + smoke → production launch). **XL**
- [ ] Lifecycle state machine `draft → configured → content_ready → domains_planned → staging_live → qa_passed → production_ready → live → paused/archived`, **showing which blocker prevents the next transition**. **L**
- [ ] Marketplace dashboard (§4.2): GMV, paid orders, conversion, payment failure rate, orders needing action, seller moderation queue, low stock, unmatched events, integration health, domain/SSL/release status. **L**
- [ ] Re-scope the [super-admin Phase 1 design](superpowers/specs/superuser.md) against this — it overlaps registry and audit. **M**
- [ ] Consolidate `MarketplaceRef` vs. `TenantConfig` if a third marketplace-shaped type appears. **S**
### Sprint 9.2 — Domain automation `[BE]`
- [ ] Hostinger DNS integration, all 7 endpoints from §8.2. Zero references exist today. **L**
- [ ] Read current zone → snapshot/rollback payload → build and validate plan → apply only after production approval. **L**
- [ ] **Never touch MX/SPF/DKIM/DMARC/CAA** without a separate task. **S**
- [ ] Propagation, SSL and health verification; mark domain active only after checks pass. **M**
- [ ] Backoffice **Domains & Releases** section (missing from nav). **M**
### Sprint 9.3 — Publish model `[BOTH]`
- [ ] `draft → validation → preview → publish` with immutable published revisions; rollback creates a new revision (§8.3). **L**
- [ ] Real builder persistence — today `apiEndpoints.builder` is an empty placeholder and "publish" only promotes a `localStorage` signal. **L**
- [ ] CMS/static pages get a real backend write path (currently in-memory bootstrap only). **L**
- [ ] Enforce that orders/payments/inventory ledger are **not** part of a content revision and never roll back with the storefront. **S**
- [ ] Tenant resolution hardening: verified Host server-side, unknown Host → 404 with **no fallback tenant** (§6.1). **M**
---
## Phase 10 — Tenant content modules (P2-B, Gorbushka)
Only after Commerce Core is real. The plan is explicit that Gorbushka does not define the architecture.
### Sprint 10.1 — Directory content entities `[BOTH]`
- [ ] `Shop`, `ShopCategory`, `Service`, `Floor`, `SchemePin`, `RentListing`, `News/Promo`, `StaticPage`, `Lead`, `MallSettings`. Only static pages exist today. **XL**
- [ ] Every entity carries `marketplaceId`, audit, and publish/preview flow. **M**
- [ ] Mall scheme / floors / pins UI. **L**
- [ ] Rent listings + lead capture. **M**
### Sprint 10.2 — Gorbushka tenant config `[FE]`
- [ ] Feature set per §11.1: CMS, shops, services, scheme, rent, news, SEO/media/domains **on**; catalog / seller portal / commerce **platform-ready but off**. **M**
- [ ] Prove commerce can be switched on later without touching backend or storefront code. **M**
---
## Parallel tracks
### Track A — Analytics pipeline (P1-D, §3.1 §6.3)
**Start at Phase 1, not last.** Longest lead time in the programme, and it is a P0 in the plan's own §3. There is no tracking infrastructure at all today — this is not a missing endpoint.
- [ ] **A1** Server-side event logging spine. **XL** `[BE]`
- [ ] **A2** Traffic events: `session_started`, `page_view`, source/utm/referrer, unique users/sessions. **M**
- [ ] **A3** Catalog events: `search`, `category_view`, `product_view`, `seller_view`. **M**
- [ ] **A4** Commerce events: `add_to_cart`, `cart_view`, `checkout_started`, `payment_started`, `payment_success/failed`, `order_created`. **M**
- [ ] **A5** Operations metrics: `order_paid_to_notification` latency, fulfillment time, connector lag, payment webhook lag. **M**
- [ ] **A6** Quality metrics: frontend/backend errors, checkout validation failures, FX stale-rate blocks. **M**
- [ ] **A7** Real funnel dashboard in backoffice, replacing the mock-composed Analytics facade. **L**
- [ ] **A8** **Synthetic traffic technically separated** from production analytics — staging/test only, never presented as real visits (§3.1, §6.3). **M**
- [ ] **A9** Real product view counts — the shipped "Views" column always renders `0`. Either bridge to the live storefront `Item.visits` or serve it from the real Products backend. **S**
- [ ] **A10** Post-launch monitoring set (§13.3): checkout conversion, payment success/failure, webhook lag, order-notification lag, connector lag, FX quote age, unmatched reconciliation, stuck fulfillment. **L**
- [ ] **A11** Trending search terms endpoint — `loadTrending()` is a stub returning `of(null)`. **S**
### Track S — Security, RBAC, audit (§4.4, §10)
**Gate on Phase 5 and on the launch gate.** Today the role model is decorative: types exist, nothing gates any button, page or action. Anyone who authenticates has full access.
- [ ] **S1** Enforce RBAC backend-side with tenant scope on every request. **L**
- [ ] **S2** Implement the 17 roles across 3 scopes (5 platform / 7 marketplace / 5 seller). **L**
- [ ] **S3** Frontend permission guards on routes and actions — currently zero. **M**
- [ ] **S4** Audit log covering permissions, seller changes, catalog moderation, price, payment/refund, manual order actions, integrations, production launch. `audit` appears only as mock display fields today. **L**
- [ ] **S5** Backoffice **Audit & Security** section (missing from nav): role changes, sensitive actions, login/security events, exports. **M**
- [ ] **S6** Step-up authentication for sensitive financial actions. **M**
- [ ] **S7** Rate limits and abuse controls on storefront/auth/provider endpoints; client-side 429 handling (zero today). **M**
- [ ] **S8** Secret storage for provider/connector credentials, scoped per marketplace/seller. **M**
- [ ] **S9** PII minimisation: store only necessary customer data, restrict access and export. **M**
- [ ] **S10** Ed25519 admin auth backend — wired client-side, 404s today. Decide: build it, or drop it for the plan's conventional RBAC. **DEC** + **L**
- [ ] **S11** HttpOnly session cookie (existing frontend-blocked TODO). **M**
### Track P — Partner provisioning API (added 2026-08-18)
Inbound partner API for programmatic merchant-hierarchy management. Contract: [backend/BACKEND-INTEGRATION.md](backend/BACKEND-INTEGRATION.md). Decision: [ADR-0003](context/adrs/ADR-0003-generic-partner-provisioning-api.md).
**P1P3 gate Phase 1.** They change the payments and tenant schemas, so they must land before Phase 1 is implemented — retrofitting a routing dimension onto a populated payments table costs far more than carrying it from the first row. P4 onward can run any time after.
- [ ] **P1** Add `RoutingContext` to `CheckoutSession`/`PaymentIntent`/`Payment` ([Phase 1 §6.5](backend/BACKEND-INTEGRATION.md)) and to `Refund`/`ReconciliationRecord` ([Phase 7](backend/BACKEND-INTEGRATION.md)). Frozen at checkout-session creation, immutable after. **M**
- [ ] **P2** Add `Company` and `Project` above `Marketplace`; `Marketplace` gains `companyId`/`projectId`/`externalReference` ([Phase 9 §1](backend/BACKEND-INTEGRATION.md)). **M**
- [ ] **P3** Add `PaymentPoint` (one payment method per marketplace; `qr` and `card` both ship today) and backfill existing marketplaces per [Phase 9 §1.2](backend/BACKEND-INTEGRATION.md). **M**
- [ ] **P4** Provisioning endpoints: create/read/status/disable for project, store, payment point, with cascading disable. **L**
- [ ] **P5** Idempotency-Key handling: replay on identical body, `409` on same key + different body, in-flight collision, 24h retention. **M**
- [ ] **P6** Partner credentials: public-key registration, node-scoped authority, signed-request verification, rotation with overlap, immediate revoke ([Track S §4.1](backend/BACKEND-INTEGRATION.md)). **L**
- [ ] **P7** Read surfaces: full-hierarchy fetch, `externalReference` lookup, partner-scoped audit query. **M**
- [ ] **P8** `PartnerProfile` config: required levels, level aliases, routing field names, rate tier, rotation window. Onboarding a partner must be a config row, not a deployment. **M**
- [ ] **P9** TEST/LIVE partition: disjoint credentials, disjoint ids, `403` on cross-environment access. **M**
- [ ] **P10** Partner OpenAPI spec generated from the implementation, plus documented error codes and published rate limits. **M**
### Track Q — QA & E2E (§13)
The plan's entire Definition of Done is end-to-end. We have **zero** E2E tests and ~32% statement / ~19% branch coverage across 11 spec files.
- [ ] **Q1** Stand up an E2E harness (Playwright or equivalent) — none exists. **L**
- [ ] **Q2** Solve automated admin login; several past "verified live" claims were code-inspection only because `/edit` and `/backoffice` need Telegram login. **M**
- [ ] **Q3** E2E: full §13.1 acceptance path — seller → catalog → storefront → cart → checkout → payment → order → notification → fulfillment. **XL**
- [ ] **Q4** E2E: currency switch recalculates by FX quote — explicitly, `160 RUB` must not become `160 USD/AMD`. **M**
- [ ] **Q5** E2E: repeat webhook and double-click create exactly one order. **M**
- [ ] **Q6** E2E: external marketplace purchase imports and notifies. **M**
- [ ] **Q7** Facade tests for cart/checkout, moderation, Orders, Products, Users, Transactions, Monitoring — the domains about to get real backends carry the most regression risk with the least coverage. **L**
- [ ] **Q8** Regression pattern for reactive flag/config reads that must track `bootstrapRevision()` — this bug class already bit us once and was invisible until specifically hunted. **S**
- [ ] **Q9** Set a justified coverage floor and a CI gate. Deliberately unset today. **M**
- [ ] **Q10** One real screen-reader pass (NVDA/VoiceOver). Never performed on this codebase — every accessibility claim to date is automated tree inspection only. **M**
### Track N — API namespace migration (§9.3)
Cheapest now, more expensive every phase. Decision in Sprint 0.1.
- [ ] **N1** Adopt `/api/v2/storefront/*`, `/api/admin/v2/*`, `/api/seller/v1/*`, `/api/identity/v1/*`, `/api/providers/v1/*`, `/api/integrations/v1/*`. **L** `[BOTH]`
- [ ] **N2** Migrate today's flat unversioned endpoints (`/cart`, `/orders`, `/items`, `/category`, `/searchitems`) plus the separate `qrApiUrl` host. **L**
- [ ] **N3** Agree the structured error envelope; today no interceptor reads error bodies at all. **M** (implementation lands in Sprint 0.2)
### Track Z — Pre-existing repo debt
Not in the plan, but real. Fold into whichever phase touches the same surface.
- [ ] **Z1** Dark-mode selector does nothing — nothing reads `data-theme-mode`. **S**
- [ ] **Z2** "Site Layout" selector has no effect — `layout.type` is edited but never read. **S**
- [ ] **Z3** Footer "Contacts" link has no content behind it. **S**
- [ ] **Z4** `SeoService.setItemMeta()` exists but is **never called** — product pages ship only site-wide meta. **S**
- [ ] **Z5** `og:locale` hardcoded to `ru_RU` regardless of active locale. **S**
- [ ] **Z6** No JSON-LD structured data, no sitemap generation. **M**
- [ ] **Z7** Hardcoded Russian payment-description fallback (`'Покупка на Маркетплейсе'`) in a multi-tenant product. **S**
- [ ] **Z8** Brand colours fail WCAG AA — `--border-color` at 1.241.42:1 against a 3:1 requirement; status colours fail 4.5:1 as text. **Needs theme-owner sign-off, not just a code fix.** **M**
- [ ] **Z9** Literal hex `#cdd6d5` in `stars.component.scss:10` with no token behind it. **S**
- [ ] **Z10** Two large lazy chunks unaddressed: `project-editor` (~1.0 MB), `catalog-container` (~330375 kB). Profile under real backend latency, not instant mock responses. **M**
- [ ] **Z11** `navigation.header` is editable in the builder with zero runtime consumer — needs a product decision, not a wiring fix. **DEC**
- [ ] **Z12** `catalog.navigationMode` renders a deliberate placeholder; the mega-menu / carousel / left-nav variants it implies do not exist. **DEC**
- [ ] **Z13** `sellerId` typed as bare `string` instead of the `UUID` alias used elsewhere. **S**
- [ ] **Z14** No shared breadcrumb component; the only breadcrumb logic is a local signal in the catalog container. **S**
- [ ] **Z15** Duplicate search models under two module paths. **S**
- [ ] **Z16** Consolidate the eight cross-linked Seller Management documents onto the now-resolved decision (unified orders, Sprint 0.1, 2026-08-17) — at least three independently restated the question before it was answered. Do this **before** Phase 5 starts. **M**
- [ ] **Z17** Angular 22 upgrade — researched, not started; needs a dependency fix and a Node bump. **Its own dedicated session, never bundled with feature work.** **M**
---
## Critical path
```
Sprint 0.1 (decisions)
└─> Sprint 0.2 (seams)
└─> Phase 1 (money truth) ──────────────┐
└─> Phase 2 (orders + notif) │
└─> Phase 3 (offer split) │
└─> Phase 4 (external ingestion)
└─> 🚦 LAUNCH GATE
Track A (analytics) ── starts at Phase 1, gates the launch ──┘
Track S (RBAC/audit) ── starts at Phase 2, gates the launch ──┘
Track Q (E2E) ── starts at Phase 1, evidences the gate ┘
```
Phases 510 all sit behind the launch gate and can be resequenced by business priority. Phases 14 cannot.
**Single hardest dependency:** Phase 1 Sprint 1.3 needs the payment chain unfrozen. If that answer is "no", the programme stops at Sprint 0.2 and the plan's P0s cannot be delivered — that outcome should go back to them in writing, not be worked around.

View File

@@ -0,0 +1,279 @@
# Product Plan v3.1 — What They Want vs. What We Have
**Source:** `Marketplaces-Platform-Product-Plan-v3.1.pdf` (27 pages, RU). Version block inside still reads `3.0 / 17 августа 2026` — the filename says v3.1. Section 14 is the v3.1 addition (appended after the document's own conclusion).
**Our side, as verified in this repo:** Angular frontend only (426 `.ts` files). Sources for "what we have": [BACKEND-API-REFERENCE.md](../BACKEND-API-REFERENCE.md), [GAPS-AND-IMPROVEMENTS.md](../GAPS-AND-IMPROVEMENTS.md), and direct source inspection.
---
## 1. What they are actually asking for
One sentence: **stop building storefronts, build a platform** — a single multi-tenant commerce core where launching a new marketplace is a configuration act, not an engineering project.
Their own acceptance bar (§"ГЛАВНЫЙ КРИТЕРИЙ" and §13):
> A real product walks the whole path: seller → catalog → storefront → cart → checkout → payment → order → notification → fulfillment → reconciliation.
Three things the document is really about, under the product language:
1. **They do not trust our numbers.** Traffic counters, payment timings, order totals and currency amounts are all called out as unexplainable. §10.2 says it outright: don't fix appearance, fix the data.
2. **They suspect demo behaviour in production.** "No fixed 5-second payment", "no synthetic traffic in production analytics", "no special branch for banks/inspectors" (§3.2, §3.1, §3.6, §10.2, and again in the launch checklist). This is an audit/compliance posture, not a feature request — a bank or NSPK is checking this platform.
3. **Commerce Core is no longer optional.** In v3.0 language, Catalog/Seller Portal/Cart/Checkout/Payments/Orders stopped being "a possible extension" and became mandatory platform modules. Gorbushka is demoted to "one tenant scenario" (§11) — it does not define the architecture.
**Launch blockers they define (§3, "LAUNCH BLOCKERS"):** all P0s — money/FX, payment timeline, notifications, external order ingestion, price traceability, guaranteed fulfillability of published offers.
---
## 2. What is new in v3.1 vs v3.0
Everything in **§14 "Customer Identity и коммуникация после покупки"** (pages 2627). Nothing else in the document is marked as changed.
| New in v3.1 | Detail | Our state |
|---|---|---|
| **VK ID as primary social login** | Backend completes OAuth 2.1/PKCE, links external identity to `Customer` | Zero. No `vk` reference anywhere in source; one `oauth` reference total. |
| **MAX messenger bot** | Bot-assisted account linking via one-time code; official MAX Bot API | Zero. |
| **Telegram demoted** | Kept, but as *one* identity provider among several | Today Telegram is the **only** login for both customers and admins. |
| **Notification Orchestrator** | Routes `order.paid` to the customer's chosen channel; backoffice notification always fires even if the messenger is down | Zero. |
| **Delivery Conversation State Machine** | `not_started → awaiting_customer → details_received → manager_assigned/auto_confirmed → shipment_planned → completed`, bot collects delivery details, manager handoff | Zero. |
| **Channel choice in checkout** | "Where should we send confirmation?" — VK / MAX / Telegram / email-SMS fallback, recorded in `OrderContactSnapshot` | Zero. |
| **`ExternalIdentity` / `ContactChannel` / `BotConversationBinding` / `MessagingConsent`** | Four new entities | Zero. |
**Manager note:** §14 partially collides with our approved [email/phone OTP login spec](superpowers/specs/2026-08-15-email-phone-login-design.md). v3.1 keeps email/phone but reduces them to *recovery/fallback* when a messenger is unavailable. Our in-flight work is still valid, but its priority drops below VK ID. Needs a call before that spec is implemented.
---
## 3. The differences — detailed
Legend: ✅ have · 🟡 partial / mock only · ❌ missing · ⚠️ conflicts with something we already decided.
### 3.1 Platform components (§1.1) — 8 named components, we have 2
| Plan component | Our state |
|---|---|
| Storefront Runtime | ✅ Bootstrap-driven, tenant-configured, no per-project fork. This is our strongest match to the plan. |
| Platform Backoffice | 🟡 14 admin modules exist, but only **Categories** has a real HTTP backend. 9 of 11 admin domains inject their mock gateway directly — no DI seam to swap at all. |
| Platform API | 🟡 Storefront catalog/search/cart-payment are live; everything admin-side is mock. |
| Seller Portal | ❌ A static placeholder page, feature flag `false` by default, zero backend bytes, zero `HttpClient` reference. |
| Workers / Event Processing | ❌ Nothing. No event bus, no retry, no dead-letter. |
| Integration Hub | ❌ Nothing. Zero `reconcil*`, zero `idempot*` in the whole codebase. |
| Domain Automation | ❌ Nothing. Zero `hostinger` references — the plan's §8.2 lists seven Hostinger DNS endpoints we have never touched. |
| Marketplace Registry / Launch Center | ❌ Nothing shipped. Closest thing is our unshipped [super-admin Phase 1 design](superpowers/specs/superuser.md), which covers cross-tenant *viewing* but not registry/feature-set/launch. |
### 3.2 Catalog model (§2.1) — the biggest structural gap
The plan's core catalog idea is a **two-layer split**: `Product` (content card) vs. `Offer/Listing` (the seller's commercial proposition, which owns price, stock, currency, status). Order lines then snapshot the offer.
| Plan entity | Our state |
|---|---|
| `Product` / `Variant` / `SKU` | 🟡 Exists as admin mock + a separate live storefront `Item` domain. Two unrelated `Category` types, both fed by the same response, both in use. |
| `Offer / Listing` | ❌ Does not exist. Price and stock hang off the product. Multi-seller pricing on one product card is not expressible. |
| `PriceSnapshot` | ❌ Does not exist. |
| `InventoryRecord` (available/reserved/sold) | ❌ Does not exist. No reservations, no TTL, no oversell queue. |
| `PriceHistory` | ❌ Does not exist. |
| Draft → moderation → published → paused/archived | 🟡 An admin Moderation module exists, on mock data. |
| Bulk import CSV/API with pre-apply error preview | ❌ Only bulk *edit* actions inside Admin Categories. No import pipeline. |
| "Storefront search/filters run on published data, not local mock arrays" | ⚠️ Directly aimed at us. `PRODUCT_DATA_PROVIDER` and `CATEGORY_REPOSITORY` silently always resolve to the real API — but Search, wishlist/compare, cart contents and CMS are entirely `localStorage`. |
### 3.3 Money, FX and price traceability (§2.3, §3.3, §3.8, §7)
This is where the plan is most explicit, and where we most clearly do the forbidden thing.
| Plan requirement | Our state |
|---|---|
| `Money = amountMinor + currency`, **no float for money math** | ⚠️ We use plain `number` prices and float division/multiplication in `CurrencyRatesService.convert()`. |
| Rates come from a configurable **external source** with `source`, `rate`, `timestamp`, `TTL` | ⚠️ Rates are **hand-typed by an admin** into Admin Settings and stored in **browser `localStorage`** (`currencyRates.v1`), with hardcoded fallbacks (`USD: 0.011`, `AMD: 4.3`). They never update and drift from market. |
| `FxQuote { base, quote, rate, source, observedAt, expiresAt, quoteId }` | ❌ Does not exist. |
| Stale-quote control blocks checkout | ❌ Does not exist. |
| Checkout writes an immutable price snapshot; old orders never recalculated | ❌ Does not exist. |
| `PriceBook` (base currency + allowed display/checkout currencies) | ❌ Does not exist. |
| Backoffice shows the total formula: lines × qty discounts + delivery + fees, plus the FX quote used | ❌ Does not exist. |
| Reconciliation of internal orders vs. provider transactions | ❌ Does not exist (`reconcil*` = 0 hits repo-wide). |
**Nuance worth telling them:** their §3.3 complaint is *"switching RUB/USD/AMD keeps the same number"*. Our storefront **does** convert the displayed number. Their real, unstated problem is the one our own [§12.7](../BACKEND-API-REFERENCE.md) already flagged: the **charged** amount is computed client-side in RUB and posted to `/cart` as `amount`, so bank settlement totals don't reconcile against order counts. We agree with the plan here — we raised it first.
### 3.4 Cart and Checkout (§2.5, §2.6) — ⚠️ head-on conflict with a frozen system
| Plan requirement | Our state |
|---|---|
| Cart is **server-side**, keyed on `offerId` | ⚠️ Cart is `localStorage` + Telegram CloudStorage. There is no backend cart at all. |
| "Client never sends a trusted price to the server" | ⚠️ `CartPaymentRequest` sends `amount`, `currency`, and a per-item `price` array from the browser. This is exactly the pattern the plan forbids. |
| Checkout is a **server session** producing a price snapshot + contact snapshot | ❌ Checkout is an inline popup in `pages/cart/cart.component.ts` (751 lines). `features/website/checkout/` is an empty directory. |
| Idempotent order creation keyed on the payment | ❌ `/orders` is called fire-and-forget after payment success. Zero `idempot*` in the codebase. |
| Backend re-validates offers/stock at checkout | ❌ No stock concept exists to validate. |
| Multi-seller cart grouped by seller and fulfillment rules | ❌ Undefined behaviour — already flagged in our own gaps doc. |
| No duplicate payment intents on double-click | 🟡 Popup state guards the UI; nothing server-side. |
**Blocker:** [BACKEND-API-REFERENCE.md §7](../BACKEND-API-REFERENCE.md) states *"Payments are frozen — this call chain is explicitly out of scope for changes."* The plan's P0-A and P0-C cannot be delivered without unfreezing it. **This needs an explicit decision from whoever froze it.**
### 3.5 Payments (§2.7, §3.2)
| Plan requirement | Our state |
|---|---|
| Explicit state machines: `PaymentIntent` / `Payment` / `Order` | ❌ None. Payment status is a client-side signal with values `creating/waiting/success/timeout/error`. |
| Webhook signature verification + idempotency | ❌ None. `webhook` appears only as a display field in the admin **monitoring mock**. |
| Store `provider event id`, `provider timestamp`, `receivedAt`, `processedAt` | ❌ None. |
| "No artificial fixed delays" | ✅ **We already comply.** We poll real provider status (`/qr/dynamic/{partnerId}/{qrId}`, `/card/{partnerId}/{orderId}`) on an interval bounded by the QR TTL. There is no 5-second timer in this codebase. |
| Refunds as a first-class operation with reason/actor/order-line link | ❌ `requestRefund(id)` exists only as a mock gateway method. |
| Reconciliation queue | ❌ None. |
**Ask them:** §3.2 describes a fixed 5-second payment. We cannot reproduce it here. Either they observed a different build/environment, or they inferred it from the *admin* mock data. Worth pinning down before we spend P0 budget on a problem that may not be ours.
### 3.6 Orders and Fulfillment (§2.8, §3.6)
| Plan requirement | Our state |
|---|---|
| Canonical `Order` regardless of source (storefront / external marketplace / backoffice / API partner) | ❌ Admin Orders is a **static 24-row in-memory seed with no create path**, and no DI token to swap it. |
| `OrderLine` with SKU/title/price snapshots | ❌ |
| `Source mapping` (`externalMarketplace`, `externalOrderId`, `connectorId`) | ❌ |
| `Fulfillment` (manual / warehouse / pickup / digital) with evidence | ❌ One `fulfil*` hit in the entire codebase. |
| `Timeline` of all order events | ❌ Already logged as our own frontend-blocked TODO ("Real order audit trail"). |
| Admin actions: assign, resend notification, replay sync, cancel/refund by permission | ❌ |
| **No special branch for inspectors — any published, available product must be genuinely buyable and fulfillable** | ❌ We have no publish-time executability validation and no fulfillment flow, so we cannot currently *prove* compliance either way. |
### 3.7 Customer identity (§2.9, §3.4, §14)
| Plan requirement | Our state |
|---|---|
| `Customer` + multiple `ExternalIdentity` + verified `ContactMethod` | ❌ Telegram user is effectively the customer identity. |
| `emailVerifiedAt` / `phoneVerifiedAt` / `telegramLinkedAt` | ❌ |
| Order contact snapshot, immutable after order creation | ❌ |
| Email/phone OTP | 🟡 **Designed, not built** — spec approved 2026-08-15. |
| VK ID / MAX | ❌ New in v3.1, nothing exists. |
| Guest checkout toggled by tenant policy | ❌ |
### 3.8 Notifications (§2.10, §3.5)
| Plan requirement | Our state |
|---|---|
| Platform event bus emitting `order.created` / `order.paid` / `payment.failed` / `webhook.error` / `stock.low` / `oversell` / `refund.*` / `external_order.imported` | ❌ |
| Notification with `unread/read`, `severity`, `marketplaceId`, entity type/id, **deep link** | 🟡 `AdminOrderWatcherService` polls for new orders and toasts/badges the admin — the right shape, wrong data source. |
| Unread counter + filter by marketplace / event type in backoffice | 🟡 Partial (counter yes, marketplace filter no). |
| External channel delivery status logged; a Telegram/email failure must not lose the internal notification | ❌ |
**Status:** the notification feature is built and **functionally inert** — it polls the mock Orders gateway, which has no create path, so no new order can ever appear. It starts working the day Orders gets a real backend, with no further frontend change.
### 3.9 Analytics (§3.1, §6.3)
| Plan requirement | Our state |
|---|---|
| Server-side event logging: `session_started`, `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `payment_started/success/failed`, `order_created` | ❌ **No tracking pipeline exists at all.** Not a missing endpoint — missing infrastructure. Our own docs rate it the single largest remaining backend effort. |
| Operational metrics: notification latency, fulfillment time, connector lag, webhook lag | ❌ |
| Quality metrics: frontend/backend errors, checkout validation failures, FX stale blocks | ❌ |
| Real funnel in backoffice | ❌ Admin Analytics composes five mock gateways and has no data source. |
| Synthetic traffic technically separated from production analytics | ⚠️ Cannot comply — there is no production analytics to separate it from. |
| Product view counts | 🟡 A "Views" column was shipped in Admin Products; it always renders `0` because no tracking source exists. Storefront `Item.visits` is live-wired but displayed nowhere. |
### 3.10 Backoffice navigation (§4.1) — 12 required sections, 5 missing outright
Have (mock unless noted): Overview/Dashboard, Catalog (Categories real, Products mock), Orders, Payments partial (Transactions), Customers, Notifications partial, Content & Design (builder/CMS, `localStorage` only), Monitoring, Reports, Users, Settings.
Missing entirely:
- **Marketplaces** — registry, type, status, domains, currencies, feature set, responsible manager. Nothing.
- **Sellers** — organizations, applications, roles, listings, integration health. Placeholder page only.
- **Payments & Finance** — refunds, reconciliation, unmatched events, settlements. `settlement*` = 0 hits.
- **Integrations** — external connectors, payment providers, FX sources, messaging. Nothing.
- **Domains & Releases** — DNS/SSL, staging, production, health checks, rollback. Nothing.
- **Audit & Security** — role changes, sensitive actions, login/security events, exports. `audit` appears only as display fields on mock models.
### 3.11 Roles and RBAC (§4.4, §10.1) — ⚠️ our most serious security gap
The plan specifies three scopes and 17 named roles (5 platform, 7 marketplace, 5 seller).
Our state: **the admin role model is decorative.** `AdminRole` and permissions exist as types, but nothing gates any button, page or action anywhere in the app. Anyone who passes admin authentication has full access. `AdminRole` is additionally defined twice with unrelated shapes.
Also missing from §10.1: idempotency keys, rate-limit handling (429 has zero client-side handling), step-up authentication for financial actions, audit log, PII minimisation policy.
### 3.12 External marketplace integrations (§5) — 0% built
Nothing in this section exists in any form: connector contract, webhook-preferred/polling-fallback ingestion, raw event storage, normalizer, SKU mapping, unmatched queue, exponential retry, dead-letter, manual replay, reconciliation, connector observability, and the proposed SLA (99% of webhook events processed under 60s, zero duplicate orders).
**Blocking unknown:** the plan never names which external marketplaces. Ozon? Wildberries? Yandex Market? Avito? Each is a separate connector with its own auth and rate limits. We cannot size this without the list.
### 3.13 Domains, publishing and tenant launch (§8)
| Plan requirement | Our state |
|---|---|
| Marketplace lifecycle `draft → configured → content_ready → domains_planned → staging_live → qa_passed → production_ready → live → paused/archived`, with the blocking item shown per transition | ❌ |
| DNS automation via Hostinger API (7 endpoints listed), snapshot + rollback, never touching MX/SPF/DKIM/DMARC/CAA, approval gate in production, propagation + SSL + health checks | ❌ Zero references. |
| Publish model: `draft → validation → preview → publish`, immutable published revision, rollback creates a new revision | 🟡 The builder edits an in-memory config and persists drafts to `localStorage`. "Publish" only promotes a local signal. No revisions, no server-side publish endpoint (`apiEndpoints.builder` is an empty placeholder). |
| Commerce data explicitly **not** part of content revisions | ✅ Structurally true today — orders/payments simply aren't in the revision at all. |
### 3.14 API boundaries (§9.3) — ⚠️ a naming migration we have not planned
Plan namespaces: `/api/v2/storefront/*`, `/api/admin/v2/*`, `/api/seller/v1/*`, `/api/identity/v1/*`, `/api/providers/v1/*`, `/api/integrations/v1/*`.
Ours: unversioned, flat — `/cart`, `/orders`, `/items`, `/category`, `/searchitems`, plus a separate `qrApiUrl` host. Our own reference says **"No API versioning scheme has been decided"**.
Adopting the plan's namespaces is a coordinated frontend+backend rename, not a config change. It should be sequenced *before* the new commerce endpoints are built, not after.
Also in §9: the plan's error model assumes a structured envelope. Ours is a proposal only — no interceptor inspects error bodies today; every error reaction happens at raw HTTP-status level.
### 3.15 Gorbushka as a tenant (§11)
The plan lists mall-directory content entities: `Shop`, `ShopCategory`, `Service`, `Floor`, `SchemePin`, `RentListing`, `News/Promo`, `StaticPage`, `Lead`, `MallSettings` — each with `marketplaceId`, audit, and publish/preview.
We have: static pages inside the bootstrap document. None of the other nine entity types exist, and CMS content has no backend write path at all.
Positive read: the plan explicitly says Gorbushka must **not** dictate platform architecture, and that the existing frontend is UX reference only. That matches our ADR-0001 constraint ("frontend must not contain marketplace-specific code"). No conflict here — just unbuilt scope.
### 3.16 Definition of Done (§13) — where we stand today
Of the 13 launch-checklist items, we can currently claim **zero** as green. Additionally, our own QA position makes their DoD hard to evidence:
- ~32% statement coverage, ~19% branch coverage, 11 spec files repo-wide.
- **Zero E2E tests** — no Playwright/Cypress config anywhere. The plan's acceptance criteria are all end-to-end by construction.
- Several past "verified live" claims were code-inspection only, because `/edit` and `/backoffice` require Telegram admin login that automated environments cannot complete.
---
## 4. What we have that the plan does not account for
Not gaps — assets and risks they should know about before sequencing:
1. **Project editor / builder** (~1.0 MB lazy chunk) — a full visual site builder. The plan's §8.3 publish model would replace its persistence layer entirely.
2. **Ed25519 challenge/response admin auth** — fully wired client-side, backend returns 404 today. The plan never mentions it; it assumes conventional RBAC.
3. **Widget manifest / dynamic renderer** — the mechanism that makes one storefront runtime serve many tenants. This is the part of the plan we have *already* solved and should defend.
4. **Super-admin Phase 1 design** (`docs/superpowers/specs/superuser.md`) — cross-tenant read-only view. Overlaps §4.3 Marketplace Registry and §10 audit. Worth re-scoping against the plan rather than building as specified.
5. **Three in-flight items already answer v3.0 P0s:** admin purchase notifications (§3.5), admin product views column (§3.1), email/phone OTP login (§3.4). Two of the three are inert until a real backend exists.
---
## 5. Manager's read — the honest framing
**Split of ownership.** Roughly 80% of this document is backend and platform-service work: Platform API, Workers/Event Processing, Integration Hub, Domain Automation, payment state machines, reconciliation, analytics pipeline. This repository is a frontend. Of the plan's ~14 sections, only Storefront Runtime (§6.1) is substantially delivered, and it is delivered *well*.
**The real message is trust, not features.** Every P0 in §3 is a variant of "we cannot explain your numbers." Sequencing should follow that: traceability first (money model, price snapshot, payment timeline, audit), feature breadth second. That happens to also be the plan's own P0-A ordering.
**The largest single risk is not scope — it is the frozen payment chain.** Cart is client-owned, price is client-supplied, orders are fire-and-forget, and the whole chain is marked "do not modify." Three P0s sit behind that freeze. Nothing else in this list can be honestly estimated until that decision is reversed or explained.
**Second risk: RBAC.** The plan assumes 17 enforced roles across three scopes. We enforce none. Any real admin backend going live before this is fixed hands full platform access to every authenticated operator.
---
## 6. Decisions — answered 2026-08-17
See [PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md](PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md) Sprint 0.1 for the full record and downstream consequences. Summary:
1. **Backend ownership — answered 2026-08-18.** A separate backend developer implements against `docs/backend/`. This repository owns the frontend and the contract set.
2. **Payment chain — unfrozen. Yes.** Phases 1, 6, 7 proceed.
3. **External marketplaces — no fixed list.** Connectors onboard partners as they arrive; build the Phase 4 framework config-driven/generic, not per-named-provider.
4. **FX rate source — ours, in-house, as a safety gate.** No external provider committed; backend computes FX authoritatively until/unless one is chosen later.
5. **§14 vs. OTP — VK ID first, then everything else** ("do all after vk"). Phase 8 resequenced.
6. **Multi-seller orders — unified.** One `Order` per checkout, seller-scoped `Fulfillment` groups internally. Resolves the three-document disagreement.
7. **"Fixed 5-second payment" — resolved as a non-issue.** `PAYMENT_POLL_INTERVAL_MS` is already `5000` — that's poll cadence against real provider status, not an artificial delay. Confirmed compliant, no change needed.
8. **API namespace — new endpoints only, no forced migration.** `/api/v2/...` used for all new Phase 1+ contracts; legacy endpoints stay as-is pending a dedicated migration sprint.
9. **Document version — v3.1 is canonical.** The source PDF's internal "3.0" version block is stale.
---
## 7. Suggested first slice (if they want a proposal back)
Following their own dependency order, restricted to what is buildable and provable:
1. **Money model + FX quote + price snapshot** (P0-A) — needs the payment freeze lifted. Removes client-supplied `amount`, kills the float math, gives every total an explainable formula. This one item closes §3.3, §3.8 and half of §13.1.
2. **Order canonical model + timeline + notification wiring** (P0-B) — the notification feature already exists and switches on for free.
3. **RBAC enforcement** — not on their P0 list, but it is the gate on everything else in the backoffice going live safely.
4. **Analytics event pipeline** (P0/§3.1) — long lead time, so start it in parallel rather than last.
Explicitly *not* in a first slice: Seller Portal, external connectors, domain automation, VK/MAX bots. All of them depend on the commerce core being real first, which is what the plan itself says in §12.1.

View File

@@ -0,0 +1,499 @@
# Backend — the whole thing, one file
**Date:** 2026-08-22 · **Branch of record:** `improvements/fork-harvest`
This is the single source of truth for the marketplaces backend. It replaces the former `docs/backend/` set (Phase 110, Track A/S, the handoffs, the partner and harvest docs) — all of it is folded in here. The frontend is Angular 22, built and waiting; **there is no backend yet.** Everything below is the wire contract and the invariants the frontend needs, never DB schema or service boundaries, which stay the backend's own call.
> **The rule (keep this file alive).** When a backend need is added, a contract changes, or something ships, update THIS file in the same change — the relevant section and the change log at the bottom (§14). One file, always current. Do not create a new backend `.md`; add a section here.
---
## 0. Map
| § | Area | Was |
|---|---|---|
| 1 | System shape — multi-tenancy, auth, infra state | Handoff §14 |
| 2 | Release invariants (the gate) | Handoff §0 |
| 3 | How work lands — PR & release discipline | Handoff §0a |
| 4 | Cross-cutting mechanisms | Phase 1/Track S/Partner |
| 5 | Money, FX, payment state machine | Phase 1 |
| 6 | Cart & checkout | Phase 6 |
| 7 | Payments, reconciliation, refunds, settlements | Phase 7 |
| 8 | Catalog, offers, inventory, fulfillment | Phase 3 |
| 9 | Orders, events, notifications | Phase 2 |
| 10 | Identity & messaging (VK/Yandex/Telegram/MAX) | Phase 8 |
| 11 | Tenant registry, domains, publish | Phase 9 |
| 12 | Sellers · connectors · content · analytics · partner API | Phase 5/4/10, Track A, Partner |
| 13 | Infra, tenant routing, deploy | Tenant-API handoff + hardening |
| — | Acceptance tests · build order · dev setup · open decisions · change log | §2 end, §1518, §14 |
New endpoints use `/api/v2/...`; legacy endpoints (documented in `../../BACKEND-API-REFERENCE.md`) are not being migrated.
---
## 1. System shape
### 1.1 Multi-tenancy — shapes every endpoint
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:
- **Telegram QR/session (live).** `{authApiUrl}/users/sessions``POST` create, `GET /{id}` poll, `DELETE /{id}` logout. A clean implementation returns `{ webSessionID, user: { userId, username, firstName, lastName }, status, expiresAt }`.
- **Ed25519 challenge/response (not built).** `GET /api/admin/auth/challenge`, `POST /api/admin/auth/verify|refresh|logout`.
**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 |
|---|---|
| nginx 1.24 | Running. Proxies `/api/``127.0.0.1:8080`; `/health` = `ok`. |
| Backend on :8080 | **Not running.** `/api/` currently 502s. |
| PostgreSQL | Installed, inactive. Needs db, user, schema. |
| `@marketplaces/auth` | Installs over plain git, no credentials. |
| TLS / certbot | **Not installed.** Plain HTTP today. Multi-tenant needs per-domain or wildcard certs. |
| DNS / subdomains | **Not set up.** No domain points at the server. Phase-equivalent target in §11. |
| Frontend CD | Push to `main` deploys nothing today; `deploy.yml` exists (§13). |
Host hardening (sshd, fail2ban, sysctl) **is** applied on the frontend deploy — see `../DEPLOYMENT.md` §3.2.
---
## 2. Release invariants (the gate)
A release that violates any one of these does not ship. Each is falsifiable; the acceptance tests are in §15.
1. Public tenant is determined by verified `Host` alone. No public endpoint accepts a `marketplaceId` from the browser.
2. The price of an order is computed by the backend. A price in a request is ignored, never validated-and-used.
3. Stock and reservation change atomically — two buyers racing for the last unit produce exactly one payable order.
4. Payment creation and webhook receipt are idempotent, enforced by unique constraints, not handler logic.
5. Provider credentials never leave the backend — not in a response, not in a bundle, not in a log.
6. A published revision is immutable. Rollback creates a new revision; history is never rewritten.
7. Rolling back design does not roll back live inventory, orders, or payments.
8. No user reads a marketplace they are not assigned to — through the UI or a direct API call.
9. Every administrative mutation leaves an audit record: actor, action, before, after.
---
## 3. How work lands
**One functional area per PR.** Each carries: purpose, screenshots (where UI), API changes, migrations, test evidence, security impact, rollback plan. Never change a payment/inventory/order state machine in the same PR as a redesign.
**Migrations are expand/contract.** The expand step must be deployable on its own.
**A release is not "the build passed."** Each records version, migrations applied, healthcheck, post-deploy smoke, dependency audit, and the rollback path. Audit coverage (invariant 9) is a property every mutating endpoint carries from its first line, not a step.
---
## 4. Cross-cutting mechanisms
### 4.1 Money
All money is minor units, never float. `Money { amountMinor: number; currency: string }` (ISO 4217). RUB/USD/EUR/AMD are 2-decimal. Conversion rounds half-up to the currency's minor-unit precision, once, at the point of conversion — never re-rounded on redisplay.
### 4.2 Sessions (FH-2.3)
- Token = 32 random bytes, stored as **SHA-256 hash only** — a DB read yields no usable credential.
- `HttpOnly; Secure; SameSite`; revocable; rows carry `expiresAt`/`revokedAt`/`ip`/`userAgent`. Admin sessions 12 h, customer sessions 30 days.
- **One cookie name per contour** — `bo_session` / `manager_session` / `marketplace_session`. A customer session must never satisfy an admin guard; the guarantee is different cookies checked by different guards.
- Validation rejects on: unknown hash, `revokedAt` set, past `expiresAt`, user deactivated, or second factor not enrolled.
- Password change revokes every live session for the user **in the same transaction** as the password write.
- Credentials: Argon2id `memoryCost 65536, timeCost 3, parallelism 1`, ≥16 chars. TOTP **mandatory** for every platform/marketplace role: first login without an enrolled factor returns a signed, single-use, 10-minute enrolment token + `otpauth://` URI and issues no session until confirmed. The enrolment token grants nothing else.
### 4.3 Origin allowlist (FH-2.4)
One hook ahead of routing: any non-`GET`/`HEAD`/`OPTIONS` on `/api/admin/*`, `/api/platform/*`, `/api/manager/*` whose `Origin` is not allowlisted → `403`, before the handler. CORS uses the same allowlist with `credentials:true` — never `*`, never reflected. The allowlist is per-environment configuration.
### 4.4 Encrypted secret envelope (FH-2.9)
Stored credentials use `v1.<iv>.<authTag>.<ciphertext>` base64url, AES-256-GCM, 12-byte random IV per value, 32-byte key from env/secret-manager. The version tag lets the algorithm rotate. Decrypt only inside the using service — never on a DTO, in a log, or in any response (including to a `PLATFORM_OWNER`; backoffice shows presence, last-rotated, and an HMAC fingerprint, not the value). Fingerprints are `HMAC-SHA256(key, value)`. Redirect/callback URLs are built backend-side from the verified domain and allowlisted; the browser receives a URL to navigate to, never the material to build one. Covers payment credentials, connector credentials, bot tokens, FX keys, per-tenant OAuth secrets.
### 4.5 RoutingContext (Partner §7, on every payment)
```ts
interface RoutingContext {
companyId: string;
routingPath: string[]; // ordered node ids, root -> leaf
leafNodeId: string; // the payment point money is accepted at
environment: 'TEST' | 'LIVE';
merchantReference: string; // partner-supplied, opaque, echoed on every related event
providerPaymentId: string; // our payment id, stable, unique
}
```
Required on `CheckoutSession`, `PaymentIntent`, `Payment`, and every refund/reconciliation/settlement row. Resolved and **frozen at checkout-session creation**, immutable for the payment's life. `routingPath` must resolve to exactly one leaf or the payment is rejected at creation (never accepted and resolved during reconciliation). A payment whose leaf is `suspended`/`disabled` is rejected. `environment` must match the credential's or `403`. **Carry it from the first payment row — retrofitting it onto a populated table is far more expensive.**
### 4.6 RBAC (Track S)
17 roles, 3 scopes:
```ts
type PlatformRole = 'PLATFORM_OWNER' | 'TECH_ADMIN' | 'SECURITY_ADMIN' | 'DOMAIN_MANAGER' | 'VIEWER';
type MarketplaceRole = 'MARKETPLACE_ADMIN' | 'CONTENT_MANAGER' | 'CATALOG_MANAGER'
| 'ORDER_MANAGER' | 'FINANCE_MANAGER' | 'SUPPORT_MANAGER' | 'VIEWER';
type SellerRole = 'SELLER_OWNER' | 'SELLER_CATALOG_MANAGER' | 'SELLER_ORDER_MANAGER'
| 'SELLER_FINANCE_VIEWER' | 'SELLER_VIEWER';
```
Every `/api/admin/v2/*` and `/api/platform/v1/*` endpoint checks `(role, tenantScope)` against the session **before** touching data. A `MARKETPLACE_ADMIN` for A querying B's data gets `403`, not an empty result. `GET /api/identity/v1/session/permissions -> { role, scopes[], marketplaceIds[] }` is what frontend guards derive from — never hardcode role logic client-side beyond hiding affordances.
**Step-up auth** required before: bank/payment detail changes, production launch, role grants at `PLATFORM_OWNER`/`MARKETPLACE_ADMIN` level, any manual financial override. **PII minimization:** exposed only to roles that need it for scope; export endpoints are themselves audited.
**Bootstrap admin & self-service** (§8 of old Track S): each marketplace ships one bootstrap `MARKETPLACE_ADMIN``login` = marketplace slug, `password` = a cryptographically random one-time secret delivered out of band (never derived from the slug), `mustChangePassword: true`; login succeeds but every non-auth request `403`s with `PASSWORD_CHANGE_REQUIRED` until changed. `POST /api/identity/v1/session/change-password`. A `MARKETPLACE_ADMIN` provisions sub-admins scoped to its own tenant via `POST /api/admin/v2/team/invite { email, role: MarketplaceRole, marketplaceId }` (+ `GET/PATCH/DELETE /team`); `role` must be a `MarketplaceRole` (platform-scope → `403 SCOPE_ESCALATION_DENIED`), `marketplaceId` is forced server-side to the caller's scope, every change audited, `MARKETPLACE_ADMIN` grants require step-up.
### 4.7 Audit log
```ts
interface AuditEvent {
id: string; actor: string; action: string; // 'role.changed', 'offer.price_updated', 'refund.approved'
entityType: string; entityId: string;
before?: unknown; after?: unknown; reason?: string; occurredAt: string; ip?: string;
}
```
Mandatory coverage: permission changes, seller status changes, catalog moderation, price changes, payment/refund actions, manual order overrides, credential changes, launch actions. `GET /api/admin/v2/audit?marketplaceId=&entityType=&actor=&from=&to=`.
### 4.8 Rate limiting
`429 { error: { code: 'RATE_LIMITED', retryAfterSeconds } }` on storefront/auth/provider endpoints. Partner limits are per `partnerId` by tier, published in the OpenAPI so a partner reads its limit rather than discovering it via `429`.
### 4.9 Order-manager contour (FH-2.14)
`ORDER_MANAGER` is a **separate surface**, not a narrower menu: own URL, shell, login, and session cookie; a manager hitting a backoffice URL gets `403` from the guard. Scope from **membership rows, never configuration**. Catalog, design, domains, payment settings, platform users refuse — not merely hidden. PII masked in lists, revealed in detail only with permission, reveal and export audited.
---
## 5. Money, FX, payment state machine (Phase 1)
**Why:** rates are typed into `localStorage` and drift; the charged `amount` is computed client-side and trusted; nothing records which FX rate produced a price. Bank/NSPK totals can't reconcile.
**FX quote.** `GET /api/v2/pricing/fx-quote?base=RUB&quote=USD``{ quoteId, base, quote, rate, source, observedAt, expiresAt }`. `rate` may be float (market rate, not money). The frontend must re-fetch past `expiresAt`. If the source is down, the backend either blocks (`503 FX_SOURCE_UNAVAILABLE`) or serves a `"source":"fallback"` quote — a tenant setting. FX source is **ours, in-house, as the default** (`source: "internal"`); no external provider committed.
**PriceSnapshot.** Created once at checkout, immutable. `{ id, offerId, amount, displayAmount, fxQuoteId, capturedAt }`. Never recalculated — an old order shows the price it was actually charged.
**Server-authoritative amount (highest priority).** Replace client-trusted `POST /cart {amount, items[{price}]}` with:
```
POST /api/v2/storefront/checkout { offers: [{offerId, qty}], currency, deliveryOptionId }
```
The frontend sends offer ids + quantities only; the backend computes every price from the live offer price and current FX quote. **No `amount`/`price` is ever accepted from the client for anything affecting the charge.** `POST /api/v2/storefront/payments/intents` references `checkoutSessionId` only. Total = `sum(unitPrice*qty) discounts + delivery + taxes/fees`, reconstructable per line for backoffice.
**Payment state machine.**
```
PaymentIntent: created -> pending -> authorized/paid -> failed/cancelled
Payment: received -> confirmed -> captured/settled -> refunded/partially_refunded
Order: pending_payment -> paid -> processing -> fulfilled/completed
```
`PaymentEvent { id, paymentIntentId, fromState, toState, providerEventId, providerTimestamp, receivedAt, processedAt }`. No fixed delays anywhere. Webhook: `POST /api/providers/v1/payments/{provider}/webhook` — signature mandatory (`401` on fail), idempotency key `provider + providerEventId`, on success emit `payment.confirmed`/`payment.failed` onto the bus so order creation is event-driven. Idempotent order creation: `POST /api/admin/v2/orders` (internal) with `Idempotency-Key: <checkoutSessionId>` returns the existing order on retry.
---
## 6. Cart & checkout (Phase 6)
Server-owned cart from add-to-cart onward (today it's `localStorage` + Telegram CloudStorage; `features/website/checkout/` is empty).
```ts
interface Cart { id; marketplaceId; customerId?; sessionToken?; createdAt; expiresAt }
interface CartLine { id; cartId; offerId; qty; addedAt } // never a client price
interface CheckoutSession { id; cartId; customerContact:{email?,phone?,verified}; deliveryOptionId; status:'open'|'confirmed'|'expired'; createdAt; expiresAt }
interface DeliveryOption { id; marketplaceId; label; price: Money; type:'pickup'|'courier'|'digital' }
```
```
POST /api/v2/storefront/cart/lines { offerId, qty }
PATCH /api/v2/storefront/cart/lines/{id} { qty }
DELETE /api/v2/storefront/cart/lines/{id}
GET /api/v2/storefront/cart
```
Idempotent mutations; qty validated against Offer/Inventory on **every** mutation. Guest cart by `sessionToken`, merges into the customer cart on login (never drops items). Inactive carts and their reservations clear on `expiresAt`. **Price-refresh:** `GET /cart` returns captured price + current price + `priceChanged` when an offer's price moved; the frontend must confirm before checkout, the backend must expose the comparison, never silently pick one. Checkout reads the server cart directly; contact requirement and guest-checkout allowance are per-tenant policy.
---
## 7. Payments, reconciliation, refunds, settlements (Phase 7)
**Idempotency as constraints (FH-2.2).** `UNIQUE(payment.idempotency_key)` and `UNIQUE(payment_webhook_event.provider, event_key)`.
- Payment create requires `Idempotency-Key`; same key + same order → return existing, + different order/marketplace → `409`.
- Webhook: insert the event row **first**; a unique-violation is the duplicate signal → `{accepted:true, duplicate:true}`, stop. Only a successful insert applies the status change; set `processedAt` after applying (a crash between insert and apply shows as unprocessed, not lost). `event_key` = provider event id, else `sha256(rawBody)`. **Signature verified against the raw body** before any parse.
- Poll as reconciliation, not primary: a scheduled job re-checks provider status for payments still `pending` in the last 24 h and applies through the same state-machine path; transient failures swallowed, next tick retries. No fixed delay, no UI-driven poll standing in for a missed webhook.
**Refunds.** `Refund { id, orderId, orderLineIds[], amount, reason, actor, status:'requested'|'approved'|'processing'|'completed'|'failed', requestedAt, completedAt?, routing }`. Routing is **copied verbatim** from the original payment, never re-resolved — a store suspended after payment is still refundable. `POST /api/admin/v2/orders/{orderId}/refunds { orderLineIds, amount, reason }`, `GET` same. Updates `Payment.status` to `refunded`/`partially_refunded`, emits `refund.requested`/`refund.completed`.
**Reconciliation.** `ReconciliationRecord { id, orderId, providerPaymentId?, internalAmount, providerAmount?, matchStrategy:'provider_payment_id'|'merchant_reference'|'amount_currency_fallback', result:'matched'|'unmatched'|'duplicate'|'amount_mismatch'|'status_mismatch', resolvedBy?, resolvedAt?, resolutionNote?, routing }`. Match by providerPaymentId → merchant reference → amount+currency; surface non-matched in backoffice with audited resolution. `GET /api/admin/v2/reconciliation/queue?marketplaceId=&companyId=&projectId=&leafNodeId=&result=`, `POST /{id}/resolve {note}`.
**Settlements.** `Settlement { id, sellerId, periodStart, periodEnd, grossAmount, commission, refunds, netPayout, status:'pending'|'paid' }`. Seller split happens **after** routing: payment → routed to one payment point (frozen at checkout) → reconciled there → split across the sellers whose lines the order contains. A settlement belongs to one seller within one store; a seller in two stores gets two settlements. Splitting never rewrites RoutingContext. `grossAmount` across a store's settlements must reconcile against that store's matched rows for the period. `GET /api/seller/v1/finance/settlements`, `GET /api/admin/v2/finance/settlements?...`.
**Provider breadth:** QR + card today via one integration; the `PaymentIntent`/`Payment` shapes are provider-agnostic, so wallets/BNPL are a new adapter behind the same state machine — an open business decision, no action until made.
---
## 8. Catalog, offers, inventory, fulfillment (Phase 3)
**Two-layer split.** `Product` (content) vs `Offer` (one seller's proposition). One product, many offers.
```ts
interface Product { id; marketplaceId; categoryId; brand?; title; description; attributes; media[]; status:'draft'|'moderation'|'published'|'paused'|'archived' }
interface Variant { id; productId; sku; barcode?; optionValues; dimensions? }
interface Category { id; marketplaceId; parentId|null; slug; attributesSchema; order; seo }
interface Offer { id; marketplaceId; sellerId; variantId; sellerSku; price: Money; stockPolicy:'track'|'no_track'|'preorder'; status:...; publishedAt?; executabilityChecked }
interface PriceHistory { offerId; price: Money; changedBy; changedAt }
```
**Inventory** `{ offerId, available, reserved, sold, warehouse?, source }` + `StockReservation { id, offerId, qty, reason:'checkout'|'pre_payment', expiresAt, released }`. available/reserved/sold counted separately, never derived. Feed updates are idempotent upserts. Oversell → dedicated incident queue, never silently hidden.
**Atomic reservation (FH-2.1).** Reserve with one conditional write:
```sql
UPDATE inventory SET reserved = reserved + :qty
WHERE offer_id = :id AND (available - reserved) >= :qty RETURNING id
```
Zero rows → `409`, no retry, no partial reserve; a multi-line cart reserves every line in one transaction and rolls all back if any line returns zero. No `SELECT` before the `UPDATE`, no advisory lock — the `WHERE` clause is the concurrency control. TTL 15 min. Release and consume follow the same one-statement rule.
**Inventory journal (FH-2.8).** Every change writes one immutable `InventoryMovement { id, offerId, deltaAvailable, deltaReserved, deltaSold, reason, referenceType?, referenceId?, actor?, resultingAvailable, occurredAt }`. Never updated/deleted; a correction is a new compensating row. `resultingAvailable` recorded at the time; replaying the journal reproduces the record exactly. A manual adjustment without `actor` is rejected.
**Publish-time executability.** An offer that can't be fulfilled must not publish: valid `Fulfillment` type, stock policy `track` with `available>0` or `no_track`/`preorder`, required category attributes present. This is what makes "no branch distinguishes a buyer from an inspector" true.
**Digital code pools (FH-2.11).** `FulfillmentMode: manual | code_pool`. `DigitalCode { id, marketplaceId, offerId, encryptedValue, valueHash, status:'available'|'reserved'|'assigned'|'revoked', orderLineId?, createdAt, assignedAt? }`. `valueHash` unique per `(marketplace, offer)` — importing a code twice is refused by the DB. `available` for a code_pool offer derives from the count of available codes. Moves `available→reserved` under the FH-2.1 write, `reserved→assigned` only on confirmed payment. **A code is returned to the browser only when the order is `paid`/`processing`/`fulfilled`** — earlier states return an empty code list. Revocation is terminal and audited.
**Bulk import.** `POST /api/admin/v2/products/bulk-import` (CSV multipart or JSON array) returns a validation-error **preview**; a separate `POST .../bulk-import/{importId}/apply` commits. Idempotent by SKU/external key (FH-2.15) — re-run updates, never duplicates; a row-level error never publishes a partial result; rollback-able only while none of its products have appeared on a paid order, then archive.
**Endpoints.** `GET/POST/PATCH /api/admin/v2/products[/{id}]`, `GET/POST/PATCH /api/admin/v2/offers[/{id}]`, `POST /api/admin/v2/offers/{id}/publish` (runs executability, `422 details[]` on fail), `GET /api/admin/v2/offers/lookup?sku=&sellerSku=&externalId=`.
---
## 9. Orders, events, notifications (Phase 2)
**One `Order` per checkout**, regardless of seller count; lines group into per-seller `Fulfillment`. No parent/child splitting. A seller sees only their `Fulfillment` group and their `OrderLine`s.
```ts
interface Order { id; marketplaceId; source:'storefront'|'external'|'backoffice'|'api_partner'; externalOrderRef?; customerId?; currency; subtotal; discount; delivery; total: Money; paymentStatus; orderStatus; createdAt; paidAt? }
interface OrderLine { id; orderId; offerId; sellerId; skuSnapshot; titleSnapshot; qty; unitPrice; lineTotal: Money; priceSnapshotId }
interface Fulfillment { id; orderId; sellerId; type:'manual'|'warehouse'|'pickup'|'digital'; status:'pending'|'assigned'|'in_progress'|'issued'|'shipped'|'cancelled'; assignedTo?; issuedAt?; shippedAt?; evidence? }
interface OrderEvent { id; orderId; type:'created'|'paid'|'seller_notified'|'accepted'|'fulfilled'|'cancelled'|'refunded'; actor?; occurredAt; metadata? }
interface OrderContactSnapshot { orderId; name; email?; phone?; preferredChannel?; capturedAt } // immutable
```
**Public token + snapshot completeness (FH-2.13).** `Order.publicToken` ≥24 random bytes, base64url, unique; **every customer-facing route addresses an order by it, never by `id`** (a sequential id turns "check my order" into enumeration). `GET /api/v2/storefront/orders/{publicToken}` is tenant-scoped; a valid token from another marketplace → `404`. `OrderLine` snapshots everything that must survive a later edit — currency, per-line discount, delivery option and price, tax/fee components — written once at creation, never updated in place; a correction is a new event/refund/amendment.
**Endpoints.** `GET /api/admin/v2/orders?marketplaceId=&status=&source=&page=&pageSize=`, `GET /{id}`, `PATCH /{id}/status`, `POST /{id}/refund-request`, `POST /{id}/notes`, `POST /{id}/archive|restore`, `DELETE /{id}`. `GET /api/seller/v1/orders` returns only the authenticated seller's fulfillment groups and lines.
**Event bus.** `order.created|paid`, `payment.failed`, `webhook.error`, `stock.low`, `oversell`, `refund.requested|completed`, `external_order.imported`. Backend owns the implementation. Contract: `order.paid` **always** produces a backoffice notification even if every external channel is down.
**Notification Center.** `Notification { id, marketplaceId, entityType, entityId, severity:'info'|'warning'|'critical', eventType, read, deepLink, createdAt }` + `DeliveryAttempt { notificationId, channel, status:'sent'|'failed', error?, attemptedAt }`. `GET /api/admin/v2/notifications?...`, `PATCH /{id}/read`. A `DeliveryAttempt` failure never prevents the `Notification` row from being created and visible.
---
## 10. Identity & messaging (Phase 8)
Customer identity providers: VK ID and Yandex ID (OAuth), Telegram and MAX (bot/QR). **Frontend is built and tested** — provider-agnostic gateway, VK/Yandex login buttons, and the account-linking screen all exist; what's left is backend + the FH-0.1 decision.
**Provider-agnostic surface (FH-4.1/4.2).**
```
GET /api/identity/v1/{provider}/authorize?returnTo= -> { url } (or 302)
GET /api/identity/v1/{provider}/callback?code=&state=[&device_id=]
POST /api/identity/v1/{provider}/unlink (authenticated)
GET /api/identity/v1/me/identities (authenticated) -> ExternalIdentity[]
```
`/authorize` mints and stores `{state, codeVerifier, marketplaceId, returnTo, expiresAt}` **single-use for 10 min**, returns/302s to the provider with `code_challenge` (S256). `/callback` validates `state`, exchanges the code with the stored verifier, links the identity, issues the session cookie, redirects to a `returnTo` validated against the tenant origin. **The client never sees a secret, token, or verifier** — we are a confidential client, the backend owns PKCE. Unknown/expired/replayed `state` → generic error.
**`ExternalIdentity` (FH-4.3).** `{ customerId, provider:'vk_id'|'yandex_id'|'telegram'|'max', providerUserId, email?, phone?, displayName?, verifiedAt, lastUsedAt }`. `UNIQUE(provider, providerUserId)`; a provider account already bound to a *different* customer is an identity conflict routed to controlled resolution — never a silent rebind, enforced by the index. Email optional (VK often returns none). Per-tenant OAuth app config `{ clientId, clientSecret, scopes[], redirectUri }` stored under the §4.4 envelope.
**VK ID (FH-4.4).** OAuth 2.1, PKCE mandatory. Authorize `id.vk.com/authorize`, token `POST id.vk.com/oauth2/auth`, profile `POST id.vk.com/oauth2/user_info`, logout on unlink. **The callback returns `device_id` alongside `code` and the token exchange fails without it** — the most common integration bug.
**Yandex ID (FH-4.5).** OAuth 2.0 + PKCE. Authorize `oauth.yandex.ru/authorize`, token `POST oauth.yandex.ru/token` (HTTP Basic `client_id:client_secret`), profile `GET login.yandex.ru/info?format=json` (`Authorization: OAuth <token>`). A second strategy on the same surface; build after VK.
**Telegram → identity (FH-4.6).** A Telegram login writes an `ExternalIdentity` (`provider:'telegram'`) under the same uniqueness/conflict rule; appears in `/me/identities`, unlinkable subject to the **last-identity `409`** (never remove a customer's only login). Keep customer (`marketplace_session`) and admin (`bo_session`) sessions as distinct cookies — closes the shared customer/admin session finding. The identity row and the messaging `BotConversationBinding` stay separate records.
**Email/phone OTP (FH-4.8).** Recovery when a linked messenger is unreachable and an addable second factor — never the primary login; one more identity/contact on the same customer, not a parallel account. Implements the existing `../superpowers/specs/2026-08-15-email-phone-login-design.md`.
**MAX + Telegram bot channels.** `BotConversationBinding { customerId, marketplaceId, provider:'telegram'|'max', chatId, state, orderId?, lastMessageAt }`. MAX linking: `POST /api/identity/v1/max/link-code -> { code, expiresAt }` (single-use, bound to marketplace + browser session); user sends the code to the bot; `POST /api/providers/v1/max/bot-webhook` (idempotent) links the session. All providers' bot updates normalize to `MessagingEvent { provider, chatId, orderId?, text?, receivedAt }`. Bot tokens never reach the frontend.
**Notification Orchestrator + delivery conversation.** `order.paid` routes to the customer's chosen channel; the backoffice notification always fires even if the messenger is down. The bot never changes financial statuses — it writes delivery-detail fields via a dedicated service only. Follow-ups rate-limited, then hand off to a human. `POST /api/providers/v1/{provider}/bot-webhook`, `GET /api/admin/v2/orders/{orderId}/conversation`, `POST /{orderId}/conversation/handoff`.
**Blocking decision — FH-0.1.** VK and Yandex validate `redirect_uri` against an exact registered list; a multi-tenant platform can't register one per tenant domain. Resolution to confirm: one **central identity host** as the sole registered callback, tenant carried in the signed `state`, a 302 back to the tenant domain with a short-lived signed handoff token the tenant API exchanges for the session cookie. Also decide: one VK account across two storefronts — one `Customer` or two? (`Customer.marketplaceId` implies two, the safer default.) Record both in an ADR before any identity code.
---
## 11. Tenant registry, domains, publish (Phase 9)
**Hierarchy** (Company → Project → Marketplace → PaymentPoint; see §12 partner API). `Company`/`Project` are thin ownership/scope nodes; all config stays on `Marketplace`.
```ts
interface Marketplace { id; companyId; projectId; externalReference?; name; code; type:'commerce'|'mall_directory'|'hybrid'|'single_brand'; ownerId; countries[]; locales[]; currencies[]; timezone; lifecycleState }
type MarketplaceLifecycleState = 'draft'|'configured'|'content_ready'|'domains_planned'|'staging_live'|'qa_passed'|'production_ready'|'live'|'paused'|'archived';
interface MarketplaceDomain { marketplaceId; domain; type:'production'|'www'|'staging'|'preview'|'api'|'seller'; status:'planned'|'dns_pending'|'ssl_pending'|'active'|'failed' }
interface MarketplaceFeatureSet { marketplaceId; features: Record<string,boolean> }
interface MarketplaceRevision { id; marketplaceId; status:'draft'|'validated'|'preview'|'published'; publishedAt?; supersedesRevisionId? }
interface PaymentPoint { id; marketplaceId; method:'qr'|'card'; currencies[]; externalReference?; status; providerAccountRef?; createdAt; updatedAt }
```
Creating a payment point registers the channel but does **not** enable real money (needs `providerAccountRef` via a separate flow). Backfill existing marketplaces: create a Company, a Project ("marketplaces"), set `companyId`/`projectId` on every marketplace, create PaymentPoints for existing methods, then make the fks non-nullable.
**Lifecycle.** `GET /api/admin/v2/marketplaces/{id}/lifecycle -> { currentState, nextState, blockers[] }` (return the *specific* blocker), `POST .../lifecycle/advance`. **Onboarding wizard** — 8 steps: `POST /marketplaces` (name/code/type/owner/locales/currencies/timezone), `PATCH /{id}/feature-set`, `POST /{id}/domains`, `PATCH /{id}/design`, `POST /{id}/roles`, `PATCH /{id}/integrations`, `POST /{id}/staging-launch` (smoke tests), `POST /{id}/production-launch` (all P0 blockers closed + approval).
**Domain automation (Hostinger).** `GET/POST(validate)/PUT/DELETE /api/dns/v1/zones/{domain}`, `GET /snapshots/{domain}[/{id}]`, `POST /snapshots/{domain}/{id}/restore`. Order: read zone → **snapshot before any change** → build+validate plan → never touch MX/SPF/DKIM/DMARC/CAA without a scoped task → apply after approval → verify propagation/SSL/health → mark `active` only then.
**Publish model.** `draft → validation → preview → publish`. `POST /api/admin/v2/marketplaces/{id}/revisions`, `.../{revId}/validate|publish|rollback`.
- **Immutability (FH-2.7):** `version = max(version)+1`, `UNIQUE(marketplaceId, version)`, materialized snapshot (a product renamed tomorrow doesn't change what was published today), `publishedRevision` pointer flipped in the publishing transaction, rollback writes revision *n* as *max+1* (history only grows). **Operational state — inventory, reservations, orders, payments — never travels with a revision.**
- **Clone (FH-2.7):** carries theme/sections/pages/navigation/category tree/collections/offer assignments; **never** carries domains/admin users/customers/sessions/orders/payments/credentials/webhook secrets/audit. Inventory starts at zero unless a platform role opts otherwise. Category walk is topological with cycle detection (`400` naming the cycle).
- **Preview (FH-2.6):** `POST .../{id}/preview-token -> { url, expiresAt }`. HMAC over `{marketplaceId, expiresAt, nonce}`, 15-min TTL, `storefront_preview` HttpOnly cookie, constant-time compare, invalid/expired → `404` (an unpublished storefront doesn't confirm its existence). **While the preview cookie is present, every non-`GET` on the public API → `404`** (hook ahead of routing). Responses carry `X-Robots-Tag: noindex, nofollow`.
**Tenant resolution (FH-2.5).** `GET /api/v2/storefront/bootstrap` resolves server-side from verified `Host`. Normalize: lowercase, strip trailing dot, strip port, then match a unique `hostname` row — resolve only once `verifiedAt` is set and the marketplace serves. Brief cache (~30 s) with **explicit invalidation** on domain add/verify/remove and state change. `Host` read from the trusted proxy chain (proxy overwrites the client value). **No public endpoint accepts `marketplaceId`.** Unknown/unverified host → `404`, no fallback tenant.
**Hard invariant:** `Order`, `Payment`, `InventoryRecord`, and every ledger row are not part of a revision.
---
## 12. Sellers · connectors · content · analytics · partner API
### 12.1 Seller portal (Phase 5)
A seller never owns a separate `Order` — they see their `Fulfillment` groups and `OrderLine`s within shared orders, pre-filtered server-side (never trust a frontend `sellerId`).
```ts
interface SellerOrganization { id; marketplaceId; legalName; status:'pending'|'approved'|'suspended'|'rejected'; bankDetailsRef; createdAt }
interface SellerUser { id; sellerOrganizationId; role: SellerRole; email; status:'active'|'invited'|'suspended' }
interface SellerMarketplaceMembership { sellerOrganizationId; marketplaceId; status }
interface SellerIntegration { sellerOrganizationId; apiCredentialRef; webhookUrl?; lastSyncAt?; lastSyncError? }
```
`POST /api/seller/v1/onboarding`, `GET /profile`, `GET/POST/PATCH /offers`, `POST /offers/bulk-price-update`, `GET /orders`, `PATCH /orders/{orderId}/fulfillment/{fulfillmentId}`, `GET /finance/accruals|settlements`, `POST /finance/bank-details` (step-up + audit, optional maker/checker), `GET /team`, `POST /team/invite`, `GET /integrations`. Every endpoint enforces `SellerUser.role` server-side; the query layer carries an implicit `WHERE sellerOrganizationId = :authenticatedSeller` — a seller can never reach another seller's data by parameter manipulation.
### 12.2 Connectors — external order ingest (Phase 4)
A new partner connector is an onboarding action, not a code change. Fixed shared pipeline: ingest → verify/auth → persist `RawExternalEvent` **before parsing** → normalize to a canonical shape → map `externalSku → Offer` (no mapping → Unmatched queue, never silent) → create/update order (`source:'external'`) → emit events → push status back if supported.
```ts
interface Connector { id; marketplaceId; provider; authType:'webhook_signed'|'api_key'|'oauth2'; credentialRef; pollingIntervalSeconds?; cursorState?; status:'active'|'paused'|'error' }
interface RawExternalEvent { id; connectorId; payload; receivedAt; processedAt? }
interface ExternalOrderMapping { connectorId; externalSellerId; externalProductId; externalSku; internalSellerId; internalOfferId }
interface DeadLetter { id; connectorId; rawEventId; reason; retryCount; lastAttemptAt; resolvedAt? }
interface ExternalOrderEvent { connectorId; externalOrderId; externalCreatedAt; customer; lines[{externalSku,qty,unitPriceMinor,currency}]; totalMinor; currency; rawEventId }
```
Idempotency key = `connectorId + externalOrderId/eventId`; **zero duplicate orders on repeated delivery**. `POST /api/providers/v1/{connector}/webhook`, `GET/POST/PATCH /api/admin/v2/integrations[/{id}]`, `GET /{id}/unmatched`, `POST /{id}/unmatched/{eventId}/resolve`, `POST /{id}/dead-letter/{id}/replay`. SLA: webhook 99% under 60 s; polling delay ≤ `interval + 60`; every error carries a trace id.
### 12.3 Content modules — mall-class tenants (Phase 10)
Lowest priority, only after commerce core is real. Entities (all carry `marketplaceId`, audit, and the §11 draft/publish flow): `Shop`, `ShopCategory`, `Service`, `Floor`, `SchemePin`, `RentListing`, `Lead`, `NewsPromo`, `MallSettings`. `GET/POST/PATCH/DELETE /api/admin/v2/content/{shops|shop-categories|services|floors|scheme-pins|rent-listings|news}`, `POST /content/rent-listings/{id}/leads`, `PATCH /content/mall-settings`. Commerce modules are platform-ready but off via `MarketplaceFeatureSet` — the point is proving a tenant flips `catalog`/`cart`/`checkout` to `true` later with zero code change.
**Server-side content validation (FH-2.10).** The server re-runs the editor's rules on write. Clamp-and-fallback: clamp out-of-range numbers, fall back an invalid colour, blank a URL that isn't a same-origin path or `https://`, trim/truncate text. Structural violations (unknown block type, malformed id, too many blocks/ids) → `400`. Limits published as one schema both sides read. Referential checks (block → deleted category / unpublished offer) are publish blockers unless a fallback is declared.
### 12.4 Analytics (Track A) — start early, longest lead time
`AnalyticsEvent { eventType, marketplaceId, sessionId, customerId?, timestamp, properties, isSynthetic }`. `POST /api/v2/storefront/analytics/events`. Backend is source of truth for `sessionId` and `isSynthetic`**never trust a client synthetic flag.** Vocabulary: traffic (`session_started`, `page_view`, `product_view`), catalog (`search`, `category_view`, `seller_view`), commerce (`add_to_cart`, `checkout_started`, `payment_started|success|failed`, `order_created`) — emitted from the same code paths that produce `PaymentEvent`/`OrderEvent`, not a drifting parallel layer. `OperationalMetric` for latencies/lag. **Synthetic traffic** is staging/demo only, `isSynthetic:true` set server-side by environment/token — reports filter it by construction. `GET /api/admin/v2/analytics/funnel|operational|quality`, `GET /api/v2/storefront/search/trending`.
### 12.5 Partner provisioning — inbound (`/api/partner/v1/`)
Partners provision their own merchant hierarchy, then payments route back to the correct leaf. **Deliberately generic** — no partner name in any entity/field/endpoint; partner-specific behaviour lives in a `PartnerProfile` config row.
Four fixed levels `Company → Project → Store(=Marketplace) → PaymentPoint`; middle levels optional per profile. `ProvisioningNode { id, level, parentId, companyId, path[], environment:'TEST'|'LIVE', status:'active'|'suspended'|'disabled', externalReference, displayName, ... }`. `path` is server-computed; nodes never re-parent (move = disable + create); `disable` cascades terminally, `suspend` cascades reversibly by cascade id; creating a node never enables money. `TEST`/`LIVE` are a hard partition (cross-env → `403`).
Write: `POST /companies/{id}/projects`, `/projects/{id}/stores`, `/stores/{id}/payment-points`, `PATCH /nodes/{id}/status`, `POST /nodes/{id}/disable`. Read: `GET /nodes/{id}`, `/companies/{id}/hierarchy`, `/nodes/lookup?externalReference=`, `/companies/{id}/audit`. Every `POST` needs `Idempotency-Key` (scope `(partnerId, endpoint, key)`, 24 h, same key+body → replay, +different body → `409`, no partial hierarchy). **Signed requests** (ed25519/rsa-pss), private key never transmitted, ±5 min skew, nonce replay rejected; authority is the credential's `scopeNodeId` subtree, a credential can never widen its own scope. `POST/GET/rotate/DELETE /credentials`. Stable error codes (`validation_failed 422`, `scope_forbidden 403`, `environment_mismatch 403`, `node_disabled 409`, `signature_invalid 401`, …). Partner-facing serialization uses the partner's own field names via `PartnerProfile.routingFieldNames`.
---
## 13. Infra, tenant routing, deploy
**Deterministic hostname rule.** One API hostname per base domain: `example.com`, `store1.example.com`, `www.example.com` all use `https://api.example.com`. Localhost is the only exception (local `/api` proxy).
**Backend must,** for every request on the shared `api.<base-domain>`: use `X-Storefront-Host` (nginx derives it from a validated browser `Origin`, sends it as upstream `Host`, keeps the shared API host in `X-Forwarded-Host`); not infer a subdomain tenant from the API `Host`; resolve the normalized storefront hostname through the domain registry; reject unknown/disabled/unverified domains with `403` before reading tenant data (never fall back to a default tenant); bind the session to the resolved tenant and reject a mismatch; trust `X-Storefront-Host`/`X-Forwarded-*` only from the known proxy; return JSON for `/bootstrap` with a tenant identity matching the domain (HTML or a default-tenant response is a fault).
**CORS.** Echo the exact validated storefront origin, `Access-Control-Allow-Credentials: true`, `Vary: Origin`, methods `GET,POST,PUT,PATCH,DELETE,OPTIONS`, headers `Authorization, Content-Type, AdminWebSessionID, X-Requested-With`, preflight `204`. Never `*` with credentials.
**nginx/TLS.** `scripts/deploy/configure-api-domain.sh --domain … --email … --upstream https://127.0.0.1:445` (idempotent, root) creates the shared `api.<domain>`, issues/renews its cert, configures CORS, proxies all paths. Subdomains need no extra API DNS/cert.
**CI/CD.** `deploy.yml` runs the same configurator before activating a frontend release. Secrets: `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, `DEPLOY_KNOWN_HOSTS`, `STOREFRONT_DOMAINS`, `CERTBOT_EMAIL`, `BACKEND_UPSTREAM`. One-time `server-setup.sh` installs the root-owned configurator and host hardening (`../DEPLOYMENT.md` §3.2).
**Structural DB isolation (FH-D.2).** Data network `internal: true`, API bound to loopback, `no-new-privileges` on every service. **Restore drill (FH-D.1):** WAL archiving (`wal_level=replica`, `archive_mode=on`, `archive_timeout=300`) plus a scheduled restore-check that restores into a clean environment and records the result.
**Acceptance:** `curl -fsS https://api.example.com/bootstrap | jq -e 'type=="object"'` and an OPTIONS preflight both pass; the bundle contains no fixed marketplace API hostname; unknown domains `403`; API never returns the Angular `index.html` fallback.
---
## 14. Change log
Append here whenever a section changes. Newest first.
- **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.
---
## 15. Acceptance tests
Backend integration tests — the frontend can't prove a race or a replay against a mock.
| # | Scenario | Passes when | Guards |
|---|---|---|---|
| A1 | Two concurrent checkouts for the last unit | One payable order, one clean `409` | Inv. 3 |
| A2 | Same provider webhook delivered twice | Order completes once, stock moves once, one notification | Inv. 4 |
| A3 | A price sent in a checkout request | Ignored; charged amount is the server's | Inv. 2 |
| A4 | Unknown/unverified `Host` | `404`, no other tenant's data | Inv. 1 |
| A5 | `MARKETPLACE_ADMIN` for A queries B directly | `403`, not empty | Inv. 8 |
| A6 | Cross-origin POST with a valid session cookie | Refused | §4.3 |
| A7 | Any credential value searched for in responses/logs/bundle | Absent | Inv. 5 |
| A8 | Rollback a design revision | Revision restored, live inventory untouched | Inv. 67 |
| A9 | Mutation while a preview cookie is present | `404` | §11 |
| A10 | Hand-crafted config the editor would reject | Refused | §12.3 |
| A11 | Unpaid order requests its digital code | Empty code list | §8 |
| A12 | Re-run the same import file | Updates, no duplicate | §8 |
| A13 | Second VK login, same `providerUserId` | Same `Customer`, no duplicate | §10 |
| A14 | VK account already bound to another customer | Conflict resolution, no silent rebind | §10 |
| A15 | Unlink a customer's only identity | `409` | §10 |
---
## 16. Build order
1. **Launch gate (P0):** money model (§5) → orders/events (§9) → catalog/offers/inventory (§8) → connectors (§12.2). Track S (§4.6, §4.9) gates the launch — enforce it, nothing does today. Track A (§12.4) starts in parallel with §5 (longest lead time). Read the partner API (§12.5) before implementing §5 — it adds RoutingContext to the payment tables.
2. **Publish & content:** §11 (preview/revision/clone/tenant hardening), §12.3 content validation.
3. **Identity:** unblock FH-0.1, then §10 in order VK → Yandex → Telegram migration → OTP. Frontend already built.
4. **Digital goods & manager contour:** §8 code pools, §4.9.
5. **Continuous:** §13 ops.
---
## 17. Dev setup (day one)
1. Start/configure PostgreSQL; create db + user.
2. Design the schema from these contracts (schema is the backend's own call; tenant scoping from day one).
3. Build the API service on `127.0.0.1:8080` — nginx already proxies `/api/`.
4. Implement the **bootstrap config endpoint** (§1.1) — without it the frontend can't render.
5. Implement the Telegram session endpoints — login is fully built client-side, blocked only on these.
6. Implement `GET /api/identity/v1/session/permissions` (§4.6) — frontend guards derive from it.
7. Seed per-marketplace bootstrap admins (§4.6).
Steps 46 unblock the entire frontend.
---
## 18. Open decisions
- **FH-0.1** — central identity host + one-VK-account-across-storefronts (§10). Blocks identity.
- Additional payment providers (wallets/BNPL) — new adapter, business decision (§7).
- Per-connector adapters — written per partner at onboarding (§12.2).
- Backfill of Company/Project/PaymentPoint for existing marketplaces — sequence in §11, not scheduled.
- CI registry reachability (reverse proxy + TLS, or a different registry).

View File

@@ -0,0 +1,46 @@
---
id: ADR-0001
title: Extract auth and payment into shared @marketplaces packages
status: active
date: 2026-08-17
supersedes: []
tags: [architecture, auth, payment, monorepo]
---
# ADR-0001: Extract auth and payment into shared @marketplaces packages
## Context
`marketplaces` currently owns auth end-to-end: customer auth (`core/auth` — VK ID, OTP, session, facade), admin auth (`core/admin-auth` — ed25519-verified admin sessions, permission guards, interceptor), and a legacy `services/auth.service.ts`. Payment/finance logic (`core/finance`, `core/pricing`) is server-owned per [Phase 1](../../backend/BACKEND-INTEGRATION.md) and [Phase 7](../../backend/BACKEND-INTEGRATION.md) contracts — the frontend piece is thin (gateways/tokens, no business logic).
Multiple marketplace projects beyond this repo need the same auth and payment client logic. Duplicating it per-project drifts fast (auth bugs get fixed in one place, not others) and blocks a consistent security posture across projects — directly relevant to [../../backend/BACKEND-INTEGRATION.md](../../backend/BACKEND-INTEGRATION.md), which already treats auth/RBAC as the single most serious cross-cutting concern.
## Decision
Extract auth and payment client logic into two standalone, independently versioned npm packages:
- `@marketplaces/auth` — customer auth (VK ID/OTP/session), admin auth (ed25519 verification, permission guards, interceptors), token/session management.
- `@marketplaces/payment` — payment/finance client gateways, FX/pricing models, checkout client contracts (thin — business logic stays backend per Phase 1/7).
Each package:
1. Lives in its own git repo (handed over separately; this repo does not host it long-term).
2. Is consumed by `marketplaces` (and other projects) as an installed node_modules dependency — imported, never copy-pasted.
3. Is versioned with semver; CI on the package repo auto-bumps and publishes on push to `main`, driven by conventional commit prefixes already used in this repo (`feat:`/`fix:`/etc — semantic-release reads these directly).
4. Ships with its own test suite; `marketplaces` treats it as a black-box dependency, not source to edit in place.
Rollout order: scaffold packages and CI in this repo first (reversible, local-only) → hand over target git repo → publish → migrate `marketplaces` call sites to import from the package → delete the in-repo originals only after the app builds and passes tests against the package.
## Amendment 2026-08-18 — distribution mechanism
The original decision left distribution open ("private registry ... or installed straight from git"). A private Verdaccio registry was stood up on the dev server and both packages published to it. **That approach was then abandoned**: the registry listens on `127.0.0.1:4873` behind a firewall allowing only 80/443/SSH, so neither CI runners nor developers could install without an SSH tunnel. That broke `marketplaces`' existing `architecture-governance` workflow, whose `npm ci` step could no longer resolve `@marketplaces/auth`.
Distribution is now **git release branches**: `release/auth` and `release/payment` in vitanovaPackages, each an orphan branch whose root *is* the package (`package.json` + built `dist/`), force-pushed by CI on every release. Consumers install with `git+<repo>#release/auth` — no registry, no token, no tunnel, no CI secret; anonymous git read suffices.
The Verdaccio instance still runs but nothing depends on it. Making a registry the primary path again would require a reverse proxy plus TLS on the dev server, which buys nothing over the current approach at this scale.
## Consequences
- `marketplaces` loses direct edit access to auth/payment source — changes go through the package's own repo/PR/release cycle. Slower iteration, but consistent behavior across all consuming projects.
- ~30 call sites in `marketplaces` (see `core/auth`, `core/admin-auth`, `services/auth.service.ts`, interceptors) need import rewiring during migration — tracked as follow-up work, not done in this ADR.
- New failure mode: `marketplaces` builds now depend on `sources.vitanova.network` being reachable. A branch ref also tracks its tip, so an install can pick up a new build — acceptable while the package churns, but pin to a commit SHA once it stabilises.
- [../../backend/BACKEND-INTEGRATION.md](../../backend/BACKEND-INTEGRATION.md) §8 (admin provisioning) becomes package-owned behavior once migrated — that doc's endpoint contracts stay backend-side and unaffected, only the frontend client implementation moves.

View File

@@ -0,0 +1,72 @@
---
id: ADR-0003
title: Build partner merchant-provisioning as a generic API, not a per-partner integration
status: active
date: 2026-08-18
supersedes: []
tags: [architecture, api, payments, multi-tenant, security, decision]
---
# ADR-0003: Build partner merchant-provisioning as a generic API, not a per-partner integration
## Context
A partner asked (2026-08-18) for an API to programmatically manage a merchant hierarchy — Company → Project → Store → PaymentPoint — with idempotent provisioning, `externalReference` lookup, TEST/LIVE separation, public-key credentials with scoped authority and rotation, and payment/callback fields that route a payment unambiguously to one store.
Their request arrived written in their own vocabulary. Building against that vocabulary directly would produce a partner-shaped API, and the next partner asking for the same capability with different level names would either get a second parallel surface or force a rename through our schema.
Three facts about our current model made the ask non-trivial:
1. Nothing exists above `Marketplace` ([Phase 9](../../backend/BACKEND-INTEGRATION.md)). No company, no project.
2. Payments carry no store dimension ([Phase 1](../../backend/BACKEND-INTEGRATION.md) §6). Reconciliation can reconstruct *why* an amount was charged but not *who for*.
3. We have no partner-facing write API at all. [Phase 4](../../backend/BACKEND-INTEGRATION.md) is outbound/ingest — the opposite direction.
## Decision
Build one generic partner provisioning API. Contract: [../../backend/BACKEND-INTEGRATION.md](../../backend/BACKEND-INTEGRATION.md).
### 1. Partner-specific behaviour is config, never schema
No partner name appears in any entity, field, endpoint, or status value. Everything partner-varying lives in a `PartnerProfile` row: which levels are required, level name aliases, routing field names, rate-limit tier, key rotation window, webhook field map. **Onboarding a partner is a config row, not a deployment.**
Deliberately *not* configurable, because configurability there breaks reconciliation or safety: status values and transitions, idempotency semantics, environment partitioning, signature scheme, the four-level ceiling.
### 2. Fixed four levels with optional middles, not a free-form tree
`company → project → store → payment_point`. Middle levels are omittable per partner profile; depth is never partner-defined. An arbitrary-depth tree would push every downstream consumer — routing, reconciliation, settlement, audit — into handling shapes no partner actually has.
### 3. Credentials are node-scoped
The partner asked us to choose between per-company, per-project, and per-store credentials. We answer all three with one mechanism: a credential binds to **any single node**, and its authority is that node's subtree. Partner keypairs are partner-generated; we hold only the public key. Rotation runs with a bounded overlap; revocation is immediate and irreversible.
### 4. Level mapping onto our model
| Partner level | Our entity |
|---|---|
| `company` | new, thin |
| `project` | new, thin — a product line (e.g. `marketplaces`) |
| `store` | `Marketplace` (Phase 9), gains `companyId`/`projectId`/`externalReference` |
| `payment_point` | new — one payment method accepted at one marketplace (`qr`, `card`; both ship today) |
`PaymentPoint` is an acceptance channel, not a physical till and not a settlement account. Registering one never enables real money — financial enablement is a separate approved flow that sets `providerAccountRef`.
### 5. Seller is excluded from the hierarchy
`Seller` ([Phase 5](../../backend/BACKEND-INTEGRATION.md)) is orthogonal. A payment routes to one payment point, is reconciled there, and only then splits across the sellers whose lines the order contains ([Phase 7](../../backend/BACKEND-INTEGRATION.md) §3.1). Putting `Seller` in the partner hierarchy would force every partner to model our multi-seller concept, which most do not have.
### 6. RoutingContext lands in Phase 1 before implementation, not after
`RoutingContext` (companyId, routingPath, leafNodeId, environment, merchantReference, providerPaymentId) is required on `CheckoutSession`, `PaymentIntent`, `Payment`, `Refund`, `ReconciliationRecord`. Frozen at checkout-session creation, immutable thereafter.
## Consequences
**Cost now:** two new entities (`Company`, `Project`), one new leaf (`PaymentPoint`), three amended contracts (Phases 1, 7, 9) plus Track S §4.1, and a backfill for existing marketplaces (Phase 9 §1.2).
**Cost avoided:** retrofitting a routing dimension onto a populated payments table after launch; a second parallel provisioning surface for partner number two.
**Accepted limits:**
- A partner needing more than four levels cannot be served without a contract change. Judged unlikely enough to be worth the simplicity.
- Backfilled rows carry a synthetic company and project. `externalReference` stays null for them.
- Partners cannot create companies through the API — company creation stays a commercial, out-of-band action.
**Unaffected:** the `@marketplaces/auth` / `@marketplaces/payment` package split ([ADR-0001](ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md)). The provisioning API is backend-side; nothing about it belongs in a frontend package.

View File

@@ -0,0 +1,55 @@
---
id: ADR-0004
title: Derive each API host from the complete storefront host
status: superseded
date: 2026-08-20
supersedes: []
tags: [architecture, multi-tenant, api, routing, dns]
superseded_by: [ADR-0005]
---
# ADR-0004: Derive each API host from the complete storefront host
> Superseded by [ADR-0005](ADR-0005-share-api-host-across-storefront-subdomains.md).
## Context
One production bundle serves root domains and arbitrary storefront subdomains.
The old bundle embedded `api.dexarmarket.ru`, while an earlier correction used
a same-origin `/backend` gateway. Neither expresses the required domain rule:
each storefront has a corresponding API hostname derived from its full host.
Examples:
- `example.com` uses `api.example.com`.
- `store1.example.com` uses `api.store1.example.com`.
Bootstrap, auth, legacy endpoints, and versioned endpoints must not use
different base-host selection rules.
## Decision
At runtime the frontend prefixes the complete browser hostname with `api.` and
keeps the browser protocol: `{protocol}//api.{hostname}`.
- Bootstrap loads from `https://api.{hostname}/bootstrap`.
- Auth receives the same derived base through `AUTH_API_URL`.
- Legacy endpoints append their existing paths to that base.
- Versioned `/api/...` endpoints retain the `/api` prefix.
- Localhost and loopback continue to use the local `/api` development proxy.
- An explicit `tenantApiBaseUrls` entry may override the convention for an
exceptional host, without changing the shared bundle.
The complete hostname is preserved. In particular, `www.example.com` maps to
`api.www.example.com`; no label is stripped or interpreted by the frontend.
## Consequences
One artifact works on root domains and nested storefront subdomains without a
tenant allowlist or per-domain build. Every API hostname must have DNS, TLS, a
working reverse proxy, and CORS configured for its corresponding storefront.
A wildcard such as `*.example.com` does not cover the multi-label hostname
`api.store1.example.com`; nested API names need explicit certificates/DNS or a
certificate and routing strategy that covers that depth. Backend tenant lookup
must recognize `api.<storefront-host>` as the API alias of `<storefront-host>`.

View File

@@ -0,0 +1,35 @@
---
id: ADR-0005
title: Share one API host across storefront subdomains
status: active
date: 2026-08-20
supersedes: [ADR-0004]
tags: [architecture, multi-tenant, api, routing, dns]
---
# ADR-0005: Share one API host across storefront subdomains
## Context
One frontend bundle serves a base storefront domain and tenant subdomains. The
API is shared at the base-domain level; a tenant subdomain must not create a
nested API hostname.
## Decision
- `example.com`, `store1.example.com`, and `www.example.com` all use
`https://api.example.com`.
- The complete storefront hostname remains the tenant hint. nginx validates the
browser Origin and forwards that hostname as `X-Storefront-Host`.
- Backend tenant lookup trusts that header only from the known proxy, verifies
it against the domain registry, and binds authenticated sessions to the same
tenant.
- Localhost continues through `/api`. `tenantApiBaseUrls` remains available for
public-suffix or custom-domain exceptions.
## Consequences
Tenant subdomains need no extra API DNS records or certificates. CORS must echo
the exact allowed storefront origin, while unknown or disabled domains still
receive `403` from the backend. The shared API `Host` alone cannot identify a
subdomain tenant.

View File

@@ -0,0 +1,38 @@
---
id: ADR-0006
title: Harvest mechanisms from the parallel platform, keep our architecture
status: active
date: 2026-08-21
tags: [architecture, security, contracts, platform, governance]
---
# ADR-0006: Harvest mechanisms from the parallel platform, keep our architecture
## Context
A second team built a competing platform monorepo — NestJS/Fastify API, PostgreSQL/Prisma, two Angular apps, Docker/Nginx infrastructure — sharing an older `dexarmarket` ancestor with this repo. On 2026-08-11 they received a snapshot of our code, audited it, and vendored it into their tree as `reference/parallel-frontend/`, classified as a UI/UX reference rather than production. Their handoff document ranks our work fifth of five priority sources.
Full comparison: [FORK-ANALYSIS-2026-08-21.md](../../FORK-ANALYSIS-2026-08-21.md).
The asymmetry is real and runs both ways. They have working server-side truth: tenancy resolved from a verified `Host`, RBAC enforced per endpoint, hashed server sessions with mandatory TOTP, encrypted per-tenant payment credentials, idempotent webhooks, immutable publish revisions, WAL archiving and a restore check. We have the deeper frontend — 530 `.ts` files against 189, 158 components, 30 spec files plus Playwright e2e against their 25 unit tests and no e2e at all, Angular 22 with a clean production audit against their 21.2.18 with open high findings, and architecture governance in CI that they have no equivalent of.
Three of their audit findings against us were still live when re-checked on 2026-08-21, and two of them were defects rather than posture: a plaintext `ip-api.com` call that mixed-content blocking had silently killed in production, and an unvalidated bank URL rendered into an iframe that most acquirers refuse to be framed in.
## Decision
Take the mechanisms. Do not take the architecture, and do not merge the codebases.
- **Harvest** specific, proven mechanisms into our backend contracts under a traceable `FH-*` tag: conditional-write stock reservation, idempotency as a unique constraint, hashed server sessions with per-contour cookies, an origin allowlist on cookie-authenticated mutations, an AES-256-GCM envelope for stored secrets, signed read-only preview, revision immutability and clone semantics, an append-only inventory journal, server-side content validation, and digital code pools.
- **Reject** anything that would regress us: their Angular version, their mock service still shipping in a backoffice, their test posture, their environment-pinned manager scope, their hardcoded server IP, and their narrower section schema.
- **Keep ours where ours is better** and say so explicitly, so it does not get relitigated: our marketplace lifecycle state machine is richer than theirs, our bulk-import preview/apply flow is equivalent, our editor validation engine is stronger — it simply needs a server-side counterpart to bind.
- **Record the nine invariants** from their handoff as the acceptance gate at the head of our own backend handoff, since they are more falsifiable than anything our delivery plan had.
## Consequences
The backend contracts gain normative mechanism text where they previously stated intent, which raises the bar a backend built against them must clear — at the cost of more prescription than these documents originally carried. That trade is deliberate: "the webhook must be idempotent" survives one refactor, `UNIQUE (provider, event_key)` survives every refactor.
Our repo stays frontend-only. Nothing harvested requires standing up Prisma or NestJS here; anything that would have becomes a contract line instead. Work splits across five lanes — frontend, contracts, the `@marketplaces/auth` package, infrastructure, and process — tracked in [FORK-HARVEST-TODO.md](../../FORK-HARVEST-TODO.md).
Adopting their invariants and their PR and release discipline as our own means our releases get slower and more evidenced. That is the intended direction.
The organizational question this ADR does not settle: whether the two implementations converge as their backend plus our frontend. Left unchallenged, their handoff document's ranking becomes the plan of record by default.

View File

@@ -4,3 +4,11 @@
{"id":"PV-20260713T000000Z-0004","subject":"translatable-fields","predicate":"must-be-modeled-as","object":"generic translations.{lang} map so adding/removing a language automatically exposes/removes translation fields across all translatable objects","src":["docs/context/adrs/ADR-0001-marketplace-platform-vision.md"],"status":"active","kind":"constraint","updated_at":"2026-07-13T00:00:00Z","confidence":"high","tags":["i18n","constraint"]} {"id":"PV-20260713T000000Z-0004","subject":"translatable-fields","predicate":"must-be-modeled-as","object":"generic translations.{lang} map so adding/removing a language automatically exposes/removes translation fields across all translatable objects","src":["docs/context/adrs/ADR-0001-marketplace-platform-vision.md"],"status":"active","kind":"constraint","updated_at":"2026-07-13T00:00:00Z","confidence":"high","tags":["i18n","constraint"]}
{"id":"PV-20260713T000000Z-0005","subject":"admin-app","predicate":"is-isolated-from","object":"marketplace storefront bundle: admin code never ships to storefront and vice versa, though they may share a domain","src":["docs/context/adrs/ADR-0001-marketplace-platform-vision.md"],"status":"active","kind":"constraint","updated_at":"2026-07-13T00:00:00Z","confidence":"high","tags":["admin","security"]} {"id":"PV-20260713T000000Z-0005","subject":"admin-app","predicate":"is-isolated-from","object":"marketplace storefront bundle: admin code never ships to storefront and vice versa, though they may share a domain","src":["docs/context/adrs/ADR-0001-marketplace-platform-vision.md"],"status":"active","kind":"constraint","updated_at":"2026-07-13T00:00:00Z","confidence":"high","tags":["admin","security"]}
{"id":"PV-20260713T000000Z-0006","subject":"widgets","predicate":"must-not-own","object":"page spacing or page width; the renderer owns sections, spacing, and page width, widgets own only their internal layout","src":["docs/context/adrs/ADR-0001-marketplace-platform-vision.md"],"status":"active","kind":"constraint","updated_at":"2026-07-13T00:00:00Z","confidence":"high","tags":["widgets","layout"]} {"id":"PV-20260713T000000Z-0006","subject":"widgets","predicate":"must-not-own","object":"page spacing or page width; the renderer owns sections, spacing, and page width, widgets own only their internal layout","src":["docs/context/adrs/ADR-0001-marketplace-platform-vision.md"],"status":"active","kind":"constraint","updated_at":"2026-07-13T00:00:00Z","confidence":"high","tags":["widgets","layout"]}
{"id":"PV-20260818T001500Z-a1f3","subject":"auth-and-payment-client-logic","predicate":"is-decided-to-extract-into","object":"standalone versioned npm packages @marketplaces/auth and @marketplaces/payment, installed as dependencies rather than edited in-repo","src":["docs/context/adrs/ADR-0001-extract-auth-and-payment-into-shared-marketplaces-packages.md"],"status":"active","kind":"decision","updated_at":"2026-08-18T00:15:00Z","confidence":"high","tags":["architecture","auth","payment","decision"]}
{"id":"PV-20260818T104000Z-c7d1","subject":"partner-merchant-provisioning","predicate":"is-decided-to-build-as","object":"one generic inbound API where all partner-specific behaviour is a PartnerProfile config row (required levels, level aliases, routing field names, rate tier); no partner name appears in any entity, field, endpoint or status value","src":["docs/context/adrs/ADR-0003-generic-partner-provisioning-api.md","docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md"],"status":"active","kind":"decision","updated_at":"2026-08-18T10:40:00Z","confidence":"high","tags":["architecture","api","partner","decision"]}
{"id":"PV-20260818T104100Z-e2b8","subject":"partner-hierarchy-levels","predicate":"map-onto","object":"company and project are new thin entities above Marketplace; store IS Marketplace (Phase 9); payment_point is new and equals one payment method accepted at one marketplace (qr, card)","src":["docs/context/adrs/ADR-0003-generic-partner-provisioning-api.md","docs/backend/PHASE-9-TENANT-REGISTRY-DOMAINS-CONTRACT.md"],"status":"active","kind":"decision","updated_at":"2026-08-18T10:41:00Z","confidence":"high","tags":["architecture","multi-tenant","payments","decision"]}
{"id":"PV-20260818T104200Z-f5a9","subject":"Seller","predicate":"is-excluded-from","object":"the partner provisioning hierarchy; a payment routes to exactly one payment point, is reconciled there, and only then splits across sellers in Phase 7 settlement","src":["docs/context/adrs/ADR-0003-generic-partner-provisioning-api.md","docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md"],"status":"active","kind":"constraint","updated_at":"2026-08-18T10:42:00Z","confidence":"high","tags":["payments","reconciliation","sellers"]}
{"id":"PV-20260818T104300Z-b3c4","subject":"RoutingContext","predicate":"is-required-on","object":"CheckoutSession, PaymentIntent, Payment, Refund and ReconciliationRecord; frozen at checkout-session creation and immutable thereafter, so a payment is always attributable to exactly one payment point","src":["docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md","docs/backend/PHASE-7-PAYMENTS-RECONCILIATION-CONTRACT.md"],"status":"active","kind":"constraint","updated_at":"2026-08-18T10:43:00Z","confidence":"high","tags":["payments","reconciliation","contract"]}
{"id":"PV-20260818T104400Z-d9e2","subject":"partner-api-credentials","predicate":"are-scoped-by","object":"a single node whose subtree defines authority; we hold only the partner-generated public key, rotation runs on a bounded overlap window and revocation is immediate and irreversible","src":["docs/backend/PARTNER-PROVISIONING-API-CONTRACT.md","docs/backend/TRACK-S-SECURITY-RBAC-CONTRACT.md"],"status":"active","kind":"decision","updated_at":"2026-08-18T10:44:00Z","confidence":"high","tags":["security","credentials","partner"]}
{"id":"PV-20260818T104500Z-a6f7","subject":"checkout-payment-methods","predicate":"already-support","object":"both qr and card end to end in src/app/pages/cart/cart.component.ts (separate create paths and separate status pollers); card is not an outstanding gap","src":["src/app/pages/cart/cart.component.ts","src/app/services/api.service.ts"],"status":"active","kind":"implemented","updated_at":"2026-08-18T10:45:00Z","confidence":"high","tags":["payments","frontend"]}
{"id":"PV-20260820T095500Z-b17e","subject":"tenant-api-routing","predicate":"is-decided-to-use","object":"one runtime-derived API origin per base domain; example.com and store1.example.com both map to api.example.com for bootstrap, auth, legacy, and versioned endpoints","src":["docs/context/adrs/ADR-0005-share-api-host-across-storefront-subdomains.md","src/app/core/config/api-config.service.ts"],"status":"active","kind":"decision","updated_at":"2026-08-20T16:00:00Z","confidence":"high","tags":["architecture","multi-tenant","api","routing","dns"]}

View File

@@ -0,0 +1,225 @@
# Admin Product Views Column 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:** Show a real (currently-zero) per-product view count as a toggleable column in Admin Products list, and document the backend gap that keeps it at zero today.
**Architecture:** Add `visits: number` to the `AdminProduct` model, default it to `0` everywhere the mock gateway constructs an `AdminProduct`, add `'visits'` to the existing toggleable-column system (`ALL_PRODUCT_COLUMNS`), render it in the table view using the established `isColumnVisible()` pattern.
**Tech Stack:** Angular signals, existing `LocalStorageService`-backed column-visibility persistence (already built, not touched).
## Global Constraints
- Never fabricate view numbers — the mock gateway has no real tracking source, so `visits` must default to `0`, not a random/seeded number.
- Table view only — no grid-view or product-detail-page display (out of scope per design doc).
- Follow the existing `isColumnVisible('stock')`-style pattern exactly — no new column-visibility mechanism.
---
### Task 1: `visits` field, column, and backend doc ask
**Files:**
- Modify: `src/app/features/admin/products/models/admin-product.model.ts:81-121` (add field)
- Modify: `src/app/features/admin/products/services/admin-products-local.gateway.ts:82-94,133-166` (default the field)
- Modify: `src/app/features/admin/products/facade/admin-products.facade.ts:39` (add to column list)
- Modify: `src/app/features/admin/products/components/admin-products-list.component.html:96-112` (render column + header)
- Modify: `src/app/i18n/en.ts:1647,1670`, `src/app/i18n/ru.ts:1642`, `src/app/i18n/hy.ts:1642`, `src/app/i18n/translations.ts:1655` (i18n keys)
- Modify: `BACKEND-API-REFERENCE.md` (new §12.10 ask)
- Test: `src/app/features/admin/products/services/admin-products-local.gateway.spec.ts` (new)
**Interfaces:**
- Produces: `AdminProduct.visits: number`
- Produces: `ALL_PRODUCT_COLUMNS` includes `'visits'` (so `AdminProductColumn` union includes `'visits'`)
- [ ] **Step 1: Write the failing test**
Create `src/app/features/admin/products/services/admin-products-local.gateway.spec.ts`:
```typescript
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { AdminProductsLocalGateway } from './admin-products-local.gateway';
describe('AdminProductsLocalGateway visits field', () => {
let gateway: AdminProductsLocalGateway;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
gateway = TestBed.inject(AdminProductsLocalGateway);
});
it('defaults visits to 0 on every loaded product', (done) => {
gateway.loadProducts({ search: '', categoryId: 'all', visibility: 'all', stockStatus: 'all', page: 1, pageSize: 50 }).subscribe(result => {
expect(result.items.length).toBeGreaterThan(0);
expect(result.items.every(product => product.visits === 0)).toBe(true);
done();
});
});
it('resets visits to 0 on a duplicated product, even if the source had a nonzero count', (done) => {
gateway.loadProducts({ search: '', categoryId: 'all', visibility: 'all', stockStatus: 'all', page: 1, pageSize: 50 }).subscribe(result => {
const source = result.items[0];
gateway.duplicateProduct(source.id).subscribe(duplicated => {
expect(duplicated).not.toBeNull();
expect(duplicated!.visits).toBe(0);
done();
});
});
});
});
```
Note: read `src/app/features/admin/products/models/admin-product.model.ts` for the exact `AdminProductListFilters` shape before writing the test's filter object — if the field names above (`categoryId`, `visibility`, `stockStatus`, `page`, `pageSize`) don't match exactly, use the real ones; don't guess.
- [ ] **Step 2: Run test to verify it fails**
Run: `npm run test -- --include='**/admin-products-local.gateway.spec.ts'`
Expected: FAIL — `Property 'visits' does not exist on type 'AdminProduct'` (TS compile error surfaces as a Karma failure).
- [ ] **Step 3: Add the field to the model**
In `src/app/features/admin/products/models/admin-product.model.ts`, add to the `AdminProduct` interface (next to `quantity: number;`):
```typescript
quantity: number;
visits: number;
```
- [ ] **Step 4: Default it in the mock gateway**
In `src/app/features/admin/products/services/admin-products-local.gateway.ts`, in `toAdminProduct()` (around line 155, next to the `quantity` line):
```typescript
quantity: product.stockStatus === 'out_of_stock' ? 0 : product.stockStatus === 'low_stock' ? 3 : 25,
visits: 0,
```
In `duplicateProduct()` (around line 82-91), add `visits: 0` to the override object so a duplicate never inherits the source's count via the `...source` spread:
```typescript
const duplicated: AdminProduct = {
...source,
archived: false,
id: `${source.id}-copy-${Date.now()}`,
sku: `${source.sku}-COPY`,
slug: `${source.slug}-copy-${Date.now()}`,
name: `${source.name} Copy`,
visits: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
```
- [ ] **Step 5: Run test to verify it passes**
Run: `npm run test -- --include='**/admin-products-local.gateway.spec.ts'`
Expected: PASS (2/2)
- [ ] **Step 6: Add the column**
In `src/app/features/admin/products/facade/admin-products.facade.ts:39`, change:
```typescript
export const ALL_PRODUCT_COLUMNS = ['sku', 'brand', 'price', 'stock', 'visibility', 'updated'] as const;
```
to:
```typescript
export const ALL_PRODUCT_COLUMNS = ['sku', 'brand', 'price', 'stock', 'visibility', 'updated', 'visits'] as const;
```
In `src/app/features/admin/products/components/admin-products-list.component.html`, add a header cell after the `visibility` header (around line 100):
```html
@if (isColumnVisible('visibility')) { <th scope="col">{{ 'adminProducts.visibility' | translate }}</th> }
@if (isColumnVisible('visits')) { <th scope="col">{{ 'adminProducts.views' | translate }}</th> }
```
And a matching body cell after the `visibility` cell (around line 126, right after its closing `}`):
```html
@if (isColumnVisible('visits')) { <td>{{ product.visits }}</td> }
```
The column-picker panel (`admin-products-list.component.html:50-59`) needs no template change — it already iterates `allColumns` generically and looks up `adminProducts.column_<name>`, so it auto-picks up `'visits'` once the i18n key exists (Step 7).
- [ ] **Step 7: Add i18n keys**
In `src/app/i18n/translations.ts`, in the `adminProducts` interface block, add two lines (next to `stockStatus: string;` and near the other `column_*` entries):
```typescript
stockStatus: string;
views: string;
```
```typescript
column_updated: string;
column_visits: string;
```
In `src/app/i18n/en.ts`, `adminProducts` block:
```typescript
stockStatus: 'Stock status',
views: 'Views',
```
```typescript
column_updated: 'Last updated',
column_visits: 'Views',
```
In `src/app/i18n/ru.ts`, `adminProducts` block (next to its `stockStatus:` line and its `column_updated:` line — read the file first to find them, they're at different line numbers than en.ts):
```typescript
views: 'Просмотры',
```
```typescript
column_visits: 'Просмотры',
```
In `src/app/i18n/hy.ts`, `adminProducts` block:
```typescript
views: 'Դիտումներ',
```
```typescript
column_visits: 'Դիտումներ',
```
(For ru.ts/hy.ts: read the file first, find the exact existing `stockStatus:`/`column_updated:` lines in the `adminProducts` block — there may be more than one `column_updated:` in the file for a different admin domain, only edit the one inside `adminProducts`, at the location already found: `ru.ts:1642` area, `hy.ts:1642` area.)
- [ ] **Step 8: Run full verification**
Run: `npx tsc --noEmit -p tsconfig.json`
Expected: no errors.
Run: `npx ng build --configuration development`
Expected: build succeeds.
Run: `npm run test -- --include='**/admin-products-local.gateway.spec.ts'`
Expected: PASS (2/2).
- [ ] **Step 9: Document the backend gap**
In `BACKEND-API-REFERENCE.md`, after the existing §12.8 section (search for `### 12.8 Admin purchase notifications depend on Orders CRUD being real` — it currently ends right before `### 12.9 Trending search terms`), insert a new section, and renumber `12.9` to `12.10`:
```markdown
### 12.9 Admin product view counts
**Gap:** Admin Products (§8) runs on a fully separate mock domain from the storefront's live catalog — `AdminProduct.visits` is a new field added to support a "Views" column in Admin Products, but the mock gateway always defaults it to `0` because there is no real tracking source available to the admin domain today. This is unrelated to the storefront's `Item.visits` field (§6, `/items/{id}`), which is live-wired but never displayed anywhere in the UI.
**Ask:** two options, not mutually exclusive:
1. Once admin Products gets a real backend (§10 step 4), include a per-product view/visit count in the response.
2. Bridge `AdminProduct.visits` to the storefront's already-live `Item.visits` by product id, if a unified product identity exists between the storefront and admin domains — smaller change than building new tracking infrastructure.
### 12.10 Trending search terms
```
(The existing body text of the old `### 12.9 Trending search terms` section stays exactly as-is below the renumbered heading — only the heading number changes, from `12.9` to `12.10`.)
- [ ] **Step 10: Commit**
```bash
git add src/app/features/admin/products/models/admin-product.model.ts src/app/features/admin/products/services/admin-products-local.gateway.ts src/app/features/admin/products/services/admin-products-local.gateway.spec.ts src/app/features/admin/products/facade/admin-products.facade.ts src/app/features/admin/products/components/admin-products-list.component.html src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts src/app/i18n/translations.ts BACKEND-API-REFERENCE.md
git commit -m "feat: admin product views column (always 0 until backend tracks it)"
```

View File

@@ -0,0 +1,944 @@
# Admin Purchase Notifications 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:** Notify admin (toast + topbar bell badge/panel) when a new order lands on the marketplace, poll-based since the backend has no WebSocket/SSE.
**Architecture:** A single `AdminOrderWatcherService` polls `AdminOrdersLocalGateway.loadOrders()` on an editable interval (default 15s), diffs against a persisted "last notified" order id to fire toasts for genuinely new orders, and exposes a `recentOrders`/`unreadCount` signal pair that the existing (currently-empty) topbar bell panel renders. Poll interval is editable in the admin settings page, same pattern as the currency-rates section added previously.
**Tech Stack:** Angular 17+ signals, RxJS, Jasmine/Karma (`ng test`), existing `LocalStorageService`/`UserNotificationService`/`TranslateService` patterns.
## Global Constraints
- No WebSocket/SSE available — polling only (confirmed `BACKEND-API-REFERENCE.md:20`).
- Persist state via `LocalStorageService` (`getItem`/`setItem`), never raw `localStorage`.
- Reuse the existing topbar bell (`admin-layout.component.html:141-157`) instead of adding a new nav badge.
- Admin backoffice price/amount displays stay in the order's raw stored currency (no `currencyConvert` pipe) — consistent with every other admin screen.
- Route arrays for admin navigation use the pattern `[languageService.currentLanguage(), 'backoffice', 'orders', id]` (no leading `/`), matching `admin-orders-list-page.component.ts:52`.
---
### Task 1: `UserNotificationService` gains an optional click-to-navigate route
**Files:**
- Modify: `src/app/features/website/user-experience/services/user-notification.service.ts`
- Modify: `src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts`
- Modify: `src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html`
- Test: `src/app/features/website/user-experience/services/user-notification.service.spec.ts` (new)
**Interfaces:**
- Produces: `UserNotificationService.show(message: string, type?: UserNotificationType, durationMs?: number, route?: string[]): void`
- Produces: `UserNotification.route?: string[]`
- [ ] **Step 1: Write the failing test**
Create `src/app/features/website/user-experience/services/user-notification.service.spec.ts`:
```typescript
import { TestBed } from '@angular/core/testing';
import { UserNotificationService } from './user-notification.service';
describe('UserNotificationService', () => {
let service: UserNotificationService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(UserNotificationService);
});
it('stores the route on the notification when provided', () => {
service.show('New order #1042', 'info', 4000, ['en', 'backoffice', 'orders', 'ord_1']);
const [note] = service.notifications();
expect(note.message).toBe('New order #1042');
expect(note.route).toEqual(['en', 'backoffice', 'orders', 'ord_1']);
});
it('leaves route undefined when not provided', () => {
service.show('Saved');
const [note] = service.notifications();
expect(note.route).toBeUndefined();
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm run test -- --include='**/user-notification.service.spec.ts'`
Expected: FAIL — `show` has no fourth parameter, `route` does not exist on `UserNotification`.
- [ ] **Step 3: Implement**
Replace the full contents of `src/app/features/website/user-experience/services/user-notification.service.ts`:
```typescript
import { Injectable, signal } from '@angular/core';
export type UserNotificationType = 'success' | 'info' | 'warning';
export interface UserNotification {
id: string;
message: string;
type: UserNotificationType;
/** Route to navigate to when the notification is clicked. Absent means not clickable. */
route?: string[];
}
@Injectable({ providedIn: 'root' })
export class UserNotificationService {
private readonly state = signal<UserNotification[]>([]);
readonly notifications = this.state.asReadonly();
show(message: string, type: UserNotificationType = 'info', durationMs: number = 2500, route?: string[]): void {
const next: UserNotification = {
id: `note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
message,
type,
...(route ? { route } : {}),
};
this.state.update(items => [next, ...items].slice(0, 4));
setTimeout(() => this.dismiss(next.id), durationMs);
}
dismiss(id: string): void {
this.state.update(items => items.filter(item => item.id !== id));
}
}
```
Modify `src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts` — replace full contents:
```typescript
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import { UserNotification, UserNotificationService } from '../../services/user-notification.service';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
@Component({
selector: 'app-floating-notifications',
standalone: true,
imports: [TranslatePipe],
templateUrl: './floating-notifications.component.html',
styleUrls: ['./floating-notifications.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FloatingNotificationsComponent {
private readonly notificationsService = inject(UserNotificationService);
private readonly router = inject(Router);
readonly notifications = this.notificationsService.notifications;
dismiss(id: string): void {
this.notificationsService.dismiss(id);
}
navigate(note: UserNotification): void {
if (note.route) {
void this.router.navigate(note.route);
}
this.dismiss(note.id);
}
}
```
Replace full contents of `src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html`:
```html
@if (notifications().length > 0) {
<aside class="floating-notifications" aria-live="polite" aria-atomic="true">
@for (note of notifications(); track note.id) {
<article
class="floating-note"
[class]="'floating-note floating-note-' + note.type"
[class.floating-note-clickable]="!!note.route"
(click)="note.route && navigate(note)"
>
<p>{{ note.message }}</p>
<button type="button" (click)="$event.stopPropagation(); dismiss(note.id)" [attr.aria-label]="'common.dismiss' | translate">×</button>
</article>
}
</aside>
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm run test -- --include='**/user-notification.service.spec.ts'`
Expected: PASS (2 specs)
- [ ] **Step 5: Commit**
```bash
git add src/app/features/website/user-experience/services/user-notification.service.ts src/app/features/website/user-experience/services/user-notification.service.spec.ts src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html
git commit -m "feat: UserNotificationService supports click-to-navigate toasts"
```
---
### Task 2: `AdminOrderWatcherService` — polling, diffing, toast firing
**Files:**
- Create: `src/app/features/admin/shell/services/admin-order-watcher.service.ts`
- Test: `src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts`
- Modify: `src/app/i18n/translations.ts`, `src/app/i18n/en.ts`, `src/app/i18n/ru.ts`, `src/app/i18n/hy.ts` (one new key)
**Interfaces:**
- Consumes: `AdminOrdersLocalGateway.loadOrders(filters: AdminOrderListFilters): Observable<AdminOrdersListResult>` (existing)
- Consumes: `UserNotificationService.show(message, type?, durationMs?, route?)` (Task 1)
- Consumes: `LocalStorageService.getItem(key): string | null`, `.setItem(key, value): void` (existing)
- Produces: `AdminOrderWatcherService.recentOrders: Signal<AdminOrder[]>`
- Produces: `AdminOrderWatcherService.unreadCount: Signal<number>`
- Produces: `AdminOrderWatcherService.intervalMs: Signal<number>`
- Produces: `AdminOrderWatcherService.start(): void`
- Produces: `AdminOrderWatcherService.markAllSeen(): void`
- Produces: `AdminOrderWatcherService.setIntervalSeconds(seconds: number): void`
- [ ] **Step 1: Add the i18n key first (needed by the test's translated toast message)**
In `src/app/i18n/translations.ts`, inside the `topbar:` block under `adminShell` (next to `notificationsEmpty: string;`):
```typescript
notificationsEmpty: string;
notificationNewOrder: string;
```
In `src/app/i18n/en.ts`, inside `adminShell.topbar` (next to `notificationsEmpty:`):
```typescript
notificationsEmpty: 'No new notifications',
notificationNewOrder: 'New order #{{orderNumber}}',
```
In `src/app/i18n/ru.ts`, inside `adminShell.topbar`:
```typescript
notificationsEmpty: 'Нет новых уведомлений',
notificationNewOrder: 'Новый заказ №{{orderNumber}}',
```
In `src/app/i18n/hy.ts`, inside `adminShell.topbar`:
```typescript
notificationsEmpty: 'Նոր ծանուցումներ չկան',
notificationNewOrder: 'Նոր պատվեր #{{orderNumber}}',
```
(Match each file's existing `notificationsEmpty` value/indentation exactly — only add the new line after it.)
- [ ] **Step 2: Write the failing test**
Create `src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts`:
```typescript
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { AdminOrderWatcherService } from './admin-order-watcher.service';
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
import { AdminOrder, AdminOrdersListResult } from '../../orders/models/admin-order.model';
import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service';
function makeOrder(id: string, orderNumber: string, createdAt: string): AdminOrder {
return {
id,
orderNumber,
status: 'pending',
customer: { name: 'Test Customer', email: 't@example.com', phone: '+70000000000' },
payment: { method: 'card', status: 'paid', amount: 1000, currency: 'RUB' },
shipping: { address: '', method: '', trackingNumber: '' },
items: [],
total: 1000,
currency: 'RUB',
notes: '',
internalNotes: '',
timeline: [],
archived: false,
createdAt,
updatedAt: createdAt,
};
}
describe('AdminOrderWatcherService', () => {
let ordersByPoll: AdminOrder[][];
let pollIndex: number;
let notifications: UserNotificationService;
let service: AdminOrderWatcherService;
function fakeGateway() {
return {
loadOrders: () => {
const items = ordersByPoll[pollIndex] ?? ordersByPoll[ordersByPoll.length - 1];
const result: AdminOrdersListResult = { items, total: items.length, page: 1, pageSize: 20 };
return of(result);
},
};
}
beforeEach(() => {
pollIndex = 0;
ordersByPoll = [
[makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'), makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z')],
];
localStorage.clear();
TestBed.configureTestingModule({
providers: [
provideRouter([]),
{ provide: AdminOrdersLocalGateway, useValue: fakeGateway() as unknown as AdminOrdersLocalGateway },
],
});
notifications = TestBed.inject(UserNotificationService);
service = TestBed.inject(AdminOrderWatcherService);
});
it('does not toast on the very first poll and marks everything as acknowledged', fakeAsync(() => {
service.start();
tick(0);
expect(notifications.notifications().length).toBe(0);
expect(service.unreadCount()).toBe(0);
expect(service.recentOrders().map(o => o.id)).toEqual(['o2', 'o1']);
}));
it('toasts and increments unreadCount for orders newer than the last-notified one', fakeAsync(() => {
service.start();
tick(0);
pollIndex = 1;
ordersByPoll.push([
makeOrder('o3', '1003', '2026-08-15T11:00:00.000Z'),
makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'),
makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z'),
]);
tick(service.intervalMs());
expect(notifications.notifications().length).toBe(1);
expect(notifications.notifications()[0].message).toContain('1003');
expect(notifications.notifications()[0].route).toEqual(['ru', 'backoffice', 'orders', 'o3']);
expect(service.unreadCount()).toBe(1);
}));
it('markAllSeen resets unreadCount without clearing recentOrders', fakeAsync(() => {
service.start();
tick(0);
pollIndex = 1;
ordersByPoll.push([
makeOrder('o3', '1003', '2026-08-15T11:00:00.000Z'),
makeOrder('o2', '1002', '2026-08-15T10:00:00.000Z'),
makeOrder('o1', '1001', '2026-08-15T09:00:00.000Z'),
]);
tick(service.intervalMs());
expect(service.unreadCount()).toBe(1);
service.markAllSeen();
expect(service.unreadCount()).toBe(0);
expect(service.recentOrders().map(o => o.id)).toEqual(['o3', 'o2', 'o1']);
}));
it('setIntervalSeconds updates intervalMs and rejects invalid values', () => {
service.setIntervalSeconds(30);
expect(service.intervalMs()).toBe(30000);
service.setIntervalSeconds(0);
expect(service.intervalMs()).toBe(30000);
service.setIntervalSeconds(-5);
expect(service.intervalMs()).toBe(30000);
});
});
```
Note: `LanguageService` defaults to `'ru'` (see `language.service.ts:23`), which is why the expected route in the second test starts with `'ru'`.
- [ ] **Step 3: Run test to verify it fails**
Run: `npm run test -- --include='**/admin-order-watcher.service.spec.ts'`
Expected: FAIL — `admin-order-watcher.service.ts` does not exist yet.
- [ ] **Step 4: Implement**
Create `src/app/features/admin/shell/services/admin-order-watcher.service.ts`:
```typescript
import { Injectable, Signal, computed, inject, signal } from '@angular/core';
import { AdminOrder } from '../../orders/models/admin-order.model';
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
import { LocalStorageService } from '../../../../core/storage/local-storage.service';
import { UserNotificationService } from '../../../website/user-experience/services/user-notification.service';
import { LanguageService } from '../../../../services/language.service';
import { TranslateService } from '../../../../i18n/translate.service';
const LAST_NOTIFIED_KEY = 'adminOrderWatcher.lastNotifiedOrderId.v1';
const LAST_ACKNOWLEDGED_KEY = 'adminOrderWatcher.lastAcknowledgedOrderId.v1';
const POLL_INTERVAL_KEY = 'adminOrderWatcher.pollIntervalMs.v1';
export const DEFAULT_POLL_INTERVAL_MS = 15000;
const MIN_POLL_INTERVAL_MS = 1000;
const RECENT_ORDERS_LIMIT = 20;
const TOAST_DURATION_MS = 4000;
@Injectable({ providedIn: 'root' })
export class AdminOrderWatcherService {
private readonly gateway = inject(AdminOrdersLocalGateway);
private readonly storage = inject(LocalStorageService);
private readonly notifications = inject(UserNotificationService);
private readonly languageService = inject(LanguageService);
private readonly i18n = inject(TranslateService);
private readonly recentOrdersSignal = signal<AdminOrder[]>([]);
readonly recentOrders: Signal<AdminOrder[]> = this.recentOrdersSignal.asReadonly();
private readonly lastAcknowledgedOrderIdSignal = signal<string | null>(this.storage.getItem(LAST_ACKNOWLEDGED_KEY));
readonly unreadCount = computed(() => {
const orders = this.recentOrdersSignal();
if (orders.length === 0) {
return 0;
}
const ackId = this.lastAcknowledgedOrderIdSignal();
if (ackId === null) {
return orders.length;
}
const idx = orders.findIndex(order => order.id === ackId);
return idx === -1 ? orders.length : idx;
});
private readonly intervalMsSignal = signal<number>(this.readStoredIntervalMs());
readonly intervalMs: Signal<number> = this.intervalMsSignal.asReadonly();
private lastNotifiedOrderId: string | null = this.storage.getItem(LAST_NOTIFIED_KEY);
private timerId: ReturnType<typeof setInterval> | null = null;
private started = false;
start(): void {
if (this.started) {
return;
}
this.started = true;
this.poll();
this.scheduleNext();
}
setIntervalSeconds(seconds: number): void {
if (!Number.isFinite(seconds) || seconds < MIN_POLL_INTERVAL_MS / 1000) {
return;
}
const ms = Math.round(seconds * 1000);
this.intervalMsSignal.set(ms);
this.storage.setItem(POLL_INTERVAL_KEY, String(ms));
if (this.started) {
this.scheduleNext();
}
}
markAllSeen(): void {
const newestId = this.recentOrdersSignal()[0]?.id ?? null;
this.lastAcknowledgedOrderIdSignal.set(newestId);
if (newestId) {
this.storage.setItem(LAST_ACKNOWLEDGED_KEY, newestId);
}
}
private scheduleNext(): void {
if (this.timerId !== null) {
clearInterval(this.timerId);
}
this.timerId = setInterval(() => this.poll(), this.intervalMsSignal());
}
private poll(): void {
this.gateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: RECENT_ORDERS_LIMIT }).subscribe({
next: result => this.handleOrders(result.items),
error: err => console.error('Error polling for new orders:', err),
});
}
private handleOrders(items: AdminOrder[]): void {
this.recentOrdersSignal.set(items);
if (items.length === 0) {
return;
}
const isFirstPoll = this.lastNotifiedOrderId === null;
const notifyIndex = isFirstPoll ? -1 : items.findIndex(order => order.id === this.lastNotifiedOrderId);
const newOrders = isFirstPoll ? [] : (notifyIndex === -1 ? items : items.slice(0, notifyIndex));
this.lastNotifiedOrderId = items[0].id;
this.storage.setItem(LAST_NOTIFIED_KEY, this.lastNotifiedOrderId);
if (isFirstPoll) {
// Nothing existed to compare against yet - treat current orders as already
// acknowledged so a fresh admin session doesn't see the whole history as unread.
if (this.lastAcknowledgedOrderIdSignal() === null) {
this.lastAcknowledgedOrderIdSignal.set(items[0].id);
this.storage.setItem(LAST_ACKNOWLEDGED_KEY, items[0].id);
}
return;
}
for (let i = newOrders.length - 1; i >= 0; i--) {
const order = newOrders[i];
this.notifications.show(
this.i18n.t('adminShell.topbar.notificationNewOrder', { orderNumber: order.orderNumber }),
'info',
TOAST_DURATION_MS,
[this.languageService.currentLanguage(), 'backoffice', 'orders', order.id]
);
}
}
private readStoredIntervalMs(): number {
const stored = Number(this.storage.getItem(POLL_INTERVAL_KEY));
return Number.isFinite(stored) && stored >= MIN_POLL_INTERVAL_MS ? stored : DEFAULT_POLL_INTERVAL_MS;
}
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `npm run test -- --include='**/admin-order-watcher.service.spec.ts'`
Expected: PASS (4 specs)
- [ ] **Step 6: Commit**
```bash
git add src/app/features/admin/shell/services/admin-order-watcher.service.ts src/app/features/admin/shell/services/admin-order-watcher.service.spec.ts src/app/i18n/translations.ts src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts
git commit -m "feat: AdminOrderWatcherService polls for new orders and toasts"
```
---
### Task 3: Wire the watcher into the admin topbar bell
**Files:**
- Modify: `src/app/features/admin/shell/admin-layout.component.ts`
- Modify: `src/app/features/admin/shell/admin-layout.component.html`
- Modify: `src/app/features/admin/shell/admin-layout.component.scss`
- Test: `src/app/features/admin/shell/admin-layout.component.spec.ts` (new)
**Interfaces:**
- Consumes: `AdminOrderWatcherService.{recentOrders, unreadCount, start, markAllSeen}` (Task 2)
- [ ] **Step 1: Write the failing test**
Create `src/app/features/admin/shell/admin-layout.component.spec.ts`:
```typescript
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { signal } from '@angular/core';
import { AdminLayoutComponent } from './admin-layout.component';
import { AdminOrderWatcherService } from './services/admin-order-watcher.service';
import { AdminOrder } from '../orders/models/admin-order.model';
function makeOrder(id: string, orderNumber: string): AdminOrder {
return {
id,
orderNumber,
status: 'pending',
customer: { name: 'Test Customer', email: 't@example.com', phone: '' },
payment: { method: 'card', status: 'paid', amount: 500, currency: 'RUB' },
shipping: { address: '', method: '', trackingNumber: '' },
items: [],
total: 500,
currency: 'RUB',
notes: '',
internalNotes: '',
timeline: [],
archived: false,
createdAt: '2026-08-15T10:00:00.000Z',
updatedAt: '2026-08-15T10:00:00.000Z',
};
}
describe('AdminLayoutComponent notifications bell', () => {
let watcherStub: {
recentOrders: ReturnType<typeof signal<AdminOrder[]>>;
unreadCount: ReturnType<typeof signal<number>>;
start: jasmine.Spy;
markAllSeen: jasmine.Spy;
};
beforeEach(() => {
watcherStub = {
recentOrders: signal<AdminOrder[]>([makeOrder('o1', '1001')]),
unreadCount: signal(1),
start: jasmine.createSpy('start'),
markAllSeen: jasmine.createSpy('markAllSeen'),
};
TestBed.configureTestingModule({
imports: [AdminLayoutComponent],
providers: [
provideRouter([]),
{ provide: AdminOrderWatcherService, useValue: watcherStub },
],
});
});
it('starts the watcher once on construction', () => {
TestBed.createComponent(AdminLayoutComponent);
expect(watcherStub.start).toHaveBeenCalledTimes(1);
});
it('exposes unreadCount and recentOrders from the watcher', () => {
const fixture = TestBed.createComponent(AdminLayoutComponent);
const component = fixture.componentInstance;
expect(component.unreadCount()).toBe(1);
expect(component.recentOrders().map(o => o.id)).toEqual(['o1']);
});
it('marks orders seen when the notifications panel opens', () => {
const fixture = TestBed.createComponent(AdminLayoutComponent);
const component = fixture.componentInstance;
component.toggleNotifications();
expect(component.notificationsOpen()).toBe(true);
expect(watcherStub.markAllSeen).toHaveBeenCalledTimes(1);
component.toggleNotifications();
expect(component.notificationsOpen()).toBe(false);
expect(watcherStub.markAllSeen).toHaveBeenCalledTimes(1);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm run test -- --include='**/admin-layout.component.spec.ts'`
Expected: FAIL — `AdminOrderWatcherService` not referenced by the component yet, `unreadCount`/`recentOrders` don't exist on `AdminLayoutComponent`.
- [ ] **Step 3: Implement**
In `src/app/features/admin/shell/admin-layout.component.ts`, add the import and field (place near the other service injections):
```typescript
import { AdminOrderWatcherService } from './services/admin-order-watcher.service';
```
```typescript
private readonly orderWatcher = inject(AdminOrderWatcherService);
readonly unreadCount = this.orderWatcher.unreadCount;
readonly recentOrders = this.orderWatcher.recentOrders;
```
In the constructor, after `this.readRouteData();`, add:
```typescript
this.orderWatcher.start();
```
Replace the `toggleNotifications` method:
```typescript
toggleNotifications(): void {
this.notificationsOpen.update(open => !open);
if (this.notificationsOpen()) {
this.orderWatcher.markAllSeen();
}
}
```
Add a navigation helper next to `adminLinkFor`:
```typescript
goToOrder(orderId: string): void {
void this.router.navigate([this.currentLang(), 'backoffice', 'orders', orderId]);
this.notificationsOpen.set(false);
}
```
In `src/app/features/admin/shell/admin-layout.component.html`, replace the notifications block (lines 141-157):
```html
<div class="admin-layout__notifications">
<button
type="button"
class="admin-layout__icon-button"
aria-haspopup="true"
[attr.aria-expanded]="notificationsOpen()"
[attr.aria-label]="'adminShell.topbar.notifications' | translate"
(click)="toggleNotifications()"
>
<app-icon name="bell" [size]="18" />
@if (unreadCount() > 0) {
<span class="admin-layout__notifications-badge">{{ unreadCount() }}</span>
}
</button>
@if (notificationsOpen()) {
<div class="admin-layout__notifications-panel" role="menu">
@if (recentOrders().length === 0) {
<p>{{ 'adminShell.topbar.notificationsEmpty' | translate }}</p>
} @else {
@for (order of recentOrders(); track order.id) {
<button
type="button"
class="admin-layout__notification-item"
role="menuitem"
(click)="goToOrder(order.id)"
>
<span class="admin-layout__notification-order">#{{ order.orderNumber }}</span>
<span class="admin-layout__notification-customer">{{ order.customer.name }}</span>
<span class="admin-layout__notification-amount">{{ order.total }} {{ order.currency }}</span>
</button>
}
}
</div>
}
</div>
```
In `src/app/features/admin/shell/admin-layout.component.scss`, add (near other `.admin-layout__notifications*` rules if any exist, otherwise at the end):
```scss
.admin-layout__notifications {
position: relative;
}
.admin-layout__notifications-badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: var(--color-danger, #ef4444);
color: #fff;
font-size: 10px;
line-height: 16px;
text-align: center;
}
.admin-layout__notification-item {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 8px 10px;
border: none;
background: none;
text-align: left;
cursor: pointer;
border-radius: var(--radius-sm, 4px);
}
.admin-layout__notification-item:hover {
background: var(--bg-secondary, #f4f6f5);
}
.admin-layout__notification-order { font-weight: var(--font-weight-medium, 500); }
.admin-layout__notification-customer { color: var(--text-secondary, #6b7280); font-size: var(--font-size-sm, 0.8125rem); }
.admin-layout__notification-amount { font-size: var(--font-size-sm, 0.8125rem); }
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm run test -- --include='**/admin-layout.component.spec.ts'`
Expected: PASS (3 specs)
- [ ] **Step 5: Run full build to catch template errors**
Run: `npx ng build --configuration development`
Expected: build succeeds, no template compile errors.
- [ ] **Step 6: Commit**
```bash
git add src/app/features/admin/shell/admin-layout.component.ts src/app/features/admin/shell/admin-layout.component.html src/app/features/admin/shell/admin-layout.component.scss src/app/features/admin/shell/admin-layout.component.spec.ts
git commit -m "feat: wire order watcher into admin topbar bell (badge + panel)"
```
---
### Task 4: Editable poll interval in admin settings
**Files:**
- Modify: `src/app/features/admin/settings/pages/admin-settings-page.component.ts`
- Modify: `src/app/features/admin/settings/pages/admin-settings-page.component.html`
- Modify: `src/app/i18n/translations.ts`, `src/app/i18n/en.ts`, `src/app/i18n/ru.ts`, `src/app/i18n/hy.ts`
- Test: `src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts` (new)
**Interfaces:**
- Consumes: `AdminOrderWatcherService.{intervalMs, setIntervalSeconds}` (Task 2)
- [ ] **Step 1: Add i18n keys**
In `src/app/i18n/translations.ts`, inside `adminSettings:` (after `currencyRatesSaved: string;`):
```typescript
notificationInterval: string;
notificationIntervalExplain: string;
notificationIntervalSave: string;
notificationIntervalSaved: string;
```
In `src/app/i18n/en.ts`, inside `adminSettings` (after `currencyRatesSaved: 'Rates saved',`):
```typescript
notificationInterval: 'New-order check interval (seconds)',
notificationIntervalExplain: 'How often the admin panel polls for new orders to show a notification.',
notificationIntervalSave: 'Save interval',
notificationIntervalSaved: 'Interval saved',
```
In `src/app/i18n/ru.ts`, inside `adminSettings` (after `currencyRatesSaved: 'Курсы сохранены',`):
```typescript
notificationInterval: 'Интервал проверки новых заказов (сек)',
notificationIntervalExplain: 'Как часто админ-панель проверяет новые заказы для уведомления.',
notificationIntervalSave: 'Сохранить интервал',
notificationIntervalSaved: 'Интервал сохранён',
```
In `src/app/i18n/hy.ts`, inside `adminSettings` (after `currencyRatesSaved: 'Փոխարժեքները պահպանվեցին',`):
```typescript
notificationInterval: 'Նոր պատվերների ստուգման ինտերվալ (վրկ)',
notificationIntervalExplain: 'Որքան հաճախ է ադմին վահանակը ստուգում նոր պատվերներ ծանուցման համար։',
notificationIntervalSave: 'Պահպանել ինտերվալը',
notificationIntervalSaved: 'Ինտերվալը պահպանվեց',
```
- [ ] **Step 2: Write the failing test**
Create `src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts`:
```typescript
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { signal } from '@angular/core';
import { AdminSettingsPageComponent } from './admin-settings-page.component';
import { AdminOrderWatcherService } from '../../shell/services/admin-order-watcher.service';
describe('AdminSettingsPageComponent notification interval', () => {
let watcherStub: {
intervalMs: ReturnType<typeof signal<number>>;
setIntervalSeconds: jasmine.Spy;
};
beforeEach(() => {
watcherStub = {
intervalMs: signal(15000),
setIntervalSeconds: jasmine.createSpy('setIntervalSeconds'),
};
TestBed.configureTestingModule({
imports: [AdminSettingsPageComponent],
providers: [
provideRouter([]),
{ provide: AdminOrderWatcherService, useValue: watcherStub },
],
});
});
it('initializes the draft from the current interval in seconds', () => {
const fixture = TestBed.createComponent(AdminSettingsPageComponent);
expect(fixture.componentInstance.notificationIntervalSecondsDraft()).toBe(15);
});
it('saveNotificationInterval calls setIntervalSeconds with the draft value', () => {
const fixture = TestBed.createComponent(AdminSettingsPageComponent);
const component = fixture.componentInstance;
component.notificationIntervalSecondsDraft.set(30);
component.saveNotificationInterval();
expect(watcherStub.setIntervalSeconds).toHaveBeenCalledWith(30);
});
});
```
- [ ] **Step 3: Run test to verify it fails**
Run: `npm run test -- --include='**/admin-settings-page.component.spec.ts'`
Expected: FAIL — `notificationIntervalSecondsDraft`/`saveNotificationInterval` don't exist yet.
- [ ] **Step 4: Implement**
In `src/app/features/admin/settings/pages/admin-settings-page.component.ts`, add the import:
```typescript
import { AdminOrderWatcherService } from '../../shell/services/admin-order-watcher.service';
```
Add the field and methods to the class (alongside the currency-rates fields):
```typescript
readonly orderWatcher = inject(AdminOrderWatcherService);
readonly notificationIntervalSecondsDraft = signal(Math.round(this.orderWatcher.intervalMs() / 1000));
readonly showNotificationIntervalSaved = signal(false);
saveNotificationInterval(): void {
this.orderWatcher.setIntervalSeconds(this.notificationIntervalSecondsDraft());
this.showNotificationIntervalSaved.set(true);
setTimeout(() => this.showNotificationIntervalSaved.set(false), SAVED_MESSAGE_DURATION_MS);
}
```
In `src/app/features/admin/settings/pages/admin-settings-page.component.html`, add a new `.settings-card` block after the currency-rates one (before the closing `</section>`):
```html
<div class="settings-card">
<h2>{{ 'adminSettings.notificationInterval' | translate }}</h2>
<p class="settings-explain">{{ 'adminSettings.notificationIntervalExplain' | translate }}</p>
<div class="rate-row">
<input
class="rate-input"
type="number"
min="1"
step="1"
[ngModel]="notificationIntervalSecondsDraft()"
(ngModelChange)="notificationIntervalSecondsDraft.set($event)"
/>
</div>
<div class="rate-actions">
<button type="button" class="save-button" (click)="saveNotificationInterval()">{{ 'adminSettings.notificationIntervalSave' | translate }}</button>
<span class="saved-message" *ngIf="showNotificationIntervalSaved()">{{ 'adminSettings.notificationIntervalSaved' | translate }}</span>
</div>
</div>
```
- [ ] **Step 5: Run test to verify it passes**
Run: `npm run test -- --include='**/admin-settings-page.component.spec.ts'`
Expected: PASS (2 specs)
- [ ] **Step 6: Full verification**
Run: `npx tsc --noEmit -p tsconfig.json`
Expected: no errors.
Run: `npx ng build --configuration development`
Expected: build succeeds.
Run: `npm run test -- --include='**/admin-order-watcher.service.spec.ts' --include='**/admin-layout.component.spec.ts' --include='**/admin-settings-page.component.spec.ts' --include='**/user-notification.service.spec.ts'`
Expected: all specs PASS.
- [ ] **Step 7: Commit**
```bash
git add src/app/features/admin/settings/pages/admin-settings-page.component.ts src/app/features/admin/settings/pages/admin-settings-page.component.html src/app/features/admin/settings/pages/admin-settings-page.component.spec.ts src/app/i18n/translations.ts src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts
git commit -m "feat: editable new-order poll interval in admin settings"
```
---
## Manual Verification (after all tasks)
1. `npm run barry -- kb search --source cq --query "admin order notifications"` if KB sharing is enabled (skip if local-only — see project `CLAUDE.md`).
2. Start the dev server, log into `/backoffice`, leave the tab open.
3. In another tab (or via `admin-orders-local.gateway.ts`'s seed data timing), wait for the poll interval — confirm no toast fires on first load.
4. Trigger a new order (checkout flow in `cart.component.ts`, or temporarily lower `SEED_COUNT`/seed timing in `admin-orders-local.gateway.ts` to simulate one — revert after testing) and confirm: toast appears with the order number, bell badge shows `1`, clicking either navigates to `/backoffice/orders/:id`.
5. Open the bell panel without clicking a row — confirm badge clears but the order still lists in the panel.
6. Change the interval in Admin Settings, save, confirm the toast "Interval saved" message. no crash on next poll cycle.

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,38 @@
# Admin product view count column — design
**Status:** Approved
**Date:** 2026-08-15
**Related backlog item:** #1 (site traffic counter)
## Problem
User reported "site traffic isn't visible, counter shows low." Investigation found two separate things already exist and are working as intended, neither of which is the actual gap:
- Admin Analytics → Traffic tab already shows an honest `"Unknown - available after backend"` badge (`admin-analytics-page.component.html:231`) — no fake data, correctly reflects that no traffic-tracking pipeline exists at all (`BACKEND-API-REFERENCE.md` §10 step 10).
- The storefront `Item.visits` field is wired end-to-end from the live backend (`api.service.ts:438`) but is never rendered anywhere in the UI, and the backend mock always seeds it `0`.
User confirmed (via clarifying question) the actual complaint is: **no per-product view count visible in Admin Products.**
Further investigation found Admin Products runs on a fully separate mock domain (`AdminProduct` model, `admin-products-local.gateway.ts`, seeded from `list.json`) that has no relationship to the storefront's live `Item.visits` pipeline at all. So a "Views" column here cannot show real per-product traffic today — there is no data source for it in the admin domain. This mirrors the currency/FX and order-notification gaps already documented this session: build the honest client-side piece, document the backend gap explicitly, never fabricate numbers.
## Design
**Model:** add `visits: number` to `AdminProduct` (`src/app/features/admin/products/models/admin-product.model.ts`), alongside the other stat-like fields (`priority`, `quantity`).
**Mock gateway:** `admin-products-local.gateway.ts` defaults `visits: 0` when building the in-memory seed from `list.json` — no fabricated numbers, matches the field's actual state (nothing increments it yet).
**List column:** `ALL_PRODUCT_COLUMNS` (`admin-products.facade.ts:39`) gains `'visits'`. Rendered in `admin-products-list.component.html` table view only (grid view is out of scope per user's placement choice), following the exact existing `isColumnVisible('stock')`/`isColumnVisible('price')` pattern — toggleable via the same column-picker UI, persisted the same way (`LocalStorageService`, `COLUMNS_KEY`).
**i18n:** one new key, `adminProducts.views` (label for the column header), added to `en.ts`/`ru.ts`/`hy.ts`/`translations.ts`.
## Backend doc update
New `BACKEND-API-REFERENCE.md` §12.x ask (numbered after the existing 12.8, following the established "Gap / Ask" format): the admin Products domain has no view-count source. Two options to raise:
1. Once admin Products gets a real backend (§10 step 4), include a view/visit count per product in the response.
2. Alternatively, bridge to the storefront's already-live `Item.visits` (§6, `/items/{id}`) by product id — smaller change if a unified product identity exists between the storefront and admin domains.
## Out of scope
- Storefront customer-facing "N people viewed this" display — not requested, deferred (was offered as a placement option, not chosen).
- Product edit/detail page display — not requested (list column only, per user's placement choice).
- Any client-side view tracking/incrementing — explicitly rejected in favor of the honest display-only approach; a client-only counter would only reflect the admin's own browser, not real shoppers, same trap already avoided for currency rates.

View File

@@ -0,0 +1,86 @@
# Admin purchase notifications — design
**Status:** Approved
**Date:** 2026-08-15
**Related backlog item:** #7 (marked ВАЖНО — important)
## Problem
Admin has no signal when a purchase happens on the marketplace. Orders only surface if
someone manually opens the Orders list and refreshes. Backend exposes no WebSocket/SSE
(confirmed in `BACKEND-API-REFERENCE.md:20` — every "live" feature today, e.g. payment
status, is plain polling), so this has to be poll-based like the rest of the app.
## Architecture
**`AdminOrderWatcherService`** (new, `providedIn: root`, admin-scoped)
- Polls `AdminOrdersLocalGateway.loadOrders()` (sorted `createdAt` desc, already the
default sort) on an interval.
- Diffs the newest order's `id`/`createdAt` against the last-seen value, kept in memory
and persisted via `LocalStorageService` (survives page reload, same pattern as
`AdminPreferencesService`).
- On finding order(s) newer than last-seen: fires one toast per new order and
increments an `unreadCount` signal.
- Started once at the admin shell root, so it keeps polling regardless of which admin
page is open.
**Poll interval**
- Editable by admin, default 15s.
- Setting lives in the same admin-settings page as currency rates
(`admin-settings-page.component.ts`), persisted via `LocalStorageService`.
**Toast delivery**
- Reuses the existing `UserNotificationService` / `FloatingNotificationsComponent`
(already global — `providedIn: root`, mounted once in `app.html`). No new toast UI.
- `UserNotification` gains an optional `route: string[]` field.
- `FloatingNotificationsComponent` gets a click handler: navigate to `route` (if set)
then dismiss.
**Badge — reuses existing topbar bell**
`admin-layout.component.html:141-157` already has an unused bell icon +
dropdown panel (currently hardcoded to always show "no notifications").
Wire the watcher's data into it instead of adding a new indicator:
- `unreadCount` signal (from `AdminOrderWatcherService`) rendered as a badge
on the bell icon (`admin-layout__icon-button`).
- Opening the panel (`notificationsOpen()`, already wired to the bell click)
lists the unread new orders instead of the static "notificationsEmpty"
text.
- Opening the panel marks all currently-known orders as seen → badge resets
to 0 (same trigger `AdminLayoutComponent.toggleNotifications()` already
has).
**Click behavior**
- Toast click → `/admin/orders/:id` (the new order's detail page).
- Clicking an order row inside the bell panel → same, then closes the panel.
## Data flow
```
AdminOrderWatcherService (interval timer)
-> AdminOrdersLocalGateway.loadOrders()
-> diff against last-seen order id/createdAt (LocalStorageService)
-> new order(s) found?
-> UserNotificationService.show(message, 'info', { route: ['/admin/orders', id] })
-> unreadCount.update(n => n + 1)
-> admin clicks toast/badge -> router navigate -> orders-list visit resets unreadCount
```
## Error handling
Poll failures are silent/logged only (`console.error`), consistent with existing
polling code (payment status polling in `cart.component.ts`). No toast spam on
transient network errors — watcher just retries on the next interval.
## Out of scope
- Native OS push notifications (tab not focused) — user explicitly chose in-app
toast/badge only, not browser Notification API.
- Sound alerts — not selected.
- Telegram/email alerts to staff — not selected, would need backend bot/mail
integration.

View File

@@ -0,0 +1,83 @@
# Email/phone customer login (OTP) — design
**Status:** Approved
**Date:** 2026-08-15
**Related backlog item:** #4 (Telegram-only identification)
## Problem
Customer storefront login/checkout requires Telegram today (`src/app/services/auth.service.ts`, `TelegramSessionApiService`) — shoppers without Telegram have no way to identify themselves. User asked for email/phone as an alternative.
Backend has zero email/phone/OTP/password infrastructure — only Telegram session polling exists (`BACKEND-API-REFERENCE.md` §2a). Building real authentication client-side is not possible; this is fundamentally a backend feature. Per user's explicit choice, this round produces the design + backend spec only — no client UI/code, since there is no real backend to build a working feature against yet (mirrors the currency-FX and admin-notifications backend-dependency pattern already documented this session).
## Design
**Mechanism: OTP code (email or SMS)**, chosen over magic link (email-only, extra click) and password (heaviest backend lift — storage, hashing, reset flow). Passwordless matches the feel of the existing Telegram QR flow.
**A third, independent auth mechanism** — coexists with Telegram QR (§2a) and admin Ed25519 (§2b, still unimplemented) exactly the way those two already coexist. Does not replace or modify either.
### Proposed backend endpoints
```
POST /auth/otp/request
Body: { "identifier": "user@example.com" } // or E.164 phone: "+79991234567"
Response: { "requestId": "...", "expiresAt": "2026-08-15T10:15:00Z" }
```
```
POST /auth/otp/verify
Body: { "requestId": "...", "code": "482913" }
Response (on success): {
"sessionId": "...",
"userId": 8823771,
"username": null,
"displayName": "user@example.com",
"active": true,
"expires": "2026-08-15T11:15:00Z"
}
```
The success response is shaped identically to the existing `AuthSession` model (`src/app/models/auth.model.ts`) — `sessionId`, `userId`, `username`, `displayName`, `active`, `expires`. This is deliberate: every downstream consumer (auth guards, session signals, cart/checkout) already works against `AuthSession` regardless of which mechanism produced it, so wiring this in later requires no changes to guards or session state — only a new "request/verify" UI flow that ends by populating the same session shape Telegram QR already produces.
**Rate limiting / expiry (backend-enforced, not left implicit):**
- Resend cooldown: 60s between `POST /auth/otp/request` calls for the same identifier.
- Code expiry: 10 minutes from issuance.
- `requestId` allows up to 5 verify attempts before it's invalidated — consumed on success, on the 5th wrong attempt, or on expiry, whichever comes first. (Revised from an earlier single-use-per-attempt draft: burning the whole request on one typo is bad UX — a shopper should be able to correct a mistyped digit without waiting out a fresh 60s cooldown.)
### Admin-configurable login methods
Admin can enable/disable each login method independently — Telegram QR, Email OTP, Phone OTP — via three checkboxes in Admin Settings, same section/pattern as the existing currency-rates and notification-interval settings (`admin-settings-page.component.ts`, `LocalStorageService`-persisted signal).
- Default: all three enabled — a settings change must never silently lock shoppers out.
- A new `AuthMethodsService` (or an extension of the existing settings service) exposes `enabledMethods: Signal<('telegram' | 'email' | 'phone')[]>`. The storefront login screen reads it and only renders buttons for enabled methods; if exactly one is enabled, skip the method-picker screen entirely and go straight to it.
- Purely a client-side UI gate — the backend OTP endpoints stay unconditionally available; disabling "Email OTP" in admin just hides the button, it doesn't need a corresponding backend flag. (Same category of client-only gate as the existing `adminAuthGuard`/permission checks — real enforcement, if ever needed, would be a separate backend concern.)
### Error handling
The codebase has an established error envelope (`BACKEND-API-REFERENCE.md` §5: `error.code`, `error.message`, `error.status`, `error.details`) explicitly flagged as "recommended for new endpoints, not wired anywhere yet." Since the OTP endpoints are new, this is the natural first real adopter — every response maps to a specific code, not just an HTTP status:
| `error.code` | HTTP status | UX |
|---|---|---|
| `VALIDATION_FAILED` | 422 | Inline field error under the identifier input, sourced from `error.details[0].message` — same pattern the client already uses for local validation errors (`cart.component.ts`'s email/phone inline errors), so a 422 slots into the existing inline-error UI without inventing a second display mechanism. |
| `RATE_LIMITED` | 429 | "Too many attempts — try again in Ns," countdown derived from `error.details`/`Retry-After` if present, otherwise a flat 60s. Resend button stays disabled until the countdown ends. |
| `CODE_EXPIRED` | 410 | "This code expired — request a new one." Auto-focuses/enables the resend action; does not silently re-send. |
| `CODE_INVALID` | 401 | "Wrong code, try again" — stays on the code-entry screen (does not consume the whole flow; see the 5-attempt allowance above). Shows the remaining-attempts count once ≤2 remain. |
| `REQUEST_NOT_FOUND` | 404 | `requestId` unknown/already invalidated (5 wrong attempts, expiry, or a stale reload) — "This login attempt is no longer valid, start again," returns to the identifier-entry step. |
| Anything else / network error / 5xx | — | Generic fallback: "Something went wrong. Try again, or use a different login method" — the second half of that sentence is a real, populated action, not filler text: it surfaces whichever other methods are currently enabled per the admin toggle above (e.g. falls back to the Telegram QR button), not just a dead-end retry link. |
**Identifier validation:** email vs. phone format is auto-detected client-side. Extract the validation logic already written inline in `cart.component.ts` (`validateEmail`/`validatePhone`, currently only used for post-purchase contact capture) into a shared utility rather than duplicating it when the client UI is eventually built — the same email/phone shape-checking applies to both use cases.
### Future client UI (not built this round)
A "Login with email or phone" option next to the existing Telegram QR button: identifier entry → code entry → session established. Deferred until the backend endpoints above exist — no client code to write against a 404.
## Backend doc update
New `BACKEND-API-REFERENCE.md` §2c ("Email/phone OTP login — customer (NOT IMPLEMENTED)"), following the same Gap/Ask format as the existing §12.x entries, documenting the two endpoints, the response-shape compatibility requirement, and the rate-limit/expiry asks above.
## Out of scope
- Admin backoffice login — user confirmed this round is customer-storefront only (item 4 was split into two potential specs during brainstorming; admin auth is a separate future spec if wanted).
- Magic link and password mechanisms — considered, OTP chosen.
- Any client-side UI or session-handling code — explicitly deferred; nothing to build against a non-existent backend.
- Account merging (e.g. a shopper who later links Telegram + email to the same identity) — not raised, not designed.

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,201 @@
# Fork Harvest — Design / Working Description
**Branch:** `improvements/fork-harvest` (cut from `B2B` @ `92f1c88`)
**Date:** 2026-08-21
**Input:** [FORK-ANALYSIS-2026-08-21.md](../../FORK-ANALYSIS-2026-08-21.md)
**Companion:** [FORK-HARVEST-TODO.md](../../FORK-HARVEST-TODO.md)
---
## 1. Purpose
Take **only the improvements** from the `hub.numus.cc/numus/marketplaces` archive. Nothing else. No architecture adoption, no code copying, no rewrite, no framework regression.
The archive is a competing platform monorepo, not a fork of us. It contains our repo verbatim as `reference/parallel-frontend/`. It is ahead of us on backend truth and operations, behind us on frontend depth, testing, and framework currency.
This document is the working brief for whoever executes the harvest — including a future session of me. It states what is true today in this repo, what changes, and how each item is proven done.
---
## 2. Constraint that shapes everything
**This repo has no backend.** 530 `.ts` files, Angular 22, zero server code. Our backend exists only as 17 contract documents in `docs/backend/`, implemented by another team.
That splits every harvested improvement into one of five lanes:
| Lane | Meaning | Where it lands |
|---|---|---|
| **A — Frontend** | We write the code, this sprint | `src/`, `e2e/`, `angular.json`, CI |
| **B — Contracts** | We write the requirement; backend implements | `docs/backend/*.md` |
| **C — Packages** | Ships in `@marketplaces/auth` (external repo `vitanovaPackages`) | package repo + DI wiring here |
| **D — Infra/Ops** | Deploy scripts and runbook | `scripts/deploy/`, `docs/DEPLOYMENT.md` |
| **E — Process** | Governance, gates, policy | `../../backend/BACKEND-INTEGRATION.md`, ADRs |
Anything that would require us to stand up Prisma, Postgres, or NestJS in *this* repo is out of scope. It becomes a Lane B contract line instead.
---
## 3. What we verified in our own code (2026-08-21, current HEAD)
Their audit was written against our 11 Aug snapshot. Re-checked against today's code:
| Their finding | Status now | Evidence |
|---|---|---|
| Admin session cookie set by JS, readable via `document.cookie` | **Already fixed** | zero `document.cookie` hits in `src/` |
| Admin JWT / refresh token in `localStorage` | **Already fixed** | no `setItem(*token*)` anywhere |
| `http://ip-api.com` from an HTTPS storefront | **STILL LIVE** | `src/app/services/location.service.ts:75` |
| Unconditional `bypassSecurityTrustResourceUrl` on a bank URL | **STILL LIVE** | `src/app/pages/cart/cart.component.ts:485` |
| Client-side `authorization-key` / `userid-value` headers | **STILL LIVE** | `src/app/services/api.service.ts:675` |
| Hardcoded partner ID in the bundle | **STILL LIVE** | `src/app/services/api.service.ts:143``'web-97ec-9c57-4dde-9037-3a68f7f83750'` |
| `localStorage` as persistence | **19 files**, mostly admin facades + editor draft storage | see TODO FH-A7 |
| Bundle 452 kB over a 700 kB budget | **Still true** | their measured build |
| Zero `idempot*` in the codebase | **Still true** | no Idempotency-Key sent on payment creation |
Two of those are live bugs, not just security posture:
- **`http://ip-api.com`** — browsers block mixed active content on an HTTPS origin. `detectLocation()` therefore always takes its error branch in production. Region auto-detect has been silently dead.
- **Bank URL in an iframe** — most acquirer 3-D Secure pages send `X-Frame-Options: DENY` / frame-ancestors CSP. The popup renders blank for those banks. Their spec calls this out explicitly: card checkout should navigate the current tab, not open an intermediate popup.
That reframes three of the "security" items as **defect fixes with a security benefit**, which is a much easier sell and a much better use of the sprint.
---
## 4. Selection rule — what counts as "an improvement"
An item is harvested only if it passes all four:
1. **It is better than what we have**, not merely different.
2. **It survives without their backend.** Either we can build it, or it is a contract line the backend team can implement against.
3. **It does not regress us.** Nothing that drops us to Angular 21, reintroduces mocks, or lowers our test bar.
4. **It is falsifiable.** There is a test, a check, or an observable state that proves it done.
Explicitly rejected by this rule (from the analysis §9): their Angular version, their `mock-data.service.ts`, their 25-test/zero-e2e posture, their env-pinned `ORDER_MANAGER_MARKETPLACE_SLUG`, their hardcoded server IP, their narrower 5-type section schema.
---
## 5. The harvest, by theme
### 5.1 Correctness primitives (the highest-value cluster)
Three patterns from their backend that are worth more than everything else combined, because each replaces application logic with a database guarantee:
**Conditional-UPDATE reservation.** One statement is their entire oversell defence:
```sql
UPDATE "MarketplaceInventory"
SET "reserved" = "reserved" + $qty
WHERE "marketplaceId" = $mp AND "variantId" = $variant
AND ("onHand" - "reserved") >= $qty
RETURNING "id"
```
Empty result set → `409`. No read-then-write window, no advisory lock, no retry loop. Goes into `../../backend/BACKEND-INTEGRATION.md` as a normative requirement, not a suggestion.
**Idempotency as a unique constraint.** `Payment.idempotencyKey UNIQUE` and `PaymentWebhookEvent @@unique([provider, eventKey])`. A duplicate insert throws, and the catch returns `{accepted: true, duplicate: true}`. Replay protection becomes structurally impossible to forget, versus an `if` somebody eventually deletes. Goes into `PHASE-7`.
**Append-only inventory journal.** Every stock change writes `reason`, `referenceType`, `referenceId`, `actorId`, resulting balance. This is the direct answer to the v3.1 plan's "we cannot explain your numbers" complaint — it makes every quantity reconstructible after the fact.
None of these are hard. All three are cheap to specify and expensive to retrofit.
### 5.2 Session and credential hygiene
Their model, which we adopt as the contract target: server-stored sessions, random 32 bytes, **stored as SHA-256 hash only**, HttpOnly + Secure + SameSite, revocable, one distinct cookie per contour (`bo_session` / `manager_session` / `marketplace_session`), Argon2id `memoryCost 65536 / timeCost 3 / parallelism 1`, mandatory TOTP with a signed 10-minute setup token, and password change revoking every live session in the same transaction.
Plus the twelve-line CSRF defence we do not have: a global `onRequest` hook rejecting any non-GET on an admin/manager path whose `Origin` is not in the configured allowlist.
Our side of this is subtractive: stop sending provider credentials from the browser, stop shipping a partner ID literal in the bundle.
### 5.3 Tenant and preview safety
Host → verified domain row → tenant, 30-second cache with explicit invalidation, `404` on unknown host with no fallback tenant. We have bootstrap-driven runtime config and no equivalent guarantee written down.
Their preview mechanism is the piece worth copying outright: an HMAC-signed token carrying `{marketplaceId, expiresAt, nonce}`, 15-minute TTL, delivered as a `storefront_preview` cookie, plus a global hook that returns `404 Preview mode is read-only` for any non-GET while that cookie is present. We have preview UI and no preview safety at all.
### 5.4 Publish, revisions, clone
`version = max(version) + 1`, immutable snapshot row, `publishedRevision` pointer flipped in the same transaction, rollback creates a *new* revision rather than rewriting history. Clone copies design and catalog assignments, **forces inventory to zero**, and never copies domains, customers, orders, or secrets; its category walk is topological with explicit cycle detection.
### 5.5 Product ideas worth taking
- **Order-manager as a fully separate contour** — separate URL, shell, cookie, login, scoped to one marketplace, with no visibility into catalog, design, domains, or payment settings. Removes an entire permissions surface rather than guarding it.
- **Digital goods in one table** — `FulfillmentMode: MANUAL | CODE_POOL`, a `DigitalCode` pool with `AVAILABLE/RESERVED/ASSIGNED/REVOKED`, encrypted values, `valueHash` unique per `(marketplace, variant)`, codes revealed only once the order is `PAID`. We have no digital-goods story; this is a complete one.
- **`DOMAIN_PENDING` as a real marketplace state**, not an error condition.
- **CSV marketplace import with `dryRun` default true** — bulk tenant creation as a first-class operation.
- **Their §22 acceptance list** as a ready-made e2e suite. Two of the fifteen are worth writing immediately: concurrent purchase of the last unit, and a replayed webhook.
### 5.6 Social identity — VK ID and Yandex ID
The archive has **zero** VK/Yandex/OAuth code; there is nothing to copy. What we take is the *session-issuing shape* of their Telegram flow and terminate VK/Yandex into it.
Design decisions, all of which belong in `@marketplaces/auth`:
1. **Provider-agnostic surface.** `SocialIdentityGateway` with a `SocialProvider` union, replacing today's VK-specific `VkIdGateway`. One controller pattern backend-side, one strategy object per provider.
2. **Backend-owned PKCE.** Our current interface passes `codeVerifier` from the client, which forces the browser to generate and hold the verifier. We are a confidential client. The backend generates `state` + `code_verifier`, stores them single-use for 10 minutes, and the browser only ever gets redirected. `completeCallback()` disappears from the frontend entirely.
3. **VK ID gotcha:** the callback returns `device_id` next to `code`, and the token exchange fails without it. This is the single most common VK ID integration bug and it must be in the contract text.
4. **Multi-tenant `redirect_uri` is a one-way door.** Both providers validate `redirect_uri` against an exact registered list; we cannot register one per tenant domain. Resolution: a single central identity host as the only registered callback, tenant carried inside the signed `state`, then a 302 back to the tenant domain with a short-lived signed handoff token the tenant API exchanges for its session cookie. **This must be decided before any code is written.**
5. **Identity conflict is not an upsert.** `@@unique([provider, providerUserId])` so the database refuses a silent rebind; conflicts route to controlled resolution.
Build order: provider-agnostic surface → VK ID → Yandex ID (a second strategy, roughly a day) → migrate Telegram onto `ExternalIdentity` → linking UI → email/phone OTP demoted to recovery.
### 5.7 Operations
Adopt: WAL archiving plus a *scheduled, proven* restore drill; a data network that is `internal: true` so "the database is not reachable from the internet" is structural rather than a firewall promise; host hardening we lack (fail2ban, sshd drop-in, sysctl).
Already better on our side, keep as-is: our `add-domain.sh` already pre-checks the DNS A record and runs `nginx -t` before and after; `server-setup.sh` already configures ufw. Their `provision-domain.sh` hardcodes the server IP — do not copy that shape.
### 5.8 Process
Their `DEVELOPER_HANDOFF.md` §7 is nine falsifiable invariants and is a better acceptance gate than anything currently in our delivery plan. Their PR policy (one functional area per PR; mandatory security impact and rollback plan; never touch payment/inventory/order state machines inside a redesign PR) and their release discipline ("a local build or the existence of a UI does not mean production readiness") are both worth adopting verbatim.
---
## 6. Sequencing
Four waves. Each wave is independently shippable; nothing in a later wave blocks an earlier one.
**Wave 1 — Defect fixes with a security benefit (this sprint, Lane A).**
The three live bugs: geo over HTTP, bank URL in an iframe, provider credentials and partner ID in the bundle. Plus `Idempotency-Key` on payment creation. All frontend, all provable, all things their audit will otherwise keep pointing at.
**Wave 2 — Contract hardening (Lane B, parallel with Wave 1).**
Write the correctness primitives, session model, tenant/preview rules, revision semantics, and inventory journal into `docs/backend/`. Costs no engineering capacity from the frontend team and immediately raises the bar the backend is built to.
**Wave 3 — Proof (Lane A).**
The two acceptance e2e tests, bundle budget as a blocking CI check, and the deployable split that gets us under budget.
**Wave 4 — Identity (Lane C).**
Blocked on the central-identity-host decision. Provider-agnostic surface, VK ID, Yandex ID, Telegram migration, linking UI.
Ops (Lane D) and process (Lane E) run continuously alongside.
---
## 7. Explicitly out of scope
- Merging the two codebases, in either direction.
- Reimplementing their backend here.
- Adopting their section schema, their template list, or their backoffice.
- Any dependency downgrade.
- Removing our boundary checker, cycle check, or coverage floor to match their looser governance.
---
## 8. Risks
| Risk | Mitigation |
|---|---|
| Contract lines in `docs/backend/` are written and never implemented | Pair each with an acceptance scenario in the handoff doc so it is a delivery gate, not a wish |
| The central-identity-host decision slips and blocks all of Wave 4 | It is the first item in the TODO; escalate on day one |
| Removing the client-side payment credential path breaks checkout before the server side exists | Confirm the server-priced checkout session path (already in `api.service.ts`) covers every live flow before deleting the legacy header path |
| The deployable split is larger than estimated | Wave 3 item, not a blocker for Waves 12; can ship the bundle-budget CI check first and let it fail loudly |
| Harvest is read as "they were right about everything" | The analysis records where they are behind us — tests, e2e, framework currency, frontend depth — and the TODO carries no item that regresses those |
---
## 9. Done means
- Every Wave 1 item has a test or an observable check proving it.
- Every Lane B item exists as normative text in `docs/backend/` with an acceptance scenario attached.
- The two §22 acceptance tests run in CI.
- Bundle budget is a blocking check and the storefront is under it.
- VK ID and Yandex ID both log a customer in through `@marketplaces/auth`, with the client never holding a secret, a token, or a code verifier.
- No item in this harvest lowered our Angular version, our test count, or our architecture governance.

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

@@ -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.

39
e2e/README.md Normal file
View File

@@ -0,0 +1,39 @@
# E2E — Playwright
Track Q (`docs/PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md`, Q1). None of this existed before 2026-08-18.
## Run
```bash
npm run e2e # headless, boots the dev server automatically
npm run e2e:ui # interactive runner
npm run e2e:report # last HTML report
```
Against a different server (staging, a locally-started backend):
```bash
BASE_URL=https://staging.example.com npm run e2e
```
## What this suite currently covers, and what it doesn't
`environment.ts` ships `useMockData: false` — the dev server this suite boots hits real `/api/` endpoints, which 404 (`../docs/backend/BACKEND-INTEGRATION.md` — no backend is running anywhere this session can reach). The product catalog itself renders from a separate mocked bootstrap/catalog path (`useMockBootstrapOnLocal: true`), so real prices and real currency conversion ARE exercised — `smoke.spec.ts` explicitly ignores the expected 404 console noise rather than pretending it isn't there.
**This is not the same guarantee as running against a live backend.** Checkout, payment, and anything behind a real endpoint are not covered until `BASE_URL` points at a live environment. Confirmed once, concretely: on the first run, this suite caught a real bug (`@marketplaces/auth` shipping without Angular Ivy metadata, breaking app bootstrap) and a real test defect (a duplicate hidden dropdown made the first currency-switch attempt click a no-op element) — both fixed as part of standing this suite up. See the commit history in `src/main.ts` and this directory for what each was.
## Files
| File | Covers |
|---|---|
| `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` | 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
- Prefer existing CSS classes / ARIA roles already in the templates (`.currency-button`, `role="option"`, etc.) over inventing new selectors — there are no `data-testid` attributes in this codebase yet, and adding them project-wide is out of scope for this suite.
- One behavior per test. Name the file after the behavior, not the page.
- If a test needs backend state that mock data can't produce, mark it `test.skip(!process.env.BASE_URL, 'needs a live backend')` rather than deleting it — it documents the gap.

View File

@@ -0,0 +1,27 @@
import { expect, test } from '@playwright/test';
/**
* Track Q Q2 / frontend backlog F59: past "verified live" admin claims were
* code-inspection only, because /backoffice needs a real Telegram login this
* suite cannot perform. ?devBypassAdmin=true (src/app/app.ts, gated by
* Angular's isDevMode() at runtime in @marketplaces/auth's
* AdminAuthService.devBypassLogin - not just build-time, and a no-op in any
* production build) is the existing, already-shipped answer - this test just
* proves it actually gets an E2E run into the admin shell.
*/
test.describe('admin dev bypass', () => {
test('?devBypassAdmin=true reaches the admin shell without a Telegram login', async ({ page }) => {
await page.goto('/?devBypassAdmin=true');
await page.waitForLoadState('networkidle');
// The bypass alone doesn't navigate anywhere - it only activates the
// session, so the admin surface has to be reached directly afterwards.
await page.goto('/admin/dashboard');
await page.waitForLoadState('networkidle');
// A real Telegram-gated admin route would redirect to a login dialog;
// reaching dashboard content is the actual proof the bypass worked.
await expect(page).not.toHaveURL(/login/i);
await expect(page.locator('body')).not.toContainText(/scan.*qr|log in with telegram/i);
});
});

View File

@@ -0,0 +1,78 @@
import { Page, Route, expect, test } from '@playwright/test';
/**
* Track Q Q5 / frontend backlog F62: "repeat webhook and double-click create
* exactly one order." The webhook-idempotency half is a backend contract
* (PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §6.3, provider + providerEventId as
* the dedup key) this suite cannot exercise without a live backend. This
* test covers the half that IS frontend-testable: a double-click on the
* checkout button must not fire two checkout-session requests.
*/
const FAKE_ITEM = {
categoryID: 1, itemID: 5151, name: 'Idempotency Test Item', photos: null,
description: '', currency: 'RUB', price: 500, discount: 0, rating: 0,
callbacks: null, questions: null, quantity: 1,
};
test('double-clicking checkout sends exactly one checkout-session request', async ({ page, context }) => {
await page.addInitScript(item => {
window.localStorage.setItem('marketplace_cart', JSON.stringify([item]));
}, FAKE_ITEM);
// 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({
status: 200, contentType: 'application/json',
body: JSON.stringify({ sessionId: 'e2e-fake-session', status: 'active', username: 'e2e_user', userId: 1 }),
}),
);
await page.route('**/api/v2/pricing/fx-quote**', route =>
route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify({ quoteId: 'fxq_e2e', base: 'RUB', quote: 'RUB', rate: 1, source: 'e2e', observedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 300000).toISOString() }),
}),
);
let checkoutRequestCount = 0;
await page.route('**/api/v2/storefront/checkout', async (route: Route) => {
checkoutRequestCount += 1;
// Deliberately slow, so a real double-click's second event has to land
// while the first request is still in flight - the exact race this test
// exists to catch.
await new Promise(resolve => setTimeout(resolve, 300));
route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify({
checkoutSessionId: 'chk_e2e_idempotent',
lines: [{ offerId: String(FAKE_ITEM.itemID), qty: 1, unitPrice: { amountMinor: 50000, currency: 'RUB' }, lineTotal: { amountMinor: 50000, currency: 'RUB' }, priceSnapshotId: 'snap_e2e' }],
subtotal: { amountMinor: 50000, currency: 'RUB' }, discount: { amountMinor: 0, currency: 'RUB' },
delivery: { amountMinor: 0, currency: 'RUB' }, total: { amountMinor: 50000, currency: 'RUB' },
fxQuoteId: 'fxq_e2e', expiresAt: new Date(Date.now() + 300000).toISOString(),
}),
});
});
await page.route('**/api/v2/storefront/payments/intents', route =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ qrId: 'qr_e2e', nspkurl: 'https://example.com/pay', qrTTL: 5 }) }),
);
await page.goto('/cart');
await page.waitForLoadState('networkidle');
// No <label for="terms-checkbox"> exists in the markup - the checkbox and
// its text share a plain clickable wrapper - so toggle the input directly.
await page.locator('#terms-checkbox').dispatchEvent('click');
await expect(page.locator('#terms-checkbox')).toBeChecked();
const qrButton = page.getByRole('button', { name: /qr/i }).first();
await expect(qrButton).toBeEnabled({ timeout: 10_000 });
await qrButton.dblclick();
// Give the deliberately slow mock time to resolve and for any second,
// erroneously-fired request to have landed.
await page.waitForTimeout(1000);
expect(checkoutRequestCount, 'a double-click must not create two checkout sessions').toBe(1);
});

View File

@@ -0,0 +1,201 @@
import { Page, Route, expect, test } from '@playwright/test';
/**
* Guards the specific contract this rewrite exists to enforce
* (PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5.2): the amount actually charged
* must be computed server-side, never sent by the client. Before this
* rewrite, POST /cart carried a client-computed `amount` the backend was
* asked to trust.
*
* cart.component.ts has no unit spec (no src/app/pages/cart/*.spec.ts
* exists), so this E2E test is the only coverage the checkout request shape
* has. Scoped narrowly on purpose: cart state is seeded directly into
* localStorage and the customer session is faked via cookie + intercepted
* session-check, rather than driving a full add-to-cart-then-login UI
* journey - that journey is real product surface worth its own test, but
* would make this test about navigation, not about what it exists to prove.
*/
const FAKE_SESSION_ID = 'e2e-fake-session';
const FAKE_ITEM = {
categoryID: 1,
itemID: 4242,
name: 'E2E Test Item',
photos: null,
description: '',
currency: 'RUB',
price: 1000,
discount: 0,
rating: 0,
callbacks: null,
questions: null,
quantity: 2,
};
test.describe('checkout request shape', () => {
test.beforeEach(async ({ page, context }) => {
await seedCart(page);
await fakeCustomerSession(page, context);
await mockFxQuoteEndpoint(page);
});
test('checkout session request carries offers and qty, never amount or price', async ({ page }) => {
const checkoutRequest = interceptCheckoutSession(page);
await page.goto('/cart');
await acceptTermsAndCheckout(page);
const body = await checkoutRequest;
expect(body, 'must never send a client-computed amount').not.toHaveProperty('amount');
expect(body, 'must never send a client-computed price').not.toHaveProperty('price');
expect(Array.isArray(body.offers), 'must send an offers array').toBe(true);
expect(body.offers[0]).toMatchObject({ offerId: String(FAKE_ITEM.itemID), qty: FAKE_ITEM.quantity });
});
test('payment intent request references the checkout session id, not a raw amount', async ({ page }) => {
interceptCheckoutSession(page); // must resolve for the intent call to fire at all
const intentRequest = interceptPaymentIntent(page);
await page.goto('/cart');
await acceptTermsAndCheckout(page);
const body = await intentRequest;
// Payment creation now goes through @marketplaces/payment
// (MARKETPLACES_PAYMENT_GATEWAY -> POST {qrApiUrl}/api/v1/payments),
// not api.service.ts's superseded createPaymentIntent - see
// cart.component.ts's createPaymentIntent() comment.
expect(body.checkoutSessionId, 'must reference the session created in step 1').toBe('chk_e2e_fixture');
expect(body).not.toHaveProperty('amount');
const metadata = body.metadata as Record<string, string> | undefined;
expect(typeof metadata?.merchantReference).toBe('string');
expect((metadata?.merchantReference ?? '').length).toBeGreaterThan(0);
});
});
async function seedCart(page: Page): Promise<void> {
await page.addInitScript(item => {
window.localStorage.setItem('marketplace_cart', JSON.stringify([item]));
}, FAKE_ITEM);
}
async function fakeCustomerSession(page: Page, context: import('@playwright/test').BrowserContext): Promise<void> {
// 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',
value: FAKE_SESSION_ID,
url: 'http://localhost:4200',
},
]);
// Matches TelegramSessionApiService.normalizeWebSession's expected shape.
await page.route('**/users/sessions/**', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
sessionId: FAKE_SESSION_ID,
status: 'active',
username: 'e2e_user',
userId: 1,
}),
});
});
}
async function mockFxQuoteEndpoint(page: Page): Promise<void> {
await page.route('**/api/v2/pricing/fx-quote**', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
quoteId: 'fxq_e2e',
base: 'RUB',
quote: 'RUB',
rate: 1,
source: 'e2e-fixture',
observedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 300_000).toISOString(),
}),
});
});
}
function interceptCheckoutSession(page: Page): Promise<Record<string, unknown>> {
return new Promise(resolve => {
page.route('**/api/v2/storefront/checkout', (route: Route) => {
const body = route.request().postDataJSON();
resolve(body);
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
checkoutSessionId: 'chk_e2e_fixture',
lines: [{
offerId: String(FAKE_ITEM.itemID),
qty: FAKE_ITEM.quantity,
unitPrice: { amountMinor: FAKE_ITEM.price * 100, currency: 'RUB' },
lineTotal: { amountMinor: FAKE_ITEM.price * FAKE_ITEM.quantity * 100, currency: 'RUB' },
priceSnapshotId: 'snap_e2e',
}],
subtotal: { amountMinor: FAKE_ITEM.price * FAKE_ITEM.quantity * 100, currency: 'RUB' },
discount: { amountMinor: 0, currency: 'RUB' },
delivery: { amountMinor: 0, currency: 'RUB' },
total: { amountMinor: FAKE_ITEM.price * FAKE_ITEM.quantity * 100, currency: 'RUB' },
fxQuoteId: 'fxq_e2e',
expiresAt: new Date(Date.now() + 300_000).toISOString(),
}),
});
});
});
}
function interceptPaymentIntent(page: Page): Promise<Record<string, unknown>> {
return new Promise(resolve => {
// @marketplaces/payment: apiUrl (qrApiUrl with its trailing /api
// stripped, see app.config.ts) + default paymentsPath '/api/v1/payments'.
page.route('**/api/v1/payments', (route: Route) => {
const body = route.request().postDataJSON();
resolve(body);
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
paymentId: 'qr_e2e_fixture',
method: 'qr',
status: 'pending',
action: { type: 'qr', url: 'https://example.com/pay/e2e' },
}),
});
});
});
}
async function acceptTermsAndCheckout(page: Page): Promise<void> {
await page.waitForLoadState('networkidle');
// #terms-checkbox is a custom-styled input (zero-size native element, a
// <label> renders the visible box) - .check() refuses on geometry even
// with force:true, so toggle it via its label the way a real user would.
const termsCheckbox = page.locator('#terms-checkbox');
if (await termsCheckbox.count() > 0) {
const label = page.locator('label[for="terms-checkbox"]');
if (await label.count() > 0) {
await label.click();
} else {
await termsCheckbox.dispatchEvent('click');
}
}
const qrButton = page.getByRole('button', { name: /qr/i }).first();
await qrButton.click();
}

124
e2e/currency-switch.spec.ts Normal file
View File

@@ -0,0 +1,124 @@
import { Page, Route, expect, test } from '@playwright/test';
/**
* Track Q Q4: currency switch must recalculate by FX quote. Explicitly,
* "160 RUB" must not become "160 USD" - the number has to change, not just
* the label next to it.
*
* Written before the checkout money-truth rewrite (frontend backlog F10-F16,
* which deletes CurrencyRatesService's client-side float math and switches
* checkout to a server-computed total per
* docs/backend/PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §5). This test exists so
* that rewrite has something to break loudly if it silently stops converting.
*
* This session has no live backend to run against, so GET
* /api/v2/pricing/fx-quote is intercepted with a response shaped exactly per
* contract §3.1. That exercises the REAL code path - FxQuoteApiGateway,
* CurrencyRatesService, the currencyConvert pipe - rather than switching the
* whole app into mock mode, which would test a different (mock) gateway
* instead of the one actually shipped.
*/
/** Rate relative to RUB, only what this test needs. */
const MOCK_RATE: Record<string, number> = { USD: 0.0108, EUR: 0.0092, AMD: 4.31 };
test.describe('currency switch', () => {
test.beforeEach(async ({ page }) => {
await mockFxQuoteEndpoint(page);
});
test('switching currency changes the displayed price value, not just its label', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
const priceLocator = page.locator('.current-price, .original-price').first();
await expect(priceLocator).toBeVisible({ timeout: 15_000 });
const before = await readPrice(priceLocator);
expect(before.value, 'a price must be a real positive number before switching').toBeGreaterThan(0);
await switchCurrency(page, before.currency === 'USD' ? 'RUB' : 'USD');
// The currency LABEL flips synchronously (a signal write), but the rate
// itself arrives from the mocked network call asynchronously - polling
// only the label races ahead of the actual conversion and passes before
// the number has caught up. Poll the parsed numeric value instead, since
// that is what this test exists to guard.
await expect
.poll(async () => (await readPrice(priceLocator)).value, {
message: 'price value never diverged from the pre-switch amount',
})
.not.toBeCloseTo(before.value, 2);
const after = await readPrice(priceLocator);
expect(after.currency, 'the currency label must actually change').not.toBe(before.currency);
// The literal regression this test exists to catch: a rate of 1 disguised
// as a real conversion. RUB->USD or USD->RUB is never a 1:1 rate.
expect(after.value, `${before.value} ${before.currency} must not equal ${after.value} ${after.currency}`).not.toBeCloseTo(before.value, 2);
});
test('an out-of-range rate must not silently pass as valid', async ({ page }) => {
// Guards the specific bad-data class this suite exists to catch: a
// conversion that returns something implausible (zero, negative, or
// absurdly large) instead of erroring visibly.
await page.goto('/');
const priceLocator = page.locator('.current-price, .original-price').first();
await expect(priceLocator).toBeVisible({ timeout: 15_000 });
const { value } = await readPrice(priceLocator);
expect(value).toBeGreaterThan(0);
expect(value).toBeLessThan(100_000_000);
});
});
async function mockFxQuoteEndpoint(page: Page): Promise<void> {
await page.route('**/api/v2/pricing/fx-quote**', (route: Route) => {
const url = new URL(route.request().url());
const base = url.searchParams.get('base') ?? 'RUB';
const quote = url.searchParams.get('quote') ?? 'USD';
const rate = MOCK_RATE[quote] ?? 1;
const now = new Date();
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
quoteId: `fxq_e2e_${base}_${quote}_${now.getTime()}`,
base,
quote,
rate,
source: 'e2e-fixture',
observedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + 5 * 60 * 1000).toISOString(),
}),
});
});
}
async function switchCurrency(page: Page, targetCode: string): Promise<void> {
// The page renders more than one language-selector instance (desktop/mobile
// variants share the same markup) - scoping to the dropdown that actually
// carries the "open" class avoids clicking an option in a hidden duplicate,
// which is silently a no-op rather than a failure.
const trigger = page.locator('.currency-button:visible').first();
await trigger.click();
const openDropdown = page.locator('.currency-dropdown.open').first();
await expect(openDropdown).toBeVisible();
await openDropdown.locator('.currency-option', { hasText: targetCode }).first().click();
}
async function readPrice(locator: import('@playwright/test').Locator): Promise<{ value: number; currency: string }> {
const text = (await locator.textContent()) ?? '';
// Matches "1 234.56 USD" / "1234.56 ₽" shapes the price templates render.
const match = text.replace(/\s/g, '').match(/([\d.,]+)([A-Z]{3}|\D+)$/);
if (!match) {
throw new Error(`could not parse price text: "${text}"`);
}
const value = Number(match[1].replace(/,/g, ''));
return { value, currency: match[2] };
}

53
e2e/smoke.spec.ts Normal file
View File

@@ -0,0 +1,53 @@
import { expect, test } from '@playwright/test';
/** First E2E test in this repo. If this fails, nothing else in the suite matters. */
test.describe('smoke', () => {
test('storefront boots with no console errors', async ({ page }) => {
const errors: string[] = [];
// pageerror catches uncaught exceptions - always a real bug, always kept.
page.on('pageerror', err => errors.push(err.message));
page.on('console', msg => {
if (msg.type() !== 'error') {
return;
}
// "Failed to load resource" is Chrome's own message for a failed
// network request (404/502/etc), not application code. With
// environment.useMockData: false and no live backend behind this dev
// server (docs/backend/BACKEND-HANDOFF.md), every /api/ call 404s by
// design - that is a backend-availability fact, not something this
// smoke test exists to catch. A real app-level console.error still
// fails this test.
if (/^Failed to load resource/.test(msg.text())) {
return;
}
errors.push(msg.text());
});
await page.goto('/');
await expect(page.locator('body')).toBeVisible();
// Give the bootstrap fetch + first render cycle time to settle before
// asserting on the error list, or this is a race against app.config.ts.
await page.waitForLoadState('networkidle');
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

@@ -26,6 +26,20 @@ module.exports = function (config) {
dir: require('path').join(__dirname, 'coverage'), dir: require('path').join(__dirname, 'coverage'),
subdir: '.', subdir: '.',
reporters: [{ type: 'text-summary' }, { type: 'html' }, { type: 'lcovonly' }], reporters: [{ type: 'text-summary' }, { type: 'html' }, { type: 'lcovonly' }],
// Floor set 2026-08-18, ~5 points below the measured level right
// after this session's facade-test pass (43.3%/29.0%/34.1%/43.7%
// statements/branches/functions/lines) - a deliberate floor per the
// delivery plan's Q9 ("deliberately unset today"), not an aspiration.
// Ratchet this UP as coverage grows; a PR that drops below it should
// fail CI, not get merged with a lower number quietly re-baselined in.
check: {
global: {
statements: 40,
branches: 25,
functions: 30,
lines: 40,
},
},
}, },
restartOnFileChange: true, restartOnFileChange: true,
}); });

View File

@@ -30,9 +30,7 @@
{ {
"name": "api-cache", "name": "api-cache",
"urls": [ "urls": [
"/api/**", "/api/**"
"https://api.dexarmarket.ru:445/**",
"https://api.novo.market:444/**"
], ],
"cacheConfig": { "cacheConfig": {
"maxSize": 100, "maxSize": 100,

1463
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,11 +14,15 @@
"arch:check:boundaries": "node tools/architecture/check-boundaries.mjs", "arch:check:boundaries": "node tools/architecture/check-boundaries.mjs",
"arch:check:cycles": "npx --yes madge --circular --extensions ts src/app --ts-config tsconfig.app.json", "arch:check:cycles": "npx --yes madge --circular --extensions ts src/app --ts-config tsconfig.app.json",
"arch:check": "npm run arch:check:boundaries ; npm run arch:check:cycles", "arch:check": "npm run arch:check:boundaries ; npm run arch:check:cycles",
"scan:bundle": "bash scripts/ci/scan-bundle.sh",
"barry": "barry-cache", "barry": "barry-cache",
"barry:validate": "barry-cache validate", "barry:validate": "barry-cache validate",
"barry:resume": "barry-cache resume", "barry:resume": "barry-cache resume",
"barry:finalize": "barry-cache finalize", "barry:finalize": "barry-cache finalize",
"barry:failure": "barry-cache failure" "barry:failure": "barry-cache failure",
"e2e": "playwright test",
"e2e:ui": "playwright test --ui",
"e2e:report": "playwright show-report"
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
@@ -31,6 +35,8 @@
"@angular/platform-browser": "22.0.8", "@angular/platform-browser": "22.0.8",
"@angular/router": "22.0.8", "@angular/router": "22.0.8",
"@angular/service-worker": "22.0.8", "@angular/service-worker": "22.0.8",
"@marketplaces/auth": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/auth",
"@marketplaces/payment": "git+https://sources.vitanova.network/sdarbinyan/vitanovaPackages.git#release/payment",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.8.0", "tslib": "^2.8.0",
"zone.js": "~0.16.0" "zone.js": "~0.16.0"
@@ -39,6 +45,7 @@
"@angular/build": "22.0.8", "@angular/build": "22.0.8",
"@angular/cli": "22.0.8", "@angular/cli": "22.0.8",
"@angular/compiler-cli": "22.0.8", "@angular/compiler-cli": "22.0.8",
"@playwright/test": "^1.62.1",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"barry-cache": "^0.9.3", "barry-cache": "^0.9.3",
"istanbul-lib-instrument": "^6.0.3", "istanbul-lib-instrument": "^6.0.3",

41
playwright.config.ts Normal file
View File

@@ -0,0 +1,41 @@
import { defineConfig, devices } from '@playwright/test';
/**
* E2E harness. Track Q (docs/PRODUCT-PLAN-v3.1-DELIVERY-PLAN.md Q1) — none
* existed before this. Runs against the mock-data build (environment.dev's
* useMockData: true, per src/environments/), because the dev server this
* session can reach has no live backend behind it.
*
* Once a real backend is reachable, point BASE_URL at it and set
* PW_USE_MOCK_DATA=false to get end-to-end coverage instead of
* frontend-only coverage. See e2e/README.md.
*/
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list',
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
// Boots the mock-data dev server unless BASE_URL points somewhere already
// running (a staging box, a locally-started server).
webServer: process.env.BASE_URL
? undefined
: {
command: 'npm run dexar',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});

12
renovate.json Normal file
View File

@@ -0,0 +1,12 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"packageRules": [
{
"matchPackageNames": ["@marketplaces/auth", "@marketplaces/payment"],
"groupName": "marketplaces shared packages",
"automerge": false,
"labels": ["shared-package-update"]
}
]
}

67
scripts/ci/scan-bundle.sh Normal file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Fails the build if a production bundle contains anything that should only
# ever exist server-side.
#
# It also fails on mock gateway code, for the same reason in a different
# register: a production build that can reach a *LocalGateway is a production
# build that can serve seeded fixtures as if they were real data. Those used to
# ship - a fixture string from partner-hierarchy-local.gateway.ts was present in
# a production bundle on 2026-08-21 - because naming both classes in a token
# factory kept both reachable no matter what the flag said.
#
# Why this exists: the storefront used to send provider payment credentials
# from the browser - an `authorization-key` header, a `userid-value` header,
# and a hardcoded partner ID literal compiled into the bundle. That code is
# gone (FH-1.3), and this check is what stops it coming back. A credential in
# a JS bundle is not a leak you can revoke quietly; it is published.
#
# Usage:
# npm run build && scripts/ci/scan-bundle.sh [dist-dir]
set -euo pipefail
DIST="${1:-dist}"
if [[ ! -d "$DIST" ]]; then
echo "scan-bundle: '$DIST' does not exist - build first" >&2
exit 2
fi
# Each entry is "label|extended-regex". Keep patterns specific: a pattern that
# fires on ordinary code trains people to ignore this check.
PATTERNS=(
"provider auth header|authorization-key"
"provider user header|userid-value"
"hardcoded partner id|web-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
"oauth client secret|client_secret[\"']?[[:space:]]*[:=]"
"private key block|BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY"
"aws access key|AKIA[0-9A-Z]{16}"
"telegram bot token|[0-9]{8,10}:AA[0-9A-Za-z_-]{33}"
"mock gateway class|[A-Za-z]+LocalGateway"
"mock gateway fixture|ptr_local|customer_vk_mock"
)
failed=0
for entry in "${PATTERNS[@]}"; do
label="${entry%%|*}"
pattern="${entry#*|}"
if matches="$(grep -rIlE "$pattern" "$DIST" 2>/dev/null)"; then
if [[ -n "$matches" ]]; then
echo "FAIL: $label found in the built bundle" >&2
echo "$matches" | sed 's/^/ /' >&2
failed=1
fi
fi
done
if [[ $failed -ne 0 ]]; then
echo >&2
echo "Something reached the browser bundle that should not have." >&2
echo "Credentials belong behind the API; mock gateways belong in dev-only" >&2
echo "providers swapped out by angular.json fileReplacements." >&2
exit 1
fi
echo "scan-bundle: clean ($DIST)"

143
scripts/deploy/add-domain.sh Executable file
View File

@@ -0,0 +1,143 @@
#!/usr/bin/env bash
#
# Attach one customer domain to this server and issue a TLS certificate.
# Idempotent: re-running for an existing domain renews/repairs rather than duplicates.
# Run as root, AFTER the domain's A/AAAA record already resolves to this server.
#
# bash add-domain.sh shop.example.com --email ops@example.com
# bash add-domain.sh shop.example.com --email ops@example.com --with-www
#
# Why per-domain blocks exist at all: the application is multi-tenant off the
# Host header and needs no per-domain root. Certificates are the exception —
# certbot must match a concrete server_name, which `default_server _` is not.
set -euo pipefail
DOMAIN="${1:-}"; shift || true
EMAIL=""
WITH_WWW=0
while [[ $# -gt 0 ]]; do
case "$1" in
--email) EMAIL="$2"; shift 2 ;;
--with-www) WITH_WWW=1; shift ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
[[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
[[ -n "$DOMAIN" ]] || { echo "usage: add-domain.sh <domain> --email <address> [--with-www]" >&2; exit 2; }
[[ -n "$EMAIL" ]] || { echo "--email is required (certbot expiry notices)" >&2; exit 2; }
# Fail loudly rather than let certbot fail obscurely on an unpointed domain.
echo "==> checking DNS for $DOMAIN"
RESOLVED="$(getent hosts "$DOMAIN" | awk '{print $1}' | head -1 || true)"
if [[ -z "$RESOLVED" ]]; then
echo "ERROR: $DOMAIN does not resolve. Point its A record at this server first." >&2
exit 1
fi
echo " resolves to $RESOLVED"
NAMES="$DOMAIN"
CERT_ARGS=(-d "$DOMAIN")
if [[ $WITH_WWW -eq 1 ]]; then
NAMES="$DOMAIN www.$DOMAIN"
CERT_ARGS+=(-d "www.$DOMAIN")
fi
CONF="/etc/nginx/sites-available/tenant-$DOMAIN.conf"
echo "==> nginx server block: $CONF"
cat > "$CONF" <<NGINX
# Tenant domain: $DOMAIN
# Same root as the catch-all — the SPA resolves the tenant from the Host header.
# This block exists so certbot has a concrete server_name to attach TLS to.
server {
listen 80;
listen [::]:80;
server_name $NAMES;
root /srv/marketplaces/current/frontend;
index index.html;
location = /index.html {
add_header Cache-Control "no-store, must-revalidate" always;
try_files \$uri =404;
}
location ~* \.(js|css|woff2?|png|jpe?g|svg|gif|webp|avif|ico)\$ {
expires 1y;
add_header Cache-Control "public, immutable" always;
try_files \$uri =404;
}
location /health {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_read_timeout 60s;
}
location / {
try_files \$uri \$uri/ /index.html;
}
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
}
NGINX
ln -sfn "$CONF" "/etc/nginx/sites-enabled/tenant-$DOMAIN.conf"
nginx -t
systemctl reload nginx
echo "==> certificate"
# --nginx rewrites the block above in place to add listen 443 + ssl directives
# and an HTTP->HTTPS redirect. Re-running is a no-op when the cert is current.
certbot --nginx "${CERT_ARGS[@]}" \
--non-interactive --agree-tos --email "$EMAIL" \
--redirect --keep-until-expiring
nginx -t
systemctl reload nginx
echo "==> companion API domain(s)"
CONFIGURE_API="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/configure-api-domain.sh"
[[ -x "$CONFIGURE_API" ]] || {
echo "ERROR: configure-api-domain.sh must be executable and next to add-domain.sh" >&2
exit 1
}
IFS=. read -ra DOMAIN_LABELS <<< "$DOMAIN"
LABEL_COUNT=${#DOMAIN_LABELS[@]}
TAKE=2
TLD=${DOMAIN_LABELS[LABEL_COUNT-1]}
SECOND_LEVEL=${DOMAIN_LABELS[LABEL_COUNT-2]}
if (( LABEL_COUNT >= 3 && ${#TLD} == 2 && ${#SECOND_LEVEL} <= 3 )); then
TAKE=3
fi
START=$((LABEL_COUNT - TAKE))
API_BASE_DOMAIN=$(IFS=.; echo "${DOMAIN_LABELS[*]:START}")
"$CONFIGURE_API" --domain "$API_BASE_DOMAIN" --email "$EMAIL"
echo "==> renewal timer"
systemctl enable --now certbot.timer
systemctl status certbot.timer --no-pager | head -3 || true
echo
echo "done. verify:"
echo " curl -I https://$DOMAIN/health"
echo " certbot certificates | grep -A3 $DOMAIN"

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# Configure one shared api.<base-domain> for the base storefront and all tenant
# subdomains. Idempotent. Run as root after the API DNS record resolves here.
set -euo pipefail
DOMAIN=""
EMAIL=""
UPSTREAM="https://127.0.0.1:445"
while [[ $# -gt 0 ]]; do
case "$1" in
--domain) DOMAIN="$2"; shift 2 ;;
--email) EMAIL="$2"; shift 2 ;;
--upstream) UPSTREAM="$2"; shift 2 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
[[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
[[ "$DOMAIN" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ ]] || {
echo "--domain must be a valid lowercase hostname" >&2; exit 2;
}
[[ "$EMAIL" =~ ^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$ ]] || {
echo "--email must be valid" >&2; exit 2;
}
[[ "$UPSTREAM" =~ ^https?://[a-zA-Z0-9.:-]+$ ]] || {
echo "--upstream must be an http(s) origin without a path" >&2; exit 2;
}
API_DOMAIN="api.$DOMAIN"
CONF="/etc/nginx/sites-available/$API_DOMAIN"
DOMAIN_REGEX="${DOMAIN//./\\.}"
echo "==> checking DNS for $API_DOMAIN"
getent hosts "$API_DOMAIN" >/dev/null || {
echo "ERROR: $API_DOMAIN does not resolve; create DNS before provisioning TLS" >&2
exit 1
}
cat > "$CONF" <<NGINX
# Managed by marketplaces configure-api-domain.sh. Manual edits are overwritten.
# Storefront $DOMAIN and its tenant subdomains share https://$API_DOMAIN.
server {
listen 80;
listen [::]:80;
server_name $API_DOMAIN;
access_log /var/log/nginx/$API_DOMAIN.access.log;
error_log /var/log/nginx/$API_DOMAIN.error.log;
set \$cors_origin "";
set \$storefront_host "$DOMAIN";
if (\$http_origin ~* "^https://(?<allowed_storefront>([a-z0-9-]+\\.)*$DOMAIN_REGEX)$") {
set \$cors_origin \$http_origin;
set \$storefront_host \$allowed_storefront;
}
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, WebSessionID, Currency, X-Language, X-Region, X-Requested-With" always;
add_header Vary "Origin" always;
if (\$request_method = OPTIONS) { return 204; }
location / {
proxy_pass $UPSTREAM;
proxy_http_version 1.1;
# Browser Origin selects the storefront tenant while every tenant under
# this base domain shares one public API hostname.
proxy_set_header Host \$storefront_host;
proxy_set_header X-Forwarded-Host $API_DOMAIN;
proxy_set_header X-Storefront-Host \$storefront_host;
# nginx has already validated and answered CORS. The existing backend
# rejects browser Origin on :445, so do not forward it a second time.
proxy_set_header Origin "";
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 60s;
proxy_connect_timeout 10s;
proxy_ssl_server_name on;
proxy_ssl_name $DOMAIN;
}
}
NGINX
ln -sfn "$CONF" "/etc/nginx/sites-enabled/$API_DOMAIN"
nginx -t
certbot --nginx -d "$API_DOMAIN" \
--non-interactive --agree-tos --email "$EMAIL" \
--redirect --keep-until-expiring
nginx -t
systemctl reload nginx
echo "==> verifying https://$API_DOMAIN/bootstrap"
bootstrap_tmp="$(mktemp)"
trap 'rm -f "$bootstrap_tmp"' EXIT
content_type="$(curl --resolve "$API_DOMAIN:443:127.0.0.1" -fsS \
-o "$bootstrap_tmp" -w '%{content_type}' \
"https://$API_DOMAIN/bootstrap")"
[[ "$content_type" == application/json* ]] || {
echo "ERROR: $API_DOMAIN/bootstrap returned $content_type, expected application/json" >&2
exit 1
}
jq -e 'type == "object"' "$bootstrap_tmp" >/dev/null
rm -f "$bootstrap_tmp"
trap - EXIT
echo "configured: $DOMAIN -> https://$API_DOMAIN -> $UPSTREAM"

286
scripts/deploy/server-setup.sh Executable file
View File

@@ -0,0 +1,286 @@
#!/usr/bin/env bash
#
# One-time server provisioning for the marketplaces frontend.
# Idempotent: safe to re-run. Run as root on the target server.
#
# bash server-setup.sh --pubkey "ssh-ed25519 AAAA... ci@marketplaces"
#
# What it does NOT do: issue TLS certificates (no domain points here yet).
# Run add-domain.sh per domain once DNS resolves. See docs/DEPLOYMENT.md.
set -euo pipefail
DEPLOY_USER="deploy"
BASE="/srv/marketplaces"
PUBKEY=""
KEEP_RELEASES=5
while [[ $# -gt 0 ]]; do
case "$1" in
--pubkey) PUBKEY="$2"; shift 2 ;;
--user) DEPLOY_USER="$2"; shift 2 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
[[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
[[ -n "$PUBKEY" ]] || { echo "--pubkey is required (the CI deploy key's PUBLIC half)" >&2; exit 1; }
echo "==> packages"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq nginx certbot python3-certbot-nginx rsync ufw jq curl openssl
echo "==> deploy user: $DEPLOY_USER"
if ! id -u "$DEPLOY_USER" >/dev/null 2>&1; then
# No password is ever set: this account is key-only by construction.
adduser --system --group --shell /bin/bash --home "/home/$DEPLOY_USER" "$DEPLOY_USER"
fi
install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "/home/$DEPLOY_USER/.ssh"
AUTH="/home/$DEPLOY_USER/.ssh/authorized_keys"
touch "$AUTH"
grep -qxF "$PUBKEY" "$AUTH" || echo "$PUBKEY" >> "$AUTH"
chown "$DEPLOY_USER:$DEPLOY_USER" "$AUTH"
chmod 600 "$AUTH"
echo "==> directories"
install -d -m 755 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$BASE" "$BASE/releases"
# First deploy creates $BASE/current as a symlink into releases/.
# Seed a placeholder so nginx starts cleanly before anything is deployed.
if [[ ! -e "$BASE/current" ]]; then
install -d -m 755 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "$BASE/releases/bootstrap/frontend"
echo "<!doctype html><title>marketplaces</title><p>Not deployed yet." \
> "$BASE/releases/bootstrap/frontend/index.html"
chown -R "$DEPLOY_USER:$DEPLOY_USER" "$BASE/releases/bootstrap"
ln -sfn "$BASE/releases/bootstrap" "$BASE/current"
chown -h "$DEPLOY_USER:$DEPLOY_USER" "$BASE/current"
fi
echo "==> nginx catch-all (multi-tenant: one bundle serves every domain)"
cat > /etc/nginx/sites-available/marketplaces.conf <<'NGINX'
# Multi-tenant by design: the SPA derives its tenant from the Host header,
# so ONE server block serves every customer domain. Do not add a per-tenant
# root here. Per-domain server blocks exist only to hold TLS certificates
# (see add-domain.sh) and proxy to this same root.
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
root /srv/marketplaces/current/frontend;
index index.html;
access_log /var/log/nginx/marketplaces.access.log;
error_log /var/log/nginx/marketplaces.error.log;
# Do not let the browser cache the app shell: a deploy must take effect
# on the next reload, not whenever a stale index.html expires.
location = /index.html {
add_header Cache-Control "no-store, must-revalidate" always;
try_files $uri =404;
}
# Hashed build artifacts are immutable by construction.
location ~* \.(js|css|woff2?|png|jpe?g|svg|gif|webp|avif|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable" always;
try_files $uri =404;
}
location /health {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# SPA fallback. Must stay last: every unmatched path is a client route.
location / {
try_files $uri $uri/ /index.html;
}
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
}
NGINX
ln -sfn /etc/nginx/sites-available/marketplaces.conf /etc/nginx/sites-enabled/marketplaces.conf
rm -f /etc/nginx/sites-enabled/default
echo "==> firewall"
ufw allow OpenSSH >/dev/null
ufw allow 80/tcp >/dev/null
ufw allow 443/tcp >/dev/null
ufw --force enable >/dev/null
# FH-D.3. ufw alone leaves SSH open to unlimited password guessing and leaves
# the kernel on defaults that are wrong for an internet-facing host. All three
# blocks below are drop-in files, so a re-run overwrites its own config and
# never edits a distro file in place.
echo "==> sshd hardening"
cat > /etc/ssh/sshd_config.d/10-marketplaces-hardening.conf <<'SSHD'
# Both accounts on this host are key-only by construction (the deploy user is
# created with no password at all), so password auth can only ever succeed for
# a credential nobody intended to exist.
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitEmptyPasswords no
PermitRootLogin prohibit-password
X11Forwarding no
AllowAgentForwarding no
MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2
SSHD
# Validate before reloading: a bad sshd config that takes effect on a remote
# box is how people lock themselves out permanently.
if sshd -t; then
systemctl reload ssh 2>/dev/null || systemctl reload sshd
else
echo "sshd config test FAILED - removing the drop-in and leaving sshd as it was" >&2
rm -f /etc/ssh/sshd_config.d/10-marketplaces-hardening.conf
exit 1
fi
echo "==> fail2ban"
apt-get install -y -qq fail2ban
cat > /etc/fail2ban/jail.d/marketplaces.local <<'F2B'
[DEFAULT]
backend = systemd
findtime = 10m
bantime = 1h
maxretry = 5
[sshd]
enabled = true
[nginx-http-auth]
enabled = true
[nginx-bad-request]
enabled = true
F2B
systemctl enable --now fail2ban
systemctl restart fail2ban
echo "==> kernel hardening"
cat > /etc/sysctl.d/99-marketplaces-hardening.conf <<'SYSCTL'
# Ignore ICMP redirects and source routing: this host has one gateway and
# nothing upstream should be rewriting its routing table.
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
# Reverse-path filtering and martian logging.
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.all.log_martians = 1
# SYN flood resistance.
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_max_syn_backlog = 2048
net.ipv4.tcp_synack_retries = 2
# No IP forwarding: this is a web server, not a router.
net.ipv4.ip_forward = 0
# Restrict kernel pointer and dmesg exposure to unprivileged users.
kernel.kptr_restrict = 2
kernel.dmesg_restrict = 1
SYSCTL
sysctl --quiet --system
echo "==> nginx config test"
nginx -t
systemctl enable --now nginx
systemctl reload nginx
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "==> tenant API-domain configurator"
if [[ -f "$SRC_DIR/configure-api-domain.sh" ]]; then
install -m 755 -o root -g root "$SRC_DIR/configure-api-domain.sh" \
/usr/local/sbin/marketplaces-configure-api-domain
else
echo "configure-api-domain.sh not found next to server-setup.sh" >&2
exit 1
fi
echo "==> dynamic domain reconciler"
install -d -m 755 "$BASE/bin" /etc/marketplaces "/var/lib/marketplaces"
if [[ -f "$SRC_DIR/sync-domains.sh" ]]; then
install -m 755 "$SRC_DIR/sync-domains.sh" "$BASE/bin/sync-domains.sh"
if [[ ! -f /etc/marketplaces/domains.env ]]; then
cat > /etc/marketplaces/domains.env <<'ENVFILE'
# Where the desired domain list comes from.
# file:/etc/marketplaces/domains.txt one hostname per line
# https://api.example.com/api/admin/v2/domains JSON, once the backend exists
DOMAINS_SOURCE=file:/etc/marketplaces/domains.txt
# Required: certbot expiry notices.
CERTBOT_EMAIL=
# Cap per run so a bad source cannot burn the weekly ACME budget in one pass.
MAX_ISSUE_PER_RUN=10
# Set by setup-wildcard-tls.sh. Subdomains of this apex skip per-domain issuance.
#WILDCARD_APEX=
ENVFILE
chmod 600 /etc/marketplaces/domains.env
fi
touch /etc/marketplaces/domains.txt
if [[ -d "$SRC_DIR/systemd" ]]; then
install -m 644 "$SRC_DIR/systemd/marketplaces-domains.service" /etc/systemd/system/
install -m 644 "$SRC_DIR/systemd/marketplaces-domains.timer" /etc/systemd/system/
systemctl daemon-reload
# Not started yet: CERTBOT_EMAIL is still blank. Enable it after filling in
# /etc/marketplaces/domains.env, or the first run just fails on every tick.
echo " timer installed but NOT started - set CERTBOT_EMAIL first, then:"
echo " systemctl enable --now marketplaces-domains.timer"
fi
else
echo " sync-domains.sh not found next to this script - skipping"
fi
echo "==> sudoers: deployment reload plus validated tenant API provisioning"
cat > /etc/sudoers.d/marketplaces-deploy <<SUDO
Cmnd_Alias MARKETPLACES_DEPLOY = /bin/systemctl reload nginx, /usr/local/sbin/marketplaces-configure-api-domain *
$DEPLOY_USER ALL=(root) NOPASSWD: MARKETPLACES_DEPLOY
SUDO
chmod 440 /etc/sudoers.d/marketplaces-deploy
visudo -c -f /etc/sudoers.d/marketplaces-deploy
echo
echo "done."
echo " deploy user : $DEPLOY_USER (key-only, no password)"
echo " web root : $BASE/current/frontend"
echo " keep : last $KEEP_RELEASES releases"
echo
echo "next:"
echo " 1. curl -I http://<this-server>/health -> expect 200"
echo " 2. point a domain's A record here"
echo " 3. bash add-domain.sh <domain> -> issues TLS"
echo " 4. add CI secrets, push to main -> first real deploy"

View File

@@ -0,0 +1,148 @@
#!/usr/bin/env bash
#
# Issue ONE wildcard certificate covering every tenant subdomain under an apex.
# After this, a new tenant at <slug>.<apex> needs no certificate work at all —
# DNS record, and it is live over HTTPS immediately.
#
# setup-wildcard-tls.sh --apex marketplaces.example.com --email ops@example.com --dns cloudflare
# setup-wildcard-tls.sh --apex marketplaces.example.com --email ops@example.com --dns manual
#
# Wildcards require DNS-01 validation — HTTP-01 cannot issue them. That means
# certbot must create a _acme-challenge TXT record, which needs either a DNS
# provider plugin (automatic, renews unattended) or manual intervention every
# 60-90 days. Prefer a plugin. Use manual only to prove the idea out.
#
# Tenants on their OWN domains are not covered by a wildcard; those are handled
# per-domain by sync-domains.sh.
set -euo pipefail
APEX=""; EMAIL=""; DNS_PLUGIN="manual"; CREDS=""
while [[ $# -gt 0 ]]; do
case "$1" in
--apex) APEX="$2"; shift 2 ;;
--email) EMAIL="$2"; shift 2 ;;
--dns) DNS_PLUGIN="$2"; shift 2 ;;
--creds) CREDS="$2"; shift 2 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
[[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
[[ -n "$APEX" ]] || { echo "--apex is required" >&2; exit 2; }
[[ -n "$EMAIL" ]] || { echo "--email is required" >&2; exit 2; }
export DEBIAN_FRONTEND=noninteractive
CERT_ARGS=(-d "$APEX" -d "*.$APEX")
case "$DNS_PLUGIN" in
cloudflare)
apt-get install -y -qq python3-certbot-dns-cloudflare
[[ -n "$CREDS" ]] || { echo "--creds <file> required for cloudflare (contains the API token)" >&2; exit 2; }
chmod 600 "$CREDS"
CERT_ARGS+=(--dns-cloudflare --dns-cloudflare-credentials "$CREDS" --dns-cloudflare-propagation-seconds 30)
;;
route53)
apt-get install -y -qq python3-certbot-dns-route53
CERT_ARGS+=(--dns-route53) # credentials come from the instance role or ~/.aws
;;
manual)
cat >&2 <<'WARN'
WARNING: manual DNS-01.
certbot will print a TXT record for you to create by hand, and will do so again
at every renewal (every 60-90 days). Unattended renewal will NOT work. This is
acceptable to prove the setup out; it is not acceptable as the steady state.
Hostinger has no certbot plugin. If DNS lives there, the options are: move DNS
to a provider with a plugin (Cloudflare is free and takes minutes), or drive
issuance from the Phase 9 domain-automation API instead.
WARN
CERT_ARGS+=(--manual --preferred-challenges dns)
;;
*)
echo "unsupported --dns: $DNS_PLUGIN (cloudflare|route53|manual)" >&2; exit 2 ;;
esac
echo "==> issuing wildcard for $APEX and *.$APEX via $DNS_PLUGIN"
certbot certonly "${CERT_ARGS[@]}" \
--agree-tos --email "$EMAIL" --keep-until-expiring \
$([[ "$DNS_PLUGIN" != "manual" ]] && echo --non-interactive)
LIVE="/etc/letsencrypt/live/$APEX"
[[ -f "$LIVE/fullchain.pem" ]] || { echo "certificate not found at $LIVE" >&2; exit 1; }
echo "==> nginx: TLS on the catch-all, so every subdomain is served immediately"
cat > /etc/nginx/snippets/marketplaces-wildcard-tls.conf <<SNIPPET
# Managed by setup-wildcard-tls.sh
ssl_certificate $LIVE/fullchain.pem;
ssl_certificate_key $LIVE/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_stapling on;
ssl_stapling_verify on;
SNIPPET
cat > /etc/nginx/sites-available/marketplaces-tls.conf <<NGINX
# Wildcard TLS catch-all for *.$APEX
# Any tenant subdomain is served here with no per-tenant configuration.
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
http2 on;
server_name $APEX *.$APEX;
include /etc/nginx/snippets/marketplaces-wildcard-tls.conf;
root /srv/marketplaces/current/frontend;
index index.html;
location = /index.html {
add_header Cache-Control "no-store, must-revalidate" always;
try_files \$uri =404;
}
location ~* \.(js|css|woff2?|png|jpe?g|svg|gif|webp|avif|ico)\$ {
expires 1y;
add_header Cache-Control "public, immutable" always;
try_files \$uri =404;
}
location /health { access_log off; return 200 "ok\n"; add_header Content-Type text/plain; }
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
location / { try_files \$uri \$uri/ /index.html; }
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
}
NGINX
ln -sfn /etc/nginx/sites-available/marketplaces-tls.conf /etc/nginx/sites-enabled/marketplaces-tls.conf
nginx -t
systemctl reload nginx
systemctl enable --now certbot.timer
# Tell sync-domains.sh which names it can skip.
mkdir -p /etc/marketplaces
if [[ -f /etc/marketplaces/domains.env ]]; then
sed -i '/^WILDCARD_APEX=/d' /etc/marketplaces/domains.env
fi
echo "WILDCARD_APEX=$APEX" >> /etc/marketplaces/domains.env
echo
echo "done. every <slug>.$APEX is now served over HTTPS with no further action."
echo "verify: curl -I https://anything.$APEX/health"

View File

@@ -0,0 +1,234 @@
#!/usr/bin/env bash
#
# Reconcile the set of TLS-enabled domains on this server against a desired
# list. Idempotent and safe to run on a timer: it issues what is missing,
# leaves what is current alone, and disables what has been removed.
#
# The nginx catch-all already serves ANY Host over HTTP with no config, so a
# new domain works on port 80 the moment DNS resolves. This script exists only
# because TLS needs a certificate per name.
#
# sync-domains.sh # reconcile from $DOMAINS_SOURCE
# sync-domains.sh --dry-run # print the plan, change nothing
#
# Config: /etc/marketplaces/domains.env
# DOMAINS_SOURCE=file:/etc/marketplaces/domains.txt
# DOMAINS_SOURCE=https://api.example.com/api/admin/v2/domains (JSON array)
# CERTBOT_EMAIL=ops@example.com
# MAX_ISSUE_PER_RUN=10
#
# Let's Encrypt caps new certificates per registered domain per week. The
# per-run issuance cap keeps a misconfigured source from burning that budget
# in one pass; the remainder is picked up on the next run.
set -euo pipefail
CONFIG="/etc/marketplaces/domains.env"
STATE_DIR="/var/lib/marketplaces"
DRY_RUN=0
RENEW_WINDOW_DAYS=30
[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1
# shellcheck source=/dev/null
[[ -f "$CONFIG" ]] && source "$CONFIG"
DOMAINS_SOURCE="${DOMAINS_SOURCE:-file:/etc/marketplaces/domains.txt}"
CERTBOT_EMAIL="${CERTBOT_EMAIL:-}"
MAX_ISSUE_PER_RUN="${MAX_ISSUE_PER_RUN:-10}"
WILDCARD_APEX="${WILDCARD_APEX:-}"
[[ $EUID -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
[[ -n "$CERTBOT_EMAIL" ]] || { echo "CERTBOT_EMAIL not set in $CONFIG" >&2; exit 1; }
mkdir -p "$STATE_DIR"
log() { printf '%s %s\n' "$(date -Is)" "$*"; }
# ---------------------------------------------------------------- desired set
fetch_desired() {
case "$DOMAINS_SOURCE" in
file:*)
local path="${DOMAINS_SOURCE#file:}"
[[ -f "$path" ]] || { log "source file $path missing"; return 1; }
# one domain per line; # comments and blanks ignored
sed -e 's/#.*//' -e 's/[[:space:]]//g' "$path" | grep -v '^$' || true
;;
http://*|https://*)
# Expected shape: ["a.example.com","b.example.com"] or
# [{"domain":"a.example.com","status":"active"}, ...]
local body
body="$(curl -fsS --max-time 20 ${DOMAINS_API_TOKEN:+-H "Authorization: Bearer $DOMAINS_API_TOKEN"} "$DOMAINS_SOURCE")" || {
log "ERROR: could not fetch $DOMAINS_SOURCE — leaving current config untouched"
return 1
}
echo "$body" | jq -r '
if type=="array" and (.[0]|type)=="object"
then .[] | select((.status // "active") == "active") | .domain
else .[] end' 2>/dev/null || {
log "ERROR: unparseable response from $DOMAINS_SOURCE"
return 1
}
;;
*)
log "ERROR: unsupported DOMAINS_SOURCE: $DOMAINS_SOURCE"; return 1 ;;
esac
}
# A source failure must never be read as "all domains removed". Bail instead.
if ! DESIRED_RAW="$(fetch_desired)"; then
log "reconcile aborted: desired set unavailable"
exit 1
fi
# Reject anything that is not a plausible hostname before it reaches certbot
# or an nginx server_name.
DESIRED="$(echo "$DESIRED_RAW" | grep -Ei '^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$' | sort -u || true)"
REJECTED="$(comm -23 <(echo "$DESIRED_RAW" | sort -u) <(echo "$DESIRED") || true)"
[[ -n "$REJECTED" ]] && log "WARNING: ignoring malformed entries: $(echo "$REJECTED" | tr '\n' ' ')"
if [[ -z "$DESIRED" ]]; then
log "desired set is empty — nothing to do (not treating this as 'remove everything')"
exit 0
fi
log "desired: $(echo "$DESIRED" | wc -l) domain(s)"
# ------------------------------------------------------------------ helpers
covered_by_wildcard() {
# A domain one label under the wildcard apex needs no certificate of its own.
local d="$1"
[[ -n "$WILDCARD_APEX" ]] || return 1
[[ "$d" == *".$WILDCARD_APEX" ]] || return 1
[[ "${d%.$WILDCARD_APEX}" != *.* ]]
}
cert_is_current() {
local d="$1" live="/etc/letsencrypt/live/$1/cert.pem"
[[ -f "$live" ]] || return 1
openssl x509 -in "$live" -noout -checkend $((RENEW_WINDOW_DAYS * 86400)) >/dev/null 2>&1
}
resolves_here() {
local d="$1"
local got want
got="$(getent hosts "$d" | awk '{print $1}' | sort -u)"
[[ -n "$got" ]] || return 1
# Compare against every address this host actually answers on.
want="$(hostname -I | tr ' ' '\n' | grep -v '^$')"
grep -qxF -f <(echo "$want") <(echo "$got")
}
write_block() {
local d="$1" conf="/etc/nginx/sites-available/tenant-$1.conf"
cat > "$conf" <<NGINX
# Managed by sync-domains.sh. Manual edits are overwritten.
# Same root as the catch-all: the SPA resolves its tenant from the Host header.
server {
listen 80;
listen [::]:80;
server_name $d;
root /srv/marketplaces/current/frontend;
index index.html;
location = /index.html {
add_header Cache-Control "no-store, must-revalidate" always;
try_files \$uri =404;
}
location ~* \.(js|css|woff2?|png|jpe?g|svg|gif|webp|avif|ico)\$ {
expires 1y;
add_header Cache-Control "public, immutable" always;
try_files \$uri =404;
}
location /health { access_log off; return 200 "ok\n"; add_header Content-Type text/plain; }
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
location / { try_files \$uri \$uri/ /index.html; }
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
}
NGINX
ln -sfn "$conf" "/etc/nginx/sites-enabled/tenant-$d.conf"
}
# ------------------------------------------------------------------ reconcile
issued=0 skipped=0 waiting=0 wildcarded=0 failed=0
while read -r d; do
[[ -n "$d" ]] || continue
if covered_by_wildcard "$d"; then
wildcarded=$((wildcarded + 1)); continue
fi
if cert_is_current "$d"; then
skipped=$((skipped + 1)); continue
fi
if ! resolves_here "$d"; then
log "waiting on DNS: $d (does not resolve to this server yet)"
waiting=$((waiting + 1)); continue
fi
if [[ $issued -ge $MAX_ISSUE_PER_RUN ]]; then
log "issuance cap ($MAX_ISSUE_PER_RUN) reached — remaining domains roll to the next run"
break
fi
if [[ $DRY_RUN -eq 1 ]]; then
log "DRY RUN would issue: $d"; issued=$((issued + 1)); continue
fi
log "issuing: $d"
write_block "$d"
if ! nginx -t >/dev/null 2>&1; then
log "ERROR: nginx config invalid after adding $d — reverting that block"
rm -f "/etc/nginx/sites-enabled/tenant-$d.conf"
failed=$((failed + 1)); continue
fi
systemctl reload nginx
if certbot --nginx -d "$d" --non-interactive --agree-tos \
--email "$CERTBOT_EMAIL" --redirect --keep-until-expiring >>"$STATE_DIR/certbot.log" 2>&1; then
issued=$((issued + 1))
log "issued: $d"
else
log "ERROR: certbot failed for $d (see $STATE_DIR/certbot.log) — HTTP still served, HTTPS not yet"
failed=$((failed + 1))
fi
done <<< "$DESIRED"
# Domains dropped from the source: stop serving them, but never delete the
# certificate — a domain re-added next week should not need a fresh issuance.
for link in /etc/nginx/sites-enabled/tenant-*.conf; do
[[ -e "$link" ]] || continue
name="$(basename "$link")"; name="${name#tenant-}"; name="${name%.conf}"
if ! grep -qxF "$name" <<< "$DESIRED"; then
if [[ $DRY_RUN -eq 1 ]]; then
log "DRY RUN would disable: $name"
else
log "disabling (removed from source): $name"
rm -f "$link"
fi
fi
done
if [[ $DRY_RUN -eq 0 ]]; then
nginx -t && systemctl reload nginx
fi
log "done. issued=$issued current=$skipped wildcard=$wildcarded awaiting-dns=$waiting failed=$failed"
[[ $failed -eq 0 ]]

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Reconcile tenant TLS domains
After=network-online.target nginx.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/srv/marketplaces/bin/sync-domains.sh
# A failed run must not tear down what is already serving; the next run retries.
SuccessExitStatus=0
StandardOutput=journal
StandardError=journal

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Reconcile tenant TLS domains every 10 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=10min
# Spread load so many servers do not all hit the ACME API at once.
RandomizedDelaySec=90s
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -4,15 +4,19 @@ import { provideHttpClient, withInterceptors, withXhr } from '@angular/common/ht
import { routes } from './app.routes'; import { routes } from './app.routes';
import { cacheInterceptor } from './interceptors/cache.interceptor'; import { cacheInterceptor } from './interceptors/cache.interceptor';
import { apiErrorInterceptor } from './core/interceptors/api-error.interceptor';
import { apiBaseUrlInterceptor } from './interceptors/api-base-url.interceptor'; import { apiBaseUrlInterceptor } from './interceptors/api-base-url.interceptor';
import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor'; import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor';
import { mockDataInterceptor } from './interceptors/mock-data.interceptor'; import { mockDataInterceptor } from './interceptors/mock-data.interceptor';
import { adminAuthHeadersInterceptor } from './core/admin-auth/admin-auth-headers.interceptor'; import { adminAuthHeadersInterceptor, Ed25519VerificationService, NoopEd25519VerificationService, AUTH_API_URL, TELEGRAM_BOT_USERNAME } from '@marketplaces/auth';
import { Ed25519VerificationService } from './core/admin-auth/ed25519-verification.model'; import { provideMarketplacesPayment } from '@marketplaces/payment';
import { NoopEd25519VerificationService } from './core/admin-auth/noop-ed25519-verification.service';
import { provideServiceWorker } from '@angular/service-worker'; import { provideServiceWorker } from '@angular/service-worker';
import { MediaRepository } from './core/media/media-repository'; import { MediaRepository } from './core/media/media-repository';
import { MockMediaRepository } from './core/media/mock-media-repository.service'; import { MockMediaRepository } from './core/media/mock-media-repository.service';
import { ApiConfigService } from './core/config/api-config.service';
import { TenantResolverService } from './core/config/tenant-resolver.service';
import { environment } from '../environments/environment';
import { MOCK_GATEWAY_PROVIDERS } from './mock-gateway.providers';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
@@ -23,13 +27,55 @@ export const appConfig: ApplicationConfig = {
withInMemoryScrolling({ scrollPositionRestoration: 'top' }) withInMemoryScrolling({ scrollPositionRestoration: 'top' })
), ),
provideHttpClient(withXhr(), provideHttpClient(withXhr(),
withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, adminAuthHeadersInterceptor, cacheInterceptor]) // apiErrorInterceptor sits last so it observes the response after every
// other interceptor has run, and normalizes whatever actually came back.
withInterceptors([mockDataInterceptor, apiBaseUrlInterceptor, apiHeadersInterceptor, adminAuthHeadersInterceptor, cacheInterceptor, apiErrorInterceptor])
), ),
{ provide: Ed25519VerificationService, useClass: NoopEd25519VerificationService }, {
provide: AUTH_API_URL,
useFactory: (apiConfig: ApiConfigService) => apiConfig.getBaseUrl(),
deps: [ApiConfigService]
},
{ provide: TELEGRAM_BOT_USERNAME, useValue: environment.telegramBot },
// useFactory, not useClass: @marketplaces/auth ships plain tsc output, not
// Angular Package Format, so it carries no baked-in Ivy DI metadata for
// this class. useClass forces Angular to JIT-compile it at runtime, which
// throws when @angular/compiler isn't loaded (true for this build). A
// factory sidesteps that - NoopEd25519VerificationService has zero
// constructor deps, so this is a correct fix, not a workaround.
// Real fix belongs in vitanovaPackages: publish with ng-packagr.
{ provide: Ed25519VerificationService, useFactory: () => new NoopEd25519VerificationService() },
{ provide: MediaRepository, useClass: MockMediaRepository }, { provide: MediaRepository, useClass: MockMediaRepository },
// apiUrl: environment.qrApiUrl ('https://qr.vitanova.network/api') is the
// same "central payment service" the legacy /qr and
// /card/{partnerId}/{orderId} endpoints already used (api.service.ts) -
// one service shared across every tenant, unlike the per-tenant
// AUTH_API_URL above. Stripped the trailing /api here: the package's own
// default paymentsPath is '/api/v1/payments', so passing qrApiUrl
// unchanged would double it to .../api/api/v1/payments. Confirmed by
// reading the package's baseUrl() directly (apiUrl + paymentsPath,
// simple concatenation, no de-dup) - not yet confirmed against a live
// backend, since qrApiUrl's own /api suffix was never meant for this
// package. Revisit once a real payment request has actually been made.
//
// marketplaceDomain: a plain closure, not TenantResolverService.
// provideMarketplacesPayment runs outside the injector (it returns
// EnvironmentProviders, called before DI exists), so inject(DOCUMENT)
// isn't available here. The package evaluates this function lazily
// inside PaymentMarketplaceContext, which IS a real injection context -
// this closure just can't be one itself. Mirrors
// TenantResolverService.getHostname() intentionally; if that method's
// logic changes, this needs to change with it.
provideMarketplacesPayment({
apiUrl: environment.qrApiUrl.replace(/\/api\/?$/, ''),
marketplaceDomain: () => window.location.hostname.toLowerCase(),
}),
provideServiceWorker('ngsw-worker.js', { provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(), enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000' registrationStrategy: 'registerWhenStable:30000'
}) }),
// Empty in production - the file is swapped at build time so no mock
// gateway is even importable there. See mock-gateway.providers.ts.
...MOCK_GATEWAY_PROVIDERS
] ]
}; };

View File

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

View File

@@ -1,7 +1,8 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { languageGuard } from './guards/language.guard'; import { languageGuard } from './guards/language.guard';
import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.guard'; import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.guard';
import { adminAuthGuard, requireAdminPermission } from './core/admin-auth/admin-auth.guard'; import { adminAuthGuard } from '@marketplaces/auth';
import { requireAdminPermission } from './core/admin-auth/admin-auth.guard';
import { authRoutes } from './core/auth/auth.routes'; import { authRoutes } from './core/auth/auth.routes';
import { adminCategoryDirtyGuard } from './features/admin/categories/guards/admin-category-dirty.guard'; import { adminCategoryDirtyGuard } from './features/admin/categories/guards/admin-category-dirty.guard';
import { adminProductDirtyGuard } from './features/admin/products/guards/admin-product-dirty.guard'; import { adminProductDirtyGuard } from './features/admin/products/guards/admin-product-dirty.guard';
@@ -191,6 +192,60 @@ const coreRoutes: Routes = [
breadcrumb: [{ labelKey: 'adminShell.nav.customers', path: ['customers'] }, { labelKey: 'adminShell.pages.customerDetail.title' }] breadcrumb: [{ labelKey: 'adminShell.nav.customers', path: ['customers'] }, { labelKey: 'adminShell.pages.customerDetail.title' }]
} }
}, },
{
path: 'notifications',
loadComponent: () => import('./features/admin/notifications/pages/admin-notifications-page.component').then(m => m.AdminNotificationsPageComponent),
data: {
titleKey: 'adminShell.nav.notifications',
descriptionKey: 'adminShell.nav.notifications',
breadcrumb: [{ labelKey: 'adminShell.nav.notifications' }]
}
},
{
path: 'integrations',
loadComponent: () => import('./features/admin/integrations/pages/admin-integrations-page.component').then(m => m.AdminIntegrationsPageComponent),
data: {
titleKey: 'adminShell.nav.integrations',
descriptionKey: 'adminShell.nav.integrations',
breadcrumb: [{ labelKey: 'adminShell.nav.integrations' }]
}
},
{
path: 'finance',
loadComponent: () => import('./features/admin/finance/pages/admin-finance-page.component').then(m => m.AdminFinancePageComponent),
data: {
titleKey: 'adminShell.nav.finance',
descriptionKey: 'adminShell.nav.finance',
breadcrumb: [{ labelKey: 'adminShell.nav.finance' }]
}
},
{
path: 'marketplaces',
loadComponent: () => import('./features/admin/marketplaces/pages/admin-marketplaces-page.component').then(m => m.AdminMarketplacesPageComponent),
data: {
titleKey: 'adminShell.nav.marketplaces',
descriptionKey: 'adminShell.nav.marketplaces',
breadcrumb: [{ labelKey: 'adminShell.nav.marketplaces' }]
}
},
{
path: 'partner-hierarchy',
loadComponent: () => import('./features/admin/partner-hierarchy/pages/admin-partner-hierarchy-page.component').then(m => m.AdminPartnerHierarchyPageComponent),
data: {
titleKey: 'adminShell.nav.partnerHierarchy',
descriptionKey: 'adminShell.nav.partnerHierarchy',
breadcrumb: [{ labelKey: 'adminShell.nav.partnerHierarchy' }]
}
},
{
path: 'audit',
loadComponent: () => import('./features/admin/audit/pages/admin-audit-page.component').then(m => m.AdminAuditPageComponent),
data: {
titleKey: 'adminShell.nav.audit',
descriptionKey: 'adminShell.nav.audit',
breadcrumb: [{ labelKey: 'adminShell.nav.audit' }]
}
},
{ {
path: 'moderation', path: 'moderation',
loadComponent: () => import('./features/admin/moderation/pages/admin-reviews-list-page.component').then(m => m.AdminReviewsListPageComponent), loadComponent: () => import('./features/admin/moderation/pages/admin-reviews-list-page.component').then(m => m.AdminReviewsListPageComponent),

View File

@@ -16,8 +16,7 @@ import { UiRuntimeFacade } from './facades/runtime/ui-runtime.facade';
import { ApiHealthService } from './services/api-health.service'; import { ApiHealthService } from './services/api-health.service';
import { SeoService } from './services/seo.service'; import { SeoService } from './services/seo.service';
import { FloatingNotificationsComponent } from './features/website/user-experience/components/floating-notifications/floating-notifications.component'; import { FloatingNotificationsComponent } from './features/website/user-experience/components/floating-notifications/floating-notifications.component';
import { AdminAuthService } from './core/admin-auth/admin-auth.service'; import { AdminAuthService, AuthService } from '@marketplaces/auth';
import { AuthService } from './services/auth.service';
import { TelegramLoginComponent } from './components/telegram-login/telegram-login.component'; import { TelegramLoginComponent } from './components/telegram-login/telegram-login.component';
@Component({ @Component({
@@ -29,6 +28,8 @@ import { TelegramLoginComponent } from './components/telegram-login/telegram-log
}) })
export class App implements OnInit { export class App implements OnInit {
protected title = ''; protected title = '';
readonly isAdminHost = typeof window !== 'undefined'
&& window.location.hostname.toLowerCase().startsWith('admin.');
isHomePage = signal(true); isHomePage = signal(true);
isAdminRoute = signal(false); isAdminRoute = signal(false);
checkingServer = signal(true); checkingServer = signal(true);

View File

@@ -1,8 +1,10 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, inject } from '@angular/core';
import { DecimalPipe } from '@angular/common'; import { DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { CartItem, DeliveryOption } from '../../models'; import { CartItem, DeliveryOption } from '../../models';
import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe';
import { LanguageService } from '../../services/language.service';
import { CurrencyRatesService } from '../../services/currency-rates.service';
let nextDeliverySelectorId = 0; let nextDeliverySelectorId = 0;
@@ -15,6 +17,9 @@ let nextDeliverySelectorId = 0;
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class DeliverySelectorComponent { export class DeliverySelectorComponent {
private readonly languageService = inject(LanguageService);
private readonly currencyRates = inject(CurrencyRatesService);
@Input({ required: true }) item: CartItem | null = null; @Input({ required: true }) item: CartItem | null = null;
@Output() selectedDeliveryChange = new EventEmitter<DeliveryOption | null>(); @Output() selectedDeliveryChange = new EventEmitter<DeliveryOption | null>();
@@ -29,10 +34,20 @@ export class DeliverySelectorComponent {
return this.item?.selectedDelivery ?? null; return this.item?.selectedDelivery ?? null;
} }
get currency(): string { /** Source currency the item's prices are stored in. */
get sourceCurrency(): string {
return this.item?.currency || 'RUB'; return this.item?.currency || 'RUB';
} }
/** Currency the shopper has selected for display. */
get currency(): string {
return this.languageService.currentCurrency();
}
private convert(amount: number): number {
return this.currencyRates.convert(amount, this.sourceCurrency, this.currency);
}
get required(): boolean { get required(): boolean {
return this.item?.deliveryMode !== 'digital' return this.item?.deliveryMode !== 'digital'
&& this.options.length > 0 && this.options.length > 0
@@ -48,7 +63,8 @@ export class DeliverySelectorComponent {
} }
get selectedDeliveryTotal(): number { get selectedDeliveryTotal(): number {
return (this.selectedDelivery?.deliveryPrice ?? 0) * (this.item?.quantity ?? 1); const base = (this.selectedDelivery?.deliveryPrice ?? 0) * (this.item?.quantity ?? 1);
return this.convert(base);
} }
optionKey(option: DeliveryOption): string { optionKey(option: DeliveryOption): string {
@@ -57,7 +73,7 @@ export class DeliverySelectorComponent {
optionLabel(option: DeliveryOption): string { optionLabel(option: DeliveryOption): string {
const details = [option.deliveryPlace, option.deliveryTime].filter(Boolean); const details = [option.deliveryPlace, option.deliveryTime].filter(Boolean);
details.push(`${option.deliveryPrice.toFixed(2)} ${this.currency}`); details.push(`${this.convert(option.deliveryPrice).toFixed(2)} ${this.currency}`);
return details.join(' • '); return details.join(' • ');
} }

View File

@@ -6,7 +6,7 @@ import { of } from 'rxjs';
import { BootstrapConfig } from '../../shared/models/config'; import { BootstrapConfig } from '../../shared/models/config';
import { CONFIG_PROVIDER } from '../../core/config/config-provider.token'; import { CONFIG_PROVIDER } from '../../core/config/config-provider.token';
import { ConfigService } from '../../core/config/config.service'; import { ConfigService } from '../../core/config/config.service';
import { AuthService } from '../../services/auth.service'; import { AuthService, AUTH_API_URL } from '@marketplaces/auth';
import { HeaderComponent } from './header.component'; import { HeaderComponent } from './header.component';
function makeBootstrap(): BootstrapConfig { function makeBootstrap(): BootstrapConfig {
@@ -52,6 +52,7 @@ describe('HeaderComponent profile control (login/logout gating regression)', ()
provideHttpClient(), provideHttpClient(),
provideHttpClientTesting(), provideHttpClientTesting(),
{ provide: CONFIG_PROVIDER, useValue: { loadBootstrap: () => of(makeBootstrap()) } }, { provide: CONFIG_PROVIDER, useValue: { loadBootstrap: () => of(makeBootstrap()) } },
{ provide: AUTH_API_URL, useValue: 'https://test.local' },
{ provide: AuthService, useValue: fakeAuth }, { provide: AuthService, useValue: fakeAuth },
], ],
}); });

View File

@@ -14,7 +14,7 @@ import { FeatureConfigService } from '../../core/config/feature-config.service';
import { DEFAULT_HEADER_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../shared/models/config'; import { DEFAULT_HEADER_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../shared/models/config';
import { StaticPageResolverService } from '../../core/config/static-page-resolver.service'; import { StaticPageResolverService } from '../../core/config/static-page-resolver.service';
import { IconComponent } from '../../shared/ui/icon/icon.component'; import { IconComponent } from '../../shared/ui/icon/icon.component';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '@marketplaces/auth';
import { TelegramLoginComponent } from '../telegram-login/telegram-login.component'; import { TelegramLoginComponent } from '../telegram-login/telegram-login.component';
@Component({ @Component({

View File

@@ -51,10 +51,10 @@
<div class="product-price"> <div class="product-price">
@if (item.discount > 0) { @if (item.discount > 0) {
<span class="original-price">{{ item.price | number:'1.2-2' }} {{ item.currency }}</span> <span class="original-price">{{ item.price | currencyConvert:item.currency | number:'1.2-2' }} {{ displayCurrency() }}</span>
<span class="discounted-price">{{ getDiscountedPrice(item) | number:'1.2-2' }} {{ item.currency }}</span> <span class="discounted-price">{{ getDiscountedPrice(item) | currencyConvert:item.currency | number:'1.2-2' }} {{ displayCurrency() }}</span>
} @else { } @else {
<span class="current-price">{{ item.price | number:'1.2-2' }} {{ item.currency }}</span> <span class="current-price">{{ item.price | currencyConvert:item.currency | number:'1.2-2' }} {{ displayCurrency() }}</span>
} }
</div> </div>

View File

@@ -1,9 +1,11 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, inject } from '@angular/core';
import { DecimalPipe } from '@angular/common'; import { DecimalPipe } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { Product } from '../../core/products/models/product-domain.model'; import { Product } from '../../core/products/models/product-domain.model';
import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe';
import { CurrencyConvertPipe } from '../../pipes/currency-convert.pipe';
import { LanguageService } from '../../services/language.service';
import { cleanDescription, getBadgeClass, getDiscountedPrice, getMainImage, onImageError } from '../../utils/item.utils'; import { cleanDescription, getBadgeClass, getDiscountedPrice, getMainImage, onImageError } from '../../utils/item.utils';
const STOCK_LABEL_KEYS: Record<string, string> = { const STOCK_LABEL_KEYS: Record<string, string> = {
@@ -18,12 +20,15 @@ export type ProductCardAppearance = 'standard' | 'compact';
@Component({ @Component({
selector: 'app-product-card', selector: 'app-product-card',
standalone: true, standalone: true,
imports: [DecimalPipe, RouterLink, LangRoutePipe, TranslatePipe], imports: [DecimalPipe, RouterLink, LangRoutePipe, TranslatePipe, CurrencyConvertPipe],
templateUrl: './product-card.component.html', templateUrl: './product-card.component.html',
styleUrls: ['./product-card.component.scss'], styleUrls: ['./product-card.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ProductCardComponent { export class ProductCardComponent {
private readonly languageService = inject(LanguageService);
readonly displayCurrency = this.languageService.currentCurrency;
@Input({ required: true }) item!: Product; @Input({ required: true }) item!: Product;
@Input() title = ''; @Input() title = '';
@Input() description = ''; @Input() description = '';

View File

@@ -0,0 +1,10 @@
<button
type="button"
class="social-login-button"
[class]="'social-login-button--' + provider()"
[disabled]="loading()"
(click)="startLogin()"
>
<app-icon name="user" [size]="18" />
<span>{{ label() }}</span>
</button>

View File

@@ -0,0 +1,19 @@
.social-login-button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-primary);
color: var(--text-primary);
font-weight: var(--font-weight-bold, 700);
cursor: pointer;
&:disabled {
opacity: 0.6;
cursor: default;
}
}

View File

@@ -0,0 +1,56 @@
import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core';
import { take } from 'rxjs/operators';
import { SOCIAL_IDENTITY_GATEWAY } from '../../core/identity/services/social-identity-gateway.token';
import { SocialProvider } from '../../core/identity/services/social-identity-gateway.interface';
import { IconComponent } from '../../shared/ui/icon/icon.component';
const PROVIDER_LABEL: Record<SocialProvider, string> = {
vk: 'Continue with VK ID',
yandex: 'Continue with Yandex ID',
};
/**
* One button per social provider, per v3.1 §14 (VK ID is the primary
* storefront social login; Yandex ID is the second instance of the same
* flow, not a separate integration).
*
* Deliberately not spliced into TelegramLoginComponent's dialog yet. That
* component is the live customer login surface, and adding providers to it
* belongs in the pass that also demotes Telegram to one ExternalIdentity
* among several (FH-4.6) - not before a real OAuth application exists to
* test against.
*/
@Component({
selector: 'app-social-login-button',
standalone: true,
imports: [IconComponent],
templateUrl: './social-login-button.component.html',
styleUrls: ['./social-login-button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SocialLoginButtonComponent {
private readonly gateway = inject(SOCIAL_IDENTITY_GATEWAY);
readonly provider = input.required<SocialProvider>();
/** Where to land after the callback. Validated backend-side. */
readonly returnTo = input<string | undefined>(undefined);
readonly loading = signal(false);
readonly label = computed(() => PROVIDER_LABEL[this.provider()]);
startLogin(): void {
this.loading.set(true);
this.gateway
.getAuthorizeUrl(this.provider(), this.returnTo())
.pipe(take(1))
.subscribe({
next: url => {
this.loading.set(false);
if (typeof window !== 'undefined') {
window.location.href = url;
}
},
error: () => this.loading.set(false),
});
}
}

View File

@@ -9,8 +9,8 @@
<app-icon name="lock" [size]="40" /> <app-icon name="lock" [size]="40" />
</div> </div>
<h2>{{ 'auth.loginRequired' | translate }}</h2> <h2>{{ (mode === 'admin' ? 'auth.adminLoginRequired' : 'auth.loginRequired') | translate }}</h2>
<p class="login-desc">{{ 'auth.loginDescription' | translate }}</p> <p class="login-desc">{{ (mode === 'admin' ? 'auth.adminLoginDescription' : 'auth.loginDescription') | translate }}</p>
@if (status() === 'checking') { @if (status() === 'checking') {
<div class="login-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"> <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"/> <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> </svg>
{{ 'auth.loginWithTelegram' | translate }} {{ (mode === 'admin' ? 'auth.adminLoginWithTelegram' : 'auth.loginWithTelegram') | translate }}
</button> </button>
<!-- @if (loginUrl()) { <!-- @if (loginUrl()) {
@@ -64,7 +64,7 @@
} }
</div> </div>
<p class="login-note">{{ 'auth.loginNote' | translate }}</p> <p class="login-note">{{ (mode === 'admin' ? 'auth.adminLoginNote' : 'auth.loginNote') | translate }}</p>
} }
</div> </div>
</div> </div>

View File

@@ -1,12 +1,10 @@
import { Component, ChangeDetectionStrategy, Input, Injector, Signal, inject, effect, OnDestroy, OnInit } from '@angular/core'; import { Component, ChangeDetectionStrategy, Input, Injector, Signal, inject, effect, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AuthService } from '../../services/auth.service'; import { AuthService, AdminAuthService, AuthSession } from '@marketplaces/auth';
import { AdminAuthService } from '../../core/admin-auth/admin-auth.service';
import { LanguageService } from '../../services/language.service'; import { LanguageService } from '../../services/language.service';
import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe';
import { QrLoginEngine } from '../../shared/qr-login/qr-login.engine'; import { QrLoginEngine } from '../../shared/qr-login/qr-login.engine';
import { QrLoginAdapter, QrLoginStatus } from '../../shared/qr-login/qr-login.model'; import { QrLoginAdapter, QrLoginStatus } from '../../shared/qr-login/qr-login.model';
import { AuthSession } from '../../models/auth.model';
import { IconComponent } from '../../shared/ui/icon/icon.component'; import { IconComponent } from '../../shared/ui/icon/icon.component';
/** /**

View File

@@ -1,33 +0,0 @@
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AdminAuthService } from './admin-auth.service';
/** Backend paths that require an active AdminWebSessionID per API-REFERENCE.md §0. */
const ADMIN_GATED_PATH_SEGMENTS = ['/admin/', '/backoffice/', '/builder/', '/media/'];
/**
* Attaches admin session/token headers only to admin API requests. Mirrors
* apiHeadersInterceptor's self-guarding pattern but scoped to admin-gated
* paths so it never touches customer requests and never reads AuthService's
* session.
*/
export const adminAuthHeadersInterceptor: HttpInterceptorFn = (req, next) => {
const isAdminRequest = ADMIN_GATED_PATH_SEGMENTS.some(segment => req.url.includes(segment));
if (!isAdminRequest) {
return next(req);
}
const adminAuth = inject(AdminAuthService);
const session = adminAuth.session();
const token = adminAuth.getAdminToken();
let headers = req.headers;
if (session?.sessionId) {
headers = headers.set('AdminWebSessionID', session.sessionId);
}
if (token) {
headers = headers.set('Authorization', `Bearer ${token}`);
}
return next(req.clone({ headers }));
};

View File

@@ -1,24 +1,14 @@
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { CanActivateFn } from '@angular/router'; import { CanActivateFn } from '@angular/router';
import { AdminAuthService } from './admin-auth.service'; import { AdminAuthService } from '@marketplaces/auth';
import { AdminPermissionsService } from './admin-permissions.service'; import { AdminPermissionsService } from './admin-permissions.service';
/** Guards `/admin/**` routes. Never shares state with the customer auth guard/service. */
export const adminAuthGuard: CanActivateFn = () => {
const adminAuth = inject(AdminAuthService);
if (adminAuth.isAuthenticated()) {
return true;
}
adminAuth.requestLogin();
return false;
};
/** /**
* UI-only gate for a specific permission, on top of adminAuthGuard's * UI-only gate for a specific permission, on top of the package's
* authentication check. See AdminPermissionsService for why this is * adminAuthGuard authentication check. See AdminPermissionsService for why
* cosmetic until the backend ships real admin-role enforcement. * this is cosmetic until the backend ships real admin-role enforcement.
* Kept app-local because it depends on AdminPermissionsService, which reads
* this app's mock Users domain - not a portable auth concern.
*/ */
export function requireAdminPermission(permission: string): CanActivateFn { export function requireAdminPermission(permission: string): CanActivateFn {
return () => { return () => {

View File

@@ -1,212 +0,0 @@
import { Injectable, signal, computed, inject } from '@angular/core';
import { Observable, tap } from 'rxjs';
import { AdminAuthStatus } from '../../models/admin-auth.model';
import { AuthSession, WebSessionStart } from '../../models/auth.model';
import { TelegramSessionApiService } from '../../services/telegram-session-api.service';
import { environment } from '../../../environments/environment';
/**
* Admin login uses the exact same Telegram QR/session API as the customer
* login (TelegramSessionApiService, `{authApiUrl}/users/sessions`) - there is
* no separate admin backend endpoint, and none should be invented client-side.
* Only the *storage* is kept separate from AuthService, so an admin QR scan
* never authenticates the customer session or vice versa: distinct cookie
* name, distinct signals, distinct guard/interceptor.
*
* Backend gap this creates (see docs/backend/BACKEND-INTEGRATION.md §2.5): since the session
* API itself has no concept of "admin", the frontend cannot tell an admin
* Telegram session from a regular one. Actual admin authorization must be
* enforced server-side when admin API calls are made with the resulting
* session id - the frontend only decides where to *store* the result.
*/
const ADMIN_SESSION_COOKIE = 'adminSessionID';
const ADMIN_TOKEN_STORAGE_KEY = 'adminToken';
const ADMIN_REFRESH_STORAGE_KEY = 'adminRefreshToken';
const ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60;
@Injectable({ providedIn: 'root' })
export class AdminAuthService {
private readonly api = inject(TelegramSessionApiService);
private readonly sessionSignal = signal<AuthSession | null>(null);
private readonly statusSignal = signal<AdminAuthStatus>('unknown');
private readonly showLoginSignal = signal(false);
readonly session = this.sessionSignal.asReadonly();
readonly status = this.statusSignal.asReadonly();
readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated');
readonly showLoginDialog = this.showLoginSignal.asReadonly();
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
private sessionCheckTimer?: ReturnType<typeof setTimeout>;
constructor() {
this.checkSession();
}
checkSession(): void {
const webSessionID = this.getStoredAdminSessionID();
if (!webSessionID) {
this.clearAuthState('unauthenticated');
return;
}
this.statusSignal.set('checking');
this.checkSessionOnce(webSessionID).subscribe(session => {
if (!session?.active) {
this.clearAuthState('unauthenticated');
}
});
}
/** Check session without mutating internal state beyond activating on success (used for polling). */
checkSessionOnce(webSessionID = this.getStoredAdminSessionID()): Observable<AuthSession | null> {
return this.api.checkSessionOnce(webSessionID).pipe(
tap(session => {
if (session?.active) {
this.activateSession(session);
}
})
);
}
/** Create a backend web session - identical call to the customer login (TelegramSessionApiService.createSession). */
createWebSession(): Observable<WebSessionStart> {
return this.api.createSession();
}
getAdminAppLoginUrl(webSessionID: string): string {
return this.api.getBotAppLoginUrl(webSessionID);
}
onLoginComplete(): void {
this.hideLogin();
if (!this.isAuthenticated()) {
this.checkSession();
}
}
requestLogin(): void {
this.showLoginSignal.set(true);
}
/**
* Dev-only shortcut for local testing without a reachable Telegram/session
* backend: fabricates a local session and activates it directly, skipping
* the QR flow entirely. No-ops in production builds (checked at runtime,
* not just build-time, so it is safe even if this code ships). Never call
* this from anywhere reachable in a production build.
*/
devBypassLogin(): void {
if (environment.production) {
return;
}
this.hideLogin();
this.activateSession({
sessionId: `dev-bypass-${Date.now()}`,
userId: 0,
username: 'dev-admin',
displayName: 'Dev Admin (local bypass)',
active: true,
expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
});
}
hideLogin(): void {
this.showLoginSignal.set(false);
}
logout(): void {
const webSessionID = this.sessionSignal()?.sessionId || this.getStoredAdminSessionID();
if (!webSessionID) {
this.clearAuthState('unauthenticated');
return;
}
this.api.logout(webSessionID).subscribe(() => this.clearAuthState('unauthenticated'));
}
/** JWT pair storage, reserved for once the backend issues admin access/refresh tokens. Unused until then. */
getAdminToken(): string | null {
return typeof localStorage === 'undefined' ? null : localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY);
}
setAdminTokens(token: string, refreshToken: string): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, token);
localStorage.setItem(ADMIN_REFRESH_STORAGE_KEY, refreshToken);
}
clearAdminTokens(): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY);
localStorage.removeItem(ADMIN_REFRESH_STORAGE_KEY);
}
private activateSession(session: AuthSession): void {
this.sessionSignal.set(session);
this.statusSignal.set('authenticated');
this.setStoredAdminSessionID(session.sessionId);
this.scheduleSessionRefresh(session.expires);
}
private clearAuthState(status: AdminAuthStatus): void {
this.sessionSignal.set(null);
this.statusSignal.set(status);
this.clearStoredAdminSessionID();
this.clearAdminTokens();
this.clearSessionRefresh();
}
private scheduleSessionRefresh(expiresAt: string): void {
this.clearSessionRefresh();
const expiresMs = new Date(expiresAt).getTime();
const nowMs = Date.now();
const refreshIn = Number.isFinite(expiresMs)
? Math.max(expiresMs - nowMs - 60_000, 30_000)
: ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS * 1000;
this.sessionCheckTimer = setTimeout(() => this.checkSession(), refreshIn);
}
private clearSessionRefresh(): void {
if (this.sessionCheckTimer) {
clearTimeout(this.sessionCheckTimer);
this.sessionCheckTimer = undefined;
}
}
private getStoredAdminSessionID(): string | null {
if (typeof document === 'undefined') {
return null;
}
const cookie = document.cookie.split('; ').find(row => row.startsWith(`${ADMIN_SESSION_COOKIE}=`));
if (!cookie) {
return null;
}
try {
return decodeURIComponent(cookie.substring(ADMIN_SESSION_COOKIE.length + 1));
} catch {
return null;
}
}
private setStoredAdminSessionID(webSessionID: string): void {
if (typeof document === 'undefined') {
return;
}
const secure = typeof window !== 'undefined' && window.location.protocol === 'https:' ? '; Secure' : '';
document.cookie = `${ADMIN_SESSION_COOKIE}=${encodeURIComponent(webSessionID)}; Max-Age=${ADMIN_SESSION_COOKIE_MAX_AGE_SECONDS}; Path=/; SameSite=Strict${secure}`;
}
private clearStoredAdminSessionID(): void {
if (typeof document === 'undefined') {
return;
}
document.cookie = `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Strict`;
}
}

View File

@@ -1,6 +1,6 @@
import { Injectable, computed, inject } from '@angular/core'; import { Injectable, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop'; import { toSignal } from '@angular/core/rxjs-interop';
import { AdminAuthService } from './admin-auth.service'; import { AdminAuthService } from '@marketplaces/auth';
import { AdminUsersLocalGateway } from '../../features/admin/users/services/admin-users-local.gateway'; import { AdminUsersLocalGateway } from '../../features/admin/users/services/admin-users-local.gateway';
/** /**

View File

@@ -1,31 +0,0 @@
import { Observable } from 'rxjs';
/**
* Prep interfaces for a future Ed25519 challenge/response admin auth flow.
* No crypto is implemented here - verification is delegated to an injectable
* service so the real implementation (native WebCrypto Ed25519 support, or a
* backend verification call) can be swapped in once the backend API exists,
* without touching AdminAuthService or components.
*/
export interface Ed25519Challenge {
nonce: string;
timestamp: string;
/** Opaque challenge payload the client must sign with its private key. */
payload: string;
}
export interface Ed25519SignedResponse {
challenge: Ed25519Challenge;
publicKey: string;
signature: string;
}
export interface Ed25519VerificationResult {
valid: boolean;
reason?: string;
}
export abstract class Ed25519VerificationService {
abstract requestChallenge(): Observable<Ed25519Challenge>;
abstract verify(response: Ed25519SignedResponse): Observable<Ed25519VerificationResult>;
}

View File

@@ -1,20 +0,0 @@
import { Injectable } from '@angular/core';
import { Observable, throwError } from 'rxjs';
import { Ed25519Challenge, Ed25519SignedResponse, Ed25519VerificationResult, Ed25519VerificationService } from './ed25519-verification.model';
/**
* Default DI binding for Ed25519VerificationService until the backend ships
* the real challenge/verify endpoints. Intentionally fails closed (throws)
* rather than pretending to verify anything, so accidental use in a login
* path is loud instead of silently accepting unsigned sessions.
*/
@Injectable({ providedIn: 'root' })
export class NoopEd25519VerificationService implements Ed25519VerificationService {
requestChallenge(): Observable<Ed25519Challenge> {
return throwError(() => new Error('Ed25519 challenge endpoint is not yet available from the backend.'));
}
verify(_response: Ed25519SignedResponse): Observable<Ed25519VerificationResult> {
return throwError(() => new Error('Ed25519 verification endpoint is not yet available from the backend.'));
}
}

View File

@@ -0,0 +1,11 @@
/** Per docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1-4. */
export type AnalyticsEventType =
| 'session_started' | 'page_view' | 'search' | 'category_view' | 'product_view' | 'seller_view'
| 'add_to_cart' | 'cart_view' | 'checkout_started'
| 'payment_started' | 'payment_success' | 'payment_failed' | 'order_created';
export interface AnalyticsEvent {
eventType: AnalyticsEventType;
properties: Record<string, unknown>;
isSynthetic: boolean;
}

View File

@@ -0,0 +1,18 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AnalyticsEvent } from '../models/analytics-event.model';
import { AnalyticsGateway } from './analytics-gateway.interface';
/** Contract: docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1 - server-side batched ingest. */
@Injectable({ providedIn: 'root' })
export class AnalyticsApiGateway implements AnalyticsGateway {
private readonly http = inject(HttpClient);
track(event: AnalyticsEvent): Observable<void> {
return this.http
.post('/api/v2/storefront/analytics/events', event)
.pipe(map(() => undefined));
}
}

View File

@@ -0,0 +1,7 @@
import { Observable } from 'rxjs';
import { AnalyticsEvent } from '../models/analytics-event.model';
/** Per docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1. */
export interface AnalyticsGateway {
track(event: AnalyticsEvent): Observable<void>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { AnalyticsGateway } from './analytics-gateway.interface';
import { AnalyticsApiGateway } from './analytics-api.gateway';
/** Swap point for docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §1. */
export const ANALYTICS_GATEWAY = new InjectionToken<AnalyticsGateway>('ANALYTICS_GATEWAY', {
providedIn: 'root',
factory: () => inject(AnalyticsApiGateway),
});

View File

@@ -0,0 +1,17 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { AnalyticsEvent } from '../models/analytics-event.model';
import { AnalyticsGateway } from './analytics-gateway.interface';
/**
* No tracking pipeline exists at all today (confirmed - this is missing
* infrastructure, not a missing endpoint, per GAPS-AND-IMPROVEMENTS.md and
* docs/backend/TRACK-A-ANALYTICS-CONTRACT.md). This mock only proves the
* call-site wiring is correct; it does not persist anything.
*/
@Injectable({ providedIn: 'root' })
export class AnalyticsLocalGateway implements AnalyticsGateway {
track(_event: AnalyticsEvent): Observable<void> {
return of(void 0);
}
}

View File

@@ -0,0 +1,25 @@
import { Injectable, inject } from '@angular/core';
import { take } from 'rxjs/operators';
import { AnalyticsEventType } from '../models/analytics-event.model';
import { ANALYTICS_GATEWAY } from './analytics-gateway.token';
import { environment } from '../../../../environments/environment';
/**
* Thin call-site wrapper so storefront components fire events without
* knowing about the gateway/token plumbing. isSynthetic is derived from the
* build environment, never client-settable at the call site (per
* docs/backend/TRACK-A-ANALYTICS-CONTRACT.md §6 - synthetic traffic must be
* inseparable-by-accident from production data).
*/
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
private readonly gateway = inject(ANALYTICS_GATEWAY);
track(eventType: AnalyticsEventType, properties: Record<string, unknown> = {}): void {
this.gateway.track({
eventType,
properties,
isSynthetic: !environment.production,
}).pipe(take(1)).subscribe();
}
}

View File

@@ -1,46 +0,0 @@
import { AdminRole } from './permission.model';
/**
* Wire contracts for the Ed25519 challenge/response admin auth flow. These
* are documented in docs/AUTH.md and match the endpoints listed there
* exactly - none of this is invented beyond what's documented as FUTURE
* there and in docs/backend/BACKEND-INTEGRATION.md §2.5.
*/
export interface AuthChallenge {
nonce: string;
/** ISO 8601 issue time of the challenge. */
issuedAt: string;
/** ISO 8601 - challenge must be used before this or the backend rejects it. */
expiresAt: string;
}
export interface VerifySignatureRequest {
publicKey: string;
signature: string;
nonce: string;
}
export interface AuthTokenPair {
token: string;
refreshToken: string;
}
export interface RefreshTokenRequest {
refreshToken: string;
}
/**
* Claims expected in the JWT `token`. Decoded client-side for display/UX
* only (role-gating UI, expiry countdown) - the frontend never treats this
* as proof of authorization; every admin request is still re-checked
* server-side per docs/AUTH.md security considerations.
*/
export interface JwtClaims {
sub: string;
role: AdminRole;
/** Issued-at, seconds since epoch (standard `iat` claim). */
iat: number;
/** Expiry, seconds since epoch (standard `exp` claim). */
exp: number;
publicKey: string;
}

View File

@@ -1,50 +0,0 @@
/**
* Error codes the Ed25519 admin auth flow can surface to the UI. Each maps to
* a dedicated screen (see `core/auth/pages`) rather than a generic toast,
* because the recovery action differs per code (re-login vs. retry vs. wait).
*/
export type AuthErrorCode =
| 'session-expired'
| 'invalid-signature'
| 'unauthorized'
| 'forbidden'
| 'backend-unavailable';
export interface AuthError {
code: AuthErrorCode;
message: string;
/** HTTP status that produced this error, when known (absent for client-side errors, e.g. no Ed25519 support). */
status?: number;
}
/**
* Maps the backend error envelope's `error.code` (see
* BACKEND-API-REFERENCE.md §5) to the client's AuthErrorCode screens.
* Only codes with a dedicated screen are mapped; anything else falls back
* to the HTTP-status-derived code via authErrorCodeFromStatus.
*/
const BACKEND_ERROR_CODE_MAP: Record<string, AuthErrorCode> = {
TOKEN_EXPIRED: 'session-expired',
INVALID_SIGNATURE: 'invalid-signature',
UNAUTHENTICATED: 'unauthorized',
FORBIDDEN: 'forbidden',
SERVICE_UNAVAILABLE: 'backend-unavailable',
};
export function authErrorCodeFromBackendCode(code: unknown): AuthErrorCode | undefined {
return typeof code === 'string' ? BACKEND_ERROR_CODE_MAP[code] : undefined;
}
/** Maps a backend HTTP status to the AuthErrorCode screen it should route to. */
export function authErrorCodeFromStatus(status: number): AuthErrorCode {
switch (status) {
case 401:
return 'unauthorized';
case 403:
return 'forbidden';
case 0:
return 'backend-unavailable';
default:
return status >= 500 ? 'backend-unavailable' : 'unauthorized';
}
}

View File

@@ -1,30 +0,0 @@
/**
* Roles the Ed25519 JWT `role` claim is expected to carry (see docs/AUTH.md
* §JWT Claims). Ordered highest-to-lowest privilege; PermissionService does
* not rely on the order, it is documentation only.
*/
export type AdminRole = 'Owner' | 'Administrator' | 'Editor' | 'Support' | 'ReadOnly';
/**
* Coarse-grained permission keys. Intentionally small and domain-agnostic
* (mirrors the existing bootstrap-level `PermissionsConfig` shape in
* `shared/models/config/permissions.model.ts`) - fine-grained, per-domain
* permissions stay server-side until the backend ships a real permission
* model; the frontend only needs enough to hide/disable UI, never to be the
* source of truth for authorization.
*/
export type Permission =
| 'backoffice.read'
| 'backoffice.write'
| 'builder.read'
| 'builder.write'
| 'users.manage'
| 'settings.manage';
export const ROLE_PERMISSIONS: Readonly<Record<AdminRole, readonly Permission[]>> = {
Owner: ['backoffice.read', 'backoffice.write', 'builder.read', 'builder.write', 'users.manage', 'settings.manage'],
Administrator: ['backoffice.read', 'backoffice.write', 'builder.read', 'builder.write', 'users.manage'],
Editor: ['backoffice.read', 'backoffice.write', 'builder.read', 'builder.write'],
Support: ['backoffice.read'],
ReadOnly: ['backoffice.read', 'builder.read']
};

View File

@@ -1,7 +1,6 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { ButtonComponent } from '../../../shared/ui/button/button.component'; import { ButtonComponent } from '../../../shared/ui/button/button.component';
import { AuthFacade } from '../services/auth-facade.service'; import { AuthFacade, Ed25519KeypairService } from '@marketplaces/auth';
import { Ed25519KeypairService } from '../services/ed25519-keypair.service';
/** /**
* Ed25519 admin login page. Prepared UI for the flow described in * Ed25519 admin login page. Prepared UI for the flow described in

View File

@@ -4,7 +4,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { map } from 'rxjs'; import { map } from 'rxjs';
import { ButtonComponent } from '../../../shared/ui/button/button.component'; import { ButtonComponent } from '../../../shared/ui/button/button.component';
import { EmptyStateComponent } from '../../../shared/ui/empty-state/empty-state.component'; import { EmptyStateComponent } from '../../../shared/ui/empty-state/empty-state.component';
import { AuthErrorCode } from '../models/auth-error.model'; import { AuthErrorCode } from '@marketplaces/auth';
interface AuthErrorCopy { interface AuthErrorCopy {
title: string; title: string;

View File

@@ -1,36 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../../environments/environment';
import { AuthChallenge, AuthTokenPair, RefreshTokenRequest, VerifySignatureRequest } from '../models/auth-api.model';
/**
* Thin HTTP client for the Ed25519 admin auth endpoints documented in
* docs/AUTH.md. These endpoints do not exist on the backend yet (FUTURE -
* see docs/backend/BACKEND-INTEGRATION.md §2.5) - calling them today 404s
* or connection-errors, which AuthService maps to the
* `backend-unavailable` error screen. No mock/fake responses are fabricated
* here; this is real HttpClient wiring against the real contract, ready for
* the moment the backend ships.
*/
@Injectable({ providedIn: 'root' })
export class AuthApiService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.authApiUrl}/api/admin/auth`;
requestChallenge(): Observable<AuthChallenge> {
return this.http.get<AuthChallenge>(`${this.baseUrl}/challenge`);
}
verifySignature(request: VerifySignatureRequest): Observable<AuthTokenPair> {
return this.http.post<AuthTokenPair>(`${this.baseUrl}/verify`, request);
}
refresh(request: RefreshTokenRequest): Observable<AuthTokenPair> {
return this.http.post<AuthTokenPair>(`${this.baseUrl}/refresh`, request);
}
logout(refreshToken: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/logout`, { refreshToken } satisfies RefreshTokenRequest);
}
}

View File

@@ -1,56 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router';
import { finalize } from 'rxjs';
import { AuthService } from './auth.service';
import { PermissionService } from './permission.service';
import { SessionService } from './session.service';
import { Permission } from '../models/permission.model';
/**
* Public surface for components/pages. Components should depend on this,
* not on AuthService/SessionService/PermissionService directly, so the
* orchestration details (which service owns what) can change without
* touching UI code.
*/
@Injectable({ providedIn: 'root' })
export class AuthFacade {
private readonly auth = inject(AuthService);
private readonly session = inject(SessionService);
private readonly permissions = inject(PermissionService);
private readonly router = inject(Router);
readonly isAuthenticated = this.session.isAuthenticated;
readonly status = this.session.status;
readonly role = this.session.role;
readonly loginPhase = this.auth.loginPhase;
readonly lastError = this.auth.lastError;
restoreSession(): void {
this.auth.restoreSession();
}
login(onSuccessRedirectTo?: string): void {
this.auth.login().subscribe({
next: () => {
if (onSuccessRedirectTo) {
this.router.navigateByUrl(onSuccessRedirectTo);
}
},
error: () => {
const code = this.auth.lastError()?.code ?? 'unauthorized';
this.router.navigate(['/admin-login/error', code]);
}
});
}
logout(redirectTo = '/admin-login'): void {
this.auth
.logout()
.pipe(finalize(() => this.router.navigateByUrl(redirectTo)))
.subscribe({ error: () => undefined });
}
can(permission: Permission): boolean {
return this.permissions.has(permission);
}
}

View File

@@ -1,126 +0,0 @@
import { Injectable, inject, signal } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { catchError, switchMap, tap, throwError } from 'rxjs';
import { Observable } from 'rxjs';
import { AuthTokenPair } from '../models/auth-api.model';
import { AuthError, authErrorCodeFromBackendCode, authErrorCodeFromStatus } from '../models/auth-error.model';
import { AuthApiService } from './auth-api.service';
import { Ed25519KeypairService } from './ed25519-keypair.service';
import { SessionService } from './session.service';
export type LoginPhase = 'idle' | 'requesting-challenge' | 'signing' | 'verifying' | 'done';
/**
* Orchestrates the Ed25519 challenge/response admin auth flow end to end:
*
* GET /api/admin/auth/challenge -> { nonce }
* sign(nonce) with local Ed25519 key -> signature
* POST /api/admin/auth/verify -> { token, refreshToken }
*
* This is the lowest-level orchestrator; components should go through
* AuthFacade rather than calling this directly.
*/
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly api = inject(AuthApiService);
private readonly keypair = inject(Ed25519KeypairService);
private readonly session = inject(SessionService);
private readonly loginPhaseSignal = signal<LoginPhase>('idle');
private readonly lastErrorSignal = signal<AuthError | null>(null);
readonly loginPhase = this.loginPhaseSignal.asReadonly();
readonly lastError = this.lastErrorSignal.asReadonly();
constructor() {
this.session.onRefreshDue(() => this.refresh().subscribe());
}
/** Restores a persisted session on app bootstrap. Call once from an APP_INITIALIZER or root component. */
restoreSession(): void {
this.session.restore();
}
login(): Observable<AuthTokenPair> {
this.lastErrorSignal.set(null);
this.loginPhaseSignal.set('requesting-challenge');
return this.api.requestChallenge().pipe(
switchMap(challenge =>
this.signChallenge(challenge.nonce).pipe(
switchMap(({ publicKeyBase64, signature }) => {
this.loginPhaseSignal.set('verifying');
return this.api.verifySignature({ publicKey: publicKeyBase64, signature, nonce: challenge.nonce });
})
)
),
tap(tokens => {
this.session.activate(tokens);
this.loginPhaseSignal.set('done');
}),
catchError(error => this.handleAuthError<AuthTokenPair>(error, 'invalid-signature'))
);
}
refresh(): Observable<AuthTokenPair> {
const refreshToken = this.session.getRefreshToken();
if (!refreshToken) {
this.session.markExpired();
return throwError(() => this.toAuthError({ code: 'session-expired', message: 'No refresh token available.' }));
}
return this.api.refresh({ refreshToken }).pipe(
tap(tokens => this.session.activate(tokens)),
catchError(error => this.handleAuthError<AuthTokenPair>(error, 'session-expired', () => this.session.markExpired()))
);
}
logout(): Observable<void> {
const refreshToken = this.session.getRefreshToken();
this.session.clear();
if (!refreshToken) {
return new Observable<void>(subscriber => {
subscriber.next();
subscriber.complete();
});
}
return this.api.logout(refreshToken).pipe(catchError(() => throwError(() => null)));
}
private signChallenge(nonce: string): Observable<{ publicKeyBase64: string; signature: string }> {
this.loginPhaseSignal.set('signing');
return new Observable<{ publicKeyBase64: string; signature: string }>(subscriber => {
this.keypair
.getOrCreateKeyPair()
.then(({ publicKeyBase64 }) =>
this.keypair.sign(nonce).then(signature => {
subscriber.next({ publicKeyBase64, signature });
subscriber.complete();
})
)
.catch(error => subscriber.error(error));
});
}
private handleAuthError<T>(error: unknown, fallbackCode: AuthError['code'], onError?: () => void): Observable<T> {
onError?.();
return throwError(() => this.toAuthError(this.toAuthErrorShape(error, fallbackCode)));
}
private toAuthErrorShape(error: unknown, fallbackCode: AuthError['code']): AuthError {
if (error instanceof HttpErrorResponse) {
const bodyCode = (error.error as { error?: { code?: unknown } } | null)?.error?.code;
const code = authErrorCodeFromBackendCode(bodyCode) ?? authErrorCodeFromStatus(error.status);
return { code, message: error.message, status: error.status };
}
if (error instanceof Error) {
return { code: fallbackCode, message: error.message };
}
return { code: fallbackCode, message: 'Unknown authentication error.' };
}
private toAuthError(error: AuthError): AuthError {
this.lastErrorSignal.set(error);
return error;
}
}

View File

@@ -1,125 +0,0 @@
import { Injectable } from '@angular/core';
/**
* Manages the browser-local Ed25519 keypair used to sign admin auth
* challenges. Real WebCrypto Ed25519 (RFC 8032 support landed in evergreen
* browsers) - not a placeholder. The private key is generated
* non-extractable and kept only in IndexedDB as a CryptoKey handle; it is
* never serialized, never sent anywhere, and cannot be exported by design.
*
* Registering `publicKey` with an admin's account (associating it with a
* role) is a backend-side, out-of-band operation (e.g. an Owner approving a
* new admin's public key) - entirely outside this frontend's scope.
*/
const DB_NAME = 'admin-auth-ed25519';
const DB_VERSION = 1;
const STORE_NAME = 'keypair';
const KEY_RECORD_ID = 'device-keypair';
interface StoredKeyPair {
id: string;
publicKey: CryptoKey;
privateKey: CryptoKey;
publicKeyBase64: string;
}
@Injectable({ providedIn: 'root' })
export class Ed25519KeypairService {
private cached: StoredKeyPair | null = null;
isSupported(): boolean {
return typeof crypto !== 'undefined' && !!crypto.subtle && typeof indexedDB !== 'undefined';
}
/** Returns the device's Ed25519 keypair, generating and persisting one on first use. */
async getOrCreateKeyPair(): Promise<{ publicKeyBase64: string }> {
if (!this.isSupported()) {
throw new Error('Ed25519 is not supported in this browser (requires WebCrypto + IndexedDB).');
}
const existing = await this.loadFromStore();
if (existing) {
this.cached = existing;
return { publicKeyBase64: existing.publicKeyBase64 };
}
const generated = await this.generateAndPersist();
this.cached = generated;
return { publicKeyBase64: generated.publicKeyBase64 };
}
async sign(message: string): Promise<string> {
const keyPair = this.cached ?? (await this.loadFromStore());
if (!keyPair) {
throw new Error('No Ed25519 keypair available - call getOrCreateKeyPair() first.');
}
const signatureBuffer = await crypto.subtle.sign('Ed25519', keyPair.privateKey, new TextEncoder().encode(message));
return this.toBase64(new Uint8Array(signatureBuffer));
}
/** Discards the local keypair (e.g. "forget this device"). A new keypair on next login requires re-registration with the backend. */
async clear(): Promise<void> {
this.cached = null;
const db = await this.openDatabase();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(KEY_RECORD_ID);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
private async generateAndPersist(): Promise<StoredKeyPair> {
const keyPair = (await crypto.subtle.generateKey({ name: 'Ed25519' }, false, ['sign', 'verify'])) as CryptoKeyPair;
const publicKeyRaw = await crypto.subtle.exportKey('raw', keyPair.publicKey);
const publicKeyBase64 = this.toBase64(new Uint8Array(publicKeyRaw));
const record: StoredKeyPair = {
id: KEY_RECORD_ID,
publicKey: keyPair.publicKey,
privateKey: keyPair.privateKey,
publicKeyBase64
};
const db = await this.openDatabase();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put(record);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
return record;
}
private async loadFromStore(): Promise<StoredKeyPair | null> {
const db = await this.openDatabase();
return new Promise<StoredKeyPair | null>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const request = tx.objectStore(STORE_NAME).get(KEY_RECORD_ID);
request.onsuccess = () => resolve((request.result as StoredKeyPair | undefined) ?? null);
request.onerror = () => reject(request.error);
});
}
private openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
request.result.createObjectStore(STORE_NAME, { keyPath: 'id' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
private toBase64(bytes: Uint8Array): string {
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
}

View File

@@ -1,44 +0,0 @@
import { Injectable } from '@angular/core';
import { JwtClaims } from '../models/auth-api.model';
/**
* Client-side JWT *decoding* only - never verification. The signature is
* meaningless to check here because the frontend has no trusted key to check
* it against; verifying a JWT's signature is the backend's job on every
* request. This service exists purely so the UI can read `role`/`exp` for
* display and route-gating UX (e.g. "session expires in 4m").
*/
@Injectable({ providedIn: 'root' })
export class JwtService {
decode(token: string): JwtClaims | null {
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
try {
const payload = this.base64UrlDecode(parts[1]);
const claims = JSON.parse(payload) as JwtClaims;
return this.isJwtClaims(claims) ? claims : null;
} catch {
return null;
}
}
isExpired(claims: JwtClaims, skewSeconds = 0): boolean {
return claims.exp * 1000 <= Date.now() + skewSeconds * 1000;
}
private isJwtClaims(value: unknown): value is JwtClaims {
if (!value || typeof value !== 'object') {
return false;
}
const claims = value as Partial<JwtClaims>;
return typeof claims.sub === 'string' && typeof claims.role === 'string' && typeof claims.exp === 'number';
}
private base64UrlDecode(input: string): string {
const base64 = input.replace(/-/g, '+').replace(/_/g, '/').padEnd(input.length + ((4 - (input.length % 4)) % 4), '=');
return decodeURIComponent(escape(atob(base64)));
}
}

View File

@@ -1,26 +0,0 @@
import { Injectable, computed, inject } from '@angular/core';
import { Permission, ROLE_PERMISSIONS } from '../models/permission.model';
import { SessionService } from './session.service';
/**
* Derives the current admin's permission set from their JWT `role` claim.
* UI-only gate (hide/disable) - the backend must independently enforce
* every mutation server-side; see docs/AUTH.md security considerations.
*/
@Injectable({ providedIn: 'root' })
export class PermissionService {
private readonly session = inject(SessionService);
readonly permissions = computed<readonly Permission[]>(() => {
const role = this.session.role();
return role ? ROLE_PERMISSIONS[role] : [];
});
has(permission: Permission): boolean {
return this.permissions().includes(permission);
}
hasAny(permissions: readonly Permission[]): boolean {
return permissions.some(permission => this.has(permission));
}
}

View File

@@ -1,133 +0,0 @@
import { Injectable, computed, signal } from '@angular/core';
import { AuthTokenPair, JwtClaims } from '../models/auth-api.model';
import { JwtService } from './jwt.service';
export type SessionStatus = 'unknown' | 'restoring' | 'authenticated' | 'unauthenticated' | 'expired';
const TOKEN_STORAGE_KEY = 'ed25519AdminToken';
const REFRESH_STORAGE_KEY = 'ed25519AdminRefreshToken';
/** Refresh this long before actual expiry, so a request never races an expiring token. */
const REFRESH_SKEW_MS = 60_000;
/**
* Holds the Ed25519-flow JWT/refresh-token pair and derived claims. Separate
* from AdminAuthService (Telegram-session state) by design - the two auth
* mechanisms are not merged until the backend actually ships the Ed25519
* endpoints and a migration decision is made (see docs/AUTH.md).
*/
@Injectable({ providedIn: 'root' })
export class SessionService {
private readonly jwt = new JwtService();
private readonly tokenSignal = signal<string | null>(null);
private readonly refreshTokenSignal = signal<string | null>(null);
private readonly claimsSignal = signal<JwtClaims | null>(null);
private readonly statusSignal = signal<SessionStatus>('unknown');
readonly token = this.tokenSignal.asReadonly();
readonly claims = this.claimsSignal.asReadonly();
readonly status = this.statusSignal.asReadonly();
readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated');
readonly role = computed(() => this.claimsSignal()?.role ?? null);
private refreshTimer?: ReturnType<typeof setTimeout>;
private refreshCallback?: () => void;
/** Called once by AuthService on init to wire up the refresh trigger without a circular DI dependency. */
onRefreshDue(callback: () => void): void {
this.refreshCallback = callback;
}
/** Restores session state from persisted storage. Returns true if a (possibly expired) session was found. */
restore(): boolean {
this.statusSignal.set('restoring');
const token = this.readStorage(TOKEN_STORAGE_KEY);
const refreshToken = this.readStorage(REFRESH_STORAGE_KEY);
if (!token || !refreshToken) {
this.statusSignal.set('unauthenticated');
return false;
}
const claims = this.jwt.decode(token);
if (!claims) {
this.clear();
return false;
}
this.tokenSignal.set(token);
this.refreshTokenSignal.set(refreshToken);
this.claimsSignal.set(claims);
if (this.jwt.isExpired(claims)) {
this.statusSignal.set('expired');
} else {
this.statusSignal.set('authenticated');
this.scheduleRefresh(claims);
}
return true;
}
activate(tokens: AuthTokenPair): void {
const claims = this.jwt.decode(tokens.token);
if (!claims) {
throw new Error('Received a malformed JWT from the auth backend.');
}
this.tokenSignal.set(tokens.token);
this.refreshTokenSignal.set(tokens.refreshToken);
this.claimsSignal.set(claims);
this.statusSignal.set('authenticated');
this.writeStorage(TOKEN_STORAGE_KEY, tokens.token);
this.writeStorage(REFRESH_STORAGE_KEY, tokens.refreshToken);
this.scheduleRefresh(claims);
}
getRefreshToken(): string | null {
return this.refreshTokenSignal();
}
markExpired(): void {
this.statusSignal.set('expired');
this.clearRefreshTimer();
}
clear(): void {
this.tokenSignal.set(null);
this.refreshTokenSignal.set(null);
this.claimsSignal.set(null);
this.statusSignal.set('unauthenticated');
this.removeStorage(TOKEN_STORAGE_KEY);
this.removeStorage(REFRESH_STORAGE_KEY);
this.clearRefreshTimer();
}
private scheduleRefresh(claims: JwtClaims): void {
this.clearRefreshTimer();
const expiresInMs = claims.exp * 1000 - Date.now();
const refreshInMs = Math.max(expiresInMs - REFRESH_SKEW_MS, 5_000);
this.refreshTimer = setTimeout(() => this.refreshCallback?.(), refreshInMs);
}
private clearRefreshTimer(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = undefined;
}
}
private readStorage(key: string): string | null {
return typeof localStorage === 'undefined' ? null : localStorage.getItem(key);
}
private writeStorage(key: string, value: string): void {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(key, value);
}
}
private removeStorage(key: string): void {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(key);
}
}
}

View File

@@ -0,0 +1,37 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { ApiConfigService } from '../../config/api-config.service';
import { ApiBootstrapProvider } from './api-bootstrap.provider';
describe('ApiBootstrapProvider', () => {
let provider: ApiBootstrapProvider;
let httpTesting: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ApiBootstrapProvider,
provideHttpClient(),
provideHttpClientTesting(),
{
provide: ApiConfigService,
useValue: { getBaseUrl: () => 'https://api.gorbushka.market' }
}
]
});
provider = TestBed.inject(ApiBootstrapProvider);
httpTesting = TestBed.inject(HttpTestingController);
});
afterEach(() => httpTesting.verify());
it('loads bootstrap from the same tenant API base as every other request', () => {
provider.loadBootstrap().subscribe();
const request = httpTesting.expectOne('https://api.gorbushka.market/bootstrap');
expect(request.request.method).toBe('GET');
request.flush({});
});
});

View File

@@ -3,14 +3,16 @@ import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { BootstrapConfig } from '../../../shared/models/config'; import { BootstrapConfig } from '../../../shared/models/config';
import { ConfigProvider } from '../../config/config-provider.interface'; import { ConfigProvider } from '../../config/config-provider.interface';
import { ApiConfigService } from '../../config/api-config.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ApiBootstrapProvider implements ConfigProvider { export class ApiBootstrapProvider implements ConfigProvider {
private readonly bootstrapUrl = '/bootstrap'; constructor(
private readonly http: HttpClient,
constructor(private readonly http: HttpClient) {} private readonly apiConfig: ApiConfigService
) {}
loadBootstrap(): Observable<BootstrapConfig> { loadBootstrap(): Observable<BootstrapConfig> {
return this.http.get<BootstrapConfig>(this.bootstrapUrl); return this.http.get<BootstrapConfig>(`${this.apiConfig.getBaseUrl()}/bootstrap`);
} }
} }

View File

@@ -0,0 +1,35 @@
/** Per docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §2. */
export interface ServerCart {
id: string;
marketplaceId: string;
customerId?: string;
sessionToken?: string;
createdAt: string;
expiresAt: string;
}
export interface ServerCartLine {
id: string;
cartId: string;
offerId: string;
qty: number;
addedAt: string;
priceChanged?: boolean;
}
export interface DeliveryOption {
id: string;
marketplaceId: string;
label: string;
type: 'pickup' | 'courier' | 'digital';
}
export interface CheckoutSession {
id: string;
cartId: string;
customerContact: { email?: string; phone?: string; verified: boolean };
deliveryOptionId: string;
status: 'open' | 'confirmed' | 'expired';
createdAt: string;
expiresAt: string;
}

View File

@@ -0,0 +1,36 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { CheckoutSession, ServerCart, ServerCartLine } from '../models/server-cart.model';
import { ServerCartGateway } from './server-cart-gateway.interface';
/** Contract: docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §3, §5. */
@Injectable({ providedIn: 'root' })
export class ServerCartApiGateway implements ServerCartGateway {
private readonly http = inject(HttpClient);
getCart(): Observable<{ cart: ServerCart; lines: ServerCartLine[] }> {
return this.http.get<{ cart: ServerCart; lines: ServerCartLine[] }>('/api/v2/storefront/cart');
}
addLine(offerId: string, qty: number): Observable<ServerCartLine> {
return this.http.post<ServerCartLine>('/api/v2/storefront/cart/lines', { offerId, qty });
}
updateLine(lineId: string, qty: number): Observable<ServerCartLine> {
return this.http.patch<ServerCartLine>(`/api/v2/storefront/cart/lines/${encodeURIComponent(lineId)}`, { qty });
}
removeLine(lineId: string): Observable<void> {
return this.http
.delete(`/api/v2/storefront/cart/lines/${encodeURIComponent(lineId)}`)
.pipe(map(() => undefined));
}
startCheckout(deliveryOptionId: string, currency: string): Observable<CheckoutSession> {
// §5 example body is { cartId, currency, deliveryOptionId } - cartId is
// implicit server-side (the session's own cart), so it is not sent here.
return this.http.post<CheckoutSession>('/api/v2/storefront/checkout', { currency, deliveryOptionId });
}
}

View File

@@ -0,0 +1,11 @@
import { Observable } from 'rxjs';
import { CheckoutSession, ServerCart, ServerCartLine } from '../models/server-cart.model';
/** Per docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §3, §5. */
export interface ServerCartGateway {
getCart(): Observable<{ cart: ServerCart; lines: ServerCartLine[] }>;
addLine(offerId: string, qty: number): Observable<ServerCartLine>;
updateLine(lineId: string, qty: number): Observable<ServerCartLine>;
removeLine(lineId: string): Observable<void>;
startCheckout(deliveryOptionId: string, currency: string): Observable<CheckoutSession>;
}

View File

@@ -0,0 +1,9 @@
import { InjectionToken, inject } from '@angular/core';
import { ServerCartGateway } from './server-cart-gateway.interface';
import { ServerCartApiGateway } from './server-cart-api.gateway';
/** Swap point for docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md §3, §5. */
export const SERVER_CART_GATEWAY = new InjectionToken<ServerCartGateway>('SERVER_CART_GATEWAY', {
providedIn: 'root',
factory: () => inject(ServerCartApiGateway),
});

View File

@@ -0,0 +1,66 @@
import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs';
import { CheckoutSession, ServerCart, ServerCartLine } from '../models/server-cart.model';
import { ServerCartGateway } from './server-cart-gateway.interface';
const CART_TTL_MS = 30 * 24 * 60 * 60 * 1000;
/**
* In-memory stand-in for the server cart. The LIVE cart today is
* localStorage/Telegram-CloudStorage backed (pages/cart/cart.component.ts,
* services/cart.service.ts) and deliberately untouched by this module - see
* docs/backend/PHASE-6-CART-CHECKOUT-CONTRACT.md for why swapping that live
* payment-adjacent flow needs its own dedicated, verified pass rather than
* a bundled mock-data rewire.
*/
@Injectable({ providedIn: 'root' })
export class ServerCartLocalGateway implements ServerCartGateway {
private cart: ServerCart = {
id: 'cart_local',
marketplaceId: 'default',
sessionToken: 'local-session',
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + CART_TTL_MS).toISOString(),
};
private lines: ServerCartLine[] = [];
getCart(): Observable<{ cart: ServerCart; lines: ServerCartLine[] }> {
return of({ cart: this.cart, lines: this.lines });
}
addLine(offerId: string, qty: number): Observable<ServerCartLine> {
const existing = this.lines.find(l => l.offerId === offerId);
if (existing) {
existing.qty += qty;
return of(existing);
}
const line: ServerCartLine = { id: `line_${Date.now()}`, cartId: this.cart.id, offerId, qty, addedAt: new Date().toISOString() };
this.lines.push(line);
return of(line);
}
updateLine(lineId: string, qty: number): Observable<ServerCartLine> {
const line = this.lines.find(l => l.id === lineId);
if (line) {
line.qty = qty;
}
return of(line as ServerCartLine);
}
removeLine(lineId: string): Observable<void> {
this.lines = this.lines.filter(l => l.id !== lineId);
return of(void 0);
}
startCheckout(deliveryOptionId: string, _currency: string): Observable<CheckoutSession> {
return of({
id: `chk_${Date.now()}`,
cartId: this.cart.id,
customerContact: { verified: false },
deliveryOptionId,
status: 'open',
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
});
}
}

View File

@@ -0,0 +1,70 @@
import { TestBed } from '@angular/core/testing';
import { ApiConfigService } from './api-config.service';
import { TenantResolverService } from './tenant-resolver.service';
describe('ApiConfigService', () => {
let service: ApiConfigService;
let tenantResolver: jasmine.SpyObj<TenantResolverService>;
beforeEach(() => {
tenantResolver = jasmine.createSpyObj<TenantResolverService>(
'TenantResolverService',
['getHostname', 'getBaseDomain', 'getProtocol', 'getTenantKey', 'isLocalhost']
);
tenantResolver.getBaseDomain.and.returnValue('gorbushka.market');
tenantResolver.getTenantKey.and.returnValue('gorbushka');
tenantResolver.getProtocol.and.returnValue('https:');
tenantResolver.isLocalhost.and.returnValue(false);
TestBed.configureTestingModule({
providers: [
ApiConfigService,
{ provide: TenantResolverService, useValue: tenantResolver }
]
});
service = TestBed.inject(ApiConfigService);
});
it('uses the current customer hostname for the production API base URL', () => {
tenantResolver.getHostname.and.returnValue('gorbushka.market');
expect(service.getBaseUrl()).toBe('https://api.gorbushka.market');
});
it('uses the shared base-domain API for a tenant subdomain', () => {
tenantResolver.getHostname.and.returnValue('store1.example.com');
tenantResolver.getBaseDomain.and.returnValue('example.com');
expect(service.getBaseUrl()).toBe('https://api.example.com');
});
it('uses the shared base-domain API for www', () => {
tenantResolver.getHostname.and.returnValue('www.gorbushka.market');
expect(service.getBaseUrl()).toBe('https://api.gorbushka.market');
});
it('preserves the API namespace when targeting a tenant backend', () => {
tenantResolver.getHostname.and.returnValue('gorbushka.market');
expect(service.toApiUrl('/api/v2/storefront/cart'))
.toBe('https://api.gorbushka.market/api/v2/storefront/cart');
});
it('does not duplicate the API prefix for localhost proxy requests', () => {
tenantResolver.getHostname.and.returnValue('localhost');
tenantResolver.getTenantKey.and.returnValue('default');
tenantResolver.isLocalhost.and.returnValue(true);
expect(service.toApiUrl('/api/v2/storefront/cart')).toBe('/api/v2/storefront/cart');
});
it('leaves absolute and non-API URLs unchanged', () => {
tenantResolver.getHostname.and.returnValue('gorbushka.market');
expect(service.toApiUrl('https://cdn.example.com/image.png'))
.toBe('https://cdn.example.com/image.png');
expect(service.toApiUrl('/assets/config.json')).toBe('/assets/config.json');
});
});

View File

@@ -1,16 +1,16 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { ConfigService } from './config.service';
import { TenantResolverService } from './tenant-resolver.service'; import { TenantResolverService } from './tenant-resolver.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ApiConfigService { export class ApiConfigService {
private readonly tenantResolver = inject(TenantResolverService); private readonly tenantResolver = inject(TenantResolverService);
private readonly configService = inject(ConfigService);
getBaseUrl(): string { getBaseUrl(): string {
const hostname = this.tenantResolver.getHostname();
const baseDomain = this.tenantResolver.getBaseDomain();
const protocol = this.tenantResolver.getProtocol();
const tenantKey = this.tenantResolver.getTenantKey(); const tenantKey = this.tenantResolver.getTenantKey();
const bootstrapUrl = this.resolveBootstrapApiBaseUrl();
const tenantMap = (environment as any).tenantApiBaseUrls as Record<string, string> | undefined; const tenantMap = (environment as any).tenantApiBaseUrls as Record<string, string> | undefined;
const localhostUrl = (environment as any).localhostApiUrl as string | undefined; const localhostUrl = (environment as any).localhostApiUrl as string | undefined;
const apiTemplate = (environment as any).tenantApiTemplate as string | undefined; const apiTemplate = (environment as any).tenantApiTemplate as string | undefined;
@@ -19,13 +19,16 @@ export class ApiConfigService {
if (this.tenantResolver.isLocalhost() && localhostUrl) { if (this.tenantResolver.isLocalhost() && localhostUrl) {
url = localhostUrl; url = localhostUrl;
} else if (tenantMap?.[hostname]) {
url = tenantMap[hostname];
} else if (tenantMap?.[tenantKey]) { } else if (tenantMap?.[tenantKey]) {
url = tenantMap[tenantKey]; url = tenantMap[tenantKey];
} else if (apiTemplate) { } else if (apiTemplate && hostname) {
url = apiTemplate.replace('{tenant}', tenantKey); url = apiTemplate
} else if (bootstrapUrl) { .replace('{protocol}', protocol)
// Bootstrap API override is opt-in and only for absolute URLs. .replace('{baseDomain}', baseDomain)
url = bootstrapUrl; .replace('{hostname}', hostname)
.replace('{tenant}', tenantKey);
} }
return this.normalizeBaseUrl(url); return this.normalizeBaseUrl(url);
@@ -54,36 +57,15 @@ export class ApiConfigService {
} }
const baseUrl = this.getBaseUrl(); const baseUrl = this.getBaseUrl();
const path = url.slice('/api'.length); if (baseUrl === '/') {
return `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`; return url;
}
private resolveBootstrapApiBaseUrl(): string | null {
const allowBootstrapApiOverride = (environment as any).allowBootstrapApiOverride === true;
if (!allowBootstrapApiOverride) {
return null;
} }
const bootstrap = this.configService.getBootstrapSnapshot() as any; if (baseUrl === '/api' || baseUrl.endsWith('/api')) {
if (!bootstrap) { return `${baseUrl}${url.slice('/api'.length)}`;
return null;
} }
const endpointBase = bootstrap?.apiEndpoints?.website?.baseUrl; return `${baseUrl}${url}`;
if (typeof endpointBase === 'string' && this.isAbsoluteHttpUrl(endpointBase)) {
return endpointBase;
}
const tenantBase = bootstrap?.tenant?.apiBaseUrl;
if (typeof tenantBase === 'string' && this.isAbsoluteHttpUrl(tenantBase)) {
return tenantBase;
}
return null;
}
private isAbsoluteHttpUrl(url: string): boolean {
return /^https?:\/\//i.test(url.trim());
} }
private normalizeBaseUrl(url: string): string { private normalizeBaseUrl(url: string): string {
@@ -93,4 +75,4 @@ export class ApiConfigService {
return url.replace(/\/+$/, ''); return url.replace(/\/+$/, '');
} }
} }

Some files were not shown because too many files have changed in this diff Show More