Compare commits

...

2 Commits

Author SHA1 Message Date
Sylvain Duchesne 4148df8fcb Record what running the flow proved, and fix the leaf that caused one defect
The create-and-participate flow was driven in a real browser for the first time.
It had been called correct by construction -- typecheck, build, reading -- and
the probe found three defects none of those could see.

Two open bugs, both major, both filed rather than worked around:
signing up to your own event makes the NEXT connection fail outright
(`ensureIdentity()` rejects inside the data layer's own inbox processing, 3/3,
reproduced on a fresh origin and identity), and the participant count does not
converge in the same session (2/2, 120 s and 75 s). Whether it converges at the
next connection is recorded as UNKNOWN and unmeasurable, because the first bug
stops the app from getting there.

The doctrine defect is the one worth the trouble. `knowledge_build-pipeline`
said production builds into `dist/`; `knowledge_deployment` said the container
runs `bun run start` from `src/`. Both were written down, they contradicted each
other, and the code followed the wrong one -- which is how a deployed app that
could sign nobody in was shipped. The three paths now live in one table whose
discriminating column is what is actually served: dev `src/`, production `src/`,
and `dist/` served by nothing at all. A build artefact nobody serves is a trap
for the next reader who assumes otherwise.

The probe method itself is written down: the suite cannot run, a targeted probe
can, and the difference is worth knowing before concluding that nothing is
measurable.

Stated once where a reader meets it: honest steps do not add up to an honest
flow. Every gesture in the sign-up reports correctly, and the user is still told
they participate while the count never moves and the next connection fails.
2026-08-16 16:46:10 +02:00
Sylvain Duchesne fa934ccdc6 Production serves from source, so it must fetch its config like everything else
The deployed application could never sign anybody in.

`loadRuntimeConfig` skipped fetching `/festipod-config.json` under
`NODE_ENV=production`, reasoning that a production build has the value inlined
by `build.ts`'s `define`. This project's production does not build: the
container copies the sources and runs `bun run start`
(= `NODE_ENV=production bun src/index.ts`), serving from `src/` exactly as dev
does. Nothing serves `dist/` at all. So the one step that could supply a wallet
was skipped, `ensureIdentity()` threw for want of one, and the endpoint sat
there — served, and never asked.

The condition tested the wrong thing. "Was the value inlined?" is a question the
global itself answers; "am I in production?" only ever stood in for it, and the
stand-in was false on the very path that matters.

Verified on the served bundle rather than on the endpoint: under
`NODE_ENV=production` it now carries the fetch, where it carried none.

The same combination is what `hooks.ts` spawns for the @e2e app server, so that
layer could not sign in either.
2026-08-16 16:27:00 +02:00
15 changed files with 161 additions and 22 deletions
@@ -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).
@@ -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 `<App />` |
| `src/app/frontend.tsx` | React root — pulls the runtime config, sets the wallet global, **then** dynamically imports and renders `<App />`; 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.
@@ -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.
@@ -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
@@ -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.
@@ -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
@@ -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.
@@ -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]].
@@ -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.
@@ -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".
@@ -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]]).
@@ -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 \
@@ -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
@@ -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` |
+20 -7
View File
@@ -4,19 +4,32 @@
*
* It is included in `src/index.html`.
*
* Before loading the app tree it pulls the RUNTIME shared-wallet config (dev
* server + `bun run start`, which serve from src/ and so miss build.ts's
* compile-time `define`), sets the global, then dynamically imports `App` so
* `sharedWallet.ts` reads the value on evaluation. In a build.ts bundle the
* password is already inlined via `define`, so this step is skipped (NODE_ENV).
* Before loading the app tree it pulls the RUNTIME shared-wallet config, sets
* the global, then dynamically imports `App` so `sharedWallet.ts` reads the
* value on evaluation. A `build.ts` bundle already carries the value inlined by
* `define`, and then there is nothing to fetch.
*
* WHICH ONE APPLIES IS NOT `NODE_ENV`. This used to skip the fetch under
* `NODE_ENV=production`, on the reasoning "production means built". This
* project's production does NOT build: the container copies the sources and
* runs `bun run start` (= `NODE_ENV=production bun src/index.ts`), serving from
* src/ exactly as dev does. Nothing ever serves `dist/`. So the deployed app
* skipped the only step that could give it a wallet, `ensureIdentity()` threw
* for want of one, and it could never sign anybody in — while `/festipod-config.json`
* sat there, served and unasked.
*
* The question is therefore "was the value inlined?", never "am I in
* production?" — ask the global itself.
*/
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
/** Fetch the runtime shared-wallet config and set the global (dev/start only). */
/** Fetch the runtime shared-wallet config and set the global, unless it is already there. */
async function loadRuntimeConfig(): Promise<void> {
if (process.env.NODE_ENV === "production") return; // build.ts define provides it
// Already inlined by `build.ts`'s `define` → nothing to fetch. Bracket access,
// so that same `define` (which rewrites the dotted global) leaves this read alone.
if ((globalThis as Record<string, unknown>)["__FESTIPOD_SHARED_WALLET_PASSWORD__"] != null) return;
try {
const res = await fetch("/festipod-config.json");
if (!res.ok) return;