Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 69 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,9 +346,23 @@ larger image beats a step that silently half-works.

**`NG_ALLOWED_HOSTS` — a hard 400.** Angular checks `Host` and
`X-Forwarded-Host` against an allowlist (SSRF). With one configured a miss is
**400 `text/plain`**; only an *empty* list falls back to CSR. The env var is a
comma list and is **unioned with** `angular.json`'s `security.allowedHosts`, not
a replacement. `*.example.com` matches by suffix. Never set it to `*`.
**400 `text/plain`** naming the host; only an *empty* list falls back to CSR.
The env var is a comma list and is **unioned with** `angular.json`'s
`security.allowedHosts`, not a replacement — `AngularAppEngine.getAllowedHosts`
builds `new Set([...envList, ...manifest.allowedHosts])`, so `localhost` keeps
working whatever is listed. `*.example.com` matches by suffix
(`hostname.endsWith('.example.com')`, so it does **not** cover the bare apex).
Never set it to `*`. Verified against `@angular/ssr`, not the docs.

**Static files skip the check entirely**, which is how a half-broken deploy
presents: `express.static` runs ahead of the Angular handler, so on a hostname
that is not allowlisted `/sitemap.xml` and `/robots.txt` answer 200 while every
SSR route 400s.

The service answers on **two** hostnames — `actuo.onrender.com` and
`actuo.programmersingh.dev` — so both are listed. The custom domain is listed
exactly rather than as `*.programmersingh.dev`, which would admit every other
subdomain of that zone.

**Untrusted proxy headers — this is the silent one.** Angular deopts to CSR on
any `x-forwarded-*` header it was not told to trust, returning a normal 200 that
Expand All @@ -361,9 +375,10 @@ Verify with `pnpm run verify:deploy <url>`, which tells the two apart. Reproduce
the second locally:
`curl -s -H 'X-Forwarded-For: 203.0.113.9' localhost:8080/ | grep ng-server-context`

**`PUBLIC_ORIGIN` is a BUILD-time variable, not a runtime one** — a `Dockerfile`
`ARG`. On Render it is declared as a normal env var only because Render turns
those into build args; on any other host it needs an explicit `--build-arg`. The public pages are prerendered, so absolute
**`PUBLIC_ORIGIN` is read twice, and changing it needs a REBUILD.** It is a
`Dockerfile` `ARG` for the build half; on Render it is declared as a normal env
var only because Render turns those into build args, and on any other host it
needs an explicit `--build-arg`. The public pages are prerendered, so absolute
URLs — `<loc>` 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
Expand All @@ -379,8 +394,53 @@ 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 `<loc>` that is not a URL.

The **second** read is at runtime, by `server.mjs`, and it is why one variable
covers both rather than a `CANONICAL_ORIGIN` that would have to stay in step
with it — the same reasoning that keeps `CONVERTER_URL` a single value. On a
host that only passes it as a `--build-arg` it is absent at runtime and the
redirect below is simply off.

**The canonical redirect.** Two origins serving identical pages is duplicate
content, and only one can be the `canonical` the prerendered HTML names, so
`backend/src/common/canonical-redirect.ts` 308s page requests on any other
hostname to `PUBLIC_ORIGIN`. Four things about it are load-bearing:

- **`/api` can never reach it, by placement rather than by a check.** It is
registered after Nest, whose `setGlobalPrefix('/api')` scopes the not-found
router to `/api`, so every `/api/*` request is answered first — Render's
`/api/health` probe included. `routing-contract.e2e-spec.ts` pins that.
- **It runs before the Angular handler**, deliberately, so it also covers the
static files that skip Angular's host check.
- **It changes what an unknown host sees**: a 308 here instead of Angular's
400. The two guards compose. Not an open redirect — the target is always
built from `PUBLIC_ORIGIN`, never from the request.
- **GET/HEAD only, and never loopback**, or `node server.mjs` locally would
bounce to production.

**It refuses to run when `PUBLIC_ORIGIN`'s host is not in `NG_ALLOWED_HOSTS`**,
and that guard is the reason the two variables can be changed independently.
Without it, moving `PUBLIC_ORIGIN` to a host the allowlist does not cover makes
the alias 308 to a host Angular answers 400 for — **every page dead**, while
`/api/health` still returns 200 so Render reports a healthy deploy and never
rolls back. Refusing degrades that to "both hosts keep serving", and the service
log names both values.

`isHostAllowed`/`parseAllowedHosts` duplicate Angular's matcher, which is safe in
the only direction that matters: the check can *only* disable a redirect. Drift
that wrongly says "allowed" leaves the behaviour it would have had anyway; drift
that wrongly says "not allowed" still serves every host. Angular's list stays the
authority on what is served — this decides only whether to redirect.

It lives under `backend/` because that is the only workspace with a test runner
that can reach it; `server.mjs` imports it from `dist` exactly as it already
imports `createNestApp`.

**`pnpm run verify:deploy <url>`** checks the deployed result. Local green does
not mean deployed correct.
not mean deployed correct. It also asserts the **stamped origin matches the URL
being verified** — a missing sentinel only proves something was substituted, not
that the right thing was, and attaching the custom domain made every page it
served name the old origin — and it recognises an **alias** origin, reporting
the redirect and skipping the page checks that belong to the canonical one.

