From 0a49b1d618382099f59774da29af0d31ae8f006d Mon Sep 17 00:00:00 2001 From: theprogrammersingh Date: Thu, 3 Sep 2026 07:15:36 +0530 Subject: [PATCH 1/3] feat(converter): embed a cross-origin currency converter, advisory only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Actuo has no FX pass, so `converted_amount` is null for every foreign row and totals count base-currency rows only, stating what they left out. That gap is honest but unhelpful: there was nowhere in the app to find out what an excluded row is actually worth. A separate converter app is now framed on four surfaces — `/convert`, the `/agent` panel, beside the dashboard's excluded-rows notice, and on expense rows filed in another currency. It is a reference a person reads. Nothing it shows is written to `converted_amount`, folded into `sumSpend()`, or allowed to change the excluded-rows copy. That boundary is structural, not a promise. `CurrencyConverter` has no `output()`, no `postMessage` listener, and never reads a value back out of the frame, so no converted figure exists in the component tree to be wired in; adding one would mean first inventing a return channel. The specs assert the component's inputs and outputs directly, and the dashboard and expenses specs assert that opening the lookup moves no figure. `core/expense/amount.ts` and its spec are untouched, which is the point. `PARTNER_DEMO_ORIGIN` becomes `CONVERTER_URL`, and is now a full URL rather than a bare origin: the production converter serves at `/` while the local partner demo serves at `/partner-demo/`, so one value covers both and consumers take the origin with `new URL(...).origin`. A second path variable that had to stay in step would be one too many. Non-http(s) values are rejected before the sanitizer bypass that renders the frame. Discovery moves out of `/agent` into `ConverterSession`. Page-owned teardown was correct while one page framed one partner; with four surfaces it cleared the Copilot's remote tools while a frame was still mounted elsewhere. The service enforces two rules. Only one frame may be open at a time, because `getTools()` returns a descriptor per *window* and two live frames publish two tools of the same name. And discovery is reference counted, because Angular constructs the incoming component before destroying the outgoing one, so clear-on-destroy would wipe what the new surface just found. Three things this turned up on the way: - The expenses page renders the desktop table and the phone card list into the DOM at once and lets CSS pick, so a panel inside the row markup mounted TWICE for one open row — two iframes, two loads of a whole separate app. Confirmed in the browser before fixing. There is now one converter for the page, driven by whichever row is open. - `Copilot.discoverRemoteTools()` did not dedupe by name. The same page framed here and also open in another tab would hand Gemini two identical function declarations, which is malformed rather than merely redundant. This was already reachable with the partner demo; the fix is not specific to the converter. - The paging specs queued mock responses by call order, so any new request from this screen displaced them. They now route by path, as the dashboard spec already did. Verified with the stack running, not only by unit test: one frame across all four surfaces and when switching rows, the `?actuo=` handshake present on the frame src, both partner tools discovered cross-origin with `readOnlyHint`, `executeTool()` returning a real result across origins (`inputSchema` arrived as a string and the result as a string, both documented quirks), and the expenses money column unchanged at $200 / $45 / rupee rows through opening and switching the lookup. Both themes checked. Gate: typechecks clean, 892 tests green (9 shared, 65 backend, 784 frontend, 34 e2e), build clean with 11 routes prerendered. Not yet verified: the deployed converter. That needs its `exposedTo` change released and `CONVERTER_URL` set on the service; until then the surfaces show their honest "no tools discovered from that origin yet" state. --- CLAUDE.md | 51 +++- Dockerfile | 6 +- Progress.md | 34 ++- README.md | 25 +- backend/.env.example | 20 +- backend/src/config/config.controller.ts | 12 +- backend/src/config/env.service.spec.ts | 47 +-- backend/src/config/env.service.ts | 32 +- docs/Actuo-PRD.md | 6 +- frontend/src/app/app.routes.ts | 10 + .../app/converter/converter-session.spec.ts | 279 ++++++++++++++++++ .../src/app/converter/converter-session.ts | 231 +++++++++++++++ .../app/converter/currency-converter.spec.ts | 207 +++++++++++++ .../src/app/converter/currency-converter.ts | 176 +++++++++++ frontend/src/app/copilot/copilot.spec.ts | 55 ++++ frontend/src/app/copilot/copilot.ts | 25 +- frontend/src/app/pages/agent/agent.spec.ts | 50 ++-- frontend/src/app/pages/agent/agent.ts | 190 ++++-------- frontend/src/app/pages/convert/convert.ts | 78 +++++ .../src/app/pages/dashboard/dashboard.spec.ts | 69 ++++- frontend/src/app/pages/dashboard/dashboard.ts | 51 +++- .../src/app/pages/expenses/expenses.spec.ts | 122 +++++++- frontend/src/app/pages/expenses/expenses.ts | 121 +++++++- .../app/pages/settings/key-privacy.spec.ts | 4 + .../src/app/pages/settings/settings.spec.ts | 10 + frontend/src/app/pages/settings/settings.ts | 18 +- render.yaml | 17 +- scripts/partner-server.mjs | 2 +- 28 files changed, 1711 insertions(+), 237 deletions(-) create mode 100644 frontend/src/app/converter/converter-session.spec.ts create mode 100644 frontend/src/app/converter/converter-session.ts create mode 100644 frontend/src/app/converter/currency-converter.spec.ts create mode 100644 frontend/src/app/converter/currency-converter.ts create mode 100644 frontend/src/app/pages/convert/convert.ts diff --git a/CLAUDE.md b/CLAUDE.md index 14ff629..5e1db35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -242,9 +242,36 @@ there `normalizeRegisteredTool()` marks its tools `isCrossOrigin: false`, which exactly the set the Copilot filters out. `scripts/partner-server.mjs` (zero dependencies, `node:http`) serves `frontend/public` on **:4201** so the same `/partner-demo/` path exists on a different origin; `pnpm run dev` starts it as a -third pane. The origin the app embeds is `PARTNER_DEMO_ORIGIN`, served to the browser -by `GET /api/config` — so a deploy changes it without a rebuild. When it equals the -app's own origin, `/agent` says so instead of showing an empty list. +third pane. + +What the app frames is **`CONVERTER_URL`**, served to the browser by +`GET /api/config` — so a deploy changes it without a rebuild. It replaced +`PARTNER_DEMO_ORIGIN`, and it is a **full URL rather than a bare origin** because +the two things it points at disagree on the path: the production converter serves +at `/`, the local partner demo at `/partner-demo/`. Consumers derive the origin +with `new URL(value).origin`; a second "path" variable that had to stay in step +would be one too many. Non-http(s) values are rejected before the sanitizer +bypass. When it equals the app's own origin, every surface says so instead of +showing an empty list. + +**The `?actuo=` handshake is what makes any of it work.** A WebMCP tool is +visible only to its own document unless registration names an origin in +`exposedTo`, so the framed page has to be *told* which origin to expose to. +`ConverterSession.frameUrl` appends `?actuo=`, and both +`frontend/public/partner-demo/index.html` and the production converter read it. +Sending it at runtime rather than hardcoding our hostname there means a deploy +URL can change without a release on the other side. + +**`ConverterSession` owns the discovery lifecycle, not any page.** It used to +belong to `/agent`, which was fine while one page framed one partner. With four +surfaces, page-owned teardown cleared the Copilot's remote tools while a frame +was still mounted elsewhere. Two rules live there now: **only one frame at a +time** (`getTools()` returns a descriptor per *window*, so two live frames +publish two tools called `convertCurrency`), and **reference-counted discovery** +(during a route change Angular builds the incoming component before destroying +the outgoing one, so clear-on-destroy would wipe what the new surface just +found). `Copilot.discoverRemoteTools()` also dedupes by name now, which fixes +the same duplicate-window bug for the partner page. ## The deploy @@ -461,6 +488,24 @@ wrong number beat a bar reading zero. It was not slightly wrong: a $200 charge was counted as ₹200. When a real FX pass starts filling `converted_amount`, those rows re-enter every total with no code change. +**The embedded converter does not change this, and must not.** `converter/` +frames a separate converter app on four surfaces (`/convert`, `/agent`, the +dashboard's excluded-rows notice, and expense rows in another currency). It is +advisory: a rate a person reads off another site is not the historical rate +locked at entry, so nothing it shows may reach `converted_amount`, `sumSpend()`, +`sumByCategory()`, or the `excludedNotice()` copy. + +That is enforced structurally rather than by good intentions: +`CurrencyConverter` has **no `output()`, no `postMessage` listener, and never +reads a value back out of the frame**, so no converted figure exists anywhere in +Actuo's component tree to be wired in. Adding one would mean first inventing a +return channel — a visible, reviewable act rather than a one-line slip. +`currency-converter.spec.ts` asserts the component's inputs and outputs +directly, and the dashboard and expenses specs assert that opening the lookup +moves no figure. **`core/expense/amount.ts` and its spec were not touched by +that work**; if a change to the converter needs to edit them, the change is +wrong. + ## Architectural rules that must not be violated These are the load-bearing constraints — most bugs worth preventing here are violations of one of them. diff --git a/Dockerfile b/Dockerfile index 9f61e86..537a1e8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -74,9 +74,9 @@ FROM node:22-bookworm-slim AS runner WORKDIR /app -# NODE_ENV=production is read by EnvService.partnerOrigin, which drops its -# localhost:4201 default here — otherwise /agent would embed an iframe pointing -# at each visitor's own machine. +# NODE_ENV=production is read by EnvService.converterUrl, which drops its +# localhost:4201 default here — otherwise the converter surfaces would embed an +# iframe pointing at each visitor's own machine. ENV NODE_ENV=production ENV PORT=8080 diff --git a/Progress.md b/Progress.md index 6fc3cf5..6874e3e 100644 --- a/Progress.md +++ b/Progress.md @@ -2,7 +2,7 @@ Tracks every feature in the PRD against what is actually in the codebase. -**Last audited:** 2026-08-29 · **Baseline:** 9 shared · 64 backend unit · 34 backend e2e · 742 frontend +**Last audited:** 2026-09-03 · **Baseline:** 9 shared · 65 backend unit · 34 backend e2e · 784 frontend Status is evidence-based, not aspirational. A row is `DONE` only when the code exists, is reachable from the running app, and has a test. A file existing is not @@ -147,8 +147,9 @@ their own expense. | Item | Phase | Status | Notes | |---|---|---|---| | Original + converted amounts stored | 1 | ✅ | Columns exist. `core/expense/amount.ts` owns the rule: `sumSpend()` adds only base-currency rows and reports the rest | -| Live FX + daily cache | 1 | ⬜ | No FX client, no cache, no rates table | +| Live FX + daily cache | 1 | ⬜ | No FX client, no cache, no rates table. The embedded converter does **not** count — see below | | Historical rate lock | 1 | ⬜ | No rate column | +| Embedded converter (advisory) | 1 | ✅ | `converter/currency-converter.ts` frames a separate converter app on `/convert`, `/agent`, the dashboard notice and foreign-currency expense rows. One frame at a time, lazily mounted, `CONVERTER_URL` from `GET /api/config` | > **Totals are now honest about what they exclude.** `convertedAmount` is still > only set when the currency already equals the base currency, so foreign rows @@ -164,6 +165,16 @@ their own expense. > This is the honest interim, not the feature: real FX (live rates, daily cache, > historical lock) is still ⬜, and the moment `converted_amount` starts being > filled, those rows re-enter every total with no code change. +> +> **The embedded converter does not change any of that, deliberately.** It is a +> reference a person reads, framed from a separate origin; it writes nothing, +> and `CurrencyConverter` has no `output()` and no `postMessage` listener, so +> there is no channel a converted figure could travel back through. That +> absence is the enforcement, and `currency-converter.spec.ts` asserts it +> directly — along with the two surface specs proving that opening the lookup +> moves neither the row's amount nor the dashboard total. `amount.spec.ts` is +> untouched by this work, which is the point: if a change here needed to edit +> it, the change would be wrong. **Verify:** file expenses in two currencies and confirm the dashboard total is not a naive sum, and that it says how many rows it left out. @@ -199,7 +210,7 @@ Cross-origin is live as of 2026-08-29; only the standalone-script packaging (Pha | Confirmation before mutating tools | 0 | ✅ | In-chat card. PRD says "native dialog"; in-chat was chosen deliberately | | Key-setup flow when no key | 0 | ✅ | Opens into setup rather than failing silently | | Embeddable via one ` - - diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt index a19b68c..7e7edde 100644 --- a/frontend/public/robots.txt +++ b/frontend/public/robots.txt @@ -1,11 +1,11 @@ # Actuo — only the public surface is indexable (PRD §8.5). User-agent: * Allow: /$ -Allow: /partner-demo/ Disallow: /dashboard Disallow: /expenses Disallow: /add Disallow: /budgets +Disallow: /convert Disallow: /agent Disallow: /settings Disallow: /login diff --git a/frontend/src/app/converter/converter-session.spec.ts b/frontend/src/app/converter/converter-session.spec.ts index b1d69fd..81e700c 100644 --- a/frontend/src/app/converter/converter-session.spec.ts +++ b/frontend/src/app/converter/converter-session.spec.ts @@ -56,12 +56,13 @@ describe('ConverterSession', () => { describe('where the converter is', () => { it('derives the origin from a URL that carries a path', async () => { - const session = create('http://localhost:4201/partner-demo/'); + // A converter need not sit at the root of its host — a GitHub Pages + // project site is `.github.io//`. One variable carries both; + // two that had to agree would be one too many. + const session = create('https://theprogrammersingh.github.io/cambiaro/'); await session.ensureConfig(); - // One variable covers both the converter (at /) and the local partner - // demo (at /partner-demo/); two that had to agree would be one too many. - expect(session.converterOrigin()).toBe('http://localhost:4201'); + expect(session.converterOrigin()).toBe('https://theprogrammersingh.github.io'); expect(session.isAvailable()).toBe(true); }); @@ -95,7 +96,7 @@ describe('ConverterSession', () => { }); it('says so when the converter is on this app own origin', async () => { - const session = create(`${SELF_ORIGIN}/partner-demo/`); + const session = create(`${SELF_ORIGIN}/converter/`); await session.ensureConfig(); // getTools() returns same-origin descriptors too, and the Copilot filters @@ -250,7 +251,7 @@ describe('ConverterSession', () => { }); it('does not discover a same-origin converter', async () => { - const session = create(`${SELF_ORIGIN}/partner-demo/`); + const session = create(`${SELF_ORIGIN}/converter/`); const release = session.acquire(); await settle(); diff --git a/frontend/src/app/converter/converter-session.ts b/frontend/src/app/converter/converter-session.ts index 0b69a1f..fc53ef1 100644 --- a/frontend/src/app/converter/converter-session.ts +++ b/frontend/src/app/converter/converter-session.ts @@ -17,8 +17,8 @@ const FRAMABLE_SCHEMES = new Set(['https:', 'http:']); * the cross-origin tool discovery that follows it around. * * Discovery used to belong to `/agent`, which was right while exactly one page - * framed exactly one partner. The converter appears on four surfaces now, and - * page-owned discovery breaks the moment two of them overlap: navigating away + * framed exactly one other origin. The converter appears on four surfaces now, + * and page-owned discovery breaks the moment two overlap: navigating away * from `/agent` called `clearRemoteTools()` and stripped the Copilot's * converter tools while a converter was still mounted and visible elsewhere. * @@ -63,9 +63,9 @@ export class ConverterSession { /** * The origin to hand `getTools({fromOrigins})`. * - * Derived rather than configured separately: `CONVERTER_URL` carries a path - * (the converter at `/`, the local partner demo at `/partner-demo/`), and two - * variables that have to agree is one more than necessary. + * Derived rather than configured separately: `CONVERTER_URL` may carry a path + * — a converter need not sit at the root of its host — and two variables that + * have to agree is one more than necessary. */ readonly converterOrigin = computed(() => { const value = this.url(); @@ -101,9 +101,9 @@ export class ConverterSession { * The URL to frame, with this origin passed along so the converter can expose * its tools back to us. * - * The `?actuo=` handshake is the same one `frontend/public/partner-demo/` - * uses: a WebMCP tool is same-origin unless registration names an origin in - * `exposedTo`, and the embedded page cannot know ours without being told. + * The `?actuo=` handshake is what the converter reads to decide who may call + * its tools: a WebMCP tool is same-origin unless registration names an origin + * in `exposedTo`, and the embedded page cannot know ours without being told. * Sending it at runtime rather than hardcoding it there means our hostname * can change without a release on the other side. */ @@ -216,7 +216,14 @@ export class ConverterSession { private teardown(): void { this.unsubscribeToolChange?.(); this.unsubscribeToolChange = null; - this.openSurface.set(null); + /* + * `openSurface` is deliberately NOT cleared here. It is the user's intent — + * "show me the converter on this surface" — while the mount count is a + * resource. Coupling them broke going offline: the frame is released, this + * ran, the surface closed, and the panel vanished instead of rendering the + * "live rates need a connection" state. Coming back online then left it + * closed, because nothing had asked for it any more. + */ /* * Without this the Copilot keeps offering `convertCurrency` to the model * after the document implementing it is gone, and every call fails with a diff --git a/frontend/src/app/converter/currency-converter.spec.ts b/frontend/src/app/converter/currency-converter.spec.ts index 9b92e7f..ada6b20 100644 --- a/frontend/src/app/converter/currency-converter.spec.ts +++ b/frontend/src/app/converter/currency-converter.spec.ts @@ -151,6 +151,43 @@ describe('CurrencyConverter', () => { expect(copilot.discoverRemoteTools).not.toHaveBeenCalled(); }); + /** + * The transition, not just the initial state. Creating the component while + * already offline was covered; *going* offline was not, and it was broken: + * releasing the frame tore the session down, which cleared the open surface, + * so the panel vanished rather than explaining itself — and coming back + * online left it closed because nothing was asking for it any more. + */ + it('keeps the panel and explains itself when the connection drops', async () => { + await create(); + expect(iframe()).not.toBeNull(); + + offline.set(true); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(iframe()).toBeNull(); + expect(text()).toContain('Live rates need a connection'); + expect(session.isOpen('test')).toBe(true); + }); + + it('frames it again when the connection comes back', async () => { + await create(); + offline.set(true); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(iframe()).toBeNull(); + + offline.set(false); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(iframe()).not.toBeNull(); + }); + it('names the flag when this browser has no WebMCP, and still frames it', async () => { registry.isSupported.set(false); await create(); @@ -168,7 +205,7 @@ describe('CurrencyConverter', () => { }); it('explains a same-origin converter instead of discovering nothing', async () => { - await create({ converterUrl: `${SELF_ORIGIN}/partner-demo/` }); + await create({ converterUrl: `${SELF_ORIGIN}/converter/` }); expect(iframe()).toBeNull(); expect(text()).toContain('cross-origin'); diff --git a/frontend/src/app/converter/currency-converter.ts b/frontend/src/app/converter/currency-converter.ts index e21f4f4..cad823c 100644 --- a/frontend/src/app/converter/currency-converter.ts +++ b/frontend/src/app/converter/currency-converter.ts @@ -95,8 +95,8 @@ import { ConverterSession } from './converter-session.js'; } @else if (session.isResolved()) {

