feat(studio): browse the durable log archive in the Logs panel - #155
Conversation
Add a third "Archive" feed to the studio Logs panel that reads the durable
ctx.log archive pipelineLogSink writes to R2 (Iceberg / R2 Data Catalog).
- @lunora/runtime: a new admin-gated `/_lunora/admin/logs/archive` route
(`log-archive-admin-routes.ts`) runs `createPipelineLogReader` server-side —
the R2 SQL token stays on the worker, only decoded `{ rows, nextCursor }`
reaches the browser. Reads creds from env (`R2_SQL_*`, `CLOUDFLARE_ACCOUNT_ID`
fallback) + the table from a new `logArchive` WorkerOption. Fails closed with
`LOG_ARCHIVE_NOT_CONFIGURED` when unwired.
- @lunora/client: `queryLogArchive(query)` method + re-exported PipelineLog*
wire types (owned by @lunora/runtime).
- @lunora/studio: a self-contained `ArchiveFeed` (function/user/min-level
filters, keyset "Load more", a "not configured" empty state distinct from an
error) rendered under the new Archive tab; `errorCode` helper.
- Docs + API snapshots updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9
✅ Deploy Preview for lunorash ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (4)
🚧 Files skipped from review as they are similar to previous changes (3)
WalkthroughAdds durable pipeline log archive querying through a server-side admin route, exposes it through the client SDK, and introduces a filtered, paginated Archive view in Studio with configuration-aware empty states. ChangesDurable log archive
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Studio
participant LunoraClient
participant LogArchiveAdminRoute
participant PipelineLogReader
participant R2SQL
Studio->>LunoraClient: Submit archive filters
LunoraClient->>LogArchiveAdminRoute: POST /_lunora/admin/logs/archive
LogArchiveAdminRoute->>PipelineLogReader: Query validated PipelineLogQuery
PipelineLogReader->>R2SQL: Execute R2 SQL query
R2SQL-->>PipelineLogReader: Return archived log rows
PipelineLogReader-->>LogArchiveAdminRoute: Return rows and nextCursor
LogArchiveAdminRoute-->>LunoraClient: Return PipelineLogPage
LunoraClient-->>Studio: Render rows and pagination
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Thank you for following the naming conventions! 🙏 |
|
Thank you for confirming the Contributor License Agreement! 🙏 |
|
React Doctor found no new issues. 🎉 Reviewed by React Doctor for commit |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/runtime/src/log-archive-admin-routes.ts`:
- Line 212: Update the credentials resolution in the route handler around
resolveCredentials to cast the nullish-coalesced env value to
LogArchiveEnvironment before passing it, preserving the existing empty-object
fallback.
In `@packages/studio/src/features/logs/archive-feed.tsx`:
- Around line 236-249: Update the archive feed render logic in the component
containing the `rows`, `loading`, and `error` branches to show a loading
indicator while `rows === null && loading === true`. Keep the existing table,
empty-state, and error rendering behavior unchanged once rows or an error is
available, and use the component’s established loading UI pattern if one exists.
- Around line 168-178: Update the onPathPrefixChange, onUserIdChange, and
onMinLevelChange handler annotations to use the directly imported ChangeEvent
type. Add ChangeEvent to the existing React import and replace each
React.ChangeEvent reference, matching the pattern used by logs-panel.tsx.
- Around line 105-166: Make loadMore ignore completions from an outdated query
by tracking the active querySignature with a useRef and checking it after the
request resolves, matching the cancellation behavior in the main useEffect. Only
append rows, update the cursor, or set errors when the request's captured
signature still matches the current query; update the ref when querySignature
changes so filter changes invalidate in-flight pagination requests.
In `@packages/studio/src/features/logs/logs-panel.tsx`:
- Line 191: Update the activeQuery selection for the LogsView union so the
"archive" view uses the archive query state rather than falling through to
errorsQuery. Ensure the derived error, errorSource, activeLoading, and liveError
values in the surrounding logic come from the selected archive query, while
preserving the existing requests and errors behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 89d2c392-359e-45ac-8e44-c95c9acff803
⛔ Files ignored due to path filters (6)
api-snapshots/client.api.mdis excluded by none and included by noneapi-snapshots/lunora.api.mdis excluded by none and included by noneapi-snapshots/runtime.api.mdis excluded by none and included by nonepackages/runtime/__tests__/log-archive-admin.test.tsis excluded by!**/__tests__/**,!**/*.test.tsand included bypackages/**packages/studio/__tests__/features/logs/archive-feed.test.tsxis excluded by!**/__tests__/**and included bypackages/**packages/studio/__tests__/mock-client.tsis excluded by!**/__tests__/**and included bypackages/**
📒 Files selected for processing (11)
apps/docs/src/content/docs/concepts/observability.mdxpackages/client/src/index.tspackages/client/src/lunora-client.tspackages/client/src/types.tspackages/runtime/src/create-worker.tspackages/runtime/src/index.tspackages/runtime/src/log-archive-admin-routes.tspackages/studio/src/features/logs/archive-feed.tsxpackages/studio/src/features/logs/logs-panel.tsxpackages/studio/src/lib/internal.tspackages/studio/src/locales/en.ts
| message: "log archive requires a `logArchive` config (an R2 Data Catalog table) on the worker — see the observability docs", | ||
| }); | ||
|
|
||
| const { accountId, apiToken, bucket } = resolveCredentials(env ?? {}); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win
Fix TypeScript compilation error by casting env.
The env parameter is typed as unknown, so env ?? {} evaluates to unknown. Passing it to resolveCredentials, which expects LogArchiveEnvironment, will result in a TypeScript compilation error. As per path instructions, verify TypeScript types are correct.
🐛 Proposed fix
- const { accountId, apiToken, bucket } = resolveCredentials(env ?? {});
+ const { accountId, apiToken, bucket } = resolveCredentials((env ?? {}) as LogArchiveEnvironment);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const { accountId, apiToken, bucket } = resolveCredentials(env ?? {}); | |
| const { accountId, apiToken, bucket } = resolveCredentials((env ?? {}) as LogArchiveEnvironment); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/runtime/src/log-archive-admin-routes.ts` at line 212, Update the
credentials resolution in the route handler around resolveCredentials to cast
the nullish-coalesced env value to LogArchiveEnvironment before passing it,
preserving the existing empty-object fallback.
Source: Path instructions
The React Compiler bails on a `try` with a `finally` clause (React Doctor `react-hooks-js/todo`), so the component missed automatic memoization. Reset `loading` in each branch instead, matching the repo's no-`finally` pattern. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9
Merging this PR will degrade performance by 10.17%
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing Footnotes
|
Code-quality + correctness follow-ups from the two-pass review: - Extract the duplicated LEVEL_VARIANT map into `log-level-variant.ts`, shared by the live Logs feeds and the Archive feed (restores the exhaustive `Record<LogLevel, BadgeVariant>` type — drops the `?? "outline"` fallback). - Collapse the four `view !== "archive"` readout guards in logs-panel into one `view === "archive" ? <ArchiveFeed/> : <>…</>` branch. - Drop the `JSON.parse(JSON.stringify(baseQuery))` round-trip in the fetch effect — pass `baseQuery` directly, keying the effect on `querySignature`. - Guard `loadMore` against a cross-filter race: a page-2 fetch that resolves after a filter change is dropped (via `activeSignatureRef`) instead of appending stale rows / overwriting the cursor. - Show a "Loading…" placeholder on the initial fetch instead of a blank panel. - Type `minLevel` state as `"" | ContextLogLevel` (removes a cast). - Move the `LOG_ARCHIVE_NOT_CONFIGURED` sentinel to `shared/log-archive.ts` so runtime and studio share one source of truth with no dependency edge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9
- Fold ArchiveFeed's five fetch-related useState into one useReducer, so each
fetch transition (loading / loaded / append / notConfigured / failed /
pageFailed) is a single dispatched action (React Doctor prefer-useReducer).
- Use the imported `ChangeEvent` type instead of the `React.*` namespace, matching
logs-panel.tsx.
- Gate the toolbar `LiveError` on `view !== "archive"` so the disabled Errors
feed's live-connection state can't leak into the (WS-less) Archive tab.
The CodeRabbit "cast env to LogArchiveEnvironment" suggestion is intentionally
skipped: `env ?? {}` narrows to `{}`, which is assignable to the all-optional
LogArchiveEnvironment (lint:types is green), and eslint's
no-unnecessary-type-assertion rejects the cast.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9
Thermos re-review nice-to-have: the reducer's `loading` action now clears `error`/`notConfigured` (matching the kv reducers' `submitStart`), so retrying after a failure shows the loading placeholder instead of the stale error line. Rows are kept, so paging / filter-change refetches don't blank the table. (Kept `default: return state` to stay consistent with the existing kv reducers rather than introduce a one-off `unreachable` exhaustiveness guard.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9
…rts, sessions, dashboards (#179) * feat(studio): browse the durable log archive in the Logs panel (#155) * feat(studio): browse the durable log archive in the Logs panel Add a third "Archive" feed to the studio Logs panel that reads the durable ctx.log archive pipelineLogSink writes to R2 (Iceberg / R2 Data Catalog). - @lunora/runtime: a new admin-gated `/_lunora/admin/logs/archive` route (`log-archive-admin-routes.ts`) runs `createPipelineLogReader` server-side — the R2 SQL token stays on the worker, only decoded `{ rows, nextCursor }` reaches the browser. Reads creds from env (`R2_SQL_*`, `CLOUDFLARE_ACCOUNT_ID` fallback) + the table from a new `logArchive` WorkerOption. Fails closed with `LOG_ARCHIVE_NOT_CONFIGURED` when unwired. - @lunora/client: `queryLogArchive(query)` method + re-exported PipelineLog* wire types (owned by @lunora/runtime). - @lunora/studio: a self-contained `ArchiveFeed` (function/user/min-level filters, keyset "Load more", a "not configured" empty state distinct from an error) rendered under the new Archive tab; `errorCode` helper. - Docs + API snapshots updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * refactor(studio): drop try/finally in ArchiveFeed for React Compiler The React Compiler bails on a `try` with a `finally` clause (React Doctor `react-hooks-js/todo`), so the component missed automatic memoization. Reset `loading` in each branch instead, matching the repo's no-`finally` pattern. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * refactor(studio): address thermos review of the archive feed Code-quality + correctness follow-ups from the two-pass review: - Extract the duplicated LEVEL_VARIANT map into `log-level-variant.ts`, shared by the live Logs feeds and the Archive feed (restores the exhaustive `Record<LogLevel, BadgeVariant>` type — drops the `?? "outline"` fallback). - Collapse the four `view !== "archive"` readout guards in logs-panel into one `view === "archive" ? <ArchiveFeed/> : <>…</>` branch. - Drop the `JSON.parse(JSON.stringify(baseQuery))` round-trip in the fetch effect — pass `baseQuery` directly, keying the effect on `querySignature`. - Guard `loadMore` against a cross-filter race: a page-2 fetch that resolves after a filter change is dropped (via `activeSignatureRef`) instead of appending stale rows / overwriting the cursor. - Show a "Loading…" placeholder on the initial fetch instead of a blank panel. - Type `minLevel` state as `"" | ContextLogLevel` (removes a cast). - Move the `LOG_ARCHIVE_NOT_CONFIGURED` sentinel to `shared/log-archive.ts` so runtime and studio share one source of truth with no dependency edge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * refactor(studio): address CodeRabbit review of the archive feed - Fold ArchiveFeed's five fetch-related useState into one useReducer, so each fetch transition (loading / loaded / append / notConfigured / failed / pageFailed) is a single dispatched action (React Doctor prefer-useReducer). - Use the imported `ChangeEvent` type instead of the `React.*` namespace, matching logs-panel.tsx. - Gate the toolbar `LiveError` on `view !== "archive"` so the disabled Errors feed's live-connection state can't leak into the (WS-less) Archive tab. The CodeRabbit "cast env to LogArchiveEnvironment" suggestion is intentionally skipped: `env ?? {}` narrows to `{}`, which is assignable to the all-optional LogArchiveEnvironment (lint:types is green), and eslint's no-unnecessary-type-assertion rejects the cast. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * polish(studio): clear prior error on a fresh archive fetch Thermos re-review nice-to-have: the reducer's `loading` action now clears `error`/`notConfigured` (matching the kv reducers' `submitStart`), so retrying after a failure shows the loading placeholder instead of the stale error line. Rows are kept, so paging / filter-change refetches don't blank the table. (Kept `default: return state` to stay consistent with the existing kv reducers rather than introduce a one-off `unreachable` exhaustiveness guard.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * chore(release): @lunora/runtime@1.0.0-alpha.32 [skip ci]\n\n## @lunora/runtime [1.0.0-alpha.32](https://github.com/anolilab/lunora/compare/%40lunora%2Fruntime%401.0.0-alpha.31...%40lunora%2Fruntime%401.0.0-alpha.32) (2026-07-21) * chore(release): @lunora/client@1.0.0-alpha.26 [skip ci]\n\n## @lunora/client [1.0.0-alpha.26](https://github.com/anolilab/lunora/compare/%40lunora%2Fclient%401.0.0-alpha.25...%40lunora%2Fclient%401.0.0-alpha.26) (2026-07-21) ### Dependencies * **@lunora/runtime:** upgraded to 1.0.0-alpha.32 * docs: add cirrus cloud platform plan Reverses the managed-deploy-plane won't-do (VOID-TEARDOWN.md §0/§6, CONVEX-PARITY.md #23) with a scoped managed tier: Workers for Platforms data plane, Convex-shaped product model (teams/projects/prod+dev+preview deployments), PartyKit-style managed-vs-BYO CLI split, and a phased roadmap starting with remote-binding dev. Synthesized from a repo inventory, a Convex Cloud teardown, and a GitHub/Cloudflare-primitives survey. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: fold supabase platform teardown into cloud plan Adds Supabase as a reference model: the OSS/proprietary cut line, IS_PLATFORM single-codebase studio pattern, Branching 2.0 preview DX (and its pain points to fix: empty branches, hourly branch billing outside spend caps), the Management API + OAuth-apps growth channel, and the structural cost advantage WfP gives over per-project VMs. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: add wfp constraints, eject path, spike checklist Gap review of the cloud plan: documents that cron triggers are silently dropped for namespaced user Workers (with SchedulerDO alarm-based fan-out mitigation), queue-consumer and send_email verification items, KV account-limit multiplexing, EU jurisdiction toggle, a cirrus-eject portability command built on existing export/import RPCs, namespace-wide observability reuse, managed backups + abuse controls in Phase 4, and a Phase 1 constraint spike. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: verify cloud plan claims against cloudflare docs Fact-checked every Cloudflare claim in CLOUD-PLAN.md against the official docs: WfP pricing, KV/D1/R2 account limits, DO/R2 jurisdictions vs D1 location hints, CF for SaaS hostname pricing, and remote-bindings GA versions all confirmed. Adds three newly verified constraints: no gradual deployments for user Workers (rollback must be platform-side bundle re-upload), the 1200-req/5-min account API rate limit on provisioning, and outbound-Worker TCP/DO interception trade-offs. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: add cell-based scaling architecture to cloud plan Answers how the managed tier scales without hitting account limits or risking platform-wide blocks: script-resident tenant state with lazy inference-driven provisioning, multi-account cells with cell IDs baked into identifiers from day one, a per-cell API token-bucket scheduler, a tenancy graduation ladder up to managed-BYO and the Tenant API, and abuse containment to keep tenant abuse from looking like platform abuse. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: scope alchemy to cell bring-up, not tenant deploys Records the provisioning-engine decision: the per-tenant deploy path stays hand-rolled on cloudflare-typescript (control-plane DB as the single source of truth, cell scheduler, progress events, rollback artifacts); Alchemy (pre-1.0, v2 rewrite underway, no confirmed dispatch-namespace resource) is a candidate only for low-cardinality cell bring-up IaC, with Terraform/Pulumi as the fallback. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: correct alchemy facts (dispatch-namespace resource, v0.93) Re-checked Alchemy against GitHub/npm: it is v0.93.12 (Apache-2.0) and does ship a dispatch-namespace (Workers for Platforms) resource — my earlier 'lacks a confirmed dispatch-namespace resource' was wrong and 'v0.9x' undersold it. Recommendation is unchanged (hand-roll the per-tenant deploy, use Alchemy for cell bring-up) but now rests on the real reason — source-of-truth shape and deploy-orchestration concerns, not capability — with a re-evaluation trigger at a stable 1.x. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: adopt alchemy as the provisioning engine Decision: Alchemy is the provisioning engine across cell bring-up, per-tenant managed deploy, and BYO. Verified it ships DispatchNamespace/ Worker/D1/R2/DO resources plus a built-in D1StateStore and runs inside a Worker (await alchemy(scope) -> finalize/destroy). Backing each tenant scope with the control-plane D1 collapses the two-sources-of-truth concern into one store. The per-cell rate-limit scheduler now paces finalize() runs; bundling stays in the Vite pipeline; rollback re- converges to a prior R2-retained bundle. Risk of a 0.x dependency contained behind a @cirrus/provision adapter with cloudflare-typescript as fallback; spike open items recorded. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: build on alchemy v2 (alchemy@next, 2.0.0-beta.55) Per decision to start on the v2 line: target alchemy@next (verified 2.0.0-beta.55, Effect-based) to avoid a v1->v2 migration mid-build, with v1 0.93.x as the named fallback. Records the trade-offs (beta churn, Effect pulled into the control-plane tree, quarantined behind the @cirrus/provision adapter) and turns the unverified v2 facts (DispatchNamespace resource + D1/DO state store, confirmed on v1 only since docs/CDN were unreachable) into hard Phase 1 spike gates with v1 fallback per surface. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: commit fully to alchemy v2, drop v1 fallback Remove the v1 (0.93.x) fallback hedging throughout: v2 (alchemy@next, 2.0.0-beta.55) is the engine outright. Spike gates remain but now resolve via owned shims or upstream contributions rather than retreat to v1; a hard unresolvable 'no' escalates the engine decision instead of silently dual-tracking. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: add forgotten must-haves + fleet runtime-versioning risk Gap pass on the cloud plan. New risk #8 (the load-bearing one): the Cirrus runtime is bundled into each tenant Worker, so a security patch means redeploying the whole fleet unless the tenant Worker is made 'thin' against a central runtime — a fat-vs-thin decision that must be made before Phase 1 since it shapes the bundle format, deploy API, and vite emit. New section 7 collects launch-blocking gaps the plan had assumed away: control-plane DB durability/DR, cross-cell disaster recovery, secrets-at-rest + cell-token custody, frontend-hosting scope, AUP + bill-shock/cryptomining controls, billing/MoR/tax, GDPR processor/DPA/SOC2, account offboarding + right-to-erasure, platform self-observability + status page, dispatcher canary, and a staging cell. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): scaffold control-plane app built on cirrus First implementation step from CLOUD-PLAN.md: a new apps/cloud workspace app that dogfoods Cirrus as the platform's own control-plane backend. - cirrus/schema.ts: control-plane data model (cells, organizations, members, projects, deployments, deployKeys, auditLog), all .global() (D1) — the plan's 'Worker + D1' control plane. - cirrus functions: organizations/projects/deployments/cells/deploy-keys (create/list/issue/updateStatus), with owner seeding + audit trail. - src/server.ts: control-plane Worker entry wiring D1-backed global tables. - src/provision.ts: the @cirrus/provision seam — the sole coupling to the Alchemy v2 engine (stub that rejects until the Phase 1 spike wires it). - configs (package.json/tsconfig/project.json/wrangler.jsonc/eslint/vitest), README, and a provision test. Verified: codegen clean (no advisories), eslint clean, tsc --noEmit passes, vitest green. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): add deploy-orchestration core Builds the next control-plane layer on the scaffold's Provisioner seam, all pure/testable (no live Cloudflare needed): - token-bucket.ts: per-cell API budget (§2.5), models CF's 1200/5min account limit; deterministic + clock-injectable. - scheduler.ts: CellScheduler paces/serializes provisioner work against the bucket with priority ordering + a concurrency cap. - orchestrator.ts: runDeployment state machine emitting queued → provisioning → live/failed progress events (§2.2); destroyDeployment for preview-TTL/project teardown. - keys.ts: deploy-key format/parse/hash helpers; deploy-keys.ts mutation refactored to use them (one tested place for the format + SHA-256). 17 tests across 5 files; eslint + tsc --noEmit clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): add org authorization + deploy-key lifecycle Closes gaps found reviewing the control plane: - authz.ts: assertMember(ctx, orgId, roles?) — the org ACL gate. Every org-scoped function now verifies the caller is a member with a permitted role, closing an IDOR hole where any signed-in user could read/mutate any org by passing its id. Applied across projects, deployments, deploy-keys. - members.ts: list / add / remove so memberships can actually be granted (owner is seeded on org create; admins/owners manage the rest). - deploy-keys: verify (the deploy API's auth path — match by SHA-256, reject revoked, bump lastUsedAt, return the DB-authoritative target) and revoke (leaked-key mitigation); lastUsedAt/revokedAt are now live. - deployments: create checks the project belongs to the org; updateStatus loads the deployment and gates on its org (documented as the system seam for the orchestrator). codegen clean, eslint + tsc --noEmit clean, 17 tests pass. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): deploy API endpoint, deploy-key auth, handler tests Lands the three remaining pieces together: - Deploy API: POST /v1/deploy mounted via the httpRouter seam (src/deploy/ router.ts) → pure handler (src/deploy/handler.ts) authenticates the bearer deploy key, records a queued deployment, drives runDeployment through the per-cell scheduler, and streams NDJSON progress (accepted→queued→ provisioning→live/failed→done), patching status per phase. - Auth path: investigation showed internalMutation is unreachable from the HTTP action-context dispatch (no system flag → RPC 404), so verify/ updateStatus stay public; instead added deploy-key authorization (authz.authorizeDeployKey) and a dual-path (member session OR deploy key) on deployments.create/updateStatus, so CI deploys need no user session. Corrected the stale 'should become internalMutation' comments. - Tests: handleDeployRequest (401/403/400 + success and failure streaming + status transitions) and authz (assertMember + authorizeDeployKey) via a fake ctx. 28 tests / 7 files; eslint + tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs(cloud): refresh status — deploy API + auth now in place * chore(cloud): track generated schema snapshot Matches the apps/playground convention — .cirrus-schema.json is the codegen schema snapshot used for migration/drift detection and is committed, not gitignored. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): preview lifecycle, GitHub webhook, deploy client (Phase 1/2) Phase 2: - Preview deployments are TTL'd: deployments.create stamps expiresAt for kind=preview (src/deploy/preview.ts: deterministic previewScriptName + 5-day previewExpiry); an hourly cron (cirrus/crons.ts -> internal deployments.cleanupExpiredPreviews) marks expired previews destroyed. Worker gains scheduled(); wrangler cron trigger added. - GitHub webhook (src/github/webhook.ts): HMAC-SHA256 verify + pull_request -> preview-intent parsing, mounted at POST /v1/github/webhook. Phase 1: - Deploy client (src/deploy/client.ts): the cirrus-deploy core — POSTs to /v1/deploy and consumes the NDJSON progress stream. 13 new tests (41 total); codegen/eslint/tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: update roadmap — Phase 0 shipped, Phases 1-2 status Phase 0 (remote-binding dev) is already implemented in the framework (@cirrus/config remote-bindings + @cirrus/vite plugin + cirrus dev; 30 tests) — corrected from 'not started'. Phases 1-2 marked substantially-built with the live-Cloudflare-dependent remainder (Alchemy provisioner, dispatcher, e2e validation) called out. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): team invitations + billing quotas; repo-extraction guide (Phase 3/4) Phase 3 (hosted studio sliver): - Team invitations (cirrus/invitations.ts + invitations table): invite/list/ revoke/accept, single-use SHA-256-hashed tokens (plaintext mailed once), owner-admin gated; accept-by-token adds the caller as a member. Phase 4 (billing sliver): - Plans + quota entitlements (src/billing/plans.ts) on @cirrus/payment's entitlements model — free/pro/enterprise limits + feature flags, with effectiveLimit/withinQuota and a free-tier fallback for non-subscribers. Portability (move to a private repo): - EXTRACT.md documents the mechanical extraction; audit confirms the app imports only published @cirrus/* entry points (no monorepo-internal reaches). 6 new tests (47 total); codegen/eslint/tsc clean; secret-scan clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): real Cloudflare provisioner, quota enforcement, webhook project resolution Provisioner (Phase 1 — the big one): replaces the rejecting stub with a real implementation over a typed Cloudflare REST port (src/cloudflare/api.ts): deploy provisions per-tenant D1/R2, uploads the user Worker into the dispatch namespace with binding + DO-migration metadata, applies secrets, returns the bundle hash + routed URL; destroy deletes the script. Port-injected so it's tested with a fake; plug in CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN to run. (REST via fetch rather than the unverifiable alchemy@next beta — same seam.) Quota (Phase 4): plans.ts gains planLimit/withinPlanQuota; projects.create and members.add enforce the org plan's limits. Preview automation (Phase 2): projects gain githubRepo + byGithubRepo lookup; the webhook resolves the connected project and returns the preview script name. Env documented (.dev.vars.example + wrangler vars). 50 tests; eslint/tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): dispatcher Worker + hosted-studio admin-RPC proxy (Phase 1/3) Phase 1 — dispatcher Worker (the request-path front door): resolveTenant maps {scriptName}.{appDomain} (and custom domains via injected lookup) to a dispatch-namespace script; the worker forwards via env.DISPATCHER.get with per-plan limits. Separate deployable (dispatcher.wrangler.jsonc). Phase 3 — admin-RPC proxy: proxyAdminRequest authorizes org membership, forwards the admin RPC to the tenant's /_cirrus/admin/* with that deployment's admin token, and records an audit entry. Pure (deps injected). 7 new tests (57 total); eslint/tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): cirrus login/link/deploy CLI commands (Phase 1) Pure command logic over a ConfigStore + the deploy client: login persists the API endpoint + deploy key, link binds a project, deploy streams a managed deploy (requires login+link). File-backed store at ~/.cirrus/cloud.json for the Node CLI; cerebro registration in @cirrus/cli calls these. 3 new tests (60 total); eslint/tsc clean; secret-scan clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: roadmap status — control-plane backend feature-complete as code All phases' backend code is built + unit-tested in apps/cloud (60 tests): real REST provisioner, dispatcher, CLI, preview lifecycle, GitHub webhook, team invitations, admin-RPC proxy, quota enforcement. Remaining items are the ones needing live Cloudflare / external services / the studio UI. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): admin-proxy live wiring, usage metering, custom-hostname port (Phase 3/4) Phase 3 — admin proxy mounted at POST /v1/admin: deployments now carry the platform-minted tenant adminToken (set as the worker's CIRRUS_ADMIN_TOKEN secret + stored on the row), deployments.adminTarget resolves {url, adminToken} after asserting membership, and the router forwards to the tenant's /_cirrus/admin/* with an audit-log.record entry. Phase 4 — usage metering: usageEvents table + internal record mutation + member summary query over a pure aggregateUsage roll-up. Custom hostnames: CloudflareApi.createCustomHostname (Cloudflare for SaaS, zone-scoped REST). Router refactored into per-route handlers. 64 tests; eslint/tsc/secret-scan clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: roadmap — admin proxy mounted, usage metering + custom-hostname port added * feat(cloud): add hosted studio react spa Build the hosted-studio frontend for the Cirrus Cloud control plane: a better-auth-gated React SPA served on one origin with the control-plane Worker via @cirrus/vite. - src/client: main/auth-client/Login, App auth gate, OrganizationList, OrganizationDashboard with tabs for projects, deployments, members, deploy keys, invitations, and usage; AsyncList loading/empty helper. - Wire @cirrus/auth into src/server.ts (createAuth + cirrusD1Adapter, ensureMigrated, handleAuthRequest, authAdmin, resolveIdentity) and add AUTH_SECRET/AUTH_URL env + .dev.vars.example entries. - Switch package scripts to vite (build/dev), add react/react-dom + @cirrus/react/@cirrus/auth deps, vite.config.ts, index.html, and the DOM lib + jsx in tsconfig. - eslint: client section (filename-case, react-perf, void), browser globals; ignore vite.config.ts. - Refresh README + CLOUD-PLAN status to reflect the studio UI. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): add billing, metering, and hardened auth Billing on @cirrus/payment (§4): org id is the payment referenceId. Wire a Stripe adapter into createShardDO({ payment }); add cirrus/billing.ts with checkout/portal actions, entitlements/subscription reads (resolved through CIRRUS_CLOUD_PLANS with a free-tier fallback), and a signature-verified processWebhook mounted at POST /v1/billing/webhook. The studio gains a Billing tab. Platform metering (§4): rename the resource-metering table to platformUsage (freeing usageEvents for @cirrus/payment's billing ledger), add a deploy-key authenticated usage.ingest mutation + POST /v1/usage endpoint, and enforce per-plan runtime limits in the dispatcher (limitsForPlan → DISPATCHER.get). Auth hardening (§3) on @cirrus/auth/better-auth: mail-backed email verification + password reset (@cirrus/mail), optional GitHub/Google OAuth, admin/twoFactor/passkey plugins, built-in auth rate limiting, plus a per-IP @cirrus/ratelimit cap on the /v1/* surface. Invitations now email the token via POST /v1/invitations/send (never shown in the browser). The Cirrus organizations/members model stays the single org source of truth (better-auth organization plugin deliberately omitted). Add deps (@cirrus/mail, @cirrus/ratelimit, stripe), tests for the router routes + rate limiting + per-plan limits (69 total), and reconcile the README + CLOUD-PLAN status (the provisioner is a real Cloudflare REST impl, not a stub). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): enforce entitlements, wire metering source, add secrets Close the billing loose ends and add the metering source, tenant secrets, and an audit-log view. Entitlements (close loose end #1): quota is now enforced against live subscription state (cirrus/entitlements.ts resolves from the synced `subscriptions` table) rather than the static organizations.plan column — projects/members creation call assertWithinQuota, so a Stripe upgrade raises limits immediately with no column to sync. Per-plan dispatch limits (close loose end #2): deployments.planForScript + a bearer-gated GET /v1/tenants/plan endpoint + a cached plan resolver in the dispatcher (createPlanResolver) wire resolvePlan, so runtime limits actually scale per plan instead of always falling back to free. Metering source: the dispatcher emits one Analytics Engine data point per tenant request (src/metering/analytics.ts); a reader port + HTTP impl and an hourly usage.rollup compaction cron complete the pipeline alongside the existing /v1/usage ledger ingest. Tenant secrets (§7): AES-256-GCM envelope encryption at the edge (src/secrets/crypto.ts), a secrets table (ciphertext + IV only), store/list/ listEncrypted/remove functions, POST /v1/secrets, deploy-time materialization into the tenant Worker, and a studio Secrets tab. Studio: add Secrets + Activity (audit log) tabs; add audit-log.list. Tests: crypto round-trip, plan resolver caching/fallback, entitlement quota, analytics writer/reader (83 total). Docs + .dev.vars.example updated. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * fix(cloud): address review findings (rollup atomicity, deploy failure, dedup) Apply /review findings on the recent billing/metering/secrets work: Correctness: - usage.rollup: the D1 global backend has no multi-statement transaction, so the old insert-summed-then-delete-originals order could double-count (over- bill) on a mid-rollup crash. Reorder to delete the extras first, then patch the surviving row's total last — a crash can now only under-count, never leave a summed row beside surviving originals. - deploy handler: a tenant-secret decrypt failure (corrupt secret / rotated key) threw inside the NDJSON stream and left the deployment stuck in `accepted`. Catch it and transition to `failed` with a status update. - POST /v1/secrets: encryption/config failures (e.g. a malformed SECRET_ENCRYPTION_KEY) now return 500, not a misleading 403 (kept distinct from the membership 403 the store mutation raises); reject the reserved CIRRUS_ADMIN_TOKEN secret name up front instead of silently clobbering it. - studio: drop the plan picker from org creation — limits now come from live subscription entitlements, so selecting a paid plan at create-time granted nothing. Orgs start free; upgrade via the Billing tab. Cleanup: - Extract the cross-org IDOR guard into authz.assertRowInOrg and call it from secrets/members/deploy-keys/invitations (was four byte-identical copies). - Remove dead plans.ts exports planLimit/withinPlanQuota (superseded by entitlements-based quota); add a single highestPlan/PLAN_PRECEDENCE helper and use it in deployments.planForScript (was a hand-rolled tier ladder). - Memoize the Stripe payment config per isolate (was rebuilt on every shard request that touches ctx.payments). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * test(cloud): validate websockets through dispatch (phase 1 spike) Validate the hottest path — hibernated-WS subscriptions + per-invocation limits through env.DISPATCHER.get() — the least-documented WfP case (risk #3). - spikes/ws-dispatch/: a runnable harness for live validation on a real dispatch namespace. A framework-free hibernatable-WebSocket Durable Object (the exact primitive ShardDO uses: acceptWebSocket + webSocketMessage), deployable into the namespace, plus a zero-dep Node probe that drives it through the dispatcher and asserts: (1) the WS upgrade survives the dispatch hop (101 + live socket), (2) a hibernated server push (broadcast) reaches the socket — the mutation-to-subscription shape, (3) cpuMs-limit behaviour. The README documents deploy/run, pass/fail, and the expected results + caveats. - __tests__/dispatcher-ws.test.ts: unit-pins the dispatcher forwarding contract (returns the tenant 101+webSocket response unchanged, applies per-plan limits, meters the upgrade once) — runs in CI, no infra needed. - dispatcher worker: clarifying comments on WS pass-through + per-frame metering semantics. CLOUD-PLAN risk #3 now references the harness. The dispatcher half is verified here (94 tests); the end-to-end behaviour needs a live Cloudflare account + the Workers-for-Platforms add-on to run. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): tenant cron fan-out through dispatch (wfp workaround) Cloudflare drops triggers.crons for Workers in a dispatch namespace, so tenant cron jobs never fire. Fan them out from the control plane (CLOUD-PLAN §2.4). - @cirrus/runtime: add an admin-gated POST /_cirrus/scheduled tick endpoint that runs a cron expression's jobs through the SAME handleScheduled path the native scheduled() trigger uses (user crons + code crons + backup), so a platform can drive a namespaced tenant's crons over HTTP. (Dispatch stubs expose only fetch()/connect() — no scheduled()/queue() — so HTTP is the only transport in.) - src/fanout/cron.ts: pure 5-field cron-expression matching (lists, ranges, steps, dom/dow OR semantics) + dueTicks + fanOutCron orchestration. - control plane: capture each tenant's cronSpecs on the deployments row at deploy; an every-minute heartbeat cron (cirrus/fanout.ts) makes codegen emit the */1 trigger, and server.ts scheduled() reads live cron targets and ticks each due tenant via env.DISPATCHER.get(script).fetch('/_cirrus/scheduled') with the per-deployment admin token (kept in-process — never exposed). Adds the DISPATCHER binding to the control-plane wrangler. Tests: cron matching, dueTicks, fanOutCron (103 cloud tests; 337 runtime tests still green). Live validation on a dispatch namespace pending; queue consumer fan-out is the remaining half. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): tenant queue-consumer fan-out through dispatch (wfp workaround) WfP namespaced Workers can't be queue consumers, so tenant queue-backed work (@cirrus/mail sends, scheduler queue-workpool) never drains. Fan it out from a platform-owned consumer (CLOUD-PLAN §2.4) — the queue counterpart to the cron fan-out. - @cirrus/runtime: add a `queueHandler` option + an admin-gated POST /_cirrus/queue endpoint that reconstructs the batch and invokes it, returning the message ids to retry. (Dispatch stubs are fetch-only, so HTTP is the only transport into a namespaced tenant.) - src/fanout/queue.ts: pure grouping of a shared-queue batch by the producing tenant's script (envelope `{ script, body }`) + fan-out orchestration that collects per-message retries and retries a whole group on delivery failure. - control plane: the account-level Worker is the consumer — server.ts queue() drains the shared cirrus-tenant-queue, resolves each tenant's admin token in-process (never exposed), forwards sub-batches via env.DISPATCHER.get(script).fetch('/_cirrus/queue'), and acks/retries per the tenant reply. Adds the queues.consumers binding to the control-plane wrangler. Tests: groupByTenant + fanOutQueue (108 cloud tests; 337 runtime tests green). Live validation on a dispatch namespace + a producer-side script-tagging helper remain. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): align with the lunora rebrand + reuse @lunora/analytics Rebased onto alpha, which renamed the framework cirrus → lunora. Reconcile the control-plane app and reuse a newly-shipped package. Rebrand: - npm scope @cirrus/* → @lunora/* across deps + imports. - app functions dir cirrus/ → lunora/ (+ tsconfig/eslint globs, _generated paths, the committed schema snapshot → .lunora-schema.json). - reserved paths /_cirrus/* → /_lunora/* (incl. the new scheduled/queue tick endpoints) and the runtime-injected env.__lunoraCtx; renamed exported symbols (LunoraError, LunoraClient/Provider, useLunora, lunoraD1Adapter, LunoraAuth*, LUNORA_CRONS/FUNCTIONS); vite plugin cirrus() → lunora(); CLI config dir ~/.cirrus → ~/.lunora. - wire the new required GlobalIntrospector.facetColumn via @lunora/d1's facetGlobalColumn. Reuse: - src/metering/analytics.ts is now a thin domain layer over @lunora/analytics (createAnalytics writeDataPoint + createAnalyticsSqlClient AE-SQL reader) instead of a hand-rolled writeDataPoint + HTTP SQL client. Verified the rest is genuinely cloud-specific (cron-expression matching, AES-GCM secret crypto, the Cloudflare REST provisioner, the per-cell CF-API token bucket) — no upstream equivalent to fold into. 103→108 cloud tests green; runtime 379 tests green; tsc/eslint/build clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): rebrand the product Cirrus Cloud → Lunora Cloud Complete the lunora rebrand to the product layer (the framework already moved): - brand prose Cirrus Cloud → Lunora Cloud across code comments, README, EXTRACT, the studio (index.html title, Login/dashboard), and CLOUD-PLAN.md. - env vars CIRRUS_* → LUNORA_*: LUNORA_ADMIN_TOKEN and LUNORA_MAIL_CAPTURE are functional (read by @lunora/mail); LUNORA_APP_DOMAIN / LUNORA_CELL and the VITE_LUNORA_URL client var follow for consistency. - the LUNORA_CLOUD_PLANS entitlements constant. - infra names cirrus-* → lunora-*: worker names (lunora-cloud, lunora-dispatcher), dispatch namespace (lunora-production), shared queue (lunora-tenant-queue), AE dataset (lunora_tenant_usage), the lunora.app apex, and the deploy dispatch-namespace prefix. - the hosted-CLI verbs (lunora login/link/deploy) and config dir ~/.lunora. - docs' reserved-path/marker refs (/_lunora/*, __lunora_admin__, env.__lunoraCtx). 108 cloud tests green; tsc/eslint/build clean; zero residual `cirrus` references. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * refactor(cloud): migrate functions to fluent builder api Adapt the cloud control-plane functions to alpha v1.0.0-alpha.1's fluent function builders: kind.input({...}).<terminal>(({ ctx, args }) => ...) replaces the removed object form kind({ args, handler }). Regenerate _generated/* and pick up codegen's observability block in wrangler. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): license under polyform noncommercial The control plane is the proprietary product layer, so it must not carry the framework's FSL-1.1-Apache-2.0 (which grants broad commercial rights). Apply PolyForm Noncommercial 1.0.0: any noncommercial purpose is permitted, but commercial use requires a separate license. Replaces UNLICENSED. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): adopt @lunora/bindings/analytics after package fold-in The latest alpha folded @lunora/analytics into @lunora/bindings (subpath export ./analytics, identical API) and codegen now emits _generated/functions.ts importing @lunora/values directly. Swap the dependency and import specifiers, declare @lunora/values, regenerate _generated/*, and reconcile the lockfile. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * fix(cloud): address pr review findings - secrets: assert the project belongs to the org in store/list/ listEncrypted and scope queries by organizationId, closing the cross-org IDOR where a member of one org could read or overwrite another org's project secrets (+ idor tests) - deploy: require a base64 worker bundle in POST /v1/deploy and thread it client → CLI → provisioner instead of uploading an empty module; 400 on missing/malformed bundle - studio: replace try/finally + throw-in-try with promise combinators in Login/Invitations/Secrets forms so React Compiler can memoize them https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs(cloud): add consolidated gap analysis and build plan https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): blue/green releases with health gating and rollback Every deployment now uploads an immutable versioned script ({alias}-v{n}); the project's stable URL follows an active-deployment pointer that only swaps after the new script passes a health probe, so a bad deploy never replaces a serving one (gaps.md a1). Adds POST /v1/deployments/rollback + lunora rollback (pointer swap back to a retained superseded release), GET /v1/tenants/route + a cached alias resolver in the dispatcher, per-phase deployment timestamps (a2), and an x-lunora-id debug header (b3). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): aggregate spend caps with org suspension Per-invocation limits cap one request; nothing capped aggregate period spend (gaps.md c1). Adds a pure spend evaluator at the wfp cost basis with per-plan default caps (org-overridable; explicit 0 = uncapped), an hourly enforcement cron that suspends breaching orgs and self-heals recovered ones, and dispatcher enforcement — a suspended org's tenants serve 503 via the sentinel plan carried through the existing cache. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): custom domains — model, txt verification, hostname routing First slice of gaps.md b1: the domains table (unique hostname, per-org project scoping, redirect-only rows, cloudflare custom-hostname id), add/list/remove/markVerified functions with the same authz gates as secrets, a pure dns-over-https verification core (_lunora txt token + platform cname check, injectable resolver), and routeForHostname — the dispatcher-facing lookup that only ever routes verified domains to the project's active script. Edge routes + dispatcher wiring land next. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): wire custom domains through edge and dispatcher Completes the code-tractable half of gaps.md b1: POST /v1/domains (add, returns the txt record to create), POST /v1/domains/verify (dns-over- https txt + cname checks under the caller's session, outcome recorded via markVerified), GET /v1/tenants/custom-domain for the dispatcher, and a cached custom-domain resolver in the dispatcher that routes verified hostnames to the project's active script and answers redirect-only rows directly. cloudflare-for-saas cert provisioning remains the 🌐 half. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * style(cloud): hoist the trailing-dot regex to module scope https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): server-side builds, build logs, and push-to-deploy webhook gaps.md a3/a4: builds table with a stale-recoverable work lease and commit-sha dedup (a repeat push reuses the successful build's bundle hash instead of rebuilding), streamed line-per-row build logs with a cursor-paginated tail query, github app installations linked by account slug, push + installation webhook parsing (default-branch pushes only, zero-sha deletes ignored) wired through the hmac-verified edge route, and a pure build-runner orchestration (claim → fetch → execute → complete/fail) whose tarball-fetch and container-execute ports are the remaining 🌐 half. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): tenant log ingestion and org right-to-erasure gaps.md b2 + d3. logs: a tenantLogs ledger fed by the tail worker via deploy-key-gated POST /v1/logs/ingest (batch + line-length caps, lines truncated rather than dropped), a cursor-paginated member tail query, and a 6-hourly retention prune (48h window). erasure: owners request org deletion (30-day reversible window); the purge cron then erases every org-scoped control-plane row, marks deployments destroyed for the provisioner teardown path, and removes the org. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs(cloud): mark shipped gaps in the build plan https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): dunning state machine and residency-aware cell placement gaps.md c2 + f. dunning: a pure evaluator (payment failure → 14-day grace anchored at first failure → suspend; any active/trialing subscription rescues) driven by a 6-hourly cron over the synced subscription states. suspensions now carry a reason so the spend-cap and dunning crons only lift their own. placement: organizations.create accepts a jurisdiction ("eu"/"fedramp") and picks a matching active cell when no explicit cellId is given. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): lunora eject core — the no-lock-in exit hatch gaps.md d2: a pure eject flow that pulls the tenant's full data snapshot through its admin export api, scaffolds the byo wrangler.jsonc the project would have had outside the platform (do bindings, d1 placeholder, sqlite migrations), and writes a restore readme — all over injected ports so the packaging is fully unit-tested; the cli wires the real i/o. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * style(cloud): use a template literal in the eject scaffold https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): studio tabs for domains, builds, and runtime logs wires the round-7 backends into the hosted studio: a domains tab (add → txt record callout → verify → live verified badge, remove), a builds tab (per-project build list with live streamed output), and a logs tab (deployment picker over a live runtime-log tail). marks c2/d2/f shipped in the gap plan. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): fat-vs-thin runtime spike + fleet re-release pipeline gaps.md e4 (the plan's ⭐ decide-now item). spike package (spikes/runtime-versioning): the analysis — user functions execute inside ShardDO and workerd has no dynamic code loading, so true-thin is a distributed-transaction redesign, not a packaging change — plus live probes for the three deciding hypotheses (cross-script DO bindings under wfp, callback per-hop cost vs a 1ms viability line, fat-path patch throughput arithmetic). provisional call: fat + pinned runtime + automated forced re-release. that pipeline ships here too: deployments record their runtimeVersion, and src/fleet/upgrade.ts plans canary-first batches and halts on a dirty canary or breached failure rate — a runtime patch becomes a paced batch job over the existing build + health-gated release machinery. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): ring-2 pass — harden, close seams, finish product edges security: github installations move to a staged-claim model (webhook stages, owner/admin claims; recordPush only accepts claimed installations and caps in-flight builds), domains.add enforces the customDomains entitlement, and audit coverage lands for domains, rollback, deletion requests, installation claims, and both suspension crons. seams: build → deploy handoff via the runner's release port (failed release keeps the artifact), stale-build self-healing cron, superseded-release pruning (retain 3/project), and server-built pr previews through the same pipeline. product: per-environment secrets (all/production/preview/dev with kind-over-shared resolution + studio picker), rollback button, suspension/deletion banners, org rename, member role change (last-owner protected), project rename. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): switch billing to creem as merchant of record Resolves gaps.md c3: creem (via @lunora/payment/creem) replaces the stripe adapter as the platform's payment provider. As a merchant of record it is the legal seller and calculates/collects/remits sales tax/vat across 190+ jurisdictions, so the platform never inherits worldwide tax compliance. Swaps the adapter wiring in the shard config (CREEM_API_KEY / CREEM_WEBHOOK_SECRET / CREEM_TEST_MODE for the sandbox), the webhook route + action to the creem-signature header, the studio copy to creem product ids and hosted portal, and the docs. Entitlements, dunning, plans, and quota enforcement are unchanged — they ride the provider-agnostic subscriptions store. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): prepaid-credits overage billing core for creem Verified against creem sdk 1.5.3: products are recurring/onetime only — no metered subscription pricing — but creem ships a first-party credits ledger (per-customer accounts, idempotent credit/debit by reference) built for api metering. Overage is therefore prepaid: orgs buy credit packs (one-time mor sales, tax handled by creem) and the platform debits usage beyond the plan's included quota. Ships the pure core (included quotas per plan, cost-plus overage rates, watermark-delta debits with crash-safe idempotent references, exhausted → the existing c1 suspension path, never negative), the overageDebits watermark table with forward-only advancement, and 10 tests. The live credits api wiring (CreditsLedgerPort) is the remaining 🌐 piece. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): creem credits-ledger adapter and fleet overage reconciliation Completes the api/token-metering implementation over creem's customerCredits api: a structural ledger adapter (balance reads via bigint-safe strings, debits with the idempotent watermark reference, missing account → null and never debitable), applyCreditPurchase for the billing webhook (first purchase creates the account seeded with the pack; later ones credit with the payment id as reference), the organizations.creditsAccountId linkage (never overwritten once set), and reconcileAllOverages — the fleet driver with per-org failure isolation, watermark-advance strictly after a successful debit, and exhausted balances handed to the c1 suspension hook. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): studio ux pass — usage meters, daily chart, command palette Ring 3, patterns from the maple teardown (fsl-licensed observability platform — ideas only, all code our own). usage tab: included-vs-used plan-quota meters (amber at 80%, red past allowance, honest prepaid- credits overage label) + a per-day request-volume chart over the new usage.series query, rendered with a zero-dependency svg bar chart. adds a ⌘k command palette (tab navigation + actions, substring match, arrow/enter/escape keyboard flow, state reset by remount) wired into the org dashboard. gaps.md gains the ranked ring-3 backlog (alerting pillar, health charts, log-viewer upgrade, design tokens, onboarding checklist, mcp surface, integrations hub). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * fix(cloud): keep cron triggers within cloudflare cap The control-plane Worker declared 4 distinct cron expressions (0 */1, 0 */6, 0 */12, */1) — one over Cloudflare's hard limit of 3 Cron Triggers per Worker, which would reject the deploy. The lone 0 */12 trigger existed solely for "purge deleted organizations". Fold that job into the existing 6h bucket: the purge gates on each org's own 30-day retention cutoff, so a tighter cadence only shortens erasure latency — it never erases early. Codegen drops the 0 */12 trigger, leaving exactly 3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01423xZDDqzhQ5D79vy25huF * feat(cloud): observability ingest pipeline — issues + incidents (Phase 3) (#140) * feat(cloud): add the observability ingest pipeline (issues + incidents) Phase 3 of the observability plan — durable, cross-deployment monitoring in the Lunora Cloud control plane, fed by the Phase 2 OTLP transport. - ingest: `POST /v1/telemetry` accepts OTLP-over-HTTP/JSON from the tenant `otlpSink` and the container exporter, decodes the error spans (`src/telemetry/otlp.ts`), and folds them into grouped issues/incidents through a deploy-key-authorized `telemetry.ingest` mutation. Synchronous — the cloud app has no queue producer binding, so ingest inserts to D1 directly (like `usage.ingest`); auth reuses `authorizeDeployKey`, not the plaintext admin token. - store: `issues` + `incidents` `.global()` D1 tables, fingerprinted with `@lunora/fingerprint` (the same hash the local Studio computes, so a local Issue and a cloud Issue are one object); `lunora/{issues,incidents}.ts` member-authorized read/triage functions. A `TelemetryStore` adapter (`src/telemetry/store.ts`) owns the non-relational side — AE metrics plus a guarded Pipeline→R2 archive, each a no-op without its binding. - dashboard: hosted `IssuesSection` / `IncidentsSection`, gated behind the `logStreams` entitlement, wired into `OrganizationDashboard`. - bindings: a `TELEMETRY` AE dataset + `TELEMETRY_BUCKET` R2 bucket. Vendors `@lunora/fingerprint` (Phase 1, not yet merged) so this stacks on the cloud branch; the graft folds away once Phase 1 lands on alpha. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm * feat(cloud): observability alerts — rules + delivery (Phase 4) (#141) * feat(cloud): add observability alerts — rules, firing + delivery Phase 4 of the observability plan (the "watches while you sleep" tier), stacked on the Phase 3 ingest. - schema: `alertRules` (name, target issue/incident, threshold, channel email/webhook, destination, enabled) + `alerts` (fired-alert audit trail with firing→delivered state, notification denormalized). - firing: the telemetry `ingest` mutation loads the org's enabled rules and fires each the first time a source's count crosses its threshold (`before < threshold <= after`, so exactly once), inserting a `firing` alert row. The pure crossing/render logic lives in `src/telemetry/alerts.ts` (unit-tested), mirroring how `usage.ingest` delegates to `evaluateSpendCap`. - delivery: the `/v1/telemetry` edge handler delivers fired alerts best-effort (email via `@lunora/mail`, webhook via JSON POST) then stamps them delivered — never blocking or failing ingest. - functions: `alerts.{rules,createRule,setRuleEnabled,deleteRule,list, markDelivered}` (member-authed reads/writes; deploy-key-authed markDelivered). - dashboard: `AlertsSection` (manage rules + recent fired alerts), gated behind the `logStreams` entitlement, wired into `OrganizationDashboard`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm * fix(cloud): validate webhook alert destinations against SSRF An alert rule's webhook `destination` is `fetch`ed by the control plane when the alert fires, so an owner/admin could otherwise aim it at internal infrastructure (loopback, RFC-1918, the 169.254.169.254 metadata IP, …) — server-side request forgery. Add a pure `isSafeWebhookUrl` guard (https only, public host, no embedded credentials, no loopback/private/link-local IPv4 or IPv6) enforced both at `createRule` (reject the rule) and in `deliverAlert` (never fetch an unsafe target — defense in depth for any rule created before this guard). String-level, so it can't defeat DNS rebinding, but it blocks the direct-address cases. Unit-tested. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud): harden webhook SSRF guard Two SSRF gaps in the Observability alert delivery path: - deliverAlert followed webhook redirects, so a destination that passes isSafeWebhookUrl could 3xx-redirect to an internal address (e.g. the metadata IP). Set redirect: "manual" and reject 3xx responses. - isSafeWebhookUrl let IPv4-mapped IPv6 (::ffff:169.254.169.254, which the URL parser compresses to ::ffff:7f00:1) and the unspecified address (::) through. Reject the whole ::-prefixed non-global class. Numeric IPv4 forms (2130706433, 0x7f000001, 0177.0.0.1) were already blocked via WHATWG URL normalization; added as regression tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfLmCwH5xMfz7L73LRPFj --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(cloud): AI incident triage (@lunora/ai) — Phase 4C (#142) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(runtime): use LunoraError not undefined CirrusError in cloud endpoints The scheduled-tick and queue-dispatch admin endpoints threw `new CirrusError(...)`, a class that exists nowhere in the repo, so the file failed to type-check (TS2304). The intended class is `LunoraError`, already imported and used throughout the file. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cloud): full tenant log management with structured fields + trace correlation Consume the structured, trace-correlated logs the framework now emits (shared/log-event.ts) — the cloud log path kept only 3 severities + a flat line and had no producer. Closes the framework side of Maple gap #2. - Producer (GAPS.md B2, the missing piece): src/tail/worker.ts — the dispatch-namespace tail worker decodes each tenant `{source:"lunora", type:"log"}` console event (src/tail/parse.ts, pure + unit-tested), groups them per script, and POSTs batches to POST /v1/logs/tail. Holds one platform secret (LUNORA_TAIL_SECRET), not per-org deploy keys; the route resolves scriptName → org (logs.orgForScript) and stores via logs.ingestInternal. Deployed from tail.wrangler.jsonc. - Store: tenantLogs widened to the full LogEvent shape — 7-tier severity, message, structured fields, functionPath, traceId/spanId, userId, shardKey — plus (scriptName, createdAt) and (org, traceId) indexes. - Query: logs.list gained server-side levels/functionPath/traceId/search filters + a cursor and bounded limit, newest-first. - UI: the studio Logs tab renders severity chips (filter), search, structured fields, and a short trace id per line. Still 🌐: the provisioner setting tail_consumers on tenant scripts, an e2e run, and correlating error/fatal lines to OTLP Issues by traceId (follow-up). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud): provision tenant bindings so deployed workers boot The deploy handler built the provisioner spec with an empty binding set (`bindings: {}`), so every uploaded tenant Worker was created with no Durable Object binding and no `new_sqlite_classes` migration tag. A real Lunora app always exports ShardDO, so it could never boot — the deploy pipeline could only ship a binding-less worker. The deploy request now carries the app's binding manifest (DO classes, optional per-tenant D1/R2) which the CLI reads from `wrangler.jsonc`, and the handler normalizes it to a spec that always includes the ShardDO floor even when a caller under-declares or omits it. Malformed entries are dropped and the DO list is capped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): tear down Cloudflare scripts for destroyed deployments The lifecycle crons (cleanupExpiredPreviews, pruneSuperseded, organizations.purgeDeleted) only transitioned a deployment to `destroyed` — nothing ever deleted the Cloudflare dispatch script, so dispatch namespaces grew unboundedly (the leak GAPS.md Ring-2 flagged as closed). Add a `teardownAt` marker and a pure, port-injected `runTeardownSweep` (per-target failure isolation, crash-safe idempotent off the marker), wired into the control-plane Worker's scheduled() handler on the hourly/6-hourly buckets — right after the crons that mark rows destroyed. No-ops without Cloudflare credentials. Per-tenant D1/R2 teardown-by-id still needs resource-id persistence and is left as a follow-up; script deletion is the load-bearing fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): fold Analytics-Engine usage into the metering ledger The dispatcher wrote one AE data point per tenant request, but nothing ever read them back — createHttpAnalyticsReader had no caller, so `platformUsage` only held what tenants self-report over POST /v1/usage (nothing, in practice). Spend caps, the usage summary, and the usage chart therefore evaluated an empty ledger. Add a per-cell `usageReadAtMs` checkpoint and a pure, port-injected `runUsageRollback` that delta-reads AE (`timestamp > checkpoint`), attributes each dispatch script to its org/deployment, and appends `requests` rows — then advances the checkpoint so re-runs never double count. A per-row ledger failure is dropped rather than retried (under- count, never double-bill — the same fail-safe as usage.rollup). Wired into scheduled() on the hourly/6-hourly buckets; no-ops without Cloudflare credentials. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): build-queue dispatcher (claim → run → drain) `builds.claimNext` had no caller: enqueued builds sat untouched until the 24h expiry cron failed them with "no build runner picked this up". Add the missing claim→run loop as a pure, port-injected `runBuildDispatch` (bounded per-tick drain; a failed build never aborts the drain), fully unit-tested against the runner ports. Production activation stays gated on the runner's 🌐 seams — `execute` (a throwaway Cloudflare Container running `lunora build`) and `fetchSource` (GitHub App tarball) — which need live container infra, so the dispatcher is not yet wired into scheduled(): claiming builds with no executor would only burn them. This lands the verified logic so the remaining work is purely the container seam, not the orchestration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * docs(cloud): split GAPS legend into wired vs pure-module (🧩) The single ✅ conflated "tested pure function exists" with "feature runs". Add a 🧩 marker for tested-but-uncalled modules, a dated wiring-pass section covering the four gaps just addressed, and correct the two most misleading inline entries: - A3 builds: the claim dispatcher now exists (was missing); only the container execute() seam remains 🌐. - C3 overage credits: reconcileAllOverages / applyCreditPurchase have no production caller (verified) — scheduling + webhook mapping are code (🔨), not credentials (🌐), so the honest status is 🧩, not "✅ core". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): boot-time route classification scanner Port of Openship's route-scanner idea (Apache-2.0) to the /v1 router. The control-plane routes each did inline auth then delegated to a self- authorizing function, but nothing forced a *new* route to be classified — an unclassified endpoint could ship silently and read as protected. Every route now carries an explicit RouteSpec.auth (deployKey / session / webhookHmac / tailSecret / adminToken / public), and assertRoutesClassified runs when createDeployRouter builds the table: a missing/unknown classification, a public route with no reason, or a duplicate (method, path) throws at construction — the Worker fails to start rather than serving an unclassified route. The flat dispatch tables are derived from the one checked list (GET + POST unified). The spec's opt-in `mcp` field is the allowlist the MCP surface will read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): MCP surface generated from the route registry Port of Openship's "MCP tools derived from the route registry" idea (Apache-2.0). A `/v1/mcp` JSON-RPC endpoint (tools/list + tools/call) exposes only routes that opt in via RouteSpec.mcp, and every tool call dispatches back through the real router carrying the agent's own bearer credential — so it runs the identical auth + rate-limit + handler + function-authz path as any HTTP caller; the MCP layer grants no privilege. A hard deny-list (buildMcpTools) guarantees token/secret/tenant-access routes (/v1/secrets, /v1/admin, /v1/invitations/send, /v1/logs/tail) and the surface itself (/v1/mcp) can never become tools even if mis-annotated — the same scope-escape guard Openship applies to tokens/auth/mcp. Only bearer-callable (deployKey/adminToken) opted-in routes are eligible; session/webhook routes are excluded. deployments.rollback is the first tool exposed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * chore(codegen): regenerate _generated for teardownAt + usageReadAtMs Keeps the emitted dataModel/shard/drizzle types consistent with the new deployments.teardownAt and cells.usageReadAtMs schema columns. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): tear down tenant D1 + R2, and test the sweep glue Extends resource teardown past the dispatch script (#2). The lifecycle sweep now also deletes the per-tenant D1 database and R2 bucket, resolved by the same naming convention the provisioner creates them under (shared tenantD1Name / tenantR2Bucket helpers — no drift, no new persistence). New CF API methods: findD1DatabaseByName + deleteD1Database (uuid) and deleteR2Bucket (name). Script + D1 delete are retryable; R2 is best-effort (a non-empty bucket needs an S3-API object purge the teardown context lacks — logged, left for follow-up). D1 (every .global() app has one) and empty R2 buckets are now fully reclaimed. Also extracts the scheduled() sweep glue into testable port-builders (#4): teardownPorts + usageRollbackPorts over a structural ControlPlaneDb, so the row→target mapping, the teardownAt stamp, the ledger insert, and the per-cell checkpoint are unit-tested against a fake store instead of living untested inside server.ts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): carry cronSpecs + bindings from wrangler on deploy The cron fan-out read live deployments' cronSpecs, but nothing ever populated them: deployments.create accepted the field yet the deploy handler/router never passed it, so readCronTargets always returned [] and the entire §2.4 tenant-cron fan-out had no data source (#3). Add parseWranglerManifest — a pure reader that extracts the binding manifest (DO classes / D1 / R2) and cron expressions from a tenant's wrangler.jsonc — and thread cronSpecs through the deploy request → handler → create mutation. The deploy client + CLI now forward both bindings and cronSpecs, so a real deploy provisions what the Worker needs and registers the crons the fan-out drives. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): synthetic uptime monitoring with alerting Adds external-vantage uptime to the control plane's Observability tier — the piece a deployment can't self-report (if it's down, it can't say so). - Probe: pure probeDeployment (generalizes the deploy-time healthCheck — GET, sub-500 = up, latency + timeout, never throws), a consecutive-failure state machine, and a summarizer, all unit-tested (src/uptime/probe.ts). - Sweep: runUptimeSweep over injected ControlPlaneDb ports (mirroring the teardown/usage sweeps) probes every live deployment, records a uptimeChecks row, advances uptimeState, and fires an "uptime" alert the first time a deployment's failures cross a rule threshold — reusing crossesThreshold, renderAlert, and the alerts table/delivery pipeline (src/uptime/sweep.ts). - Edge: server.ts scheduled() runs the sweep on the every-minute tick and delivers fired alerts over their channel (webhook/email), stamping the outcome. - Alerts gain an "uptime" target (schema + createRule + renderAlert), so users configure "page me when my deployment is down" alongside issue/incident rules. - Read side: lunora/uptime.ts (summary + recent queries, retention prune cron) backs a new Uptime dashboard section. Cron triggers stay at 3 expressions (prune rides the 6h bucket, the probe rides the existing every-minute tick). Full suite: 272 tests, lint:types clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019M6G6CAoLVrQMxDYg7BWq2 * fix(cloud): address thermo review of uptime monitoring Security/correctness (branch audit): - SSRF: th…
…rts, sessions, dashboards (#179) * feat(studio): browse the durable log archive in the Logs panel (#155) * feat(studio): browse the durable log archive in the Logs panel Add a third "Archive" feed to the studio Logs panel that reads the durable ctx.log archive pipelineLogSink writes to R2 (Iceberg / R2 Data Catalog). - @lunora/runtime: a new admin-gated `/_lunora/admin/logs/archive` route (`log-archive-admin-routes.ts`) runs `createPipelineLogReader` server-side — the R2 SQL token stays on the worker, only decoded `{ rows, nextCursor }` reaches the browser. Reads creds from env (`R2_SQL_*`, `CLOUDFLARE_ACCOUNT_ID` fallback) + the table from a new `logArchive` WorkerOption. Fails closed with `LOG_ARCHIVE_NOT_CONFIGURED` when unwired. - @lunora/client: `queryLogArchive(query)` method + re-exported PipelineLog* wire types (owned by @lunora/runtime). - @lunora/studio: a self-contained `ArchiveFeed` (function/user/min-level filters, keyset "Load more", a "not configured" empty state distinct from an error) rendered under the new Archive tab; `errorCode` helper. - Docs + API snapshots updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * refactor(studio): drop try/finally in ArchiveFeed for React Compiler The React Compiler bails on a `try` with a `finally` clause (React Doctor `react-hooks-js/todo`), so the component missed automatic memoization. Reset `loading` in each branch instead, matching the repo's no-`finally` pattern. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * refactor(studio): address thermos review of the archive feed Code-quality + correctness follow-ups from the two-pass review: - Extract the duplicated LEVEL_VARIANT map into `log-level-variant.ts`, shared by the live Logs feeds and the Archive feed (restores the exhaustive `Record<LogLevel, BadgeVariant>` type — drops the `?? "outline"` fallback). - Collapse the four `view !== "archive"` readout guards in logs-panel into one `view === "archive" ? <ArchiveFeed/> : <>…</>` branch. - Drop the `JSON.parse(JSON.stringify(baseQuery))` round-trip in the fetch effect — pass `baseQuery` directly, keying the effect on `querySignature`. - Guard `loadMore` against a cross-filter race: a page-2 fetch that resolves after a filter change is dropped (via `activeSignatureRef`) instead of appending stale rows / overwriting the cursor. - Show a "Loading…" placeholder on the initial fetch instead of a blank panel. - Type `minLevel` state as `"" | ContextLogLevel` (removes a cast). - Move the `LOG_ARCHIVE_NOT_CONFIGURED` sentinel to `shared/log-archive.ts` so runtime and studio share one source of truth with no dependency edge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * refactor(studio): address CodeRabbit review of the archive feed - Fold ArchiveFeed's five fetch-related useState into one useReducer, so each fetch transition (loading / loaded / append / notConfigured / failed / pageFailed) is a single dispatched action (React Doctor prefer-useReducer). - Use the imported `ChangeEvent` type instead of the `React.*` namespace, matching logs-panel.tsx. - Gate the toolbar `LiveError` on `view !== "archive"` so the disabled Errors feed's live-connection state can't leak into the (WS-less) Archive tab. The CodeRabbit "cast env to LogArchiveEnvironment" suggestion is intentionally skipped: `env ?? {}` narrows to `{}`, which is assignable to the all-optional LogArchiveEnvironment (lint:types is green), and eslint's no-unnecessary-type-assertion rejects the cast. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * polish(studio): clear prior error on a fresh archive fetch Thermos re-review nice-to-have: the reducer's `loading` action now clears `error`/`notConfigured` (matching the kv reducers' `submitStart`), so retrying after a failure shows the loading placeholder instead of the stale error line. Rows are kept, so paging / filter-change refetches don't blank the table. (Kept `default: return state` to stay consistent with the existing kv reducers rather than introduce a one-off `unreachable` exhaustiveness guard.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * chore(release): @lunora/runtime@1.0.0-alpha.32 [skip ci]\n\n## @lunora/runtime [1.0.0-alpha.32](https://github.com/anolilab/lunora/compare/%40lunora%2Fruntime%401.0.0-alpha.31...%40lunora%2Fruntime%401.0.0-alpha.32) (2026-07-21) * chore(release): @lunora/client@1.0.0-alpha.26 [skip ci]\n\n## @lunora/client [1.0.0-alpha.26](https://github.com/anolilab/lunora/compare/%40lunora%2Fclient%401.0.0-alpha.25...%40lunora%2Fclient%401.0.0-alpha.26) (2026-07-21) * **@lunora/runtime:** upgraded to 1.0.0-alpha.32 * docs: add cirrus cloud platform plan Reverses the managed-deploy-plane won't-do (VOID-TEARDOWN.md §0/§6, CONVEX-PARITY.md #23) with a scoped managed tier: Workers for Platforms data plane, Convex-shaped product model (teams/projects/prod+dev+preview deployments), PartyKit-style managed-vs-BYO CLI split, and a phased roadmap starting with remote-binding dev. Synthesized from a repo inventory, a Convex Cloud teardown, and a GitHub/Cloudflare-primitives survey. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: fold supabase platform teardown into cloud plan Adds Supabase as a reference model: the OSS/proprietary cut line, IS_PLATFORM single-codebase studio pattern, Branching 2.0 preview DX (and its pain points to fix: empty branches, hourly branch billing outside spend caps), the Management API + OAuth-apps growth channel, and the structural cost advantage WfP gives over per-project VMs. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: add wfp constraints, eject path, spike checklist Gap review of the cloud plan: documents that cron triggers are silently dropped for namespaced user Workers (with SchedulerDO alarm-based fan-out mitigation), queue-consumer and send_email verification items, KV account-limit multiplexing, EU jurisdiction toggle, a cirrus-eject portability command built on existing export/import RPCs, namespace-wide observability reuse, managed backups + abuse controls in Phase 4, and a Phase 1 constraint spike. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: verify cloud plan claims against cloudflare docs Fact-checked every Cloudflare claim in CLOUD-PLAN.md against the official docs: WfP pricing, KV/D1/R2 account limits, DO/R2 jurisdictions vs D1 location hints, CF for SaaS hostname pricing, and remote-bindings GA versions all confirmed. Adds three newly verified constraints: no gradual deployments for user Workers (rollback must be platform-side bundle re-upload), the 1200-req/5-min account API rate limit on provisioning, and outbound-Worker TCP/DO interception trade-offs. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: add cell-based scaling architecture to cloud plan Answers how the managed tier scales without hitting account limits or risking platform-wide blocks: script-resident tenant state with lazy inference-driven provisioning, multi-account cells with cell IDs baked into identifiers from day one, a per-cell API token-bucket scheduler, a tenancy graduation ladder up to managed-BYO and the Tenant API, and abuse containment to keep tenant abuse from looking like platform abuse. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: scope alchemy to cell bring-up, not tenant deploys Records the provisioning-engine decision: the per-tenant deploy path stays hand-rolled on cloudflare-typescript (control-plane DB as the single source of truth, cell scheduler, progress events, rollback artifacts); Alchemy (pre-1.0, v2 rewrite underway, no confirmed dispatch-namespace resource) is a candidate only for low-cardinality cell bring-up IaC, with Terraform/Pulumi as the fallback. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: correct alchemy facts (dispatch-namespace resource, v0.93) Re-checked Alchemy against GitHub/npm: it is v0.93.12 (Apache-2.0) and does ship a dispatch-namespace (Workers for Platforms) resource — my earlier 'lacks a confirmed dispatch-namespace resource' was wrong and 'v0.9x' undersold it. Recommendation is unchanged (hand-roll the per-tenant deploy, use Alchemy for cell bring-up) but now rests on the real reason — source-of-truth shape and deploy-orchestration concerns, not capability — with a re-evaluation trigger at a stable 1.x. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: adopt alchemy as the provisioning engine Decision: Alchemy is the provisioning engine across cell bring-up, per-tenant managed deploy, and BYO. Verified it ships DispatchNamespace/ Worker/D1/R2/DO resources plus a built-in D1StateStore and runs inside a Worker (await alchemy(scope) -> finalize/destroy). Backing each tenant scope with the control-plane D1 collapses the two-sources-of-truth concern into one store. The per-cell rate-limit scheduler now paces finalize() runs; bundling stays in the Vite pipeline; rollback re- converges to a prior R2-retained bundle. Risk of a 0.x dependency contained behind a @cirrus/provision adapter with cloudflare-typescript as fallback; spike open items recorded. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: build on alchemy v2 (alchemy@next, 2.0.0-beta.55) Per decision to start on the v2 line: target alchemy@next (verified 2.0.0-beta.55, Effect-based) to avoid a v1->v2 migration mid-build, with v1 0.93.x as the named fallback. Records the trade-offs (beta churn, Effect pulled into the control-plane tree, quarantined behind the @cirrus/provision adapter) and turns the unverified v2 facts (DispatchNamespace resource + D1/DO state store, confirmed on v1 only since docs/CDN were unreachable) into hard Phase 1 spike gates with v1 fallback per surface. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: commit fully to alchemy v2, drop v1 fallback Remove the v1 (0.93.x) fallback hedging throughout: v2 (alchemy@next, 2.0.0-beta.55) is the engine outright. Spike gates remain but now resolve via owned shims or upstream contributions rather than retreat to v1; a hard unresolvable 'no' escalates the engine decision instead of silently dual-tracking. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: add forgotten must-haves + fleet runtime-versioning risk Gap pass on the cloud plan. New risk #8 (the load-bearing one): the Cirrus runtime is bundled into each tenant Worker, so a security patch means redeploying the whole fleet unless the tenant Worker is made 'thin' against a central runtime — a fat-vs-thin decision that must be made before Phase 1 since it shapes the bundle format, deploy API, and vite emit. New section 7 collects launch-blocking gaps the plan had assumed away: control-plane DB durability/DR, cross-cell disaster recovery, secrets-at-rest + cell-token custody, frontend-hosting scope, AUP + bill-shock/cryptomining controls, billing/MoR/tax, GDPR processor/DPA/SOC2, account offboarding + right-to-erasure, platform self-observability + status page, dispatcher canary, and a staging cell. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): scaffold control-plane app built on cirrus First implementation step from CLOUD-PLAN.md: a new apps/cloud workspace app that dogfoods Cirrus as the platform's own control-plane backend. - cirrus/schema.ts: control-plane data model (cells, organizations, members, projects, deployments, deployKeys, auditLog), all .global() (D1) — the plan's 'Worker + D1' control plane. - cirrus functions: organizations/projects/deployments/cells/deploy-keys (create/list/issue/updateStatus), with owner seeding + audit trail. - src/server.ts: control-plane Worker entry wiring D1-backed global tables. - src/provision.ts: the @cirrus/provision seam — the sole coupling to the Alchemy v2 engine (stub that rejects until the Phase 1 spike wires it). - configs (package.json/tsconfig/project.json/wrangler.jsonc/eslint/vitest), README, and a provision test. Verified: codegen clean (no advisories), eslint clean, tsc --noEmit passes, vitest green. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): add deploy-orchestration core Builds the next control-plane layer on the scaffold's Provisioner seam, all pure/testable (no live Cloudflare needed): - token-bucket.ts: per-cell API budget (§2.5), models CF's 1200/5min account limit; deterministic + clock-injectable. - scheduler.ts: CellScheduler paces/serializes provisioner work against the bucket with priority ordering + a concurrency cap. - orchestrator.ts: runDeployment state machine emitting queued → provisioning → live/failed progress events (§2.2); destroyDeployment for preview-TTL/project teardown. - keys.ts: deploy-key format/parse/hash helpers; deploy-keys.ts mutation refactored to use them (one tested place for the format + SHA-256). 17 tests across 5 files; eslint + tsc --noEmit clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): add org authorization + deploy-key lifecycle Closes gaps found reviewing the control plane: - authz.ts: assertMember(ctx, orgId, roles?) — the org ACL gate. Every org-scoped function now verifies the caller is a member with a permitted role, closing an IDOR hole where any signed-in user could read/mutate any org by passing its id. Applied across projects, deployments, deploy-keys. - members.ts: list / add / remove so memberships can actually be granted (owner is seeded on org create; admins/owners manage the rest). - deploy-keys: verify (the deploy API's auth path — match by SHA-256, reject revoked, bump lastUsedAt, return the DB-authoritative target) and revoke (leaked-key mitigation); lastUsedAt/revokedAt are now live. - deployments: create checks the project belongs to the org; updateStatus loads the deployment and gates on its org (documented as the system seam for the orchestrator). codegen clean, eslint + tsc --noEmit clean, 17 tests pass. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): deploy API endpoint, deploy-key auth, handler tests Lands the three remaining pieces together: - Deploy API: POST /v1/deploy mounted via the httpRouter seam (src/deploy/ router.ts) → pure handler (src/deploy/handler.ts) authenticates the bearer deploy key, records a queued deployment, drives runDeployment through the per-cell scheduler, and streams NDJSON progress (accepted→queued→ provisioning→live/failed→done), patching status per phase. - Auth path: investigation showed internalMutation is unreachable from the HTTP action-context dispatch (no system flag → RPC 404), so verify/ updateStatus stay public; instead added deploy-key authorization (authz.authorizeDeployKey) and a dual-path (member session OR deploy key) on deployments.create/updateStatus, so CI deploys need no user session. Corrected the stale 'should become internalMutation' comments. - Tests: handleDeployRequest (401/403/400 + success and failure streaming + status transitions) and authz (assertMember + authorizeDeployKey) via a fake ctx. 28 tests / 7 files; eslint + tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs(cloud): refresh status — deploy API + auth now in place * chore(cloud): track generated schema snapshot Matches the apps/playground convention — .cirrus-schema.json is the codegen schema snapshot used for migration/drift detection and is committed, not gitignored. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): preview lifecycle, GitHub webhook, deploy client (Phase 1/2) Phase 2: - Preview deployments are TTL'd: deployments.create stamps expiresAt for kind=preview (src/deploy/preview.ts: deterministic previewScriptName + 5-day previewExpiry); an hourly cron (cirrus/crons.ts -> internal deployments.cleanupExpiredPreviews) marks expired previews destroyed. Worker gains scheduled(); wrangler cron trigger added. - GitHub webhook (src/github/webhook.ts): HMAC-SHA256 verify + pull_request -> preview-intent parsing, mounted at POST /v1/github/webhook. Phase 1: - Deploy client (src/deploy/client.ts): the cirrus-deploy core — POSTs to /v1/deploy and consumes the NDJSON progress stream. 13 new tests (41 total); codegen/eslint/tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: update roadmap — Phase 0 shipped, Phases 1-2 status Phase 0 (remote-binding dev) is already implemented in the framework (@cirrus/config remote-bindings + @cirrus/vite plugin + cirrus dev; 30 tests) — corrected from 'not started'. Phases 1-2 marked substantially-built with the live-Cloudflare-dependent remainder (Alchemy provisioner, dispatcher, e2e validation) called out. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): team invitations + billing quotas; repo-extraction guide (Phase 3/4) Phase 3 (hosted studio sliver): - Team invitations (cirrus/invitations.ts + invitations table): invite/list/ revoke/accept, single-use SHA-256-hashed tokens (plaintext mailed once), owner-admin gated; accept-by-token adds the caller as a member. Phase 4 (billing sliver): - Plans + quota entitlements (src/billing/plans.ts) on @cirrus/payment's entitlements model — free/pro/enterprise limits + feature flags, with effectiveLimit/withinQuota and a free-tier fallback for non-subscribers. Portability (move to a private repo): - EXTRACT.md documents the mechanical extraction; audit confirms the app imports only published @cirrus/* entry points (no monorepo-internal reaches). 6 new tests (47 total); codegen/eslint/tsc clean; secret-scan clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): real Cloudflare provisioner, quota enforcement, webhook project resolution Provisioner (Phase 1 — the big one): replaces the rejecting stub with a real implementation over a typed Cloudflare REST port (src/cloudflare/api.ts): deploy provisions per-tenant D1/R2, uploads the user Worker into the dispatch namespace with binding + DO-migration metadata, applies secrets, returns the bundle hash + routed URL; destroy deletes the script. Port-injected so it's tested with a fake; plug in CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN to run. (REST via fetch rather than the unverifiable alchemy@next beta — same seam.) Quota (Phase 4): plans.ts gains planLimit/withinPlanQuota; projects.create and members.add enforce the org plan's limits. Preview automation (Phase 2): projects gain githubRepo + byGithubRepo lookup; the webhook resolves the connected project and returns the preview script name. Env documented (.dev.vars.example + wrangler vars). 50 tests; eslint/tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): dispatcher Worker + hosted-studio admin-RPC proxy (Phase 1/3) Phase 1 — dispatcher Worker (the request-path front door): resolveTenant maps {scriptName}.{appDomain} (and custom domains via injected lookup) to a dispatch-namespace script; the worker forwards via env.DISPATCHER.get with per-plan limits. Separate deployable (dispatcher.wrangler.jsonc). Phase 3 — admin-RPC proxy: proxyAdminRequest authorizes org membership, forwards the admin RPC to the tenant's /_cirrus/admin/* with that deployment's admin token, and records an audit entry. Pure (deps injected). 7 new tests (57 total); eslint/tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): cirrus login/link/deploy CLI commands (Phase 1) Pure command logic over a ConfigStore + the deploy client: login persists the API endpoint + deploy key, link binds a project, deploy streams a managed deploy (requires login+link). File-backed store at ~/.cirrus/cloud.json for the Node CLI; cerebro registration in @cirrus/cli calls these. 3 new tests (60 total); eslint/tsc clean; secret-scan clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: roadmap status — control-plane backend feature-complete as code All phases' backend code is built + unit-tested in apps/cloud (60 tests): real REST provisioner, dispatcher, CLI, preview lifecycle, GitHub webhook, team invitations, admin-RPC proxy, quota enforcement. Remaining items are the ones needing live Cloudflare / external services / the studio UI. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): admin-proxy live wiring, usage metering, custom-hostname port (Phase 3/4) Phase 3 — admin proxy mounted at POST /v1/admin: deployments now carry the platform-minted tenant adminToken (set as the worker's CIRRUS_ADMIN_TOKEN secret + stored on the row), deployments.adminTarget resolves {url, adminToken} after asserting membership, and the router forwards to the tenant's /_cirrus/admin/* with an audit-log.record entry. Phase 4 — usage metering: usageEvents table + internal record mutation + member summary query over a pure aggregateUsage roll-up. Custom hostnames: CloudflareApi.createCustomHostname (Cloudflare for SaaS, zone-scoped REST). Router refactored into per-route handlers. 64 tests; eslint/tsc/secret-scan clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: roadmap — admin proxy mounted, usage metering + custom-hostname port added * feat(cloud): add hosted studio react spa Build the hosted-studio frontend for the Cirrus Cloud control plane: a better-auth-gated React SPA served on one origin with the control-plane Worker via @cirrus/vite. - src/client: main/auth-client/Login, App auth gate, OrganizationList, OrganizationDashboard with tabs for projects, deployments, members, deploy keys, invitations, and usage; AsyncList loading/empty helper. - Wire @cirrus/auth into src/server.ts (createAuth + cirrusD1Adapter, ensureMigrated, handleAuthRequest, authAdmin, resolveIdentity) and add AUTH_SECRET/AUTH_URL env + .dev.vars.example entries. - Switch package scripts to vite (build/dev), add react/react-dom + @cirrus/react/@cirrus/auth deps, vite.config.ts, index.html, and the DOM lib + jsx in tsconfig. - eslint: client section (filename-case, react-perf, void), browser globals; ignore vite.config.ts. - Refresh README + CLOUD-PLAN status to reflect the studio UI. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): add billing, metering, and hardened auth Billing on @cirrus/payment (§4): org id is the payment referenceId. Wire a Stripe adapter into createShardDO({ payment }); add cirrus/billing.ts with checkout/portal actions, entitlements/subscription reads (resolved through CIRRUS_CLOUD_PLANS with a free-tier fallback), and a signature-verified processWebhook mounted at POST /v1/billing/webhook. The studio gains a Billing tab. Platform metering (§4): rename the resource-metering table to platformUsage (freeing usageEvents for @cirrus/payment's billing ledger), add a deploy-key authenticated usage.ingest mutation + POST /v1/usage endpoint, and enforce per-plan runtime limits in the dispatcher (limitsForPlan → DISPATCHER.get). Auth hardening (§3) on @cirrus/auth/better-auth: mail-backed email verification + password reset (@cirrus/mail), optional GitHub/Google OAuth, admin/twoFactor/passkey plugins, built-in auth rate limiting, plus a per-IP @cirrus/ratelimit cap on the /v1/* surface. Invitations now email the token via POST /v1/invitations/send (never shown in the browser). The Cirrus organizations/members model stays the single org source of truth (better-auth organization plugin deliberately omitted). Add deps (@cirrus/mail, @cirrus/ratelimit, stripe), tests for the router routes + rate limiting + per-plan limits (69 total), and reconcile the README + CLOUD-PLAN status (the provisioner is a real Cloudflare REST impl, not a stub). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): enforce entitlements, wire metering source, add secrets Close the billing loose ends and add the metering source, tenant secrets, and an audit-log view. Entitlements (close loose end #1): quota is now enforced against live subscription state (cirrus/entitlements.ts resolves from the synced `subscriptions` table) rather than the static organizations.plan column — projects/members creation call assertWithinQuota, so a Stripe upgrade raises limits immediately with no column to sync. Per-plan dispatch limits (close loose end #2): deployments.planForScript + a bearer-gated GET /v1/tenants/plan endpoint + a cached plan resolver in the dispatcher (createPlanResolver) wire resolvePlan, so runtime limits actually scale per plan instead of always falling back to free. Metering source: the dispatcher emits one Analytics Engine data point per tenant request (src/metering/analytics.ts); a reader port + HTTP impl and an hourly usage.rollup compaction cron complete the pipeline alongside the existing /v1/usage ledger ingest. Tenant secrets (§7): AES-256-GCM envelope encryption at the edge (src/secrets/crypto.ts), a secrets table (ciphertext + IV only), store/list/ listEncrypted/remove functions, POST /v1/secrets, deploy-time materialization into the tenant Worker, and a studio Secrets tab. Studio: add Secrets + Activity (audit log) tabs; add audit-log.list. Tests: crypto round-trip, plan resolver caching/fallback, entitlement quota, analytics writer/reader (83 total). Docs + .dev.vars.example updated. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * fix(cloud): address review findings (rollup atomicity, deploy failure, dedup) Apply /review findings on the recent billing/metering/secrets work: Correctness: - usage.rollup: the D1 global backend has no multi-statement transaction, so the old insert-summed-then-delete-originals order could double-count (over- bill) on a mid-rollup crash. Reorder to delete the extras first, then patch the surviving row's total last — a crash can now only under-count, never leave a summed row beside surviving originals. - deploy handler: a tenant-secret decrypt failure (corrupt secret / rotated key) threw inside the NDJSON stream and left the deployment stuck in `accepted`. Catch it and transition to `failed` with a status update. - POST /v1/secrets: encryption/config failures (e.g. a malformed SECRET_ENCRYPTION_KEY) now return 500, not a misleading 403 (kept distinct from the membership 403 the store mutation raises); reject the reserved CIRRUS_ADMIN_TOKEN secret name up front instead of silently clobbering it. - studio: drop the plan picker from org creation — limits now come from live subscription entitlements, so selecting a paid plan at create-time granted nothing. Orgs start free; upgrade via the Billing tab. Cleanup: - Extract the cross-org IDOR guard into authz.assertRowInOrg and call it from secrets/members/deploy-keys/invitations (was four byte-identical copies). - Remove dead plans.ts exports planLimit/withinPlanQuota (superseded by entitlements-based quota); add a single highestPlan/PLAN_PRECEDENCE helper and use it in deployments.planForScript (was a hand-rolled tier ladder). - Memoize the Stripe payment config per isolate (was rebuilt on every shard request that touches ctx.payments). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * test(cloud): validate websockets through dispatch (phase 1 spike) Validate the hottest path — hibernated-WS subscriptions + per-invocation limits through env.DISPATCHER.get() — the least-documented WfP case (risk #3). - spikes/ws-dispatch/: a runnable harness for live validation on a real dispatch namespace. A framework-free hibernatable-WebSocket Durable Object (the exact primitive ShardDO uses: acceptWebSocket + webSocketMessage), deployable into the namespace, plus a zero-dep Node probe that drives it through the dispatcher and asserts: (1) the WS upgrade survives the dispatch hop (101 + live socket), (2) a hibernated server push (broadcast) reaches the socket — the mutation-to-subscription shape, (3) cpuMs-limit behaviour. The README documents deploy/run, pass/fail, and the expected results + caveats. - __tests__/dispatcher-ws.test.ts: unit-pins the dispatcher forwarding contract (returns the tenant 101+webSocket response unchanged, applies per-plan limits, meters the upgrade once) — runs in CI, no infra needed. - dispatcher worker: clarifying comments on WS pass-through + per-frame metering semantics. CLOUD-PLAN risk #3 now references the harness. The dispatcher half is verified here (94 tests); the end-to-end behaviour needs a live Cloudflare account + the Workers-for-Platforms add-on to run. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): tenant cron fan-out through dispatch (wfp workaround) Cloudflare drops triggers.crons for Workers in a dispatch namespace, so tenant cron jobs never fire. Fan them out from the control plane (CLOUD-PLAN §2.4). - @cirrus/runtime: add an admin-gated POST /_cirrus/scheduled tick endpoint that runs a cron expression's jobs through the SAME handleScheduled path the native scheduled() trigger uses (user crons + code crons + backup), so a platform can drive a namespaced tenant's crons over HTTP. (Dispatch stubs expose only fetch()/connect() — no scheduled()/queue() — so HTTP is the only transport in.) - src/fanout/cron.ts: pure 5-field cron-expression matching (lists, ranges, steps, dom/dow OR semantics) + dueTicks + fanOutCron orchestration. - control plane: capture each tenant's cronSpecs on the deployments row at deploy; an every-minute heartbeat cron (cirrus/fanout.ts) makes codegen emit the */1 trigger, and server.ts scheduled() reads live cron targets and ticks each due tenant via env.DISPATCHER.get(script).fetch('/_cirrus/scheduled') with the per-deployment admin token (kept in-process — never exposed). Adds the DISPATCHER binding to the control-plane wrangler. Tests: cron matching, dueTicks, fanOutCron (103 cloud tests; 337 runtime tests still green). Live validation on a dispatch namespace pending; queue consumer fan-out is the remaining half. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): tenant queue-consumer fan-out through dispatch (wfp workaround) WfP namespaced Workers can't be queue consumers, so tenant queue-backed work (@cirrus/mail sends, scheduler queue-workpool) never drains. Fan it out from a platform-owned consumer (CLOUD-PLAN §2.4) — the queue counterpart to the cron fan-out. - @cirrus/runtime: add a `queueHandler` option + an admin-gated POST /_cirrus/queue endpoint that reconstructs the batch and invokes it, returning the message ids to retry. (Dispatch stubs are fetch-only, so HTTP is the only transport into a namespaced tenant.) - src/fanout/queue.ts: pure grouping of a shared-queue batch by the producing tenant's script (envelope `{ script, body }`) + fan-out orchestration that collects per-message retries and retries a whole group on delivery failure. - control plane: the account-level Worker is the consumer — server.ts queue() drains the shared cirrus-tenant-queue, resolves each tenant's admin token in-process (never exposed), forwards sub-batches via env.DISPATCHER.get(script).fetch('/_cirrus/queue'), and acks/retries per the tenant reply. Adds the queues.consumers binding to the control-plane wrangler. Tests: groupByTenant + fanOutQueue (108 cloud tests; 337 runtime tests green). Live validation on a dispatch namespace + a producer-side script-tagging helper remain. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): align with the lunora rebrand + reuse @lunora/analytics Rebased onto alpha, which renamed the framework cirrus → lunora. Reconcile the control-plane app and reuse a newly-shipped package. Rebrand: - npm scope @cirrus/* → @lunora/* across deps + imports. - app functions dir cirrus/ → lunora/ (+ tsconfig/eslint globs, _generated paths, the committed schema snapshot → .lunora-schema.json). - reserved paths /_cirrus/* → /_lunora/* (incl. the new scheduled/queue tick endpoints) and the runtime-injected env.__lunoraCtx; renamed exported symbols (LunoraError, LunoraClient/Provider, useLunora, lunoraD1Adapter, LunoraAuth*, LUNORA_CRONS/FUNCTIONS); vite plugin cirrus() → lunora(); CLI config dir ~/.cirrus → ~/.lunora. - wire the new required GlobalIntrospector.facetColumn via @lunora/d1's facetGlobalColumn. Reuse: - src/metering/analytics.ts is now a thin domain layer over @lunora/analytics (createAnalytics writeDataPoint + createAnalyticsSqlClient AE-SQL reader) instead of a hand-rolled writeDataPoint + HTTP SQL client. Verified the rest is genuinely cloud-specific (cron-expression matching, AES-GCM secret crypto, the Cloudflare REST provisioner, the per-cell CF-API token bucket) — no upstream equivalent to fold into. 103→108 cloud tests green; runtime 379 tests green; tsc/eslint/build clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): rebrand the product Cirrus Cloud → Lunora Cloud Complete the lunora rebrand to the product layer (the framework already moved): - brand prose Cirrus Cloud → Lunora Cloud across code comments, README, EXTRACT, the studio (index.html title, Login/dashboard), and CLOUD-PLAN.md. - env vars CIRRUS_* → LUNORA_*: LUNORA_ADMIN_TOKEN and LUNORA_MAIL_CAPTURE are functional (read by @lunora/mail); LUNORA_APP_DOMAIN / LUNORA_CELL and the VITE_LUNORA_URL client var follow for consistency. - the LUNORA_CLOUD_PLANS entitlements constant. - infra names cirrus-* → lunora-*: worker names (lunora-cloud, lunora-dispatcher), dispatch namespace (lunora-production), shared queue (lunora-tenant-queue), AE dataset (lunora_tenant_usage), the lunora.app apex, and the deploy dispatch-namespace prefix. - the hosted-CLI verbs (lunora login/link/deploy) and config dir ~/.lunora. - docs' reserved-path/marker refs (/_lunora/*, __lunora_admin__, env.__lunoraCtx). 108 cloud tests green; tsc/eslint/build clean; zero residual `cirrus` references. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * refactor(cloud): migrate functions to fluent builder api Adapt the cloud control-plane functions to alpha v1.0.0-alpha.1's fluent function builders: kind.input({...}).<terminal>(({ ctx, args }) => ...) replaces the removed object form kind({ args, handler }). Regenerate _generated/* and pick up codegen's observability block in wrangler. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): license under polyform noncommercial The control plane is the proprietary product layer, so it must not carry the framework's FSL-1.1-Apache-2.0 (which grants broad commercial rights). Apply PolyForm Noncommercial 1.0.0: any noncommercial purpose is permitted, but commercial use requires a separate license. Replaces UNLICENSED. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): adopt @lunora/bindings/analytics after package fold-in The latest alpha folded @lunora/analytics into @lunora/bindings (subpath export ./analytics, identical API) and codegen now emits _generated/functions.ts importing @lunora/values directly. Swap the dependency and import specifiers, declare @lunora/values, regenerate _generated/*, and reconcile the lockfile. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * fix(cloud): address pr review findings - secrets: assert the project belongs to the org in store/list/ listEncrypted and scope queries by organizationId, closing the cross-org IDOR where a member of one org could read or overwrite another org's project secrets (+ idor tests) - deploy: require a base64 worker bundle in POST /v1/deploy and thread it client → CLI → provisioner instead of uploading an empty module; 400 on missing/malformed bundle - studio: replace try/finally + throw-in-try with promise combinators in Login/Invitations/Secrets forms so React Compiler can memoize them https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs(cloud): add consolidated gap analysis and build plan https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): blue/green releases with health gating and rollback Every deployment now uploads an immutable versioned script ({alias}-v{n}); the project's stable URL follows an active-deployment pointer that only swaps after the new script passes a health probe, so a bad deploy never replaces a serving one (gaps.md a1). Adds POST /v1/deployments/rollback + lunora rollback (pointer swap back to a retained superseded release), GET /v1/tenants/route + a cached alias resolver in the dispatcher, per-phase deployment timestamps (a2), and an x-lunora-id debug header (b3). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): aggregate spend caps with org suspension Per-invocation limits cap one request; nothing capped aggregate period spend (gaps.md c1). Adds a pure spend evaluator at the wfp cost basis with per-plan default caps (org-overridable; explicit 0 = uncapped), an hourly enforcement cron that suspends breaching orgs and self-heals recovered ones, and dispatcher enforcement — a suspended org's tenants serve 503 via the sentinel plan carried through the existing cache. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): custom domains — model, txt verification, hostname routing First slice of gaps.md b1: the domains table (unique hostname, per-org project scoping, redirect-only rows, cloudflare custom-hostname id), add/list/remove/markVerified functions with the same authz gates as secrets, a pure dns-over-https verification core (_lunora txt token + platform cname check, injectable resolver), and routeForHostname — the dispatcher-facing lookup that only ever routes verified domains to the project's active script. Edge routes + dispatcher wiring land next. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): wire custom domains through edge and dispatcher Completes the code-tractable half of gaps.md b1: POST /v1/domains (add, returns the txt record to create), POST /v1/domains/verify (dns-over- https txt + cname checks under the caller's session, outcome recorded via markVerified), GET /v1/tenants/custom-domain for the dispatcher, and a cached custom-domain resolver in the dispatcher that routes verified hostnames to the project's active script and answers redirect-only rows directly. cloudflare-for-saas cert provisioning remains the 🌐 half. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * style(cloud): hoist the trailing-dot regex to module scope https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): server-side builds, build logs, and push-to-deploy webhook gaps.md a3/a4: builds table with a stale-recoverable work lease and commit-sha dedup (a repeat push reuses the successful build's bundle hash instead of rebuilding), streamed line-per-row build logs with a cursor-paginated tail query, github app installations linked by account slug, push + installation webhook parsing (default-branch pushes only, zero-sha deletes ignored) wired through the hmac-verified edge route, and a pure build-runner orchestration (claim → fetch → execute → complete/fail) whose tarball-fetch and container-execute ports are the remaining 🌐 half. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): tenant log ingestion and org right-to-erasure gaps.md b2 + d3. logs: a tenantLogs ledger fed by the tail worker via deploy-key-gated POST /v1/logs/ingest (batch + line-length caps, lines truncated rather than dropped), a cursor-paginated member tail query, and a 6-hourly retention prune (48h window). erasure: owners request org deletion (30-day reversible window); the purge cron then erases every org-scoped control-plane row, marks deployments destroyed for the provisioner teardown path, and removes the org. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs(cloud): mark shipped gaps in the build plan https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): dunning state machine and residency-aware cell placement gaps.md c2 + f. dunning: a pure evaluator (payment failure → 14-day grace anchored at first failure → suspend; any active/trialing subscription rescues) driven by a 6-hourly cron over the synced subscription states. suspensions now carry a reason so the spend-cap and dunning crons only lift their own. placement: organizations.create accepts a jurisdiction ("eu"/"fedramp") and picks a matching active cell when no explicit cellId is given. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): lunora eject core — the no-lock-in exit hatch gaps.md d2: a pure eject flow that pulls the tenant's full data snapshot through its admin export api, scaffolds the byo wrangler.jsonc the project would have had outside the platform (do bindings, d1 placeholder, sqlite migrations), and writes a restore readme — all over injected ports so the packaging is fully unit-tested; the cli wires the real i/o. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * style(cloud): use a template literal in the eject scaffold https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): studio tabs for domains, builds, and runtime logs wires the round-7 backends into the hosted studio: a domains tab (add → txt record callout → verify → live verified badge, remove), a builds tab (per-project build list with live streamed output), and a logs tab (deployment picker over a live runtime-log tail). marks c2/d2/f shipped in the gap plan. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): fat-vs-thin runtime spike + fleet re-release pipeline gaps.md e4 (the plan's ⭐ decide-now item). spike package (spikes/runtime-versioning): the analysis — user functions execute inside ShardDO and workerd has no dynamic code loading, so true-thin is a distributed-transaction redesign, not a packaging change — plus live probes for the three deciding hypotheses (cross-script DO bindings under wfp, callback per-hop cost vs a 1ms viability line, fat-path patch throughput arithmetic). provisional call: fat + pinned runtime + automated forced re-release. that pipeline ships here too: deployments record their runtimeVersion, and src/fleet/upgrade.ts plans canary-first batches and halts on a dirty canary or breached failure rate — a runtime patch becomes a paced batch job over the existing build + health-gated release machinery. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): ring-2 pass — harden, close seams, finish product edges security: github installations move to a staged-claim model (webhook stages, owner/admin claims; recordPush only accepts claimed installations and caps in-flight builds), domains.add enforces the customDomains entitlement, and audit coverage lands for domains, rollback, deletion requests, installation claims, and both suspension crons. seams: build → deploy handoff via the runner's release port (failed release keeps the artifact), stale-build self-healing cron, superseded-release pruning (retain 3/project), and server-built pr previews through the same pipeline. product: per-environment secrets (all/production/preview/dev with kind-over-shared resolution + studio picker), rollback button, suspension/deletion banners, org rename, member role change (last-owner protected), project rename. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): switch billing to creem as merchant of record Resolves gaps.md c3: creem (via @lunora/payment/creem) replaces the stripe adapter as the platform's payment provider. As a merchant of record it is the legal seller and calculates/collects/remits sales tax/vat across 190+ jurisdictions, so the platform never inherits worldwide tax compliance. Swaps the adapter wiring in the shard config (CREEM_API_KEY / CREEM_WEBHOOK_SECRET / CREEM_TEST_MODE for the sandbox), the webhook route + action to the creem-signature header, the studio copy to creem product ids and hosted portal, and the docs. Entitlements, dunning, plans, and quota enforcement are unchanged — they ride the provider-agnostic subscriptions store. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): prepaid-credits overage billing core for creem Verified against creem sdk 1.5.3: products are recurring/onetime only — no metered subscription pricing — but creem ships a first-party credits ledger (per-customer accounts, idempotent credit/debit by reference) built for api metering. Overage is therefore prepaid: orgs buy credit packs (one-time mor sales, tax handled by creem) and the platform debits usage beyond the plan's included quota. Ships the pure core (included quotas per plan, cost-plus overage rates, watermark-delta debits with crash-safe idempotent references, exhausted → the existing c1 suspension path, never negative), the overageDebits watermark table with forward-only advancement, and 10 tests. The live credits api wiring (CreditsLedgerPort) is the remaining 🌐 piece. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): creem credits-ledger adapter and fleet overage reconciliation Completes the api/token-metering implementation over creem's customerCredits api: a structural ledger adapter (balance reads via bigint-safe strings, debits with the idempotent watermark reference, missing account → null and never debitable), applyCreditPurchase for the billing webhook (first purchase creates the account seeded with the pack; later ones credit with the payment id as reference), the organizations.creditsAccountId linkage (never overwritten once set), and reconcileAllOverages — the fleet driver with per-org failure isolation, watermark-advance strictly after a successful debit, and exhausted balances handed to the c1 suspension hook. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): studio ux pass — usage meters, daily chart, command palette Ring 3, patterns from the maple teardown (fsl-licensed observability platform — ideas only, all code our own). usage tab: included-vs-used plan-quota meters (amber at 80%, red past allowance, honest prepaid- credits overage label) + a per-day request-volume chart over the new usage.series query, rendered with a zero-dependency svg bar chart. adds a ⌘k command palette (tab navigation + actions, substring match, arrow/enter/escape keyboard flow, state reset by remount) wired into the org dashboard. gaps.md gains the ranked ring-3 backlog (alerting pillar, health charts, log-viewer upgrade, design tokens, onboarding checklist, mcp surface, integrations hub). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * fix(cloud): keep cron triggers within cloudflare cap The control-plane Worker declared 4 distinct cron expressions (0 */1, 0 */6, 0 */12, */1) — one over Cloudflare's hard limit of 3 Cron Triggers per Worker, which would reject the deploy. The lone 0 */12 trigger existed solely for "purge deleted organizations". Fold that job into the existing 6h bucket: the purge gates on each org's own 30-day retention cutoff, so a tighter cadence only shortens erasure latency — it never erases early. Codegen drops the 0 */12 trigger, leaving exactly 3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01423xZDDqzhQ5D79vy25huF * feat(cloud): observability ingest pipeline — issues + incidents (Phase 3) (#140) * feat(cloud): add the observability ingest pipeline (issues + incidents) Phase 3 of the observability plan — durable, cross-deployment monitoring in the Lunora Cloud control plane, fed by the Phase 2 OTLP transport. - ingest: `POST /v1/telemetry` accepts OTLP-over-HTTP/JSON from the tenant `otlpSink` and the container exporter, decodes the error spans (`src/telemetry/otlp.ts`), and folds them into grouped issues/incidents through a deploy-key-authorized `telemetry.ingest` mutation. Synchronous — the cloud app has no queue producer binding, so ingest inserts to D1 directly (like `usage.ingest`); auth reuses `authorizeDeployKey`, not the plaintext admin token. - store: `issues` + `incidents` `.global()` D1 tables, fingerprinted with `@lunora/fingerprint` (the same hash the local Studio computes, so a local Issue and a cloud Issue are one object); `lunora/{issues,incidents}.ts` member-authorized read/triage functions. A `TelemetryStore` adapter (`src/telemetry/store.ts`) owns the non-relational side — AE metrics plus a guarded Pipeline→R2 archive, each a no-op without its binding. - dashboard: hosted `IssuesSection` / `IncidentsSection`, gated behind the `logStreams` entitlement, wired into `OrganizationDashboard`. - bindings: a `TELEMETRY` AE dataset + `TELEMETRY_BUCKET` R2 bucket. Vendors `@lunora/fingerprint` (Phase 1, not yet merged) so this stacks on the cloud branch; the graft folds away once Phase 1 lands on alpha. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm * feat(cloud): observability alerts — rules + delivery (Phase 4) (#141) * feat(cloud): add observability alerts — rules, firing + delivery Phase 4 of the observability plan (the "watches while you sleep" tier), stacked on the Phase 3 ingest. - schema: `alertRules` (name, target issue/incident, threshold, channel email/webhook, destination, enabled) + `alerts` (fired-alert audit trail with firing→delivered state, notification denormalized). - firing: the telemetry `ingest` mutation loads the org's enabled rules and fires each the first time a source's count crosses its threshold (`before < threshold <= after`, so exactly once), inserting a `firing` alert row. The pure crossing/render logic lives in `src/telemetry/alerts.ts` (unit-tested), mirroring how `usage.ingest` delegates to `evaluateSpendCap`. - delivery: the `/v1/telemetry` edge handler delivers fired alerts best-effort (email via `@lunora/mail`, webhook via JSON POST) then stamps them delivered — never blocking or failing ingest. - functions: `alerts.{rules,createRule,setRuleEnabled,deleteRule,list, markDelivered}` (member-authed reads/writes; deploy-key-authed markDelivered). - dashboard: `AlertsSection` (manage rules + recent fired alerts), gated behind the `logStreams` entitlement, wired into `OrganizationDashboard`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm * fix(cloud): validate webhook alert destinations against SSRF An alert rule's webhook `destination` is `fetch`ed by the control plane when the alert fires, so an owner/admin could otherwise aim it at internal infrastructure (loopback, RFC-1918, the 169.254.169.254 metadata IP, …) — server-side request forgery. Add a pure `isSafeWebhookUrl` guard (https only, public host, no embedded credentials, no loopback/private/link-local IPv4 or IPv6) enforced both at `createRule` (reject the rule) and in `deliverAlert` (never fetch an unsafe target — defense in depth for any rule created before this guard). String-level, so it can't defeat DNS rebinding, but it blocks the direct-address cases. Unit-tested. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud): harden webhook SSRF guard Two SSRF gaps in the Observability alert delivery path: - deliverAlert followed webhook redirects, so a destination that passes isSafeWebhookUrl could 3xx-redirect to an internal address (e.g. the metadata IP). Set redirect: "manual" and reject 3xx responses. - isSafeWebhookUrl let IPv4-mapped IPv6 (::ffff:169.254.169.254, which the URL parser compresses to ::ffff:7f00:1) and the unspecified address (::) through. Reject the whole ::-prefixed non-global class. Numeric IPv4 forms (2130706433, 0x7f000001, 0177.0.0.1) were already blocked via WHATWG URL normalization; added as regression tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfLmCwH5xMfz7L73LRPFj --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(cloud): AI incident triage (@lunora/ai) — Phase 4C (#142) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(runtime): use LunoraError not undefined CirrusError in cloud endpoints The scheduled-tick and queue-dispatch admin endpoints threw `new CirrusError(...)`, a class that exists nowhere in the repo, so the file failed to type-check (TS2304). The intended class is `LunoraError`, already imported and used throughout the file. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cloud): full tenant log management with structured fields + trace correlation Consume the structured, trace-correlated logs the framework now emits (shared/log-event.ts) — the cloud log path kept only 3 severities + a flat line and had no producer. Closes the framework side of Maple gap #2. - Producer (GAPS.md B2, the missing piece): src/tail/worker.ts — the dispatch-namespace tail worker decodes each tenant `{source:"lunora", type:"log"}` console event (src/tail/parse.ts, pure + unit-tested), groups them per script, and POSTs batches to POST /v1/logs/tail. Holds one platform secret (LUNORA_TAIL_SECRET), not per-org deploy keys; the route resolves scriptName → org (logs.orgForScript) and stores via logs.ingestInternal. Deployed from tail.wrangler.jsonc. - Store: tenantLogs widened to the full LogEvent shape — 7-tier severity, message, structured fields, functionPath, traceId/spanId, userId, shardKey — plus (scriptName, createdAt) and (org, traceId) indexes. - Query: logs.list gained server-side levels/functionPath/traceId/search filters + a cursor and bounded limit, newest-first. - UI: the studio Logs tab renders severity chips (filter), search, structured fields, and a short trace id per line. Still 🌐: the provisioner setting tail_consumers on tenant scripts, an e2e run, and correlating error/fatal lines to OTLP Issues by traceId (follow-up). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud): provision tenant bindings so deployed workers boot The deploy handler built the provisioner spec with an empty binding set (`bindings: {}`), so every uploaded tenant Worker was created with no Durable Object binding and no `new_sqlite_classes` migration tag. A real Lunora app always exports ShardDO, so it could never boot — the deploy pipeline could only ship a binding-less worker. The deploy request now carries the app's binding manifest (DO classes, optional per-tenant D1/R2) which the CLI reads from `wrangler.jsonc`, and the handler normalizes it to a spec that always includes the ShardDO floor even when a caller under-declares or omits it. Malformed entries are dropped and the DO list is capped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): tear down Cloudflare scripts for destroyed deployments The lifecycle crons (cleanupExpiredPreviews, pruneSuperseded, organizations.purgeDeleted) only transitioned a deployment to `destroyed` — nothing ever deleted the Cloudflare dispatch script, so dispatch namespaces grew unboundedly (the leak GAPS.md Ring-2 flagged as closed). Add a `teardownAt` marker and a pure, port-injected `runTeardownSweep` (per-target failure isolation, crash-safe idempotent off the marker), wired into the control-plane Worker's scheduled() handler on the hourly/6-hourly buckets — right after the crons that mark rows destroyed. No-ops without Cloudflare credentials. Per-tenant D1/R2 teardown-by-id still needs resource-id persistence and is left as a follow-up; script deletion is the load-bearing fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): fold Analytics-Engine usage into the metering ledger The dispatcher wrote one AE data point per tenant request, but nothing ever read them back — createHttpAnalyticsReader had no caller, so `platformUsage` only held what tenants self-report over POST /v1/usage (nothing, in practice). Spend caps, the usage summary, and the usage chart therefore evaluated an empty ledger. Add a per-cell `usageReadAtMs` checkpoint and a pure, port-injected `runUsageRollback` that delta-reads AE (`timestamp > checkpoint`), attributes each dispatch script to its org/deployment, and appends `requests` rows — then advances the checkpoint so re-runs never double count. A per-row ledger failure is dropped rather than retried (under- count, never double-bill — the same fail-safe as usage.rollup). Wired into scheduled() on the hourly/6-hourly buckets; no-ops without Cloudflare credentials. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): build-queue dispatcher (claim → run → drain) `builds.claimNext` had no caller: enqueued builds sat untouched until the 24h expiry cron failed them with "no build runner picked this up". Add the missing claim→run loop as a pure, port-injected `runBuildDispatch` (bounded per-tick drain; a failed build never aborts the drain), fully unit-tested against the runner ports. Production activation stays gated on the runner's 🌐 seams — `execute` (a throwaway Cloudflare Container running `lunora build`) and `fetchSource` (GitHub App tarball) — which need live container infra, so the dispatcher is not yet wired into scheduled(): claiming builds with no executor would only burn them. This lands the verified logic so the remaining work is purely the container seam, not the orchestration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * docs(cloud): split GAPS legend into wired vs pure-module (🧩) The single ✅ conflated "tested pure function exists" with "feature runs". Add a 🧩 marker for tested-but-uncalled modules, a dated wiring-pass section covering the four gaps just addressed, and correct the two most misleading inline entries: - A3 builds: the claim dispatcher now exists (was missing); only the container execute() seam remains 🌐. - C3 overage credits: reconcileAllOverages / applyCreditPurchase have no production caller (verified) — scheduling + webhook mapping are code (🔨), not credentials (🌐), so the honest status is 🧩, not "✅ core". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): boot-time route classification scanner Port of Openship's route-scanner idea (Apache-2.0) to the /v1 router. The control-plane routes each did inline auth then delegated to a self- authorizing function, but nothing forced a *new* route to be classified — an unclassified endpoint could ship silently and read as protected. Every route now carries an explicit RouteSpec.auth (deployKey / session / webhookHmac / tailSecret / adminToken / public), and assertRoutesClassified runs when createDeployRouter builds the table: a missing/unknown classification, a public route with no reason, or a duplicate (method, path) throws at construction — the Worker fails to start rather than serving an unclassified route. The flat dispatch tables are derived from the one checked list (GET + POST unified). The spec's opt-in `mcp` field is the allowlist the MCP surface will read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): MCP surface generated from the route registry Port of Openship's "MCP tools derived from the route registry" idea (Apache-2.0). A `/v1/mcp` JSON-RPC endpoint (tools/list + tools/call) exposes only routes that opt in via RouteSpec.mcp, and every tool call dispatches back through the real router carrying the agent's own bearer credential — so it runs the identical auth + rate-limit + handler + function-authz path as any HTTP caller; the MCP layer grants no privilege. A hard deny-list (buildMcpTools) guarantees token/secret/tenant-access routes (/v1/secrets, /v1/admin, /v1/invitations/send, /v1/logs/tail) and the surface itself (/v1/mcp) can never become tools even if mis-annotated — the same scope-escape guard Openship applies to tokens/auth/mcp. Only bearer-callable (deployKey/adminToken) opted-in routes are eligible; session/webhook routes are excluded. deployments.rollback is the first tool exposed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * chore(codegen): regenerate _generated for teardownAt + usageReadAtMs Keeps the emitted dataModel/shard/drizzle types consistent with the new deployments.teardownAt and cells.usageReadAtMs schema columns. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): tear down tenant D1 + R2, and test the sweep glue Extends resource teardown past the dispatch script (#2). The lifecycle sweep now also deletes the per-tenant D1 database and R2 bucket, resolved by the same naming convention the provisioner creates them under (shared tenantD1Name / tenantR2Bucket helpers — no drift, no new persistence). New CF API methods: findD1DatabaseByName + deleteD1Database (uuid) and deleteR2Bucket (name). Script + D1 delete are retryable; R2 is best-effort (a non-empty bucket needs an S3-API object purge the teardown context lacks — logged, left for follow-up). D1 (every .global() app has one) and empty R2 buckets are now fully reclaimed. Also extracts the scheduled() sweep glue into testable port-builders (#4): teardownPorts + usageRollbackPorts over a structural ControlPlaneDb, so the row→target mapping, the teardownAt stamp, the ledger insert, and the per-cell checkpoint are unit-tested against a fake store instead of living untested inside server.ts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): carry cronSpecs + bindings from wrangler on deploy The cron fan-out read live deployments' cronSpecs, but nothing ever populated them: deployments.create accepted the field yet the deploy handler/router never passed it, so readCronTargets always returned [] and the entire §2.4 tenant-cron fan-out had no data source (#3). Add parseWranglerManifest — a pure reader that extracts the binding manifest (DO classes / D1 / R2) and cron expressions from a tenant's wrangler.jsonc — and thread cronSpecs through the deploy request → handler → create mutation. The deploy client + CLI now forward both bindings and cronSpecs, so a real deploy provisions what the Worker needs and registers the crons the fan-out drives. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): synthetic uptime monitoring with alerting Adds external-vantage uptime to the control plane's Observability tier — the piece a deployment can't self-report (if it's down, it can't say so). - Probe: pure probeDeployment (generalizes the deploy-time healthCheck — GET, sub-500 = up, latency + timeout, never throws), a consecutive-failure state machine, and a summarizer, all unit-tested (src/uptime/probe.ts). - Sweep: runUptimeSweep over injected ControlPlaneDb ports (mirroring the teardown/usage sweeps) probes every live deployment, records a uptimeChecks row, advances uptimeState, and fires an "uptime" alert the first time a deployment's failures cross a rule threshold — reusing crossesThreshold, renderAlert, and the alerts table/delivery pipeline (src/uptime/sweep.ts). - Edge: server.ts scheduled() runs the sweep on the every-minute tick and delivers fired alerts over their channel (webhook/email), stamping the outcome. - Alerts gain an "uptime" target (schema + createRule + renderAlert), so users configure "page me when my deployment is down" alongside issue/incident rules. - Read side: lunora/uptime.ts (summary + recent queries, retention prune cron) backs a new Uptime dashboard section. Cron triggers stay at 3 expressions (prune rides the 6h bucket, the probe rides the existing every-minute tick). Full suite: 272 tests, lint:types clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019M6G6CAoLVrQMxDYg7BWq2 * fix(cloud): address thermo review of uptime monitoring Security/correctness (branch audit): - SSRF: the sweep fetched a …
…rts, sessions, dashboards (#179) * feat(studio): browse the durable log archive in the Logs panel (#155) * feat(studio): browse the durable log archive in the Logs panel Add a third "Archive" feed to the studio Logs panel that reads the durable ctx.log archive pipelineLogSink writes to R2 (Iceberg / R2 Data Catalog). - @lunora/runtime: a new admin-gated `/_lunora/admin/logs/archive` route (`log-archive-admin-routes.ts`) runs `createPipelineLogReader` server-side — the R2 SQL token stays on the worker, only decoded `{ rows, nextCursor }` reaches the browser. Reads creds from env (`R2_SQL_*`, `CLOUDFLARE_ACCOUNT_ID` fallback) + the table from a new `logArchive` WorkerOption. Fails closed with `LOG_ARCHIVE_NOT_CONFIGURED` when unwired. - @lunora/client: `queryLogArchive(query)` method + re-exported PipelineLog* wire types (owned by @lunora/runtime). - @lunora/studio: a self-contained `ArchiveFeed` (function/user/min-level filters, keyset "Load more", a "not configured" empty state distinct from an error) rendered under the new Archive tab; `errorCode` helper. - Docs + API snapshots updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * refactor(studio): drop try/finally in ArchiveFeed for React Compiler The React Compiler bails on a `try` with a `finally` clause (React Doctor `react-hooks-js/todo`), so the component missed automatic memoization. Reset `loading` in each branch instead, matching the repo's no-`finally` pattern. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * refactor(studio): address thermos review of the archive feed Code-quality + correctness follow-ups from the two-pass review: - Extract the duplicated LEVEL_VARIANT map into `log-level-variant.ts`, shared by the live Logs feeds and the Archive feed (restores the exhaustive `Record<LogLevel, BadgeVariant>` type — drops the `?? "outline"` fallback). - Collapse the four `view !== "archive"` readout guards in logs-panel into one `view === "archive" ? <ArchiveFeed/> : <>…</>` branch. - Drop the `JSON.parse(JSON.stringify(baseQuery))` round-trip in the fetch effect — pass `baseQuery` directly, keying the effect on `querySignature`. - Guard `loadMore` against a cross-filter race: a page-2 fetch that resolves after a filter change is dropped (via `activeSignatureRef`) instead of appending stale rows / overwriting the cursor. - Show a "Loading…" placeholder on the initial fetch instead of a blank panel. - Type `minLevel` state as `"" | ContextLogLevel` (removes a cast). - Move the `LOG_ARCHIVE_NOT_CONFIGURED` sentinel to `shared/log-archive.ts` so runtime and studio share one source of truth with no dependency edge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * refactor(studio): address CodeRabbit review of the archive feed - Fold ArchiveFeed's five fetch-related useState into one useReducer, so each fetch transition (loading / loaded / append / notConfigured / failed / pageFailed) is a single dispatched action (React Doctor prefer-useReducer). - Use the imported `ChangeEvent` type instead of the `React.*` namespace, matching logs-panel.tsx. - Gate the toolbar `LiveError` on `view !== "archive"` so the disabled Errors feed's live-connection state can't leak into the (WS-less) Archive tab. The CodeRabbit "cast env to LogArchiveEnvironment" suggestion is intentionally skipped: `env ?? {}` narrows to `{}`, which is assignable to the all-optional LogArchiveEnvironment (lint:types is green), and eslint's no-unnecessary-type-assertion rejects the cast. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * polish(studio): clear prior error on a fresh archive fetch Thermos re-review nice-to-have: the reducer's `loading` action now clears `error`/`notConfigured` (matching the kv reducers' `submitStart`), so retrying after a failure shows the loading placeholder instead of the stale error line. Rows are kept, so paging / filter-change refetches don't blank the table. (Kept `default: return state` to stay consistent with the existing kv reducers rather than introduce a one-off `unreachable` exhaustiveness guard.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * chore(release): @lunora/runtime@1.0.0-alpha.32 [skip ci]\n\n## @lunora/runtime [1.0.0-alpha.32](https://github.com/anolilab/lunora/compare/%40lunora%2Fruntime%401.0.0-alpha.31...%40lunora%2Fruntime%401.0.0-alpha.32) (2026-07-21) * chore(release): @lunora/client@1.0.0-alpha.26 [skip ci]\n\n## @lunora/client [1.0.0-alpha.26](https://github.com/anolilab/lunora/compare/%40lunora%2Fclient%401.0.0-alpha.25...%40lunora%2Fclient%401.0.0-alpha.26) (2026-07-21) * **@lunora/runtime:** upgraded to 1.0.0-alpha.32 * docs: add cirrus cloud platform plan Reverses the managed-deploy-plane won't-do (VOID-TEARDOWN.md §0/§6, CONVEX-PARITY.md #23) with a scoped managed tier: Workers for Platforms data plane, Convex-shaped product model (teams/projects/prod+dev+preview deployments), PartyKit-style managed-vs-BYO CLI split, and a phased roadmap starting with remote-binding dev. Synthesized from a repo inventory, a Convex Cloud teardown, and a GitHub/Cloudflare-primitives survey. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: fold supabase platform teardown into cloud plan Adds Supabase as a reference model: the OSS/proprietary cut line, IS_PLATFORM single-codebase studio pattern, Branching 2.0 preview DX (and its pain points to fix: empty branches, hourly branch billing outside spend caps), the Management API + OAuth-apps growth channel, and the structural cost advantage WfP gives over per-project VMs. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: add wfp constraints, eject path, spike checklist Gap review of the cloud plan: documents that cron triggers are silently dropped for namespaced user Workers (with SchedulerDO alarm-based fan-out mitigation), queue-consumer and send_email verification items, KV account-limit multiplexing, EU jurisdiction toggle, a cirrus-eject portability command built on existing export/import RPCs, namespace-wide observability reuse, managed backups + abuse controls in Phase 4, and a Phase 1 constraint spike. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: verify cloud plan claims against cloudflare docs Fact-checked every Cloudflare claim in CLOUD-PLAN.md against the official docs: WfP pricing, KV/D1/R2 account limits, DO/R2 jurisdictions vs D1 location hints, CF for SaaS hostname pricing, and remote-bindings GA versions all confirmed. Adds three newly verified constraints: no gradual deployments for user Workers (rollback must be platform-side bundle re-upload), the 1200-req/5-min account API rate limit on provisioning, and outbound-Worker TCP/DO interception trade-offs. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: add cell-based scaling architecture to cloud plan Answers how the managed tier scales without hitting account limits or risking platform-wide blocks: script-resident tenant state with lazy inference-driven provisioning, multi-account cells with cell IDs baked into identifiers from day one, a per-cell API token-bucket scheduler, a tenancy graduation ladder up to managed-BYO and the Tenant API, and abuse containment to keep tenant abuse from looking like platform abuse. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: scope alchemy to cell bring-up, not tenant deploys Records the provisioning-engine decision: the per-tenant deploy path stays hand-rolled on cloudflare-typescript (control-plane DB as the single source of truth, cell scheduler, progress events, rollback artifacts); Alchemy (pre-1.0, v2 rewrite underway, no confirmed dispatch-namespace resource) is a candidate only for low-cardinality cell bring-up IaC, with Terraform/Pulumi as the fallback. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: correct alchemy facts (dispatch-namespace resource, v0.93) Re-checked Alchemy against GitHub/npm: it is v0.93.12 (Apache-2.0) and does ship a dispatch-namespace (Workers for Platforms) resource — my earlier 'lacks a confirmed dispatch-namespace resource' was wrong and 'v0.9x' undersold it. Recommendation is unchanged (hand-roll the per-tenant deploy, use Alchemy for cell bring-up) but now rests on the real reason — source-of-truth shape and deploy-orchestration concerns, not capability — with a re-evaluation trigger at a stable 1.x. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: adopt alchemy as the provisioning engine Decision: Alchemy is the provisioning engine across cell bring-up, per-tenant managed deploy, and BYO. Verified it ships DispatchNamespace/ Worker/D1/R2/DO resources plus a built-in D1StateStore and runs inside a Worker (await alchemy(scope) -> finalize/destroy). Backing each tenant scope with the control-plane D1 collapses the two-sources-of-truth concern into one store. The per-cell rate-limit scheduler now paces finalize() runs; bundling stays in the Vite pipeline; rollback re- converges to a prior R2-retained bundle. Risk of a 0.x dependency contained behind a @cirrus/provision adapter with cloudflare-typescript as fallback; spike open items recorded. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: build on alchemy v2 (alchemy@next, 2.0.0-beta.55) Per decision to start on the v2 line: target alchemy@next (verified 2.0.0-beta.55, Effect-based) to avoid a v1->v2 migration mid-build, with v1 0.93.x as the named fallback. Records the trade-offs (beta churn, Effect pulled into the control-plane tree, quarantined behind the @cirrus/provision adapter) and turns the unverified v2 facts (DispatchNamespace resource + D1/DO state store, confirmed on v1 only since docs/CDN were unreachable) into hard Phase 1 spike gates with v1 fallback per surface. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: commit fully to alchemy v2, drop v1 fallback Remove the v1 (0.93.x) fallback hedging throughout: v2 (alchemy@next, 2.0.0-beta.55) is the engine outright. Spike gates remain but now resolve via owned shims or upstream contributions rather than retreat to v1; a hard unresolvable 'no' escalates the engine decision instead of silently dual-tracking. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: add forgotten must-haves + fleet runtime-versioning risk Gap pass on the cloud plan. New risk #8 (the load-bearing one): the Cirrus runtime is bundled into each tenant Worker, so a security patch means redeploying the whole fleet unless the tenant Worker is made 'thin' against a central runtime — a fat-vs-thin decision that must be made before Phase 1 since it shapes the bundle format, deploy API, and vite emit. New section 7 collects launch-blocking gaps the plan had assumed away: control-plane DB durability/DR, cross-cell disaster recovery, secrets-at-rest + cell-token custody, frontend-hosting scope, AUP + bill-shock/cryptomining controls, billing/MoR/tax, GDPR processor/DPA/SOC2, account offboarding + right-to-erasure, platform self-observability + status page, dispatcher canary, and a staging cell. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): scaffold control-plane app built on cirrus First implementation step from CLOUD-PLAN.md: a new apps/cloud workspace app that dogfoods Cirrus as the platform's own control-plane backend. - cirrus/schema.ts: control-plane data model (cells, organizations, members, projects, deployments, deployKeys, auditLog), all .global() (D1) — the plan's 'Worker + D1' control plane. - cirrus functions: organizations/projects/deployments/cells/deploy-keys (create/list/issue/updateStatus), with owner seeding + audit trail. - src/server.ts: control-plane Worker entry wiring D1-backed global tables. - src/provision.ts: the @cirrus/provision seam — the sole coupling to the Alchemy v2 engine (stub that rejects until the Phase 1 spike wires it). - configs (package.json/tsconfig/project.json/wrangler.jsonc/eslint/vitest), README, and a provision test. Verified: codegen clean (no advisories), eslint clean, tsc --noEmit passes, vitest green. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): add deploy-orchestration core Builds the next control-plane layer on the scaffold's Provisioner seam, all pure/testable (no live Cloudflare needed): - token-bucket.ts: per-cell API budget (§2.5), models CF's 1200/5min account limit; deterministic + clock-injectable. - scheduler.ts: CellScheduler paces/serializes provisioner work against the bucket with priority ordering + a concurrency cap. - orchestrator.ts: runDeployment state machine emitting queued → provisioning → live/failed progress events (§2.2); destroyDeployment for preview-TTL/project teardown. - keys.ts: deploy-key format/parse/hash helpers; deploy-keys.ts mutation refactored to use them (one tested place for the format + SHA-256). 17 tests across 5 files; eslint + tsc --noEmit clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): add org authorization + deploy-key lifecycle Closes gaps found reviewing the control plane: - authz.ts: assertMember(ctx, orgId, roles?) — the org ACL gate. Every org-scoped function now verifies the caller is a member with a permitted role, closing an IDOR hole where any signed-in user could read/mutate any org by passing its id. Applied across projects, deployments, deploy-keys. - members.ts: list / add / remove so memberships can actually be granted (owner is seeded on org create; admins/owners manage the rest). - deploy-keys: verify (the deploy API's auth path — match by SHA-256, reject revoked, bump lastUsedAt, return the DB-authoritative target) and revoke (leaked-key mitigation); lastUsedAt/revokedAt are now live. - deployments: create checks the project belongs to the org; updateStatus loads the deployment and gates on its org (documented as the system seam for the orchestrator). codegen clean, eslint + tsc --noEmit clean, 17 tests pass. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): deploy API endpoint, deploy-key auth, handler tests Lands the three remaining pieces together: - Deploy API: POST /v1/deploy mounted via the httpRouter seam (src/deploy/ router.ts) → pure handler (src/deploy/handler.ts) authenticates the bearer deploy key, records a queued deployment, drives runDeployment through the per-cell scheduler, and streams NDJSON progress (accepted→queued→ provisioning→live/failed→done), patching status per phase. - Auth path: investigation showed internalMutation is unreachable from the HTTP action-context dispatch (no system flag → RPC 404), so verify/ updateStatus stay public; instead added deploy-key authorization (authz.authorizeDeployKey) and a dual-path (member session OR deploy key) on deployments.create/updateStatus, so CI deploys need no user session. Corrected the stale 'should become internalMutation' comments. - Tests: handleDeployRequest (401/403/400 + success and failure streaming + status transitions) and authz (assertMember + authorizeDeployKey) via a fake ctx. 28 tests / 7 files; eslint + tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs(cloud): refresh status — deploy API + auth now in place * chore(cloud): track generated schema snapshot Matches the apps/playground convention — .cirrus-schema.json is the codegen schema snapshot used for migration/drift detection and is committed, not gitignored. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): preview lifecycle, GitHub webhook, deploy client (Phase 1/2) Phase 2: - Preview deployments are TTL'd: deployments.create stamps expiresAt for kind=preview (src/deploy/preview.ts: deterministic previewScriptName + 5-day previewExpiry); an hourly cron (cirrus/crons.ts -> internal deployments.cleanupExpiredPreviews) marks expired previews destroyed. Worker gains scheduled(); wrangler cron trigger added. - GitHub webhook (src/github/webhook.ts): HMAC-SHA256 verify + pull_request -> preview-intent parsing, mounted at POST /v1/github/webhook. Phase 1: - Deploy client (src/deploy/client.ts): the cirrus-deploy core — POSTs to /v1/deploy and consumes the NDJSON progress stream. 13 new tests (41 total); codegen/eslint/tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: update roadmap — Phase 0 shipped, Phases 1-2 status Phase 0 (remote-binding dev) is already implemented in the framework (@cirrus/config remote-bindings + @cirrus/vite plugin + cirrus dev; 30 tests) — corrected from 'not started'. Phases 1-2 marked substantially-built with the live-Cloudflare-dependent remainder (Alchemy provisioner, dispatcher, e2e validation) called out. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): team invitations + billing quotas; repo-extraction guide (Phase 3/4) Phase 3 (hosted studio sliver): - Team invitations (cirrus/invitations.ts + invitations table): invite/list/ revoke/accept, single-use SHA-256-hashed tokens (plaintext mailed once), owner-admin gated; accept-by-token adds the caller as a member. Phase 4 (billing sliver): - Plans + quota entitlements (src/billing/plans.ts) on @cirrus/payment's entitlements model — free/pro/enterprise limits + feature flags, with effectiveLimit/withinQuota and a free-tier fallback for non-subscribers. Portability (move to a private repo): - EXTRACT.md documents the mechanical extraction; audit confirms the app imports only published @cirrus/* entry points (no monorepo-internal reaches). 6 new tests (47 total); codegen/eslint/tsc clean; secret-scan clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): real Cloudflare provisioner, quota enforcement, webhook project resolution Provisioner (Phase 1 — the big one): replaces the rejecting stub with a real implementation over a typed Cloudflare REST port (src/cloudflare/api.ts): deploy provisions per-tenant D1/R2, uploads the user Worker into the dispatch namespace with binding + DO-migration metadata, applies secrets, returns the bundle hash + routed URL; destroy deletes the script. Port-injected so it's tested with a fake; plug in CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN to run. (REST via fetch rather than the unverifiable alchemy@next beta — same seam.) Quota (Phase 4): plans.ts gains planLimit/withinPlanQuota; projects.create and members.add enforce the org plan's limits. Preview automation (Phase 2): projects gain githubRepo + byGithubRepo lookup; the webhook resolves the connected project and returns the preview script name. Env documented (.dev.vars.example + wrangler vars). 50 tests; eslint/tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): dispatcher Worker + hosted-studio admin-RPC proxy (Phase 1/3) Phase 1 — dispatcher Worker (the request-path front door): resolveTenant maps {scriptName}.{appDomain} (and custom domains via injected lookup) to a dispatch-namespace script; the worker forwards via env.DISPATCHER.get with per-plan limits. Separate deployable (dispatcher.wrangler.jsonc). Phase 3 — admin-RPC proxy: proxyAdminRequest authorizes org membership, forwards the admin RPC to the tenant's /_cirrus/admin/* with that deployment's admin token, and records an audit entry. Pure (deps injected). 7 new tests (57 total); eslint/tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): cirrus login/link/deploy CLI commands (Phase 1) Pure command logic over a ConfigStore + the deploy client: login persists the API endpoint + deploy key, link binds a project, deploy streams a managed deploy (requires login+link). File-backed store at ~/.cirrus/cloud.json for the Node CLI; cerebro registration in @cirrus/cli calls these. 3 new tests (60 total); eslint/tsc clean; secret-scan clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: roadmap status — control-plane backend feature-complete as code All phases' backend code is built + unit-tested in apps/cloud (60 tests): real REST provisioner, dispatcher, CLI, preview lifecycle, GitHub webhook, team invitations, admin-RPC proxy, quota enforcement. Remaining items are the ones needing live Cloudflare / external services / the studio UI. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): admin-proxy live wiring, usage metering, custom-hostname port (Phase 3/4) Phase 3 — admin proxy mounted at POST /v1/admin: deployments now carry the platform-minted tenant adminToken (set as the worker's CIRRUS_ADMIN_TOKEN secret + stored on the row), deployments.adminTarget resolves {url, adminToken} after asserting membership, and the router forwards to the tenant's /_cirrus/admin/* with an audit-log.record entry. Phase 4 — usage metering: usageEvents table + internal record mutation + member summary query over a pure aggregateUsage roll-up. Custom hostnames: CloudflareApi.createCustomHostname (Cloudflare for SaaS, zone-scoped REST). Router refactored into per-route handlers. 64 tests; eslint/tsc/secret-scan clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: roadmap — admin proxy mounted, usage metering + custom-hostname port added * feat(cloud): add hosted studio react spa Build the hosted-studio frontend for the Cirrus Cloud control plane: a better-auth-gated React SPA served on one origin with the control-plane Worker via @cirrus/vite. - src/client: main/auth-client/Login, App auth gate, OrganizationList, OrganizationDashboard with tabs for projects, deployments, members, deploy keys, invitations, and usage; AsyncList loading/empty helper. - Wire @cirrus/auth into src/server.ts (createAuth + cirrusD1Adapter, ensureMigrated, handleAuthRequest, authAdmin, resolveIdentity) and add AUTH_SECRET/AUTH_URL env + .dev.vars.example entries. - Switch package scripts to vite (build/dev), add react/react-dom + @cirrus/react/@cirrus/auth deps, vite.config.ts, index.html, and the DOM lib + jsx in tsconfig. - eslint: client section (filename-case, react-perf, void), browser globals; ignore vite.config.ts. - Refresh README + CLOUD-PLAN status to reflect the studio UI. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): add billing, metering, and hardened auth Billing on @cirrus/payment (§4): org id is the payment referenceId. Wire a Stripe adapter into createShardDO({ payment }); add cirrus/billing.ts with checkout/portal actions, entitlements/subscription reads (resolved through CIRRUS_CLOUD_PLANS with a free-tier fallback), and a signature-verified processWebhook mounted at POST /v1/billing/webhook. The studio gains a Billing tab. Platform metering (§4): rename the resource-metering table to platformUsage (freeing usageEvents for @cirrus/payment's billing ledger), add a deploy-key authenticated usage.ingest mutation + POST /v1/usage endpoint, and enforce per-plan runtime limits in the dispatcher (limitsForPlan → DISPATCHER.get). Auth hardening (§3) on @cirrus/auth/better-auth: mail-backed email verification + password reset (@cirrus/mail), optional GitHub/Google OAuth, admin/twoFactor/passkey plugins, built-in auth rate limiting, plus a per-IP @cirrus/ratelimit cap on the /v1/* surface. Invitations now email the token via POST /v1/invitations/send (never shown in the browser). The Cirrus organizations/members model stays the single org source of truth (better-auth organization plugin deliberately omitted). Add deps (@cirrus/mail, @cirrus/ratelimit, stripe), tests for the router routes + rate limiting + per-plan limits (69 total), and reconcile the README + CLOUD-PLAN status (the provisioner is a real Cloudflare REST impl, not a stub). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): enforce entitlements, wire metering source, add secrets Close the billing loose ends and add the metering source, tenant secrets, and an audit-log view. Entitlements (close loose end #1): quota is now enforced against live subscription state (cirrus/entitlements.ts resolves from the synced `subscriptions` table) rather than the static organizations.plan column — projects/members creation call assertWithinQuota, so a Stripe upgrade raises limits immediately with no column to sync. Per-plan dispatch limits (close loose end #2): deployments.planForScript + a bearer-gated GET /v1/tenants/plan endpoint + a cached plan resolver in the dispatcher (createPlanResolver) wire resolvePlan, so runtime limits actually scale per plan instead of always falling back to free. Metering source: the dispatcher emits one Analytics Engine data point per tenant request (src/metering/analytics.ts); a reader port + HTTP impl and an hourly usage.rollup compaction cron complete the pipeline alongside the existing /v1/usage ledger ingest. Tenant secrets (§7): AES-256-GCM envelope encryption at the edge (src/secrets/crypto.ts), a secrets table (ciphertext + IV only), store/list/ listEncrypted/remove functions, POST /v1/secrets, deploy-time materialization into the tenant Worker, and a studio Secrets tab. Studio: add Secrets + Activity (audit log) tabs; add audit-log.list. Tests: crypto round-trip, plan resolver caching/fallback, entitlement quota, analytics writer/reader (83 total). Docs + .dev.vars.example updated. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * fix(cloud): address review findings (rollup atomicity, deploy failure, dedup) Apply /review findings on the recent billing/metering/secrets work: Correctness: - usage.rollup: the D1 global backend has no multi-statement transaction, so the old insert-summed-then-delete-originals order could double-count (over- bill) on a mid-rollup crash. Reorder to delete the extras first, then patch the surviving row's total last — a crash can now only under-count, never leave a summed row beside surviving originals. - deploy handler: a tenant-secret decrypt failure (corrupt secret / rotated key) threw inside the NDJSON stream and left the deployment stuck in `accepted`. Catch it and transition to `failed` with a status update. - POST /v1/secrets: encryption/config failures (e.g. a malformed SECRET_ENCRYPTION_KEY) now return 500, not a misleading 403 (kept distinct from the membership 403 the store mutation raises); reject the reserved CIRRUS_ADMIN_TOKEN secret name up front instead of silently clobbering it. - studio: drop the plan picker from org creation — limits now come from live subscription entitlements, so selecting a paid plan at create-time granted nothing. Orgs start free; upgrade via the Billing tab. Cleanup: - Extract the cross-org IDOR guard into authz.assertRowInOrg and call it from secrets/members/deploy-keys/invitations (was four byte-identical copies). - Remove dead plans.ts exports planLimit/withinPlanQuota (superseded by entitlements-based quota); add a single highestPlan/PLAN_PRECEDENCE helper and use it in deployments.planForScript (was a hand-rolled tier ladder). - Memoize the Stripe payment config per isolate (was rebuilt on every shard request that touches ctx.payments). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * test(cloud): validate websockets through dispatch (phase 1 spike) Validate the hottest path — hibernated-WS subscriptions + per-invocation limits through env.DISPATCHER.get() — the least-documented WfP case (risk #3). - spikes/ws-dispatch/: a runnable harness for live validation on a real dispatch namespace. A framework-free hibernatable-WebSocket Durable Object (the exact primitive ShardDO uses: acceptWebSocket + webSocketMessage), deployable into the namespace, plus a zero-dep Node probe that drives it through the dispatcher and asserts: (1) the WS upgrade survives the dispatch hop (101 + live socket), (2) a hibernated server push (broadcast) reaches the socket — the mutation-to-subscription shape, (3) cpuMs-limit behaviour. The README documents deploy/run, pass/fail, and the expected results + caveats. - __tests__/dispatcher-ws.test.ts: unit-pins the dispatcher forwarding contract (returns the tenant 101+webSocket response unchanged, applies per-plan limits, meters the upgrade once) — runs in CI, no infra needed. - dispatcher worker: clarifying comments on WS pass-through + per-frame metering semantics. CLOUD-PLAN risk #3 now references the harness. The dispatcher half is verified here (94 tests); the end-to-end behaviour needs a live Cloudflare account + the Workers-for-Platforms add-on to run. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): tenant cron fan-out through dispatch (wfp workaround) Cloudflare drops triggers.crons for Workers in a dispatch namespace, so tenant cron jobs never fire. Fan them out from the control plane (CLOUD-PLAN §2.4). - @cirrus/runtime: add an admin-gated POST /_cirrus/scheduled tick endpoint that runs a cron expression's jobs through the SAME handleScheduled path the native scheduled() trigger uses (user crons + code crons + backup), so a platform can drive a namespaced tenant's crons over HTTP. (Dispatch stubs expose only fetch()/connect() — no scheduled()/queue() — so HTTP is the only transport in.) - src/fanout/cron.ts: pure 5-field cron-expression matching (lists, ranges, steps, dom/dow OR semantics) + dueTicks + fanOutCron orchestration. - control plane: capture each tenant's cronSpecs on the deployments row at deploy; an every-minute heartbeat cron (cirrus/fanout.ts) makes codegen emit the */1 trigger, and server.ts scheduled() reads live cron targets and ticks each due tenant via env.DISPATCHER.get(script).fetch('/_cirrus/scheduled') with the per-deployment admin token (kept in-process — never exposed). Adds the DISPATCHER binding to the control-plane wrangler. Tests: cron matching, dueTicks, fanOutCron (103 cloud tests; 337 runtime tests still green). Live validation on a dispatch namespace pending; queue consumer fan-out is the remaining half. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): tenant queue-consumer fan-out through dispatch (wfp workaround) WfP namespaced Workers can't be queue consumers, so tenant queue-backed work (@cirrus/mail sends, scheduler queue-workpool) never drains. Fan it out from a platform-owned consumer (CLOUD-PLAN §2.4) — the queue counterpart to the cron fan-out. - @cirrus/runtime: add a `queueHandler` option + an admin-gated POST /_cirrus/queue endpoint that reconstructs the batch and invokes it, returning the message ids to retry. (Dispatch stubs are fetch-only, so HTTP is the only transport into a namespaced tenant.) - src/fanout/queue.ts: pure grouping of a shared-queue batch by the producing tenant's script (envelope `{ script, body }`) + fan-out orchestration that collects per-message retries and retries a whole group on delivery failure. - control plane: the account-level Worker is the consumer — server.ts queue() drains the shared cirrus-tenant-queue, resolves each tenant's admin token in-process (never exposed), forwards sub-batches via env.DISPATCHER.get(script).fetch('/_cirrus/queue'), and acks/retries per the tenant reply. Adds the queues.consumers binding to the control-plane wrangler. Tests: groupByTenant + fanOutQueue (108 cloud tests; 337 runtime tests green). Live validation on a dispatch namespace + a producer-side script-tagging helper remain. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): align with the lunora rebrand + reuse @lunora/analytics Rebased onto alpha, which renamed the framework cirrus → lunora. Reconcile the control-plane app and reuse a newly-shipped package. Rebrand: - npm scope @cirrus/* → @lunora/* across deps + imports. - app functions dir cirrus/ → lunora/ (+ tsconfig/eslint globs, _generated paths, the committed schema snapshot → .lunora-schema.json). - reserved paths /_cirrus/* → /_lunora/* (incl. the new scheduled/queue tick endpoints) and the runtime-injected env.__lunoraCtx; renamed exported symbols (LunoraError, LunoraClient/Provider, useLunora, lunoraD1Adapter, LunoraAuth*, LUNORA_CRONS/FUNCTIONS); vite plugin cirrus() → lunora(); CLI config dir ~/.cirrus → ~/.lunora. - wire the new required GlobalIntrospector.facetColumn via @lunora/d1's facetGlobalColumn. Reuse: - src/metering/analytics.ts is now a thin domain layer over @lunora/analytics (createAnalytics writeDataPoint + createAnalyticsSqlClient AE-SQL reader) instead of a hand-rolled writeDataPoint + HTTP SQL client. Verified the rest is genuinely cloud-specific (cron-expression matching, AES-GCM secret crypto, the Cloudflare REST provisioner, the per-cell CF-API token bucket) — no upstream equivalent to fold into. 103→108 cloud tests green; runtime 379 tests green; tsc/eslint/build clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): rebrand the product Cirrus Cloud → Lunora Cloud Complete the lunora rebrand to the product layer (the framework already moved): - brand prose Cirrus Cloud → Lunora Cloud across code comments, README, EXTRACT, the studio (index.html title, Login/dashboard), and CLOUD-PLAN.md. - env vars CIRRUS_* → LUNORA_*: LUNORA_ADMIN_TOKEN and LUNORA_MAIL_CAPTURE are functional (read by @lunora/mail); LUNORA_APP_DOMAIN / LUNORA_CELL and the VITE_LUNORA_URL client var follow for consistency. - the LUNORA_CLOUD_PLANS entitlements constant. - infra names cirrus-* → lunora-*: worker names (lunora-cloud, lunora-dispatcher), dispatch namespace (lunora-production), shared queue (lunora-tenant-queue), AE dataset (lunora_tenant_usage), the lunora.app apex, and the deploy dispatch-namespace prefix. - the hosted-CLI verbs (lunora login/link/deploy) and config dir ~/.lunora. - docs' reserved-path/marker refs (/_lunora/*, __lunora_admin__, env.__lunoraCtx). 108 cloud tests green; tsc/eslint/build clean; zero residual `cirrus` references. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * refactor(cloud): migrate functions to fluent builder api Adapt the cloud control-plane functions to alpha v1.0.0-alpha.1's fluent function builders: kind.input({...}).<terminal>(({ ctx, args }) => ...) replaces the removed object form kind({ args, handler }). Regenerate _generated/* and pick up codegen's observability block in wrangler. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): license under polyform noncommercial The control plane is the proprietary product layer, so it must not carry the framework's FSL-1.1-Apache-2.0 (which grants broad commercial rights). Apply PolyForm Noncommercial 1.0.0: any noncommercial purpose is permitted, but commercial use requires a separate license. Replaces UNLICENSED. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): adopt @lunora/bindings/analytics after package fold-in The latest alpha folded @lunora/analytics into @lunora/bindings (subpath export ./analytics, identical API) and codegen now emits _generated/functions.ts importing @lunora/values directly. Swap the dependency and import specifiers, declare @lunora/values, regenerate _generated/*, and reconcile the lockfile. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * fix(cloud): address pr review findings - secrets: assert the project belongs to the org in store/list/ listEncrypted and scope queries by organizationId, closing the cross-org IDOR where a member of one org could read or overwrite another org's project secrets (+ idor tests) - deploy: require a base64 worker bundle in POST /v1/deploy and thread it client → CLI → provisioner instead of uploading an empty module; 400 on missing/malformed bundle - studio: replace try/finally + throw-in-try with promise combinators in Login/Invitations/Secrets forms so React Compiler can memoize them https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs(cloud): add consolidated gap analysis and build plan https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): blue/green releases with health gating and rollback Every deployment now uploads an immutable versioned script ({alias}-v{n}); the project's stable URL follows an active-deployment pointer that only swaps after the new script passes a health probe, so a bad deploy never replaces a serving one (gaps.md a1). Adds POST /v1/deployments/rollback + lunora rollback (pointer swap back to a retained superseded release), GET /v1/tenants/route + a cached alias resolver in the dispatcher, per-phase deployment timestamps (a2), and an x-lunora-id debug header (b3). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): aggregate spend caps with org suspension Per-invocation limits cap one request; nothing capped aggregate period spend (gaps.md c1). Adds a pure spend evaluator at the wfp cost basis with per-plan default caps (org-overridable; explicit 0 = uncapped), an hourly enforcement cron that suspends breaching orgs and self-heals recovered ones, and dispatcher enforcement — a suspended org's tenants serve 503 via the sentinel plan carried through the existing cache. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): custom domains — model, txt verification, hostname routing First slice of gaps.md b1: the domains table (unique hostname, per-org project scoping, redirect-only rows, cloudflare custom-hostname id), add/list/remove/markVerified functions with the same authz gates as secrets, a pure dns-over-https verification core (_lunora txt token + platform cname check, injectable resolver), and routeForHostname — the dispatcher-facing lookup that only ever routes verified domains to the project's active script. Edge routes + dispatcher wiring land next. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): wire custom domains through edge and dispatcher Completes the code-tractable half of gaps.md b1: POST /v1/domains (add, returns the txt record to create), POST /v1/domains/verify (dns-over- https txt + cname checks under the caller's session, outcome recorded via markVerified), GET /v1/tenants/custom-domain for the dispatcher, and a cached custom-domain resolver in the dispatcher that routes verified hostnames to the project's active script and answers redirect-only rows directly. cloudflare-for-saas cert provisioning remains the 🌐 half. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * style(cloud): hoist the trailing-dot regex to module scope https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): server-side builds, build logs, and push-to-deploy webhook gaps.md a3/a4: builds table with a stale-recoverable work lease and commit-sha dedup (a repeat push reuses the successful build's bundle hash instead of rebuilding), streamed line-per-row build logs with a cursor-paginated tail query, github app installations linked by account slug, push + installation webhook parsing (default-branch pushes only, zero-sha deletes ignored) wired through the hmac-verified edge route, and a pure build-runner orchestration (claim → fetch → execute → complete/fail) whose tarball-fetch and container-execute ports are the remaining 🌐 half. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): tenant log ingestion and org right-to-erasure gaps.md b2 + d3. logs: a tenantLogs ledger fed by the tail worker via deploy-key-gated POST /v1/logs/ingest (batch + line-length caps, lines truncated rather than dropped), a cursor-paginated member tail query, and a 6-hourly retention prune (48h window). erasure: owners request org deletion (30-day reversible window); the purge cron then erases every org-scoped control-plane row, marks deployments destroyed for the provisioner teardown path, and removes the org. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs(cloud): mark shipped gaps in the build plan https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): dunning state machine and residency-aware cell placement gaps.md c2 + f. dunning: a pure evaluator (payment failure → 14-day grace anchored at first failure → suspend; any active/trialing subscription rescues) driven by a 6-hourly cron over the synced subscription states. suspensions now carry a reason so the spend-cap and dunning crons only lift their own. placement: organizations.create accepts a jurisdiction ("eu"/"fedramp") and picks a matching active cell when no explicit cellId is given. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): lunora eject core — the no-lock-in exit hatch gaps.md d2: a pure eject flow that pulls the tenant's full data snapshot through its admin export api, scaffolds the byo wrangler.jsonc the project would have had outside the platform (do bindings, d1 placeholder, sqlite migrations), and writes a restore readme — all over injected ports so the packaging is fully unit-tested; the cli wires the real i/o. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * style(cloud): use a template literal in the eject scaffold https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): studio tabs for domains, builds, and runtime logs wires the round-7 backends into the hosted studio: a domains tab (add → txt record callout → verify → live verified badge, remove), a builds tab (per-project build list with live streamed output), and a logs tab (deployment picker over a live runtime-log tail). marks c2/d2/f shipped in the gap plan. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): fat-vs-thin runtime spike + fleet re-release pipeline gaps.md e4 (the plan's ⭐ decide-now item). spike package (spikes/runtime-versioning): the analysis — user functions execute inside ShardDO and workerd has no dynamic code loading, so true-thin is a distributed-transaction redesign, not a packaging change — plus live probes for the three deciding hypotheses (cross-script DO bindings under wfp, callback per-hop cost vs a 1ms viability line, fat-path patch throughput arithmetic). provisional call: fat + pinned runtime + automated forced re-release. that pipeline ships here too: deployments record their runtimeVersion, and src/fleet/upgrade.ts plans canary-first batches and halts on a dirty canary or breached failure rate — a runtime patch becomes a paced batch job over the existing build + health-gated release machinery. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): ring-2 pass — harden, close seams, finish product edges security: github installations move to a staged-claim model (webhook stages, owner/admin claims; recordPush only accepts claimed installations and caps in-flight builds), domains.add enforces the customDomains entitlement, and audit coverage lands for domains, rollback, deletion requests, installation claims, and both suspension crons. seams: build → deploy handoff via the runner's release port (failed release keeps the artifact), stale-build self-healing cron, superseded-release pruning (retain 3/project), and server-built pr previews through the same pipeline. product: per-environment secrets (all/production/preview/dev with kind-over-shared resolution + studio picker), rollback button, suspension/deletion banners, org rename, member role change (last-owner protected), project rename. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): switch billing to creem as merchant of record Resolves gaps.md c3: creem (via @lunora/payment/creem) replaces the stripe adapter as the platform's payment provider. As a merchant of record it is the legal seller and calculates/collects/remits sales tax/vat across 190+ jurisdictions, so the platform never inherits worldwide tax compliance. Swaps the adapter wiring in the shard config (CREEM_API_KEY / CREEM_WEBHOOK_SECRET / CREEM_TEST_MODE for the sandbox), the webhook route + action to the creem-signature header, the studio copy to creem product ids and hosted portal, and the docs. Entitlements, dunning, plans, and quota enforcement are unchanged — they ride the provider-agnostic subscriptions store. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): prepaid-credits overage billing core for creem Verified against creem sdk 1.5.3: products are recurring/onetime only — no metered subscription pricing — but creem ships a first-party credits ledger (per-customer accounts, idempotent credit/debit by reference) built for api metering. Overage is therefore prepaid: orgs buy credit packs (one-time mor sales, tax handled by creem) and the platform debits usage beyond the plan's included quota. Ships the pure core (included quotas per plan, cost-plus overage rates, watermark-delta debits with crash-safe idempotent references, exhausted → the existing c1 suspension path, never negative), the overageDebits watermark table with forward-only advancement, and 10 tests. The live credits api wiring (CreditsLedgerPort) is the remaining 🌐 piece. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): creem credits-ledger adapter and fleet overage reconciliation Completes the api/token-metering implementation over creem's customerCredits api: a structural ledger adapter (balance reads via bigint-safe strings, debits with the idempotent watermark reference, missing account → null and never debitable), applyCreditPurchase for the billing webhook (first purchase creates the account seeded with the pack; later ones credit with the payment id as reference), the organizations.creditsAccountId linkage (never overwritten once set), and reconcileAllOverages — the fleet driver with per-org failure isolation, watermark-advance strictly after a successful debit, and exhausted balances handed to the c1 suspension hook. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): studio ux pass — usage meters, daily chart, command palette Ring 3, patterns from the maple teardown (fsl-licensed observability platform — ideas only, all code our own). usage tab: included-vs-used plan-quota meters (amber at 80%, red past allowance, honest prepaid- credits overage label) + a per-day request-volume chart over the new usage.series query, rendered with a zero-dependency svg bar chart. adds a ⌘k command palette (tab navigation + actions, substring match, arrow/enter/escape keyboard flow, state reset by remount) wired into the org dashboard. gaps.md gains the ranked ring-3 backlog (alerting pillar, health charts, log-viewer upgrade, design tokens, onboarding checklist, mcp surface, integrations hub). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * fix(cloud): keep cron triggers within cloudflare cap The control-plane Worker declared 4 distinct cron expressions (0 */1, 0 */6, 0 */12, */1) — one over Cloudflare's hard limit of 3 Cron Triggers per Worker, which would reject the deploy. The lone 0 */12 trigger existed solely for "purge deleted organizations". Fold that job into the existing 6h bucket: the purge gates on each org's own 30-day retention cutoff, so a tighter cadence only shortens erasure latency — it never erases early. Codegen drops the 0 */12 trigger, leaving exactly 3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01423xZDDqzhQ5D79vy25huF * feat(cloud): observability ingest pipeline — issues + incidents (Phase 3) (#140) * feat(cloud): add the observability ingest pipeline (issues + incidents) Phase 3 of the observability plan — durable, cross-deployment monitoring in the Lunora Cloud control plane, fed by the Phase 2 OTLP transport. - ingest: `POST /v1/telemetry` accepts OTLP-over-HTTP/JSON from the tenant `otlpSink` and the container exporter, decodes the error spans (`src/telemetry/otlp.ts`), and folds them into grouped issues/incidents through a deploy-key-authorized `telemetry.ingest` mutation. Synchronous — the cloud app has no queue producer binding, so ingest inserts to D1 directly (like `usage.ingest`); auth reuses `authorizeDeployKey`, not the plaintext admin token. - store: `issues` + `incidents` `.global()` D1 tables, fingerprinted with `@lunora/fingerprint` (the same hash the local Studio computes, so a local Issue and a cloud Issue are one object); `lunora/{issues,incidents}.ts` member-authorized read/triage functions. A `TelemetryStore` adapter (`src/telemetry/store.ts`) owns the non-relational side — AE metrics plus a guarded Pipeline→R2 archive, each a no-op without its binding. - dashboard: hosted `IssuesSection` / `IncidentsSection`, gated behind the `logStreams` entitlement, wired into `OrganizationDashboard`. - bindings: a `TELEMETRY` AE dataset + `TELEMETRY_BUCKET` R2 bucket. Vendors `@lunora/fingerprint` (Phase 1, not yet merged) so this stacks on the cloud branch; the graft folds away once Phase 1 lands on alpha. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm * feat(cloud): observability alerts — rules + delivery (Phase 4) (#141) * feat(cloud): add observability alerts — rules, firing + delivery Phase 4 of the observability plan (the "watches while you sleep" tier), stacked on the Phase 3 ingest. - schema: `alertRules` (name, target issue/incident, threshold, channel email/webhook, destination, enabled) + `alerts` (fired-alert audit trail with firing→delivered state, notification denormalized). - firing: the telemetry `ingest` mutation loads the org's enabled rules and fires each the first time a source's count crosses its threshold (`before < threshold <= after`, so exactly once), inserting a `firing` alert row. The pure crossing/render logic lives in `src/telemetry/alerts.ts` (unit-tested), mirroring how `usage.ingest` delegates to `evaluateSpendCap`. - delivery: the `/v1/telemetry` edge handler delivers fired alerts best-effort (email via `@lunora/mail`, webhook via JSON POST) then stamps them delivered — never blocking or failing ingest. - functions: `alerts.{rules,createRule,setRuleEnabled,deleteRule,list, markDelivered}` (member-authed reads/writes; deploy-key-authed markDelivered). - dashboard: `AlertsSection` (manage rules + recent fired alerts), gated behind the `logStreams` entitlement, wired into `OrganizationDashboard`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm * fix(cloud): validate webhook alert destinations against SSRF An alert rule's webhook `destination` is `fetch`ed by the control plane when the alert fires, so an owner/admin could otherwise aim it at internal infrastructure (loopback, RFC-1918, the 169.254.169.254 metadata IP, …) — server-side request forgery. Add a pure `isSafeWebhookUrl` guard (https only, public host, no embedded credentials, no loopback/private/link-local IPv4 or IPv6) enforced both at `createRule` (reject the rule) and in `deliverAlert` (never fetch an unsafe target — defense in depth for any rule created before this guard). String-level, so it can't defeat DNS rebinding, but it blocks the direct-address cases. Unit-tested. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud): harden webhook SSRF guard Two SSRF gaps in the Observability alert delivery path: - deliverAlert followed webhook redirects, so a destination that passes isSafeWebhookUrl could 3xx-redirect to an internal address (e.g. the metadata IP). Set redirect: "manual" and reject 3xx responses. - isSafeWebhookUrl let IPv4-mapped IPv6 (::ffff:169.254.169.254, which the URL parser compresses to ::ffff:7f00:1) and the unspecified address (::) through. Reject the whole ::-prefixed non-global class. Numeric IPv4 forms (2130706433, 0x7f000001, 0177.0.0.1) were already blocked via WHATWG URL normalization; added as regression tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfLmCwH5xMfz7L73LRPFj --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(cloud): AI incident triage (@lunora/ai) — Phase 4C (#142) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(runtime): use LunoraError not undefined CirrusError in cloud endpoints The scheduled-tick and queue-dispatch admin endpoints threw `new CirrusError(...)`, a class that exists nowhere in the repo, so the file failed to type-check (TS2304). The intended class is `LunoraError`, already imported and used throughout the file. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cloud): full tenant log management with structured fields + trace correlation Consume the structured, trace-correlated logs the framework now emits (shared/log-event.ts) — the cloud log path kept only 3 severities + a flat line and had no producer. Closes the framework side of Maple gap #2. - Producer (GAPS.md B2, the missing piece): src/tail/worker.ts — the dispatch-namespace tail worker decodes each tenant `{source:"lunora", type:"log"}` console event (src/tail/parse.ts, pure + unit-tested), groups them per script, and POSTs batches to POST /v1/logs/tail. Holds one platform secret (LUNORA_TAIL_SECRET), not per-org deploy keys; the route resolves scriptName → org (logs.orgForScript) and stores via logs.ingestInternal. Deployed from tail.wrangler.jsonc. - Store: tenantLogs widened to the full LogEvent shape — 7-tier severity, message, structured fields, functionPath, traceId/spanId, userId, shardKey — plus (scriptName, createdAt) and (org, traceId) indexes. - Query: logs.list gained server-side levels/functionPath/traceId/search filters + a cursor and bounded limit, newest-first. - UI: the studio Logs tab renders severity chips (filter), search, structured fields, and a short trace id per line. Still 🌐: the provisioner setting tail_consumers on tenant scripts, an e2e run, and correlating error/fatal lines to OTLP Issues by traceId (follow-up). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud): provision tenant bindings so deployed workers boot The deploy handler built the provisioner spec with an empty binding set (`bindings: {}`), so every uploaded tenant Worker was created with no Durable Object binding and no `new_sqlite_classes` migration tag. A real Lunora app always exports ShardDO, so it could never boot — the deploy pipeline could only ship a binding-less worker. The deploy request now carries the app's binding manifest (DO classes, optional per-tenant D1/R2) which the CLI reads from `wrangler.jsonc`, and the handler normalizes it to a spec that always includes the ShardDO floor even when a caller under-declares or omits it. Malformed entries are dropped and the DO list is capped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): tear down Cloudflare scripts for destroyed deployments The lifecycle crons (cleanupExpiredPreviews, pruneSuperseded, organizations.purgeDeleted) only transitioned a deployment to `destroyed` — nothing ever deleted the Cloudflare dispatch script, so dispatch namespaces grew unboundedly (the leak GAPS.md Ring-2 flagged as closed). Add a `teardownAt` marker and a pure, port-injected `runTeardownSweep` (per-target failure isolation, crash-safe idempotent off the marker), wired into the control-plane Worker's scheduled() handler on the hourly/6-hourly buckets — right after the crons that mark rows destroyed. No-ops without Cloudflare credentials. Per-tenant D1/R2 teardown-by-id still needs resource-id persistence and is left as a follow-up; script deletion is the load-bearing fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): fold Analytics-Engine usage into the metering ledger The dispatcher wrote one AE data point per tenant request, but nothing ever read them back — createHttpAnalyticsReader had no caller, so `platformUsage` only held what tenants self-report over POST /v1/usage (nothing, in practice). Spend caps, the usage summary, and the usage chart therefore evaluated an empty ledger. Add a per-cell `usageReadAtMs` checkpoint and a pure, port-injected `runUsageRollback` that delta-reads AE (`timestamp > checkpoint`), attributes each dispatch script to its org/deployment, and appends `requests` rows — then advances the checkpoint so re-runs never double count. A per-row ledger failure is dropped rather than retried (under- count, never double-bill — the same fail-safe as usage.rollup). Wired into scheduled() on the hourly/6-hourly buckets; no-ops without Cloudflare credentials. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): build-queue dispatcher (claim → run → drain) `builds.claimNext` had no caller: enqueued builds sat untouched until the 24h expiry cron failed them with "no build runner picked this up". Add the missing claim→run loop as a pure, port-injected `runBuildDispatch` (bounded per-tick drain; a failed build never aborts the drain), fully unit-tested against the runner ports. Production activation stays gated on the runner's 🌐 seams — `execute` (a throwaway Cloudflare Container running `lunora build`) and `fetchSource` (GitHub App tarball) — which need live container infra, so the dispatcher is not yet wired into scheduled(): claiming builds with no executor would only burn them. This lands the verified logic so the remaining work is purely the container seam, not the orchestration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * docs(cloud): split GAPS legend into wired vs pure-module (🧩) The single ✅ conflated "tested pure function exists" with "feature runs". Add a 🧩 marker for tested-but-uncalled modules, a dated wiring-pass section covering the four gaps just addressed, and correct the two most misleading inline entries: - A3 builds: the claim dispatcher now exists (was missing); only the container execute() seam remains 🌐. - C3 overage credits: reconcileAllOverages / applyCreditPurchase have no production caller (verified) — scheduling + webhook mapping are code (🔨), not credentials (🌐), so the honest status is 🧩, not "✅ core". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): boot-time route classification scanner Port of Openship's route-scanner idea (Apache-2.0) to the /v1 router. The control-plane routes each did inline auth then delegated to a self- authorizing function, but nothing forced a *new* route to be classified — an unclassified endpoint could ship silently and read as protected. Every route now carries an explicit RouteSpec.auth (deployKey / session / webhookHmac / tailSecret / adminToken / public), and assertRoutesClassified runs when createDeployRouter builds the table: a missing/unknown classification, a public route with no reason, or a duplicate (method, path) throws at construction — the Worker fails to start rather than serving an unclassified route. The flat dispatch tables are derived from the one checked list (GET + POST unified). The spec's opt-in `mcp` field is the allowlist the MCP surface will read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): MCP surface generated from the route registry Port of Openship's "MCP tools derived from the route registry" idea (Apache-2.0). A `/v1/mcp` JSON-RPC endpoint (tools/list + tools/call) exposes only routes that opt in via RouteSpec.mcp, and every tool call dispatches back through the real router carrying the agent's own bearer credential — so it runs the identical auth + rate-limit + handler + function-authz path as any HTTP caller; the MCP layer grants no privilege. A hard deny-list (buildMcpTools) guarantees token/secret/tenant-access routes (/v1/secrets, /v1/admin, /v1/invitations/send, /v1/logs/tail) and the surface itself (/v1/mcp) can never become tools even if mis-annotated — the same scope-escape guard Openship applies to tokens/auth/mcp. Only bearer-callable (deployKey/adminToken) opted-in routes are eligible; session/webhook routes are excluded. deployments.rollback is the first tool exposed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * chore(codegen): regenerate _generated for teardownAt + usageReadAtMs Keeps the emitted dataModel/shard/drizzle types consistent with the new deployments.teardownAt and cells.usageReadAtMs schema columns. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): tear down tenant D1 + R2, and test the sweep glue Extends resource teardown past the dispatch script (#2). The lifecycle sweep now also deletes the per-tenant D1 database and R2 bucket, resolved by the same naming convention the provisioner creates them under (shared tenantD1Name / tenantR2Bucket helpers — no drift, no new persistence). New CF API methods: findD1DatabaseByName + deleteD1Database (uuid) and deleteR2Bucket (name). Script + D1 delete are retryable; R2 is best-effort (a non-empty bucket needs an S3-API object purge the teardown context lacks — logged, left for follow-up). D1 (every .global() app has one) and empty R2 buckets are now fully reclaimed. Also extracts the scheduled() sweep glue into testable port-builders (#4): teardownPorts + usageRollbackPorts over a structural ControlPlaneDb, so the row→target mapping, the teardownAt stamp, the ledger insert, and the per-cell checkpoint are unit-tested against a fake store instead of living untested inside server.ts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): carry cronSpecs + bindings from wrangler on deploy The cron fan-out read live deployments' cronSpecs, but nothing ever populated them: deployments.create accepted the field yet the deploy handler/router never passed it, so readCronTargets always returned [] and the entire §2.4 tenant-cron fan-out had no data source (#3). Add parseWranglerManifest — a pure reader that extracts the binding manifest (DO classes / D1 / R2) and cron expressions from a tenant's wrangler.jsonc — and thread cronSpecs through the deploy request → handler → create mutation. The deploy client + CLI now forward both bindings and cronSpecs, so a real deploy provisions what the Worker needs and registers the crons the fan-out drives. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): synthetic uptime monitoring with alerting Adds external-vantage uptime to the control plane's Observability tier — the piece a deployment can't self-report (if it's down, it can't say so). - Probe: pure probeDeployment (generalizes the deploy-time healthCheck — GET, sub-500 = up, latency + timeout, never throws), a consecutive-failure state machine, and a summarizer, all unit-tested (src/uptime/probe.ts). - Sweep: runUptimeSweep over injected ControlPlaneDb ports (mirroring the teardown/usage sweeps) probes every live deployment, records a uptimeChecks row, advances uptimeState, and fires an "uptime" alert the first time a deployment's failures cross a rule threshold — reusing crossesThreshold, renderAlert, and the alerts table/delivery pipeline (src/uptime/sweep.ts). - Edge: server.ts scheduled() runs the sweep on the every-minute tick and delivers fired alerts over their channel (webhook/email), stamping the outcome. - Alerts gain an "uptime" target (schema + createRule + renderAlert), so users configure "page me when my deployment is down" alongside issue/incident rules. - Read side: lunora/uptime.ts (summary + recent queries, retention prune cron) backs a new Uptime dashboard section. Cron triggers stay at 3 expressions (prune rides the 6h bucket, the probe rides the existing every-minute tick). Full suite: 272 tests, lint:types clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019M6G6CAoLVrQMxDYg7BWq2 * fix(cloud): address thermo review of uptime monitoring Security/correctness (branch audit): - SSRF: the sweep fetched a …
…rts, sessions, dashboards (#179) * feat(studio): browse the durable log archive in the Logs panel (#155) * feat(studio): browse the durable log archive in the Logs panel Add a third "Archive" feed to the studio Logs panel that reads the durable ctx.log archive pipelineLogSink writes to R2 (Iceberg / R2 Data Catalog). - @lunora/runtime: a new admin-gated `/_lunora/admin/logs/archive` route (`log-archive-admin-routes.ts`) runs `createPipelineLogReader` server-side — the R2 SQL token stays on the worker, only decoded `{ rows, nextCursor }` reaches the browser. Reads creds from env (`R2_SQL_*`, `CLOUDFLARE_ACCOUNT_ID` fallback) + the table from a new `logArchive` WorkerOption. Fails closed with `LOG_ARCHIVE_NOT_CONFIGURED` when unwired. - @lunora/client: `queryLogArchive(query)` method + re-exported PipelineLog* wire types (owned by @lunora/runtime). - @lunora/studio: a self-contained `ArchiveFeed` (function/user/min-level filters, keyset "Load more", a "not configured" empty state distinct from an error) rendered under the new Archive tab; `errorCode` helper. - Docs + API snapshots updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * refactor(studio): drop try/finally in ArchiveFeed for React Compiler The React Compiler bails on a `try` with a `finally` clause (React Doctor `react-hooks-js/todo`), so the component missed automatic memoization. Reset `loading` in each branch instead, matching the repo's no-`finally` pattern. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * refactor(studio): address thermos review of the archive feed Code-quality + correctness follow-ups from the two-pass review: - Extract the duplicated LEVEL_VARIANT map into `log-level-variant.ts`, shared by the live Logs feeds and the Archive feed (restores the exhaustive `Record<LogLevel, BadgeVariant>` type — drops the `?? "outline"` fallback). - Collapse the four `view !== "archive"` readout guards in logs-panel into one `view === "archive" ? <ArchiveFeed/> : <>…</>` branch. - Drop the `JSON.parse(JSON.stringify(baseQuery))` round-trip in the fetch effect — pass `baseQuery` directly, keying the effect on `querySignature`. - Guard `loadMore` against a cross-filter race: a page-2 fetch that resolves after a filter change is dropped (via `activeSignatureRef`) instead of appending stale rows / overwriting the cursor. - Show a "Loading…" placeholder on the initial fetch instead of a blank panel. - Type `minLevel` state as `"" | ContextLogLevel` (removes a cast). - Move the `LOG_ARCHIVE_NOT_CONFIGURED` sentinel to `shared/log-archive.ts` so runtime and studio share one source of truth with no dependency edge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * refactor(studio): address CodeRabbit review of the archive feed - Fold ArchiveFeed's five fetch-related useState into one useReducer, so each fetch transition (loading / loaded / append / notConfigured / failed / pageFailed) is a single dispatched action (React Doctor prefer-useReducer). - Use the imported `ChangeEvent` type instead of the `React.*` namespace, matching logs-panel.tsx. - Gate the toolbar `LiveError` on `view !== "archive"` so the disabled Errors feed's live-connection state can't leak into the (WS-less) Archive tab. The CodeRabbit "cast env to LogArchiveEnvironment" suggestion is intentionally skipped: `env ?? {}` narrows to `{}`, which is assignable to the all-optional LogArchiveEnvironment (lint:types is green), and eslint's no-unnecessary-type-assertion rejects the cast. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * polish(studio): clear prior error on a fresh archive fetch Thermos re-review nice-to-have: the reducer's `loading` action now clears `error`/`notConfigured` (matching the kv reducers' `submitStart`), so retrying after a failure shows the loading placeholder instead of the stale error line. Rows are kept, so paging / filter-change refetches don't blank the table. (Kept `default: return state` to stay consistent with the existing kv reducers rather than introduce a one-off `unreachable` exhaustiveness guard.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * chore(release): @lunora/runtime@1.0.0-alpha.32 [skip ci]\n\n## @lunora/runtime [1.0.0-alpha.32](https://github.com/anolilab/lunora/compare/%40lunora%2Fruntime%401.0.0-alpha.31...%40lunora%2Fruntime%401.0.0-alpha.32) (2026-07-21) * chore(release): @lunora/client@1.0.0-alpha.26 [skip ci]\n\n## @lunora/client [1.0.0-alpha.26](https://github.com/anolilab/lunora/compare/%40lunora%2Fclient%401.0.0-alpha.25...%40lunora%2Fclient%401.0.0-alpha.26) (2026-07-21) * **@lunora/runtime:** upgraded to 1.0.0-alpha.32 * docs: add cirrus cloud platform plan Reverses the managed-deploy-plane won't-do (VOID-TEARDOWN.md §0/§6, CONVEX-PARITY.md #23) with a scoped managed tier: Workers for Platforms data plane, Convex-shaped product model (teams/projects/prod+dev+preview deployments), PartyKit-style managed-vs-BYO CLI split, and a phased roadmap starting with remote-binding dev. Synthesized from a repo inventory, a Convex Cloud teardown, and a GitHub/Cloudflare-primitives survey. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: fold supabase platform teardown into cloud plan Adds Supabase as a reference model: the OSS/proprietary cut line, IS_PLATFORM single-codebase studio pattern, Branching 2.0 preview DX (and its pain points to fix: empty branches, hourly branch billing outside spend caps), the Management API + OAuth-apps growth channel, and the structural cost advantage WfP gives over per-project VMs. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: add wfp constraints, eject path, spike checklist Gap review of the cloud plan: documents that cron triggers are silently dropped for namespaced user Workers (with SchedulerDO alarm-based fan-out mitigation), queue-consumer and send_email verification items, KV account-limit multiplexing, EU jurisdiction toggle, a cirrus-eject portability command built on existing export/import RPCs, namespace-wide observability reuse, managed backups + abuse controls in Phase 4, and a Phase 1 constraint spike. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: verify cloud plan claims against cloudflare docs Fact-checked every Cloudflare claim in CLOUD-PLAN.md against the official docs: WfP pricing, KV/D1/R2 account limits, DO/R2 jurisdictions vs D1 location hints, CF for SaaS hostname pricing, and remote-bindings GA versions all confirmed. Adds three newly verified constraints: no gradual deployments for user Workers (rollback must be platform-side bundle re-upload), the 1200-req/5-min account API rate limit on provisioning, and outbound-Worker TCP/DO interception trade-offs. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: add cell-based scaling architecture to cloud plan Answers how the managed tier scales without hitting account limits or risking platform-wide blocks: script-resident tenant state with lazy inference-driven provisioning, multi-account cells with cell IDs baked into identifiers from day one, a per-cell API token-bucket scheduler, a tenancy graduation ladder up to managed-BYO and the Tenant API, and abuse containment to keep tenant abuse from looking like platform abuse. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: scope alchemy to cell bring-up, not tenant deploys Records the provisioning-engine decision: the per-tenant deploy path stays hand-rolled on cloudflare-typescript (control-plane DB as the single source of truth, cell scheduler, progress events, rollback artifacts); Alchemy (pre-1.0, v2 rewrite underway, no confirmed dispatch-namespace resource) is a candidate only for low-cardinality cell bring-up IaC, with Terraform/Pulumi as the fallback. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: correct alchemy facts (dispatch-namespace resource, v0.93) Re-checked Alchemy against GitHub/npm: it is v0.93.12 (Apache-2.0) and does ship a dispatch-namespace (Workers for Platforms) resource — my earlier 'lacks a confirmed dispatch-namespace resource' was wrong and 'v0.9x' undersold it. Recommendation is unchanged (hand-roll the per-tenant deploy, use Alchemy for cell bring-up) but now rests on the real reason — source-of-truth shape and deploy-orchestration concerns, not capability — with a re-evaluation trigger at a stable 1.x. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: adopt alchemy as the provisioning engine Decision: Alchemy is the provisioning engine across cell bring-up, per-tenant managed deploy, and BYO. Verified it ships DispatchNamespace/ Worker/D1/R2/DO resources plus a built-in D1StateStore and runs inside a Worker (await alchemy(scope) -> finalize/destroy). Backing each tenant scope with the control-plane D1 collapses the two-sources-of-truth concern into one store. The per-cell rate-limit scheduler now paces finalize() runs; bundling stays in the Vite pipeline; rollback re- converges to a prior R2-retained bundle. Risk of a 0.x dependency contained behind a @cirrus/provision adapter with cloudflare-typescript as fallback; spike open items recorded. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: build on alchemy v2 (alchemy@next, 2.0.0-beta.55) Per decision to start on the v2 line: target alchemy@next (verified 2.0.0-beta.55, Effect-based) to avoid a v1->v2 migration mid-build, with v1 0.93.x as the named fallback. Records the trade-offs (beta churn, Effect pulled into the control-plane tree, quarantined behind the @cirrus/provision adapter) and turns the unverified v2 facts (DispatchNamespace resource + D1/DO state store, confirmed on v1 only since docs/CDN were unreachable) into hard Phase 1 spike gates with v1 fallback per surface. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: commit fully to alchemy v2, drop v1 fallback Remove the v1 (0.93.x) fallback hedging throughout: v2 (alchemy@next, 2.0.0-beta.55) is the engine outright. Spike gates remain but now resolve via owned shims or upstream contributions rather than retreat to v1; a hard unresolvable 'no' escalates the engine decision instead of silently dual-tracking. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: add forgotten must-haves + fleet runtime-versioning risk Gap pass on the cloud plan. New risk #8 (the load-bearing one): the Cirrus runtime is bundled into each tenant Worker, so a security patch means redeploying the whole fleet unless the tenant Worker is made 'thin' against a central runtime — a fat-vs-thin decision that must be made before Phase 1 since it shapes the bundle format, deploy API, and vite emit. New section 7 collects launch-blocking gaps the plan had assumed away: control-plane DB durability/DR, cross-cell disaster recovery, secrets-at-rest + cell-token custody, frontend-hosting scope, AUP + bill-shock/cryptomining controls, billing/MoR/tax, GDPR processor/DPA/SOC2, account offboarding + right-to-erasure, platform self-observability + status page, dispatcher canary, and a staging cell. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): scaffold control-plane app built on cirrus First implementation step from CLOUD-PLAN.md: a new apps/cloud workspace app that dogfoods Cirrus as the platform's own control-plane backend. - cirrus/schema.ts: control-plane data model (cells, organizations, members, projects, deployments, deployKeys, auditLog), all .global() (D1) — the plan's 'Worker + D1' control plane. - cirrus functions: organizations/projects/deployments/cells/deploy-keys (create/list/issue/updateStatus), with owner seeding + audit trail. - src/server.ts: control-plane Worker entry wiring D1-backed global tables. - src/provision.ts: the @cirrus/provision seam — the sole coupling to the Alchemy v2 engine (stub that rejects until the Phase 1 spike wires it). - configs (package.json/tsconfig/project.json/wrangler.jsonc/eslint/vitest), README, and a provision test. Verified: codegen clean (no advisories), eslint clean, tsc --noEmit passes, vitest green. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): add deploy-orchestration core Builds the next control-plane layer on the scaffold's Provisioner seam, all pure/testable (no live Cloudflare needed): - token-bucket.ts: per-cell API budget (§2.5), models CF's 1200/5min account limit; deterministic + clock-injectable. - scheduler.ts: CellScheduler paces/serializes provisioner work against the bucket with priority ordering + a concurrency cap. - orchestrator.ts: runDeployment state machine emitting queued → provisioning → live/failed progress events (§2.2); destroyDeployment for preview-TTL/project teardown. - keys.ts: deploy-key format/parse/hash helpers; deploy-keys.ts mutation refactored to use them (one tested place for the format + SHA-256). 17 tests across 5 files; eslint + tsc --noEmit clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): add org authorization + deploy-key lifecycle Closes gaps found reviewing the control plane: - authz.ts: assertMember(ctx, orgId, roles?) — the org ACL gate. Every org-scoped function now verifies the caller is a member with a permitted role, closing an IDOR hole where any signed-in user could read/mutate any org by passing its id. Applied across projects, deployments, deploy-keys. - members.ts: list / add / remove so memberships can actually be granted (owner is seeded on org create; admins/owners manage the rest). - deploy-keys: verify (the deploy API's auth path — match by SHA-256, reject revoked, bump lastUsedAt, return the DB-authoritative target) and revoke (leaked-key mitigation); lastUsedAt/revokedAt are now live. - deployments: create checks the project belongs to the org; updateStatus loads the deployment and gates on its org (documented as the system seam for the orchestrator). codegen clean, eslint + tsc --noEmit clean, 17 tests pass. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): deploy API endpoint, deploy-key auth, handler tests Lands the three remaining pieces together: - Deploy API: POST /v1/deploy mounted via the httpRouter seam (src/deploy/ router.ts) → pure handler (src/deploy/handler.ts) authenticates the bearer deploy key, records a queued deployment, drives runDeployment through the per-cell scheduler, and streams NDJSON progress (accepted→queued→ provisioning→live/failed→done), patching status per phase. - Auth path: investigation showed internalMutation is unreachable from the HTTP action-context dispatch (no system flag → RPC 404), so verify/ updateStatus stay public; instead added deploy-key authorization (authz.authorizeDeployKey) and a dual-path (member session OR deploy key) on deployments.create/updateStatus, so CI deploys need no user session. Corrected the stale 'should become internalMutation' comments. - Tests: handleDeployRequest (401/403/400 + success and failure streaming + status transitions) and authz (assertMember + authorizeDeployKey) via a fake ctx. 28 tests / 7 files; eslint + tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs(cloud): refresh status — deploy API + auth now in place * chore(cloud): track generated schema snapshot Matches the apps/playground convention — .cirrus-schema.json is the codegen schema snapshot used for migration/drift detection and is committed, not gitignored. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): preview lifecycle, GitHub webhook, deploy client (Phase 1/2) Phase 2: - Preview deployments are TTL'd: deployments.create stamps expiresAt for kind=preview (src/deploy/preview.ts: deterministic previewScriptName + 5-day previewExpiry); an hourly cron (cirrus/crons.ts -> internal deployments.cleanupExpiredPreviews) marks expired previews destroyed. Worker gains scheduled(); wrangler cron trigger added. - GitHub webhook (src/github/webhook.ts): HMAC-SHA256 verify + pull_request -> preview-intent parsing, mounted at POST /v1/github/webhook. Phase 1: - Deploy client (src/deploy/client.ts): the cirrus-deploy core — POSTs to /v1/deploy and consumes the NDJSON progress stream. 13 new tests (41 total); codegen/eslint/tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: update roadmap — Phase 0 shipped, Phases 1-2 status Phase 0 (remote-binding dev) is already implemented in the framework (@cirrus/config remote-bindings + @cirrus/vite plugin + cirrus dev; 30 tests) — corrected from 'not started'. Phases 1-2 marked substantially-built with the live-Cloudflare-dependent remainder (Alchemy provisioner, dispatcher, e2e validation) called out. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): team invitations + billing quotas; repo-extraction guide (Phase 3/4) Phase 3 (hosted studio sliver): - Team invitations (cirrus/invitations.ts + invitations table): invite/list/ revoke/accept, single-use SHA-256-hashed tokens (plaintext mailed once), owner-admin gated; accept-by-token adds the caller as a member. Phase 4 (billing sliver): - Plans + quota entitlements (src/billing/plans.ts) on @cirrus/payment's entitlements model — free/pro/enterprise limits + feature flags, with effectiveLimit/withinQuota and a free-tier fallback for non-subscribers. Portability (move to a private repo): - EXTRACT.md documents the mechanical extraction; audit confirms the app imports only published @cirrus/* entry points (no monorepo-internal reaches). 6 new tests (47 total); codegen/eslint/tsc clean; secret-scan clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): real Cloudflare provisioner, quota enforcement, webhook project resolution Provisioner (Phase 1 — the big one): replaces the rejecting stub with a real implementation over a typed Cloudflare REST port (src/cloudflare/api.ts): deploy provisions per-tenant D1/R2, uploads the user Worker into the dispatch namespace with binding + DO-migration metadata, applies secrets, returns the bundle hash + routed URL; destroy deletes the script. Port-injected so it's tested with a fake; plug in CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN to run. (REST via fetch rather than the unverifiable alchemy@next beta — same seam.) Quota (Phase 4): plans.ts gains planLimit/withinPlanQuota; projects.create and members.add enforce the org plan's limits. Preview automation (Phase 2): projects gain githubRepo + byGithubRepo lookup; the webhook resolves the connected project and returns the preview script name. Env documented (.dev.vars.example + wrangler vars). 50 tests; eslint/tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): dispatcher Worker + hosted-studio admin-RPC proxy (Phase 1/3) Phase 1 — dispatcher Worker (the request-path front door): resolveTenant maps {scriptName}.{appDomain} (and custom domains via injected lookup) to a dispatch-namespace script; the worker forwards via env.DISPATCHER.get with per-plan limits. Separate deployable (dispatcher.wrangler.jsonc). Phase 3 — admin-RPC proxy: proxyAdminRequest authorizes org membership, forwards the admin RPC to the tenant's /_cirrus/admin/* with that deployment's admin token, and records an audit entry. Pure (deps injected). 7 new tests (57 total); eslint/tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): cirrus login/link/deploy CLI commands (Phase 1) Pure command logic over a ConfigStore + the deploy client: login persists the API endpoint + deploy key, link binds a project, deploy streams a managed deploy (requires login+link). File-backed store at ~/.cirrus/cloud.json for the Node CLI; cerebro registration in @cirrus/cli calls these. 3 new tests (60 total); eslint/tsc clean; secret-scan clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: roadmap status — control-plane backend feature-complete as code All phases' backend code is built + unit-tested in apps/cloud (60 tests): real REST provisioner, dispatcher, CLI, preview lifecycle, GitHub webhook, team invitations, admin-RPC proxy, quota enforcement. Remaining items are the ones needing live Cloudflare / external services / the studio UI. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): admin-proxy live wiring, usage metering, custom-hostname port (Phase 3/4) Phase 3 — admin proxy mounted at POST /v1/admin: deployments now carry the platform-minted tenant adminToken (set as the worker's CIRRUS_ADMIN_TOKEN secret + stored on the row), deployments.adminTarget resolves {url, adminToken} after asserting membership, and the router forwards to the tenant's /_cirrus/admin/* with an audit-log.record entry. Phase 4 — usage metering: usageEvents table + internal record mutation + member summary query over a pure aggregateUsage roll-up. Custom hostnames: CloudflareApi.createCustomHostname (Cloudflare for SaaS, zone-scoped REST). Router refactored into per-route handlers. 64 tests; eslint/tsc/secret-scan clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: roadmap — admin proxy mounted, usage metering + custom-hostname port added * feat(cloud): add hosted studio react spa Build the hosted-studio frontend for the Cirrus Cloud control plane: a better-auth-gated React SPA served on one origin with the control-plane Worker via @cirrus/vite. - src/client: main/auth-client/Login, App auth gate, OrganizationList, OrganizationDashboard with tabs for projects, deployments, members, deploy keys, invitations, and usage; AsyncList loading/empty helper. - Wire @cirrus/auth into src/server.ts (createAuth + cirrusD1Adapter, ensureMigrated, handleAuthRequest, authAdmin, resolveIdentity) and add AUTH_SECRET/AUTH_URL env + .dev.vars.example entries. - Switch package scripts to vite (build/dev), add react/react-dom + @cirrus/react/@cirrus/auth deps, vite.config.ts, index.html, and the DOM lib + jsx in tsconfig. - eslint: client section (filename-case, react-perf, void), browser globals; ignore vite.config.ts. - Refresh README + CLOUD-PLAN status to reflect the studio UI. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): add billing, metering, and hardened auth Billing on @cirrus/payment (§4): org id is the payment referenceId. Wire a Stripe adapter into createShardDO({ payment }); add cirrus/billing.ts with checkout/portal actions, entitlements/subscription reads (resolved through CIRRUS_CLOUD_PLANS with a free-tier fallback), and a signature-verified processWebhook mounted at POST /v1/billing/webhook. The studio gains a Billing tab. Platform metering (§4): rename the resource-metering table to platformUsage (freeing usageEvents for @cirrus/payment's billing ledger), add a deploy-key authenticated usage.ingest mutation + POST /v1/usage endpoint, and enforce per-plan runtime limits in the dispatcher (limitsForPlan → DISPATCHER.get). Auth hardening (§3) on @cirrus/auth/better-auth: mail-backed email verification + password reset (@cirrus/mail), optional GitHub/Google OAuth, admin/twoFactor/passkey plugins, built-in auth rate limiting, plus a per-IP @cirrus/ratelimit cap on the /v1/* surface. Invitations now email the token via POST /v1/invitations/send (never shown in the browser). The Cirrus organizations/members model stays the single org source of truth (better-auth organization plugin deliberately omitted). Add deps (@cirrus/mail, @cirrus/ratelimit, stripe), tests for the router routes + rate limiting + per-plan limits (69 total), and reconcile the README + CLOUD-PLAN status (the provisioner is a real Cloudflare REST impl, not a stub). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): enforce entitlements, wire metering source, add secrets Close the billing loose ends and add the metering source, tenant secrets, and an audit-log view. Entitlements (close loose end #1): quota is now enforced against live subscription state (cirrus/entitlements.ts resolves from the synced `subscriptions` table) rather than the static organizations.plan column — projects/members creation call assertWithinQuota, so a Stripe upgrade raises limits immediately with no column to sync. Per-plan dispatch limits (close loose end #2): deployments.planForScript + a bearer-gated GET /v1/tenants/plan endpoint + a cached plan resolver in the dispatcher (createPlanResolver) wire resolvePlan, so runtime limits actually scale per plan instead of always falling back to free. Metering source: the dispatcher emits one Analytics Engine data point per tenant request (src/metering/analytics.ts); a reader port + HTTP impl and an hourly usage.rollup compaction cron complete the pipeline alongside the existing /v1/usage ledger ingest. Tenant secrets (§7): AES-256-GCM envelope encryption at the edge (src/secrets/crypto.ts), a secrets table (ciphertext + IV only), store/list/ listEncrypted/remove functions, POST /v1/secrets, deploy-time materialization into the tenant Worker, and a studio Secrets tab. Studio: add Secrets + Activity (audit log) tabs; add audit-log.list. Tests: crypto round-trip, plan resolver caching/fallback, entitlement quota, analytics writer/reader (83 total). Docs + .dev.vars.example updated. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * fix(cloud): address review findings (rollup atomicity, deploy failure, dedup) Apply /review findings on the recent billing/metering/secrets work: Correctness: - usage.rollup: the D1 global backend has no multi-statement transaction, so the old insert-summed-then-delete-originals order could double-count (over- bill) on a mid-rollup crash. Reorder to delete the extras first, then patch the surviving row's total last — a crash can now only under-count, never leave a summed row beside surviving originals. - deploy handler: a tenant-secret decrypt failure (corrupt secret / rotated key) threw inside the NDJSON stream and left the deployment stuck in `accepted`. Catch it and transition to `failed` with a status update. - POST /v1/secrets: encryption/config failures (e.g. a malformed SECRET_ENCRYPTION_KEY) now return 500, not a misleading 403 (kept distinct from the membership 403 the store mutation raises); reject the reserved CIRRUS_ADMIN_TOKEN secret name up front instead of silently clobbering it. - studio: drop the plan picker from org creation — limits now come from live subscription entitlements, so selecting a paid plan at create-time granted nothing. Orgs start free; upgrade via the Billing tab. Cleanup: - Extract the cross-org IDOR guard into authz.assertRowInOrg and call it from secrets/members/deploy-keys/invitations (was four byte-identical copies). - Remove dead plans.ts exports planLimit/withinPlanQuota (superseded by entitlements-based quota); add a single highestPlan/PLAN_PRECEDENCE helper and use it in deployments.planForScript (was a hand-rolled tier ladder). - Memoize the Stripe payment config per isolate (was rebuilt on every shard request that touches ctx.payments). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * test(cloud): validate websockets through dispatch (phase 1 spike) Validate the hottest path — hibernated-WS subscriptions + per-invocation limits through env.DISPATCHER.get() — the least-documented WfP case (risk #3). - spikes/ws-dispatch/: a runnable harness for live validation on a real dispatch namespace. A framework-free hibernatable-WebSocket Durable Object (the exact primitive ShardDO uses: acceptWebSocket + webSocketMessage), deployable into the namespace, plus a zero-dep Node probe that drives it through the dispatcher and asserts: (1) the WS upgrade survives the dispatch hop (101 + live socket), (2) a hibernated server push (broadcast) reaches the socket — the mutation-to-subscription shape, (3) cpuMs-limit behaviour. The README documents deploy/run, pass/fail, and the expected results + caveats. - __tests__/dispatcher-ws.test.ts: unit-pins the dispatcher forwarding contract (returns the tenant 101+webSocket response unchanged, applies per-plan limits, meters the upgrade once) — runs in CI, no infra needed. - dispatcher worker: clarifying comments on WS pass-through + per-frame metering semantics. CLOUD-PLAN risk #3 now references the harness. The dispatcher half is verified here (94 tests); the end-to-end behaviour needs a live Cloudflare account + the Workers-for-Platforms add-on to run. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): tenant cron fan-out through dispatch (wfp workaround) Cloudflare drops triggers.crons for Workers in a dispatch namespace, so tenant cron jobs never fire. Fan them out from the control plane (CLOUD-PLAN §2.4). - @cirrus/runtime: add an admin-gated POST /_cirrus/scheduled tick endpoint that runs a cron expression's jobs through the SAME handleScheduled path the native scheduled() trigger uses (user crons + code crons + backup), so a platform can drive a namespaced tenant's crons over HTTP. (Dispatch stubs expose only fetch()/connect() — no scheduled()/queue() — so HTTP is the only transport in.) - src/fanout/cron.ts: pure 5-field cron-expression matching (lists, ranges, steps, dom/dow OR semantics) + dueTicks + fanOutCron orchestration. - control plane: capture each tenant's cronSpecs on the deployments row at deploy; an every-minute heartbeat cron (cirrus/fanout.ts) makes codegen emit the */1 trigger, and server.ts scheduled() reads live cron targets and ticks each due tenant via env.DISPATCHER.get(script).fetch('/_cirrus/scheduled') with the per-deployment admin token (kept in-process — never exposed). Adds the DISPATCHER binding to the control-plane wrangler. Tests: cron matching, dueTicks, fanOutCron (103 cloud tests; 337 runtime tests still green). Live validation on a dispatch namespace pending; queue consumer fan-out is the remaining half. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): tenant queue-consumer fan-out through dispatch (wfp workaround) WfP namespaced Workers can't be queue consumers, so tenant queue-backed work (@cirrus/mail sends, scheduler queue-workpool) never drains. Fan it out from a platform-owned consumer (CLOUD-PLAN §2.4) — the queue counterpart to the cron fan-out. - @cirrus/runtime: add a `queueHandler` option + an admin-gated POST /_cirrus/queue endpoint that reconstructs the batch and invokes it, returning the message ids to retry. (Dispatch stubs are fetch-only, so HTTP is the only transport into a namespaced tenant.) - src/fanout/queue.ts: pure grouping of a shared-queue batch by the producing tenant's script (envelope `{ script, body }`) + fan-out orchestration that collects per-message retries and retries a whole group on delivery failure. - control plane: the account-level Worker is the consumer — server.ts queue() drains the shared cirrus-tenant-queue, resolves each tenant's admin token in-process (never exposed), forwards sub-batches via env.DISPATCHER.get(script).fetch('/_cirrus/queue'), and acks/retries per the tenant reply. Adds the queues.consumers binding to the control-plane wrangler. Tests: groupByTenant + fanOutQueue (108 cloud tests; 337 runtime tests green). Live validation on a dispatch namespace + a producer-side script-tagging helper remain. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): align with the lunora rebrand + reuse @lunora/analytics Rebased onto alpha, which renamed the framework cirrus → lunora. Reconcile the control-plane app and reuse a newly-shipped package. Rebrand: - npm scope @cirrus/* → @lunora/* across deps + imports. - app functions dir cirrus/ → lunora/ (+ tsconfig/eslint globs, _generated paths, the committed schema snapshot → .lunora-schema.json). - reserved paths /_cirrus/* → /_lunora/* (incl. the new scheduled/queue tick endpoints) and the runtime-injected env.__lunoraCtx; renamed exported symbols (LunoraError, LunoraClient/Provider, useLunora, lunoraD1Adapter, LunoraAuth*, LUNORA_CRONS/FUNCTIONS); vite plugin cirrus() → lunora(); CLI config dir ~/.cirrus → ~/.lunora. - wire the new required GlobalIntrospector.facetColumn via @lunora/d1's facetGlobalColumn. Reuse: - src/metering/analytics.ts is now a thin domain layer over @lunora/analytics (createAnalytics writeDataPoint + createAnalyticsSqlClient AE-SQL reader) instead of a hand-rolled writeDataPoint + HTTP SQL client. Verified the rest is genuinely cloud-specific (cron-expression matching, AES-GCM secret crypto, the Cloudflare REST provisioner, the per-cell CF-API token bucket) — no upstream equivalent to fold into. 103→108 cloud tests green; runtime 379 tests green; tsc/eslint/build clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): rebrand the product Cirrus Cloud → Lunora Cloud Complete the lunora rebrand to the product layer (the framework already moved): - brand prose Cirrus Cloud → Lunora Cloud across code comments, README, EXTRACT, the studio (index.html title, Login/dashboard), and CLOUD-PLAN.md. - env vars CIRRUS_* → LUNORA_*: LUNORA_ADMIN_TOKEN and LUNORA_MAIL_CAPTURE are functional (read by @lunora/mail); LUNORA_APP_DOMAIN / LUNORA_CELL and the VITE_LUNORA_URL client var follow for consistency. - the LUNORA_CLOUD_PLANS entitlements constant. - infra names cirrus-* → lunora-*: worker names (lunora-cloud, lunora-dispatcher), dispatch namespace (lunora-production), shared queue (lunora-tenant-queue), AE dataset (lunora_tenant_usage), the lunora.app apex, and the deploy dispatch-namespace prefix. - the hosted-CLI verbs (lunora login/link/deploy) and config dir ~/.lunora. - docs' reserved-path/marker refs (/_lunora/*, __lunora_admin__, env.__lunoraCtx). 108 cloud tests green; tsc/eslint/build clean; zero residual `cirrus` references. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * refactor(cloud): migrate functions to fluent builder api Adapt the cloud control-plane functions to alpha v1.0.0-alpha.1's fluent function builders: kind.input({...}).<terminal>(({ ctx, args }) => ...) replaces the removed object form kind({ args, handler }). Regenerate _generated/* and pick up codegen's observability block in wrangler. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): license under polyform noncommercial The control plane is the proprietary product layer, so it must not carry the framework's FSL-1.1-Apache-2.0 (which grants broad commercial rights). Apply PolyForm Noncommercial 1.0.0: any noncommercial purpose is permitted, but commercial use requires a separate license. Replaces UNLICENSED. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): adopt @lunora/bindings/analytics after package fold-in The latest alpha folded @lunora/analytics into @lunora/bindings (subpath export ./analytics, identical API) and codegen now emits _generated/functions.ts importing @lunora/values directly. Swap the dependency and import specifiers, declare @lunora/values, regenerate _generated/*, and reconcile the lockfile. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * fix(cloud): address pr review findings - secrets: assert the project belongs to the org in store/list/ listEncrypted and scope queries by organizationId, closing the cross-org IDOR where a member of one org could read or overwrite another org's project secrets (+ idor tests) - deploy: require a base64 worker bundle in POST /v1/deploy and thread it client → CLI → provisioner instead of uploading an empty module; 400 on missing/malformed bundle - studio: replace try/finally + throw-in-try with promise combinators in Login/Invitations/Secrets forms so React Compiler can memoize them https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs(cloud): add consolidated gap analysis and build plan https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): blue/green releases with health gating and rollback Every deployment now uploads an immutable versioned script ({alias}-v{n}); the project's stable URL follows an active-deployment pointer that only swaps after the new script passes a health probe, so a bad deploy never replaces a serving one (gaps.md a1). Adds POST /v1/deployments/rollback + lunora rollback (pointer swap back to a retained superseded release), GET /v1/tenants/route + a cached alias resolver in the dispatcher, per-phase deployment timestamps (a2), and an x-lunora-id debug header (b3). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): aggregate spend caps with org suspension Per-invocation limits cap one request; nothing capped aggregate period spend (gaps.md c1). Adds a pure spend evaluator at the wfp cost basis with per-plan default caps (org-overridable; explicit 0 = uncapped), an hourly enforcement cron that suspends breaching orgs and self-heals recovered ones, and dispatcher enforcement — a suspended org's tenants serve 503 via the sentinel plan carried through the existing cache. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): custom domains — model, txt verification, hostname routing First slice of gaps.md b1: the domains table (unique hostname, per-org project scoping, redirect-only rows, cloudflare custom-hostname id), add/list/remove/markVerified functions with the same authz gates as secrets, a pure dns-over-https verification core (_lunora txt token + platform cname check, injectable resolver), and routeForHostname — the dispatcher-facing lookup that only ever routes verified domains to the project's active script. Edge routes + dispatcher wiring land next. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): wire custom domains through edge and dispatcher Completes the code-tractable half of gaps.md b1: POST /v1/domains (add, returns the txt record to create), POST /v1/domains/verify (dns-over- https txt + cname checks under the caller's session, outcome recorded via markVerified), GET /v1/tenants/custom-domain for the dispatcher, and a cached custom-domain resolver in the dispatcher that routes verified hostnames to the project's active script and answers redirect-only rows directly. cloudflare-for-saas cert provisioning remains the 🌐 half. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * style(cloud): hoist the trailing-dot regex to module scope https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): server-side builds, build logs, and push-to-deploy webhook gaps.md a3/a4: builds table with a stale-recoverable work lease and commit-sha dedup (a repeat push reuses the successful build's bundle hash instead of rebuilding), streamed line-per-row build logs with a cursor-paginated tail query, github app installations linked by account slug, push + installation webhook parsing (default-branch pushes only, zero-sha deletes ignored) wired through the hmac-verified edge route, and a pure build-runner orchestration (claim → fetch → execute → complete/fail) whose tarball-fetch and container-execute ports are the remaining 🌐 half. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): tenant log ingestion and org right-to-erasure gaps.md b2 + d3. logs: a tenantLogs ledger fed by the tail worker via deploy-key-gated POST /v1/logs/ingest (batch + line-length caps, lines truncated rather than dropped), a cursor-paginated member tail query, and a 6-hourly retention prune (48h window). erasure: owners request org deletion (30-day reversible window); the purge cron then erases every org-scoped control-plane row, marks deployments destroyed for the provisioner teardown path, and removes the org. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs(cloud): mark shipped gaps in the build plan https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): dunning state machine and residency-aware cell placement gaps.md c2 + f. dunning: a pure evaluator (payment failure → 14-day grace anchored at first failure → suspend; any active/trialing subscription rescues) driven by a 6-hourly cron over the synced subscription states. suspensions now carry a reason so the spend-cap and dunning crons only lift their own. placement: organizations.create accepts a jurisdiction ("eu"/"fedramp") and picks a matching active cell when no explicit cellId is given. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): lunora eject core — the no-lock-in exit hatch gaps.md d2: a pure eject flow that pulls the tenant's full data snapshot through its admin export api, scaffolds the byo wrangler.jsonc the project would have had outside the platform (do bindings, d1 placeholder, sqlite migrations), and writes a restore readme — all over injected ports so the packaging is fully unit-tested; the cli wires the real i/o. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * style(cloud): use a template literal in the eject scaffold https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): studio tabs for domains, builds, and runtime logs wires the round-7 backends into the hosted studio: a domains tab (add → txt record callout → verify → live verified badge, remove), a builds tab (per-project build list with live streamed output), and a logs tab (deployment picker over a live runtime-log tail). marks c2/d2/f shipped in the gap plan. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): fat-vs-thin runtime spike + fleet re-release pipeline gaps.md e4 (the plan's ⭐ decide-now item). spike package (spikes/runtime-versioning): the analysis — user functions execute inside ShardDO and workerd has no dynamic code loading, so true-thin is a distributed-transaction redesign, not a packaging change — plus live probes for the three deciding hypotheses (cross-script DO bindings under wfp, callback per-hop cost vs a 1ms viability line, fat-path patch throughput arithmetic). provisional call: fat + pinned runtime + automated forced re-release. that pipeline ships here too: deployments record their runtimeVersion, and src/fleet/upgrade.ts plans canary-first batches and halts on a dirty canary or breached failure rate — a runtime patch becomes a paced batch job over the existing build + health-gated release machinery. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): ring-2 pass — harden, close seams, finish product edges security: github installations move to a staged-claim model (webhook stages, owner/admin claims; recordPush only accepts claimed installations and caps in-flight builds), domains.add enforces the customDomains entitlement, and audit coverage lands for domains, rollback, deletion requests, installation claims, and both suspension crons. seams: build → deploy handoff via the runner's release port (failed release keeps the artifact), stale-build self-healing cron, superseded-release pruning (retain 3/project), and server-built pr previews through the same pipeline. product: per-environment secrets (all/production/preview/dev with kind-over-shared resolution + studio picker), rollback button, suspension/deletion banners, org rename, member role change (last-owner protected), project rename. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): switch billing to creem as merchant of record Resolves gaps.md c3: creem (via @lunora/payment/creem) replaces the stripe adapter as the platform's payment provider. As a merchant of record it is the legal seller and calculates/collects/remits sales tax/vat across 190+ jurisdictions, so the platform never inherits worldwide tax compliance. Swaps the adapter wiring in the shard config (CREEM_API_KEY / CREEM_WEBHOOK_SECRET / CREEM_TEST_MODE for the sandbox), the webhook route + action to the creem-signature header, the studio copy to creem product ids and hosted portal, and the docs. Entitlements, dunning, plans, and quota enforcement are unchanged — they ride the provider-agnostic subscriptions store. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): prepaid-credits overage billing core for creem Verified against creem sdk 1.5.3: products are recurring/onetime only — no metered subscription pricing — but creem ships a first-party credits ledger (per-customer accounts, idempotent credit/debit by reference) built for api metering. Overage is therefore prepaid: orgs buy credit packs (one-time mor sales, tax handled by creem) and the platform debits usage beyond the plan's included quota. Ships the pure core (included quotas per plan, cost-plus overage rates, watermark-delta debits with crash-safe idempotent references, exhausted → the existing c1 suspension path, never negative), the overageDebits watermark table with forward-only advancement, and 10 tests. The live credits api wiring (CreditsLedgerPort) is the remaining 🌐 piece. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): creem credits-ledger adapter and fleet overage reconciliation Completes the api/token-metering implementation over creem's customerCredits api: a structural ledger adapter (balance reads via bigint-safe strings, debits with the idempotent watermark reference, missing account → null and never debitable), applyCreditPurchase for the billing webhook (first purchase creates the account seeded with the pack; later ones credit with the payment id as reference), the organizations.creditsAccountId linkage (never overwritten once set), and reconcileAllOverages — the fleet driver with per-org failure isolation, watermark-advance strictly after a successful debit, and exhausted balances handed to the c1 suspension hook. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): studio ux pass — usage meters, daily chart, command palette Ring 3, patterns from the maple teardown (fsl-licensed observability platform — ideas only, all code our own). usage tab: included-vs-used plan-quota meters (amber at 80%, red past allowance, honest prepaid- credits overage label) + a per-day request-volume chart over the new usage.series query, rendered with a zero-dependency svg bar chart. adds a ⌘k command palette (tab navigation + actions, substring match, arrow/enter/escape keyboard flow, state reset by remount) wired into the org dashboard. gaps.md gains the ranked ring-3 backlog (alerting pillar, health charts, log-viewer upgrade, design tokens, onboarding checklist, mcp surface, integrations hub). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * fix(cloud): keep cron triggers within cloudflare cap The control-plane Worker declared 4 distinct cron expressions (0 */1, 0 */6, 0 */12, */1) — one over Cloudflare's hard limit of 3 Cron Triggers per Worker, which would reject the deploy. The lone 0 */12 trigger existed solely for "purge deleted organizations". Fold that job into the existing 6h bucket: the purge gates on each org's own 30-day retention cutoff, so a tighter cadence only shortens erasure latency — it never erases early. Codegen drops the 0 */12 trigger, leaving exactly 3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01423xZDDqzhQ5D79vy25huF * feat(cloud): observability ingest pipeline — issues + incidents (Phase 3) (#140) * feat(cloud): add the observability ingest pipeline (issues + incidents) Phase 3 of the observability plan — durable, cross-deployment monitoring in the Lunora Cloud control plane, fed by the Phase 2 OTLP transport. - ingest: `POST /v1/telemetry` accepts OTLP-over-HTTP/JSON from the tenant `otlpSink` and the container exporter, decodes the error spans (`src/telemetry/otlp.ts`), and folds them into grouped issues/incidents through a deploy-key-authorized `telemetry.ingest` mutation. Synchronous — the cloud app has no queue producer binding, so ingest inserts to D1 directly (like `usage.ingest`); auth reuses `authorizeDeployKey`, not the plaintext admin token. - store: `issues` + `incidents` `.global()` D1 tables, fingerprinted with `@lunora/fingerprint` (the same hash the local Studio computes, so a local Issue and a cloud Issue are one object); `lunora/{issues,incidents}.ts` member-authorized read/triage functions. A `TelemetryStore` adapter (`src/telemetry/store.ts`) owns the non-relational side — AE metrics plus a guarded Pipeline→R2 archive, each a no-op without its binding. - dashboard: hosted `IssuesSection` / `IncidentsSection`, gated behind the `logStreams` entitlement, wired into `OrganizationDashboard`. - bindings: a `TELEMETRY` AE dataset + `TELEMETRY_BUCKET` R2 bucket. Vendors `@lunora/fingerprint` (Phase 1, not yet merged) so this stacks on the cloud branch; the graft folds away once Phase 1 lands on alpha. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm * feat(cloud): observability alerts — rules + delivery (Phase 4) (#141) * feat(cloud): add observability alerts — rules, firing + delivery Phase 4 of the observability plan (the "watches while you sleep" tier), stacked on the Phase 3 ingest. - schema: `alertRules` (name, target issue/incident, threshold, channel email/webhook, destination, enabled) + `alerts` (fired-alert audit trail with firing→delivered state, notification denormalized). - firing: the telemetry `ingest` mutation loads the org's enabled rules and fires each the first time a source's count crosses its threshold (`before < threshold <= after`, so exactly once), inserting a `firing` alert row. The pure crossing/render logic lives in `src/telemetry/alerts.ts` (unit-tested), mirroring how `usage.ingest` delegates to `evaluateSpendCap`. - delivery: the `/v1/telemetry` edge handler delivers fired alerts best-effort (email via `@lunora/mail`, webhook via JSON POST) then stamps them delivered — never blocking or failing ingest. - functions: `alerts.{rules,createRule,setRuleEnabled,deleteRule,list, markDelivered}` (member-authed reads/writes; deploy-key-authed markDelivered). - dashboard: `AlertsSection` (manage rules + recent fired alerts), gated behind the `logStreams` entitlement, wired into `OrganizationDashboard`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm * fix(cloud): validate webhook alert destinations against SSRF An alert rule's webhook `destination` is `fetch`ed by the control plane when the alert fires, so an owner/admin could otherwise aim it at internal infrastructure (loopback, RFC-1918, the 169.254.169.254 metadata IP, …) — server-side request forgery. Add a pure `isSafeWebhookUrl` guard (https only, public host, no embedded credentials, no loopback/private/link-local IPv4 or IPv6) enforced both at `createRule` (reject the rule) and in `deliverAlert` (never fetch an unsafe target — defense in depth for any rule created before this guard). String-level, so it can't defeat DNS rebinding, but it blocks the direct-address cases. Unit-tested. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud): harden webhook SSRF guard Two SSRF gaps in the Observability alert delivery path: - deliverAlert followed webhook redirects, so a destination that passes isSafeWebhookUrl could 3xx-redirect to an internal address (e.g. the metadata IP). Set redirect: "manual" and reject 3xx responses. - isSafeWebhookUrl let IPv4-mapped IPv6 (::ffff:169.254.169.254, which the URL parser compresses to ::ffff:7f00:1) and the unspecified address (::) through. Reject the whole ::-prefixed non-global class. Numeric IPv4 forms (2130706433, 0x7f000001, 0177.0.0.1) were already blocked via WHATWG URL normalization; added as regression tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfLmCwH5xMfz7L73LRPFj --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(cloud): AI incident triage (@lunora/ai) — Phase 4C (#142) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(runtime): use LunoraError not undefined CirrusError in cloud endpoints The scheduled-tick and queue-dispatch admin endpoints threw `new CirrusError(...)`, a class that exists nowhere in the repo, so the file failed to type-check (TS2304). The intended class is `LunoraError`, already imported and used throughout the file. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cloud): full tenant log management with structured fields + trace correlation Consume the structured, trace-correlated logs the framework now emits (shared/log-event.ts) — the cloud log path kept only 3 severities + a flat line and had no producer. Closes the framework side of Maple gap #2. - Producer (GAPS.md B2, the missing piece): src/tail/worker.ts — the dispatch-namespace tail worker decodes each tenant `{source:"lunora", type:"log"}` console event (src/tail/parse.ts, pure + unit-tested), groups them per script, and POSTs batches to POST /v1/logs/tail. Holds one platform secret (LUNORA_TAIL_SECRET), not per-org deploy keys; the route resolves scriptName → org (logs.orgForScript) and stores via logs.ingestInternal. Deployed from tail.wrangler.jsonc. - Store: tenantLogs widened to the full LogEvent shape — 7-tier severity, message, structured fields, functionPath, traceId/spanId, userId, shardKey — plus (scriptName, createdAt) and (org, traceId) indexes. - Query: logs.list gained server-side levels/functionPath/traceId/search filters + a cursor and bounded limit, newest-first. - UI: the studio Logs tab renders severity chips (filter), search, structured fields, and a short trace id per line. Still 🌐: the provisioner setting tail_consumers on tenant scripts, an e2e run, and correlating error/fatal lines to OTLP Issues by traceId (follow-up). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud): provision tenant bindings so deployed workers boot The deploy handler built the provisioner spec with an empty binding set (`bindings: {}`), so every uploaded tenant Worker was created with no Durable Object binding and no `new_sqlite_classes` migration tag. A real Lunora app always exports ShardDO, so it could never boot — the deploy pipeline could only ship a binding-less worker. The deploy request now carries the app's binding manifest (DO classes, optional per-tenant D1/R2) which the CLI reads from `wrangler.jsonc`, and the handler normalizes it to a spec that always includes the ShardDO floor even when a caller under-declares or omits it. Malformed entries are dropped and the DO list is capped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): tear down Cloudflare scripts for destroyed deployments The lifecycle crons (cleanupExpiredPreviews, pruneSuperseded, organizations.purgeDeleted) only transitioned a deployment to `destroyed` — nothing ever deleted the Cloudflare dispatch script, so dispatch namespaces grew unboundedly (the leak GAPS.md Ring-2 flagged as closed). Add a `teardownAt` marker and a pure, port-injected `runTeardownSweep` (per-target failure isolation, crash-safe idempotent off the marker), wired into the control-plane Worker's scheduled() handler on the hourly/6-hourly buckets — right after the crons that mark rows destroyed. No-ops without Cloudflare credentials. Per-tenant D1/R2 teardown-by-id still needs resource-id persistence and is left as a follow-up; script deletion is the load-bearing fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): fold Analytics-Engine usage into the metering ledger The dispatcher wrote one AE data point per tenant request, but nothing ever read them back — createHttpAnalyticsReader had no caller, so `platformUsage` only held what tenants self-report over POST /v1/usage (nothing, in practice). Spend caps, the usage summary, and the usage chart therefore evaluated an empty ledger. Add a per-cell `usageReadAtMs` checkpoint and a pure, port-injected `runUsageRollback` that delta-reads AE (`timestamp > checkpoint`), attributes each dispatch script to its org/deployment, and appends `requests` rows — then advances the checkpoint so re-runs never double count. A per-row ledger failure is dropped rather than retried (under- count, never double-bill — the same fail-safe as usage.rollup). Wired into scheduled() on the hourly/6-hourly buckets; no-ops without Cloudflare credentials. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): build-queue dispatcher (claim → run → drain) `builds.claimNext` had no caller: enqueued builds sat untouched until the 24h expiry cron failed them with "no build runner picked this up". Add the missing claim→run loop as a pure, port-injected `runBuildDispatch` (bounded per-tick drain; a failed build never aborts the drain), fully unit-tested against the runner ports. Production activation stays gated on the runner's 🌐 seams — `execute` (a throwaway Cloudflare Container running `lunora build`) and `fetchSource` (GitHub App tarball) — which need live container infra, so the dispatcher is not yet wired into scheduled(): claiming builds with no executor would only burn them. This lands the verified logic so the remaining work is purely the container seam, not the orchestration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * docs(cloud): split GAPS legend into wired vs pure-module (🧩) The single ✅ conflated "tested pure function exists" with "feature runs". Add a 🧩 marker for tested-but-uncalled modules, a dated wiring-pass section covering the four gaps just addressed, and correct the two most misleading inline entries: - A3 builds: the claim dispatcher now exists (was missing); only the container execute() seam remains 🌐. - C3 overage credits: reconcileAllOverages / applyCreditPurchase have no production caller (verified) — scheduling + webhook mapping are code (🔨), not credentials (🌐), so the honest status is 🧩, not "✅ core". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): boot-time route classification scanner Port of Openship's route-scanner idea (Apache-2.0) to the /v1 router. The control-plane routes each did inline auth then delegated to a self- authorizing function, but nothing forced a *new* route to be classified — an unclassified endpoint could ship silently and read as protected. Every route now carries an explicit RouteSpec.auth (deployKey / session / webhookHmac / tailSecret / adminToken / public), and assertRoutesClassified runs when createDeployRouter builds the table: a missing/unknown classification, a public route with no reason, or a duplicate (method, path) throws at construction — the Worker fails to start rather than serving an unclassified route. The flat dispatch tables are derived from the one checked list (GET + POST unified). The spec's opt-in `mcp` field is the allowlist the MCP surface will read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): MCP surface generated from the route registry Port of Openship's "MCP tools derived from the route registry" idea (Apache-2.0). A `/v1/mcp` JSON-RPC endpoint (tools/list + tools/call) exposes only routes that opt in via RouteSpec.mcp, and every tool call dispatches back through the real router carrying the agent's own bearer credential — so it runs the identical auth + rate-limit + handler + function-authz path as any HTTP caller; the MCP layer grants no privilege. A hard deny-list (buildMcpTools) guarantees token/secret/tenant-access routes (/v1/secrets, /v1/admin, /v1/invitations/send, /v1/logs/tail) and the surface itself (/v1/mcp) can never become tools even if mis-annotated — the same scope-escape guard Openship applies to tokens/auth/mcp. Only bearer-callable (deployKey/adminToken) opted-in routes are eligible; session/webhook routes are excluded. deployments.rollback is the first tool exposed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * chore(codegen): regenerate _generated for teardownAt + usageReadAtMs Keeps the emitted dataModel/shard/drizzle types consistent with the new deployments.teardownAt and cells.usageReadAtMs schema columns. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): tear down tenant D1 + R2, and test the sweep glue Extends resource teardown past the dispatch script (#2). The lifecycle sweep now also deletes the per-tenant D1 database and R2 bucket, resolved by the same naming convention the provisioner creates them under (shared tenantD1Name / tenantR2Bucket helpers — no drift, no new persistence). New CF API methods: findD1DatabaseByName + deleteD1Database (uuid) and deleteR2Bucket (name). Script + D1 delete are retryable; R2 is best-effort (a non-empty bucket needs an S3-API object purge the teardown context lacks — logged, left for follow-up). D1 (every .global() app has one) and empty R2 buckets are now fully reclaimed. Also extracts the scheduled() sweep glue into testable port-builders (#4): teardownPorts + usageRollbackPorts over a structural ControlPlaneDb, so the row→target mapping, the teardownAt stamp, the ledger insert, and the per-cell checkpoint are unit-tested against a fake store instead of living untested inside server.ts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): carry cronSpecs + bindings from wrangler on deploy The cron fan-out read live deployments' cronSpecs, but nothing ever populated them: deployments.create accepted the field yet the deploy handler/router never passed it, so readCronTargets always returned [] and the entire §2.4 tenant-cron fan-out had no data source (#3). Add parseWranglerManifest — a pure reader that extracts the binding manifest (DO classes / D1 / R2) and cron expressions from a tenant's wrangler.jsonc — and thread cronSpecs through the deploy request → handler → create mutation. The deploy client + CLI now forward both bindings and cronSpecs, so a real deploy provisions what the Worker needs and registers the crons the fan-out drives. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): synthetic uptime monitoring with alerting Adds external-vantage uptime to the control plane's Observability tier — the piece a deployment can't self-report (if it's down, it can't say so). - Probe: pure probeDeployment (generalizes the deploy-time healthCheck — GET, sub-500 = up, latency + timeout, never throws), a consecutive-failure state machine, and a summarizer, all unit-tested (src/uptime/probe.ts). - Sweep: runUptimeSweep over injected ControlPlaneDb ports (mirroring the teardown/usage sweeps) probes every live deployment, records a uptimeChecks row, advances uptimeState, and fires an "uptime" alert the first time a deployment's failures cross a rule threshold — reusing crossesThreshold, renderAlert, and the alerts table/delivery pipeline (src/uptime/sweep.ts). - Edge: server.ts scheduled() runs the sweep on the every-minute tick and delivers fired alerts over their channel (webhook/email), stamping the outcome. - Alerts gain an "uptime" target (schema + createRule + renderAlert), so users configure "page me when my deployment is down" alongside issue/incident rules. - Read side: lunora/uptime.ts (summary + recent queries, retention prune cron) backs a new Uptime dashboard section. Cron triggers stay at 3 expressions (prune rides the 6h bucket, the probe rides the existing every-minute tick). Full suite: 272 tests, lint:types clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019M6G6CAoLVrQMxDYg7BWq2 * fix(cloud): address thermo review of uptime monitoring Security/correctness (branch audit): - SSRF: the sweep fetched a …
…rts, sessions, dashboards (#179) * feat(studio): browse the durable log archive in the Logs panel (#155) * feat(studio): browse the durable log archive in the Logs panel Add a third "Archive" feed to the studio Logs panel that reads the durable ctx.log archive pipelineLogSink writes to R2 (Iceberg / R2 Data Catalog). - @lunora/runtime: a new admin-gated `/_lunora/admin/logs/archive` route (`log-archive-admin-routes.ts`) runs `createPipelineLogReader` server-side — the R2 SQL token stays on the worker, only decoded `{ rows, nextCursor }` reaches the browser. Reads creds from env (`R2_SQL_*`, `CLOUDFLARE_ACCOUNT_ID` fallback) + the table from a new `logArchive` WorkerOption. Fails closed with `LOG_ARCHIVE_NOT_CONFIGURED` when unwired. - @lunora/client: `queryLogArchive(query)` method + re-exported PipelineLog* wire types (owned by @lunora/runtime). - @lunora/studio: a self-contained `ArchiveFeed` (function/user/min-level filters, keyset "Load more", a "not configured" empty state distinct from an error) rendered under the new Archive tab; `errorCode` helper. - Docs + API snapshots updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * refactor(studio): drop try/finally in ArchiveFeed for React Compiler The React Compiler bails on a `try` with a `finally` clause (React Doctor `react-hooks-js/todo`), so the component missed automatic memoization. Reset `loading` in each branch instead, matching the repo's no-`finally` pattern. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * refactor(studio): address thermos review of the archive feed Code-quality + correctness follow-ups from the two-pass review: - Extract the duplicated LEVEL_VARIANT map into `log-level-variant.ts`, shared by the live Logs feeds and the Archive feed (restores the exhaustive `Record<LogLevel, BadgeVariant>` type — drops the `?? "outline"` fallback). - Collapse the four `view !== "archive"` readout guards in logs-panel into one `view === "archive" ? <ArchiveFeed/> : <>…</>` branch. - Drop the `JSON.parse(JSON.stringify(baseQuery))` round-trip in the fetch effect — pass `baseQuery` directly, keying the effect on `querySignature`. - Guard `loadMore` against a cross-filter race: a page-2 fetch that resolves after a filter change is dropped (via `activeSignatureRef`) instead of appending stale rows / overwriting the cursor. - Show a "Loading…" placeholder on the initial fetch instead of a blank panel. - Type `minLevel` state as `"" | ContextLogLevel` (removes a cast). - Move the `LOG_ARCHIVE_NOT_CONFIGURED` sentinel to `shared/log-archive.ts` so runtime and studio share one source of truth with no dependency edge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * refactor(studio): address CodeRabbit review of the archive feed - Fold ArchiveFeed's five fetch-related useState into one useReducer, so each fetch transition (loading / loaded / append / notConfigured / failed / pageFailed) is a single dispatched action (React Doctor prefer-useReducer). - Use the imported `ChangeEvent` type instead of the `React.*` namespace, matching logs-panel.tsx. - Gate the toolbar `LiveError` on `view !== "archive"` so the disabled Errors feed's live-connection state can't leak into the (WS-less) Archive tab. The CodeRabbit "cast env to LogArchiveEnvironment" suggestion is intentionally skipped: `env ?? {}` narrows to `{}`, which is assignable to the all-optional LogArchiveEnvironment (lint:types is green), and eslint's no-unnecessary-type-assertion rejects the cast. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * polish(studio): clear prior error on a fresh archive fetch Thermos re-review nice-to-have: the reducer's `loading` action now clears `error`/`notConfigured` (matching the kv reducers' `submitStart`), so retrying after a failure shows the loading placeholder instead of the stale error line. Rows are kept, so paging / filter-change refetches don't blank the table. (Kept `default: return state` to stay consistent with the existing kv reducers rather than introduce a one-off `unreachable` exhaustiveness guard.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * chore(release): @lunora/runtime@1.0.0-alpha.32 [skip ci]\n\n## @lunora/runtime [1.0.0-alpha.32](https://github.com/anolilab/lunora/compare/%40lunora%2Fruntime%401.0.0-alpha.31...%40lunora%2Fruntime%401.0.0-alpha.32) (2026-07-21) * chore(release): @lunora/client@1.0.0-alpha.26 [skip ci]\n\n## @lunora/client [1.0.0-alpha.26](https://github.com/anolilab/lunora/compare/%40lunora%2Fclient%401.0.0-alpha.25...%40lunora%2Fclient%401.0.0-alpha.26) (2026-07-21) * **@lunora/runtime:** upgraded to 1.0.0-alpha.32 * docs: add cirrus cloud platform plan Reverses the managed-deploy-plane won't-do (VOID-TEARDOWN.md §0/§6, CONVEX-PARITY.md #23) with a scoped managed tier: Workers for Platforms data plane, Convex-shaped product model (teams/projects/prod+dev+preview deployments), PartyKit-style managed-vs-BYO CLI split, and a phased roadmap starting with remote-binding dev. Synthesized from a repo inventory, a Convex Cloud teardown, and a GitHub/Cloudflare-primitives survey. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: fold supabase platform teardown into cloud plan Adds Supabase as a reference model: the OSS/proprietary cut line, IS_PLATFORM single-codebase studio pattern, Branching 2.0 preview DX (and its pain points to fix: empty branches, hourly branch billing outside spend caps), the Management API + OAuth-apps growth channel, and the structural cost advantage WfP gives over per-project VMs. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: add wfp constraints, eject path, spike checklist Gap review of the cloud plan: documents that cron triggers are silently dropped for namespaced user Workers (with SchedulerDO alarm-based fan-out mitigation), queue-consumer and send_email verification items, KV account-limit multiplexing, EU jurisdiction toggle, a cirrus-eject portability command built on existing export/import RPCs, namespace-wide observability reuse, managed backups + abuse controls in Phase 4, and a Phase 1 constraint spike. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: verify cloud plan claims against cloudflare docs Fact-checked every Cloudflare claim in CLOUD-PLAN.md against the official docs: WfP pricing, KV/D1/R2 account limits, DO/R2 jurisdictions vs D1 location hints, CF for SaaS hostname pricing, and remote-bindings GA versions all confirmed. Adds three newly verified constraints: no gradual deployments for user Workers (rollback must be platform-side bundle re-upload), the 1200-req/5-min account API rate limit on provisioning, and outbound-Worker TCP/DO interception trade-offs. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: add cell-based scaling architecture to cloud plan Answers how the managed tier scales without hitting account limits or risking platform-wide blocks: script-resident tenant state with lazy inference-driven provisioning, multi-account cells with cell IDs baked into identifiers from day one, a per-cell API token-bucket scheduler, a tenancy graduation ladder up to managed-BYO and the Tenant API, and abuse containment to keep tenant abuse from looking like platform abuse. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: scope alchemy to cell bring-up, not tenant deploys Records the provisioning-engine decision: the per-tenant deploy path stays hand-rolled on cloudflare-typescript (control-plane DB as the single source of truth, cell scheduler, progress events, rollback artifacts); Alchemy (pre-1.0, v2 rewrite underway, no confirmed dispatch-namespace resource) is a candidate only for low-cardinality cell bring-up IaC, with Terraform/Pulumi as the fallback. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: correct alchemy facts (dispatch-namespace resource, v0.93) Re-checked Alchemy against GitHub/npm: it is v0.93.12 (Apache-2.0) and does ship a dispatch-namespace (Workers for Platforms) resource — my earlier 'lacks a confirmed dispatch-namespace resource' was wrong and 'v0.9x' undersold it. Recommendation is unchanged (hand-roll the per-tenant deploy, use Alchemy for cell bring-up) but now rests on the real reason — source-of-truth shape and deploy-orchestration concerns, not capability — with a re-evaluation trigger at a stable 1.x. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: adopt alchemy as the provisioning engine Decision: Alchemy is the provisioning engine across cell bring-up, per-tenant managed deploy, and BYO. Verified it ships DispatchNamespace/ Worker/D1/R2/DO resources plus a built-in D1StateStore and runs inside a Worker (await alchemy(scope) -> finalize/destroy). Backing each tenant scope with the control-plane D1 collapses the two-sources-of-truth concern into one store. The per-cell rate-limit scheduler now paces finalize() runs; bundling stays in the Vite pipeline; rollback re- converges to a prior R2-retained bundle. Risk of a 0.x dependency contained behind a @cirrus/provision adapter with cloudflare-typescript as fallback; spike open items recorded. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: build on alchemy v2 (alchemy@next, 2.0.0-beta.55) Per decision to start on the v2 line: target alchemy@next (verified 2.0.0-beta.55, Effect-based) to avoid a v1->v2 migration mid-build, with v1 0.93.x as the named fallback. Records the trade-offs (beta churn, Effect pulled into the control-plane tree, quarantined behind the @cirrus/provision adapter) and turns the unverified v2 facts (DispatchNamespace resource + D1/DO state store, confirmed on v1 only since docs/CDN were unreachable) into hard Phase 1 spike gates with v1 fallback per surface. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: commit fully to alchemy v2, drop v1 fallback Remove the v1 (0.93.x) fallback hedging throughout: v2 (alchemy@next, 2.0.0-beta.55) is the engine outright. Spike gates remain but now resolve via owned shims or upstream contributions rather than retreat to v1; a hard unresolvable 'no' escalates the engine decision instead of silently dual-tracking. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: add forgotten must-haves + fleet runtime-versioning risk Gap pass on the cloud plan. New risk #8 (the load-bearing one): the Cirrus runtime is bundled into each tenant Worker, so a security patch means redeploying the whole fleet unless the tenant Worker is made 'thin' against a central runtime — a fat-vs-thin decision that must be made before Phase 1 since it shapes the bundle format, deploy API, and vite emit. New section 7 collects launch-blocking gaps the plan had assumed away: control-plane DB durability/DR, cross-cell disaster recovery, secrets-at-rest + cell-token custody, frontend-hosting scope, AUP + bill-shock/cryptomining controls, billing/MoR/tax, GDPR processor/DPA/SOC2, account offboarding + right-to-erasure, platform self-observability + status page, dispatcher canary, and a staging cell. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): scaffold control-plane app built on cirrus First implementation step from CLOUD-PLAN.md: a new apps/cloud workspace app that dogfoods Cirrus as the platform's own control-plane backend. - cirrus/schema.ts: control-plane data model (cells, organizations, members, projects, deployments, deployKeys, auditLog), all .global() (D1) — the plan's 'Worker + D1' control plane. - cirrus functions: organizations/projects/deployments/cells/deploy-keys (create/list/issue/updateStatus), with owner seeding + audit trail. - src/server.ts: control-plane Worker entry wiring D1-backed global tables. - src/provision.ts: the @cirrus/provision seam — the sole coupling to the Alchemy v2 engine (stub that rejects until the Phase 1 spike wires it). - configs (package.json/tsconfig/project.json/wrangler.jsonc/eslint/vitest), README, and a provision test. Verified: codegen clean (no advisories), eslint clean, tsc --noEmit passes, vitest green. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): add deploy-orchestration core Builds the next control-plane layer on the scaffold's Provisioner seam, all pure/testable (no live Cloudflare needed): - token-bucket.ts: per-cell API budget (§2.5), models CF's 1200/5min account limit; deterministic + clock-injectable. - scheduler.ts: CellScheduler paces/serializes provisioner work against the bucket with priority ordering + a concurrency cap. - orchestrator.ts: runDeployment state machine emitting queued → provisioning → live/failed progress events (§2.2); destroyDeployment for preview-TTL/project teardown. - keys.ts: deploy-key format/parse/hash helpers; deploy-keys.ts mutation refactored to use them (one tested place for the format + SHA-256). 17 tests across 5 files; eslint + tsc --noEmit clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): add org authorization + deploy-key lifecycle Closes gaps found reviewing the control plane: - authz.ts: assertMember(ctx, orgId, roles?) — the org ACL gate. Every org-scoped function now verifies the caller is a member with a permitted role, closing an IDOR hole where any signed-in user could read/mutate any org by passing its id. Applied across projects, deployments, deploy-keys. - members.ts: list / add / remove so memberships can actually be granted (owner is seeded on org create; admins/owners manage the rest). - deploy-keys: verify (the deploy API's auth path — match by SHA-256, reject revoked, bump lastUsedAt, return the DB-authoritative target) and revoke (leaked-key mitigation); lastUsedAt/revokedAt are now live. - deployments: create checks the project belongs to the org; updateStatus loads the deployment and gates on its org (documented as the system seam for the orchestrator). codegen clean, eslint + tsc --noEmit clean, 17 tests pass. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): deploy API endpoint, deploy-key auth, handler tests Lands the three remaining pieces together: - Deploy API: POST /v1/deploy mounted via the httpRouter seam (src/deploy/ router.ts) → pure handler (src/deploy/handler.ts) authenticates the bearer deploy key, records a queued deployment, drives runDeployment through the per-cell scheduler, and streams NDJSON progress (accepted→queued→ provisioning→live/failed→done), patching status per phase. - Auth path: investigation showed internalMutation is unreachable from the HTTP action-context dispatch (no system flag → RPC 404), so verify/ updateStatus stay public; instead added deploy-key authorization (authz.authorizeDeployKey) and a dual-path (member session OR deploy key) on deployments.create/updateStatus, so CI deploys need no user session. Corrected the stale 'should become internalMutation' comments. - Tests: handleDeployRequest (401/403/400 + success and failure streaming + status transitions) and authz (assertMember + authorizeDeployKey) via a fake ctx. 28 tests / 7 files; eslint + tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs(cloud): refresh status — deploy API + auth now in place * chore(cloud): track generated schema snapshot Matches the apps/playground convention — .cirrus-schema.json is the codegen schema snapshot used for migration/drift detection and is committed, not gitignored. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): preview lifecycle, GitHub webhook, deploy client (Phase 1/2) Phase 2: - Preview deployments are TTL'd: deployments.create stamps expiresAt for kind=preview (src/deploy/preview.ts: deterministic previewScriptName + 5-day previewExpiry); an hourly cron (cirrus/crons.ts -> internal deployments.cleanupExpiredPreviews) marks expired previews destroyed. Worker gains scheduled(); wrangler cron trigger added. - GitHub webhook (src/github/webhook.ts): HMAC-SHA256 verify + pull_request -> preview-intent parsing, mounted at POST /v1/github/webhook. Phase 1: - Deploy client (src/deploy/client.ts): the cirrus-deploy core — POSTs to /v1/deploy and consumes the NDJSON progress stream. 13 new tests (41 total); codegen/eslint/tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: update roadmap — Phase 0 shipped, Phases 1-2 status Phase 0 (remote-binding dev) is already implemented in the framework (@cirrus/config remote-bindings + @cirrus/vite plugin + cirrus dev; 30 tests) — corrected from 'not started'. Phases 1-2 marked substantially-built with the live-Cloudflare-dependent remainder (Alchemy provisioner, dispatcher, e2e validation) called out. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): team invitations + billing quotas; repo-extraction guide (Phase 3/4) Phase 3 (hosted studio sliver): - Team invitations (cirrus/invitations.ts + invitations table): invite/list/ revoke/accept, single-use SHA-256-hashed tokens (plaintext mailed once), owner-admin gated; accept-by-token adds the caller as a member. Phase 4 (billing sliver): - Plans + quota entitlements (src/billing/plans.ts) on @cirrus/payment's entitlements model — free/pro/enterprise limits + feature flags, with effectiveLimit/withinQuota and a free-tier fallback for non-subscribers. Portability (move to a private repo): - EXTRACT.md documents the mechanical extraction; audit confirms the app imports only published @cirrus/* entry points (no monorepo-internal reaches). 6 new tests (47 total); codegen/eslint/tsc clean; secret-scan clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): real Cloudflare provisioner, quota enforcement, webhook project resolution Provisioner (Phase 1 — the big one): replaces the rejecting stub with a real implementation over a typed Cloudflare REST port (src/cloudflare/api.ts): deploy provisions per-tenant D1/R2, uploads the user Worker into the dispatch namespace with binding + DO-migration metadata, applies secrets, returns the bundle hash + routed URL; destroy deletes the script. Port-injected so it's tested with a fake; plug in CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN to run. (REST via fetch rather than the unverifiable alchemy@next beta — same seam.) Quota (Phase 4): plans.ts gains planLimit/withinPlanQuota; projects.create and members.add enforce the org plan's limits. Preview automation (Phase 2): projects gain githubRepo + byGithubRepo lookup; the webhook resolves the connected project and returns the preview script name. Env documented (.dev.vars.example + wrangler vars). 50 tests; eslint/tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): dispatcher Worker + hosted-studio admin-RPC proxy (Phase 1/3) Phase 1 — dispatcher Worker (the request-path front door): resolveTenant maps {scriptName}.{appDomain} (and custom domains via injected lookup) to a dispatch-namespace script; the worker forwards via env.DISPATCHER.get with per-plan limits. Separate deployable (dispatcher.wrangler.jsonc). Phase 3 — admin-RPC proxy: proxyAdminRequest authorizes org membership, forwards the admin RPC to the tenant's /_cirrus/admin/* with that deployment's admin token, and records an audit entry. Pure (deps injected). 7 new tests (57 total); eslint/tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): cirrus login/link/deploy CLI commands (Phase 1) Pure command logic over a ConfigStore + the deploy client: login persists the API endpoint + deploy key, link binds a project, deploy streams a managed deploy (requires login+link). File-backed store at ~/.cirrus/cloud.json for the Node CLI; cerebro registration in @cirrus/cli calls these. 3 new tests (60 total); eslint/tsc clean; secret-scan clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: roadmap status — control-plane backend feature-complete as code All phases' backend code is built + unit-tested in apps/cloud (60 tests): real REST provisioner, dispatcher, CLI, preview lifecycle, GitHub webhook, team invitations, admin-RPC proxy, quota enforcement. Remaining items are the ones needing live Cloudflare / external services / the studio UI. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): admin-proxy live wiring, usage metering, custom-hostname port (Phase 3/4) Phase 3 — admin proxy mounted at POST /v1/admin: deployments now carry the platform-minted tenant adminToken (set as the worker's CIRRUS_ADMIN_TOKEN secret + stored on the row), deployments.adminTarget resolves {url, adminToken} after asserting membership, and the router forwards to the tenant's /_cirrus/admin/* with an audit-log.record entry. Phase 4 — usage metering: usageEvents table + internal record mutation + member summary query over a pure aggregateUsage roll-up. Custom hostnames: CloudflareApi.createCustomHostname (Cloudflare for SaaS, zone-scoped REST). Router refactored into per-route handlers. 64 tests; eslint/tsc/secret-scan clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: roadmap — admin proxy mounted, usage metering + custom-hostname port added * feat(cloud): add hosted studio react spa Build the hosted-studio frontend for the Cirrus Cloud control plane: a better-auth-gated React SPA served on one origin with the control-plane Worker via @cirrus/vite. - src/client: main/auth-client/Login, App auth gate, OrganizationList, OrganizationDashboard with tabs for projects, deployments, members, deploy keys, invitations, and usage; AsyncList loading/empty helper. - Wire @cirrus/auth into src/server.ts (createAuth + cirrusD1Adapter, ensureMigrated, handleAuthRequest, authAdmin, resolveIdentity) and add AUTH_SECRET/AUTH_URL env + .dev.vars.example entries. - Switch package scripts to vite (build/dev), add react/react-dom + @cirrus/react/@cirrus/auth deps, vite.config.ts, index.html, and the DOM lib + jsx in tsconfig. - eslint: client section (filename-case, react-perf, void), browser globals; ignore vite.config.ts. - Refresh README + CLOUD-PLAN status to reflect the studio UI. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): add billing, metering, and hardened auth Billing on @cirrus/payment (§4): org id is the payment referenceId. Wire a Stripe adapter into createShardDO({ payment }); add cirrus/billing.ts with checkout/portal actions, entitlements/subscription reads (resolved through CIRRUS_CLOUD_PLANS with a free-tier fallback), and a signature-verified processWebhook mounted at POST /v1/billing/webhook. The studio gains a Billing tab. Platform metering (§4): rename the resource-metering table to platformUsage (freeing usageEvents for @cirrus/payment's billing ledger), add a deploy-key authenticated usage.ingest mutation + POST /v1/usage endpoint, and enforce per-plan runtime limits in the dispatcher (limitsForPlan → DISPATCHER.get). Auth hardening (§3) on @cirrus/auth/better-auth: mail-backed email verification + password reset (@cirrus/mail), optional GitHub/Google OAuth, admin/twoFactor/passkey plugins, built-in auth rate limiting, plus a per-IP @cirrus/ratelimit cap on the /v1/* surface. Invitations now email the token via POST /v1/invitations/send (never shown in the browser). The Cirrus organizations/members model stays the single org source of truth (better-auth organization plugin deliberately omitted). Add deps (@cirrus/mail, @cirrus/ratelimit, stripe), tests for the router routes + rate limiting + per-plan limits (69 total), and reconcile the README + CLOUD-PLAN status (the provisioner is a real Cloudflare REST impl, not a stub). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): enforce entitlements, wire metering source, add secrets Close the billing loose ends and add the metering source, tenant secrets, and an audit-log view. Entitlements (close loose end #1): quota is now enforced against live subscription state (cirrus/entitlements.ts resolves from the synced `subscriptions` table) rather than the static organizations.plan column — projects/members creation call assertWithinQuota, so a Stripe upgrade raises limits immediately with no column to sync. Per-plan dispatch limits (close loose end #2): deployments.planForScript + a bearer-gated GET /v1/tenants/plan endpoint + a cached plan resolver in the dispatcher (createPlanResolver) wire resolvePlan, so runtime limits actually scale per plan instead of always falling back to free. Metering source: the dispatcher emits one Analytics Engine data point per tenant request (src/metering/analytics.ts); a reader port + HTTP impl and an hourly usage.rollup compaction cron complete the pipeline alongside the existing /v1/usage ledger ingest. Tenant secrets (§7): AES-256-GCM envelope encryption at the edge (src/secrets/crypto.ts), a secrets table (ciphertext + IV only), store/list/ listEncrypted/remove functions, POST /v1/secrets, deploy-time materialization into the tenant Worker, and a studio Secrets tab. Studio: add Secrets + Activity (audit log) tabs; add audit-log.list. Tests: crypto round-trip, plan resolver caching/fallback, entitlement quota, analytics writer/reader (83 total). Docs + .dev.vars.example updated. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * fix(cloud): address review findings (rollup atomicity, deploy failure, dedup) Apply /review findings on the recent billing/metering/secrets work: Correctness: - usage.rollup: the D1 global backend has no multi-statement transaction, so the old insert-summed-then-delete-originals order could double-count (over- bill) on a mid-rollup crash. Reorder to delete the extras first, then patch the surviving row's total last — a crash can now only under-count, never leave a summed row beside surviving originals. - deploy handler: a tenant-secret decrypt failure (corrupt secret / rotated key) threw inside the NDJSON stream and left the deployment stuck in `accepted`. Catch it and transition to `failed` with a status update. - POST /v1/secrets: encryption/config failures (e.g. a malformed SECRET_ENCRYPTION_KEY) now return 500, not a misleading 403 (kept distinct from the membership 403 the store mutation raises); reject the reserved CIRRUS_ADMIN_TOKEN secret name up front instead of silently clobbering it. - studio: drop the plan picker from org creation — limits now come from live subscription entitlements, so selecting a paid plan at create-time granted nothing. Orgs start free; upgrade via the Billing tab. Cleanup: - Extract the cross-org IDOR guard into authz.assertRowInOrg and call it from secrets/members/deploy-keys/invitations (was four byte-identical copies). - Remove dead plans.ts exports planLimit/withinPlanQuota (superseded by entitlements-based quota); add a single highestPlan/PLAN_PRECEDENCE helper and use it in deployments.planForScript (was a hand-rolled tier ladder). - Memoize the Stripe payment config per isolate (was rebuilt on every shard request that touches ctx.payments). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * test(cloud): validate websockets through dispatch (phase 1 spike) Validate the hottest path — hibernated-WS subscriptions + per-invocation limits through env.DISPATCHER.get() — the least-documented WfP case (risk #3). - spikes/ws-dispatch/: a runnable harness for live validation on a real dispatch namespace. A framework-free hibernatable-WebSocket Durable Object (the exact primitive ShardDO uses: acceptWebSocket + webSocketMessage), deployable into the namespace, plus a zero-dep Node probe that drives it through the dispatcher and asserts: (1) the WS upgrade survives the dispatch hop (101 + live socket), (2) a hibernated server push (broadcast) reaches the socket — the mutation-to-subscription shape, (3) cpuMs-limit behaviour. The README documents deploy/run, pass/fail, and the expected results + caveats. - __tests__/dispatcher-ws.test.ts: unit-pins the dispatcher forwarding contract (returns the tenant 101+webSocket response unchanged, applies per-plan limits, meters the upgrade once) — runs in CI, no infra needed. - dispatcher worker: clarifying comments on WS pass-through + per-frame metering semantics. CLOUD-PLAN risk #3 now references the harness. The dispatcher half is verified here (94 tests); the end-to-end behaviour needs a live Cloudflare account + the Workers-for-Platforms add-on to run. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): tenant cron fan-out through dispatch (wfp workaround) Cloudflare drops triggers.crons for Workers in a dispatch namespace, so tenant cron jobs never fire. Fan them out from the control plane (CLOUD-PLAN §2.4). - @cirrus/runtime: add an admin-gated POST /_cirrus/scheduled tick endpoint that runs a cron expression's jobs through the SAME handleScheduled path the native scheduled() trigger uses (user crons + code crons + backup), so a platform can drive a namespaced tenant's crons over HTTP. (Dispatch stubs expose only fetch()/connect() — no scheduled()/queue() — so HTTP is the only transport in.) - src/fanout/cron.ts: pure 5-field cron-expression matching (lists, ranges, steps, dom/dow OR semantics) + dueTicks + fanOutCron orchestration. - control plane: capture each tenant's cronSpecs on the deployments row at deploy; an every-minute heartbeat cron (cirrus/fanout.ts) makes codegen emit the */1 trigger, and server.ts scheduled() reads live cron targets and ticks each due tenant via env.DISPATCHER.get(script).fetch('/_cirrus/scheduled') with the per-deployment admin token (kept in-process — never exposed). Adds the DISPATCHER binding to the control-plane wrangler. Tests: cron matching, dueTicks, fanOutCron (103 cloud tests; 337 runtime tests still green). Live validation on a dispatch namespace pending; queue consumer fan-out is the remaining half. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): tenant queue-consumer fan-out through dispatch (wfp workaround) WfP namespaced Workers can't be queue consumers, so tenant queue-backed work (@cirrus/mail sends, scheduler queue-workpool) never drains. Fan it out from a platform-owned consumer (CLOUD-PLAN §2.4) — the queue counterpart to the cron fan-out. - @cirrus/runtime: add a `queueHandler` option + an admin-gated POST /_cirrus/queue endpoint that reconstructs the batch and invokes it, returning the message ids to retry. (Dispatch stubs are fetch-only, so HTTP is the only transport into a namespaced tenant.) - src/fanout/queue.ts: pure grouping of a shared-queue batch by the producing tenant's script (envelope `{ script, body }`) + fan-out orchestration that collects per-message retries and retries a whole group on delivery failure. - control plane: the account-level Worker is the consumer — server.ts queue() drains the shared cirrus-tenant-queue, resolves each tenant's admin token in-process (never exposed), forwards sub-batches via env.DISPATCHER.get(script).fetch('/_cirrus/queue'), and acks/retries per the tenant reply. Adds the queues.consumers binding to the control-plane wrangler. Tests: groupByTenant + fanOutQueue (108 cloud tests; 337 runtime tests green). Live validation on a dispatch namespace + a producer-side script-tagging helper remain. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): align with the lunora rebrand + reuse @lunora/analytics Rebased onto alpha, which renamed the framework cirrus → lunora. Reconcile the control-plane app and reuse a newly-shipped package. Rebrand: - npm scope @cirrus/* → @lunora/* across deps + imports. - app functions dir cirrus/ → lunora/ (+ tsconfig/eslint globs, _generated paths, the committed schema snapshot → .lunora-schema.json). - reserved paths /_cirrus/* → /_lunora/* (incl. the new scheduled/queue tick endpoints) and the runtime-injected env.__lunoraCtx; renamed exported symbols (LunoraError, LunoraClient/Provider, useLunora, lunoraD1Adapter, LunoraAuth*, LUNORA_CRONS/FUNCTIONS); vite plugin cirrus() → lunora(); CLI config dir ~/.cirrus → ~/.lunora. - wire the new required GlobalIntrospector.facetColumn via @lunora/d1's facetGlobalColumn. Reuse: - src/metering/analytics.ts is now a thin domain layer over @lunora/analytics (createAnalytics writeDataPoint + createAnalyticsSqlClient AE-SQL reader) instead of a hand-rolled writeDataPoint + HTTP SQL client. Verified the rest is genuinely cloud-specific (cron-expression matching, AES-GCM secret crypto, the Cloudflare REST provisioner, the per-cell CF-API token bucket) — no upstream equivalent to fold into. 103→108 cloud tests green; runtime 379 tests green; tsc/eslint/build clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): rebrand the product Cirrus Cloud → Lunora Cloud Complete the lunora rebrand to the product layer (the framework already moved): - brand prose Cirrus Cloud → Lunora Cloud across code comments, README, EXTRACT, the studio (index.html title, Login/dashboard), and CLOUD-PLAN.md. - env vars CIRRUS_* → LUNORA_*: LUNORA_ADMIN_TOKEN and LUNORA_MAIL_CAPTURE are functional (read by @lunora/mail); LUNORA_APP_DOMAIN / LUNORA_CELL and the VITE_LUNORA_URL client var follow for consistency. - the LUNORA_CLOUD_PLANS entitlements constant. - infra names cirrus-* → lunora-*: worker names (lunora-cloud, lunora-dispatcher), dispatch namespace (lunora-production), shared queue (lunora-tenant-queue), AE dataset (lunora_tenant_usage), the lunora.app apex, and the deploy dispatch-namespace prefix. - the hosted-CLI verbs (lunora login/link/deploy) and config dir ~/.lunora. - docs' reserved-path/marker refs (/_lunora/*, __lunora_admin__, env.__lunoraCtx). 108 cloud tests green; tsc/eslint/build clean; zero residual `cirrus` references. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * refactor(cloud): migrate functions to fluent builder api Adapt the cloud control-plane functions to alpha v1.0.0-alpha.1's fluent function builders: kind.input({...}).<terminal>(({ ctx, args }) => ...) replaces the removed object form kind({ args, handler }). Regenerate _generated/* and pick up codegen's observability block in wrangler. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): license under polyform noncommercial The control plane is the proprietary product layer, so it must not carry the framework's FSL-1.1-Apache-2.0 (which grants broad commercial rights). Apply PolyForm Noncommercial 1.0.0: any noncommercial purpose is permitted, but commercial use requires a separate license. Replaces UNLICENSED. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): adopt @lunora/bindings/analytics after package fold-in The latest alpha folded @lunora/analytics into @lunora/bindings (subpath export ./analytics, identical API) and codegen now emits _generated/functions.ts importing @lunora/values directly. Swap the dependency and import specifiers, declare @lunora/values, regenerate _generated/*, and reconcile the lockfile. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * fix(cloud): address pr review findings - secrets: assert the project belongs to the org in store/list/ listEncrypted and scope queries by organizationId, closing the cross-org IDOR where a member of one org could read or overwrite another org's project secrets (+ idor tests) - deploy: require a base64 worker bundle in POST /v1/deploy and thread it client → CLI → provisioner instead of uploading an empty module; 400 on missing/malformed bundle - studio: replace try/finally + throw-in-try with promise combinators in Login/Invitations/Secrets forms so React Compiler can memoize them https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs(cloud): add consolidated gap analysis and build plan https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): blue/green releases with health gating and rollback Every deployment now uploads an immutable versioned script ({alias}-v{n}); the project's stable URL follows an active-deployment pointer that only swaps after the new script passes a health probe, so a bad deploy never replaces a serving one (gaps.md a1). Adds POST /v1/deployments/rollback + lunora rollback (pointer swap back to a retained superseded release), GET /v1/tenants/route + a cached alias resolver in the dispatcher, per-phase deployment timestamps (a2), and an x-lunora-id debug header (b3). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): aggregate spend caps with org suspension Per-invocation limits cap one request; nothing capped aggregate period spend (gaps.md c1). Adds a pure spend evaluator at the wfp cost basis with per-plan default caps (org-overridable; explicit 0 = uncapped), an hourly enforcement cron that suspends breaching orgs and self-heals recovered ones, and dispatcher enforcement — a suspended org's tenants serve 503 via the sentinel plan carried through the existing cache. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): custom domains — model, txt verification, hostname routing First slice of gaps.md b1: the domains table (unique hostname, per-org project scoping, redirect-only rows, cloudflare custom-hostname id), add/list/remove/markVerified functions with the same authz gates as secrets, a pure dns-over-https verification core (_lunora txt token + platform cname check, injectable resolver), and routeForHostname — the dispatcher-facing lookup that only ever routes verified domains to the project's active script. Edge routes + dispatcher wiring land next. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): wire custom domains through edge and dispatcher Completes the code-tractable half of gaps.md b1: POST /v1/domains (add, returns the txt record to create), POST /v1/domains/verify (dns-over- https txt + cname checks under the caller's session, outcome recorded via markVerified), GET /v1/tenants/custom-domain for the dispatcher, and a cached custom-domain resolver in the dispatcher that routes verified hostnames to the project's active script and answers redirect-only rows directly. cloudflare-for-saas cert provisioning remains the 🌐 half. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * style(cloud): hoist the trailing-dot regex to module scope https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): server-side builds, build logs, and push-to-deploy webhook gaps.md a3/a4: builds table with a stale-recoverable work lease and commit-sha dedup (a repeat push reuses the successful build's bundle hash instead of rebuilding), streamed line-per-row build logs with a cursor-paginated tail query, github app installations linked by account slug, push + installation webhook parsing (default-branch pushes only, zero-sha deletes ignored) wired through the hmac-verified edge route, and a pure build-runner orchestration (claim → fetch → execute → complete/fail) whose tarball-fetch and container-execute ports are the remaining 🌐 half. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): tenant log ingestion and org right-to-erasure gaps.md b2 + d3. logs: a tenantLogs ledger fed by the tail worker via deploy-key-gated POST /v1/logs/ingest (batch + line-length caps, lines truncated rather than dropped), a cursor-paginated member tail query, and a 6-hourly retention prune (48h window). erasure: owners request org deletion (30-day reversible window); the purge cron then erases every org-scoped control-plane row, marks deployments destroyed for the provisioner teardown path, and removes the org. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs(cloud): mark shipped gaps in the build plan https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): dunning state machine and residency-aware cell placement gaps.md c2 + f. dunning: a pure evaluator (payment failure → 14-day grace anchored at first failure → suspend; any active/trialing subscription rescues) driven by a 6-hourly cron over the synced subscription states. suspensions now carry a reason so the spend-cap and dunning crons only lift their own. placement: organizations.create accepts a jurisdiction ("eu"/"fedramp") and picks a matching active cell when no explicit cellId is given. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): lunora eject core — the no-lock-in exit hatch gaps.md d2: a pure eject flow that pulls the tenant's full data snapshot through its admin export api, scaffolds the byo wrangler.jsonc the project would have had outside the platform (do bindings, d1 placeholder, sqlite migrations), and writes a restore readme — all over injected ports so the packaging is fully unit-tested; the cli wires the real i/o. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * style(cloud): use a template literal in the eject scaffold https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): studio tabs for domains, builds, and runtime logs wires the round-7 backends into the hosted studio: a domains tab (add → txt record callout → verify → live verified badge, remove), a builds tab (per-project build list with live streamed output), and a logs tab (deployment picker over a live runtime-log tail). marks c2/d2/f shipped in the gap plan. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): fat-vs-thin runtime spike + fleet re-release pipeline gaps.md e4 (the plan's ⭐ decide-now item). spike package (spikes/runtime-versioning): the analysis — user functions execute inside ShardDO and workerd has no dynamic code loading, so true-thin is a distributed-transaction redesign, not a packaging change — plus live probes for the three deciding hypotheses (cross-script DO bindings under wfp, callback per-hop cost vs a 1ms viability line, fat-path patch throughput arithmetic). provisional call: fat + pinned runtime + automated forced re-release. that pipeline ships here too: deployments record their runtimeVersion, and src/fleet/upgrade.ts plans canary-first batches and halts on a dirty canary or breached failure rate — a runtime patch becomes a paced batch job over the existing build + health-gated release machinery. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): ring-2 pass — harden, close seams, finish product edges security: github installations move to a staged-claim model (webhook stages, owner/admin claims; recordPush only accepts claimed installations and caps in-flight builds), domains.add enforces the customDomains entitlement, and audit coverage lands for domains, rollback, deletion requests, installation claims, and both suspension crons. seams: build → deploy handoff via the runner's release port (failed release keeps the artifact), stale-build self-healing cron, superseded-release pruning (retain 3/project), and server-built pr previews through the same pipeline. product: per-environment secrets (all/production/preview/dev with kind-over-shared resolution + studio picker), rollback button, suspension/deletion banners, org rename, member role change (last-owner protected), project rename. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): switch billing to creem as merchant of record Resolves gaps.md c3: creem (via @lunora/payment/creem) replaces the stripe adapter as the platform's payment provider. As a merchant of record it is the legal seller and calculates/collects/remits sales tax/vat across 190+ jurisdictions, so the platform never inherits worldwide tax compliance. Swaps the adapter wiring in the shard config (CREEM_API_KEY / CREEM_WEBHOOK_SECRET / CREEM_TEST_MODE for the sandbox), the webhook route + action to the creem-signature header, the studio copy to creem product ids and hosted portal, and the docs. Entitlements, dunning, plans, and quota enforcement are unchanged — they ride the provider-agnostic subscriptions store. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): prepaid-credits overage billing core for creem Verified against creem sdk 1.5.3: products are recurring/onetime only — no metered subscription pricing — but creem ships a first-party credits ledger (per-customer accounts, idempotent credit/debit by reference) built for api metering. Overage is therefore prepaid: orgs buy credit packs (one-time mor sales, tax handled by creem) and the platform debits usage beyond the plan's included quota. Ships the pure core (included quotas per plan, cost-plus overage rates, watermark-delta debits with crash-safe idempotent references, exhausted → the existing c1 suspension path, never negative), the overageDebits watermark table with forward-only advancement, and 10 tests. The live credits api wiring (CreditsLedgerPort) is the remaining 🌐 piece. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): creem credits-ledger adapter and fleet overage reconciliation Completes the api/token-metering implementation over creem's customerCredits api: a structural ledger adapter (balance reads via bigint-safe strings, debits with the idempotent watermark reference, missing account → null and never debitable), applyCreditPurchase for the billing webhook (first purchase creates the account seeded with the pack; later ones credit with the payment id as reference), the organizations.creditsAccountId linkage (never overwritten once set), and reconcileAllOverages — the fleet driver with per-org failure isolation, watermark-advance strictly after a successful debit, and exhausted balances handed to the c1 suspension hook. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): studio ux pass — usage meters, daily chart, command palette Ring 3, patterns from the maple teardown (fsl-licensed observability platform — ideas only, all code our own). usage tab: included-vs-used plan-quota meters (amber at 80%, red past allowance, honest prepaid- credits overage label) + a per-day request-volume chart over the new usage.series query, rendered with a zero-dependency svg bar chart. adds a ⌘k command palette (tab navigation + actions, substring match, arrow/enter/escape keyboard flow, state reset by remount) wired into the org dashboard. gaps.md gains the ranked ring-3 backlog (alerting pillar, health charts, log-viewer upgrade, design tokens, onboarding checklist, mcp surface, integrations hub). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * fix(cloud): keep cron triggers within cloudflare cap The control-plane Worker declared 4 distinct cron expressions (0 */1, 0 */6, 0 */12, */1) — one over Cloudflare's hard limit of 3 Cron Triggers per Worker, which would reject the deploy. The lone 0 */12 trigger existed solely for "purge deleted organizations". Fold that job into the existing 6h bucket: the purge gates on each org's own 30-day retention cutoff, so a tighter cadence only shortens erasure latency — it never erases early. Codegen drops the 0 */12 trigger, leaving exactly 3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01423xZDDqzhQ5D79vy25huF * feat(cloud): observability ingest pipeline — issues + incidents (Phase 3) (#140) * feat(cloud): add the observability ingest pipeline (issues + incidents) Phase 3 of the observability plan — durable, cross-deployment monitoring in the Lunora Cloud control plane, fed by the Phase 2 OTLP transport. - ingest: `POST /v1/telemetry` accepts OTLP-over-HTTP/JSON from the tenant `otlpSink` and the container exporter, decodes the error spans (`src/telemetry/otlp.ts`), and folds them into grouped issues/incidents through a deploy-key-authorized `telemetry.ingest` mutation. Synchronous — the cloud app has no queue producer binding, so ingest inserts to D1 directly (like `usage.ingest`); auth reuses `authorizeDeployKey`, not the plaintext admin token. - store: `issues` + `incidents` `.global()` D1 tables, fingerprinted with `@lunora/fingerprint` (the same hash the local Studio computes, so a local Issue and a cloud Issue are one object); `lunora/{issues,incidents}.ts` member-authorized read/triage functions. A `TelemetryStore` adapter (`src/telemetry/store.ts`) owns the non-relational side — AE metrics plus a guarded Pipeline→R2 archive, each a no-op without its binding. - dashboard: hosted `IssuesSection` / `IncidentsSection`, gated behind the `logStreams` entitlement, wired into `OrganizationDashboard`. - bindings: a `TELEMETRY` AE dataset + `TELEMETRY_BUCKET` R2 bucket. Vendors `@lunora/fingerprint` (Phase 1, not yet merged) so this stacks on the cloud branch; the graft folds away once Phase 1 lands on alpha. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm * feat(cloud): observability alerts — rules + delivery (Phase 4) (#141) * feat(cloud): add observability alerts — rules, firing + delivery Phase 4 of the observability plan (the "watches while you sleep" tier), stacked on the Phase 3 ingest. - schema: `alertRules` (name, target issue/incident, threshold, channel email/webhook, destination, enabled) + `alerts` (fired-alert audit trail with firing→delivered state, notification denormalized). - firing: the telemetry `ingest` mutation loads the org's enabled rules and fires each the first time a source's count crosses its threshold (`before < threshold <= after`, so exactly once), inserting a `firing` alert row. The pure crossing/render logic lives in `src/telemetry/alerts.ts` (unit-tested), mirroring how `usage.ingest` delegates to `evaluateSpendCap`. - delivery: the `/v1/telemetry` edge handler delivers fired alerts best-effort (email via `@lunora/mail`, webhook via JSON POST) then stamps them delivered — never blocking or failing ingest. - functions: `alerts.{rules,createRule,setRuleEnabled,deleteRule,list, markDelivered}` (member-authed reads/writes; deploy-key-authed markDelivered). - dashboard: `AlertsSection` (manage rules + recent fired alerts), gated behind the `logStreams` entitlement, wired into `OrganizationDashboard`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm * fix(cloud): validate webhook alert destinations against SSRF An alert rule's webhook `destination` is `fetch`ed by the control plane when the alert fires, so an owner/admin could otherwise aim it at internal infrastructure (loopback, RFC-1918, the 169.254.169.254 metadata IP, …) — server-side request forgery. Add a pure `isSafeWebhookUrl` guard (https only, public host, no embedded credentials, no loopback/private/link-local IPv4 or IPv6) enforced both at `createRule` (reject the rule) and in `deliverAlert` (never fetch an unsafe target — defense in depth for any rule created before this guard). String-level, so it can't defeat DNS rebinding, but it blocks the direct-address cases. Unit-tested. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud): harden webhook SSRF guard Two SSRF gaps in the Observability alert delivery path: - deliverAlert followed webhook redirects, so a destination that passes isSafeWebhookUrl could 3xx-redirect to an internal address (e.g. the metadata IP). Set redirect: "manual" and reject 3xx responses. - isSafeWebhookUrl let IPv4-mapped IPv6 (::ffff:169.254.169.254, which the URL parser compresses to ::ffff:7f00:1) and the unspecified address (::) through. Reject the whole ::-prefixed non-global class. Numeric IPv4 forms (2130706433, 0x7f000001, 0177.0.0.1) were already blocked via WHATWG URL normalization; added as regression tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfLmCwH5xMfz7L73LRPFj --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(cloud): AI incident triage (@lunora/ai) — Phase 4C (#142) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(runtime): use LunoraError not undefined CirrusError in cloud endpoints The scheduled-tick and queue-dispatch admin endpoints threw `new CirrusError(...)`, a class that exists nowhere in the repo, so the file failed to type-check (TS2304). The intended class is `LunoraError`, already imported and used throughout the file. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cloud): full tenant log management with structured fields + trace correlation Consume the structured, trace-correlated logs the framework now emits (shared/log-event.ts) — the cloud log path kept only 3 severities + a flat line and had no producer. Closes the framework side of Maple gap #2. - Producer (GAPS.md B2, the missing piece): src/tail/worker.ts — the dispatch-namespace tail worker decodes each tenant `{source:"lunora", type:"log"}` console event (src/tail/parse.ts, pure + unit-tested), groups them per script, and POSTs batches to POST /v1/logs/tail. Holds one platform secret (LUNORA_TAIL_SECRET), not per-org deploy keys; the route resolves scriptName → org (logs.orgForScript) and stores via logs.ingestInternal. Deployed from tail.wrangler.jsonc. - Store: tenantLogs widened to the full LogEvent shape — 7-tier severity, message, structured fields, functionPath, traceId/spanId, userId, shardKey — plus (scriptName, createdAt) and (org, traceId) indexes. - Query: logs.list gained server-side levels/functionPath/traceId/search filters + a cursor and bounded limit, newest-first. - UI: the studio Logs tab renders severity chips (filter), search, structured fields, and a short trace id per line. Still 🌐: the provisioner setting tail_consumers on tenant scripts, an e2e run, and correlating error/fatal lines to OTLP Issues by traceId (follow-up). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud): provision tenant bindings so deployed workers boot The deploy handler built the provisioner spec with an empty binding set (`bindings: {}`), so every uploaded tenant Worker was created with no Durable Object binding and no `new_sqlite_classes` migration tag. A real Lunora app always exports ShardDO, so it could never boot — the deploy pipeline could only ship a binding-less worker. The deploy request now carries the app's binding manifest (DO classes, optional per-tenant D1/R2) which the CLI reads from `wrangler.jsonc`, and the handler normalizes it to a spec that always includes the ShardDO floor even when a caller under-declares or omits it. Malformed entries are dropped and the DO list is capped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): tear down Cloudflare scripts for destroyed deployments The lifecycle crons (cleanupExpiredPreviews, pruneSuperseded, organizations.purgeDeleted) only transitioned a deployment to `destroyed` — nothing ever deleted the Cloudflare dispatch script, so dispatch namespaces grew unboundedly (the leak GAPS.md Ring-2 flagged as closed). Add a `teardownAt` marker and a pure, port-injected `runTeardownSweep` (per-target failure isolation, crash-safe idempotent off the marker), wired into the control-plane Worker's scheduled() handler on the hourly/6-hourly buckets — right after the crons that mark rows destroyed. No-ops without Cloudflare credentials. Per-tenant D1/R2 teardown-by-id still needs resource-id persistence and is left as a follow-up; script deletion is the load-bearing fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): fold Analytics-Engine usage into the metering ledger The dispatcher wrote one AE data point per tenant request, but nothing ever read them back — createHttpAnalyticsReader had no caller, so `platformUsage` only held what tenants self-report over POST /v1/usage (nothing, in practice). Spend caps, the usage summary, and the usage chart therefore evaluated an empty ledger. Add a per-cell `usageReadAtMs` checkpoint and a pure, port-injected `runUsageRollback` that delta-reads AE (`timestamp > checkpoint`), attributes each dispatch script to its org/deployment, and appends `requests` rows — then advances the checkpoint so re-runs never double count. A per-row ledger failure is dropped rather than retried (under- count, never double-bill — the same fail-safe as usage.rollup). Wired into scheduled() on the hourly/6-hourly buckets; no-ops without Cloudflare credentials. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): build-queue dispatcher (claim → run → drain) `builds.claimNext` had no caller: enqueued builds sat untouched until the 24h expiry cron failed them with "no build runner picked this up". Add the missing claim→run loop as a pure, port-injected `runBuildDispatch` (bounded per-tick drain; a failed build never aborts the drain), fully unit-tested against the runner ports. Production activation stays gated on the runner's 🌐 seams — `execute` (a throwaway Cloudflare Container running `lunora build`) and `fetchSource` (GitHub App tarball) — which need live container infra, so the dispatcher is not yet wired into scheduled(): claiming builds with no executor would only burn them. This lands the verified logic so the remaining work is purely the container seam, not the orchestration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * docs(cloud): split GAPS legend into wired vs pure-module (🧩) The single ✅ conflated "tested pure function exists" with "feature runs". Add a 🧩 marker for tested-but-uncalled modules, a dated wiring-pass section covering the four gaps just addressed, and correct the two most misleading inline entries: - A3 builds: the claim dispatcher now exists (was missing); only the container execute() seam remains 🌐. - C3 overage credits: reconcileAllOverages / applyCreditPurchase have no production caller (verified) — scheduling + webhook mapping are code (🔨), not credentials (🌐), so the honest status is 🧩, not "✅ core". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): boot-time route classification scanner Port of Openship's route-scanner idea (Apache-2.0) to the /v1 router. The control-plane routes each did inline auth then delegated to a self- authorizing function, but nothing forced a *new* route to be classified — an unclassified endpoint could ship silently and read as protected. Every route now carries an explicit RouteSpec.auth (deployKey / session / webhookHmac / tailSecret / adminToken / public), and assertRoutesClassified runs when createDeployRouter builds the table: a missing/unknown classification, a public route with no reason, or a duplicate (method, path) throws at construction — the Worker fails to start rather than serving an unclassified route. The flat dispatch tables are derived from the one checked list (GET + POST unified). The spec's opt-in `mcp` field is the allowlist the MCP surface will read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): MCP surface generated from the route registry Port of Openship's "MCP tools derived from the route registry" idea (Apache-2.0). A `/v1/mcp` JSON-RPC endpoint (tools/list + tools/call) exposes only routes that opt in via RouteSpec.mcp, and every tool call dispatches back through the real router carrying the agent's own bearer credential — so it runs the identical auth + rate-limit + handler + function-authz path as any HTTP caller; the MCP layer grants no privilege. A hard deny-list (buildMcpTools) guarantees token/secret/tenant-access routes (/v1/secrets, /v1/admin, /v1/invitations/send, /v1/logs/tail) and the surface itself (/v1/mcp) can never become tools even if mis-annotated — the same scope-escape guard Openship applies to tokens/auth/mcp. Only bearer-callable (deployKey/adminToken) opted-in routes are eligible; session/webhook routes are excluded. deployments.rollback is the first tool exposed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * chore(codegen): regenerate _generated for teardownAt + usageReadAtMs Keeps the emitted dataModel/shard/drizzle types consistent with the new deployments.teardownAt and cells.usageReadAtMs schema columns. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): tear down tenant D1 + R2, and test the sweep glue Extends resource teardown past the dispatch script (#2). The lifecycle sweep now also deletes the per-tenant D1 database and R2 bucket, resolved by the same naming convention the provisioner creates them under (shared tenantD1Name / tenantR2Bucket helpers — no drift, no new persistence). New CF API methods: findD1DatabaseByName + deleteD1Database (uuid) and deleteR2Bucket (name). Script + D1 delete are retryable; R2 is best-effort (a non-empty bucket needs an S3-API object purge the teardown context lacks — logged, left for follow-up). D1 (every .global() app has one) and empty R2 buckets are now fully reclaimed. Also extracts the scheduled() sweep glue into testable port-builders (#4): teardownPorts + usageRollbackPorts over a structural ControlPlaneDb, so the row→target mapping, the teardownAt stamp, the ledger insert, and the per-cell checkpoint are unit-tested against a fake store instead of living untested inside server.ts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): carry cronSpecs + bindings from wrangler on deploy The cron fan-out read live deployments' cronSpecs, but nothing ever populated them: deployments.create accepted the field yet the deploy handler/router never passed it, so readCronTargets always returned [] and the entire §2.4 tenant-cron fan-out had no data source (#3). Add parseWranglerManifest — a pure reader that extracts the binding manifest (DO classes / D1 / R2) and cron expressions from a tenant's wrangler.jsonc — and thread cronSpecs through the deploy request → handler → create mutation. The deploy client + CLI now forward both bindings and cronSpecs, so a real deploy provisions what the Worker needs and registers the crons the fan-out drives. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): synthetic uptime monitoring with alerting Adds external-vantage uptime to the control plane's Observability tier — the piece a deployment can't self-report (if it's down, it can't say so). - Probe: pure probeDeployment (generalizes the deploy-time healthCheck — GET, sub-500 = up, latency + timeout, never throws), a consecutive-failure state machine, and a summarizer, all unit-tested (src/uptime/probe.ts). - Sweep: runUptimeSweep over injected ControlPlaneDb ports (mirroring the teardown/usage sweeps) probes every live deployment, records a uptimeChecks row, advances uptimeState, and fires an "uptime" alert the first time a deployment's failures cross a rule threshold — reusing crossesThreshold, renderAlert, and the alerts table/delivery pipeline (src/uptime/sweep.ts). - Edge: server.ts scheduled() runs the sweep on the every-minute tick and delivers fired alerts over their channel (webhook/email), stamping the outcome. - Alerts gain an "uptime" target (schema + createRule + renderAlert), so users configure "page me when my deployment is down" alongside issue/incident rules. - Read side: lunora/uptime.ts (summary + recent queries, retention prune cron) backs a new Uptime dashboard section. Cron triggers stay at 3 expressions (prune rides the 6h bucket, the probe rides the existing every-minute tick). Full suite: 272 tests, lint:types clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019M6G6CAoLVrQMxDYg7BWq2 * fix(cloud): address thermo review of uptime monitoring Security/correctness (branch audit): - SSRF: the sweep fetched a …
…rts, sessions, dashboards (#179) * feat(studio): browse the durable log archive in the Logs panel (#155) * feat(studio): browse the durable log archive in the Logs panel Add a third "Archive" feed to the studio Logs panel that reads the durable ctx.log archive pipelineLogSink writes to R2 (Iceberg / R2 Data Catalog). - @lunora/runtime: a new admin-gated `/_lunora/admin/logs/archive` route (`log-archive-admin-routes.ts`) runs `createPipelineLogReader` server-side — the R2 SQL token stays on the worker, only decoded `{ rows, nextCursor }` reaches the browser. Reads creds from env (`R2_SQL_*`, `CLOUDFLARE_ACCOUNT_ID` fallback) + the table from a new `logArchive` WorkerOption. Fails closed with `LOG_ARCHIVE_NOT_CONFIGURED` when unwired. - @lunora/client: `queryLogArchive(query)` method + re-exported PipelineLog* wire types (owned by @lunora/runtime). - @lunora/studio: a self-contained `ArchiveFeed` (function/user/min-level filters, keyset "Load more", a "not configured" empty state distinct from an error) rendered under the new Archive tab; `errorCode` helper. - Docs + API snapshots updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * refactor(studio): drop try/finally in ArchiveFeed for React Compiler The React Compiler bails on a `try` with a `finally` clause (React Doctor `react-hooks-js/todo`), so the component missed automatic memoization. Reset `loading` in each branch instead, matching the repo's no-`finally` pattern. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * refactor(studio): address thermos review of the archive feed Code-quality + correctness follow-ups from the two-pass review: - Extract the duplicated LEVEL_VARIANT map into `log-level-variant.ts`, shared by the live Logs feeds and the Archive feed (restores the exhaustive `Record<LogLevel, BadgeVariant>` type — drops the `?? "outline"` fallback). - Collapse the four `view !== "archive"` readout guards in logs-panel into one `view === "archive" ? <ArchiveFeed/> : <>…</>` branch. - Drop the `JSON.parse(JSON.stringify(baseQuery))` round-trip in the fetch effect — pass `baseQuery` directly, keying the effect on `querySignature`. - Guard `loadMore` against a cross-filter race: a page-2 fetch that resolves after a filter change is dropped (via `activeSignatureRef`) instead of appending stale rows / overwriting the cursor. - Show a "Loading…" placeholder on the initial fetch instead of a blank panel. - Type `minLevel` state as `"" | ContextLogLevel` (removes a cast). - Move the `LOG_ARCHIVE_NOT_CONFIGURED` sentinel to `shared/log-archive.ts` so runtime and studio share one source of truth with no dependency edge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * refactor(studio): address CodeRabbit review of the archive feed - Fold ArchiveFeed's five fetch-related useState into one useReducer, so each fetch transition (loading / loaded / append / notConfigured / failed / pageFailed) is a single dispatched action (React Doctor prefer-useReducer). - Use the imported `ChangeEvent` type instead of the `React.*` namespace, matching logs-panel.tsx. - Gate the toolbar `LiveError` on `view !== "archive"` so the disabled Errors feed's live-connection state can't leak into the (WS-less) Archive tab. The CodeRabbit "cast env to LogArchiveEnvironment" suggestion is intentionally skipped: `env ?? {}` narrows to `{}`, which is assignable to the all-optional LogArchiveEnvironment (lint:types is green), and eslint's no-unnecessary-type-assertion rejects the cast. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 * polish(studio): clear prior error on a fresh archive fetch Thermos re-review nice-to-have: the reducer's `loading` action now clears `error`/`notConfigured` (matching the kv reducers' `submitStart`), so retrying after a failure shows the loading placeholder instead of the stale error line. Rows are kept, so paging / filter-change refetches don't blank the table. (Kept `default: return state` to stay consistent with the existing kv reducers rather than introduce a one-off `unreachable` exhaustiveness guard.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9 --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * chore(release): @lunora/runtime@1.0.0-alpha.32 [skip ci]\n\n## @lunora/runtime [1.0.0-alpha.32](https://github.com/anolilab/lunora/compare/%40lunora%2Fruntime%401.0.0-alpha.31...%40lunora%2Fruntime%401.0.0-alpha.32) (2026-07-21) * chore(release): @lunora/client@1.0.0-alpha.26 [skip ci]\n\n## @lunora/client [1.0.0-alpha.26](https://github.com/anolilab/lunora/compare/%40lunora%2Fclient%401.0.0-alpha.25...%40lunora%2Fclient%401.0.0-alpha.26) (2026-07-21) * **@lunora/runtime:** upgraded to 1.0.0-alpha.32 * docs: add cirrus cloud platform plan Reverses the managed-deploy-plane won't-do (VOID-TEARDOWN.md §0/§6, CONVEX-PARITY.md #23) with a scoped managed tier: Workers for Platforms data plane, Convex-shaped product model (teams/projects/prod+dev+preview deployments), PartyKit-style managed-vs-BYO CLI split, and a phased roadmap starting with remote-binding dev. Synthesized from a repo inventory, a Convex Cloud teardown, and a GitHub/Cloudflare-primitives survey. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: fold supabase platform teardown into cloud plan Adds Supabase as a reference model: the OSS/proprietary cut line, IS_PLATFORM single-codebase studio pattern, Branching 2.0 preview DX (and its pain points to fix: empty branches, hourly branch billing outside spend caps), the Management API + OAuth-apps growth channel, and the structural cost advantage WfP gives over per-project VMs. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: add wfp constraints, eject path, spike checklist Gap review of the cloud plan: documents that cron triggers are silently dropped for namespaced user Workers (with SchedulerDO alarm-based fan-out mitigation), queue-consumer and send_email verification items, KV account-limit multiplexing, EU jurisdiction toggle, a cirrus-eject portability command built on existing export/import RPCs, namespace-wide observability reuse, managed backups + abuse controls in Phase 4, and a Phase 1 constraint spike. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: verify cloud plan claims against cloudflare docs Fact-checked every Cloudflare claim in CLOUD-PLAN.md against the official docs: WfP pricing, KV/D1/R2 account limits, DO/R2 jurisdictions vs D1 location hints, CF for SaaS hostname pricing, and remote-bindings GA versions all confirmed. Adds three newly verified constraints: no gradual deployments for user Workers (rollback must be platform-side bundle re-upload), the 1200-req/5-min account API rate limit on provisioning, and outbound-Worker TCP/DO interception trade-offs. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: add cell-based scaling architecture to cloud plan Answers how the managed tier scales without hitting account limits or risking platform-wide blocks: script-resident tenant state with lazy inference-driven provisioning, multi-account cells with cell IDs baked into identifiers from day one, a per-cell API token-bucket scheduler, a tenancy graduation ladder up to managed-BYO and the Tenant API, and abuse containment to keep tenant abuse from looking like platform abuse. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: scope alchemy to cell bring-up, not tenant deploys Records the provisioning-engine decision: the per-tenant deploy path stays hand-rolled on cloudflare-typescript (control-plane DB as the single source of truth, cell scheduler, progress events, rollback artifacts); Alchemy (pre-1.0, v2 rewrite underway, no confirmed dispatch-namespace resource) is a candidate only for low-cardinality cell bring-up IaC, with Terraform/Pulumi as the fallback. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: correct alchemy facts (dispatch-namespace resource, v0.93) Re-checked Alchemy against GitHub/npm: it is v0.93.12 (Apache-2.0) and does ship a dispatch-namespace (Workers for Platforms) resource — my earlier 'lacks a confirmed dispatch-namespace resource' was wrong and 'v0.9x' undersold it. Recommendation is unchanged (hand-roll the per-tenant deploy, use Alchemy for cell bring-up) but now rests on the real reason — source-of-truth shape and deploy-orchestration concerns, not capability — with a re-evaluation trigger at a stable 1.x. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: adopt alchemy as the provisioning engine Decision: Alchemy is the provisioning engine across cell bring-up, per-tenant managed deploy, and BYO. Verified it ships DispatchNamespace/ Worker/D1/R2/DO resources plus a built-in D1StateStore and runs inside a Worker (await alchemy(scope) -> finalize/destroy). Backing each tenant scope with the control-plane D1 collapses the two-sources-of-truth concern into one store. The per-cell rate-limit scheduler now paces finalize() runs; bundling stays in the Vite pipeline; rollback re- converges to a prior R2-retained bundle. Risk of a 0.x dependency contained behind a @cirrus/provision adapter with cloudflare-typescript as fallback; spike open items recorded. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: build on alchemy v2 (alchemy@next, 2.0.0-beta.55) Per decision to start on the v2 line: target alchemy@next (verified 2.0.0-beta.55, Effect-based) to avoid a v1->v2 migration mid-build, with v1 0.93.x as the named fallback. Records the trade-offs (beta churn, Effect pulled into the control-plane tree, quarantined behind the @cirrus/provision adapter) and turns the unverified v2 facts (DispatchNamespace resource + D1/DO state store, confirmed on v1 only since docs/CDN were unreachable) into hard Phase 1 spike gates with v1 fallback per surface. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: commit fully to alchemy v2, drop v1 fallback Remove the v1 (0.93.x) fallback hedging throughout: v2 (alchemy@next, 2.0.0-beta.55) is the engine outright. Spike gates remain but now resolve via owned shims or upstream contributions rather than retreat to v1; a hard unresolvable 'no' escalates the engine decision instead of silently dual-tracking. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: add forgotten must-haves + fleet runtime-versioning risk Gap pass on the cloud plan. New risk #8 (the load-bearing one): the Cirrus runtime is bundled into each tenant Worker, so a security patch means redeploying the whole fleet unless the tenant Worker is made 'thin' against a central runtime — a fat-vs-thin decision that must be made before Phase 1 since it shapes the bundle format, deploy API, and vite emit. New section 7 collects launch-blocking gaps the plan had assumed away: control-plane DB durability/DR, cross-cell disaster recovery, secrets-at-rest + cell-token custody, frontend-hosting scope, AUP + bill-shock/cryptomining controls, billing/MoR/tax, GDPR processor/DPA/SOC2, account offboarding + right-to-erasure, platform self-observability + status page, dispatcher canary, and a staging cell. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): scaffold control-plane app built on cirrus First implementation step from CLOUD-PLAN.md: a new apps/cloud workspace app that dogfoods Cirrus as the platform's own control-plane backend. - cirrus/schema.ts: control-plane data model (cells, organizations, members, projects, deployments, deployKeys, auditLog), all .global() (D1) — the plan's 'Worker + D1' control plane. - cirrus functions: organizations/projects/deployments/cells/deploy-keys (create/list/issue/updateStatus), with owner seeding + audit trail. - src/server.ts: control-plane Worker entry wiring D1-backed global tables. - src/provision.ts: the @cirrus/provision seam — the sole coupling to the Alchemy v2 engine (stub that rejects until the Phase 1 spike wires it). - configs (package.json/tsconfig/project.json/wrangler.jsonc/eslint/vitest), README, and a provision test. Verified: codegen clean (no advisories), eslint clean, tsc --noEmit passes, vitest green. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): add deploy-orchestration core Builds the next control-plane layer on the scaffold's Provisioner seam, all pure/testable (no live Cloudflare needed): - token-bucket.ts: per-cell API budget (§2.5), models CF's 1200/5min account limit; deterministic + clock-injectable. - scheduler.ts: CellScheduler paces/serializes provisioner work against the bucket with priority ordering + a concurrency cap. - orchestrator.ts: runDeployment state machine emitting queued → provisioning → live/failed progress events (§2.2); destroyDeployment for preview-TTL/project teardown. - keys.ts: deploy-key format/parse/hash helpers; deploy-keys.ts mutation refactored to use them (one tested place for the format + SHA-256). 17 tests across 5 files; eslint + tsc --noEmit clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): add org authorization + deploy-key lifecycle Closes gaps found reviewing the control plane: - authz.ts: assertMember(ctx, orgId, roles?) — the org ACL gate. Every org-scoped function now verifies the caller is a member with a permitted role, closing an IDOR hole where any signed-in user could read/mutate any org by passing its id. Applied across projects, deployments, deploy-keys. - members.ts: list / add / remove so memberships can actually be granted (owner is seeded on org create; admins/owners manage the rest). - deploy-keys: verify (the deploy API's auth path — match by SHA-256, reject revoked, bump lastUsedAt, return the DB-authoritative target) and revoke (leaked-key mitigation); lastUsedAt/revokedAt are now live. - deployments: create checks the project belongs to the org; updateStatus loads the deployment and gates on its org (documented as the system seam for the orchestrator). codegen clean, eslint + tsc --noEmit clean, 17 tests pass. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): deploy API endpoint, deploy-key auth, handler tests Lands the three remaining pieces together: - Deploy API: POST /v1/deploy mounted via the httpRouter seam (src/deploy/ router.ts) → pure handler (src/deploy/handler.ts) authenticates the bearer deploy key, records a queued deployment, drives runDeployment through the per-cell scheduler, and streams NDJSON progress (accepted→queued→ provisioning→live/failed→done), patching status per phase. - Auth path: investigation showed internalMutation is unreachable from the HTTP action-context dispatch (no system flag → RPC 404), so verify/ updateStatus stay public; instead added deploy-key authorization (authz.authorizeDeployKey) and a dual-path (member session OR deploy key) on deployments.create/updateStatus, so CI deploys need no user session. Corrected the stale 'should become internalMutation' comments. - Tests: handleDeployRequest (401/403/400 + success and failure streaming + status transitions) and authz (assertMember + authorizeDeployKey) via a fake ctx. 28 tests / 7 files; eslint + tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs(cloud): refresh status — deploy API + auth now in place * chore(cloud): track generated schema snapshot Matches the apps/playground convention — .cirrus-schema.json is the codegen schema snapshot used for migration/drift detection and is committed, not gitignored. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): preview lifecycle, GitHub webhook, deploy client (Phase 1/2) Phase 2: - Preview deployments are TTL'd: deployments.create stamps expiresAt for kind=preview (src/deploy/preview.ts: deterministic previewScriptName + 5-day previewExpiry); an hourly cron (cirrus/crons.ts -> internal deployments.cleanupExpiredPreviews) marks expired previews destroyed. Worker gains scheduled(); wrangler cron trigger added. - GitHub webhook (src/github/webhook.ts): HMAC-SHA256 verify + pull_request -> preview-intent parsing, mounted at POST /v1/github/webhook. Phase 1: - Deploy client (src/deploy/client.ts): the cirrus-deploy core — POSTs to /v1/deploy and consumes the NDJSON progress stream. 13 new tests (41 total); codegen/eslint/tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: update roadmap — Phase 0 shipped, Phases 1-2 status Phase 0 (remote-binding dev) is already implemented in the framework (@cirrus/config remote-bindings + @cirrus/vite plugin + cirrus dev; 30 tests) — corrected from 'not started'. Phases 1-2 marked substantially-built with the live-Cloudflare-dependent remainder (Alchemy provisioner, dispatcher, e2e validation) called out. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): team invitations + billing quotas; repo-extraction guide (Phase 3/4) Phase 3 (hosted studio sliver): - Team invitations (cirrus/invitations.ts + invitations table): invite/list/ revoke/accept, single-use SHA-256-hashed tokens (plaintext mailed once), owner-admin gated; accept-by-token adds the caller as a member. Phase 4 (billing sliver): - Plans + quota entitlements (src/billing/plans.ts) on @cirrus/payment's entitlements model — free/pro/enterprise limits + feature flags, with effectiveLimit/withinQuota and a free-tier fallback for non-subscribers. Portability (move to a private repo): - EXTRACT.md documents the mechanical extraction; audit confirms the app imports only published @cirrus/* entry points (no monorepo-internal reaches). 6 new tests (47 total); codegen/eslint/tsc clean; secret-scan clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): real Cloudflare provisioner, quota enforcement, webhook project resolution Provisioner (Phase 1 — the big one): replaces the rejecting stub with a real implementation over a typed Cloudflare REST port (src/cloudflare/api.ts): deploy provisions per-tenant D1/R2, uploads the user Worker into the dispatch namespace with binding + DO-migration metadata, applies secrets, returns the bundle hash + routed URL; destroy deletes the script. Port-injected so it's tested with a fake; plug in CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_API_TOKEN to run. (REST via fetch rather than the unverifiable alchemy@next beta — same seam.) Quota (Phase 4): plans.ts gains planLimit/withinPlanQuota; projects.create and members.add enforce the org plan's limits. Preview automation (Phase 2): projects gain githubRepo + byGithubRepo lookup; the webhook resolves the connected project and returns the preview script name. Env documented (.dev.vars.example + wrangler vars). 50 tests; eslint/tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): dispatcher Worker + hosted-studio admin-RPC proxy (Phase 1/3) Phase 1 — dispatcher Worker (the request-path front door): resolveTenant maps {scriptName}.{appDomain} (and custom domains via injected lookup) to a dispatch-namespace script; the worker forwards via env.DISPATCHER.get with per-plan limits. Separate deployable (dispatcher.wrangler.jsonc). Phase 3 — admin-RPC proxy: proxyAdminRequest authorizes org membership, forwards the admin RPC to the tenant's /_cirrus/admin/* with that deployment's admin token, and records an audit entry. Pure (deps injected). 7 new tests (57 total); eslint/tsc clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): cirrus login/link/deploy CLI commands (Phase 1) Pure command logic over a ConfigStore + the deploy client: login persists the API endpoint + deploy key, link binds a project, deploy streams a managed deploy (requires login+link). File-backed store at ~/.cirrus/cloud.json for the Node CLI; cerebro registration in @cirrus/cli calls these. 3 new tests (60 total); eslint/tsc clean; secret-scan clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: roadmap status — control-plane backend feature-complete as code All phases' backend code is built + unit-tested in apps/cloud (60 tests): real REST provisioner, dispatcher, CLI, preview lifecycle, GitHub webhook, team invitations, admin-RPC proxy, quota enforcement. Remaining items are the ones needing live Cloudflare / external services / the studio UI. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): admin-proxy live wiring, usage metering, custom-hostname port (Phase 3/4) Phase 3 — admin proxy mounted at POST /v1/admin: deployments now carry the platform-minted tenant adminToken (set as the worker's CIRRUS_ADMIN_TOKEN secret + stored on the row), deployments.adminTarget resolves {url, adminToken} after asserting membership, and the router forwards to the tenant's /_cirrus/admin/* with an audit-log.record entry. Phase 4 — usage metering: usageEvents table + internal record mutation + member summary query over a pure aggregateUsage roll-up. Custom hostnames: CloudflareApi.createCustomHostname (Cloudflare for SaaS, zone-scoped REST). Router refactored into per-route handlers. 64 tests; eslint/tsc/secret-scan clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs: roadmap — admin proxy mounted, usage metering + custom-hostname port added * feat(cloud): add hosted studio react spa Build the hosted-studio frontend for the Cirrus Cloud control plane: a better-auth-gated React SPA served on one origin with the control-plane Worker via @cirrus/vite. - src/client: main/auth-client/Login, App auth gate, OrganizationList, OrganizationDashboard with tabs for projects, deployments, members, deploy keys, invitations, and usage; AsyncList loading/empty helper. - Wire @cirrus/auth into src/server.ts (createAuth + cirrusD1Adapter, ensureMigrated, handleAuthRequest, authAdmin, resolveIdentity) and add AUTH_SECRET/AUTH_URL env + .dev.vars.example entries. - Switch package scripts to vite (build/dev), add react/react-dom + @cirrus/react/@cirrus/auth deps, vite.config.ts, index.html, and the DOM lib + jsx in tsconfig. - eslint: client section (filename-case, react-perf, void), browser globals; ignore vite.config.ts. - Refresh README + CLOUD-PLAN status to reflect the studio UI. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): add billing, metering, and hardened auth Billing on @cirrus/payment (§4): org id is the payment referenceId. Wire a Stripe adapter into createShardDO({ payment }); add cirrus/billing.ts with checkout/portal actions, entitlements/subscription reads (resolved through CIRRUS_CLOUD_PLANS with a free-tier fallback), and a signature-verified processWebhook mounted at POST /v1/billing/webhook. The studio gains a Billing tab. Platform metering (§4): rename the resource-metering table to platformUsage (freeing usageEvents for @cirrus/payment's billing ledger), add a deploy-key authenticated usage.ingest mutation + POST /v1/usage endpoint, and enforce per-plan runtime limits in the dispatcher (limitsForPlan → DISPATCHER.get). Auth hardening (§3) on @cirrus/auth/better-auth: mail-backed email verification + password reset (@cirrus/mail), optional GitHub/Google OAuth, admin/twoFactor/passkey plugins, built-in auth rate limiting, plus a per-IP @cirrus/ratelimit cap on the /v1/* surface. Invitations now email the token via POST /v1/invitations/send (never shown in the browser). The Cirrus organizations/members model stays the single org source of truth (better-auth organization plugin deliberately omitted). Add deps (@cirrus/mail, @cirrus/ratelimit, stripe), tests for the router routes + rate limiting + per-plan limits (69 total), and reconcile the README + CLOUD-PLAN status (the provisioner is a real Cloudflare REST impl, not a stub). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): enforce entitlements, wire metering source, add secrets Close the billing loose ends and add the metering source, tenant secrets, and an audit-log view. Entitlements (close loose end #1): quota is now enforced against live subscription state (cirrus/entitlements.ts resolves from the synced `subscriptions` table) rather than the static organizations.plan column — projects/members creation call assertWithinQuota, so a Stripe upgrade raises limits immediately with no column to sync. Per-plan dispatch limits (close loose end #2): deployments.planForScript + a bearer-gated GET /v1/tenants/plan endpoint + a cached plan resolver in the dispatcher (createPlanResolver) wire resolvePlan, so runtime limits actually scale per plan instead of always falling back to free. Metering source: the dispatcher emits one Analytics Engine data point per tenant request (src/metering/analytics.ts); a reader port + HTTP impl and an hourly usage.rollup compaction cron complete the pipeline alongside the existing /v1/usage ledger ingest. Tenant secrets (§7): AES-256-GCM envelope encryption at the edge (src/secrets/crypto.ts), a secrets table (ciphertext + IV only), store/list/ listEncrypted/remove functions, POST /v1/secrets, deploy-time materialization into the tenant Worker, and a studio Secrets tab. Studio: add Secrets + Activity (audit log) tabs; add audit-log.list. Tests: crypto round-trip, plan resolver caching/fallback, entitlement quota, analytics writer/reader (83 total). Docs + .dev.vars.example updated. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * fix(cloud): address review findings (rollup atomicity, deploy failure, dedup) Apply /review findings on the recent billing/metering/secrets work: Correctness: - usage.rollup: the D1 global backend has no multi-statement transaction, so the old insert-summed-then-delete-originals order could double-count (over- bill) on a mid-rollup crash. Reorder to delete the extras first, then patch the surviving row's total last — a crash can now only under-count, never leave a summed row beside surviving originals. - deploy handler: a tenant-secret decrypt failure (corrupt secret / rotated key) threw inside the NDJSON stream and left the deployment stuck in `accepted`. Catch it and transition to `failed` with a status update. - POST /v1/secrets: encryption/config failures (e.g. a malformed SECRET_ENCRYPTION_KEY) now return 500, not a misleading 403 (kept distinct from the membership 403 the store mutation raises); reject the reserved CIRRUS_ADMIN_TOKEN secret name up front instead of silently clobbering it. - studio: drop the plan picker from org creation — limits now come from live subscription entitlements, so selecting a paid plan at create-time granted nothing. Orgs start free; upgrade via the Billing tab. Cleanup: - Extract the cross-org IDOR guard into authz.assertRowInOrg and call it from secrets/members/deploy-keys/invitations (was four byte-identical copies). - Remove dead plans.ts exports planLimit/withinPlanQuota (superseded by entitlements-based quota); add a single highestPlan/PLAN_PRECEDENCE helper and use it in deployments.planForScript (was a hand-rolled tier ladder). - Memoize the Stripe payment config per isolate (was rebuilt on every shard request that touches ctx.payments). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * test(cloud): validate websockets through dispatch (phase 1 spike) Validate the hottest path — hibernated-WS subscriptions + per-invocation limits through env.DISPATCHER.get() — the least-documented WfP case (risk #3). - spikes/ws-dispatch/: a runnable harness for live validation on a real dispatch namespace. A framework-free hibernatable-WebSocket Durable Object (the exact primitive ShardDO uses: acceptWebSocket + webSocketMessage), deployable into the namespace, plus a zero-dep Node probe that drives it through the dispatcher and asserts: (1) the WS upgrade survives the dispatch hop (101 + live socket), (2) a hibernated server push (broadcast) reaches the socket — the mutation-to-subscription shape, (3) cpuMs-limit behaviour. The README documents deploy/run, pass/fail, and the expected results + caveats. - __tests__/dispatcher-ws.test.ts: unit-pins the dispatcher forwarding contract (returns the tenant 101+webSocket response unchanged, applies per-plan limits, meters the upgrade once) — runs in CI, no infra needed. - dispatcher worker: clarifying comments on WS pass-through + per-frame metering semantics. CLOUD-PLAN risk #3 now references the harness. The dispatcher half is verified here (94 tests); the end-to-end behaviour needs a live Cloudflare account + the Workers-for-Platforms add-on to run. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): tenant cron fan-out through dispatch (wfp workaround) Cloudflare drops triggers.crons for Workers in a dispatch namespace, so tenant cron jobs never fire. Fan them out from the control plane (CLOUD-PLAN §2.4). - @cirrus/runtime: add an admin-gated POST /_cirrus/scheduled tick endpoint that runs a cron expression's jobs through the SAME handleScheduled path the native scheduled() trigger uses (user crons + code crons + backup), so a platform can drive a namespaced tenant's crons over HTTP. (Dispatch stubs expose only fetch()/connect() — no scheduled()/queue() — so HTTP is the only transport in.) - src/fanout/cron.ts: pure 5-field cron-expression matching (lists, ranges, steps, dom/dow OR semantics) + dueTicks + fanOutCron orchestration. - control plane: capture each tenant's cronSpecs on the deployments row at deploy; an every-minute heartbeat cron (cirrus/fanout.ts) makes codegen emit the */1 trigger, and server.ts scheduled() reads live cron targets and ticks each due tenant via env.DISPATCHER.get(script).fetch('/_cirrus/scheduled') with the per-deployment admin token (kept in-process — never exposed). Adds the DISPATCHER binding to the control-plane wrangler. Tests: cron matching, dueTicks, fanOutCron (103 cloud tests; 337 runtime tests still green). Live validation on a dispatch namespace pending; queue consumer fan-out is the remaining half. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): tenant queue-consumer fan-out through dispatch (wfp workaround) WfP namespaced Workers can't be queue consumers, so tenant queue-backed work (@cirrus/mail sends, scheduler queue-workpool) never drains. Fan it out from a platform-owned consumer (CLOUD-PLAN §2.4) — the queue counterpart to the cron fan-out. - @cirrus/runtime: add a `queueHandler` option + an admin-gated POST /_cirrus/queue endpoint that reconstructs the batch and invokes it, returning the message ids to retry. (Dispatch stubs are fetch-only, so HTTP is the only transport into a namespaced tenant.) - src/fanout/queue.ts: pure grouping of a shared-queue batch by the producing tenant's script (envelope `{ script, body }`) + fan-out orchestration that collects per-message retries and retries a whole group on delivery failure. - control plane: the account-level Worker is the consumer — server.ts queue() drains the shared cirrus-tenant-queue, resolves each tenant's admin token in-process (never exposed), forwards sub-batches via env.DISPATCHER.get(script).fetch('/_cirrus/queue'), and acks/retries per the tenant reply. Adds the queues.consumers binding to the control-plane wrangler. Tests: groupByTenant + fanOutQueue (108 cloud tests; 337 runtime tests green). Live validation on a dispatch namespace + a producer-side script-tagging helper remain. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): align with the lunora rebrand + reuse @lunora/analytics Rebased onto alpha, which renamed the framework cirrus → lunora. Reconcile the control-plane app and reuse a newly-shipped package. Rebrand: - npm scope @cirrus/* → @lunora/* across deps + imports. - app functions dir cirrus/ → lunora/ (+ tsconfig/eslint globs, _generated paths, the committed schema snapshot → .lunora-schema.json). - reserved paths /_cirrus/* → /_lunora/* (incl. the new scheduled/queue tick endpoints) and the runtime-injected env.__lunoraCtx; renamed exported symbols (LunoraError, LunoraClient/Provider, useLunora, lunoraD1Adapter, LunoraAuth*, LUNORA_CRONS/FUNCTIONS); vite plugin cirrus() → lunora(); CLI config dir ~/.cirrus → ~/.lunora. - wire the new required GlobalIntrospector.facetColumn via @lunora/d1's facetGlobalColumn. Reuse: - src/metering/analytics.ts is now a thin domain layer over @lunora/analytics (createAnalytics writeDataPoint + createAnalyticsSqlClient AE-SQL reader) instead of a hand-rolled writeDataPoint + HTTP SQL client. Verified the rest is genuinely cloud-specific (cron-expression matching, AES-GCM secret crypto, the Cloudflare REST provisioner, the per-cell CF-API token bucket) — no upstream equivalent to fold into. 103→108 cloud tests green; runtime 379 tests green; tsc/eslint/build clean. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): rebrand the product Cirrus Cloud → Lunora Cloud Complete the lunora rebrand to the product layer (the framework already moved): - brand prose Cirrus Cloud → Lunora Cloud across code comments, README, EXTRACT, the studio (index.html title, Login/dashboard), and CLOUD-PLAN.md. - env vars CIRRUS_* → LUNORA_*: LUNORA_ADMIN_TOKEN and LUNORA_MAIL_CAPTURE are functional (read by @lunora/mail); LUNORA_APP_DOMAIN / LUNORA_CELL and the VITE_LUNORA_URL client var follow for consistency. - the LUNORA_CLOUD_PLANS entitlements constant. - infra names cirrus-* → lunora-*: worker names (lunora-cloud, lunora-dispatcher), dispatch namespace (lunora-production), shared queue (lunora-tenant-queue), AE dataset (lunora_tenant_usage), the lunora.app apex, and the deploy dispatch-namespace prefix. - the hosted-CLI verbs (lunora login/link/deploy) and config dir ~/.lunora. - docs' reserved-path/marker refs (/_lunora/*, __lunora_admin__, env.__lunoraCtx). 108 cloud tests green; tsc/eslint/build clean; zero residual `cirrus` references. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * refactor(cloud): migrate functions to fluent builder api Adapt the cloud control-plane functions to alpha v1.0.0-alpha.1's fluent function builders: kind.input({...}).<terminal>(({ ctx, args }) => ...) replaces the removed object form kind({ args, handler }). Regenerate _generated/* and pick up codegen's observability block in wrangler. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): license under polyform noncommercial The control plane is the proprietary product layer, so it must not carry the framework's FSL-1.1-Apache-2.0 (which grants broad commercial rights). Apply PolyForm Noncommercial 1.0.0: any noncommercial purpose is permitted, but commercial use requires a separate license. Replaces UNLICENSED. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * chore(cloud): adopt @lunora/bindings/analytics after package fold-in The latest alpha folded @lunora/analytics into @lunora/bindings (subpath export ./analytics, identical API) and codegen now emits _generated/functions.ts importing @lunora/values directly. Swap the dependency and import specifiers, declare @lunora/values, regenerate _generated/*, and reconcile the lockfile. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * fix(cloud): address pr review findings - secrets: assert the project belongs to the org in store/list/ listEncrypted and scope queries by organizationId, closing the cross-org IDOR where a member of one org could read or overwrite another org's project secrets (+ idor tests) - deploy: require a base64 worker bundle in POST /v1/deploy and thread it client → CLI → provisioner instead of uploading an empty module; 400 on missing/malformed bundle - studio: replace try/finally + throw-in-try with promise combinators in Login/Invitations/Secrets forms so React Compiler can memoize them https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs(cloud): add consolidated gap analysis and build plan https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): blue/green releases with health gating and rollback Every deployment now uploads an immutable versioned script ({alias}-v{n}); the project's stable URL follows an active-deployment pointer that only swaps after the new script passes a health probe, so a bad deploy never replaces a serving one (gaps.md a1). Adds POST /v1/deployments/rollback + lunora rollback (pointer swap back to a retained superseded release), GET /v1/tenants/route + a cached alias resolver in the dispatcher, per-phase deployment timestamps (a2), and an x-lunora-id debug header (b3). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): aggregate spend caps with org suspension Per-invocation limits cap one request; nothing capped aggregate period spend (gaps.md c1). Adds a pure spend evaluator at the wfp cost basis with per-plan default caps (org-overridable; explicit 0 = uncapped), an hourly enforcement cron that suspends breaching orgs and self-heals recovered ones, and dispatcher enforcement — a suspended org's tenants serve 503 via the sentinel plan carried through the existing cache. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): custom domains — model, txt verification, hostname routing First slice of gaps.md b1: the domains table (unique hostname, per-org project scoping, redirect-only rows, cloudflare custom-hostname id), add/list/remove/markVerified functions with the same authz gates as secrets, a pure dns-over-https verification core (_lunora txt token + platform cname check, injectable resolver), and routeForHostname — the dispatcher-facing lookup that only ever routes verified domains to the project's active script. Edge routes + dispatcher wiring land next. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): wire custom domains through edge and dispatcher Completes the code-tractable half of gaps.md b1: POST /v1/domains (add, returns the txt record to create), POST /v1/domains/verify (dns-over- https txt + cname checks under the caller's session, outcome recorded via markVerified), GET /v1/tenants/custom-domain for the dispatcher, and a cached custom-domain resolver in the dispatcher that routes verified hostnames to the project's active script and answers redirect-only rows directly. cloudflare-for-saas cert provisioning remains the 🌐 half. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * style(cloud): hoist the trailing-dot regex to module scope https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): server-side builds, build logs, and push-to-deploy webhook gaps.md a3/a4: builds table with a stale-recoverable work lease and commit-sha dedup (a repeat push reuses the successful build's bundle hash instead of rebuilding), streamed line-per-row build logs with a cursor-paginated tail query, github app installations linked by account slug, push + installation webhook parsing (default-branch pushes only, zero-sha deletes ignored) wired through the hmac-verified edge route, and a pure build-runner orchestration (claim → fetch → execute → complete/fail) whose tarball-fetch and container-execute ports are the remaining 🌐 half. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): tenant log ingestion and org right-to-erasure gaps.md b2 + d3. logs: a tenantLogs ledger fed by the tail worker via deploy-key-gated POST /v1/logs/ingest (batch + line-length caps, lines truncated rather than dropped), a cursor-paginated member tail query, and a 6-hourly retention prune (48h window). erasure: owners request org deletion (30-day reversible window); the purge cron then erases every org-scoped control-plane row, marks deployments destroyed for the provisioner teardown path, and removes the org. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * docs(cloud): mark shipped gaps in the build plan https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): dunning state machine and residency-aware cell placement gaps.md c2 + f. dunning: a pure evaluator (payment failure → 14-day grace anchored at first failure → suspend; any active/trialing subscription rescues) driven by a 6-hourly cron over the synced subscription states. suspensions now carry a reason so the spend-cap and dunning crons only lift their own. placement: organizations.create accepts a jurisdiction ("eu"/"fedramp") and picks a matching active cell when no explicit cellId is given. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): lunora eject core — the no-lock-in exit hatch gaps.md d2: a pure eject flow that pulls the tenant's full data snapshot through its admin export api, scaffolds the byo wrangler.jsonc the project would have had outside the platform (do bindings, d1 placeholder, sqlite migrations), and writes a restore readme — all over injected ports so the packaging is fully unit-tested; the cli wires the real i/o. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * style(cloud): use a template literal in the eject scaffold https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): studio tabs for domains, builds, and runtime logs wires the round-7 backends into the hosted studio: a domains tab (add → txt record callout → verify → live verified badge, remove), a builds tab (per-project build list with live streamed output), and a logs tab (deployment picker over a live runtime-log tail). marks c2/d2/f shipped in the gap plan. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): fat-vs-thin runtime spike + fleet re-release pipeline gaps.md e4 (the plan's ⭐ decide-now item). spike package (spikes/runtime-versioning): the analysis — user functions execute inside ShardDO and workerd has no dynamic code loading, so true-thin is a distributed-transaction redesign, not a packaging change — plus live probes for the three deciding hypotheses (cross-script DO bindings under wfp, callback per-hop cost vs a 1ms viability line, fat-path patch throughput arithmetic). provisional call: fat + pinned runtime + automated forced re-release. that pipeline ships here too: deployments record their runtimeVersion, and src/fleet/upgrade.ts plans canary-first batches and halts on a dirty canary or breached failure rate — a runtime patch becomes a paced batch job over the existing build + health-gated release machinery. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): ring-2 pass — harden, close seams, finish product edges security: github installations move to a staged-claim model (webhook stages, owner/admin claims; recordPush only accepts claimed installations and caps in-flight builds), domains.add enforces the customDomains entitlement, and audit coverage lands for domains, rollback, deletion requests, installation claims, and both suspension crons. seams: build → deploy handoff via the runner's release port (failed release keeps the artifact), stale-build self-healing cron, superseded-release pruning (retain 3/project), and server-built pr previews through the same pipeline. product: per-environment secrets (all/production/preview/dev with kind-over-shared resolution + studio picker), rollback button, suspension/deletion banners, org rename, member role change (last-owner protected), project rename. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): switch billing to creem as merchant of record Resolves gaps.md c3: creem (via @lunora/payment/creem) replaces the stripe adapter as the platform's payment provider. As a merchant of record it is the legal seller and calculates/collects/remits sales tax/vat across 190+ jurisdictions, so the platform never inherits worldwide tax compliance. Swaps the adapter wiring in the shard config (CREEM_API_KEY / CREEM_WEBHOOK_SECRET / CREEM_TEST_MODE for the sandbox), the webhook route + action to the creem-signature header, the studio copy to creem product ids and hosted portal, and the docs. Entitlements, dunning, plans, and quota enforcement are unchanged — they ride the provider-agnostic subscriptions store. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): prepaid-credits overage billing core for creem Verified against creem sdk 1.5.3: products are recurring/onetime only — no metered subscription pricing — but creem ships a first-party credits ledger (per-customer accounts, idempotent credit/debit by reference) built for api metering. Overage is therefore prepaid: orgs buy credit packs (one-time mor sales, tax handled by creem) and the platform debits usage beyond the plan's included quota. Ships the pure core (included quotas per plan, cost-plus overage rates, watermark-delta debits with crash-safe idempotent references, exhausted → the existing c1 suspension path, never negative), the overageDebits watermark table with forward-only advancement, and 10 tests. The live credits api wiring (CreditsLedgerPort) is the remaining 🌐 piece. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): creem credits-ledger adapter and fleet overage reconciliation Completes the api/token-metering implementation over creem's customerCredits api: a structural ledger adapter (balance reads via bigint-safe strings, debits with the idempotent watermark reference, missing account → null and never debitable), applyCreditPurchase for the billing webhook (first purchase creates the account seeded with the pack; later ones credit with the payment id as reference), the organizations.creditsAccountId linkage (never overwritten once set), and reconcileAllOverages — the fleet driver with per-org failure isolation, watermark-advance strictly after a successful debit, and exhausted balances handed to the c1 suspension hook. https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * feat(cloud): studio ux pass — usage meters, daily chart, command palette Ring 3, patterns from the maple teardown (fsl-licensed observability platform — ideas only, all code our own). usage tab: included-vs-used plan-quota meters (amber at 80%, red past allowance, honest prepaid- credits overage label) + a per-day request-volume chart over the new usage.series query, rendered with a zero-dependency svg bar chart. adds a ⌘k command palette (tab navigation + actions, substring match, arrow/enter/escape keyboard flow, state reset by remount) wired into the org dashboard. gaps.md gains the ranked ring-3 backlog (alerting pillar, health charts, log-viewer upgrade, design tokens, onboarding checklist, mcp surface, integrations hub). https://claude.ai/code/session_01M2dhvEWavoD5uPSXJ5aRvj * fix(cloud): keep cron triggers within cloudflare cap The control-plane Worker declared 4 distinct cron expressions (0 */1, 0 */6, 0 */12, */1) — one over Cloudflare's hard limit of 3 Cron Triggers per Worker, which would reject the deploy. The lone 0 */12 trigger existed solely for "purge deleted organizations". Fold that job into the existing 6h bucket: the purge gates on each org's own 30-day retention cutoff, so a tighter cadence only shortens erasure latency — it never erases early. Codegen drops the 0 */12 trigger, leaving exactly 3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01423xZDDqzhQ5D79vy25huF * feat(cloud): observability ingest pipeline — issues + incidents (Phase 3) (#140) * feat(cloud): add the observability ingest pipeline (issues + incidents) Phase 3 of the observability plan — durable, cross-deployment monitoring in the Lunora Cloud control plane, fed by the Phase 2 OTLP transport. - ingest: `POST /v1/telemetry` accepts OTLP-over-HTTP/JSON from the tenant `otlpSink` and the container exporter, decodes the error spans (`src/telemetry/otlp.ts`), and folds them into grouped issues/incidents through a deploy-key-authorized `telemetry.ingest` mutation. Synchronous — the cloud app has no queue producer binding, so ingest inserts to D1 directly (like `usage.ingest`); auth reuses `authorizeDeployKey`, not the plaintext admin token. - store: `issues` + `incidents` `.global()` D1 tables, fingerprinted with `@lunora/fingerprint` (the same hash the local Studio computes, so a local Issue and a cloud Issue are one object); `lunora/{issues,incidents}.ts` member-authorized read/triage functions. A `TelemetryStore` adapter (`src/telemetry/store.ts`) owns the non-relational side — AE metrics plus a guarded Pipeline→R2 archive, each a no-op without its binding. - dashboard: hosted `IssuesSection` / `IncidentsSection`, gated behind the `logStreams` entitlement, wired into `OrganizationDashboard`. - bindings: a `TELEMETRY` AE dataset + `TELEMETRY_BUCKET` R2 bucket. Vendors `@lunora/fingerprint` (Phase 1, not yet merged) so this stacks on the cloud branch; the graft folds away once Phase 1 lands on alpha. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm * feat(cloud): observability alerts — rules + delivery (Phase 4) (#141) * feat(cloud): add observability alerts — rules, firing + delivery Phase 4 of the observability plan (the "watches while you sleep" tier), stacked on the Phase 3 ingest. - schema: `alertRules` (name, target issue/incident, threshold, channel email/webhook, destination, enabled) + `alerts` (fired-alert audit trail with firing→delivered state, notification denormalized). - firing: the telemetry `ingest` mutation loads the org's enabled rules and fires each the first time a source's count crosses its threshold (`before < threshold <= after`, so exactly once), inserting a `firing` alert row. The pure crossing/render logic lives in `src/telemetry/alerts.ts` (unit-tested), mirroring how `usage.ingest` delegates to `evaluateSpendCap`. - delivery: the `/v1/telemetry` edge handler delivers fired alerts best-effort (email via `@lunora/mail`, webhook via JSON POST) then stamps them delivered — never blocking or failing ingest. - functions: `alerts.{rules,createRule,setRuleEnabled,deleteRule,list, markDelivered}` (member-authed reads/writes; deploy-key-authed markDelivered). - dashboard: `AlertsSection` (manage rules + recent fired alerts), gated behind the `logStreams` entitlement, wired into `OrganizationDashboard`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm * fix(cloud): validate webhook alert destinations against SSRF An alert rule's webhook `destination` is `fetch`ed by the control plane when the alert fires, so an owner/admin could otherwise aim it at internal infrastructure (loopback, RFC-1918, the 169.254.169.254 metadata IP, …) — server-side request forgery. Add a pure `isSafeWebhookUrl` guard (https only, public host, no embedded credentials, no loopback/private/link-local IPv4 or IPv6) enforced both at `createRule` (reject the rule) and in `deliverAlert` (never fetch an unsafe target — defense in depth for any rule created before this guard). String-level, so it can't defeat DNS rebinding, but it blocks the direct-address cases. Unit-tested. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud): harden webhook SSRF guard Two SSRF gaps in the Observability alert delivery path: - deliverAlert followed webhook redirects, so a destination that passes isSafeWebhookUrl could 3xx-redirect to an internal address (e.g. the metadata IP). Set redirect: "manual" and reject 3xx responses. - isSafeWebhookUrl let IPv4-mapped IPv6 (::ffff:169.254.169.254, which the URL parser compresses to ::ffff:7f00:1) and the unspecified address (::) through. Reject the whole ::-prefixed non-global class. Numeric IPv4 forms (2130706433, 0x7f000001, 0177.0.0.1) were already blocked via WHATWG URL normalization; added as regression tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017hfLmCwH5xMfz7L73LRPFj --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(cloud): AI incident triage (@lunora/ai) — Phase 4C (#142) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(runtime): use LunoraError not undefined CirrusError in cloud endpoints The scheduled-tick and queue-dispatch admin endpoints threw `new CirrusError(...)`, a class that exists nowhere in the repo, so the file failed to type-check (TS2304). The intended class is `LunoraError`, already imported and used throughout the file. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cloud): full tenant log management with structured fields + trace correlation Consume the structured, trace-correlated logs the framework now emits (shared/log-event.ts) — the cloud log path kept only 3 severities + a flat line and had no producer. Closes the framework side of Maple gap #2. - Producer (GAPS.md B2, the missing piece): src/tail/worker.ts — the dispatch-namespace tail worker decodes each tenant `{source:"lunora", type:"log"}` console event (src/tail/parse.ts, pure + unit-tested), groups them per script, and POSTs batches to POST /v1/logs/tail. Holds one platform secret (LUNORA_TAIL_SECRET), not per-org deploy keys; the route resolves scriptName → org (logs.orgForScript) and stores via logs.ingestInternal. Deployed from tail.wrangler.jsonc. - Store: tenantLogs widened to the full LogEvent shape — 7-tier severity, message, structured fields, functionPath, traceId/spanId, userId, shardKey — plus (scriptName, createdAt) and (org, traceId) indexes. - Query: logs.list gained server-side levels/functionPath/traceId/search filters + a cursor and bounded limit, newest-first. - UI: the studio Logs tab renders severity chips (filter), search, structured fields, and a short trace id per line. Still 🌐: the provisioner setting tail_consumers on tenant scripts, an e2e run, and correlating error/fatal lines to OTLP Issues by traceId (follow-up). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cloud): provision tenant bindings so deployed workers boot The deploy handler built the provisioner spec with an empty binding set (`bindings: {}`), so every uploaded tenant Worker was created with no Durable Object binding and no `new_sqlite_classes` migration tag. A real Lunora app always exports ShardDO, so it could never boot — the deploy pipeline could only ship a binding-less worker. The deploy request now carries the app's binding manifest (DO classes, optional per-tenant D1/R2) which the CLI reads from `wrangler.jsonc`, and the handler normalizes it to a spec that always includes the ShardDO floor even when a caller under-declares or omits it. Malformed entries are dropped and the DO list is capped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): tear down Cloudflare scripts for destroyed deployments The lifecycle crons (cleanupExpiredPreviews, pruneSuperseded, organizations.purgeDeleted) only transitioned a deployment to `destroyed` — nothing ever deleted the Cloudflare dispatch script, so dispatch namespaces grew unboundedly (the leak GAPS.md Ring-2 flagged as closed). Add a `teardownAt` marker and a pure, port-injected `runTeardownSweep` (per-target failure isolation, crash-safe idempotent off the marker), wired into the control-plane Worker's scheduled() handler on the hourly/6-hourly buckets — right after the crons that mark rows destroyed. No-ops without Cloudflare credentials. Per-tenant D1/R2 teardown-by-id still needs resource-id persistence and is left as a follow-up; script deletion is the load-bearing fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): fold Analytics-Engine usage into the metering ledger The dispatcher wrote one AE data point per tenant request, but nothing ever read them back — createHttpAnalyticsReader had no caller, so `platformUsage` only held what tenants self-report over POST /v1/usage (nothing, in practice). Spend caps, the usage summary, and the usage chart therefore evaluated an empty ledger. Add a per-cell `usageReadAtMs` checkpoint and a pure, port-injected `runUsageRollback` that delta-reads AE (`timestamp > checkpoint`), attributes each dispatch script to its org/deployment, and appends `requests` rows — then advances the checkpoint so re-runs never double count. A per-row ledger failure is dropped rather than retried (under- count, never double-bill — the same fail-safe as usage.rollup). Wired into scheduled() on the hourly/6-hourly buckets; no-ops without Cloudflare credentials. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): build-queue dispatcher (claim → run → drain) `builds.claimNext` had no caller: enqueued builds sat untouched until the 24h expiry cron failed them with "no build runner picked this up". Add the missing claim→run loop as a pure, port-injected `runBuildDispatch` (bounded per-tick drain; a failed build never aborts the drain), fully unit-tested against the runner ports. Production activation stays gated on the runner's 🌐 seams — `execute` (a throwaway Cloudflare Container running `lunora build`) and `fetchSource` (GitHub App tarball) — which need live container infra, so the dispatcher is not yet wired into scheduled(): claiming builds with no executor would only burn them. This lands the verified logic so the remaining work is purely the container seam, not the orchestration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * docs(cloud): split GAPS legend into wired vs pure-module (🧩) The single ✅ conflated "tested pure function exists" with "feature runs". Add a 🧩 marker for tested-but-uncalled modules, a dated wiring-pass section covering the four gaps just addressed, and correct the two most misleading inline entries: - A3 builds: the claim dispatcher now exists (was missing); only the container execute() seam remains 🌐. - C3 overage credits: reconcileAllOverages / applyCreditPurchase have no production caller (verified) — scheduling + webhook mapping are code (🔨), not credentials (🌐), so the honest status is 🧩, not "✅ core". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): boot-time route classification scanner Port of Openship's route-scanner idea (Apache-2.0) to the /v1 router. The control-plane routes each did inline auth then delegated to a self- authorizing function, but nothing forced a *new* route to be classified — an unclassified endpoint could ship silently and read as protected. Every route now carries an explicit RouteSpec.auth (deployKey / session / webhookHmac / tailSecret / adminToken / public), and assertRoutesClassified runs when createDeployRouter builds the table: a missing/unknown classification, a public route with no reason, or a duplicate (method, path) throws at construction — the Worker fails to start rather than serving an unclassified route. The flat dispatch tables are derived from the one checked list (GET + POST unified). The spec's opt-in `mcp` field is the allowlist the MCP surface will read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): MCP surface generated from the route registry Port of Openship's "MCP tools derived from the route registry" idea (Apache-2.0). A `/v1/mcp` JSON-RPC endpoint (tools/list + tools/call) exposes only routes that opt in via RouteSpec.mcp, and every tool call dispatches back through the real router carrying the agent's own bearer credential — so it runs the identical auth + rate-limit + handler + function-authz path as any HTTP caller; the MCP layer grants no privilege. A hard deny-list (buildMcpTools) guarantees token/secret/tenant-access routes (/v1/secrets, /v1/admin, /v1/invitations/send, /v1/logs/tail) and the surface itself (/v1/mcp) can never become tools even if mis-annotated — the same scope-escape guard Openship applies to tokens/auth/mcp. Only bearer-callable (deployKey/adminToken) opted-in routes are eligible; session/webhook routes are excluded. deployments.rollback is the first tool exposed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * chore(codegen): regenerate _generated for teardownAt + usageReadAtMs Keeps the emitted dataModel/shard/drizzle types consistent with the new deployments.teardownAt and cells.usageReadAtMs schema columns. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): tear down tenant D1 + R2, and test the sweep glue Extends resource teardown past the dispatch script (#2). The lifecycle sweep now also deletes the per-tenant D1 database and R2 bucket, resolved by the same naming convention the provisioner creates them under (shared tenantD1Name / tenantR2Bucket helpers — no drift, no new persistence). New CF API methods: findD1DatabaseByName + deleteD1Database (uuid) and deleteR2Bucket (name). Script + D1 delete are retryable; R2 is best-effort (a non-empty bucket needs an S3-API object purge the teardown context lacks — logged, left for follow-up). D1 (every .global() app has one) and empty R2 buckets are now fully reclaimed. Also extracts the scheduled() sweep glue into testable port-builders (#4): teardownPorts + usageRollbackPorts over a structural ControlPlaneDb, so the row→target mapping, the teardownAt stamp, the ledger insert, and the per-cell checkpoint are unit-tested against a fake store instead of living untested inside server.ts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): carry cronSpecs + bindings from wrangler on deploy The cron fan-out read live deployments' cronSpecs, but nothing ever populated them: deployments.create accepted the field yet the deploy handler/router never passed it, so readCronTargets always returned [] and the entire §2.4 tenant-cron fan-out had no data source (#3). Add parseWranglerManifest — a pure reader that extracts the binding manifest (DO classes / D1 / R2) and cron expressions from a tenant's wrangler.jsonc — and thread cronSpecs through the deploy request → handler → create mutation. The deploy client + CLI now forward both bindings and cronSpecs, so a real deploy provisions what the Worker needs and registers the crons the fan-out drives. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NjFm7WWd7aqnKduj2thnNU * feat(cloud): synthetic uptime monitoring with alerting Adds external-vantage uptime to the control plane's Observability tier — the piece a deployment can't self-report (if it's down, it can't say so). - Probe: pure probeDeployment (generalizes the deploy-time healthCheck — GET, sub-500 = up, latency + timeout, never throws), a consecutive-failure state machine, and a summarizer, all unit-tested (src/uptime/probe.ts). - Sweep: runUptimeSweep over injected ControlPlaneDb ports (mirroring the teardown/usage sweeps) probes every live deployment, records a uptimeChecks row, advances uptimeState, and fires an "uptime" alert the first time a deployment's failures cross a rule threshold — reusing crossesThreshold, renderAlert, and the alerts table/delivery pipeline (src/uptime/sweep.ts). - Edge: server.ts scheduled() runs the sweep on the every-minute tick and delivers fired alerts over their channel (webhook/email), stamping the outcome. - Alerts gain an "uptime" target (schema + createRule + renderAlert), so users configure "page me when my deployment is down" alongside issue/incident rules. - Read side: lunora/uptime.ts (summary + recent queries, retention prune cron) backs a new Uptime dashboard section. Cron triggers stay at 3 expressions (prune rides the 6h bucket, the probe rides the existing every-minute tick). Full suite: 272 tests, lint:types clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019M6G6CAoLVrQMxDYg7BWq2 * fix(cloud): address thermo review of uptime monitoring Security/correctness (branch audit): - SSRF: the sweep fetched a …
Follow-up to #154. The durable
pipelineLogSinkarchive was readable only from the CLI (lunora logs --durable); now the Studio can browse it too, so you never have to leave the Studio to check logs older than the bounded in-DO window.What's new — Logs → Archive feed
A third feed next to Requests/Errors, reading the durable
ctx.logarchivepipelineLogSinkwrites to R2 (an Iceberg table in R2 Data Catalog).How it's wired (token stays server-side)
The R2 SQL API token must never reach the browser, so the reader runs on the worker:
@lunora/runtime— new admin-gatedPOST /_lunora/admin/logs/archiveroute (log-archive-admin-routes.ts) runscreatePipelineLogReaderserver-side and returns only the decoded{ rows, nextCursor }. Credentials come fromenv(R2_SQL_ACCOUNT_ID/R2_SQL_TOKEN/R2_SQL_BUCKET, with aCLOUDFLARE_ACCOUNT_IDfallback); the target table comes from a newlogArchiveWorkerOption. Query filters are validated (level ∈ the ramp, finite numbers) before the reader runs. Fails closed withLOG_ARCHIVE_NOT_CONFIGURED(400) when no table/creds are wired — one signal for both.@lunora/client—queryLogArchive(query)method + re-exportedPipelineLog*wire types (owned by@lunora/runtime, so one source of truth — no hand-mirroring).@lunora/studio— a self-containedArchiveFeedcomponent (function-prefix / user / min-level filters, keyset Load more vianextCursor) rendered under the new Archive tab. An unconfigured archive renders a "not configured, see docs" empty state — never a red error. NewerrorCodehelper inlib/internal.Operator setup
Pass
logArchive: { namespace, table }tocreateWorkerand set theR2_SQL_*env vars (documented inobservability.mdx, alongside the existing table-schema / column-map /serializeFieldsguidance).Tests
@lunora/runtime:log-archive-admin.test.ts(9) — reader forwarding, cursor pass-through, bad-level 400, not-configured fail-closed (missing table + missing creds), account-id fallback, 405, plus worker-level admin-gate. Full suite 571 green.@lunora/client: 396 green (new method + type re-exports).@lunora/studio:archive-feed.test.tsx(5) — renders rows, Load-more appends via cursor, empty state, not-configured-vs-error, inline error; existing logs-panel (29) green with the new tab. Unit 190 + logs component 72 green.lint:typesclean, eslint clean (--max-warnings=0), API snapshots updated (additions only), repo-wide prettier clean.🤖 Generated with Claude Code
Summary by CodeRabbit
nextCursor.