**The service worker must never cache `/api`.** `ngsw-config.json` has no
`dataGroups` at all, deliberately: a cached response would show stale money and
Expand Down Expand Up @@ -420,7 +480,8 @@ Getting them confused is how the UI briefly offered an Approve button that alway
and the rate lock — no controller, deliberately), `budgets/`, `reports/` (polled job for
the cancellation demo), `tool-calls/` (audit log), `orgs/`, `config/`
(`GET /api/config` serves the Gemini model list so it is editable without a
rebuild), `supabase/` (client + repository seam), `common/` (rate limiting).
rebuild), `supabase/` (client + repository seam), `common/` (rate limiting, and the
canonical-host redirect `server.mjs` mounts).
Migrations live in `supabase/migrations/`. Seed users: `priya@actuo.demo`
(owner), `arjun@actuo.demo` (member), password `Demo1234!`.

Expand Down
57 changes: 23 additions & 34 deletions Progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Tracks every feature in the PRD against what is actually in the codebase.

**Last audited:** 2026-09-03 · **Baseline:** 12 shared · 93 backend unit · 34 backend e2e · 803 frontend
**Last audited:** 2026-09-03 · **Baseline:** 12 shared · 114 backend unit · 34 backend e2e · 803 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
Expand Down Expand Up @@ -216,7 +216,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 `<script>` | 3 | ⬜ | It is an Angular component inside the app shell |
| **Cross-origin tool use** | 0 | ✅ | The converter is framed from `CONVERTER_URL` with `allow="tools"`, and `ConverterSession` owns discovery for all four surfaces. The synthetic partner page and its :4201 server are gone: dev and production now frame the same independently deployed converter, so there is no cross-origin path that is only exercised in one of them. **Verified 2026-09-03 in Chrome 151 with the flag**, framing the deployed converter from `localhost:4200`: all seven of its tools discovered over a real origin boundary, badges correct (4 read-only / 3 mutating), and `executeTool(convertCurrency, {amount:200,from:'EUR',to:'INR'})` returned `200 EUR = 22,018.00 INR` with the embedded widget moving to match. Not yet run from a *deployed* Actuo — see the rough edges |
| **Cross-origin tool use** | 0 | ✅ | The converter is framed from `CONVERTER_URL` with `allow="tools"`, and `ConverterSession` owns discovery for all four surfaces. The synthetic partner page and its :4201 server are gone: dev and production now frame the same independently deployed converter, so there is no cross-origin path that is only exercised in one of them. **Verified 2026-09-03 in Chrome 151 with the flag**, first from `localhost:4200`all seven of its tools discovered over a real origin boundary, badges correct (4 read-only / 3 mutating), and `executeTool(convertCurrency, {amount:200,from:'EUR',to:'INR'})` returning `200 EUR = 22,018.00 INR` with the embedded widget moving to match — and then **from the deployed Actuo at `/agent`**, which closes the last gap: two genuinely public origins, neither serving the other |

## §6.9 Admin & Settings — 🟡

