Skip to content

cloud-v0.1.3

Choose a tag to compare

@github-actions github-actions released this 29 Aug 04:20
· 14 commits to main since this release
a98ca7c

feat(calendars): connect Google Calendar from any box address (phase 1) (#104)

  • docs: design for two-way Google Calendar sync on self-hosted boxes

Why sync only pulls today, and only from a pre-registered address: Google's
redirect URIs are exact-match, so a box at an arbitrary LAN or tunnel address
cannot complete OAuth at all, and the outbound functions in
google-sync.service.ts were never wired.

Design: one static relay page on the cloud host as the only redirect URI, a
client-side bounce (return URL kept in the browser, never sent to the relay),
and a single sync engine. Two ways in: bring-your-own Google project for
everyone, and a verified Basis client behind the paid tier, where the tunnel's
public HTTPS also makes push notifications possible.

Every constraint is cited to Google's own docs or the Home Assistant source;
three earlier claims are corrected in the doc itself. Two things are left as
spikes rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01FR1wtK6j19oYx3NFtoKGMa

  • docs: fold review findings into the Google Calendar sync design

Reviewed 378e828 against the repo. The shape holds; seven findings and the
two sign-off items are folded in.

Owner decisions (2026-08-28):

  • Option B's client config comes from a separate cloud endpoint the box calls
    on demand at connect time, not on the claim/heartbeat payload. Only boxes
    that use Google hold the secret, entitlement is checked at the moment of
    use, and the recurring contract in basis-cloud.ts stays as it is.
  • The client_secret enforcement spike is approved and goes first in phase 3 —
    a PKCE-only exchange would delete the delivery question entirely.

Findings:

  • Reserve connect before registering the redirect URI with Google. It is not
    in the cloud's reserved-subdomain list, so a customer could claim the host
    that receives every household's authorization code.
  • connect.home-basis.com needs its own Caddy site block; the wildcard sends it
    into frps today.
  • The access-log requirement is a constraint to preserve, not a change: the
    Caddyfile has no log directive at all.
  • Outbound work is discovered from row state plus journal deletes, not from
    per-route hooks. CalDAV writes and never checked isReadOnly (basis#101), and
    the REST routes write at ~20 sites with no shared helper, so per-site hooks
    would recreate that bug class. Needs external_id captured on the journal's
    delete rows.
  • remote_updated-only writes must be invisible to both calendar triggers, or
    every push churns CalDAV ETags and grows the journal.
  • Dropped the paid-tier relay fallback: it forces state to gain a structure the
    box must parse, to recover a case that already recovers by retrying.
  • Leave Outlook read-only; it has no outbound path.
  • New claim/heartbeat fields must be optional — basis-cloud.ts validates
    strictly in both directions.

Google allows two simultaneous client secrets, so rotation has an overlap
window and the refresh-token question is informational, not a blocker.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01FR1wtK6j19oYx3NFtoKGMa

  • docs: spike answered — Google enforces client_secret for Web clients

A PKCE-only authorization-code exchange against a Web application client is
rejected with invalid_request / "client_secret is missing"; the same exchange
with the secret attached succeeds. Google's "Optional" does not describe this
client type.

So there is no version of Option B where a box holds only a client id. The
on-demand client-config endpoint stands as the delivery mechanism, and the
fleet-wide-secret consequences that follow from it are real rather than
contingent.

Still open, but informational: whether a refresh token survives its issuing
secret being deleted. Two secrets can be live at once, so rotation has an
overlap window and the answer only bites at the final delete, where it is
recoverable.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01FR1wtK6j19oYx3NFtoKGMa

  • docs: implementation plans for two-way Google Calendar sync

One plan per phase, matching the spec's claim that each ships on its own.

Phase 1 (relay): reserve connect before the URI is registered with
Google, the Caddy site block, the relay's URL logic as a tested pure
module plus two static pages, the redirect-URI change at all four call
sites, the pre-flight bounce, and the Testing-consent-screen warning in
both the connect flow and the error path.

Phase 2 (engine): the schema and trigger work, echo suppression,
state-derived outbound discovery, the sweep worker, the unlock, LWW
conflicts, tighter polling, and a write-path test per path. The unlock
migration is deliberately last — unlocking before the worker exists would
be basis#101 across every household at once.

Phase 3 (Option B): owner steps for verification split out from executor
tasks, the on-demand client-config endpoint, the box side that calls it,
connect-without-a-project, and push channels. Tasks 4 and 5 are marked as
lower resolution because they depend on a client that does not exist yet.

Deviations recorded rather than papered over: the spec's Playwright relay
test is not buildable (no browser test infrastructure in this repo), so
the logic is unit-tested and the browser behaviour is checked by hand;
and the spec's "sync log" is satisfied by structured logger output, since
no such table exists and these plans do not add one.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01FR1wtK6j19oYx3NFtoKGMa

  • docs: fix six defects found reviewing the calendar sync plans

Phase 1:

  • Drop Outlook from the relay. The plan had moved its redirect URI to
    connect.home-basis.com, but nothing serves /oauth/outlook, lib.js
    forwards only to accounts.google.com, and the pre-flight was wired
    only at the Google call sites — every Outlook connect would have 404ed,
    strictly worse than the Host-derived flow it replaced. Recorded as a
    deviation; giving Outlook the relay is its own task.
  • Import /lib.js absolutely. Caddy rewrites the pages to nested paths
    while the module sits at the relay root, so './lib.js' resolved to
    /oauth/google/lib.js and 404ed. The local http.server check masked it
    by serving the pages from the root; the deploy step now checks it.

Phase 2:

  • Put the working trigger guard in the step that writes the migration,
    instead of pseudo-SQL followed by a step that replaces it. Uses
    to_jsonb(NEW), not to_jsonb(NEW.) — the . expansion is not valid in
    a PL/pgSQL expression.
  • Clamp remote_updated past updated_at on the create path as well as the
    update path, so clock skew cannot leave a just-created event looking
    locally edited.
  • Add calendars.last_outbound_at and drive the five-minute tick from it.
    The derived signal collapsed the spec's one-hour active window to a
    single sweep, since "has outstanding work" goes false as soon as the
    push succeeds.
  • Fix the write-path test's create route: events are created at
    POST /:calendarId/events, not /events.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01FR1wtK6j19oYx3NFtoKGMa

  • chore: ignore .superpowers scratch directory

Ledgers, task briefs, and review packages for subagent-driven plan
execution. Scratch state, not repository content.

  • feat(cloud): reserve connect subdomain for the OAuth relay

The relay page will live at connect.home-basis.com and become the single
registered redirect URI on the Google client. A customer holding that
hostname would receive an authorization code for every household that
connects a calendar.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01FR1wtK6j19oYx3NFtoKGMa

  • docs: CalDAV mounts at /dav, not /caldav, in the phase 2 plan

The write-paths test would have 404ed on every CalDAV case. Caught from
the baseline test run's request log; app.ts:272 registers caldavRoutes
with prefix '/dav'. Test directory paths (test/caldav/) are unaffected.

  • feat(relay): URL logic for the OAuth relay page

Pure module so the bounce logic is testable without a browser. Rejects
relative, protocol-relative and javascript: return URLs, and refuses to
forward anywhere but accounts.google.com.

  • feat(relay): static pages for the Google OAuth bounce

start.html stores the box address from the URL fragment and leaves for
Google; index.html reads it back and forwards the code to the box. The
fragment is never sent to a server, so the relay host never learns a
household's address.

  • fix(relay): scrub authorization code and fix back-button trap
  • Use location.replace() instead of assign() on both pages to avoid
    leaving the code/box address in browser history
  • Capture window.location.search once before any page mutation, and
    use the captured value for both classifyCallback and buildCallbackUrl
  • Scrub the authorization code from the address bar with
    history.replaceState on error or after success
  • Move localStorage.getItem into the try block so site storage
    access errors are caught and displayed rather than hanging the page
  • docs: amend the relay pages in the phase 1 plan

Review of Tasks 2-3 found the plan's own page code leaves the Google
authorization code live in the URL and in history on every error branch,
and location.assign leaves it in history on the success branch too.
Ruling on that surfaced a second defect: assign() also makes Back from
Google's consent screen bounce the user forward again.

Both pages now use location.replace, the error branches scrub the query
with history.replaceState, and the localStorage read moved inside the try
so blocked site storage shows a message instead of hanging the page.

  • feat(cloud): serve the OAuth relay at connect.home-basis.com

Its own site block, so the wildcard tunnel vhost does not swallow it.
Carries a comment forbidding access logs on /oauth/* — Google delivers the
authorization code as a query parameter.

Co-Authored-By: Claude Haiku 4.5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01FR1wtK6j19oYx3NFtoKGMa

  • feat(calendars): route Google OAuth through the relay

The redirect URI was built from the request Host header at both the
authorize step and the token exchange, so the flow only worked for a box
whose address was already registered in a Google console. Both sites now
use the one relay URI, which is the only address Google ever needs.

Outlook is untouched — the relay only forwards to accounts.google.com, so
giving Outlook the same URI would 404 every connect.

The connect response gains relayStart so the frontend can pre-flight.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01FR1wtK6j19oYx3NFtoKGMa

  • feat(calendars): pre-flight the Google connect through the relay

The browser now stops at the relay's start page on the way out, leaving
the box's own origin in the URL fragment so the relay can find its way
back after Google. Fragments are never sent to a server, so the cloud
host does not learn the address.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01FR1wtK6j19oYx3NFtoKGMa

  • feat(calendars): warn about the Testing consent screen, twice

A Google project left on Testing expires refresh tokens seven days after
consent, and the calendar stops syncing with no visible cause. Say so on
the connect screen before the household leaves for Google, and again in
the sync error when invalid_grant comes back a week later.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01FR1wtK6j19oYx3NFtoKGMa

  • fix(calendars): carry the Testing explanation through the scheduled sync path

The mapped invalid_grant explanation from describeGoogleSyncError was
being clobbered: the service stored it, then rethrew a generic error,
and calendar-sync.worker.ts recomputed the failure message from that
generic error before writing calendars.sync_error again. The scheduled
hourly sync — the actual seven-day scenario this exists for — never
showed the household the "Testing" explanation, only manual sync did.

Map the message in the worker's catch too, gated on syncProvider so
Google's consent-screen advice never lands on an Outlook failure that
happens to also look like invalid_grant.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01FR1wtK6j19oYx3NFtoKGMa

  • docs: ops runbook for the phase 1 relay deploy

The steps that need production access, in the order they must happen.
Reserving connect must be confirmed against the cloud database before the
redirect URI is registered with Google, and the Caddyfile must be
validated before it is reloaded.

Also records what was verified locally so it is not re-done, and the two
things that will surprise: Option A has no credential-entry UI (the spec
assumed one existed), and Outlook does not get the relay.

  • fix(calendars): rethrow the original refresh error so the Testing explanation survives

Round 1's worker-level gate was inert: the refresh-catch in
syncCalendarFromGoogle stored the mapped invalid_grant explanation, then
discarded the real error and threw a new generic one. That generic error
is what the worker's describeGoogleSyncError call actually received, so
it had nothing to match and the scheduled hourly sync — the real
seven-day scenario — never surfaced the explanation.

Rethrow the original error instead of replacing it. Also close a
type-vs-runtime gap the wider rethrow exposed: JSON.stringify(undefined)
is the JS value undefined, not a string, and JSON.stringify throws on a
circular object, so describeGoogleSyncError's fallback could silently
violate its own : string signature; guard it and cap the fallback's
length, since this text renders unmediated on the settings page.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01FR1wtK6j19oYx3NFtoKGMa

  • fix(cloud): stage the OAuth relay in the release tarball

The release workflow copied the server, frontend, and deploy scripts into
the tarball but never cloud/relay/, so connect.home-basis.com's Caddy root
pointed at a directory that would never exist on a deployed box. Add the
relay to the "Stage tarball contents" step alongside the rest.

Fix C1a from the phase-1 relay fix wave.

  • fix(cloud): correct the relay's Caddy root and harden its response headers

The tarball flattens cloud/server -> server/, cloud/frontend -> frontend/,
cloud/deploy -> deploy/ (per update.sh's own assertions), so the relay's
root must be /opt/basis-cloud/current/relay, not .../current/cloud/relay
— the latter never existed on a deployed box even before this wave's fix
to actually ship the relay files.

While in this block:

  • Add Cache-Control: no-cache. lib.js will change again in phase 3, and a
    stale cached copy of OAuth glue would be hard to reason about (M6).
  • Add a Content-Security-Policy scoped to what these two pages actually do:
    one same-origin module script and an inline <style> block, nothing else
    (M7). Verified with a Docker Caddy instance and a real page load — see
    the fix-wave report for the commands and output.
  • Fix two stale comments: the header now says "four sites" (there are),
    and the relay's own comment no longer claims a Basis-owned Google client
    exists — under Option A it's each household's own client, and there is
    no Basis client until phase 3 (M8).
  • docs: correct the ops runbook's deployment and test-count claims

Step 2 said "no build step, no copy step" for the relay; that was true
only because the relay wasn't shipping at all (fixed in C1a/C1b). Say what
actually happens now: it rides in the tarball and lands at
/opt/basis-cloud/current/relay.

Step 3 was missing the Caddyfile install entirely. update.sh does not
touch /etc/caddy/Caddyfile — only provision.sh does, on first setup — so a
normal version deploy would leave the running config without the
connect.home-basis.com block, and the hostname would fall through to the
*.home-basis.com frps wildcard. Add the explicit install before validate.

Also fix the test counts, which said 66/66 for backend test/calendars +
test/caldav; it's 70/70.

Fix C1c from the phase-1 relay fix wave.

  • fix(relay): key the stashed return address by OAuth state, not a fixed name

localStorage's 'basis.return' was written by ANY top-level navigation to
/oauth/google/start#return=...&to=..., from anywhere — not just a
navigation the box itself initiated. An attacker who opens a popup to that
URL while a victim is on Google's consent screen can overwrite the entry,
and the victim's code/state then forward to the attacker's host on
return. The client secret still gates what the code is worth, but this is
exactly the threat the relay design was built to prevent, and phase 3 puts
one Basis-owned client behind every household.

Fix: key the localStorage entry by the OAuth state the box generated and
holds in Redis, rather than a fixed key. An attacker can't guess a
victim's state, so a poisoned entry written under a different state is
never the one a legitimate callback reads back.

  • lib.js: add stateFromAuthUrl(to) and returnStorageKey(state), both
    tested directly (no DOM) like the rest of the module.
  • start.html: derive state from the validated to, store under
    returnStorageKey(state).
  • index.html: read state from the callback's own query string, fail
    readable if it's missing, look up/remove returnStorageKey(state)
    instead of the fixed key. Also clears the keyed entry on the
    access_denied path, since Google echoes state there too — no reason to
    leave a legitimate box's address sitting in storage longer than needed.

Extracted both pages' inline <script type="module"> blocks to start.js /
callback.js while touching this logic, so the upcoming CSP (M7) doesn't
need 'unsafe-inline' in script-src.

Note: this leaves poisoned entries under attacker-chosen state values
sitting in localStorage indefinitely. That's acceptable — they're never
read, since nothing legitimate will ever present that state — but worth
being explicit about rather than silent.

Fix I1 from the phase-1 relay fix wave. New tests in
cloud/server/test/relay-url.test.ts (cloud/server suite: 72 -> 77).

  • fix(backend): pin the referrer policy to no-referrer

The "cloud never learns the box address" property of the OAuth relay
currently holds partly because @fastify/helmet defaults to
Referrer-Policy: no-referrer. Nothing records that dependency. If that
default ever changed — or someone set strict-origin-when-cross-origin —
the browser would send a plain Referer on the HTTP->HTTPS navigation to
connect.home-basis.com (that's an upgrade, not a downgrade, so the origin
IS sent), leaking every household's LAN address to the cloud host.

Set it explicitly so a helmet upgrade can't silently reopen this.

Fix I2 from the phase-1 relay fix wave.

  • fix(backend): stop logging OAuth authorization codes in the request log

The spec's constraint on keeping query strings out of access logs ("the
box, whose callback also receives ?code=") was applied to the Caddy side
but not the box's own logging. The global onResponse hook logged
request.url verbatim, so every OAuth callback wrote
...?code=4/0Ab...&state=... to the backend logs at info level.

Strip the query string generally rather than special-casing the OAuth
route, since any future route could put a secret in a query parameter.
Error-path logging elsewhere (error.middleware.ts) is unchanged, and
requestId is still logged.

Fix I3 from the phase-1 relay fix wave.

  • docs(calendars): fix stale comment about where syncError renders verbatim

The comment near MAX_RAW_ERROR_LENGTH said CalendarSettingsPage renders
calendar.syncError verbatim. It doesn't — it splits on '|count:'.
CalendarSyncSettings was the actual verbatim site, and this wave's M3 fix
makes it split too, so after this branch no renderer shows the raw
'|count:N' sentinel — but the cap is still needed, since the text before
the split is unbounded raw error content in a text column and a websocket
payload.

Fix M4 from the phase-1 relay fix wave.

  • fix(calendars): frontend polish for the Google relay connect flow (M2, M3, M5)

Three small findings from the fix-wave review, bundled since they touch
the same connect-flow code:

  • M2: the "set your consent screen to production" Alert in
    CalendarSettingsPage sat above the whole Add Calendar button row and
    showed even when Google wasn't configured, so a household importing an
    ICS file or connecting only Outlook saw Google Cloud console advice for
    an integration they can't use. Gated it on the existing
    isGoogleConfigured flag rather than relocating it next to the Google
    button — the layout change for the latter was more invasive than this
    MINOR finding warranted, and hiding the advice entirely when Google
    isn't configured addresses the worse case.

  • M3: CalendarSyncSettings rendered calendar.syncError verbatim, including
    the internal '|count:1' sentinel, while CalendarSettingsPage and
    CalendarSidebar both already split on '|count:' before display. Made
    CalendarSyncSettings consistent with them.

  • M5: relayHandoffUrl(data.relayStart, data.authUrl) was called with no
    guard at both call sites. A box whose backend predates relayStart in the
    connect response (frontend deployed ahead of backend) would send the
    browser to undefined#return=.... relayHandoffUrl now throws a readable
    error when either value is missing, and both call sites catch it and
    surface the existing "Connection Failed" toast instead of navigating
    anywhere.

Fixes M2, M3, M5 from the phase-1 relay fix wave.

  • docs: runbook follow-up — this wave's own changes went stale in it too

Two more corrections in the same file, caused by this fix wave itself:

  • The cloud/server test count still said 72/72; this wave's I1 tests
    bring it to 77/77.
  • Step 3's curl checks covered /lib.js but not the two new glue files
    (start.js, callback.js) the I1/M7 work split out of the pages' inline
    scripts. They carry the exact same absolute-import 404 risk lib.js
    does, so an operator following the runbook as written could pass
    verification while the pages silently do nothing.
  • docs: runbook verification section was stale after the fix wave

The state-keyed storage check and the CSP/extracted-script verification
happened after this section was written, and the asset list predated
start.js/callback.js existing. Test counts updated to the full suite.

  • docs: ops Step 1 run and clear; correct its risk framing

No tenant holds connect/connects/oauth-relay on the production cloud.

Also corrects what the step protects against: the relay's Caddy block is a
literal hostname and beats the wildcard, so after Step 3 the hostname
serves the relay regardless of who owns the tenant row. The risk is taking
a customer's hostname and breaking their box, not codes being stolen —
codes only go astray if Step 4 jumps ahead of Step 3.

  • docs: runbook Step 2 assumed a release that does not exist

Production is on cloud-v0.1.2 from July; the relay only ships in the
tarball because of the staging fix on this branch. 'Normal repo -> deploy'
would have redeployed 0.1.2 and changed nothing.

Step 2 now states the actual chain: merge, tag cloud-v0.1.3 to trigger the
release workflow, deploy that version, and verify the relay directory
landed before touching Caddy.


Co-authored-by: dashboard-bot dashboard-bot@local
Co-authored-by: Claude Opus 5 (1M context) noreply@anthropic.com

Deploy

On the provisioned VPS (see cloud/README.md for first-time provisioning):

sudo bash /opt/basis-cloud/current/deploy/update.sh --version cloud-v0.1.3

update.sh downloads this tarball, verifies its .sha256, migrates,
smoke-tests, swaps the current symlink, and auto-rolls-back if the
new version doesn't become healthy.