diff --git a/.project/concepts/app-architecture/_debt.md b/.project/concepts/app-architecture/_debt.md deleted file mode 100644 index a8b7499..0000000 --- a/.project/concepts/app-architecture/_debt.md +++ /dev/null @@ -1,7 +0,0 @@ -# Doc-debt — app-architecture - -> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file. -> One block = one "big change": `why` + `files` + `verify` (leaves to review). - -## Raw markers (consolidate into blocks, then delete) -- TOUCHED src/app/frontend.tsx @2026-08-16 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) diff --git a/.project/concepts/app-architecture/caveat_boot-unverified-outside-broker.md b/.project/concepts/app-architecture/caveat_boot-unverified-outside-broker.md index 9d4f8ea..13305e6 100644 --- a/.project/concepts/app-architecture/caveat_boot-unverified-outside-broker.md +++ b/.project/concepts/app-architecture/caveat_boot-unverified-outside-broker.md @@ -20,6 +20,6 @@ The fix: the session starts unconditionally, in the iframe and standalone alike, **No test exercises this path.** `@data` runs the harness inside the broker iframe; `@e2e` drives the real app inside the broker iframe too. The standalone top-level boot — the one a developer uses every day with `bun run dev`, and the one a first-time visitor hits — is covered by nothing. -So: a change to `AuthGate`, to `NextGraphProvider`, or to what `configure()` receives can break the app's entry completely while every suite stays green. If you touch any of them, **open the app standalone yourself** before believing the tests. +So: a change to `AuthGate`, to `NextGraphProvider`, or to what `configure()` receives can break the app's entry completely while every suite stays green. If you touch any of them, **open the app yourself** before believing the tests — and drive a whole flow rather than looking at the page, which is what a throwaway probe is for (`bdd-testing` → [[cookbook_live-probe]]). Two related pieces: [[caveat_first-time-entry-untested]] (the wallet-import journey, same blind spot seen from the user's side) and [[caveat_shared-wallet-global-before-gate-import]] (a missing wallet password now makes `ensureIdentity()` throw, which at least fails loudly). diff --git a/.project/concepts/app-architecture/knowledge_app-shell.md b/.project/concepts/app-architecture/knowledge_app-shell.md index 963e4be..25c6cf3 100644 --- a/.project/concepts/app-architecture/knowledge_app-shell.md +++ b/.project/concepts/app-architecture/knowledge_app-shell.md @@ -39,6 +39,6 @@ ThemeProvider |---|---| | `src/index.ts` | `Bun.serve()` — HTTP server, serves `index.html` + the cucumber report | | `src/index.html` | HTML entry point, loads `src/app/frontend.tsx` | -| `src/app/frontend.tsx` | React root, renders `` | +| `src/app/frontend.tsx` | React root — pulls the runtime config, sets the wallet global, **then** dynamically imports and renders ``; the order is the point (`tech-stack` → [[knowledge_build-pipeline]], `app-security` → [[caveat_shared-wallet-global-before-gate-import]]) | The build and the bundler (Bun + Tailwind, alias `@/* → ./src/*`) are documented in the `tech-stack` concept. diff --git a/.project/concepts/app-security/caveat_shared-wallet-global-before-gate-import.md b/.project/concepts/app-security/caveat_shared-wallet-global-before-gate-import.md index eebd010..e3f55b7 100644 --- a/.project/concepts/app-security/caveat_shared-wallet-global-before-gate-import.md +++ b/.project/concepts/app-security/caveat_shared-wallet-global-before-gate-import.md @@ -1,7 +1,7 @@ --- type: caveat -summary: The wallet password is captured at the EVALUATION of src/shared/utils/sharedWallet.ts; a value set after that first import is never re-read — a missing one used to yield AccessGateScreen's error block, now it makes ensureIdentity() throw and the app render nothing at all, silently -last_checked: 2026-08-10 +summary: The wallet password is captured at the EVALUATION of src/shared/utils/sharedWallet.ts — a value set after that first import is never re-read, and the fetch that sets it must never be gated on NODE_ENV (production serves from src/). Missing it: ensureIdentity() throws, AuthGate shows the reason. +last_checked: 2026-08-16 --- # Pitfall: set the wallet-password global BEFORE the module is first imported @@ -15,8 +15,8 @@ The contract requires a deployment to **serve a wallet file and pass its URL and ## Impact — if I touch X, Y breaks - **Static import = trap.** A static `import` reaching `ngSession.ts` (hence `sharedWallet.ts`) from an entry point that sets the global itself is **hoisted above the assignment** → empty password → the failure mode above, with no JS error at the import site to signal it. The remedy is a **dynamic import** (`await import(...)`) executed after setting the global. -- **The real entry point that must get this right**: the frontend served from `src/` (`src/app/frontend.tsx` fetches `/festipod-config.json`, sets the global, then imports `App` dynamically — mechanics in `tech-stack` → [[knowledge_build-pipeline]]). A bundle produced by `build.ts` is **not** concerned: there the value is inlined by `define`. +- **The real entry point that must get this right**: the frontend served from `src/` (`src/app/frontend.tsx` fetches `/festipod-config.json`, sets the global, then imports `App` dynamically — mechanics in `tech-stack` → [[knowledge_build-pipeline]]). That fetch may be skipped on **one** condition: the global is already set, which only a `build.ts` bundle's `define` does. **Never on an `NODE_ENV` test** — this project's production serves from `src/` exactly like dev, so gating the fetch on "production" is what removed the wallet from the deployed app and left it unable to sign anybody in. - **`@ui` reaches the module too, but harmlessly today.** `screens/index.ts` eagerly imports every screen including `SettingsScreen`, which imports `ngSession.ts` — so any `@ui` test already evaluates `sharedWallet.ts` with the global unset. This does not currently break anything because no `@ui` path calls `ensureIdentity()` (`renderScreen()` bypasses `AuthGate`/`NextGraphProvider` entirely); see `bdd-testing` → [[knowledge_ui-layer]] for the detail and for what would make it stop being harmless. -- **Operations**: a server without `FESTIPOD_SHARED_WALLET_PASSWORD` now fails **silently** (blank page, console-only) rather than with a screen saying so — worth knowing when diagnosing "the app shows nothing." +- **Operations**: a server without `FESTIPOD_SHARED_WALLET_PASSWORD` cannot sign anyone in, and it **says so** — `ensureIdentity()` rejects and `AuthGate` renders its named error panel carrying the reason. Verified live on a rejected sign-in: a refusal shows the reason, not a blank page (`bdd-testing` → [[cookbook_live-probe]]). The failure mode still worth fearing is the **silent** one: a promise that never settles either way renders nothing at all and logs nothing — `app-architecture` → [[caveat_boot-unverified-outside-broker]]. **Verified (2026-08-10)**: capture at evaluation time in `src/shared/utils/sharedWallet.ts`; the `sharedWallet: hasSharedWallet() ? {...} : undefined` branch in `ngSession.ts`'s `configure()` call; the `throw` in `ensureIdentity()` when no `sharedWallet` config is present; `AuthGate`'s `.catch(err => console.error(...))` with no fallback UI. diff --git a/.project/concepts/bdd-testing/_overview.md b/.project/concepts/bdd-testing/_overview.md index 33cf4ff..935a632 100644 --- a/.project/concepts/bdd-testing/_overview.md +++ b/.project/concepts/bdd-testing/_overview.md @@ -39,3 +39,4 @@ BDD tests written in **Cucumber/Gherkin in French** (`Etant donné`, `Quand`, `A - [[decision_2026-03-12_headless-wallet-creation]] — why the test wallet is created through a headless UI - [[caveat_source-grep-vestiges]] — leftovers from the "source analysis" era in `world.ts` - [[cookbook_add-scenario]] — adding a scenario/step (layers, `evaluate` serialization pitfall, `@wip`) +- [[cookbook_live-probe]] — verifying a flow for real when the suite cannot answer: a throwaway Playwright probe on the real app, what it must collect, and why its findings must be written down the same day diff --git a/.project/concepts/bdd-testing/cookbook_live-probe.md b/.project/concepts/bdd-testing/cookbook_live-probe.md new file mode 100644 index 0000000..e2e2073 --- /dev/null +++ b/.project/concepts/bdd-testing/cookbook_live-probe.md @@ -0,0 +1,30 @@ +--- +type: cookbook +summary: How to verify a flow for real when the suite cannot answer — a throwaway Playwright probe that boots the REAL app in a real browser against the real broker and drives the UI as a user does; what it must collect, and why its findings must land in doctrine the same day. +last_checked: 2026-08-16 +--- + +# Driving the real app with a throwaway probe + +A **probe** is a one-off Playwright script, outside Cucumber — no World, no hooks, no fixtures — that boots the **real app** in a real browser against the **real broker** and drives its interface the way a user does. You write it, you run it, you read it, you delete it. + +## When to reach for one + +Before believing a flow works. The create-and-participate flow had been declared *correct by construction* on typecheck, build and reading; the first probe ever run against it found **three defects** none of those could see — two still open ([[bug_signup-breaks-the-next-connection]], [[bug_participant-count-stays-at-zero]] in `data-layer`) and one shipped as a fix. + +Reach for it when the suite cannot answer the question: the `@data` run dies silently from around its sixth scenario ([[caveat_wallet-bloat-hang]]), its scenarios have no fixtures ([[caveat_data-suite-has-no-fixtures]]), and entry paths are covered by nothing ([[caveat_first-time-entry-untested]], `app-architecture` → [[caveat_boot-unverified-outside-broker]]). + +## Method + +1. **Drive the app's own interface, never a bridge.** A probe that calls into the data context proves the data context. The whole point is the collaboration between the layers, so the only inputs are the ones a user gives — clicks, typing, waiting — and the only outputs are the ones a user sees. +2. **Reuse the boot the `@e2e` layer already documents** ([[knowledge_e2e-layer]]) rather than inventing one: the app server on its own port, the broker round-trip, the app in its iframe. Do not build a second way in. +3. **Collect `pageerror` and `console` from the first navigation.** The findings that matter surface as a rejection raised *inside a layer you never called* — invisible on screen except as a panel saying something failed. +4. **Time the steps you assert on.** "The toast landed after the write" and "1.8 s" are two different findings; the second is what makes a later regression legible. +5. **Keep watching after the confirmation, then reconnect.** A step that reports honestly can still leave the flow wrong. Give the state a real interval (minutes, not a tick), then come back through a fresh load — most of what a probe finds lives after the point where a test would have asserted green. +6. **Say what state you started from.** A **brand-new origin with a brand-new identity** is what separates a real defect from accumulated wallet state, and a finding reported without it is not yet a finding. Report the run count too (*"3 of 3"*). + +## What a probe is not + +It is **not a regression guard**: nothing re-runs it, and a deleted script protects nothing. Its whole value is converted at the end of the run, into doctrine or a `bug_` leaf, the same day — a probe run that is not written down bought nothing. Recording an observation, mark **VERIFIED** (seen, with the run count) apart from **INFERRED** (the explanation you reached for); a real symptom does not certify its diagnosis. + +> **The lesson that pays for the method: honest steps do not add up to an honest flow.** Every step of the sign-up reports truthfully — the mutation rejects rather than lying, the confirmation follows the write — and the flow as a whole still announces a success it does not obtain. No layer can see that from inside itself; only exercising the whole thing end to end shows it. diff --git a/.project/concepts/data-layer/_overview.md b/.project/concepts/data-layer/_overview.md index 8e63d3a..57778ed 100644 --- a/.project/concepts/data-layer/_overview.md +++ b/.project/concepts/data-layer/_overview.md @@ -27,6 +27,11 @@ How Festipod **persists its data** through NextGraph (P2P, local-first, end-to-e - [[rule_document-per-entity]] — every entity gets **its own document** (per scope), never one at store level; access is granted per document, so this is what makes isolation possible - [[rule_app-uses-sdk-surface-only]] — the pulled contract is the only reference; a gap in it is raised with the provider, never worked around here +## Open defects — the sign-up flow does not deliver + +- [[bug_signup-breaks-the-next-connection]] — after a sign-up, the next connection fails; a **provider-side gap**, raised with the provider, nothing to work around here +- [[bug_participant-count-stays-at-zero]] — the count never moves in the session that signs up; whether it converges later is **unknown**, and unmeasurable while the bug above holds + ## Pitfalls (read before touching deletions / event fields) - [[caveat_participation-deletion]] — withdrawal must be **authoritative** and must not come back diff --git a/.project/concepts/data-layer/bug_participant-count-stays-at-zero.md b/.project/concepts/data-layer/bug_participant-count-stays-at-zero.md new file mode 100644 index 0000000..4a82631 --- /dev/null +++ b/.project/concepts/data-layer/bug_participant-count-stays-at-zero.md @@ -0,0 +1,31 @@ +--- +type: bug +severity: major +opened: 2026-08-16 +last_checked: 2026-08-16 +summary: After a sign-up the count stays at 0 for the rest of the session while the button reads « ✓ Je participe » — observed with the event's owner (the counter's only writer) present and connected. Whether it converges at the next connection is UNKNOWN, not known-good. +--- + +# The participant count does not converge in the same session + +## What happens, VERIFIED + +In the create-and-participate flow — declare an event, sign up to it — the event's `participantCount` **stays at 0 for the rest of the session** while the button reads « ✓ Je participe ». **VERIFIED 2 runs out of 2**, the count still 0 **120 s** and **75 s** after the sign-up. + +The count starting at 0 on creation is correct and is not the defect ([[knowledge_context-internals]] §participantCount: no host baseline). The defect is that it never moves afterwards. + +**The "owner offline" explanation does not apply.** In this flow the signer **is** the event's owner, so the counter's only writer is present, connected, and watching the inbox it deposited into. Eventual delivery to an absent owner explains nothing here. + +## What is NOT established + +**Whether the count converges at the next connection is UNKNOWN** — it could not be measured, because [[bug_signup-breaks-the-next-connection]] makes the next connection fail. Do not write it down as converging, and do not treat "it will settle on reload" as a known behaviour: nobody has seen a reload. + +**Which side is at fault is also open** — never written, or written and not re-read. The pair of measurement points laid down for exactly this question (the owner's materializer logging `participantCount` before → after its write, and the display read logging the value as exposed to the render — [[knowledge_context-internals]] §logging) is where a diagnosis starts; the probe read the value as displayed and did not settle the pair. + +## Reproduce + +1. Connect, declare an event (the count shows 0 — correct). +2. Sign up to it; the button reaches « ✓ Je participe ». +3. Stay on the page and watch the count for a couple of minutes. It stays at 0. + +Method: `bdd-testing` → [[cookbook_live-probe]]. Watching *after* the confirmation, over a real interval, is what makes this visible at all — every individual step reports success. diff --git a/.project/concepts/data-layer/bug_signup-breaks-the-next-connection.md b/.project/concepts/data-layer/bug_signup-breaks-the-next-connection.md new file mode 100644 index 0000000..dde3293 --- /dev/null +++ b/.project/concepts/data-layer/bug_signup-breaks-the-next-connection.md @@ -0,0 +1,43 @@ +--- +type: bug +severity: major +opened: 2026-08-16 +last_checked: 2026-08-16 +summary: After a sign-up, the NEXT connection fails — ensureIdentity() rejects inside the data layer's own inbox processing and AuthGate renders « Connexion impossible ». A provider-side gap, raised with the provider; the app names no document to any call and has nothing to fix here. +--- + +# Signing up locks the account out of its next connection + +## What happens, VERIFIED + +Drive the create-and-participate flow (declare an event, sign up to it), then reconnect. `ensureIdentity()` **rejects**, and the app renders its named error panel, « Connexion impossible ». The rejection comes from **inside the data layer's own inbox processing** — a call the app never made. + +The message, verbatim: + +``` +docs.sparqlQuery: refused — the connected user does not hold this document's cap. +Naming a document does not grant access to it +``` + +**VERIFIED 3 runs out of 3**, including one on a **brand-new origin with a brand-new identity** — so this is not accumulated state from an old wallet ([[caveat_wallet-bloat-hang]] in `bdd-testing` is a different phenomenon and does not explain it). + +**VERIFIED**: the symptom and the sequence — a sign-up, then a failing reconnection. +**INFERRED**: that the deposit is what causes it. The sign-up is the only thing between a connection that works and the next one that does not, but nothing observed names the failing document. + +## Why there is nothing to work around here + +The app deposits through the **published** `inbox.postToDocument(doc, …)` ([[contract_polyfill-surface]]) and **names no document to any call** that could refuse one — it holds no inbox address at all ([[caveat_event-fields-not-persisted]] on why the vestigial `inbox` field must stay unused). The refusal is raised by a query the data layer issues for itself while draining what it was given. + +So this is a **provider-side gap**, raised with the provider — not an app-side problem with a clever fix ([[rule_app-uses-sdk-surface-only]] §2: a workaround is a doctrine violation even when it works). There is no app-side recovery either: a rejected `ensureIdentity()` is the contract's own instruction *not to render past it*, because a session that failed looks exactly like an account that owns nothing. + +## Reproduce + +1. Connect, declare an event, sign up to it (the button reaches « ✓ Je participe »). +2. Reconnect — a new page load through the broker, same identity. +3. The barrier resolves, then the app shows « Connexion impossible » with the message above in the console. + +A **fresh origin and a fresh identity** is what separates this defect from accumulated wallet state; run it that way before reporting anything new about it. Method: `bdd-testing` → [[cookbook_live-probe]]. + +## Blast radius + +Every account is one sign-up away from being locked out, and the lock-out is permanent for that identity as far as anything observed goes. It also **blocks measurement of other defects**: whether the participant count converges across connections cannot be established while this holds — [[bug_participant-count-stays-at-zero]]. diff --git a/.project/concepts/data-layer/knowledge_context-internals.md b/.project/concepts/data-layer/knowledge_context-internals.md index 2d5fc4f..fc53017 100644 --- a/.project/concepts/data-layer/knowledge_context-internals.md +++ b/.project/concepts/data-layer/knowledge_context-internals.md @@ -70,8 +70,10 @@ The counter is **not** incremented by whoever joins: only a document's owner wri - A participant writes their **own** participation document (protected), then **deposits** a marker into the event's inbox (`depositRegistration` / `depositLeave`, `src/shared/data/registration.ts`). - The event **owner's** session watches the inboxes of the events it owns (`inbox.watch`, no polling) and **recomputes** `participantCount` on its own event document. It is the counter's only writer, and it reads through `inbox.readSynced` — the synced view — not `inbox.read`. -- **Derived, not incremented**: `materializeAttendance` computes the set of distinct active sign-ups (deposits deduped by `uid`, minus those cancelled). `participantCount = |active set|`. There is **no host baseline** — an event has no host, the declarer is not required to attend, so the counter starts at **0** on creation and moves only on real sign-ups. Because it is a pure function of the inbox, a replay converges: no double count, no phantom decrement. The write is guarded so it only fires on a genuine change. +- **Derived, not incremented**: `materializeAttendance` computes the set of distinct active sign-ups (deposits deduped by `uid`, minus those cancelled). `participantCount = |active set|`. There is **no host baseline** — an event has no host, the declarer is not required to attend, so the counter starts at **0** on creation and moves only on real sign-ups. Being a pure function of the inbox, a replay is *designed* to converge: no double count, no phantom decrement. The write is guarded so it only fires on a genuine change. - **Owner offline = eventual.** While the owner is disconnected the count does not move for anyone else; nothing is lost. The materializer fires directly on connection, not only on a push, and it never locks in a premature 0. + +> ⚠️ **This section describes the design, and the design is not what a live run does.** Driven end to end with the owner present and connected — the signer *being* the owner — the count stayed at **0** for the rest of the session after a sign-up. Read the convergence properties above as intent to be re-established, not as observed behaviour: [[bug_participant-count-stays-at-zero]]. - The counter is an **aggregate**, not the list of named participants — `getEventParticipants` is governed by what the protected scope hands back. Which event a deposit belongs to is matched on the **canonical id-form** — see [[knowledge_write-rights-are-ownership]] §Matching, which governs every event-id comparison in this file. diff --git a/.project/concepts/data-layer/knowledge_data-modes.md b/.project/concepts/data-layer/knowledge_data-modes.md index 6ba3be1..2f9414b 100644 --- a/.project/concepts/data-layer/knowledge_data-modes.md +++ b/.project/concepts/data-layer/knowledge_data-modes.md @@ -26,3 +26,5 @@ The app has **two modes**, both consumed through the `useFestipodData()` hook: - `error` → `LocalDataProvider` with the seed (graceful fallback) > Mutations are **genuinely persisted** in connected mode: `joinEvent` writes a Participation into its own document and **deposits** into the event's inbox (the deposit is the delivery — no notification is written for the host), `leaveEvent` deletes authoritatively (see [[caveat_participation-deletion]]). Both **reject** rather than returning quietly when they cannot write, and the screen's confirmation follows the write. In local/demo mode they are **no-ops that still show a success toast** — see [[knowledge_context-internals]]. +> +> **Per-call honesty is not flow-level honesty.** Every one of those calls tells the truth about itself, and the sign-up flow driven end to end still fails — [[bug_participant-count-stays-at-zero]] and [[bug_signup-breaks-the-next-connection]]. Do not read the paragraph above as "signing up works". diff --git a/.project/concepts/functional-domain/knowledge_roadmap.md b/.project/concepts/functional-domain/knowledge_roadmap.md index 97bfd91..6d54f60 100644 --- a/.project/concepts/functional-domain/knowledge_roadmap.md +++ b/.project/concepts/functional-domain/knowledge_roadmap.md @@ -15,7 +15,7 @@ summary: What is implemented today (event + meeting point lifecycle, profiles, c - User profile, profile update, profile sharing - Friends list (connections), another user's profile -> Signing up to / withdrawing from a meeting point is **genuinely wired** on the data side: `joinEvent` persists a Participation and deposits into the event's inbox, where its owner reads it; `leaveEvent` deletes the Participation authoritatively (concept `data-layer`, [[caveat_participation-deletion]]). Neither succeeds in silence — they reject rather than returning quietly, and the confirmation the user sees follows the write. Public discovery — a user seeing another user's public event — works too. +> **Signing up is wired step by step, and the flow as a whole does not deliver.** Each step is honest: `joinEvent` persists a Participation and deposits into the event's inbox, where its owner reads it; `leaveEvent` deletes the Participation authoritatively (concept `data-layer`, [[caveat_participation-deletion]]); neither succeeds in silence, and the confirmation the user sees follows the write. Driven end to end in a real browser, the sign-up nonetheless **announces a success it does not obtain**: the user is told they participate, the count never moves ([[bug_participant-count-stays-at-zero]]), and their next connection fails ([[bug_signup-breaks-the-next-connection]]). Honest steps do not add up to an honest flow, and nothing short of exercising the whole thing shows it (concept `bdd-testing`, [[cookbook_live-probe]]). Treat the bullet above as *screens reachable*, not as a working journey. Public discovery — a user seeing another user's public event — works too. > **Updating an event is reserved to its declarer**, and the interface says so rather than discovering it late: the edit route is decided by ownership, and the confirmation follows the write instead of preceding it (concept `app-architecture`, [[knowledge_screen-pattern]]). Owner-only is not a policy choice here — it is the only reading the data model allows ([[knowledge_data-scopes-and-discovery]]). diff --git a/.project/concepts/tech-stack/knowledge_build-pipeline.md b/.project/concepts/tech-stack/knowledge_build-pipeline.md index ce73178..c17bc7a 100644 --- a/.project/concepts/tech-stack/knowledge_build-pipeline.md +++ b/.project/concepts/tech-stack/knowledge_build-pipeline.md @@ -1,13 +1,21 @@ --- type: knowledge -summary: Dev runs on bun --hot, prod builds through build.ts (Bun bundler + Tailwind plugin) into dist/, path alias @/* → ./src/* -last_checked: 2026-08-10 +summary: Three run paths — dev AND production both serve from src/ (bun --hot / bun run start), while bun run build produces a dist/ that nothing serves; NODE_ENV therefore never means "I am a bundle"; path alias @/* → ./src/* +last_checked: 2026-08-16 --- # Build pipeline -- **Dev**: `bun --hot src/index.ts` (through `bun run dev`) — HMR, port 3000. -- **Prod**: `bun run build` → `build.ts` (Bun bundler + Tailwind plugin) → `dist/`. +## Three paths, and only two of them ever run + +| Path | Command | What is served | +|---|---|---| +| **Dev** | `bun run dev` → `bun --hot src/index.ts` | **`src/`** — HMR, port 3000 | +| **Production** | `bun run start` → `NODE_ENV=production bun src/index.ts` | **`src/` as well** — Bun transpiles on the fly | +| Bundle | `bun run build` → `build.ts` (Bun bundler + Tailwind plugin) → `dist/` | **nothing** | + +> ⚠️ **`dist/` has no consumer, and `NODE_ENV=production` does not mean "built".** The container copies the sources and runs `bun run start`, serving from `src/` exactly as dev does ([[knowledge_deployment]]) — **nothing ever serves `dist/`**, here or anywhere else. So any code that branches on `NODE_ENV` to answer *"am I a bundle?"* is wrong in the one place it matters: in production the answer is **no**. That inference shipped once, on the runtime-config fetch below, and the deployed app could sign nobody in. Ask the artifact you care about, never the environment. + - **Path alias**: `@/* → ./src/*` (declared in `tsconfig.json`, resolved relative to that file — `paths` has needed no `baseUrl` since TS 4.4). > ⚠️ **Never put `baseUrl` back in `tsconfig.json`.** TypeScript 6 reports it as an **error that aborts the whole compilation**, and the failure is silent where it hurts: `tsc --noEmit` then exits **0 having checked nothing**, so the typecheck gate goes green over any amount of broken code. A green typecheck is only meaningful if `tsc` actually ran — treat an instant, output-free `tsc` as a red flag, not a fast pass. @@ -23,7 +31,11 @@ The server serves `src/index.html`, which loads `src/app/frontend.tsx` (see `app `build.ts` injects **compile-time globals** through `define`: `__FESTIPOD_SHARED_WALLET_PASSWORD__` from `FESTIPOD_SHARED_WALLET_PASSWORD`, and `__FESTIPOD_AUTO_SEED__` from `FESTIPOD_AUTO_SEED` — the dev auto-seed, OFF when absent. **Pitfall**: the `src/index.ts` server (used by `bun run dev` AND `bun run start`) bundles `index.html` through Bun's HTML import, which **applies no `define`** — neither `bun --define` nor `process.env` propagates there (verified). So an environment variable passed to `bun run dev` never reaches the frontend bundle along that path. -For those paths served from `src/`, the configuration therefore goes through the **runtime**: `src/index.ts` exposes `/festipod-config.json` (read from the environment), and the `src/app/frontend.tsx` entry **fetches it first**, sets the global, **then imports the app dynamically** (`await import('./App')`) — so that `sharedWallet.ts` reads the value when it is evaluated. In a `build.ts` bundle the value is already inlined by `define`, so the fetch is skipped (`NODE_ENV === 'production'`). Practical consequence: to exercise the "shared wallet" flow in dev **end to end** (download plus a working import), pass the REAL password of the e2e wallet **and** the file — the password shown on screen must match the imported `.ngw`, otherwise the import fails (a dummy value such as `1` merely makes the screen appear): +Everything served from `src/` — **dev and production alike** — therefore takes its configuration from the **runtime**: `src/index.ts` exposes `/festipod-config.json` (read from the environment), and the `src/app/frontend.tsx` entry **fetches it first**, sets the global, **then imports the app dynamically** (`await import('./App')`) — so that `sharedWallet.ts` reads the value when it is evaluated. + +**The fetch is skipped on one condition only: the global is already set** (which is what a `build.ts` bundle's `define` does, and nothing else does). The entry reads it through bracket access, so `define` — which rewrites the dotted form — leaves that read alone. The question is *"was the value inlined?"*, asked of the global itself; it was once asked as *"is `NODE_ENV` production?"*, which in this project means the opposite of what it looks like (see above) — the deployed app then skipped the only step that could give it a wallet, `ensureIdentity()` threw for want of one, and `/festipod-config.json` sat there served and unasked (`app-security` → [[caveat_shared-wallet-global-before-gate-import]]). + +Practical consequence: to exercise the "shared wallet" flow in dev **end to end** (download plus a working import), pass the REAL password of the e2e wallet **and** the file — the password shown on screen must match the imported `.ngw`, otherwise the import fails (a dummy value such as `1` merely makes the screen appear): ``` FESTIPOD_SHARED_WALLET_PASSWORD=festipod-e2e-tests \ diff --git a/.project/concepts/tech-stack/knowledge_deployment.md b/.project/concepts/tech-stack/knowledge_deployment.md index fad76aa..a6f8ce8 100644 --- a/.project/concepts/tech-stack/knowledge_deployment.md +++ b/.project/concepts/tech-stack/knowledge_deployment.md @@ -14,7 +14,7 @@ A `Dockerfile` exists (multi-stage Bun Alpine). **Installation goes through pnpm **`bun` peer pitfall**: `bun-plugin-tailwind` declares `bun` as a peerDependency → pnpm materializes the npm `bun` package and **creates a `node_modules/.bin/bun` shim** that shadows the `bun` from the PATH under `bun run`/`pnpm run`. Its postinstall is ignored by default → broken shim → `bun run start` fails. Fixed by approving the build: `pnpm.onlyBuiltDependencies: ["bun"]` in `package.json` (the postinstall then downloads the real binary). Without that, the whole pnpm migration breaks startup. -**Quirk**: `start` = `NODE_ENV=production bun src/index.ts` → the container **runs the TypeScript source directly** (Bun transpiles on the fly), it **does not use `dist/`**. `bun run build` (→ `dist/`) is therefore **not** on the default production path. Serving the build would require changing the entrypoint. +**Production runs the sources, and this is the normal path, not a quirk**: `start` = `NODE_ENV=production bun src/index.ts` → the container **runs the TypeScript directly** (Bun transpiles on the fly). `bun run build` (→ `dist/`) is on **no** path at all — nothing serves that directory, in this container or anywhere else; serving it would mean changing the entrypoint. Consequence for the code: in this deployment `NODE_ENV=production` says *how* the sources run, never *that they were bundled* — [[knowledge_build-pipeline]]. ## CI/CD diff --git a/.project/concepts/tech-stack/knowledge_stack-and-commands.md b/.project/concepts/tech-stack/knowledge_stack-and-commands.md index b428dbb..7cf860d 100644 --- a/.project/concepts/tech-stack/knowledge_stack-and-commands.md +++ b/.project/concepts/tech-stack/knowledge_stack-and-commands.md @@ -23,7 +23,7 @@ summary: Stack components (Bun runtime/build/test, install through pnpm, React, |---|---| | `dev` | `portless festipod bun --hot src/index.ts` — dev with HMR through the `portless` wrapper (see [[knowledge_deployment]]) | | `start` | `NODE_ENV=production bun src/index.ts` — production, served from `src/` (not `dist/`) | -| `build` | `bun run build.ts` — Bun bundler + Tailwind → `dist/` ([[knowledge_build-pipeline]]) | +| `build` | `bun run build.ts` — Bun bundler + Tailwind → `dist/`, **which nothing serves**: production runs `start`, from `src/` ([[knowledge_build-pipeline]]) | | `test:cucumber` | chains `cucumber:run` → `cucumber:report` → `features:parse` → `steps:extract` | | `cucumber:run` | `node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js` — **through Node+tsx, not Bun** (Playwright/happy-dom plugin compatibility), and through the package's **actual JS entry**, not the `.bin/` shim (see Pitfalls) | | `test:data` | same, with `--tags @data` |