Expand All @@ -238,7 +238,7 @@ Cross-origin is live as of 2026-08-29; only the standalone-script packaging (Pha
| JSON Schema inputs | ✅ | One definition in `shared/src/tools.ts`, used by client and server |
| **Dynamic / state-gated tools** | ✅ | The shell polls on sign-in and after every mutating call. Verified live: `approve_expense` present in `getTools()` as owner with 3 pending, absent as member, and every tool retired on sign-out |
| Cancellation (`AbortSignal`) | ✅ | Client aborts, polls stop, server abandons the job mid-fetch and mid-format |
| 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 |
| 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, and it is proven from the deploy itself, not only from localhost |
| 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 `Copilot.crossOriginTools` and the registry's `invocationLog()`. Still read-only: there is no form to invoke a tool by hand with arbitrary arguments |
Expand Down Expand Up @@ -287,13 +287,13 @@ and the offline banner appearing and clearing on the network events.
| Unit tests for tool `execute()` | ✅ | — |
| Structured logging / error tracking | ⬜ | Nest logger only; no Sentry-tier reporting |
| **CI** | ✅ | `.github/workflows/ci.yml` runs the full Definition of Done gate on push and PR, and **has run on GitHub** — five successful runs, most recently on PR #4 |
| Single-process deploy | ✅ | `server.mjs`; the routing contract it depends on is pinned by `routing-contract.e2e-spec.ts` |
| Single-process deploy | ✅ | `server.mjs`; the routing contract it depends on is pinned by `routing-contract.e2e-spec.ts`. It also mounts `common/canonical-redirect.ts`, which 308s pages on any non-canonical hostname — placed after Nest so `/api` can never reach it, and before the Angular handler so it also covers the static files that skip Angular's host check. It self-disables when `PUBLIC_ORIGIN`'s host is not in `NG_ALLOWED_HOSTS`, because that combination would 308 the alias to a host Angular 400s: every page down behind a health check that still returns 200 |

## §12 Submission criteria — 🟡

| Item | Status | Notes |
|---|---|---|
| **Public deployed URL** | ✅ | **Live and correct at `https://actuo.onrender.com`** — `pnpm run verify:deploy https://actuo.onrender.com` passes all six checks as of 2026-09-03: `/api/health` 200, `/` server-rendered (`ng-server-context` present), `/`, `sitemap.xml` and `robots.txt` all stamped, and `/api/config` reporting the converter. `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*). The two earlier defects — CSR fallback and a literal `__PUBLIC_ORIGIN__` — are both gone |
| **Public deployed URL** | ✅ | **Live at `https://actuo.programmersingh.dev`**, with `https://actuo.onrender.com` kept as an alias that 308s to it. `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*). Attaching the custom domain broke it in the documented way — every page 400'd on the new host until it was added to `NG_ALLOWED_HOSTS` — and in one that was **not** caught by any check: the SEO stamp is baked at build time, so the new domain served a sitemap, `canonical` and `og:image` all naming the old origin. `verify:deploy` now asserts the stamped origin matches the URL it is checking |
| 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 last box left. The script is the "What to look at" list in `README.md`, and the SSR fix it was waiting on has landed — the deployed site server-renders, so a recording made now records the real thing |
| Source with clear tool definitions | ✅ | `shared/src/tools.ts` |
Expand All @@ -302,43 +302,31 @@ and the offline banner appearing and clearing on the network events.

## What to fix next

Every Phase 0 row is green, the deploy is live *and* correct, and real FX
landed on 2026-09-03. What is left is the rest of Phase 1, and the video.

1. **Prove cross-origin from the deployed site.** `CONVERTER_URL` is set and
`/api/config` serves it, so this is very likely already working and merely
unverified. Open `https://actuo.onrender.com/agent` in flag-enabled Chrome
151, confirm Cambiaro's seven tools are discovered across two public origins,
and ask the Copilot to convert €80. Ten minutes, and it turns §7's
cross-origin row from locally-proven into deploy-proven.
2. **Demo video** — the last §12 checkbox. The script is the "What to look at"
list in `README.md`.
3. **Budgets depth** — `POST /budgets` inserts and there is no PATCH, so a
budget can be set once and not changed; the form hides categories that
already have one rather than offering a guaranteed 409. Threshold alerts
(80%) and rollover-vs-reset are the other two §6.3 rows, and `budgets.spec.ts`
already guards the rollover checkbox against returning without its behaviour.
4. **`/api/analytics/*`** — no controller; the dashboard derives everything
Every Phase 0 row is green, real FX landed on 2026-09-03, the deploy is live on
its own domain, and cross-origin is proven from that deploy. What is left is the
rest of Phase 1, and the video.

1. **Budgets depth** — three open §6.3 rows, one surface. `POST /budgets`
inserts and there is no PATCH, so a budget can be set once and not changed;
the form hides categories that already have one rather than offering a
guaranteed 409. Threshold alerts (80%) and rollover-vs-reset are the other
two, and `budgets.spec.ts` already guards the rollover checkbox against
returning without its behaviour.
2. **`/api/analytics/*`** — no controller; the dashboard derives everything
client-side. Standalone spend-by-category and a month-over-month delta tile
are the visible half.
5. **Recurring expenses** — `recurring_templates` is in PRD §8.7 and **absent
3. **Recurring expenses** — `recurring_templates` is in PRD §8.7 and **absent
from the migrations**, so it needs `0004`.
6. **Org invites** — the last Phase 1 row, and the only one needing an external
4. **Org invites** — the last Phase 1 row, and the only one needing an external
service (Resend, plus a `sync: false` secret in `render.yaml`).
7. **Everything else is Phase 2–3**: receipt OCR, notifications, multi-step
5. **Demo video** — the last §12 checkbox. The script is the "What to look at"
list in `README.md`.
6. **Everything else is Phase 2–3**: receipt OCR, notifications, multi-step
approval chains, comment threads, teams, tags, CSV import, PDF export,
session management, and packaging the Copilot as a standalone script.

### Known rough edges, deliberately not fixed here

- **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. The two blockers
are gone — the converter commits are deployed and `CONVERTER_URL` is set, so
`/api/config` now serves `https://cambiaro.programmersingh.dev/`. All that is
left is opening `https://actuo.onrender.com/agent` in flag-enabled Chrome and
watching it work.
- **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
Expand All @@ -351,7 +339,8 @@ landed on 2026-09-03. What is left is the rest of Phase 1, and the video.
- **No CSP header is set anywhere yet.** When one lands it will need `frame-src`
for the converter origin (`CONVERTER_URL`) — one origin now, not two — or
every converter surface breaks silently: an iframe blocked by CSP renders
empty with no error the page can see.
empty with no error the page can see. It also has to be written against the
*canonical* host, since that is the only origin that serves pages now.
- **Brand assets are generated, not designed.** `scripts/generate-brand-assets.mjs`
produces the icons and og card from the palette with ImageMagick. They are
clean but plain, and regenerating after a palette change is manual.
Loading
Loading