Skip to content

release: dev → main (21 commits — billing waves 0–5, deployments, auth, agent security) - #86

Draft
dylanroscover wants to merge 25 commits into
mainfrom
dev
Draft

release: dev → main (21 commits — billing waves 0–5, deployments, auth, agent security)#86
dylanroscover wants to merge 25 commits into
mainfrom
dev

Conversation

@dylanroscover

Copy link
Copy Markdown
Member

Draft. Opened as a review surface — merging this deploys to owlette.app (prod), and main is not branch-protected, so the merge is the deploy.

What's in it — 21 commits

Billing (beta exit) — waves 0–5. Tier derivation and quota unification, Stripe customer lifecycle + portal + webhook, checkout, trial lifecycle, usage metering, billing tab, trial banner and pro gates, admin overrides, ops dashboard, health alerts, delivery gate, core site limit.

Deployments. Auto-computed sha256 checksums for installer deployments; working retry (in-place, checksum self-healing) + dialog UX.

Auth. Google sign-in inside in-app browsers; returning users landing on /register; /setup-2fa cancel no longer drops users back on /register.

Landing. Problem-solving hero copy, hydration-mismatch fix on touch devices, per-load headline rotation.

Agent security + build. cryptography 44.0.3 → 50.0.0; the three range-pinned deps hard-pinned; user site-packages sealed out of the installer build; pin rationale corrected.

Billing is inert on merge

Billing is gated on a Firestore doc config/billing.goLiveAt, written separately by scripts/billing-go-live.mjs. trialClockStarted() in web/lib/actions/bootstrapUser.server.ts returns false when the doc is missing, when goLiveAt is null or future-dated, and when the read throws — fail-safe by design ("the failure mode of that choice is a too-long trial, never a premature lockout").

So this merge deploys billing code. It does not start anyone's clock. Go-live remains a separate, deliberate step against prod Firestore.

Dependabot impact

Alerts are computed against the default branch, so nothing on dev has closed anything yet. This merge closes 3 of 8 open agent/requirements.txt alerts — CVE-2026-69247, CVE-2026-69249, CVE-2026-69248, all cryptography.

The other 5 remain open on both branches: setuptools #5 (high, PackageIndex path traversal) / #8 (medium), and requests #4 / #6 / #7. All five were individually verified unreachable in this codebase — no verify=False, no extract_zipped_paths caller, no .netrc, package_index never invoked, no sdist ever built. PR #80 addresses them and should land in 2.12.20 rather than here.

Known defects this does NOT fix

  • --ignore-installed in build_installer_full.bat ships orphaned dist-info. Every installer carries two setuptools and two packaging dist-infos; scanners read the newer (patched) metadata while the older (vulnerable) code executes. Highest-value build fix outstanding.
  • google-auth==2.22.0 pins urllib3<2.0, holding the agent on urllib3 1.26.20 (EOL). The google-* trio is unused by agent code; dropping it removes 17 packages and unpins the transport.
  • agent/src/cleanup_commands.py imports firebase_admin at module scope, violating a repo guardrail. Dead code, but shipped.
  • A failed Inno compile exits 0.

Verification

  • playwright e2e green on every web-scope commit through d8334db.
  • Installer 2.12.19 built, verified, and live on dev — sha256 4081945d726d75c9b1d4a17690dd1d9f9e9a24e1e54a0d4dec36b0021092afea. Prod fleet remains on 2.12.18; promotion is a separate step.
  • Agent dependency work reviewed by 12 independent adversarial reviewers.

🤖 Generated with Claude Code

dylanroscover and others added 21 commits August 1, 2026 02:08
…keyed quota

- customers/{uid} doc shape (Customer type, TRIAL_LENGTH_DAYS=14, doc factory);
  minted with a live 14-day trial clock in bootstrapUser (created-branch only,
  idempotent, never fails a signup)
- pure billing-state resolver (trialing/active/expired/canceled) with the
  trialEndsAt:null pre-go-live sentinel and fail-open posture on corrupt data
- scripts/backfill-customers.mjs (idempotent, dry-run; not executed anywhere)
- functions quota model migrated off free/starter/pro/enterprise to the site
  tier (core: 0, pro: 1 TiB); roost on core now 403s tier_insufficient;
  readTier now reads the tier field the dashboard reads (old code read a
  never-written plan field)
- roadmap billing line updated for the unified 14-day no-card trial

Billing entity is the site-owner user (no org entity exists). Verified: web
tsc clean + 2763 jest green; functions build + 242 node:test green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
stripe@22.4.0 — official typed SDK per the billing plan; wraps land in
web/lib/stripe.server.ts with wave 1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…billing gates

