x402: @lunora/x402/pay no longer exports createPayFetch, registerWallet, resolveEvmAccount, resolveSvmSigner, buildSpendPolicy, buildPaymentGuard, releaseSpendOnFailure, assertBoundedPolicy, createSpendState, SpendState or WalletDeps; @lunora/x402/charge no longer exports createFacilitatorClient or toReceipt. Use createX402Pay / lazyX402Pay and createChargeMiddleware. SpendPolicy.decimals (already throwing) is removed. X402PayDeps declares getSecret itself. Network and recipient misconfiguration throws LunoraError ENV_INVALID instead of Error. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
feat(browser): verify on workerd and settle the surface Add a LUNORA_WORKERD_TESTS-gated workerd project for plan 463 (B3). It runs createBrowser with the real @cloudflare/playwright launch / connect / sessions against a fake Browser Run behind a real service binding (an entrypoint of the test worker). It asserts what reaches the binding: keep_alive in milliseconds, allowedHosts as normalized guardrails, the session id on connect, the session list, and quickAction arguments across the RPC boundary. It also covers the operation deadline, the DoH re-check and its timeout, and /crawl errors on workerd's timers and fetch. The fake refuses the DevTools upgrade, since no Chrome runs locally, so a live page stays covered only by the Node doubles. Keep all 25 exports, drop their @experimental tags, and export the four types the surface already referenced (BrowserSession, BrowserConnectLike, BrowserSessionsLike, RouteLike). Node tests cover a failing close(). Docs gain a platform-support table and the error reference. The workerd CI matrices gain browser (15 packages). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
chore(api): graduate x402 and browser to stable adapter Move both from TIER_3 to TIER_2 (plan 463, B3), which puts their snapshots in FULLY_TRACKED_SNAPSHOTS: every export signature is now gated by api:check. Regenerate both snapshots, and update the ROADMAP tier lists and table, the versioning page, the package index and plan 463. Bar item 5 (used in anger outside this repo) is not something this change can show; it is left to maintainer sign-off. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(notify): refuse a host whose DoH answer has no address shared/ssrf-resolve classified a DoH answer with no A/AAAA record (an empty answer, SERVFAIL, NXDOMAIN) as "public". A name's nameserver can fail the check and answer the connecting resolver a moment later, so that passed a rebinding attack. It is now a separate "unresolved" verdict, which callers refuse; a lookup that could not complete at all still falls back to the string guard. @lunora/notify refuses an unresolved web-push host at send time and does not cache the verdict, since a SERVFAIL can be transient. A broadcast test that had been resolving push.example over the real network now stubs DoH. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(browser): check redirect hops and guard every handed-out browser Playwright calls a route handler only for the first request of a redirect chain, so the page.route guard never saw a 3xx hop: a public URL could 302 the browser to 10.0.0.5 or the metadata endpoint unchecked. The guard now sits on the context, fetches each navigation itself with route.fetch({ maxRedirects: 0 }), and checks a redirect's Location before anything requests it. A refused target fails the call with FORBIDDEN; an allowed one becomes a fresh, checked navigation (withPage follows main-frame hops itself, up to 20; past that, BROWSER_TOO_MANY_REDIRECTS). Fulfilling the 3xx instead would not work: the hop it triggers is auto-continued by Playwright and never reaches the handler. The browsers launch() and connect() hand to caller code carry the same guard on every context, including the contexts already open in a re-attached session. Browser Run cannot tag a session with an owner, so the docs now state that a session id is a bearer secret. Also: a keepAlive session is closed when its callback throws; allowedHosts entries are compared in punycode; a DoH answer with no address is refused. The Node doubles now model Playwright's rule that a redirect hop never reaches the interceptor (__tests__/_helpers/fake-launch.ts), with a regression test for a public URL 302ing to 10.0.0.5. The workerd suite's DoH and /crawl paths run on workerd's own fetch against a fake internet (miniflare outboundService) instead of a stubbed global, and drops two input-guard tests the Node suite covers.
x402: BrowserContextLike requires route; PageLike drops route and gains an optional mainFrame; RouteLike gains fetch and fulfill and is generic over the response type. Test doubles passed as launch/connect need a context with route; the real @cloudflare/playwright exports are unaffected. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(x402): map facilitator failures to 502 and stop a pre-paid leak @x402/core rethrows a FacilitatorResponseError (malformed or timed-out /verify or /settle answer) out of processHTTPRequest / processSettlement, so it reached the host as an anonymous 500. The charge middleware now throws LunoraError X402_FACILITATOR_ERROR (502) with the handler not run; on settle-after the resource is withheld. Initialisation maps an unreachable facilitator to the same code and a facilitator that does not settle the network (RouteConfigurationError) to ENV_INVALID. A request that already carries PAYMENT-SIGNATURE / X-PAYMENT is now sent as-is. @x402/fetch answered its 402 by reserving against maxPerRun, signing, then throwing "Payment already attempted" with the reservation held for the rest of the run. maxPerRun's docstring said "wallet lifetime"; it is one rail, which for ctx.x402 is one function invocation. WalletDeps and GetSecret are gone: wallet.ts reads getSecret off X402PayDeps. Tests: facilitator failures at verify, settle and init; ENV_INVALID asserted on network and recipient errors; the always-passing stray-decimals test dropped; a workerd case for the pre-paid request. The workerd suites share one facilitator double (_facilitator.ts) and the Solana challenge moved to the smoke suite. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(codegen): gate ctx.x402 on the secrets capability ctx.x402 was classified credential-based (x402: null) and emitted on every target, but the generated rail reads its wallet key through ctx.secrets, which node and celld rate unsupported: raw-key and CDP custody failed on the first payment with no build-time signal. It now maps to the secrets feature, so those targets withhold ctx.x402 with platform_unsupported_feature. Signer custody still works there through createX402Pay called directly. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
docs(x402,browser): correct the guard, error and platform claims browser: the README and docs no longer say the guard re-checks every redirect hop through page.route; they describe the fetch-and-check navigation guard, its cost (documents are fetched by the Worker), the sub-resource redirects it cannot see, session ids as bearer secrets, ports not being restricted by allowedHosts, and DoH answers with no address being refused. x402: the errors section no longer claims every error is a LunoraError or that every payment failure is a 402; it lists X402_FACILITATOR_ERROR and what passes through from @x402/fetch. The platform table follows the codegen secrets gate. maxPerRun is documented as per invocation, with a durable counter checked in onPaymentRequired as the way to a cross-run budget. Also: the from-alpha migration notes cover both packages' breaks, ROADMAP's experimental-heavy example no longer names browser, and plan 463 marks B3 done for code, pending maintainer sign-off on bar item 5. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(notify): refuse a send when the DoH re-check cannot complete shared/ssrf-resolve returned the same "unknown" verdict for an IP-literal host and for a lookup that failed (timeout, network error, non-200, bad body), and callers passed both on the string guard. The name's own nameserver can stall the check on purpose, so that let a rebinding host through. The verdict is now "skipped" for an IP literal and "failed" for a lookup that could not complete; one failed address family is enough, since a stalled A lookup next to a public AAAA answer says nothing about the address the connection will use. @lunora/notify refuses "failed" like "unresolved" (fail closed) and caches neither, so the next send after an outage re-checks. The cost is that a DoH outage stops web-push sends to hosts without allowedPushOrigins; the allowlist, which skips the re-check, remains the way to avoid it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(browser): check sub-resource redirects and lock the handed-out guard Three SSRF gaps from the security review. DNS fail-open: a DoH lookup that failed or timed out fell back to the string guard. It now refuses with FORBIDDEN ("DNS ... could not be verified"). Verdicts are cached per hostname for 30s within one guarded context (failed ones evicted), so a page's hundred assets from one CDN cost one lookup. Sub-resource redirects: a non-navigation request got a string check and route.continue(), so <img src> on a public page could 302 to the metadata endpoint and render into a screenshot. Without allowedHosts, sub-resources now get every guard (DNS included) and are fetched by the guard with route.fetch({ maxRedirects: 0 }); each Location is checked and followed with route.fetch({ url, maxRedirects: 0 }) up to 20 hops, a protocol change is refused (route.fetch cannot follow it), and the final response is fulfilled. WebSockets, which never reach a route handler, are checked through routeWebSocket before they connect. With allowedHosts, Browser Run's session guardrails enforce the list on every request and hop, so requests are checked and left in the browser; this also restores the Tunnel config (an internal host on the allowlist), which Worker-side fetching broke. Guard lifetime: the browser from launch()/connect() is locked by pinning members on the live objects (same instance however reached: page.context(), browser.contexts(), popups via the context's page event). unrouteAll(), unroute() without a handler, routeFromHAR(), routeWebSocket(), newCDPSession() and newBrowserCDPSession() throw FORBIDDEN; caller route handlers keep working but their continue() falls through to the guard and route.fetch() is refused. Found while doing this and fixed: a caller route registered after the guard ran first and could continue() past it. A context another connection opens is withheld from contexts() until its guard lands. This holds against the public Playwright API, not code reaching its private channels; allowedHosts is the hard guarantee, as the docs say. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(browser): follow redirect hops like a browser and block workers Three findings from the security review of aaf603cd6. Credential leak on redirect: route.fetch({ url }) reuses the original request's method, headers and body, so a sub-resource hop to another origin carried Authorization (and any page-set credential header) there, and a POST answered with 302/303 was re-posted. Each hop is now built by browser rules: a 303 of anything but GET/HEAD and a 301/302 of a POST become a body-less GET; a cross-origin hop drops Authorization, Proxy-Authorization and Cookie (the request client re-derives cookies from the jar for the new URL), sends an opaque Origin and cuts Referer back to its origin. Authorization state drift: Playwright's request client already stores every hop's Set-Cookie in the context's jar (context.addCookies) when it fetches. Fulfilling the final response with its Set-Cookie made Chromium store a second copy under its own parsing, so a session cookie could exist twice with diverging attributes. Fulfilled Worker-fetched responses now drop Set-Cookie. Guard bypass: Playwright's route handlers never see a request a service worker handles, so a page's worker could fetch a private host and hand the body back to be rendered. Every context the factory opens, and every one opened from a guarded launch()/connect() browser, is created with serviceWorkers: "block". Other candidates (relative and credentialed Location, data:/blob:, srcdoc and about:blank frames, meta refresh, cache keying, allowPrivateTargets with allowedHosts) checked out as covered. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
chore(api): keep browser experimental pending a live Browser Run check Maintainer decision on plan 463 B3: graduate x402 only. browser moves back to TIER_3 and the experimental lists (ROADMAP, versioning page, README banner) until the live smoke test passes against a real Browser Run binding: the guard now rests on route.fetch inside a real session, and the service-worker, prerender and WebRTC paths are unverified on the real service. Its exports stay untagged and its snapshot joins container's in FULLY_TRACKED_SNAPSHOTS, so a tag cannot creep back while it waits. It keeps its workerd CI leg. The ROADMAP row marks bar item 2 as partial, and plan 463 lists browser as pending live Browser Run verification. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
feat(browser): warn once when a factory runs without allowedHosts allowedHosts, which Browser Run enforces on the session itself, is now the documented production configuration. Without it the SSRF guard is a best-effort check in the Worker, with known limits: speculation-rules prerenders and WebRTC are not routed, the scope of Domain-less cookies stored by the request client is unverified, the handed-out lock holds against the public Playwright API only, and every page asset costs a Worker subrequest. createBrowser() built without allowedHosts (and without allowPrivateTargets, which opts out on purpose) warns once per isolate, pointing at allowedHosts, the same way @lunora/notify warns about a missing allowedPushOrigins. The docs and README gain a "Production posture" section listing those limits. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(advisor): only allowedHosts contains an arg-derived browser URL browser_user_url_without_allowlist (WARN) suppressed its findings when the app's createBrowser pinned resolveDns: true. The DoH re-check is on by default anyway, and without allowedHosts the whole guard is a best-effort Worker-side check, so an args-derived URL reaching ctx.browser under it is exactly what the lint should keep visible. Only allowedHosts, which Browser Run enforces on the session, suppresses it now. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
test(browser): add a live Browser Run smoke Worker, ready to run packages/browser/smoke is a Worker that drives the SSRF guard against a real Browser Run binding, serving its own attacker-like fixtures so nothing third-party is needed. GET /report answers a JSON pass/fail report: - default mode: a guarded screenshot (route.fetch/fulfill inside a real session), an <img> that 302s to 169.254.169.254 failing with BLOCKED_BY_CLIENT, a main-frame redirect to 10.0.0.1 refused, service-worker registration blocked, and a cross-origin hop (to this version's preview URL) arriving without Authorization; - allowedHosts mode: an allowed host loads, an off-list host is refused. scripts/browser-smoke.sh deploys it with wrangler, curls /report and exits non-zero on any failure. It was not run: it needs a Workers Paid account with Browser Run. A wrangler --dry-run bundle succeeds. The smoke source is typechecked and linted with the package (tsconfig include) but is not published (files: dist) or built (packem bundles src/index.ts only). Teardown: wrangler delete --config smoke/wrangler.jsonc. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(errors): register the x402 facilitator and browser redirect codes X402_FACILITATOR_ERROR (internal, 502) and BROWSER_TOO_MANY_REDIRECTS (502) were minted without catalog entries, which the catalog-registration test rejects. Also drops a leftover diff3 marker from the rebase in the upgrade guide. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
Added
x402: graduate to stable adapter; harden browser, keep it experimental (plan 463 B3) (#1031) (0b854d3)
codegen,cli: references to a function in a folder use the nested path (api.<folder>.<file>.<fn>). An export named like a file in the same-named folder (billing.ts exporting invoices beside billing/invoices.ts) is now a codegen error.
codegen,cli: import internal from ./_generated/internal instead of ./_generated/api. anyApi and AnyApi are removed from @lunora/server and @lunora/client; build a reference by hand as { __lunoraRef: "<namespace>:<fn>" }. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(advisor): match privileged dispatches by dispatch namespace privileged_dispatch_unvalidated_payload compared the target file read off an api.* path with the procedure's real file path, so a target in lunora/<dir>/index.ts or a hyphenated folder never matched and an RLS bypass through it went unflagged. Both sides now compare as dispatch namespaces. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
docs(roadmap): list nested api and the deploy architecture diff Also sorts namespace members inside the codegen tree, so every emitter gets the same alphabetical order without pre-sorting (no generated output changes). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(playground): sort split imports and read internal from internal.ts The codemod that moved internal to _generated/internal left two import blocks unsorted, and the playground smoke test still looked for InternalApiTypes inside api.ts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(cli): keep a skipped codegen run from clearing the architecture baseline With --skip-codegen there is no manifest, which the last-module handling read as "every module removed" and recorded an empty baseline. The diff now runs only when codegen did. Also update the scheduler skill's fnRef rule for nested paths and correct an advisor comment. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
codegen,cli: references to a function in a folder use the nested path (api.<folder>.<file>.<fn>). An export named like a file in the same-named folder (billing.ts exporting invoices beside billing/invoices.ts) is now a codegen error.
codegen,cli: import internal from ./_generated/internal instead of ./_generated/api. anyApi and AnyApi are removed from @lunora/server and @lunora/client; build a reference by hand as { __lunoraRef: "<namespace>:<fn>" }. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(advisor): match privileged dispatches by dispatch namespace privileged_dispatch_unvalidated_payload compared the target file read off an api.* path with the procedure's real file path, so a target in lunora/<dir>/index.ts or a hyphenated folder never matched and an RLS bypass through it went unflagged. Both sides now compare as dispatch namespaces. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
docs(roadmap): list nested api and the deploy architecture diff Also sorts namespace members inside the codegen tree, so every emitter gets the same alphabetical order without pre-sorting (no generated output changes). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(playground): sort split imports and read internal from internal.ts The codemod that moved internal to _generated/internal left two import blocks unsorted, and the playground smoke test still looked for InternalApiTypes inside api.ts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(cli): keep a skipped codegen run from clearing the architecture baseline With --skip-codegen there is no manifest, which the last-module handling read as "every module removed" and recorded an empty baseline. The diff now runs only when codegen did. Also update the scheduler skill's fnRef rule for nested paths and correct an advisor comment. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
x402: @lunora/x402/pay no longer exports createPayFetch, registerWallet, resolveEvmAccount, resolveSvmSigner, buildSpendPolicy, buildPaymentGuard, releaseSpendOnFailure, assertBoundedPolicy, createSpendState, SpendState or WalletDeps; @lunora/x402/charge no longer exports createFacilitatorClient or toReceipt. Use createX402Pay / lazyX402Pay and createChargeMiddleware. SpendPolicy.decimals (already throwing) is removed. X402PayDeps declares getSecret itself. Network and recipient misconfiguration throws LunoraError ENV_INVALID instead of Error. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
feat(browser): verify on workerd and settle the surface Add a LUNORA_WORKERD_TESTS-gated workerd project for plan 463 (B3). It runs createBrowser with the real @cloudflare/playwright launch / connect / sessions against a fake Browser Run behind a real service binding (an entrypoint of the test worker). It asserts what reaches the binding: keep_alive in milliseconds, allowedHosts as normalized guardrails, the session id on connect, the session list, and quickAction arguments across the RPC boundary. It also covers the operation deadline, the DoH re-check and its timeout, and /crawl errors on workerd's timers and fetch. The fake refuses the DevTools upgrade, since no Chrome runs locally, so a live page stays covered only by the Node doubles. Keep all 25 exports, drop their @experimental tags, and export the four types the surface already referenced (BrowserSession, BrowserConnectLike, BrowserSessionsLike, RouteLike). Node tests cover a failing close(). Docs gain a platform-support table and the error reference. The workerd CI matrices gain browser (15 packages). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
chore(api): graduate x402 and browser to stable adapter Move both from TIER_3 to TIER_2 (plan 463, B3), which puts their snapshots in FULLY_TRACKED_SNAPSHOTS: every export signature is now gated by api:check. Regenerate both snapshots, and update the ROADMAP tier lists and table, the versioning page, the package index and plan 463. Bar item 5 (used in anger outside this repo) is not something this change can show; it is left to maintainer sign-off. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(notify): refuse a host whose DoH answer has no address shared/ssrf-resolve classified a DoH answer with no A/AAAA record (an empty answer, SERVFAIL, NXDOMAIN) as "public". A name's nameserver can fail the check and answer the connecting resolver a moment later, so that passed a rebinding attack. It is now a separate "unresolved" verdict, which callers refuse; a lookup that could not complete at all still falls back to the string guard. @lunora/notify refuses an unresolved web-push host at send time and does not cache the verdict, since a SERVFAIL can be transient. A broadcast test that had been resolving push.example over the real network now stubs DoH. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(browser): check redirect hops and guard every handed-out browser Playwright calls a route handler only for the first request of a redirect chain, so the page.route guard never saw a 3xx hop: a public URL could 302 the browser to 10.0.0.5 or the metadata endpoint unchecked. The guard now sits on the context, fetches each navigation itself with route.fetch({ maxRedirects: 0 }), and checks a redirect's Location before anything requests it. A refused target fails the call with FORBIDDEN; an allowed one becomes a fresh, checked navigation (withPage follows main-frame hops itself, up to 20; past that, BROWSER_TOO_MANY_REDIRECTS). Fulfilling the 3xx instead would not work: the hop it triggers is auto-continued by Playwright and never reaches the handler. The browsers launch() and connect() hand to caller code carry the same guard on every context, including the contexts already open in a re-attached session. Browser Run cannot tag a session with an owner, so the docs now state that a session id is a bearer secret. Also: a keepAlive session is closed when its callback throws; allowedHosts entries are compared in punycode; a DoH answer with no address is refused. The Node doubles now model Playwright's rule that a redirect hop never reaches the interceptor (__tests__/_helpers/fake-launch.ts), with a regression test for a public URL 302ing to 10.0.0.5. The workerd suite's DoH and /crawl paths run on workerd's own fetch against a fake internet (miniflare outboundService) instead of a stubbed global, and drops two input-guard tests the Node suite covers.
x402: BrowserContextLike requires route; PageLike drops route and gains an optional mainFrame; RouteLike gains fetch and fulfill and is generic over the response type. Test doubles passed as launch/connect need a context with route; the real @cloudflare/playwright exports are unaffected. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(x402): map facilitator failures to 502 and stop a pre-paid leak @x402/core rethrows a FacilitatorResponseError (malformed or timed-out /verify or /settle answer) out of processHTTPRequest / processSettlement, so it reached the host as an anonymous 500. The charge middleware now throws LunoraError X402_FACILITATOR_ERROR (502) with the handler not run; on settle-after the resource is withheld. Initialisation maps an unreachable facilitator to the same code and a facilitator that does not settle the network (RouteConfigurationError) to ENV_INVALID. A request that already carries PAYMENT-SIGNATURE / X-PAYMENT is now sent as-is. @x402/fetch answered its 402 by reserving against maxPerRun, signing, then throwing "Payment already attempted" with the reservation held for the rest of the run. maxPerRun's docstring said "wallet lifetime"; it is one rail, which for ctx.x402 is one function invocation. WalletDeps and GetSecret are gone: wallet.ts reads getSecret off X402PayDeps. Tests: facilitator failures at verify, settle and init; ENV_INVALID asserted on network and recipient errors; the always-passing stray-decimals test dropped; a workerd case for the pre-paid request. The workerd suites share one facilitator double (_facilitator.ts) and the Solana challenge moved to the smoke suite. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(codegen): gate ctx.x402 on the secrets capability ctx.x402 was classified credential-based (x402: null) and emitted on every target, but the generated rail reads its wallet key through ctx.secrets, which node and celld rate unsupported: raw-key and CDP custody failed on the first payment with no build-time signal. It now maps to the secrets feature, so those targets withhold ctx.x402 with platform_unsupported_feature. Signer custody still works there through createX402Pay called directly. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
docs(x402,browser): correct the guard, error and platform claims browser: the README and docs no longer say the guard re-checks every redirect hop through page.route; they describe the fetch-and-check navigation guard, its cost (documents are fetched by the Worker), the sub-resource redirects it cannot see, session ids as bearer secrets, ports not being restricted by allowedHosts, and DoH answers with no address being refused. x402: the errors section no longer claims every error is a LunoraError or that every payment failure is a 402; it lists X402_FACILITATOR_ERROR and what passes through from @x402/fetch. The platform table follows the codegen secrets gate. maxPerRun is documented as per invocation, with a durable counter checked in onPaymentRequired as the way to a cross-run budget. Also: the from-alpha migration notes cover both packages' breaks, ROADMAP's experimental-heavy example no longer names browser, and plan 463 marks B3 done for code, pending maintainer sign-off on bar item 5. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(notify): refuse a send when the DoH re-check cannot complete shared/ssrf-resolve returned the same "unknown" verdict for an IP-literal host and for a lookup that failed (timeout, network error, non-200, bad body), and callers passed both on the string guard. The name's own nameserver can stall the check on purpose, so that let a rebinding host through. The verdict is now "skipped" for an IP literal and "failed" for a lookup that could not complete; one failed address family is enough, since a stalled A lookup next to a public AAAA answer says nothing about the address the connection will use. @lunora/notify refuses "failed" like "unresolved" (fail closed) and caches neither, so the next send after an outage re-checks. The cost is that a DoH outage stops web-push sends to hosts without allowedPushOrigins; the allowlist, which skips the re-check, remains the way to avoid it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(browser): check sub-resource redirects and lock the handed-out guard Three SSRF gaps from the security review. DNS fail-open: a DoH lookup that failed or timed out fell back to the string guard. It now refuses with FORBIDDEN ("DNS ... could not be verified"). Verdicts are cached per hostname for 30s within one guarded context (failed ones evicted), so a page's hundred assets from one CDN cost one lookup. Sub-resource redirects: a non-navigation request got a string check and route.continue(), so <img src> on a public page could 302 to the metadata endpoint and render into a screenshot. Without allowedHosts, sub-resources now get every guard (DNS included) and are fetched by the guard with route.fetch({ maxRedirects: 0 }); each Location is checked and followed with route.fetch({ url, maxRedirects: 0 }) up to 20 hops, a protocol change is refused (route.fetch cannot follow it), and the final response is fulfilled. WebSockets, which never reach a route handler, are checked through routeWebSocket before they connect. With allowedHosts, Browser Run's session guardrails enforce the list on every request and hop, so requests are checked and left in the browser; this also restores the Tunnel config (an internal host on the allowlist), which Worker-side fetching broke. Guard lifetime: the browser from launch()/connect() is locked by pinning members on the live objects (same instance however reached: page.context(), browser.contexts(), popups via the context's page event). unrouteAll(), unroute() without a handler, routeFromHAR(), routeWebSocket(), newCDPSession() and newBrowserCDPSession() throw FORBIDDEN; caller route handlers keep working but their continue() falls through to the guard and route.fetch() is refused. Found while doing this and fixed: a caller route registered after the guard ran first and could continue() past it. A context another connection opens is withheld from contexts() until its guard lands. This holds against the public Playwright API, not code reaching its private channels; allowedHosts is the hard guarantee, as the docs say. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(browser): follow redirect hops like a browser and block workers Three findings from the security review of aaf603cd6. Credential leak on redirect: route.fetch({ url }) reuses the original request's method, headers and body, so a sub-resource hop to another origin carried Authorization (and any page-set credential header) there, and a POST answered with 302/303 was re-posted. Each hop is now built by browser rules: a 303 of anything but GET/HEAD and a 301/302 of a POST become a body-less GET; a cross-origin hop drops Authorization, Proxy-Authorization and Cookie (the request client re-derives cookies from the jar for the new URL), sends an opaque Origin and cuts Referer back to its origin. Authorization state drift: Playwright's request client already stores every hop's Set-Cookie in the context's jar (context.addCookies) when it fetches. Fulfilling the final response with its Set-Cookie made Chromium store a second copy under its own parsing, so a session cookie could exist twice with diverging attributes. Fulfilled Worker-fetched responses now drop Set-Cookie. Guard bypass: Playwright's route handlers never see a request a service worker handles, so a page's worker could fetch a private host and hand the body back to be rendered. Every context the factory opens, and every one opened from a guarded launch()/connect() browser, is created with serviceWorkers: "block". Other candidates (relative and credentialed Location, data:/blob:, srcdoc and about:blank frames, meta refresh, cache keying, allowPrivateTargets with allowedHosts) checked out as covered. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
chore(api): keep browser experimental pending a live Browser Run check Maintainer decision on plan 463 B3: graduate x402 only. browser moves back to TIER_3 and the experimental lists (ROADMAP, versioning page, README banner) until the live smoke test passes against a real Browser Run binding: the guard now rests on route.fetch inside a real session, and the service-worker, prerender and WebRTC paths are unverified on the real service. Its exports stay untagged and its snapshot joins container's in FULLY_TRACKED_SNAPSHOTS, so a tag cannot creep back while it waits. It keeps its workerd CI leg. The ROADMAP row marks bar item 2 as partial, and plan 463 lists browser as pending live Browser Run verification. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
feat(browser): warn once when a factory runs without allowedHosts allowedHosts, which Browser Run enforces on the session itself, is now the documented production configuration. Without it the SSRF guard is a best-effort check in the Worker, with known limits: speculation-rules prerenders and WebRTC are not routed, the scope of Domain-less cookies stored by the request client is unverified, the handed-out lock holds against the public Playwright API only, and every page asset costs a Worker subrequest. createBrowser() built without allowedHosts (and without allowPrivateTargets, which opts out on purpose) warns once per isolate, pointing at allowedHosts, the same way @lunora/notify warns about a missing allowedPushOrigins. The docs and README gain a "Production posture" section listing those limits. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(advisor): only allowedHosts contains an arg-derived browser URL browser_user_url_without_allowlist (WARN) suppressed its findings when the app's createBrowser pinned resolveDns: true. The DoH re-check is on by default anyway, and without allowedHosts the whole guard is a best-effort Worker-side check, so an args-derived URL reaching ctx.browser under it is exactly what the lint should keep visible. Only allowedHosts, which Browser Run enforces on the session, suppresses it now. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
test(browser): add a live Browser Run smoke Worker, ready to run packages/browser/smoke is a Worker that drives the SSRF guard against a real Browser Run binding, serving its own attacker-like fixtures so nothing third-party is needed. GET /report answers a JSON pass/fail report: - default mode: a guarded screenshot (route.fetch/fulfill inside a real session), an <img> that 302s to 169.254.169.254 failing with BLOCKED_BY_CLIENT, a main-frame redirect to 10.0.0.1 refused, service-worker registration blocked, and a cross-origin hop (to this version's preview URL) arriving without Authorization; - allowedHosts mode: an allowed host loads, an off-list host is refused. scripts/browser-smoke.sh deploys it with wrangler, curls /report and exits non-zero on any failure. It was not run: it needs a Workers Paid account with Browser Run. A wrangler --dry-run bundle succeeds. The smoke source is typechecked and linted with the package (tsconfig include) but is not published (files: dist) or built (packem bundles src/index.ts only). Teardown: wrangler delete --config smoke/wrangler.jsonc. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(errors): register the x402 facilitator and browser redirect codes X402_FACILITATOR_ERROR (internal, 502) and BROWSER_TOO_MANY_REDIRECTS (502) were minted without catalog entries, which the catalog-registration test rejects. Also drops a leftover diff3 marker from the rebase in the upgrade guide. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
Added
x402: graduate to stable adapter; harden browser, keep it experimental (plan 463 B3) (#1031) (0b854d3)
codegen,cli: references to a function in a folder use the nested path (api.<folder>.<file>.<fn>). An export named like a file in the same-named folder (billing.ts exporting invoices beside billing/invoices.ts) is now a codegen error.
codegen,cli: import internal from ./_generated/internal instead of ./_generated/api. anyApi and AnyApi are removed from @lunora/server and @lunora/client; build a reference by hand as { __lunoraRef: "<namespace>:<fn>" }. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(advisor): match privileged dispatches by dispatch namespace privileged_dispatch_unvalidated_payload compared the target file read off an api.* path with the procedure's real file path, so a target in lunora/<dir>/index.ts or a hyphenated folder never matched and an RLS bypass through it went unflagged. Both sides now compare as dispatch namespaces. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
docs(roadmap): list nested api and the deploy architecture diff Also sorts namespace members inside the codegen tree, so every emitter gets the same alphabetical order without pre-sorting (no generated output changes). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(playground): sort split imports and read internal from internal.ts The codemod that moved internal to _generated/internal left two import blocks unsorted, and the playground smoke test still looked for InternalApiTypes inside api.ts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(cli): keep a skipped codegen run from clearing the architecture baseline With --skip-codegen there is no manifest, which the last-module handling read as "every module removed" and recorded an empty baseline. The diff now runs only when codegen did. Also update the scheduler skill's fnRef rule for nested paths and correct an advisor comment. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
codegen,cli: references to a function in a folder use the nested path (api.<folder>.<file>.<fn>). An export named like a file in the same-named folder (billing.ts exporting invoices beside billing/invoices.ts) is now a codegen error.
codegen,cli: import internal from ./_generated/internal instead of ./_generated/api. anyApi and AnyApi are removed from @lunora/server and @lunora/client; build a reference by hand as { __lunoraRef: "<namespace>:<fn>" }. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(advisor): match privileged dispatches by dispatch namespace privileged_dispatch_unvalidated_payload compared the target file read off an api.* path with the procedure's real file path, so a target in lunora/<dir>/index.ts or a hyphenated folder never matched and an RLS bypass through it went unflagged. Both sides now compare as dispatch namespaces. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
docs(roadmap): list nested api and the deploy architecture diff Also sorts namespace members inside the codegen tree, so every emitter gets the same alphabetical order without pre-sorting (no generated output changes). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(playground): sort split imports and read internal from internal.ts The codemod that moved internal to _generated/internal left two import blocks unsorted, and the playground smoke test still looked for InternalApiTypes inside api.ts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(cli): keep a skipped codegen run from clearing the architecture baseline With --skip-codegen there is no manifest, which the last-module handling read as "every module removed" and recorded an empty baseline. The diff now runs only when codegen did. Also update the scheduler skill's fnRef rule for nested paths and correct an advisor comment. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
x402: @lunora/x402/pay no longer exports createPayFetch, registerWallet, resolveEvmAccount, resolveSvmSigner, buildSpendPolicy, buildPaymentGuard, releaseSpendOnFailure, assertBoundedPolicy, createSpendState, SpendState or WalletDeps; @lunora/x402/charge no longer exports createFacilitatorClient or toReceipt. Use createX402Pay / lazyX402Pay and createChargeMiddleware. SpendPolicy.decimals (already throwing) is removed. X402PayDeps declares getSecret itself. Network and recipient misconfiguration throws LunoraError ENV_INVALID instead of Error. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
feat(browser): verify on workerd and settle the surface Add a LUNORA_WORKERD_TESTS-gated workerd project for plan 463 (B3). It runs createBrowser with the real @cloudflare/playwright launch / connect / sessions against a fake Browser Run behind a real service binding (an entrypoint of the test worker). It asserts what reaches the binding: keep_alive in milliseconds, allowedHosts as normalized guardrails, the session id on connect, the session list, and quickAction arguments across the RPC boundary. It also covers the operation deadline, the DoH re-check and its timeout, and /crawl errors on workerd's timers and fetch. The fake refuses the DevTools upgrade, since no Chrome runs locally, so a live page stays covered only by the Node doubles. Keep all 25 exports, drop their @experimental tags, and export the four types the surface already referenced (BrowserSession, BrowserConnectLike, BrowserSessionsLike, RouteLike). Node tests cover a failing close(). Docs gain a platform-support table and the error reference. The workerd CI matrices gain browser (15 packages). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
chore(api): graduate x402 and browser to stable adapter Move both from TIER_3 to TIER_2 (plan 463, B3), which puts their snapshots in FULLY_TRACKED_SNAPSHOTS: every export signature is now gated by api:check. Regenerate both snapshots, and update the ROADMAP tier lists and table, the versioning page, the package index and plan 463. Bar item 5 (used in anger outside this repo) is not something this change can show; it is left to maintainer sign-off. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(notify): refuse a host whose DoH answer has no address shared/ssrf-resolve classified a DoH answer with no A/AAAA record (an empty answer, SERVFAIL, NXDOMAIN) as "public". A name's nameserver can fail the check and answer the connecting resolver a moment later, so that passed a rebinding attack. It is now a separate "unresolved" verdict, which callers refuse; a lookup that could not complete at all still falls back to the string guard. @lunora/notify refuses an unresolved web-push host at send time and does not cache the verdict, since a SERVFAIL can be transient. A broadcast test that had been resolving push.example over the real network now stubs DoH. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(browser): check redirect hops and guard every handed-out browser Playwright calls a route handler only for the first request of a redirect chain, so the page.route guard never saw a 3xx hop: a public URL could 302 the browser to 10.0.0.5 or the metadata endpoint unchecked. The guard now sits on the context, fetches each navigation itself with route.fetch({ maxRedirects: 0 }), and checks a redirect's Location before anything requests it. A refused target fails the call with FORBIDDEN; an allowed one becomes a fresh, checked navigation (withPage follows main-frame hops itself, up to 20; past that, BROWSER_TOO_MANY_REDIRECTS). Fulfilling the 3xx instead would not work: the hop it triggers is auto-continued by Playwright and never reaches the handler. The browsers launch() and connect() hand to caller code carry the same guard on every context, including the contexts already open in a re-attached session. Browser Run cannot tag a session with an owner, so the docs now state that a session id is a bearer secret. Also: a keepAlive session is closed when its callback throws; allowedHosts entries are compared in punycode; a DoH answer with no address is refused. The Node doubles now model Playwright's rule that a redirect hop never reaches the interceptor (__tests__/_helpers/fake-launch.ts), with a regression test for a public URL 302ing to 10.0.0.5. The workerd suite's DoH and /crawl paths run on workerd's own fetch against a fake internet (miniflare outboundService) instead of a stubbed global, and drops two input-guard tests the Node suite covers.
x402: BrowserContextLike requires route; PageLike drops route and gains an optional mainFrame; RouteLike gains fetch and fulfill and is generic over the response type. Test doubles passed as launch/connect need a context with route; the real @cloudflare/playwright exports are unaffected. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(x402): map facilitator failures to 502 and stop a pre-paid leak @x402/core rethrows a FacilitatorResponseError (malformed or timed-out /verify or /settle answer) out of processHTTPRequest / processSettlement, so it reached the host as an anonymous 500. The charge middleware now throws LunoraError X402_FACILITATOR_ERROR (502) with the handler not run; on settle-after the resource is withheld. Initialisation maps an unreachable facilitator to the same code and a facilitator that does not settle the network (RouteConfigurationError) to ENV_INVALID. A request that already carries PAYMENT-SIGNATURE / X-PAYMENT is now sent as-is. @x402/fetch answered its 402 by reserving against maxPerRun, signing, then throwing "Payment already attempted" with the reservation held for the rest of the run. maxPerRun's docstring said "wallet lifetime"; it is one rail, which for ctx.x402 is one function invocation. WalletDeps and GetSecret are gone: wallet.ts reads getSecret off X402PayDeps. Tests: facilitator failures at verify, settle and init; ENV_INVALID asserted on network and recipient errors; the always-passing stray-decimals test dropped; a workerd case for the pre-paid request. The workerd suites share one facilitator double (_facilitator.ts) and the Solana challenge moved to the smoke suite. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(codegen): gate ctx.x402 on the secrets capability ctx.x402 was classified credential-based (x402: null) and emitted on every target, but the generated rail reads its wallet key through ctx.secrets, which node and celld rate unsupported: raw-key and CDP custody failed on the first payment with no build-time signal. It now maps to the secrets feature, so those targets withhold ctx.x402 with platform_unsupported_feature. Signer custody still works there through createX402Pay called directly. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
docs(x402,browser): correct the guard, error and platform claims browser: the README and docs no longer say the guard re-checks every redirect hop through page.route; they describe the fetch-and-check navigation guard, its cost (documents are fetched by the Worker), the sub-resource redirects it cannot see, session ids as bearer secrets, ports not being restricted by allowedHosts, and DoH answers with no address being refused. x402: the errors section no longer claims every error is a LunoraError or that every payment failure is a 402; it lists X402_FACILITATOR_ERROR and what passes through from @x402/fetch. The platform table follows the codegen secrets gate. maxPerRun is documented as per invocation, with a durable counter checked in onPaymentRequired as the way to a cross-run budget. Also: the from-alpha migration notes cover both packages' breaks, ROADMAP's experimental-heavy example no longer names browser, and plan 463 marks B3 done for code, pending maintainer sign-off on bar item 5. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(notify): refuse a send when the DoH re-check cannot complete shared/ssrf-resolve returned the same "unknown" verdict for an IP-literal host and for a lookup that failed (timeout, network error, non-200, bad body), and callers passed both on the string guard. The name's own nameserver can stall the check on purpose, so that let a rebinding host through. The verdict is now "skipped" for an IP literal and "failed" for a lookup that could not complete; one failed address family is enough, since a stalled A lookup next to a public AAAA answer says nothing about the address the connection will use. @lunora/notify refuses "failed" like "unresolved" (fail closed) and caches neither, so the next send after an outage re-checks. The cost is that a DoH outage stops web-push sends to hosts without allowedPushOrigins; the allowlist, which skips the re-check, remains the way to avoid it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(browser): check sub-resource redirects and lock the handed-out guard Three SSRF gaps from the security review. DNS fail-open: a DoH lookup that failed or timed out fell back to the string guard. It now refuses with FORBIDDEN ("DNS ... could not be verified"). Verdicts are cached per hostname for 30s within one guarded context (failed ones evicted), so a page's hundred assets from one CDN cost one lookup. Sub-resource redirects: a non-navigation request got a string check and route.continue(), so <img src> on a public page could 302 to the metadata endpoint and render into a screenshot. Without allowedHosts, sub-resources now get every guard (DNS included) and are fetched by the guard with route.fetch({ maxRedirects: 0 }); each Location is checked and followed with route.fetch({ url, maxRedirects: 0 }) up to 20 hops, a protocol change is refused (route.fetch cannot follow it), and the final response is fulfilled. WebSockets, which never reach a route handler, are checked through routeWebSocket before they connect. With allowedHosts, Browser Run's session guardrails enforce the list on every request and hop, so requests are checked and left in the browser; this also restores the Tunnel config (an internal host on the allowlist), which Worker-side fetching broke. Guard lifetime: the browser from launch()/connect() is locked by pinning members on the live objects (same instance however reached: page.context(), browser.contexts(), popups via the context's page event). unrouteAll(), unroute() without a handler, routeFromHAR(), routeWebSocket(), newCDPSession() and newBrowserCDPSession() throw FORBIDDEN; caller route handlers keep working but their continue() falls through to the guard and route.fetch() is refused. Found while doing this and fixed: a caller route registered after the guard ran first and could continue() past it. A context another connection opens is withheld from contexts() until its guard lands. This holds against the public Playwright API, not code reaching its private channels; allowedHosts is the hard guarantee, as the docs say. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(browser): follow redirect hops like a browser and block workers Three findings from the security review of aaf603cd6. Credential leak on redirect: route.fetch({ url }) reuses the original request's method, headers and body, so a sub-resource hop to another origin carried Authorization (and any page-set credential header) there, and a POST answered with 302/303 was re-posted. Each hop is now built by browser rules: a 303 of anything but GET/HEAD and a 301/302 of a POST become a body-less GET; a cross-origin hop drops Authorization, Proxy-Authorization and Cookie (the request client re-derives cookies from the jar for the new URL), sends an opaque Origin and cuts Referer back to its origin. Authorization state drift: Playwright's request client already stores every hop's Set-Cookie in the context's jar (context.addCookies) when it fetches. Fulfilling the final response with its Set-Cookie made Chromium store a second copy under its own parsing, so a session cookie could exist twice with diverging attributes. Fulfilled Worker-fetched responses now drop Set-Cookie. Guard bypass: Playwright's route handlers never see a request a service worker handles, so a page's worker could fetch a private host and hand the body back to be rendered. Every context the factory opens, and every one opened from a guarded launch()/connect() browser, is created with serviceWorkers: "block". Other candidates (relative and credentialed Location, data:/blob:, srcdoc and about:blank frames, meta refresh, cache keying, allowPrivateTargets with allowedHosts) checked out as covered. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
chore(api): keep browser experimental pending a live Browser Run check Maintainer decision on plan 463 B3: graduate x402 only. browser moves back to TIER_3 and the experimental lists (ROADMAP, versioning page, README banner) until the live smoke test passes against a real Browser Run binding: the guard now rests on route.fetch inside a real session, and the service-worker, prerender and WebRTC paths are unverified on the real service. Its exports stay untagged and its snapshot joins container's in FULLY_TRACKED_SNAPSHOTS, so a tag cannot creep back while it waits. It keeps its workerd CI leg. The ROADMAP row marks bar item 2 as partial, and plan 463 lists browser as pending live Browser Run verification. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
feat(browser): warn once when a factory runs without allowedHosts allowedHosts, which Browser Run enforces on the session itself, is now the documented production configuration. Without it the SSRF guard is a best-effort check in the Worker, with known limits: speculation-rules prerenders and WebRTC are not routed, the scope of Domain-less cookies stored by the request client is unverified, the handed-out lock holds against the public Playwright API only, and every page asset costs a Worker subrequest. createBrowser() built without allowedHosts (and without allowPrivateTargets, which opts out on purpose) warns once per isolate, pointing at allowedHosts, the same way @lunora/notify warns about a missing allowedPushOrigins. The docs and README gain a "Production posture" section listing those limits. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(advisor): only allowedHosts contains an arg-derived browser URL browser_user_url_without_allowlist (WARN) suppressed its findings when the app's createBrowser pinned resolveDns: true. The DoH re-check is on by default anyway, and without allowedHosts the whole guard is a best-effort Worker-side check, so an args-derived URL reaching ctx.browser under it is exactly what the lint should keep visible. Only allowedHosts, which Browser Run enforces on the session, suppresses it now. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
test(browser): add a live Browser Run smoke Worker, ready to run packages/browser/smoke is a Worker that drives the SSRF guard against a real Browser Run binding, serving its own attacker-like fixtures so nothing third-party is needed. GET /report answers a JSON pass/fail report: - default mode: a guarded screenshot (route.fetch/fulfill inside a real session), an <img> that 302s to 169.254.169.254 failing with BLOCKED_BY_CLIENT, a main-frame redirect to 10.0.0.1 refused, service-worker registration blocked, and a cross-origin hop (to this version's preview URL) arriving without Authorization; - allowedHosts mode: an allowed host loads, an off-list host is refused. scripts/browser-smoke.sh deploys it with wrangler, curls /report and exits non-zero on any failure. It was not run: it needs a Workers Paid account with Browser Run. A wrangler --dry-run bundle succeeds. The smoke source is typechecked and linted with the package (tsconfig include) but is not published (files: dist) or built (packem bundles src/index.ts only). Teardown: wrangler delete --config smoke/wrangler.jsonc. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(errors): register the x402 facilitator and browser redirect codes X402_FACILITATOR_ERROR (internal, 502) and BROWSER_TOO_MANY_REDIRECTS (502) were minted without catalog entries, which the catalog-registration test rejects. Also drops a leftover diff3 marker from the rebase in the upgrade guide. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
Added
x402: graduate to stable adapter; harden browser, keep it experimental (plan 463 B3) (#1031) (0b854d3)
codegen,cli: references to a function in a folder use the nested path (api.<folder>.<file>.<fn>). An export named like a file in the same-named folder (billing.ts exporting invoices beside billing/invoices.ts) is now a codegen error.
codegen,cli: import internal from ./_generated/internal instead of ./_generated/api. anyApi and AnyApi are removed from @lunora/server and @lunora/client; build a reference by hand as { __lunoraRef: "<namespace>:<fn>" }. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(advisor): match privileged dispatches by dispatch namespace privileged_dispatch_unvalidated_payload compared the target file read off an api.* path with the procedure's real file path, so a target in lunora/<dir>/index.ts or a hyphenated folder never matched and an RLS bypass through it went unflagged. Both sides now compare as dispatch namespaces. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
docs(roadmap): list nested api and the deploy architecture diff Also sorts namespace members inside the codegen tree, so every emitter gets the same alphabetical order without pre-sorting (no generated output changes). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(playground): sort split imports and read internal from internal.ts The codemod that moved internal to _generated/internal left two import blocks unsorted, and the playground smoke test still looked for InternalApiTypes inside api.ts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(cli): keep a skipped codegen run from clearing the architecture baseline With --skip-codegen there is no manifest, which the last-module handling read as "every module removed" and recorded an empty baseline. The diff now runs only when codegen did. Also update the scheduler skill's fnRef rule for nested paths and correct an advisor comment. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
x402: @lunora/x402/pay no longer exports createPayFetch, registerWallet, resolveEvmAccount, resolveSvmSigner, buildSpendPolicy, buildPaymentGuard, releaseSpendOnFailure, assertBoundedPolicy, createSpendState, SpendState or WalletDeps; @lunora/x402/charge no longer exports createFacilitatorClient or toReceipt. Use createX402Pay / lazyX402Pay and createChargeMiddleware. SpendPolicy.decimals (already throwing) is removed. X402PayDeps declares getSecret itself. Network and recipient misconfiguration throws LunoraError ENV_INVALID instead of Error. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
feat(browser): verify on workerd and settle the surface Add a LUNORA_WORKERD_TESTS-gated workerd project for plan 463 (B3). It runs createBrowser with the real @cloudflare/playwright launch / connect / sessions against a fake Browser Run behind a real service binding (an entrypoint of the test worker). It asserts what reaches the binding: keep_alive in milliseconds, allowedHosts as normalized guardrails, the session id on connect, the session list, and quickAction arguments across the RPC boundary. It also covers the operation deadline, the DoH re-check and its timeout, and /crawl errors on workerd's timers and fetch. The fake refuses the DevTools upgrade, since no Chrome runs locally, so a live page stays covered only by the Node doubles. Keep all 25 exports, drop their @experimental tags, and export the four types the surface already referenced (BrowserSession, BrowserConnectLike, BrowserSessionsLike, RouteLike). Node tests cover a failing close(). Docs gain a platform-support table and the error reference. The workerd CI matrices gain browser (15 packages). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
chore(api): graduate x402 and browser to stable adapter Move both from TIER_3 to TIER_2 (plan 463, B3), which puts their snapshots in FULLY_TRACKED_SNAPSHOTS: every export signature is now gated by api:check. Regenerate both snapshots, and update the ROADMAP tier lists and table, the versioning page, the package index and plan 463. Bar item 5 (used in anger outside this repo) is not something this change can show; it is left to maintainer sign-off. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(notify): refuse a host whose DoH answer has no address shared/ssrf-resolve classified a DoH answer with no A/AAAA record (an empty answer, SERVFAIL, NXDOMAIN) as "public". A name's nameserver can fail the check and answer the connecting resolver a moment later, so that passed a rebinding attack. It is now a separate "unresolved" verdict, which callers refuse; a lookup that could not complete at all still falls back to the string guard. @lunora/notify refuses an unresolved web-push host at send time and does not cache the verdict, since a SERVFAIL can be transient. A broadcast test that had been resolving push.example over the real network now stubs DoH. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(browser): check redirect hops and guard every handed-out browser Playwright calls a route handler only for the first request of a redirect chain, so the page.route guard never saw a 3xx hop: a public URL could 302 the browser to 10.0.0.5 or the metadata endpoint unchecked. The guard now sits on the context, fetches each navigation itself with route.fetch({ maxRedirects: 0 }), and checks a redirect's Location before anything requests it. A refused target fails the call with FORBIDDEN; an allowed one becomes a fresh, checked navigation (withPage follows main-frame hops itself, up to 20; past that, BROWSER_TOO_MANY_REDIRECTS). Fulfilling the 3xx instead would not work: the hop it triggers is auto-continued by Playwright and never reaches the handler. The browsers launch() and connect() hand to caller code carry the same guard on every context, including the contexts already open in a re-attached session. Browser Run cannot tag a session with an owner, so the docs now state that a session id is a bearer secret. Also: a keepAlive session is closed when its callback throws; allowedHosts entries are compared in punycode; a DoH answer with no address is refused. The Node doubles now model Playwright's rule that a redirect hop never reaches the interceptor (__tests__/_helpers/fake-launch.ts), with a regression test for a public URL 302ing to 10.0.0.5. The workerd suite's DoH and /crawl paths run on workerd's own fetch against a fake internet (miniflare outboundService) instead of a stubbed global, and drops two input-guard tests the Node suite covers.
x402: BrowserContextLike requires route; PageLike drops route and gains an optional mainFrame; RouteLike gains fetch and fulfill and is generic over the response type. Test doubles passed as launch/connect need a context with route; the real @cloudflare/playwright exports are unaffected. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(x402): map facilitator failures to 502 and stop a pre-paid leak @x402/core rethrows a FacilitatorResponseError (malformed or timed-out /verify or /settle answer) out of processHTTPRequest / processSettlement, so it reached the host as an anonymous 500. The charge middleware now throws LunoraError X402_FACILITATOR_ERROR (502) with the handler not run; on settle-after the resource is withheld. Initialisation maps an unreachable facilitator to the same code and a facilitator that does not settle the network (RouteConfigurationError) to ENV_INVALID. A request that already carries PAYMENT-SIGNATURE / X-PAYMENT is now sent as-is. @x402/fetch answered its 402 by reserving against maxPerRun, signing, then throwing "Payment already attempted" with the reservation held for the rest of the run. maxPerRun's docstring said "wallet lifetime"; it is one rail, which for ctx.x402 is one function invocation. WalletDeps and GetSecret are gone: wallet.ts reads getSecret off X402PayDeps. Tests: facilitator failures at verify, settle and init; ENV_INVALID asserted on network and recipient errors; the always-passing stray-decimals test dropped; a workerd case for the pre-paid request. The workerd suites share one facilitator double (_facilitator.ts) and the Solana challenge moved to the smoke suite. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(codegen): gate ctx.x402 on the secrets capability ctx.x402 was classified credential-based (x402: null) and emitted on every target, but the generated rail reads its wallet key through ctx.secrets, which node and celld rate unsupported: raw-key and CDP custody failed on the first payment with no build-time signal. It now maps to the secrets feature, so those targets withhold ctx.x402 with platform_unsupported_feature. Signer custody still works there through createX402Pay called directly. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
docs(x402,browser): correct the guard, error and platform claims browser: the README and docs no longer say the guard re-checks every redirect hop through page.route; they describe the fetch-and-check navigation guard, its cost (documents are fetched by the Worker), the sub-resource redirects it cannot see, session ids as bearer secrets, ports not being restricted by allowedHosts, and DoH answers with no address being refused. x402: the errors section no longer claims every error is a LunoraError or that every payment failure is a 402; it lists X402_FACILITATOR_ERROR and what passes through from @x402/fetch. The platform table follows the codegen secrets gate. maxPerRun is documented as per invocation, with a durable counter checked in onPaymentRequired as the way to a cross-run budget. Also: the from-alpha migration notes cover both packages' breaks, ROADMAP's experimental-heavy example no longer names browser, and plan 463 marks B3 done for code, pending maintainer sign-off on bar item 5. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(notify): refuse a send when the DoH re-check cannot complete shared/ssrf-resolve returned the same "unknown" verdict for an IP-literal host and for a lookup that failed (timeout, network error, non-200, bad body), and callers passed both on the string guard. The name's own nameserver can stall the check on purpose, so that let a rebinding host through. The verdict is now "skipped" for an IP literal and "failed" for a lookup that could not complete; one failed address family is enough, since a stalled A lookup next to a public AAAA answer says nothing about the address the connection will use. @lunora/notify refuses "failed" like "unresolved" (fail closed) and caches neither, so the next send after an outage re-checks. The cost is that a DoH outage stops web-push sends to hosts without allowedPushOrigins; the allowlist, which skips the re-check, remains the way to avoid it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(browser): check sub-resource redirects and lock the handed-out guard Three SSRF gaps from the security review. DNS fail-open: a DoH lookup that failed or timed out fell back to the string guard. It now refuses with FORBIDDEN ("DNS ... could not be verified"). Verdicts are cached per hostname for 30s within one guarded context (failed ones evicted), so a page's hundred assets from one CDN cost one lookup. Sub-resource redirects: a non-navigation request got a string check and route.continue(), so <img src> on a public page could 302 to the metadata endpoint and render into a screenshot. Without allowedHosts, sub-resources now get every guard (DNS included) and are fetched by the guard with route.fetch({ maxRedirects: 0 }); each Location is checked and followed with route.fetch({ url, maxRedirects: 0 }) up to 20 hops, a protocol change is refused (route.fetch cannot follow it), and the final response is fulfilled. WebSockets, which never reach a route handler, are checked through routeWebSocket before they connect. With allowedHosts, Browser Run's session guardrails enforce the list on every request and hop, so requests are checked and left in the browser; this also restores the Tunnel config (an internal host on the allowlist), which Worker-side fetching broke. Guard lifetime: the browser from launch()/connect() is locked by pinning members on the live objects (same instance however reached: page.context(), browser.contexts(), popups via the context's page event). unrouteAll(), unroute() without a handler, routeFromHAR(), routeWebSocket(), newCDPSession() and newBrowserCDPSession() throw FORBIDDEN; caller route handlers keep working but their continue() falls through to the guard and route.fetch() is refused. Found while doing this and fixed: a caller route registered after the guard ran first and could continue() past it. A context another connection opens is withheld from contexts() until its guard lands. This holds against the public Playwright API, not code reaching its private channels; allowedHosts is the hard guarantee, as the docs say. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(browser): follow redirect hops like a browser and block workers Three findings from the security review of aaf603cd6. Credential leak on redirect: route.fetch({ url }) reuses the original request's method, headers and body, so a sub-resource hop to another origin carried Authorization (and any page-set credential header) there, and a POST answered with 302/303 was re-posted. Each hop is now built by browser rules: a 303 of anything but GET/HEAD and a 301/302 of a POST become a body-less GET; a cross-origin hop drops Authorization, Proxy-Authorization and Cookie (the request client re-derives cookies from the jar for the new URL), sends an opaque Origin and cuts Referer back to its origin. Authorization state drift: Playwright's request client already stores every hop's Set-Cookie in the context's jar (context.addCookies) when it fetches. Fulfilling the final response with its Set-Cookie made Chromium store a second copy under its own parsing, so a session cookie could exist twice with diverging attributes. Fulfilled Worker-fetched responses now drop Set-Cookie. Guard bypass: Playwright's route handlers never see a request a service worker handles, so a page's worker could fetch a private host and hand the body back to be rendered. Every context the factory opens, and every one opened from a guarded launch()/connect() browser, is created with serviceWorkers: "block". Other candidates (relative and credentialed Location, data:/blob:, srcdoc and about:blank frames, meta refresh, cache keying, allowPrivateTargets with allowedHosts) checked out as covered. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
chore(api): keep browser experimental pending a live Browser Run check Maintainer decision on plan 463 B3: graduate x402 only. browser moves back to TIER_3 and the experimental lists (ROADMAP, versioning page, README banner) until the live smoke test passes against a real Browser Run binding: the guard now rests on route.fetch inside a real session, and the service-worker, prerender and WebRTC paths are unverified on the real service. Its exports stay untagged and its snapshot joins container's in FULLY_TRACKED_SNAPSHOTS, so a tag cannot creep back while it waits. It keeps its workerd CI leg. The ROADMAP row marks bar item 2 as partial, and plan 463 lists browser as pending live Browser Run verification. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
feat(browser): warn once when a factory runs without allowedHosts allowedHosts, which Browser Run enforces on the session itself, is now the documented production configuration. Without it the SSRF guard is a best-effort check in the Worker, with known limits: speculation-rules prerenders and WebRTC are not routed, the scope of Domain-less cookies stored by the request client is unverified, the handed-out lock holds against the public Playwright API only, and every page asset costs a Worker subrequest. createBrowser() built without allowedHosts (and without allowPrivateTargets, which opts out on purpose) warns once per isolate, pointing at allowedHosts, the same way @lunora/notify warns about a missing allowedPushOrigins. The docs and README gain a "Production posture" section listing those limits. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(advisor): only allowedHosts contains an arg-derived browser URL browser_user_url_without_allowlist (WARN) suppressed its findings when the app's createBrowser pinned resolveDns: true. The DoH re-check is on by default anyway, and without allowedHosts the whole guard is a best-effort Worker-side check, so an args-derived URL reaching ctx.browser under it is exactly what the lint should keep visible. Only allowedHosts, which Browser Run enforces on the session, suppresses it now. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
test(browser): add a live Browser Run smoke Worker, ready to run packages/browser/smoke is a Worker that drives the SSRF guard against a real Browser Run binding, serving its own attacker-like fixtures so nothing third-party is needed. GET /report answers a JSON pass/fail report: - default mode: a guarded screenshot (route.fetch/fulfill inside a real session), an <img> that 302s to 169.254.169.254 failing with BLOCKED_BY_CLIENT, a main-frame redirect to 10.0.0.1 refused, service-worker registration blocked, and a cross-origin hop (to this version's preview URL) arriving without Authorization; - allowedHosts mode: an allowed host loads, an off-list host is refused. scripts/browser-smoke.sh deploys it with wrangler, curls /report and exits non-zero on any failure. It was not run: it needs a Workers Paid account with Browser Run. A wrangler --dry-run bundle succeeds. The smoke source is typechecked and linted with the package (tsconfig include) but is not published (files: dist) or built (packem bundles src/index.ts only). Teardown: wrangler delete --config smoke/wrangler.jsonc. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(errors): register the x402 facilitator and browser redirect codes X402_FACILITATOR_ERROR (internal, 502) and BROWSER_TOO_MANY_REDIRECTS (502) were minted without catalog entries, which the catalog-registration test rejects. Also drops a leftover diff3 marker from the rebase in the upgrade guide. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
Added
x402: graduate to stable adapter; harden browser, keep it experimental (plan 463 B3) (#1031) (0b854d3)
x402: @lunora/x402/pay no longer exports createPayFetch, registerWallet, resolveEvmAccount, resolveSvmSigner, buildSpendPolicy, buildPaymentGuard, releaseSpendOnFailure, assertBoundedPolicy, createSpendState, SpendState or WalletDeps; @lunora/x402/charge no longer exports createFacilitatorClient or toReceipt. Use createX402Pay / lazyX402Pay and createChargeMiddleware. SpendPolicy.decimals (already throwing) is removed. X402PayDeps declares getSecret itself. Network and recipient misconfiguration throws LunoraError ENV_INVALID instead of Error. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
feat(browser): verify on workerd and settle the surface Add a LUNORA_WORKERD_TESTS-gated workerd project for plan 463 (B3). It runs createBrowser with the real @cloudflare/playwright launch / connect / sessions against a fake Browser Run behind a real service binding (an entrypoint of the test worker). It asserts what reaches the binding: keep_alive in milliseconds, allowedHosts as normalized guardrails, the session id on connect, the session list, and quickAction arguments across the RPC boundary. It also covers the operation deadline, the DoH re-check and its timeout, and /crawl errors on workerd's timers and fetch. The fake refuses the DevTools upgrade, since no Chrome runs locally, so a live page stays covered only by the Node doubles. Keep all 25 exports, drop their @experimental tags, and export the four types the surface already referenced (BrowserSession, BrowserConnectLike, BrowserSessionsLike, RouteLike). Node tests cover a failing close(). Docs gain a platform-support table and the error reference. The workerd CI matrices gain browser (15 packages). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
chore(api): graduate x402 and browser to stable adapter Move both from TIER_3 to TIER_2 (plan 463, B3), which puts their snapshots in FULLY_TRACKED_SNAPSHOTS: every export signature is now gated by api:check. Regenerate both snapshots, and update the ROADMAP tier lists and table, the versioning page, the package index and plan 463. Bar item 5 (used in anger outside this repo) is not something this change can show; it is left to maintainer sign-off. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(notify): refuse a host whose DoH answer has no address shared/ssrf-resolve classified a DoH answer with no A/AAAA record (an empty answer, SERVFAIL, NXDOMAIN) as "public". A name's nameserver can fail the check and answer the connecting resolver a moment later, so that passed a rebinding attack. It is now a separate "unresolved" verdict, which callers refuse; a lookup that could not complete at all still falls back to the string guard. @lunora/notify refuses an unresolved web-push host at send time and does not cache the verdict, since a SERVFAIL can be transient. A broadcast test that had been resolving push.example over the real network now stubs DoH. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(browser): check redirect hops and guard every handed-out browser Playwright calls a route handler only for the first request of a redirect chain, so the page.route guard never saw a 3xx hop: a public URL could 302 the browser to 10.0.0.5 or the metadata endpoint unchecked. The guard now sits on the context, fetches each navigation itself with route.fetch({ maxRedirects: 0 }), and checks a redirect's Location before anything requests it. A refused target fails the call with FORBIDDEN; an allowed one becomes a fresh, checked navigation (withPage follows main-frame hops itself, up to 20; past that, BROWSER_TOO_MANY_REDIRECTS). Fulfilling the 3xx instead would not work: the hop it triggers is auto-continued by Playwright and never reaches the handler. The browsers launch() and connect() hand to caller code carry the same guard on every context, including the contexts already open in a re-attached session. Browser Run cannot tag a session with an owner, so the docs now state that a session id is a bearer secret. Also: a keepAlive session is closed when its callback throws; allowedHosts entries are compared in punycode; a DoH answer with no address is refused. The Node doubles now model Playwright's rule that a redirect hop never reaches the interceptor (__tests__/_helpers/fake-launch.ts), with a regression test for a public URL 302ing to 10.0.0.5. The workerd suite's DoH and /crawl paths run on workerd's own fetch against a fake internet (miniflare outboundService) instead of a stubbed global, and drops two input-guard tests the Node suite covers.
x402: BrowserContextLike requires route; PageLike drops route and gains an optional mainFrame; RouteLike gains fetch and fulfill and is generic over the response type. Test doubles passed as launch/connect need a context with route; the real @cloudflare/playwright exports are unaffected. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(x402): map facilitator failures to 502 and stop a pre-paid leak @x402/core rethrows a FacilitatorResponseError (malformed or timed-out /verify or /settle answer) out of processHTTPRequest / processSettlement, so it reached the host as an anonymous 500. The charge middleware now throws LunoraError X402_FACILITATOR_ERROR (502) with the handler not run; on settle-after the resource is withheld. Initialisation maps an unreachable facilitator to the same code and a facilitator that does not settle the network (RouteConfigurationError) to ENV_INVALID. A request that already carries PAYMENT-SIGNATURE / X-PAYMENT is now sent as-is. @x402/fetch answered its 402 by reserving against maxPerRun, signing, then throwing "Payment already attempted" with the reservation held for the rest of the run. maxPerRun's docstring said "wallet lifetime"; it is one rail, which for ctx.x402 is one function invocation. WalletDeps and GetSecret are gone: wallet.ts reads getSecret off X402PayDeps. Tests: facilitator failures at verify, settle and init; ENV_INVALID asserted on network and recipient errors; the always-passing stray-decimals test dropped; a workerd case for the pre-paid request. The workerd suites share one facilitator double (_facilitator.ts) and the Solana challenge moved to the smoke suite. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(codegen): gate ctx.x402 on the secrets capability ctx.x402 was classified credential-based (x402: null) and emitted on every target, but the generated rail reads its wallet key through ctx.secrets, which node and celld rate unsupported: raw-key and CDP custody failed on the first payment with no build-time signal. It now maps to the secrets feature, so those targets withhold ctx.x402 with platform_unsupported_feature. Signer custody still works there through createX402Pay called directly. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
docs(x402,browser): correct the guard, error and platform claims browser: the README and docs no longer say the guard re-checks every redirect hop through page.route; they describe the fetch-and-check navigation guard, its cost (documents are fetched by the Worker), the sub-resource redirects it cannot see, session ids as bearer secrets, ports not being restricted by allowedHosts, and DoH answers with no address being refused. x402: the errors section no longer claims every error is a LunoraError or that every payment failure is a 402; it lists X402_FACILITATOR_ERROR and what passes through from @x402/fetch. The platform table follows the codegen secrets gate. maxPerRun is documented as per invocation, with a durable counter checked in onPaymentRequired as the way to a cross-run budget. Also: the from-alpha migration notes cover both packages' breaks, ROADMAP's experimental-heavy example no longer names browser, and plan 463 marks B3 done for code, pending maintainer sign-off on bar item 5. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(notify): refuse a send when the DoH re-check cannot complete shared/ssrf-resolve returned the same "unknown" verdict for an IP-literal host and for a lookup that failed (timeout, network error, non-200, bad body), and callers passed both on the string guard. The name's own nameserver can stall the check on purpose, so that let a rebinding host through. The verdict is now "skipped" for an IP literal and "failed" for a lookup that could not complete; one failed address family is enough, since a stalled A lookup next to a public AAAA answer says nothing about the address the connection will use. @lunora/notify refuses "failed" like "unresolved" (fail closed) and caches neither, so the next send after an outage re-checks. The cost is that a DoH outage stops web-push sends to hosts without allowedPushOrigins; the allowlist, which skips the re-check, remains the way to avoid it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(browser): check sub-resource redirects and lock the handed-out guard Three SSRF gaps from the security review. DNS fail-open: a DoH lookup that failed or timed out fell back to the string guard. It now refuses with FORBIDDEN ("DNS ... could not be verified"). Verdicts are cached per hostname for 30s within one guarded context (failed ones evicted), so a page's hundred assets from one CDN cost one lookup. Sub-resource redirects: a non-navigation request got a string check and route.continue(), so <img src> on a public page could 302 to the metadata endpoint and render into a screenshot. Without allowedHosts, sub-resources now get every guard (DNS included) and are fetched by the guard with route.fetch({ maxRedirects: 0 }); each Location is checked and followed with route.fetch({ url, maxRedirects: 0 }) up to 20 hops, a protocol change is refused (route.fetch cannot follow it), and the final response is fulfilled. WebSockets, which never reach a route handler, are checked through routeWebSocket before they connect. With allowedHosts, Browser Run's session guardrails enforce the list on every request and hop, so requests are checked and left in the browser; this also restores the Tunnel config (an internal host on the allowlist), which Worker-side fetching broke. Guard lifetime: the browser from launch()/connect() is locked by pinning members on the live objects (same instance however reached: page.context(), browser.contexts(), popups via the context's page event). unrouteAll(), unroute() without a handler, routeFromHAR(), routeWebSocket(), newCDPSession() and newBrowserCDPSession() throw FORBIDDEN; caller route handlers keep working but their continue() falls through to the guard and route.fetch() is refused. Found while doing this and fixed: a caller route registered after the guard ran first and could continue() past it. A context another connection opens is withheld from contexts() until its guard lands. This holds against the public Playwright API, not code reaching its private channels; allowedHosts is the hard guarantee, as the docs say. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(browser): follow redirect hops like a browser and block workers Three findings from the security review of aaf603cd6. Credential leak on redirect: route.fetch({ url }) reuses the original request's method, headers and body, so a sub-resource hop to another origin carried Authorization (and any page-set credential header) there, and a POST answered with 302/303 was re-posted. Each hop is now built by browser rules: a 303 of anything but GET/HEAD and a 301/302 of a POST become a body-less GET; a cross-origin hop drops Authorization, Proxy-Authorization and Cookie (the request client re-derives cookies from the jar for the new URL), sends an opaque Origin and cuts Referer back to its origin. Authorization state drift: Playwright's request client already stores every hop's Set-Cookie in the context's jar (context.addCookies) when it fetches. Fulfilling the final response with its Set-Cookie made Chromium store a second copy under its own parsing, so a session cookie could exist twice with diverging attributes. Fulfilled Worker-fetched responses now drop Set-Cookie. Guard bypass: Playwright's route handlers never see a request a service worker handles, so a page's worker could fetch a private host and hand the body back to be rendered. Every context the factory opens, and every one opened from a guarded launch()/connect() browser, is created with serviceWorkers: "block". Other candidates (relative and credentialed Location, data:/blob:, srcdoc and about:blank frames, meta refresh, cache keying, allowPrivateTargets with allowedHosts) checked out as covered. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
chore(api): keep browser experimental pending a live Browser Run check Maintainer decision on plan 463 B3: graduate x402 only. browser moves back to TIER_3 and the experimental lists (ROADMAP, versioning page, README banner) until the live smoke test passes against a real Browser Run binding: the guard now rests on route.fetch inside a real session, and the service-worker, prerender and WebRTC paths are unverified on the real service. Its exports stay untagged and its snapshot joins container's in FULLY_TRACKED_SNAPSHOTS, so a tag cannot creep back while it waits. It keeps its workerd CI leg. The ROADMAP row marks bar item 2 as partial, and plan 463 lists browser as pending live Browser Run verification. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
feat(browser): warn once when a factory runs without allowedHosts allowedHosts, which Browser Run enforces on the session itself, is now the documented production configuration. Without it the SSRF guard is a best-effort check in the Worker, with known limits: speculation-rules prerenders and WebRTC are not routed, the scope of Domain-less cookies stored by the request client is unverified, the handed-out lock holds against the public Playwright API only, and every page asset costs a Worker subrequest. createBrowser() built without allowedHosts (and without allowPrivateTargets, which opts out on purpose) warns once per isolate, pointing at allowedHosts, the same way @lunora/notify warns about a missing allowedPushOrigins. The docs and README gain a "Production posture" section listing those limits. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(advisor): only allowedHosts contains an arg-derived browser URL browser_user_url_without_allowlist (WARN) suppressed its findings when the app's createBrowser pinned resolveDns: true. The DoH re-check is on by default anyway, and without allowedHosts the whole guard is a best-effort Worker-side check, so an args-derived URL reaching ctx.browser under it is exactly what the lint should keep visible. Only allowedHosts, which Browser Run enforces on the session, suppresses it now. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
test(browser): add a live Browser Run smoke Worker, ready to run packages/browser/smoke is a Worker that drives the SSRF guard against a real Browser Run binding, serving its own attacker-like fixtures so nothing third-party is needed. GET /report answers a JSON pass/fail report: - default mode: a guarded screenshot (route.fetch/fulfill inside a real session), an <img> that 302s to 169.254.169.254 failing with BLOCKED_BY_CLIENT, a main-frame redirect to 10.0.0.1 refused, service-worker registration blocked, and a cross-origin hop (to this version's preview URL) arriving without Authorization; - allowedHosts mode: an allowed host loads, an off-list host is refused. scripts/browser-smoke.sh deploys it with wrangler, curls /report and exits non-zero on any failure. It was not run: it needs a Workers Paid account with Browser Run. A wrangler --dry-run bundle succeeds. The smoke source is typechecked and linted with the package (tsconfig include) but is not published (files: dist) or built (packem bundles src/index.ts only). Teardown: wrangler delete --config smoke/wrangler.jsonc. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(errors): register the x402 facilitator and browser redirect codes X402_FACILITATOR_ERROR (internal, 502) and BROWSER_TOO_MANY_REDIRECTS (502) were minted without catalog entries, which the catalog-registration test rejects. Also drops a leftover diff3 marker from the rebase in the upgrade guide. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
Added
x402: graduate to stable adapter; harden browser, keep it experimental (plan 463 B3) (#1031) (0b854d3)
codegen,cli: references to a function in a folder use the nested path (api.<folder>.<file>.<fn>). An export named like a file in the same-named folder (billing.ts exporting invoices beside billing/invoices.ts) is now a codegen error.
codegen,cli: import internal from ./_generated/internal instead of ./_generated/api. anyApi and AnyApi are removed from @lunora/server and @lunora/client; build a reference by hand as { __lunoraRef: "<namespace>:<fn>" }. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(advisor): match privileged dispatches by dispatch namespace privileged_dispatch_unvalidated_payload compared the target file read off an api.* path with the procedure's real file path, so a target in lunora/<dir>/index.ts or a hyphenated folder never matched and an RLS bypass through it went unflagged. Both sides now compare as dispatch namespaces. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
docs(roadmap): list nested api and the deploy architecture diff Also sorts namespace members inside the codegen tree, so every emitter gets the same alphabetical order without pre-sorting (no generated output changes). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(playground): sort split imports and read internal from internal.ts The codemod that moved internal to _generated/internal left two import blocks unsorted, and the playground smoke test still looked for InternalApiTypes inside api.ts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(cli): keep a skipped codegen run from clearing the architecture baseline With --skip-codegen there is no manifest, which the last-module handling read as "every module removed" and recorded an empty baseline. The diff now runs only when codegen did. Also update the scheduler skill's fnRef rule for nested paths and correct an advisor comment. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
codegen,cli: references to a function in a folder use the nested path (api.<folder>.<file>.<fn>). An export named like a file in the same-named folder (billing.ts exporting invoices beside billing/invoices.ts) is now a codegen error.
codegen,cli: import internal from ./_generated/internal instead of ./_generated/api. anyApi and AnyApi are removed from @lunora/server and @lunora/client; build a reference by hand as { __lunoraRef: "<namespace>:<fn>" }. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(advisor): match privileged dispatches by dispatch namespace privileged_dispatch_unvalidated_payload compared the target file read off an api.* path with the procedure's real file path, so a target in lunora/<dir>/index.ts or a hyphenated folder never matched and an RLS bypass through it went unflagged. Both sides now compare as dispatch namespaces. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
docs(roadmap): list nested api and the deploy architecture diff Also sorts namespace members inside the codegen tree, so every emitter gets the same alphabetical order without pre-sorting (no generated output changes). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(playground): sort split imports and read internal from internal.ts The codemod that moved internal to _generated/internal left two import blocks unsorted, and the playground smoke test still looked for InternalApiTypes inside api.ts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr
fix(cli): keep a skipped codegen run from clearing the architecture baseline With --skip-codegen there is no manifest, which the last-module handling read as "every module removed" and recorded an empty baseline. The diff now runs only when codegen did. Also update the scheduler skill's fnRef rule for nested paths and correct an advisor comment. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019eqAUAYWECEXnGUUr8A1Fr