feat(cloud): observability ingest pipeline — issues + incidents (Phase 3) - #140
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review (2)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 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! 🙏 |
0c966b8 to
ef5291b
Compare
|
React Doctor could not complete this scan.
Reviewed by React Doctor for commit |
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): 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>
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
ec37408 to
0ad7a0e
Compare
…e 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>
* 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 deployment URL any org member can set, from the
privileged control plane, with no guard. Gate every probe URL through the same
isSafeWebhookUrl check the webhook path uses; an unsafe URL is skipped, not
probed or recorded.
- Unbounded fan-out: probing every live deployment each minute could blow the
Worker subrequest budget and record the overflow as false "down" alerts. Cap
probes/sweep, bound probe concurrency (pool), and shard large fleets across
ticks by id-rotated window.
- prune loaded the whole uptimeChecks table (the fastest-growing one) into
memory; read a bounded oldest-first page instead (no range-where in the ctx-db).
- A failed delivery no longer stamps deliveredAt.
Quality (code review):
- Kill the duplication: the alert firing loop + AlertDelivery type were copy-
pasted between telemetry.ts and sweep.ts. Lift one generic fireCrossedRules +
AlertDelivery<TId> into src/telemetry/alerts.ts; both paths now share it, so
they can't drift. TId carries the alert-row id (branded Id vs plain string).
- Extract recordCheck/advanceState; drop the dead latencyMs guard.
Full suite: 273 tests, lint:types clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019M6G6CAoLVrQMxDYg7BWq2
* feat(cloud): Traces tab — per-trace timelines in the console
The console shipped Logs + Issues but no Traces view. The cloud stores no OTel
spans (the OTLP ingest keeps only error spans → Issues), but the tenant log lines
already carry traceId/spanId/functionPath — so group them by traceId to get a
real per-trace timeline.
- `src/telemetry/traces.ts` (new): pure `foldTraces` — folds log rows into
per-trace summaries (root function, time span, line count, peak severity,
error flag), newest-active first. Unit-tested like the other telemetry logic.
- `logs.listTraces` query: reads the recent lines and folds them (members-only,
bounded). Return type mirrored locally so codegen inlines it.
- `TracesSection` + dashboard tab (after Logs): lists recent traces (red when a
line erred), drills each into its timeline reusing `logs.list` filtered to the
traceId. Reuses the shared `.table` / `.log-badge-*` styles.
- GAPS.md B2 updated; a true span-duration waterfall (needs a span store) noted
as deferred.
Note: eslint can't run on this branch (sonarjs crashes under typescript 7 —
the pin that fixes it landed on alpha, not here); typecheck + tests are green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9
* feat(cloud): waterfall look for the Traces tab
Design pass to give the Traces view a best-in-class OTel-dashboard feel, on the
app's own dark tokens (no external code — design inspiration only, per GAPS.md's
"ideas, not source"):
- List: the Span column is now a relative duration bar (scaled to the longest
trace on screen) + severity-coloured, so slow/hot traces read at a glance.
- Drill-in: replaced the plain log dump with a **waterfall** — each line is a bar
placed on the trace timeline (left = offset, width = gap to the next line),
severity-coloured, with the offset, badge, span id, function, and message. A
header with the trace id / span count / duration and a Close button.
Bar widths reflect log-line timing (the cloud stores no span durations); a true
span waterfall would need a span store, still noted in GAPS.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9
* feat(cloud): capture spans as observations (Traces phase 1)
The OTLP ingest kept only error spans (→ Issues) and threw away every span's
timing + nesting. Now it persists them all — the span store the Langfuse
teardown pointed to, reimplemented cleanroom on our schema (no Langfuse code).
- `decodeObservations` (src/telemetry/otlp.ts, pure + unit-tested): decodes
EVERY span with real startedAt/endedAt→durationMs and traceId/spanId/
parentSpanId (the fields the decoder previously ignored).
- `observations` table (.global(), by_trace + by_org_started indexes) +
`telemetry.ingest` persists them, additive to the error→Issue fold; router
decodes + forwards them; `pruneObservations` cron trims at 48h like the logs.
Unlocks phase 2 (traces.list/get + a real-duration nested waterfall). True
nesting also needs the framework to emit ctx.trace child spans over OTLP —
today the runtime emits one flat span per RPC (no parentSpanId), noted in GAPS.
eslint still can't run branch-wide (sonarjs vs typescript 7); tsc + 284 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197KjhmBDB3PXAc6TXiPqZ9
* feat(cloud): standard OTLP ingest API (/v1/traces, /v1/logs)
Make the cloud a real OpenTelemetry endpoint — any OTel SDK/Collector can ship to
it, not only Lunora's own otlpSink (mirrors Maple's ingest.maple.dev/v1/traces
and Langfuse's OTLP endpoint; the shape is the open OTLP spec, no third-party
code).
- `POST /v1/traces` — bearer-authed (Authorization: Bea…
…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…
…e 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>
…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 …
…e 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>
…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 …
…e 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>
…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 …
Phase 3 — Cloud observability ingest + durable store + hosted views
Third phase of the observability plan (superlog → Lunora). Stacks on the cloud control plane (#85) — base is
claude/cloud-platform-dx-ojvkmu, notalpha. Builds on Phase 1 (grouped Issues, #138) and Phase 2 (OTLP transport, #139) to give the hosted platform durable, cross-deployment monitoring across workers and containers.What's here
POST /v1/telemetry(src/deploy/router.ts+src/telemetry/otlp.ts): accepts OTLP-over-HTTP/JSON from the tenantotlpSinkand the@lunora/containerexporter (the Phase 2 wire contract), decodes the error spans, and folds them into grouped issues/incidents via a deploy-key-authorizedtelemetry.ingestmutation.apps/cloud/lunora/):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. Container error events additionally open/update anincidentsrow (crash-loop / OOM).issues.ts+incidents.tsexpose member-authorizedlist+setStatus.TelemetryStoreadapter (src/telemetry/store.ts): one interface, one Cloudflare-native impl — Analytics Engine metrics + a guarded Pipeline→R2 raw archive. Each method no-ops without its binding, so ingest works unchanged wherever the telemetry bindings aren't provisioned. A higher-fidelity backend (ClickHouse) can implement the same interface later.src/client/):IssuesSection+IncidentsSection, gated behind thelogStreamsplan entitlement, wired intoOrganizationDashboard(its tab render refactored to aSECTIONSmap).wrangler.jsonc): aTELEMETRYAE dataset + aTELEMETRY_BUCKETR2 bucket.Decisions that diverge from the plan (both simplify + de-risk)
usage.ingest/logs.ingest, the mutation inserts to D1 directly (fingerprint + a handful of indexed upserts is cheap and the control-plane mutation is serialized). Metrics/raw archival are best-effort side-effects in the handler, never blocking or failing ingest.adminToken. ReusesauthorizeDeployKey(hashed, uniqueby_hashindex), sidestepping the plan's plaintext-token-as-bearer caveat entirely (deployments.adminTokenhas no by-token lookup index anyway).Verification
apps/cloudfull suite 166/166 pass (33 files), incl. a new 8-casetelemetry.test.tscovering the OTLP decoder + the store.tsc --noEmitclean and eslint 0/0 on every changed file.Notes for review
fix(cloud): …stripe subpath(a pre-existing build break —server.tsimported provider adapters from the@lunora/paymentroot, which intentionally doesn't re-export them; unrelated to Phase 3, fixed so this branch typechecks) andfeat(cloud): …observability ingest(Phase 3).packages/fingerprint/**(Phase 1's zero-dep package, not yet merged) so this compiles on the cloud branch. It folds away once Phase 1 (feat(observability): grouped error Issues (local, OSS) — Phase 1 #138) lands onalphaand the cloud branch rebases./v1/telemetry— injectingLUNORA_OTLP_ENDPOINT+ a deploy key at provisioning (src/provision.ts). And the raw Pipeline archive stays a no-op until aTELEMETRY_PIPELINEproducer is minted per cell (wrangler pipelines create).error.type+ a sample message (may contain user input), same trust boundary as the Phase 2 sinks.🤖 Generated with Claude Code
https://claude.ai/code/session_018sRFb1136YE8KDmDbFMYmm