docs(260729-gjd): pre-dispatch plan for demo IndeeHub iframe + signer + preinstall

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-29 12:13:21 -04:00
parent 2e54c3e40c
commit db25545a9a

View File

@ -0,0 +1,348 @@
---
phase: quick-260729-gjd
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- neode-ui/docker/nginx-demo.conf
- neode-ui/docker/indee-demo-signin.js
- neode-ui/Dockerfile.web
- docker-compose.demo.yml
- demo-deploy/docker-compose.yml
- neode-ui/src/composables/useDemoIntro.ts
- neode-ui/src/views/appSession/useAppIdentity.ts
- neode-ui/mock-backend.js
autonomous: true
requirements: [QUICK-260729-GJD]
must_haves:
truths:
- "A fresh demo session (clean browser, no localStorage) shows IndeeHub as an installed, running app in My Apps"
- "Launching IndeeHub in the demo renders the real indee.tx1138.com site inside the in-app iframe session (no new tab, no external interstitial)"
- "The embedded IndeeHub boots signed-in (active demo account visible, no login wall) and no identity-picker modal blocks the demo visitor"
- "The non-demo (real node) build is byte-for-byte unaffected in behavior: indeedhub launch, identity picker, and NIP-07 bridge all work as before"
- "The served demo content contains no occurrence of the private release-server IP (existing Docker build guards still pass)"
artifacts:
- "neode-ui/docker/nginx-demo.conf — new whole-origin reverse-proxy server block (port 2101) for indee.tx1138.com with framing headers stripped and sign-in script injected"
- "neode-ui/docker/indee-demo-signin.js — demo-only localStorage seeding script with a labelled throwaway demo nsec"
- "docker-compose.demo.yml and demo-deploy/docker-compose.yml — publish the new 2101 port"
- "neode-ui/src/composables/useDemoIntro.ts — indeedhub moved from external-tab to iframe launch via the :2101 proxy origin"
- "neode-ui/mock-backend.js — indeedhub present in staticDevApps as installed/running"
key_links:
- "demoAppUrl('indeedhub') → http://<demo-host>:2101/ → nginx :2101 server block → https://indee.tx1138.com upstream"
- "nginx sub_filter → /__demo/indee-demo-signin.js → seeds indeedhub-accounts + indeedhub-active-account localStorage keys → IndeeHub boot-restore logs the visitor in"
- "staticDevApps['indeedhub'] → structuredClone into per-session package-data → My Apps grid on fresh session"
---
<objective>
Make IndeeHub a first-class app in the PUBLIC DEMO only: (1) the real site
https://indee.tx1138.com/ renders inside the in-app iframe session (today it is
frame-busted by `X-Frame-Options: SAMEORIGIN` and opens externally), (2) a demo
visitor sees IndeeHub already signed in with a throwaway demo Nostr identity
(no login wall, no identity-picker modal), and (3) IndeeHub appears as an
already-installed, running app on a completely fresh demo session.
Purpose: the demo currently punts IndeeHub to a new tab with a login wall —
the flagship media app looks broken/hostile to demo visitors.
Output: demo-scoped changes across nginx-demo.conf, a new sign-in seed script,
the two demo compose files, useDemoIntro.ts, useAppIdentity.ts, mock-backend.js.
All behavior changes are gated behind IS_DEMO / demo-image build paths. The
real-node build must be completely unaffected.
</objective>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@CLAUDE.md
@neode-ui/src/composables/useDemoIntro.ts
@neode-ui/docker/nginx-demo.conf
@neode-ui/Dockerfile.web
@neode-ui/Dockerfile.backend
@docker-compose.demo.yml
@demo-deploy/docker-compose.yml
@neode-ui/src/views/appSession/appSessionConfig.ts
@neode-ui/src/views/appSession/useAppIdentity.ts
@neode-ui/src/views/appSession/useNostrBridge.ts
@neode-ui/src/stores/appLauncher.ts
@neode-ui/mock-backend.js
</context>
<verified_findings>
Facts confirmed by inspection on 2026-07-29 (do not re-derive, but re-verify
the live-bundle details marked "verify at exec time"):
- `curl -sI https://indee.tx1138.com/``X-Frame-Options: SAMEORIGIN`, no
Content-Security-Policy header today (strip both defensively).
- The live index.html loads a hashed module bundle from absolute-root paths
(`/assets/index-*.js`, `/icons/...`, `/manifest.json`). This is why the old
`/app/indeedhub/` path-prefix + sub_filter proxy broke (asset/router paths
escape the prefix). A WHOLE-ORIGIN proxy on a dedicated port has no such
problem — the SPA sees itself at `/` and every relative call just works.
- The live bundle (assets/index-BMWtjRCn.js) uses applesauce-accounts:
- localStorage key `"indeedhub-accounts"` = JSON array of serialized
accounts (`Fe.toJSON`/`Fe.fromJSON`), restored on boot before UI renders.
- localStorage key `"indeedhub-active-account"` = active account id.
- A private-key account class exists whose `fromJSON` does
`const t=Vn(e.signer.key); new mr(e.pubkey, new ai(t))` — i.e. shape is
`{ id, type: "<verify at exec time>", pubkey, signer: { key: "<hex sk>" } }`
plus common fields from `loadCommonFields` (verify exact `type` string and
common fields by grepping the live bundle for `static type` / the class's
`toJSON`). An `"extension"` account type also exists (fallback path).
- The bundle expects NIP-07 as a real `window.nostr` object ("Signer
extension missing" guard) — it does NOT contain the archipelago
`nostr-request` postMessage client.
- Parent-side NIP-07 plumbing already exists: `AppSession.vue` line ~423
routes `nostr-request` messages to `useNostrBridge`, which calls
`node.nostr-pubkey` (mocked in mock-backend.js) and `node.nostr-sign`
(NOT implemented in mock-backend.js). Only needed for the fallback approach.
- `useAppIdentity.ts`: `isIdentityAwareApp('indeedhub')` is true → on iframe
load with no stored identity it opens the identity-picker modal. In the demo
this is a blocking modal the visitor shouldn't see.
- `appLauncher.ts openSession`: `IS_DEMO && isDemoExternal(appId)` is the only
thing forcing indeedhub external; `NEW_TAB_APP_IDS` is already bypassed when
`IS_DEMO && isDemoApp(appId)`. `AppSession.vue mustOpenNewTab` has the same
two-clause shape. Removing indeedhub from `DEMO_EXTERNAL_URLS` while keeping
`isDemoApp('indeedhub')` true flips it to the iframe path everywhere.
- `mock-backend.js`: per-visitor session state is initialized via
`md['package-data'] = structuredClone(staticDevApps)` (~line 5493), so
adding an entry to `staticDevApps` (~line 828) makes it installed on every
fresh session. `APP_PORTS`-style map at ~line 323 already has
`'indeedhub': 8190`; an icon exists at `/assets/img/app-icons/indeedhub.png`.
- `Dockerfile.web` copies `nginx-demo.conf` to `/etc/nginx/nginx.conf.template`
and runs `docker-entrypoint-custom.sh` (env substitution) — read the
entrypoint before editing so the new server block's nginx `$vars` survive
templating the same way the existing blocks' do.
- Both Docker builds already scrub + fail on any occurrence of the private
release-server IP; nothing in this change may hardcode host IPs — build the
iframe URL from `window.location.hostname`.
- `indeedhub/` at repo root is a git submodule (not checked out) — NEVER stage
any path under it. `indeedhub-demo/` is a prior standalone-build attempt
(clones the GitHub fork, builds with VITE env); this plan supersedes it by
proxying the LIVE site instead — leave that directory untouched.
</verified_findings>
<tasks>
<task type="auto">
<name>Task 1: nginx whole-origin proxy on :2101 + sign-in seed script + compose ports</name>
<files>neode-ui/docker/nginx-demo.conf, neode-ui/docker/indee-demo-signin.js, neode-ui/Dockerfile.web, docker-compose.demo.yml, demo-deploy/docker-compose.yml, neode-ui/docker/docker-entrypoint.sh</files>
<action>
Add a second `server` block to nginx-demo.conf: `listen 2101;` that is a
pure whole-origin reverse proxy of `https://indee.tx1138.com` — no path
prefix, no URL rewriting (this is the fix for the documented sub_filter
breakage: the SPA keeps its own absolute-root paths). In that block:
`location / { proxy_pass https://indee.tx1138.com; }` with
`proxy_ssl_server_name on;`, `proxy_ssl_name indee.tx1138.com;`,
`proxy_set_header Host indee.tx1138.com;`,
`proxy_http_version 1.1;` + WebSocket upgrade headers (reuse the existing
`$connection_upgrade` map), `proxy_hide_header X-Frame-Options;` and
`proxy_hide_header Content-Security-Policy;`. For HTML injection:
`proxy_set_header Accept-Encoding "";` (upstream must not gzip or
sub_filter no-ops), `sub_filter_types text/html;`, `sub_filter_once on;`,
`sub_filter '</head>' '<script src="/__demo/indee-demo-signin.js"></script></head>';`
— a classic (non-module) script injected at end of head still executes
BEFORE the SPA's deferred module bundle, which is what the seeding needs.
Add `location = /__demo/indee-demo-signin.js { root /usr/share/nginx/html; }`
(or alias) inside the 2101 server so the seed script is served same-origin
to the iframe. Update the comment block that currently explains why
IndeeHub is not proxied (lines ~106-109) to describe the new :2101 design.
Create neode-ui/docker/indee-demo-signin.js: a small plain-JS classic
script, clearly headed with a comment stating it is PUBLIC-DEMO-ONLY and
that the embedded key is a freshly generated THROWAWAY demo identity, not
a real secret. Generate ONE fresh secp256k1 keypair at implementation time
(e.g. `node -e` with a tiny script using any available schnorr/secp lib, or
a one-off `npx` of nostr-tools in the scratchpad — the generator itself is
not committed) and embed hex sk + hex pk as constants. The script: if
`localStorage.getItem('indeedhub-accounts')` is empty/absent, write the
two keys IndeeHub's boot-restore reads — `indeedhub-accounts` (JSON array
with ONE serialized private-key account: verify the exact `type` string
and common-field shape against the live bundle per verified_findings, shape
`{ id, type, pubkey, signer: { key } }` + whatever `loadCommonFields`
round-trips, give it a friendly name/metadata like "Archy Demo" if the
shape supports it) and `indeedhub-active-account` (that account's id).
Because the script runs on the :2101 origin inside the iframe, this
touches only the proxied app's isolated storage. IndeeHub then restores
the account on boot and self-signs with its own bundled signer — no
window.nostr and no parent bridge required. Do NOT define a partial
`window.nostr` in this approach (a pubkey-only shim with a broken
signEvent causes worse failures than no shim).
FALLBACK (only if live testing in Task-3 verification shows the seeded
account shape is not accepted): seed an `"extension"`-type account
instead, define a `window.nostr` postMessage client in this same script
(request/response protocol matching useNostrBridge: post
`{type:'nostr-request', id, method, params}` to `window.parent`, resolve on
`{type:'nostr-response', id, ...}`), and implement `node.nostr-sign` /
`identity.nostr-sign` in mock-backend.js with real schnorr signatures over
the same throwaway key (add `nostr-tools` to neode-ui dependencies — it is
pure JS and Dockerfile.backend runs `npm install` over package.json).
Prefer the primary approach; only fall back with evidence.
Wire the plumbing: `EXPOSE 2101` in Dockerfile.web (the seed script is
already inside `neode-ui/` so the existing `COPY neode-ui/ ./` +
dist copy do NOT ship it — add an explicit
`COPY neode-ui/docker/indee-demo-signin.js /usr/share/nginx/html/__demo/indee-demo-signin.js`
in the nginx stage of Dockerfile.web; it lands only in the demo web image,
never in real-node artifacts). Publish the port in docker-compose.demo.yml
(`"2101:2101"` on neode-web) and demo-deploy/docker-compose.yml (use an
env-overridable mapping consistent with its existing `DEMO_WEB_PORT`
style, e.g. `"${DEMO_INDEE_PORT:-2101}:2101"`, and document it in that
file's header comment). Read docker-entrypoint.sh first and make sure the
new server block survives its template substitution exactly like the
existing blocks (same escaping convention for nginx `$` variables); touch
the entrypoint only if its substitution list needs it.
Do not put any host IP in any of these files; upstream hostname
indee.tx1138.com is fine.
</action>
<verify>
<automated>docker run --rm -v "$PWD/neode-ui/docker/nginx-demo.conf:/etc/nginx/nginx.conf:ro" nginx:alpine nginx -t (or, if docker unavailable locally, `nginx -t -c` via a podman run — config must parse). Plus: grep -c "2101" neode-ui/docker/nginx-demo.conf docker-compose.demo.yml demo-deploy/docker-compose.yml neode-ui/Dockerfile.web — each ≥1; grep -q "indee-demo-signin" neode-ui/docker/nginx-demo.conf && grep -qi "throwaway" neode-ui/docker/indee-demo-signin.js</automated>
</verify>
<done>nginx config parses with the new :2101 whole-origin proxy block (framing headers stripped, sub_filter injection, WS upgrade); seed script exists with labelled throwaway demo key and idempotent localStorage seeding; both compose files publish 2101; demo web image copies the script and exposes the port; no host IPs added anywhere.</done>
</task>
<task type="auto">
<name>Task 2: demo frontend — iframe launch via :2101 and no identity-picker wall</name>
<files>neode-ui/src/composables/useDemoIntro.ts, neode-ui/src/views/appSession/useAppIdentity.ts</files>
<action>
In useDemoIntro.ts: remove `indeedhub` from `DEMO_EXTERNAL_URLS` (delete
the map entirely if it becomes empty, simplifying `isDemoExternal` to
return false — keep the exported function so call sites in appLauncher.ts
and AppSession.vue compile unchanged). Make `demoAppUrl('indeedhub')`
return the proxied origin built at runtime:
`${window.location.protocol}//${window.location.hostname}:2101/`
(hostname, never a hardcoded host/IP — works on any deploy host). Keep
`isDemoApp('indeedhub')` true (it must stay in the demoable set so the
NEW_TAB bypass in appLauncher.openSession and AppSession.mustOpenNewTab
keeps routing it into the in-app iframe session, and so the install
button stays enabled). Update the file-header comment block that
currently documents the external-tab workaround to describe the :2101
whole-origin proxy design instead. SSR-safety is not a concern (Vite SPA)
but guard `typeof window !== 'undefined'` if other tests import the module
in node context — check the existing unit tests under
src/views/appSession/__tests__/ and src/stores/__tests__/ for assertions
about indeedhub being demo-external and update them to the new behavior.
In useAppIdentity.ts: gate the picker for the demo. Import IS_DEMO from
useDemoIntro and in `onIframeLoadIdentity` / `handleIdentityRequest`,
when IS_DEMO is true, never set `showIdentityPicker` — the demo visitor
must not be interrupted by an identity modal (the embedded IndeeHub is
already signed in via the seeded account from Task 1, and `sendIdentity`'s
`identity.sign` RPC is not what logs it in). Real-node behavior
(picker on first launch) is untouched because IS_DEMO is compile-time
false there.
</action>
<verify>
<automated>cd neode-ui && npx vitest run src/views/appSession src/stores --silent 2>&1 | tail -5 (all green) && VITE_DEMO=1 npm run build && grep -rq "2101" dist/assets && npm run build && grep -rq "indee.tx1138.com" dist/assets && echo BUNDLE-OK</automated>
</verify>
<done>Demo build (VITE_DEMO=1) bundle contains the :2101 launch logic (grep hit proves the build didn't silently no-op — per CLAUDE.md); plain build still compiles and demo-gated branches do not alter non-demo behavior; unit tests updated and green; launching indeedhub in demo resolves to the same-host :2101 origin in the iframe session; identity picker suppressed only under IS_DEMO.</done>
</task>
<task type="auto">
<name>Task 3: mock backend — IndeeHub pre-installed on fresh demo sessions</name>
<files>neode-ui/mock-backend.js</files>
<action>
Add an `indeedhub` entry to `staticDevApps` in mock-backend.js using the
existing `staticApp({...})` helper: id `indeedhub`, title `Indeehub`
(match the existing title map at ~line 537 and APP_TITLES), a short/long
description consistent with the marketplace copy ("Bitcoin documentary
streaming platform" per the existing entry), `state: 'running'`,
`lanPort: 8190` (matches the existing port map), icon
`/assets/img/app-icons/indeedhub.png`. Because per-session demo state is
`structuredClone(staticDevApps)`, this alone makes it installed+running on
every fresh session. Then reconcile the rest of the mock so nothing
contradicts installed status: check the marketplace/available-apps mock
responses and any install/uninstall handlers (~lines 540-740, 1900-1960,
4900+) for `indeedhub` entries that would render it as not-installed or
double-listed, and check `DEMO_APP_PAGES` does NOT grow an indeedhub
placeholder (the demo launch URL bypasses /app/indeedhub/ entirely — the
iframe goes to the :2101 origin). Keep the existing `node.nostr-pubkey`
mock as-is unless Task 1's fallback path was taken (in which case align
its pubkey with the throwaway demo key and add the sign handlers described
there).
</action>
<verify>
<automated>cd neode-ui && node -e "const s=require('fs').readFileSync('mock-backend.js','utf8'); if(!/staticDevApps[\s\S]*?indeedhub:\s*staticApp/.test(s)) process.exit(1)" && (DEMO=1 timeout 20 node mock-backend.js & sleep 4; curl -s -X POST localhost:5959/rpc/v1 -H 'content-type: application/json' -d '{"method":"server.data","id":1}' -H 'cookie: demo=fresh' | grep -o '"indeedhub"' | head -1; kill %1 2>/dev/null) — expect an indeedhub hit in fresh-session package-data (adapt the RPC method/auth to what the mock actually serves; a login with the demo password first is fine)</automated>
</verify>
<done>A fresh demo session's package-data includes indeedhub as installed and running with launchable UI; My Apps shows it without an install step; no duplicate/contradictory indeedhub listing in marketplace mocks; mock backend boots cleanly with DEMO=1.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| demo nginx :2101 → indee.tx1138.com | demo host proxies an external site; upstream content is served under the demo host |
| iframe (:2101 origin) ↔ parent (:2100 origin) | cross-origin; parent NIP-07 bridge only used in fallback path |
| public visitors → demo host | anyone can drive the proxy |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-gjd-01 | Spoofing | throwaway demo nostr key | low | accept | key is a labelled public demo identity by design; generated fresh, never a real user key; anyone extracting it can only impersonate "the demo visitor" |
| T-gjd-02 | Info disclosure | private release-server IP in served content | high | mitigate | no host IPs added in any changed file; iframe URL derived from window.location.hostname; existing Docker-build scrub+fail guards remain the backstop |
| T-gjd-03 | Tampering | open reverse proxy on :2101 | medium | mitigate | proxy is pinned to a single upstream host (proxy_pass fixed hostname + proxy_ssl_name), no dynamic upstreams, no request-driven destinations — it cannot be used as an open proxy |
| T-gjd-04 | Elevation | header stripping (X-Frame-Options/CSP) | low | accept | stripping applies only to the :2101 demo proxy of one known site, demo image only; real-node builds never carry this config |
| T-gjd-SC | Tampering | npm installs | low | accept | primary path adds no dependencies; fallback path adds only nostr-tools (well-known, verify on npmjs.com before install) |
</threat_model>
<verification>
Local (executor, before commit):
1. nginx config parses (Task 1 verify).
2. Unit tests green; VITE_DEMO=1 build contains ":2101" logic; plain build
unaffected (Task 2 verify). Note: demo-gated strings are tree-shaken out of
the plain build — that is EXPECTED; the bundle-grep for demo strings must be
done on the VITE_DEMO=1 build, which is exactly what the demo Docker image
builds (Dockerfile.web defaults ARG VITE_DEMO=1).
3. Fresh-session mock package-data includes indeedhub (Task 3 verify).
4. Optional full-stack smoke: `docker compose -f docker-compose.demo.yml up
--build` locally, browse http://localhost:2100 in a private window →
login `entertoexit` → IndeeHub installed → launch → iframe renders the
proxied site from http://localhost:2101 with a signed-in account.
5. `git status` — confirm nothing under indeedhub/ is staged, ever.
Post-deploy on vps2 (orchestrator deploys; verify on http://146.59.87.168:2100):
1. `curl -sI http://146.59.87.168:2101/` returns 200 with NO X-Frame-Options
header and the injected `indee-demo-signin.js` tag in the HTML body
(`curl -s http://146.59.87.168:2101/ | grep indee-demo-signin`). If the
port is unreachable, the vps2 firewall needs 2101 opened — flag to
orchestrator.
2. Fresh private browser window → :2100 → login → IndeeHub shows installed/
running on the dashboard/My Apps without any install action.
3. Launch IndeeHub → renders inside the in-app iframe (panel/overlay), not a
new tab; content browsable; no identity-picker modal.
4. Signed-in check: IndeeHub header shows an active account (avatar/profile
instead of a sign-in button). If the seeded account shape was rejected
(login wall still visible), execute the documented fallback (extension
account + window.nostr shim + mock signer) and redeploy.
5. View-source/network spot-check: no occurrence of the private
release-server IP in any served response.
6. Repeat-visit check: reload the iframe once — a service worker registered by
IndeeHub may serve cached HTML without the injected tag on later loads;
that is acceptable because localStorage is already seeded on first load,
but confirm sign-in persists.
</verification>
<success_criteria>
- Demo visitor on a fresh browser sees IndeeHub installed, launches it into
the in-app iframe, and browses indee.tx1138.com content signed in — zero
clicks spent on install/login/identity modals.
- Real-node build behavior unchanged (all changes IS_DEMO- or demo-image-gated).
- No secrets committed beyond the labelled throwaway demo key; nothing staged
under indeedhub/; demo serves no private release-server IP.
- Work committed in focused commits (infra / frontend / mock) with the
Co-Authored-By trailer and pushed via gitea-ai per CLAUDE.md; docs left to
the orchestrator.
</success_criteria>
<output>
Create `.planning/quick/260729-gjd-demo-make-indee-tx1138-com-work-in-the-a/260729-gjd-SUMMARY.md` when done.
</output>