No converter is configured. Set CONVERTER_URL to the - origin serving one — locally that is - pnpm run dev:partner on :4201. + base URL of one — it has to be a different origin than this app, or the tools it + publishes come back same-origin and are filtered out.

} } diff --git a/frontend/src/app/copilot/copilot.spec.ts b/frontend/src/app/copilot/copilot.spec.ts index caa6450..71686b7 100644 --- a/frontend/src/app/copilot/copilot.spec.ts +++ b/frontend/src/app/copilot/copilot.spec.ts @@ -206,7 +206,7 @@ describe('Copilot', () => { /** * Cross-origin tools live only as long as the document that registered them. - * Keeping them on the menu after the partner iframe is gone means the model + * Keeping them on the menu after the converter iframe is gone means the model * keeps calling a document that no longer exists, and every call fails with * a confusing error instead of the tool simply not being offered. */ @@ -227,11 +227,11 @@ describe('Copilot', () => { registerTool: vi.fn().mockResolvedValue(undefined), getTools: vi.fn().mockResolvedValue([ { - name: 'get_book_price', - title: 'Get book price', - description: 'Price of one book.', + name: 'convertCurrency', + title: 'Convert currency', + description: 'Convert an amount between two currencies.', inputSchema: { type: 'object', properties: {} }, - origin: 'https://pageturner.example', + origin: 'https://cambiaro.example', annotations: { readOnlyHint: true }, }, // Same-origin tools come back too and must be ignored: the @@ -255,14 +255,14 @@ describe('Copilot', () => { it('keeps only genuinely cross-origin tools', async () => { const copilot = setupWithRemote(); - await copilot.discoverRemoteTools(['https://pageturner.example']); + await copilot.discoverRemoteTools(['https://cambiaro.example']); - expect(copilot.crossOriginTools().map((t) => t.name)).toEqual(['get_book_price']); + expect(copilot.crossOriginTools().map((t) => t.name)).toEqual(['convertCurrency']); }); it('forgets them when asked', async () => { const copilot = setupWithRemote(); - await copilot.discoverRemoteTools(['https://pageturner.example']); + await copilot.discoverRemoteTools(['https://cambiaro.example']); copilot.clearRemoteTools(); diff --git a/frontend/src/app/copilot/copilot.ts b/frontend/src/app/copilot/copilot.ts index ea65df5..ede46af 100644 --- a/frontend/src/app/copilot/copilot.ts +++ b/frontend/src/app/copilot/copilot.ts @@ -92,7 +92,7 @@ export class Copilot { } /** - * Pull in tools exposed by another origin (the partner-demo page). + * Pull in tools exposed by another origin (the embedded converter). * * Only genuinely cross-origin descriptors are kept: `getTools()` returns this * document's own tools too, and those are already in the registry with their @@ -108,10 +108,10 @@ export class Copilot { /** * Forget the other origin's tools. * - * Called when the page hosting the partner iframe goes away. Without it the - * Copilot keeps offering `search_books` to the model after the document that - * implements it is gone, and every call fails with a confusing error instead - * of the tool simply not being on the menu. + * Called when the last surface framing the other origin goes away. Without it + * the Copilot keeps offering `convertCurrency` to the model after the document + * that implements it is gone, and every call fails with a confusing error + * instead of the tool simply not being on the menu. */ clearRemoteTools(): void { this.remoteTools.set([]); diff --git a/frontend/src/app/pages/agent/agent.spec.ts b/frontend/src/app/pages/agent/agent.spec.ts index ccfa865..9070d0c 100644 --- a/frontend/src/app/pages/agent/agent.spec.ts +++ b/frontend/src/app/pages/agent/agent.spec.ts @@ -11,17 +11,17 @@ import type { ToolInvocation } from '../../webmcp/tool-registry.js'; import { Agent } from './agent.js'; const SELF_ORIGIN = globalThis.location.origin; -/** CONVERTER_URL carries a path, so the origin is derived from it. */ -const CONVERTER_URL = 'http://localhost:4201/partner-demo/'; -const PARTNER = 'http://localhost:4201'; +/** CONVERTER_URL may carry a path, so the origin is derived from it. */ +const CONVERTER_URL = 'https://cambiaro.example/'; +const CONVERTER_ORIGIN = 'https://cambiaro.example'; function remoteTool(overrides: Partial = {}): NormalizedTool { return { - name: 'get_book_price', - title: 'Get book price', - description: 'Return the price of one book by its id.', + name: 'convertCurrency', + title: 'Convert currency', + description: 'Convert an amount from one currency to another.', inputSchema: { type: 'object', properties: {} }, - origin: PARTNER, + origin: CONVERTER_ORIGIN, annotations: { readOnlyHint: true }, isCrossOrigin: true, raw: {} as NormalizedTool['raw'], @@ -109,12 +109,12 @@ describe('Agent tools page', () => { * The dead feature this page revives: `discoverRemoteTools()` existed, * worked and was tested, and nothing in the app ever called it. */ - it('asks the configured partner origin for its tools', async () => { + it('asks the configured converter origin for its tools', async () => { await create(); - expect(copilot.discoverRemoteTools).toHaveBeenCalledWith([PARTNER]); + expect(copilot.discoverRemoteTools).toHaveBeenCalledWith([CONVERTER_ORIGIN]); }); - it('embeds the partner page with the tools permission the spec requires', async () => { + it('embeds the converter with the tools permission the spec requires', async () => { await create(); const frame = iframe(); @@ -139,17 +139,17 @@ describe('Agent tools page', () => { listener(); await fixture.whenStable(); - // The partner page registers asynchronously after its own load, so the + // The converter registers asynchronously after its own load, so the // iframe's `load` event can fire before there is anything to find. - expect(copilot.discoverRemoteTools).toHaveBeenCalledWith([PARTNER]); + expect(copilot.discoverRemoteTools).toHaveBeenCalledWith([CONVERTER_ORIGIN]); }); it('lists what it discovered, with the origin it came from', async () => { copilot.crossOriginTools.set([remoteTool()]); await create(); - expect(text()).toContain('get_book_price'); - expect(text()).toContain('localhost:4201'); + expect(text()).toContain('convertCurrency'); + expect(text()).toContain('cambiaro.example'); expect(text()).toContain('Read-only'); }); @@ -159,7 +159,7 @@ describe('Agent tools page', () => { * read as a bug; saying so is the honest failure. */ it('explains itself instead of pretending, when the converter is same-origin', async () => { - await create(`${SELF_ORIGIN}/partner-demo/`); + await create(`${SELF_ORIGIN}/converter/`); expect(iframe()).toBeNull(); expect(text()).toContain('CONVERTER_URL'); @@ -265,7 +265,7 @@ describe('Agent tools page', () => { it('marks a cross-origin call as one', async () => { registry.invocationLog.set([ - invocation({ toolName: 'get_book_price', origin: 'cross-origin' }), + invocation({ toolName: 'convertCurrency', origin: 'cross-origin' }), ]); await create(); diff --git a/frontend/src/app/pages/agent/agent.ts b/frontend/src/app/pages/agent/agent.ts index db0b359..6409bfa 100644 --- a/frontend/src/app/pages/agent/agent.ts +++ b/frontend/src/app/pages/agent/agent.ts @@ -22,11 +22,12 @@ import { ToolRegistry } from '../../webmcp/tool-registry.js'; * so neither did anything in the running app: * * 1. **Cross-origin tool use.** `Copilot.discoverRemoteTools()` was never - * called, and the partner page was served from Actuo's own origin — so even - * if it had been, every descriptor would have come back same-origin and been - * filtered out. This screen embeds the currency converter from its own - * origin (`CONVERTER_URL`, the local partner page on :4201 in dev) with - * `allow="tools"`, then asks `getTools({fromOrigins})` for what it exposes. + * called, and the page it would have queried was served from Actuo's own + * origin — so even if it had been, every descriptor would have come back + * same-origin and been filtered out. This screen embeds the currency + * converter from `CONVERTER_URL` with `allow="tools"`, then asks + * `getTools({fromOrigins})` for what it exposes. That is a separately built, + * independently deployed app, in development as well as on a deploy. * * The frame and the discovery lifecycle belong to `ConverterSession`, not to * this page: the converter also appears on `/convert`, the dashboard and diff --git a/frontend/src/app/ui/badge.spec.ts b/frontend/src/app/ui/badge.spec.ts index f4350e5..9a85c99 100644 --- a/frontend/src/app/ui/badge.spec.ts +++ b/frontend/src/app/ui/badge.spec.ts @@ -66,11 +66,11 @@ describe('Badge', () => { it('falls back to the explicit tone when no status is given', () => { fixture.componentRef.setInput('tone', 'info'); - fixture.componentRef.setInput('label', 'via partner-demo.app'); + fixture.componentRef.setInput('label', 'via cambiaro.programmersingh.dev'); fixture.detectChanges(); expect(fixture.componentInstance.tone()).toBe('info'); - expect(pill().textContent?.trim()).toBe('via partner-demo.app'); + expect(pill().textContent?.trim()).toBe('via cambiaro.programmersingh.dev'); }); it('lets an explicit label override the status label', () => { diff --git a/frontend/src/app/ui/badge.ts b/frontend/src/app/ui/badge.ts index 8b9f730..cbc9823 100644 --- a/frontend/src/app/ui/badge.ts +++ b/frontend/src/app/ui/badge.ts @@ -39,7 +39,7 @@ const STATUS_LABEL: Record = { * * ```html * - * + * * ``` * * The colour is carried by a dot *and* the text, and the text is always present, diff --git a/frontend/src/app/ui/showcase/showcase.ts b/frontend/src/app/ui/showcase/showcase.ts index f5571b3..48c3a9c 100644 --- a/frontend/src/app/ui/showcase/showcase.ts +++ b/frontend/src/app/ui/showcase/showcase.ts @@ -108,7 +108,7 @@ import { StatCard } from '../stat-card'; }
- +
diff --git a/frontend/src/app/ui/tool-call-card.spec.ts b/frontend/src/app/ui/tool-call-card.spec.ts index 0426585..276069c 100644 --- a/frontend/src/app/ui/tool-call-card.spec.ts +++ b/frontend/src/app/ui/tool-call-card.spec.ts @@ -74,8 +74,8 @@ describe('ToolCallCard', () => { create(); expect(text()).not.toContain('via '); - create({ origin: 'partner-demo.app' }); - expect(text()).toContain('via partner-demo.app'); + create({ origin: 'cambiaro.programmersingh.dev' }); + expect(text()).toContain('via cambiaro.programmersingh.dev'); }); // §3.2.4 — a mutating call is never executed silently. diff --git a/frontend/src/app/webmcp/tool-registry.spec.ts b/frontend/src/app/webmcp/tool-registry.spec.ts index 98ccae0..1cd8844 100644 --- a/frontend/src/app/webmcp/tool-registry.spec.ts +++ b/frontend/src/app/webmcp/tool-registry.spec.ts @@ -231,14 +231,14 @@ describe('ToolRegistry', () => { title: '', description: 'List books.', inputSchema: JSON.stringify({ type: 'object' }), - origin: 'https://partner-demo.app', + origin: 'https://cambiaro.programmersingh.dev', window: globalThis.window, }, ]), }); const registry = configure(context); - const tools = await registry.discover({ fromOrigins: ['https://partner-demo.app'] }); + const tools = await registry.discover({ fromOrigins: ['https://cambiaro.programmersingh.dev'] }); expect(tools).toHaveLength(1); expect(tools[0].isCrossOrigin).toBe(true); diff --git a/frontend/src/app/webmcp/webmcp.types.spec.ts b/frontend/src/app/webmcp/webmcp.types.spec.ts index 7c8a6e1..aee77e1 100644 --- a/frontend/src/app/webmcp/webmcp.types.spec.ts +++ b/frontend/src/app/webmcp/webmcp.types.spec.ts @@ -54,7 +54,7 @@ describe('normalizeRegisteredTool', () => { expect(sameOrigin.isCrossOrigin).toBe(false); const crossOrigin = normalizeRegisteredTool( - descriptor({ origin: 'https://partner-demo.app' }), + descriptor({ origin: 'https://cambiaro.programmersingh.dev' }), 'https://actuo.app', ); expect(crossOrigin.isCrossOrigin).toBe(true); diff --git a/frontend/src/server.ts b/frontend/src/server.ts index bc20b89..d131f96 100644 --- a/frontend/src/server.ts +++ b/frontend/src/server.ts @@ -27,29 +27,6 @@ const angularApp = new AngularNodeAppEngine(); * requires, and the source of the handler `server.mjs` mounts. */ -/** - * The WebMCP partner demo — a static sub-site, not an Angular route. - * - * It needs its own mount because the general handler below sets `index: false`, - * deliberately: letting `express.static` answer a directory request would have - * it serve `index.html` for `/` and pre-empt server-side rendering of the - * landing page. Without this mount `/partner-demo/` matches no Angular route, - * falls to the router's `**` redirect and 302s to `/` — which is what it did - * until this was added, silently, since only `/partner-demo/index.html` worked. - * - * `no-store` for the same reason `scripts/partner-server.mjs` uses it: the page - * registers its tools against the `?actuo=` origin on every load, so a cached - * copy would keep re-registering against a stale one. - */ -app.use( - '/partner-demo', - express.static(join(browserDistFolder, 'partner-demo'), { - index: 'index.html', - redirect: true, - setHeaders: (res) => res.setHeader('Cache-Control', 'no-store'), - }), -); - /** * Serve static files from /browser */ diff --git a/package.json b/package.json index fd17976..5fcc20d 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,9 @@ "pnpm": ">=9.0.0 <10.0.0" }, "scripts": { - "dev": "pnpm run build:shared && concurrently -n backend,frontend,partner -c blue,magenta,yellow \"pnpm run dev:backend\" \"pnpm run dev:frontend\" \"pnpm run dev:partner\"", + "dev": "pnpm run build:shared && concurrently -n backend,frontend -c blue,magenta \"pnpm run dev:backend\" \"pnpm run dev:frontend\"", "dev:backend": "pnpm --filter backend run start:dev", "dev:frontend": "pnpm --filter frontend run start", - "dev:partner": "node scripts/partner-server.mjs", "build:shared": "pnpm --filter @actuo/shared run build", "build": "pnpm run build:shared && pnpm --filter backend run build && pnpm --filter frontend run build && pnpm run build:seo", "build:seo": "node scripts/stamp-seo.mjs", diff --git a/render.yaml b/render.yaml index 05238e7..6fcc98a 100644 --- a/render.yaml +++ b/render.yaml @@ -78,9 +78,10 @@ services: sync: false # The embedded currency converter (PRD §6.5). A full URL, not a bare - # origin, because the converter serves at `/` while the local partner-demo - # fallback serves at `/partner-demo/` — one value covers both and - # consumers derive the origin from it with `new URL(...).origin`. + # origin, because a converter need not sit at the root of its host — a + # GitHub Pages project site is `.github.io//` — and the + # `?actuo=` handshake is appended to it. Consumers derive the origin with + # `new URL(...).origin` for `getTools({fromOrigins})`. # # It must NOT be this app's own origin: `getTools({fromOrigins})` would # return same-origin tools, which the Copilot filters out, and the diff --git a/scripts/partner-server.mjs b/scripts/partner-server.mjs deleted file mode 100644 index baff88e..0000000 --- a/scripts/partner-server.mjs +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Serves the WebMCP partner-demo page on its own origin. - * - * PRD §7's cross-origin row needs two origins, not two paths. The page lives in - * `frontend/public/partner-demo/`, so in dev it is *also* reachable at - * `localhost:4200/partner-demo/` — but from there it is same-origin, and - * `normalizeRegisteredTool()` marks its tools `isCrossOrigin: false`, which is - * exactly the set the Copilot filters out. Nothing about the demo would be - * cross-origin. - * - * So this serves `frontend/public` on :4201, which puts the page at - * `/partner-demo/` there just as it is on the app's own origin — one URL shape - * in dev and in production, so `CONVERTER_URL` is the only thing that - * changes between them. Deliberately dependency-free - * (`node:http` + `node:fs`): a static file server is not worth a package, and - * pnpm blocks lifecycle scripts by default, so every added dependency is a new - * way for `pnpm run dev` to fail on a fresh clone. - * - * node scripts/partner-server.mjs # :4201 - * PORT=5001 node scripts/partner-server.mjs - */ - -import { createServer } from 'node:http'; -import { readFile } from 'node:fs/promises'; -import { extname, join, normalize, resolve, sep } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)), 'frontend/public'); -const PORT = Number(process.env.PORT ?? 4201); - -const CONTENT_TYPES = { - '.html': 'text/html; charset=utf-8', - '.js': 'text/javascript; charset=utf-8', - '.css': 'text/css; charset=utf-8', - '.json': 'application/json; charset=utf-8', - '.svg': 'image/svg+xml', - '.ico': 'image/x-icon', -}; - -const server = createServer(async (req, res) => { - const { pathname } = new URL(req.url ?? '/', `http://localhost:${PORT}`); - - // Resolve inside ROOT and verify it stayed there: `..` in a URL path is a - // directory traversal, and this process can read the whole repo. - const requested = pathname.endsWith('/') ? `${pathname}index.html` : pathname; - const target = join(ROOT, normalize(decodeURIComponent(requested))); - if (target !== ROOT && !target.startsWith(ROOT + sep)) { - res.writeHead(403, { 'content-type': 'text/plain' }).end('Forbidden'); - return; - } - - try { - const body = await readFile(target); - res.writeHead(200, { - 'content-type': CONTENT_TYPES[extname(target)] ?? 'application/octet-stream', - // The page registers tools scoped to Actuo's origin on every load, so a - // cached copy would keep re-registering against a stale `?actuo=` value. - 'cache-control': 'no-store', - }); - res.end(body); - } catch { - res.writeHead(404, { 'content-type': 'text/plain' }).end('Not found'); - } -}); - -server.listen(PORT, () => { - console.log(`Partner demo (WebMCP cross-origin) on http://localhost:${PORT}/partner-demo/`); -}); From 229716f213ed7b754a3773ecd1f04648940785f2 Mon Sep 17 00:00:00 2001 From: theprogrammersingh Date: Thu, 3 Sep 2026 09:08:36 +0530 Subject: [PATCH 3/3] fix(seo): stamp the whole build output, and check the deploy for real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deployed site has been serving a literal `__PUBLIC_ORIGIN__` in its `canonical` and `og:image`. A crawler reading a malformed URL is worse than one reading a relative URL, so this is not cosmetic. `stamp-seo.mjs` walked only `dist/frontend/browser`. Angular keeps its own copies of the page HTML under `server/` — `index.server.html` and the `assets-chunks/*.mjs` templates, `index_csr_html.mjs` among them — and those are what the SSR handler actually serves. It now walks the whole `dist/frontend` tree, and `.mjs` is stampable because that is where those templates live. Coverage goes from 28 URLs across 7 files to 63 across 14, and a build with PUBLIC_ORIGIN set now leaves zero sentinels anywhere in the output. With it unset everything still falls back to root-relative. What made this hard to see is worth recording: `sitemap.xml` and `robots.txt` were correct on the live site the whole time, because they are served from `browser/`. That is also the evidence the build was fine — the origin reached it, and only some files got it. The same look at the running site turned up a second defect this repo cannot fix from the repo: `/` carries no `ng-server-context`, so Angular is falling back to client-side rendering in production and discarding the SSR and structured-data work. It works locally, which is exactly how it went unnoticed — the same silent failure as 2026-08-29. `NG_ALLOWED_HOSTS` has to be set on the *service*; declaring it in `render.yaml` is not sufficient if the service was created by hand rather than from the Blueprint, because then its envVars were never applied. So `pnpm run verify:deploy ` now exists. Every check in it is there because that thing broke in production while every test passed and the page looked fine: the API answers, `/` genuinely server-renders, no sentinel survives in `/`, `/sitemap.xml` or `/robots.txt`, and the converter is configured on an origin the app does not serve. Each failure names the fix. Run against the live deploy it reproduces all three known defects and correctly passes sitemap and robots — the discriminating signal that led to the root cause. Progress.md was believed and it had drifted both ways. It did not know the app is deployed at all (§12 still described creating the Blueprint), while "What to fix next" still described an interactive Google login, the project actuo-2f1f3 and the Blaze plan — all Firebase App Hosting, abandoned in 6b5a3d5. §8.5 claimed SSR was fixed when it is broken in production. The baseline was one commit stale, §7 named `discoveredTools` where `/agent` renders `Copilot.crossOriginTools`, the App Hosting rough edge cited firebase-tools#7478 where the current docs cite #10435, and "Demo video" was listed twice. All corrected. §12 stays 🟡 rather than going ✅: the deploy exists and is healthy, and it is also defective. Five source comments still reasoned about "Firebase App Hosting" as the runtime. The reasoning survives the move to Render unchanged — single process, in-memory jobs, per-instance rate limiting, an unauthenticated health probe — so only the platform name was wrong, and only that changed. Gate: typechecks clean, 894 tests green (9 shared, 65 backend, 786 frontend, 34 e2e), build clean with 11 routes prerendered and zero sentinels surviving. Not fixed here, because it is not in this repo: NG_ALLOWED_HOSTS and CONVERTER_URL on the Render service, and a redeploy to pick up the stamp fix — PUBLIC_ORIGIN is a build arg, so a restart cannot carry it. --- CLAUDE.md | 25 ++++- Progress.md | 57 +++++----- README.md | 18 ++- backend/src/common/rate-limit.guard.ts | 4 +- backend/src/health/health.controller.ts | 2 +- backend/src/reports/reports.service.ts | 2 +- backend/test/routing-contract.e2e-spec.ts | 2 +- package.json | 1 + scripts/stamp-seo.mjs | 26 ++++- scripts/verify-deploy.mjs | 131 ++++++++++++++++++++++ 10 files changed, 220 insertions(+), 48 deletions(-) create mode 100644 scripts/verify-deploy.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 1b9c98c..4124753 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,7 +140,7 @@ and `pnpm run build` handle the ordering. ``` `frontend/` and `backend/` are separate codebases (own package.json, tsconfig, tests) -that ship as **one** Firebase App Hosting deploy: a single Node process routes +that ship as **one** deploy — a Docker image on Render: a single Node process routes `/api/*` to Nest and everything else to Angular's SSR handler. That process is `server.mjs` at the repo root — see "The deploy" below. @@ -358,10 +358,25 @@ those into build args; on any other host it needs an explicit `--build-arg`. The URLs — `` in the sitemap, `og:image`, `canonical` — must be decided before the build finishes. `index.html`, `sitemap.xml` and `robots.txt` carry a `__PUBLIC_ORIGIN__` sentinel that survives prerendering into every generated -file, and `scripts/stamp-seo.mjs` replaces it across `dist/frontend/browser` as -the last step of `pnpm run build`. Unset, it substitutes `''` and everything -stays root-relative and valid. It must carry the scheme: the value is -substituted verbatim, so a bare hostname yields a `` that is not a URL. +file, and `scripts/stamp-seo.mjs` replaces it across **the whole +`dist/frontend` tree** as the last step of `pnpm run build`. + +**It must not narrow to `browser/` again.** It did, and the deployed site served +a literal `__PUBLIC_ORIGIN__` in its `canonical` and `og:image`: Angular keeps +its own copies of the page HTML under `server/` — `index.server.html` and the +`assets-chunks/*.mjs` templates, `index_csr_html.mjs` among them — and those are +what the SSR handler serves. `sitemap.xml` and `robots.txt` looked right the +whole time because they come from `browser/`, which is what made it hard to see. +`.mjs` is in the stampable extension set for exactly this reason. + +Unset, `PUBLIC_ORIGIN` substitutes `''` and everything stays root-relative and +valid. It must carry the scheme: the value is substituted verbatim, so a bare +hostname yields a `` that is not a URL. + +**`pnpm run verify:deploy `** checks the deployed result of all of this — +`ng-server-context` present, no sentinel surviving, and the converter configured +on another origin. Local green does not mean deployed correct: SSR fell back to +CSR in production while every test passed and the page looked fine. **The service worker must never cache `/api`.** `ngsw-config.json` has no `dataGroups` at all, deliberately: a cached response would show stale money and diff --git a/Progress.md b/Progress.md index 33316a6..f373d4d 100644 --- a/Progress.md +++ b/Progress.md @@ -2,7 +2,7 @@ Tracks every feature in the PRD against what is actually in the codebase. -**Last audited:** 2026-09-03 · **Baseline:** 9 shared · 65 backend unit · 34 backend e2e · 784 frontend +**Last audited:** 2026-09-03 · **Baseline:** 9 shared · 65 backend unit · 34 backend e2e · 786 frontend Status is evidence-based, not aspirational. A row is `DONE` only when the code exists, is reachable from the running app, and has a test. A file existing is not @@ -68,6 +68,7 @@ Open the feature and use it. Tests did not catch the aurora-scarcity violation | **WebMCP** | Works in flag-enabled Chrome (`chrome://flags/#enable-webmcp-testing`) **and** still works with the flag off | | **Money / totals** | The number is right on a dataset larger than one page (100 rows) — truncation shows a wrong figure, not an obvious gap | | **UI** | Both themes, phone and desktop widths, keyboard reachable | +| **Anything deployed** | `pnpm run verify:deploy `. Local green does not mean deployed correct — SSR fell back to CSR in production while every test passed, and the page looked fine | ### 6. Update `CLAUDE.md` Only if the change alters a rule, a command, or a non-obvious constraint that @@ -235,7 +236,7 @@ Cross-origin is live as of 2026-08-29; only the standalone-script packaging (Pha | Cross-origin tools | ✅ | See §6.8. Needs a genuinely second origin — same-origin descriptors are filtered out, which is what made the earlier in-repo page unprovable. It is now a separately built, independently deployed app Actuo does not own, in dev as well as on a deploy | | Security annotations | ✅ | `readOnlyHint` on all five, driving the shell's re-poll and the `/agent` panel. `untrustedContentHint` on `search_expenses` and `approve_expense` — the two that surface *another person's* free text — and on the converter's tools, whose results carry third-party rate data; shown as a badge on the tool-call card | | `getTools()` discovery | ✅ | Drives the cross-origin path and the `/agent` panel; re-runs on `toolchange`. The Copilot still reads its own registry for local tools, deliberately — see "the tool registry decision" | -| `executeTool()` + manual debug panel | 🟡 | `executeTool()` done, and `/agent` renders `discoveredTools` and `invocationLog`. Still read-only: there is no form to invoke a tool by hand with arbitrary arguments | +| `executeTool()` + manual debug panel | 🟡 | `executeTool()` done, and `/agent` renders `Copilot.crossOriginTools` and the registry's `invocationLog()`. Still read-only: there is no form to invoke a tool by hand with arbitrary arguments | > Open question: `generate_report` is annotated `readOnlyHint: true` but creates a > server-side job. Defensible, but decide it deliberately. @@ -266,7 +267,7 @@ and the offline banner appearing and clearing on the network events. | Structured data | ✅ | Real `application/ld+json` `SoftwareApplication` | | llms.txt | ✅ | Accurate tool inventory and permission model | | OG / Twitter | ✅ | 1200×630 `og.png` generated from the brand tokens, plus `og:url`, `og:image:alt`, `twitter:image` and a canonical link | -| SSR on public pages | 🟡 | `app.routes.server.ts` prerenders `**` — including authenticated routes, which land on the app shell and hydrate client-side (correct for a gated view, accidental rather than chosen). **Was silently broken until 2026-08-29:** Angular 21's `Host` allowlist rejected every request and fell back to CSR, discarding the SSR entirely. Fixed via `security.allowedHosts` + `NG_ALLOWED_HOSTS`; the check is that `/` contains `ng-server-context` | +| SSR on public pages | 🟡 ⚠️ | `app.routes.server.ts` prerenders `**` — including authenticated routes, which land on the app shell and hydrate client-side (correct for a gated view, accidental rather than chosen). **Broken on the deployed site as of 2026-09-03:** `/` carries no `ng-server-context`, so Angular is falling back to CSR there and discarding the SSR entirely. It works locally, which is exactly how it went unnoticed — the same silent failure as 2026-08-29. `NG_ALLOWED_HOSTS` must be set on the *service*, not only in `render.yaml` and the Dockerfile. `pnpm run verify:deploy ` now checks it | | noindex on gated views | ✅ | `data.robots` per route, applied by `SeoService` on every navigation; a route that declares nothing defaults to `noindex`. Verified live: the tag flips going from `/` to `/expenses` | ## §9 Non-functional @@ -287,27 +288,28 @@ and the offline banner appearing and clearing on the network events. | Item | Status | Notes | |---|---|---| -| **Public deployed URL** | 🟡 | The deploy path is **built and verified locally**: `server.mjs` composes Nest under `/api` with the Angular SSR handler, and a `Dockerfile` builds and runs it. Both builder stages plus the runtime boot were simulated locally — `/api/health` 200, `/api/*` 404 as JSON, `ng-server-context` present. The target is Render via `render.yaml`; Firebase App Hosting was abandoned after three distinct buildpack failures against this workspace monorepo (see README *Why a Dockerfile*). What is left is creating the Render Blueprint, which needs an interactive login and the three secrets | +| **Public deployed URL** | 🟡 | **Live at `https://actuo.onrender.com`** — `/api/health` returns 200. `server.mjs` composes Nest under `/api` with the Angular SSR handler from a committed `Dockerfile`; Firebase App Hosting was abandoned after three distinct buildpack failures against this workspace monorepo (see README *Why a Dockerfile*). Still 🟡, not ✅, because the deployed site is **defective in two ways**: `/` does not server-render (see §8.5) and it served a literal `__PUBLIC_ORIGIN__` in `canonical`/`og:image`. The stamp half is fixed in `scripts/stamp-seo.mjs` and needs a redeploy; the SSR half needs `NG_ALLOWED_HOSTS` set on the service. `pnpm run verify:deploy ` reports both | | README | ✅ | Root `README.md`: what is WebMCP-specific and where, the flag setup, what works without it, and the deploy steps. Workspace READMEs are still starter boilerplate | -| Demo video | ⬜ | The script is the "What to look at" list in `README.md` | -| Demo video | ⬜ | — | +| Demo video | ⬜ | The script is the "What to look at" list in `README.md`. Worth filming only after the SSR fix lands, or it records the client-rendered site | | Source with clear tool definitions | ✅ | `shared/src/tools.ts` | --- ## What to fix next -Every Phase 0 row is green as of 2026-08-29. What is left is the deploy itself, -the video, and Phase 1–3 features. - -1. **Run the deploy.** Everything is committed; the remaining steps need an - interactive Google login — see the Deploying section of `README.md`. The - project `actuo-2f1f3` exists and has no App Hosting backend yet; App Hosting - needs the Blaze plan. - *Verify:* `/api/health` returns JSON on the public URL; `/` returns HTML - containing `ng-server-context` (if not, `NG_ALLOWED_HOSTS` is wrong and the - site is rendering client-side); `PUBLIC_ORIGIN` set, so `` in the - sitemap is absolute. +Every Phase 0 row is green as of 2026-09-03. The deploy exists and is healthy; +what is left is making it *correct*, the video, and Phase 1–3 features. + +1. **Make the live deploy correct.** It exists and is healthy at + `https://actuo.onrender.com`, but `/` is client-rendered and was shipping an + unstamped `__PUBLIC_ORIGIN__`. Two things remain, both on the Render service + rather than in this repo: set **`NG_ALLOWED_HOSTS`** so Angular stops falling + back to CSR, and set **`CONVERTER_URL`** so the cross-origin path runs. Then + redeploy — `PUBLIC_ORIGIN` is a build arg, so a restart cannot carry the stamp + fix. If the service was created by hand rather than from `render.yaml`, its + `envVars` were never applied, which would explain all of it. + *Verify:* `pnpm run verify:deploy https://actuo.onrender.com` passes every + check. 2. **Demo video** — the last §12 checkbox. The script is the "What to look at" list in `README.md`. 3. **Real FX** — live rates, a daily cache, a historical lock at write time. @@ -326,18 +328,19 @@ the video, and Phase 1–3 features. - **The cross-origin path has not been run end to end from a *deployed* Actuo.** It is verified locally against the deployed converter (see §6.8), but nobody has yet loaded Actuo on Render, framed the converter from there, and watched - the Copilot call `convertCurrency` across two public origins. What remains is - `CONVERTER_URL` on the service — the converter's `exposedTo` change is already - merged and live. Until then a deploy shows the honest "not configured" state, - which is correct but is not the demo. + the Copilot call `convertCurrency` across two public origins. Two things remain: + the converter commits have to reach the deploy, and `CONVERTER_URL` has to be + set on the service. The converter's own `exposedTo` change is already merged + and live. Until then `/api/config` reports no converter and the surfaces show + the honest "not configured" state, which is correct but is not the demo. - **CI has never run on GitHub.** The workflow was verified by running its exact command sequence locally; `act` is not installed on this machine. -- **Firebase App Hosting has an open issue with pnpm workspaces** - ([firebase-tools#7478](https://github.com/firebase/firebase-tools/issues/7478), - `lockfile not found`) that reproduces when the app is in a *subdirectory*. - `rootDir: "/"` keeps the lockfile where the installer looks — the arrangement - least likely to hit it, but untested against the real builder. If it fails, - `node server.mjs` runs unchanged on Cloud Run, Render or Fly. +- **The Firebase App Hosting backend may still be connected** with auto-rollouts, + in which case it fails on every push. Deleting it is + `firebase apphosting:backends:delete actuo --project actuo-2f1f3`. App Hosting + itself is no longer the target — see README *Why a Dockerfile* — and the issue + that killed it is [firebase-tools#10435](https://github.com/firebase/firebase-tools/issues/10435), + closed as not planned. - **`/agent` is a sixth tab on mobile.** The labels fit (widest is "Dashboard" at ~54px in a 65px slot at 390px, measured), but it is tight, and this was verified by measurement rather than at a real 390px viewport. diff --git a/README.md b/README.md index c326ba9..811e6a7 100644 --- a/README.md +++ b/README.md @@ -223,14 +223,17 @@ set to `https://actuo.onrender.com`; change it when a custom domain is attached and **redeploy**, because a runtime variable cannot reach already-prerendered HTML. -After the first deploy, the one check that matters: +After any deploy, run the smoke check: ```bash -curl -s https://actuo.onrender.com/ | grep -o 'ng-server-context="[^"]*"' +pnpm run verify:deploy https://actuo.onrender.com ``` -Empty output means `NG_ALLOWED_HOSTS` does not cover the hostname and Angular has -silently fallen back to client-side rendering — see *Allowed hosts* below. +It checks the four things that are only true when the deploy is correct — the API +answers, `/` actually server-renders, no `__PUBLIC_ORIGIN__` sentinel survives, +and the converter is configured on another origin — and names the fix for each +failure. Every check is there because that thing broke in production without +anything else noticing. ### Anywhere else @@ -263,8 +266,11 @@ back to client-side rendering, which throws away the SSR and structured-data wor on the public pages. `NG_ALLOWED_HOSTS` in `render.yaml` is that list, and it *replaces* the build-time list in `angular.json` rather than adding to it. -After any deploy, confirm the HTML for `/` contains `ng-server-context`. If it -does not, `NG_ALLOWED_HOSTS` does not match your hostname. +After any deploy, confirm the HTML for `/` contains `ng-server-context` — which +is what `pnpm run verify:deploy` does. If it does not, `NG_ALLOWED_HOSTS` is not +reaching the running container. Declaring it in `render.yaml` is not sufficient +on its own: a service created by hand rather than from the Blueprint never had +those `envVars` applied, so check the service's own environment first. ### The cross-origin demo on a deployed URL diff --git a/backend/src/common/rate-limit.guard.ts b/backend/src/common/rate-limit.guard.ts index 7c3f53e..418a223 100644 --- a/backend/src/common/rate-limit.guard.ts +++ b/backend/src/common/rate-limit.guard.ts @@ -39,7 +39,7 @@ interface Bucket { * editing concurrently. * * The honest limitation: state is per-process and in memory. That is correct - * for the single-process Firebase App Hosting deploy this ships as, and it + * for the single-process deploy this ships as, and it * resets on restart. If this ever runs multiple instances, swap the Map for * Redis — the guard's interface would not change. */ @@ -97,7 +97,7 @@ export class RateLimitGuard implements CanActivate { } /** - * Identify the client. Behind Firebase App Hosting the socket address is the + * Identify the client. Behind a platform proxy the socket address is the * load balancer's, so prefer the leftmost X-Forwarded-For entry — the original * client — falling back to the socket address locally. * diff --git a/backend/src/health/health.controller.ts b/backend/src/health/health.controller.ts index 540e5c2..b9d67e7 100644 --- a/backend/src/health/health.controller.ts +++ b/backend/src/health/health.controller.ts @@ -4,7 +4,7 @@ import { Public } from '../auth/public.decorator.js'; @Controller('health') export class HealthController { /** - * Unauthenticated by necessity: Firebase App Hosting's health probe has no + * Unauthenticated by necessity: a platform health probe has no * credentials. It reports only that the process is up — deliberately not * whether Supabase is reachable, since a database blip should not take the * instance out of rotation while it is still serving the Angular app. diff --git a/backend/src/reports/reports.service.ts b/backend/src/reports/reports.service.ts index 6c4dffd..3a40336 100644 --- a/backend/src/reports/reports.service.ts +++ b/backend/src/reports/reports.service.ts @@ -28,7 +28,7 @@ export interface ReportJob { * worker checks between chunks, which is what makes the stop genuine rather than * the client merely walking away from a request that keeps running. * - * Jobs live in memory. That is correct for the single-process App Hosting deploy + * Jobs live in memory. That is correct for the single-process deploy * and for a demo; a multi-instance deployment would need shared storage, and the * repository seam is where that would go. */ diff --git a/backend/test/routing-contract.e2e-spec.ts b/backend/test/routing-contract.e2e-spec.ts index 3674da7..0febcb7 100644 --- a/backend/test/routing-contract.e2e-spec.ts +++ b/backend/test/routing-contract.e2e-spec.ts @@ -4,7 +4,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createNestApp } from '../src/bootstrap.js'; /** - * The routing contract that makes the combined Firebase App Hosting deploy work. + * The routing contract that makes the combined single-process deploy work. * * In production a single Node process serves both: Nest owns `/api/*` and the * Angular SSR handler is appended after it to catch everything else. Two things diff --git a/package.json b/package.json index 5fcc20d..ad08edf 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build:shared": "pnpm --filter @actuo/shared run build", "build": "pnpm run build:shared && pnpm --filter backend run build && pnpm --filter frontend run build && pnpm run build:seo", "build:seo": "node scripts/stamp-seo.mjs", + "verify:deploy": "node scripts/verify-deploy.mjs", "start": "node server.mjs", "test": "pnpm --filter @actuo/shared run test && pnpm --filter backend run test && pnpm --filter frontend run test", "test:e2e": "pnpm --filter backend run test:e2e" diff --git a/scripts/stamp-seo.mjs b/scripts/stamp-seo.mjs index ab90a08..309bd84 100644 --- a/scripts/stamp-seo.mjs +++ b/scripts/stamp-seo.mjs @@ -12,6 +12,16 @@ * survives prerendering into every generated HTML file, so one pass covers all * of them rather than each page needing its own handling. * + * **It has to be the whole `dist/frontend` tree, not just `browser/`.** This + * walked only `browser/` once, and the deployed site shipped a literal + * `__PUBLIC_ORIGIN__` in its `canonical` and `og:image`: Angular keeps its own + * copies of the page HTML under `server/` — `index.server.html` and the + * `assets-chunks/*.mjs` templates, `index_csr_html.mjs` among them — and those + * are what the SSR handler serves. `sitemap.xml` and `robots.txt` looked right + * throughout, because they are served from `browser/`, which is exactly what + * made it hard to see. A crawler reading a malformed URL is worse than one + * reading a relative URL, so this must not narrow again. + * * With PUBLIC_ORIGIN unset it substitutes the empty string, leaving every URL * root-relative and still valid — a local build is never broken by an * unconfigured domain, it just loses the absolute forms. @@ -25,10 +35,16 @@ import { extname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const SENTINEL = '__PUBLIC_ORIGIN__'; -const STAMPABLE = new Set(['.html', '.xml', '.txt', '.webmanifest', '.json']); +/** + * `.mjs` is here for `server/assets-chunks/*.mjs` — Angular inlines each page's + * HTML into a JS module, sentinel and all. Stamping a string constant inside a + * generated module is safe: the sentinel appears nowhere else in the output. + */ +const STAMPABLE = new Set(['.html', '.xml', '.txt', '.webmanifest', '.json', '.mjs']); const ROOT = resolve(fileURLToPath(new URL('..', import.meta.url))); -const BROWSER_DIST = join(ROOT, 'frontend/dist/frontend/browser'); +/** The whole build output: `browser/` is served statically, `server/` is rendered from. */ +const DIST = join(ROOT, 'frontend/dist/frontend'); /** Trailing slashes would double up against the leading slash of every path. */ const origin = (process.env.PUBLIC_ORIGIN ?? '').trim().replace(/\/+$/, ''); @@ -41,14 +57,14 @@ async function* walk(dir) { } } -if (!existsSync(BROWSER_DIST)) { - console.error(`[seo] ${BROWSER_DIST} is missing. Run \`pnpm run build\` first.`); +if (!existsSync(DIST)) { + console.error(`[seo] ${DIST} is missing. Run \`pnpm run build\` first.`); process.exit(1); } let files = 0; let occurrences = 0; -for await (const path of walk(BROWSER_DIST)) { +for await (const path of walk(DIST)) { const before = await readFile(path, 'utf8'); if (!before.includes(SENTINEL)) continue; occurrences += before.split(SENTINEL).length - 1; diff --git a/scripts/verify-deploy.mjs b/scripts/verify-deploy.mjs new file mode 100644 index 0000000..cf35d61 --- /dev/null +++ b/scripts/verify-deploy.mjs @@ -0,0 +1,131 @@ +/** + * Smoke-checks a running deploy. + * + * Every check here exists because the thing it checks **failed silently in + * production** and nobody noticed until someone opened the site by hand: + * + * - SSR fell back to client-side rendering. Angular does not error when the + * `Host` header is off its allowlist — it quietly renders on the client and + * throws away the SSR and structured-data work. The page still looks fine. + * - `__PUBLIC_ORIGIN__` shipped literally in `canonical` and `og:image`, + * because the SEO stamp only walked `browser/` while the SSR handler serves + * HTML from `server/`. A crawler read a malformed URL, which is worse than a + * relative one. `sitemap.xml` looked correct throughout, which is what made + * it hard to see. + * + * Neither is visible to a unit test, and both are invisible to a casual look at + * the page. So they are checked here, against the real origin, over the network. + * + * node scripts/verify-deploy.mjs https://actuo.example + * pnpm run verify:deploy https://actuo.example + * + * Exits non-zero on the first failure, naming what to change. + */ + +const SENTINEL = '__PUBLIC_ORIGIN__'; +const TIMEOUT_MS = 20_000; + +const base = (process.argv[2] ?? process.env.DEPLOY_URL ?? '').trim().replace(/\/+$/, ''); +if (!base) { + console.error('usage: node scripts/verify-deploy.mjs '); + process.exit(2); +} + +/** A deploy that is merely asleep should read as slow, not as broken. */ +async function get(path) { + const url = `${base}${path}`; + const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) }); + return { url, status: response.status, body: await response.text() }; +} + +const failures = []; +const pass = (label, detail) => console.log(` ok ${label}${detail ? ` — ${detail}` : ''}`); +const fail = (label, why, fix) => { + console.log(` FAIL ${label} — ${why}`); + failures.push({ label, why, fix }); +}; + +console.log(`\nVerifying ${base}\n`); + +// --- The API answers at all ------------------------------------------------- +try { + const { status, body } = await get('/api/health'); + const ok = status === 200 && JSON.parse(body)?.status === 'ok'; + if (ok) pass('/api/health', '200 ok'); + else fail('/api/health', `status ${status}, body ${body.slice(0, 120)}`, 'The container is not serving Nest. Check the service logs.'); +} catch (error) { + fail('/api/health', String(error), 'The deploy is unreachable, or still waking up.'); +} + +// --- SSR is actually on ----------------------------------------------------- +let home = ''; +try { + const { status, body } = await get('/'); + home = body; + if (status !== 200) { + fail('/', `status ${status}`, 'The Angular handler is not answering.'); + } else if (body.includes('ng-server-context')) { + pass('/', 'server-rendered (ng-server-context present)'); + } else { + fail( + '/ server-rendered', + 'no ng-server-context — Angular fell back to client-side rendering', + 'Set NG_ALLOWED_HOSTS on the SERVICE (runtime, not just build) to cover this hostname, e.g. "*.onrender.com". Angular does not error when the Host header is off the list; it silently renders on the client.', + ); + } +} catch (error) { + fail('/', String(error), 'The deploy is unreachable.'); +} + +// --- The SEO stamp reached every copy of the HTML --------------------------- +for (const [path, body] of [['/', home], ['/sitemap.xml', null], ['/robots.txt', null]]) { + try { + const text = body ?? (await get(path)).body; + if (!text) continue; + if (text.includes(SENTINEL)) { + fail( + `${path} stamped`, + `still contains ${SENTINEL}`, + 'PUBLIC_ORIGIN is a BUILD arg, so a restart cannot fix it — redeploy. If only some paths are affected, scripts/stamp-seo.mjs is not covering the whole dist tree.', + ); + } else { + pass(`${path} stamped`, 'no sentinel'); + } + } catch (error) { + fail(`${path} stamped`, String(error), 'Could not fetch it.'); + } +} + +// --- The converter is configured, and on another origin --------------------- +try { + const { body } = await get('/api/config'); + const config = JSON.parse(body); + const url = typeof config?.converterUrl === 'string' ? config.converterUrl : ''; + if (!url) { + fail( + '/api/config converterUrl', + 'unset', + 'Set CONVERTER_URL on the service. Unset is a valid state — the converter surfaces say so — but the cross-origin demo will not run.', + ); + } else if (new URL(url).origin === new URL(base).origin) { + fail( + '/api/config converterUrl', + `same origin as the app (${url})`, + 'It must be an origin the app does not serve, or getTools() returns same-origin tools and the Copilot filters them out.', + ); + } else { + pass('/api/config converterUrl', url); + } +} catch (error) { + fail('/api/config', String(error), 'Could not read the client config.'); +} + +// --- Report ----------------------------------------------------------------- +if (failures.length === 0) { + console.log('\nAll checks passed.\n'); + process.exit(0); +} + +console.log(`\n${failures.length} check(s) failed:\n`); +for (const { label, fix } of failures) console.log(` ${label}\n → ${fix}\n`); +process.exit(1);