- createSite derives tier from the owner's billing state (trialing→pro,
  active→subscriptionTier??pro); subscriptionTier added to Customer;
  tierUpgradedAt on site docs + API projections + openapi
- web quota surfaces unified on the functions model: quota route + whoami
  (a third hand-copied mirror) now read TIER_STORAGE_BYTES from siteTier.ts;
  roostAvailable replaces unlimited; dead unlimited-plan branch removed
- billingGate.server.ts: requireActiveBilling (402 trial_expired) /
  requirePro (403 tier_insufficient) / siteTierOrThrow over an acyclic
  billingSnapshot leaf; api-key surface gated at all four _shared.ts scope
  resolvers (session auth untouched — dashboard stays readable when locked);
  error catalog entries in errors.mdx + openapi problem responses
- known follow-up (task 0.8): cli/sdk quota typings still expect unlimited

Verified: tsc clean; 163 suites / 2820 tests green; validate:api passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ook (waves 0.6/0.8/1.2-1.4)

- 13 pro-only API routes gated ({requirePro:true} at scope resolvers;
  account-scoped requireProAccount for api-key creation); control-plane
  lockout central in authorizedSiteHandler via BILLING_LOCKED_CAPABILITIES
  (mutations only — viewing, bill-reduction and security actions stay open);
  billing_locked deny audits
- stripe.server.ts lazy singleton (mode from key prefix, 10s timeout, key
  never logged) + linkStripeCustomer on first signup (metadata.uid contract,
  email-list adoption rules, zero network while unconfigured) + backfill
  script (not executed)
- POST /api/billing/portal (session-auth, owner-only) + lowercase "manage
  billing" row in account settings
- stripe webhook: signature-verified fail-closed, claim-first event dedupe,
  status normalization + price→tier mapping per plan's pinned decisions,
  transactional customer writes, invoice mirror, secret-free logging
- cli + node/python SDK quota typings aligned to roostAvailable (breaking —
  rc bumps owed before any 1.0.0 final)
- chunking 10k perf smoke given an explicit 180s budget (was the suite's one
  load-sensitive flake, failing --bail pre-commit runs on green code)
- env-manifest: 7 STRIPE_* keys registered (metadata only)
- pre-commit hook jest timeout 90s→300s (user-approved; suite outgrew it)

Verified: tsc clean; validate:api 13 warnings (delta 0); 166 suites /
2968 tests green; cli + node sdk + python sdk suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
393565b's message claims the webhook handler landed; it did not. The
harvest command chained the new-file copies behind a patch apply that
errored, the && chain aborted before the copies ran, and the omission
was masked by hand-fixing only the visible failure (env-manifest). The
source worktree had already been cleaned up, so the five files were
re-emitted by the original agent from its transcript (replay of its
Write/Edit payloads, cross-checked against edit markers, test counts,
and export counts) and re-verified here: tsc clean, 6 billing suites /
162 tests green against the real stripe.server.ts from wave 1.2.

Contents are as originally reviewed: signature-verified fail-closed
route, claim-first event dedupe ledger, status normalization + price→
tier mapping per the plan's pinned decisions, transactional customer
writes, invoice mirror, secret-free logging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lling tab

- checkout (2.1): tier-picker dialog + subscription-mode Stripe Checkout;
  metered line items (no quantity), payment_method_collection always,
  metadata {uid,tier} on session + subscription; entitlement-write-free
  (webhook remains the single writer, test-pinned); roost pro-gate CTA
  now opens the dialog
- trial lifecycle cron (2.2): /api/cron/billing-trial-lifecycle — expiry
  flips via resolveBillingState, day-10/13/expiry reminder emails through
  the existing resend transport (latest-due-wins, send-before-stamp),
  post-expiry alert cutoff at +30d cleared instantly on conversion; one
  customers read per owner in the health-check send loop
- usage metering (2.3+2.4): /api/cron/billing-usage-report — Stripe
  Billing Meters v1 (legacy usage records verified REMOVED from stripe@22);
  three meter event names, daily snapshots (meters must aggregate 'last'),
  max(3,n) pro minimum, GiB-floored pro-only storage overage, dual-layer
  idempotency (firestore ledger + deterministic identifier)
- billing tab (2.5): account-settings billing section — trial countdown,
  per-site projections, storage bars, choose-a-plan/manage-billing; fed by
  session-auth'd GET /api/billing/snapshot (admin-side reads — NO
  firestore.rules change); wire contract in lib/types/billingSnapshot.ts
  leaf; pricing constants centralized in lib/billing/pricing.ts
- docs: scheduled-endpoints table + checklist now cover both billing crons
  (registration on cron-job.org is a ship-time step)

Verified: tsc clean; validate:api 13 warnings (delta 0); 178 suites /
3246 tests green; all 21 agent-created files audited present pre-commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…aces

- trial banner (3.1): announcement (config/billing goLiveAt, set at 5.1) /
  final-week countdown / non-dismissable expired state; read-only notice
  beside the machines list (signage only — metrics stay visible per the
  lockout matrix); dismissal in devicePrefs (7-day window), owner-scoped,
  renders nothing when absent
- pro-tier gates (3.2): ProTierGate card+inline variants across roost,
  api-keys page, account-settings api section, webhooks page; core accounts
  with existing keys/webhooks keep list + revoke/rotate fully visible —
  only create affordances gate (revocation is a security action; matches
  the server's deliberately untier-gated GET/DELETE)
- billing warning (3.3): X-Owlette-Billing-Warning on api-key responses
  while trialing (silent pre-go-live), emitted at the existing advisory-
  header sink; 6 routes that never attached advisories now do; CLI warns
  once per invocation on stderr; node + python SDKs expose an optional
  callback (additive, minor)
- /api/keys POST now carries the same requireProAccount gate as its public
  sibling — it was the ungated second door into key creation (3.2 finding);
  wiring pinned by tests, GET deliberately stays open for audit

Verified: tsc clean; 186 suites / 3354 tests green; validate:api 13
(sequential after jest — a jest fixture creates a transient route dir, so
the two must never run concurrently); cli 9/9 new + build clean; node sdk
green; python sdk 75 green incl. 6 new.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, docs; go-live minting gate

- admin billing (4.1/4.2): /admin/customers (extend trial / comp / force-
  expire, confirm dialogs) + /admin/billing ops dashboard (state+tier
  counts, MRR floor from usage mirrors, conversion, storage top-list);
  three superadmin routes behind authorizedPlatformHandler; new
  BILLING_MANAGE capability + billing_mutated audit kind (web + functions);
  extension anchors at max(end, now), re-arms reminder markers consistently
  with the cron's planner, clears the alert mute; comp carries falsifiable
  provenance (compedTier/At/By/Note)
- health alerts (4.3): canceled / payment-failed / past-due-transition
  emails to owner + ops (ADMIN_EMAIL convention), per-kind 24h cooldown
  stamped only after a successful send; alert failure never fails webhook
  processing
- docs (4.4): customer billing page (dashboard/billing.mdx, honest about
  unshipped storage metering) + docs/internal/billing-runbook.md (state
  resolution, failure modes, env map, gap table)
- go-live minting gate (4.4's audit finding, fixed): bootstrapUser now
  mints the null trial sentinel until config/billing.goLiveAt has passed —
  a pre-go-live signup can never resolve expired while checkout is
  unavailable; post-go-live signups self-start; runbook rows reconciled

Verified: tsc clean; 192 suites / 3500 tests green; validate:api 13
(sequential after jest); functions build + 242 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ve script

- webhook delivery gating (2.6): fireWebhooks pauses for expired/canceled
  owners (state-named debug log, no failCount creep, fail-open on read
  errors, per-run memoized cache); functions retry pump gated via a
  mirrored billingLogic resolver — paused records stay byte-identical so
  conversion resumes them next tick; dead emitWebhook fan-out documented,
  not gated; tier deliberately not consulted (paid core keeps webhooks)
- core one-site limit (2.7): createSite rejects a second site for
  subscribed-core owners with the tier_insufficient shape ("core includes
  one site — upgrade to pro for unlimited sites"); dialog error path
  verified to surface it verbatim; trialing/pro unaffected
- billing-go-live.mjs (5.3 tooling): announce / stamp / status modes with
  refusal rails (missing customers docs, unset/future goLiveAt), goLiveAt-
  anchored clocks, idempotent-by-construction stamps, wouldExpire warning
  for late runs; NOT executed anywhere; BETA_DEFAULT_TIER constants
  deliberately untouched until the stamp actually runs (T0 deploy step,
  with the billing-golive-copy branch)
- 61 script tests incl. drift guards cross-checking the .mjs hand-mirrors
  against their TS sources; runbook + customer docs updated (delivery row,
  site-limit note, go-live invocations)

Verified: tsc clean; 193 suites / 3586 tests green; validate:api 13;
functions build + 256 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…yments

Agents 2.11.1+ (9dccd12) refuse install_software commands without
sha256_checksum, but the dashboard never collected one — every UI-created
deployment failed on up-to-date fleet machines.

The server now streams the installer from its URL at authoring time and pins
the SHA-256 automatically; nobody hashes files by hand.

- computeInstallerChecksum action: streaming hash, 10 GiB cap, SSRF guard
  (webhook validator) with per-redirect-hop re-validation
- routes: POST /api/sites/{siteId}/deployments/checksum (DEPLOYMENT_MANAGE)
  + POST /api/platform/installer-checksum (SYSTEM_PRESET_MANAGE)
- useInstallerChecksum hook + InstallerChecksumStatus row shared by
  DeploymentDialog and SystemPresetDialog: debounced auto-compute, stored
  checksum adoption, manual-entry fallback (reachable from computing/error
  states), deploy/save blocked until a checksum exists
- sha256_checksum persisted through deployment templates + system presets
  (types, hooks, actions with 64-hex validation, route serializers);
  legacy docs self-heal on next save
- openapi: document the agent-side refusal on the public deployments API
  (field stays optional there — no CLI/SDK contract break)
- tests: compute-core suite (redirect re-validation, private-ip rejection,
  size cap, abort mapping) + checksum cases in template/preset action
  tests; e2e specs updated to the required-checksum flow and now assert
  the checksum lands on the deployment doc and install command

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ialog UX

The dashboard's "retry failed" never called the retry API — it cloned a new
"<name> (Retry)" deployment without a checksum, so it failed on every agent.
Retry now goes through POST .../deployments/{id}/retry and retries IN PLACE:
the same deployment record flips to in_progress and failed targets reset to
pending, instead of accumulating duplicate clone records.

- retry route self-heals legacy deployments: computes the sha256 from the
  installer url, pins it on the doc, stamps it into every re-issued command;
  a compute failure returns the error instead of queuing commands agents
  would refuse
- retry route accepts an optional `machines` filter (validated) so a single
  failed target can be retried on its own
- per-target retry icon on failed rows (spinner + disabled while in flight),
  backed by the new useDeployments.retryDeployment(deploymentId, machineIds?)
- silent flags: single-line Input -> auto-growing Textarea in both the deploy
  and system-preset dialogs; typed/pasted newlines collapse to spaces so the
  stored value stays a valid single command line
- fix invisible "in progress" status pill (only status with no color mapped,
  fell through to a transparent default) -> cyan, matching its spinner icon
- tests: 5 retry-route cases (machines filter, self-heal, stored-checksum
  passthrough, compute failure, malformed filter); retry e2e spec rewritten
  for the in-place contract + a per-row-icon test; api-sprint retry seed
  pinned so it stays network-free

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A prospective customer reached owlette.app/register from LinkedIn's iOS
in-app browser, tapped "continue with Google", and got auth/popup-blocked
(Sentry OWLETTE-WEB-45) — surfaced as two stacked toasts both reading
"An error occurred. Please try again", then nothing. Signup lost.

Federated Google sign-in cannot work in an embedded webview and no
configuration changes that: iOS WKWebView returns null from window.open()
unless the HOST app opts in, and Google's OAuth endpoint independently
rejects embedded user-agents (disallowed_useragent). signInWithRedirect is
not a fallback — it would turn a loud error into a silent hang, since our
authDomain is *.firebaseapp.com while we serve from owlette.app, the exact
cross-origin case WebKit storage partitioning breaks.

So the fix is UI-side:

- detect the host app (inapp-spy, MIT, zero deps) and pre-empt the Google
  button with inline remediation before a tap is wasted on it
- keep a reactive branch on auth/popup-blocked for webviews the UA doesn't
  identify, and for ordinary browsers with a popup blocker
- force-expand the email fallback the notice points at, rather than leaving
  it collapsed behind a focus gesture
- offer a best-effort escape (x-safari:// on iOS, intent:// on Android) plus
  copy-link, always alongside manual "open in browser" instructions — the
  iOS scheme is undocumented and already dead in several host apps, so it is
  a shortcut, never the mechanism
- stop the double toast: AuthContext now rethrows these codes silently so
  the page owns the messaging
- preserve the Sentry signal with the raw user-agent attached; the parsed
  browser family collapses every unrecognised iOS webview to one label that
  does not name the host app, so this was previously unmeasurable

Also hides the passkey button inside a detected webview: the ceremony can
only use passkeys for the HOST app's associated domain, so
browserSupportsWebAuthn() reports true there while sign-in cannot succeed.

Three defects found by adversarial review and fixed here:

- an iOS Home Screen web app drops the Version/ and Safari/ tokens exactly
  as a webview does, so inapp-spy's catch-all iOS rule matched our own
  installed PWA — it would have been told "google sign-in doesn't work in
  this in-app browser" inside its own window and lost its passkey button.
  Guarded on navigator.standalone, and only when no host app was named so a
  positively identified LinkedIn is still detected. Deliberately NOT
  display-mode:standalone — chrome-less webviews match that media query.
- the "open in browser" button was rendered on the popup-blocked path in
  ordinary browsers, where it resolved to a same-URL reload that dropped the
  notice and returned the user to the button that had just failed. Escape
  affordances and the ••• instructions are now gated on actually being in a
  host app.
- copyToClipboard could orphan a focusable off-screen textarea if the legacy
  execCommand path threw; removal moved to a finally.

Verified in an isolated worktree: tsc clean, eslint clean, 197 suites /
3647 tests pass, production build exit 0. Detection asserted against the
verbatim production user-agent plus real-Safari, desktop, Android and
standalone-PWA negatives.

Known open: Turnstile gates the email fallback we now promote. Whether it
renders inside LinkedIn's webview is unverified and needs a device test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported as "google sign-in is stuck on dev": authenticate in the popup,
end up back at /login?redirect=%2Fdashboard, nothing else happens.

Google OAuth signs up and signs in through the same popup, so the sign-in
had actually SUCCEEDED — a user who already had an account was simply
logged in. /register then pushed to /dashboard the instant the popup
resolved, with no wait for the __session cookie that AuthContext mints
asynchronously from onAuthStateChanged and does not await. proxy.ts saw an
unauthenticated request and bounced to /login?redirect=%2Fdashboard, which
is exactly what the user saw.

/login already had a settle-and-poll guard for this. /register never got
one — the two pages drifted, and that drift is the whole bug. So:

- lib/postSignIn.ts now owns the landing resolution and is shared by all
  four sign-in paths (login email / google / passkey, register google).
  The passkey path passes settleMs 0: its verify route mints the cookie
  before returning, so settling there is dead latency on the one flow whose
  appeal is feeling instant.
- signInWithGoogle returns { isNewUser } from Firebase's
  getAdditionalUserInfo, so /register can say "welcome back — signing you
  in" instead of congratulating people on an account it did not create. An
  absent flag is read as "returning": a generic greeting is a cheaper error
  than claiming a signup that never happened.
- auth/email-already-in-use on the email path renders inline with routes to
  sign in or reset the password, instead of a toast that expires and leaves
  the user on a form that cannot succeed. AuthContext stays quiet for that
  code (the page owns the message) but still reports it — that reporting is
  how we learned real users hit this.
- ui/inline-notice extracted so the two inline notices cannot drift the way
  the two pages did; InAppBrowserNotice moved onto it.

This also fixes brand-new Google signups, which were racing the same cookie
and which nobody had noticed. The comment claiming "AuthContext will
redirect to /setup-2fa for new users" was stale — dashboard/page.tsx does
it, so a new user has to reach /dashboard first, and losing the race sent
them to /login instead. The email signup path had the same race against
proxy-protected /setup-2fa (already worked around in e2e as a known flake)
and is now resolved too.

Verified in an isolated worktree: tsc clean, eslint clean, 199 suites /
3660 tests pass, production build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OWLETTE-WEB-46: a real user signed up, reached the mandatory 2FA step, hit
"cancel", and landed on /register — the form they had just submitted. Being
authenticated but staring at a blank signup form, they concluded the account
had not been created, filled it all in again, and got
auth/email-already-in-use with nowhere to go. Three things had to line up:

- /setup-2fa's cancel was `router.back()`. For a brand-new signup the previous
  history entry is /register by construction, so cancel always led there.
- proxy.ts does bounce an authenticated user off /register, but only on a
  server request. A client-side history pop replays the page out of the App
  Router cache with no round trip, so the proxy is never consulted — the
  breadcrumbs show no /register RSC fetch after the back navigation.
- /register had no client-side guard of its own, so it rendered the form.

So:

- /setup-2fa cancel now picks a destination instead of guessing from history.
  When setup is mandatory (`requiresMfaSetup`, set at bootstrap for every new
  signup) /dashboard is a loop, not an exit — dashboard/page.tsx pushes
  straight back — so the honest way out is ending the session, and the button
  says "sign out" rather than pretending otherwise. A voluntary visit from
  account settings goes to /dashboard, matching what "finish" already does.
  The page's unauthenticated effect now replaces rather than pushes: leaving
  /setup-2fa one back-press away is the same trap in miniature.
- hooks/useRedirectIfAuthenticated mirrors the proxy rule client-side, shared
  by /login and /register so the two cannot drift again. It skips while the
  page is itself signing someone in — signUp() populates `user` before the
  handler has resolved where to send them, and the guard would otherwise beat
  the /setup-2fa push. Latched in a ref, not `loading`, which clears in the
  handlers' `finally` while the push is still in flight.
- On /login the guard reads the `redirect` param during render rather than the
  state synced by an effect: effects flush in declaration order, so the state
  version fires once against the '/dashboard' default and a signed-in visitor
  to /login?redirect=%2Froost would land on the dashboard instead. Param
  validation is now one `safeRedirect` helper shared by both readers.

Verified the two "does not pre-empt" tests are not vacuous by removing the
skip: both fail with spurious replace() calls, then pass once restored.
eslint clean, tsc --noEmit clean, 201 suites / 3672 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clears three Dependabot advisories against the agent's pinned cryptography
44.x: CVE-2026-69247 (PKCS#7 EnvelopedData Bleichenbacher oracle),
CVE-2026-69249 (exponential cert path-building DoS), and CVE-2026-69248
(wildcard DNS escaping permittedSubtrees).

None are reachable from owlette. The agent's entire cryptography surface is
Fernet (secure_storage.py) plus hashes/AESGCM/HKDF (auth_manager.py); it
never calls the PKCS#7 decryption or x509.verification routines the
advisories cover, and no transitive dependency does either.

Verified before landing:
- cp311-abi3-win_amd64 wheel exists; full requirements set resolves clean
- Fernet, HKDF and AESGCM round-trip bidirectionally between 44.0.3 and
  50.0.0, so existing .tokens.enc files keep decrypting and rollback is safe
- secure_storage and auth_manager import and exercise correctly on the
  bundled 3.11 interpreter with the Rust/PyO3 bindings initialized

The wheel also moves its statically-linked OpenSSL 3.4.1 -> 4.0.1. CPython's
ssl module links its own OpenSSL independently, so TLS for requests/urllib3
is unaffected.

requirements.txt is a build-time input, so 2.12.19 must be rebuilt for this
to reach the fleet; the existing dev artifact still bundles 44.0.3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cryptography, claude-agent-sdk and sentry-sdk were the only entries in
requirements.txt declared as ranges rather than exact versions, so every
installer build re-resolved them against whatever PyPI had that day. Two
builds of the same commit could ship different binaries with no record of
which was which, which is why the 2.12.19 rebuild could not be diffed
against the artifact already on dev.

Pinned to exactly what the verified build resolved. The rebuilt payload is
byte-for-byte identical in package set to the pre-pin build (79 packages),
so this freezes the tested state rather than changing it.

Also documents why claude-agent-sdk sits at 0.1.69 while 0.2.132 is current:
0.2.x requires mcp>=1.23.0, and every mcp from 1.23.0 on requires
pywin32>=310, which our pywin32==306 pin forbids. Under the old range pip
silently backtracked 63 releases and reported success; the pin makes that
visible instead. Raising pywin32 is what unblocks the upgrade.

This is a partial fix. 55 transitive dependencies remain unpinned, so builds
are still not fully reproducible; a generated lockfile is the real remedy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The embedded interpreter's python311._pth enables `import site` so that pip
works, which also puts this machine's user site-packages on sys.path for
every python and pip call in the build. The build machine's unrelated
packages therefore participate in dependency resolution: the 2.12.19 build
log reported conflicts against fastapi, gradio and huggingface-hub, none of
which ship in the installer, and `pip list` against the built tree described
160 packages when the real payload is 79.

Setting PYTHONNOUSERSITE=1 for the whole script drops the user-site entry
from sys.path (verified: 160 visible packages -> 77). build_installer_quick
needs no change; it reuses the tree rather than invoking pip.

Also corrects the 2.12.19 changelog date in both changelogs. The entry was
dated 2026-07-15, when the work was finished, but the release did not
actually happen until today - the installer API recorded it at
2026-08-07T15:24:22Z. Keep a Changelog dates entries by release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hero led with "attention is all you need" — a transformer-paper riff
that read as brand voice but never named a problem the visitor has. The
subhead beneath it was a rotating verb/noun animation that listed
features rather than stating a value.

- hero h1 -> "never miss a beat"; static subhead -> "owlette keeps your
  installations running 24/7". "installations" is the operator's own noun
  for exhibits, signage, kiosks and show rigs, and naming owlette gives
  the sentence a subject instead of leaving a dangling participle.
- TAGLINE -> "keep your installation running": a standalone brand line for
  the login card and /for-ai.json, which render it with no subhead nearby
  to supply context.
- page/OG/Twitter titles -> "owlette — keep every windows machine
  running", carrying the search keywords the h1 gives up. Descriptions
  realigned to SUMMARY so the hero, search results, /llms.txt and
  /for-ai.json all open on the same clause.
- specs retargeted to the new copy; the rotator's reserved subhead height
  is gone, so the hero no longer holds 3em of empty space.

RotatingWord is now unreferenced. Left in place pending a decision on
whether it returns for per-load headline rotation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ch devices

Touch support and reduced-motion were read in lazy useState initializers
guarded with `typeof window`. A lazy initializer runs during the client's
FIRST render — which is the hydration render — so on any touch device it
returned true where the server had returned false. The component then
rendered its static branch against server HTML containing the animated
branch, and React discarded and re-rendered the subtree (error #418) on
every mobile visit to the landing page. Desktop was unaffected.

Both capabilities now come from useSyncExternalStore with a
getServerSnapshot of false. That snapshot is used for SSR *and* for
hydration, so the first client pass matches the HTML exactly and React
re-renders with the real values only once hydration has committed —
without the post-mount setState the original comment was avoiding.

Reduced-motion additionally subscribes to matchMedia's change event, so
toggling the OS setting now takes effect live rather than only on reload.

Verified in-browser: 320x568 and 390x844 with touch emulation both go
from 1 console error to 0 and still render the static branch; 1280x900
stays on the animated mouse-following branch, also clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One headline can only say one thing, and repeat visitors saw the same
line every time. The hero now rolls one of nine curated phrases per
request — "never miss a beat", "always watching", "never blink", "eyes on
everything", "no blind spots", "it doesn't sleep", "awake at 3am", "the
night shift", "always the first to know".

The phrase is chosen in the page's server component and passed to
HeroSection as a prop. Choosing inside HeroSection would roll once on the
server and again during hydration, giving React two different strings for
the same node; page.tsx already awaits headers(), so it renders per
request and the phrase genuinely re-rolls rather than freezing into a
prerendered page. Typed as HeroHeadline, so only a curated phrase
compiles.

lib/heroHeadlines.ts is the single source of truth, imported by both the
component and e2e/helpers/landing.ts, which builds the specs' match
pattern from it — a hand-copied list in the specs would silently stop
covering phrases added later.

The hardcoded `<br className="sm:hidden" />` is replaced by text-balance:
it was tuned to break "never miss / a beat" and would have split most of
the other phrases in the wrong place.

Scope is the <h1> only. TAGLINE, the page title, and the OG tags stay
fixed so the machine-readable surfaces don't churn.

Verified across 320/360/390/430 with touch emulation: no headline or
document overflow for any of the nine phrases, and server-rendered HTML
matched the hydrated DOM 6/6 across separate requests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment added in 8dd3db6 was wrong in two ways, and both understated the
problem. A 12-way review caught it; the corrected facts are verified against
PyPI metadata directly:

- The pywin32 boundary is mcp 1.11.0, not 1.23.0. Every mcp from 1.11.0 on
  requires pywin32>=310, so pywin32==306 freezes mcp at 1.10.1 - the last
  release with no pywin32 dependency - not somewhere in the 1.22.x range.
- The binding constraint appears at claude-agent-sdk 0.1.70 (mcp>=1.19.0),
  not at 0.2.x (mcp>=1.23.0). 0.1.70 is already blocked; this was never a
  0.2.x-only problem.

Also records two facts a future reader needs before acting on it: pywin32
exactly 310 is sufficient (311 is only required by mcp 2.0.0, which
claude-agent-sdk 0.2.132 caps out of range), and the upgrade adds roughly
42 MB to the installer because the SDK vendors the Claude CLI - 0.1.69
carries CLI 2.1.121, 0.2.132 carries 2.1.224.

Comment-only change; the pins and the resolved dependency set are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
} catch (error) {
// Fail open — see the module header.
console.error(
`[webhooks] billing lookup failed for site ${siteId}; delivering anyway:`,
} catch (error) {
// Fail open — see the module header.
console.error(
`[webhooks] billing lookup failed for site ${siteId}; delivering anyway:`,
Comment thread web/lib/webhookSender.server.ts Outdated
@vercel

vercel Bot commented Aug 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
owlette Ignored Ignored Aug 8, 2026 10:43pm

dylanroscover and others added 4 commits August 8, 2026 06:43
…ss real

Two defects in step 5, both of which reached every installed machine.

--ignore-installed skipped pip's uninstall step, so the pinned versions were
written over get-pip's bootstrap while the bootstrap's .dist-info survived.
The 2.12.19 payload shipped two setuptools dist-infos (75.6.0 + 83.0.0) and
two packaging (23.1 + 26.3), with 36 files present on disk that belong only
to the newer RECORD. importlib.metadata then resolved by directory scan
order, so a scanner on a fleet machine could read the newer, patched version
while the older, vulnerable code was what actually ran - a false negative in
exactly the "operators who run vulnerability scanners" case 2.12.19's
changelog invokes. The flag was pure downside: step 1 wipes build/ every run,
so there was never an installed tree to ignore.

Verified: payload goes from 79 dist-info dirs / 77 unique to 77 / 77, with
no duplicates, and the build log now shows "Successfully uninstalled
packaging-26.3" where it previously orphaned it.

Second, GPUtil has never published a wheel, so --only-binary=:all: failed on
every build and the "retry with source builds" fallback was the real install
- meaning arbitrary source builds were permitted for all 77 packages and the
warning had become background noise. Naming GPUtil as the single --no-binary
exception makes the pass succeed first time and restores the guarantee for
the other 76: a missing wheel now fails the build instead of widening it.

pip check still reports one genuine conflict (wheel 0.47.0 wants
packaging>=24.0 against our packaging==23.1) and is still swallowed as
non-fatal. Addressed separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…urllib3

Removes google-auth, google-api-python-client and google-auth-oauthlib. They
are leftovers from the firebase-admin removal: no agent code imports google.*
anywhere in the repo, nothing else declares a dependency on them (the only
match across our tree is sentry-sdk's google-genai, under an extra we do not
install), and the only remaining references are stale PyInstaller
hiddenimports that also name firebase_admin - a package this repo explicitly
forbids. They were dependency roots, so dropping three lines drops 19
packages from the installer.

The consequence that matters most is transitive: google-auth 2.22.0 carried
`urllib3 (<2.0)` and was the sole constraint holding the agent on urllib3
1.26.20 - the final 1.26.x release, from 2024, effectively end-of-line.
Removing it moves the HTTP transport to 2.7.0. That was a bigger real-world
exposure than any of the advisories below, and it was invisible because
nothing reports a dependency that is merely held back.

Dependency bumps, all verified unreachable in this codebase before bumping -
these are hygiene, not incident response:
- setuptools 75.6.0 -> 83.0.0: CVE-2025-47273 (PackageIndex.download path
  traversal) and the MANIFEST.in sdist bypass. Neither reachable - nothing
  invokes package_index and the agent never builds an sdist. Note 83.0.0 no
  longer ships pkg_resources (removed in 82.0.0); the only consumer in the
  payload is sentry_sdk's <py3.8 fallback, which is doubly guarded and dead
  on 3.11.
- requests 2.31.0 -> 2.34.2: the verify=False pool-reuse, .netrc leak and
  extract_zipped_paths advisories. None reachable - no verify= in the agent,
  no .netrc on a LocalSystem profile, and extract_zipped_paths short-circuits
  because certifi's bundle is a real file. 2.34.0 also fixes greedy no_proxy
  matching and URI path slash-stripping, which do affect a fleet agent behind
  a corporate proxy using signed URLs.
- packaging 23.1 -> 24.2: not a CVE. It was the standing conflict against the
  wheel that get-pip bootstraps, and clearing it is what lets pip check gate.
  Nothing constrained it below 24 - customtkinter is the only dependent and
  declares no version.

With the tree clean, pip check is now fatal instead of a swallowed WARNING.
It had been non-fatal for as long as that conflict existed, which meant a
conflict introduced by any future bump would have shipped silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Version sync plus the 2.12.20 changelog entry in both changelogs, landed
before the installer build per the release order in .claude/CLAUDE.md - the
build bakes the version into the exe filename and binary.

Covers the two dependency commits on dev: the build fix that stops shipping
orphaned dist-info, and the dependency wave that drops the unused google
stack, clears the setuptools/requests advisories, and unpins urllib3.

Copy is deliberately outcome-first rather than a version table. Operators
care that a scanner against their machine now reports one accurate version
per library and that the HTTP layer is maintained again; the package
numbers are in the commits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two errors in the entry, both found by installing the build on a real machine
rather than reasoning about it.

The count was wrong: 17 packages are removed, not 19. The 19 came from
counting tokens in a pip --dry-run line; 17 is the difference between the two
installed trees (77 unique packages before, 60 after). The commit message on
a97defb carries the same wrong figure and is left as-is rather than rewritten,
since it is already part of the history this commit corrects.

The scanner claim was worse, because it was backwards for most machines. The
entry said this release lets operators scanning their own machines "see a
clean bill". That holds only for a fresh install. Installing over an existing
agent replaces files but deletes nothing, so an upgraded machine keeps every
previous version's metadata: measured here, dist-info directories went from
176 to 189 across one upgrade, and importlib.metadata then reported urllib3
1.26.20 / requests 2.31.0 / cryptography 41.0.7 while the code actually
running was urllib3 2.7.0 / requests 2.34.2 / cryptography 50.0.0. A scanner
on that machine reports vulnerable versions that are not what executes.

The entry now claims only what a fresh install delivers and documents the
upgrade limitation, including that uninstalling first preserves config,
tokens and logs. The underlying defect - upgrade never clears {app}\python -
is deliberate (the old uninstaller used to destroy user data) and needs its
own fix; this commit stops the changelog from overstating what shipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants