Profile
Back to NewsBack
GitHub Trending 27 min
Reader Mode
swyxio/swyxdotio: This is the repo for swyx's blog - Blog content is created in github issues, then posted on swyx.io as blog pages! Comment/watch to follow along my blog within GitHub

swyxio/swyxdotio: This is the repo for swyx's blog - Blog content is created in github issues, then posted on swyx.io as blog pages! Comment/watch to follow along my blog within GitHub

6 hours ago

swyx's personal site

swyx's personal site, using:

  • SvelteKit 2 + Svelte 5
  • Tailwind 3 + Tailwind Typography
  • marked + shiki for markdown rendering (replaced mdsvex/remark)
  • Cloudflare Workers with Static Assets (hybrid: prerendered static pages + on-demand SSR posts, edge-cached)
  • GitHub Issues as CMS
If you want to make a site based on this, see https://github.com/swyxio/swyxkit for a cleaner starter template

Architecture / rendering

  • Static (prerendered at build): /about, /portfolio, /subscribe.
  • Dynamic + edge-cached: /, /ideas, /podcasts, /[slug], /rss.xml, /sitemap.xml,
/og/, and selected /api/ routes. Rendered on demand on Cloudflare Workers and stored in the Cache API via src/hooks.server.js until s-maxage expires, so new posts appear without a rebuild and serving is O(1). Versioned OG responses and public read-count GETs retain their public cache headers; most other cached dynamic responses are returned to the browser as private, no-store to avoid an additional cache layer outside the Worker.
  • Durable content manifest: the Worker reads the parsed GitHub Issues CMS data from the
CONTENT_MANIFEST KV namespace. GitHub is only queried to bootstrap an empty namespace or refresh it after a webhook, so ordinary cache misses do not depend on GitHub availability.
  • Compact ideas list: /api/listContent.json omits article bodies for the default /ideas,
RSS, and sitemap paths. Full-body search remains available through /api/searchContent.json, which the browser downloads only after a reader uses the search box.
  • Instant publishing: a GitHub Issues webhook hits /api/revalidate, which verifies the
signature, refreshes the KV manifest, and rolls a KV-backed cache generation. Cache keys also include the Worker version, so both publishes and deploys bypass older edge entries.
  • Owned social cards: every public HTML page points at a versioned, 1200×630 PNG generated by
the same Worker under /og/page/ or /og/article/. Article cards are resolved from the trusted content manifest and can incorporate an explicit frontmatter image; query parameters never supply arbitrary card content.
  • Approximate read counts: public pages use a 0.5% engaged-read sample backed by D1. Each
accepted sample adds the server-owned weight of 200, and a best-effort copy is sent to GA4 via Measurement Protocol. Selected older articles also have a static, explicitly approximate historical estimate that is added only when returning the public count. A separate pathless, aggregate-only funnel distinguishes eligible article attempts, visible dwell, depth, sampling, and accepted D1 writes without storing article keys or reader identifiers.
  • Ephemeral live readers: public pages optionally join a page-scoped, hibernating Durable
Object room. Readers exchange only short-lived country, position, mode, reaction, and fixed share celebration frames. There is no identity, history, free-form chat, or per-reader presence database; D1 stores only aggregate hourly abuse and capacity counters.
  • Runtime-fault attribution: a dedicated Tail Worker ignores lifecycle churn and stores only
hourly coarse route, outcome, and duration enums for failed main-Worker invocations. It never retains URLs, headers, trace payloads, exceptions, or identity data.

Environment variables (Cloudflare Workers)

What each variable does

| Variable | Required? | What it does | | ------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | GH_TOKEN | Yes | A GitHub Personal Access Token used to authenticate calls to the GitHub Issues API (the CMS). Without it, requests are unauthenticated and capped at 60/hr, which the site blows through quickly and starts failing. With it, the limit is 5000/hr. Read at runtime via $env/dynamic/private (Cloudflare platform.env) and at build time for the prerendered pages — so it must be set in both the runtime secrets and the build environment. | | GH_WEBHOOK_SECRET | Recommended | A shared secret used to verify (HMAC SHA‑256) that incoming requests to /api/revalidate actually came from your GitHub webhook. This enables fast publishing: editing an Issue refreshes the KV manifest and rolls the cache generation instead of waiting for the s-maxage TTL. If unset, /api/revalidate returns 500 and you fall back to TTL-based freshness. | | GA4_MEASUREMENT_ID | Recommended | Public GA4 stream identifier used only by the server-side read-event mirror. Production currently uses G-TW6GTQ9Q4N and declares it as a non-secret [vars] value in wrangler.toml. | | GA4_API_SECRET | Production | Secret for the GA4 Measurement Protocol stream. It is sent only from the Worker and must never be committed, placed in a URL in source code, or exposed to the browser. The application treats it as optional so D1 counting survives a GA outage, but wrangler.toml requires it for production deployment. | | GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | Yes | Google web OAuth client credentials for tools sign-in. | | TOOLS_SESSION_SECRET | Yes | Random signing key of at least 32 bytes; rotation invalidates all tools sessions. | | TOOLS_OWNER_GOOGLE_SUB | Yes | Exact immutable Google account ID of the site owner, never an email or a first-login grant. | | GOOGLE_REDIRECT_URI | Yes | https://swyx.io/tools/auth/google/callback; localhost override for local development. | | PRESENCE_ENABLED | Recommended | Server-side emergency kill switch for new live-reader sockets. Set to false and deploy the main Worker to reject presence while leaving every page usable. | | PUBLIC_PRESENCE_ADMISSION_RATE | Recommended | Deployment-time browser admission fraction from 0 through 1. Production starts at 1; use 0.1 during a viral spike to reduce socket workload by roughly 90%. This value is public by design. |

Where to get the values

  • GH_TOKEN — GitHub → Settings → Developer settings → Personal access tokens. A classic token with public_repo (or repo for private) scope is sufficient since it only reads Issues.
  • GH_WEBHOOK_SECRET — generate any strong random string, e.g. openssl rand -hex 32. You'll paste the same value into the GitHub webhook config (below).
  • GA4_API_SECRET — Google Analytics Admin → Data streams → select the swyx.io web stream →
Measurement Protocol API secrets. The measurement ID and API secret are different values.

Set them on Cloudflare Workers

Option A — Dashboard: Cloudflare dashboard → Workers & Pages → your Worker → Settings → Variables and Secrets. Add each runtime value and encrypt secrets. GH_TOKEN must also be present in the Git-connected build environment because prerendered pages read it during builds.

Option B — Wrangler CLI:

# runtime secrets (encrypted)
npx wrangler secret put GH_TOKEN
npx wrangler secret put GH_WEBHOOK_SECRET
npx wrangler secret put GA4_API_SECRET
npx wrangler secret put GOOGLE_CLIENT_ID
npx wrangler secret put GOOGLE_CLIENT_SECRET
npx wrangler secret put TOOLS_SESSION_SECRET
npx wrangler secret put TOOLS_OWNER_GOOGLE_SUB

Each command prompts for the value. List them with npx wrangler secret list. wrangler.toml declares GH_TOKEN, both podcast-admin secrets, and GA4_API_SECRET as required so deployments warn or fail clearly instead of silently publishing incomplete production configuration. It also declares the existing CONTENT_MANIFEST KV, READ_COUNTERS D1, and PODCAST_MEDIA R2 bindings. A fork or new Cloudflare account must create those resources first and replace their IDs in wrangler.toml; Wrangler cannot recreate resources from another account's IDs.

The PRESENCE_ROOMS binding is different: it points to the separately deployed swyxdotio-presence Worker. Deploy that Worker before the main site Worker.

Local Wrangler preview reads secrets from a gitignored .dev.vars; ordinary Vite development
can also use .env. Never copy production secret values into README, .env.example, tests, or
shell history. A missing GA4_API_SECRET warning during a local build is expected when analytics
delivery is not under test.

Wire up the GitHub webhook (for instant publishing)

In your content repo: Settings → Webhooks → Add webhook:

  • Payload URL: https://swyxdotio.swyxio.workers.dev/api/revalidate
  • Content type: application/json
  • Secret: the same value as GH_WEBHOOK_SECRET
  • Events: "Let me select individual events" → check Issues only
On each Issue create/edit, the endpoint verifies the signature, refreshes the durable content manifest, derives the affected slug, and rolls the cache generation for the relevant pages (/, /ideas, /{slug}, /rss.xml, /sitemap.xml, and the list/api endpoints).

Cloudflare resources for a new environment

Create the durable resources before the first deployment, then copy the returned IDs into wrangler.toml:

npx wrangler kv namespace create CONTENT_MANIFEST
npx wrangler d1 create swyxdotio-read-counters
npx wrangler r2 bucket create swyxdotio-podcast-media

The D1 binding must be named READ_COUNTERS, the database must use migrations_dir = "migrations/read-counters", and migrations must be applied explicitly:

# Local development database
npx wrangler d1 migrations apply swyxdotio-read-counters --local

Production database

npx wrangler d1 migrations apply swyxdotio-read-counters --remote

Useful verification after migration or deployment

npx wrangler d1 execute swyxdotio-read-counters --remote \ --command "SELECT page_key, read_count, sample_count, sampling_policy_version FROM page_reads ORDER BY updated_at DESC LIMIT 20"

Migration 0002_add_sampling_metadata.sql contains tracked ALTER TABLE statements. Let Wrangler's migration ledger apply it once; do not copy and execute those statements by hand or rerun the SQL outside the migration command.

Do not seed historical estimates into D1. D1 is the independently auditable post-launch sample ledger; the historical estimates are a separate static presentation layer.

Live reader presence and sharing

Presence is intentionally playful and approximate. On desktop, admitted readers see ephemeral country-labelled cursors. On mobile, the persistent representation is a flag bead on a reading progress rail; a tap or drag adds a temporary passive touch cursor without interfering with native scrolling. The only room communication is movement, one of 👋 ❤️ 💡 😂 ✨, and a fixed share sparkle. Highlighted quote text, URLs, destinations, IP addresses, and user agents never enter the Durable Object.

The browser waits until the page has been visible for two seconds before connecting. The feature is on by default and has a persistent Hide live readers preference. Hidden tabs stop sending; after 30 hidden seconds the socket closes. Rooms admit at most 32 readers, use WebSocket hibernation, and store no application data. Room IDs are resolved from the same finite public-page registry and persisted non-private article manifest used by read counts, so tools, APIs, feeds, errors, private articles, and arbitrary attacker-controlled keys cannot create rooms.

Deploy and develop

The Durable Object is an auxiliary Worker because the SvelteKit adapter owns the generated main Worker entrypoint. Its declarative SQLite export lives in wrangler.presence.toml; SQLite is used for the Durable Object class declaration only and the application performs no SQL writes.

# Local: build the SvelteKit Worker, then run both Workers together
npm run build
npm run preview:presence

Production: this order is required for the external binding

npm run deploy:presence npm run deploy

For a fast production smoke test, open the same public page in two normal browser contexts and confirm the pill changes from 1 here to 2 here. Then verify hiding persists across reloads, mobile emulation shows the reading rail, a reaction travels, a highlighted quote opens sharing, and the browser network panel contains no selected text in WebSocket frames. An overflowed room closes excess clients with 1013; malformed/abusive frames close with 1008.

Cost envelope and controls

The workload model assumes one admitted socket and an average of 12 compact incoming frames per eligible visit. Hibernated idle sockets are not billed for duration, outgoing WebSocket messages are free, and incoming messages are billed in 20-message request units. Under July 2026 Cloudflare Workers/Durable Objects paid pricing, the planning envelope is:

| Eligible views/day | Estimated total monthly workload cost | | -----------------: | ------------------------------------: | | 25,000 | ~$5.03 | | 1,000,000 | ~$18 | | 5,000,000 | ~$92 | | 10,000,000 | ~$187 |

The first row is mostly the existing $5 Workers plan. These are engineering estimates, not a bill forecast: visit length, motion, cache behavior, and other site Worker traffic can move the result. Do not add application heartbeats; they waste billable incoming messages and defeat hibernation.

Operational thresholds:

  1. Start with PUBLIC_PRESENCE_ADMISSION_RATE = "1" and PRESENCE_ENABLED = "true".
  2. If projected incremental presence cost exceeds $25/month or traffic approaches 1M eligible
views/day, set the build-time public admission value to 0.1, build, and deploy the main Worker: PUBLIC_PRESENCE_ADMISSION_RATE=0.1 npm run deploy. Keep the matching wrangler.toml value as an operational record. Existing rooms remain useful while roughly 90% of browsers avoid opening a socket at all.
  1. For emergency shutdown, set PRESENCE_ENABLED = "false", rebuild with public admission 0,
then deploy the main Worker. Pages, read counts, selection sharing, and local confetti continue to work.
  1. Monitor only aggregate Worker/DO requests, active duration, room-full, malformed-frame, and
rate-limit counts. Never add logs containing peer IDs, countries, coordinates, selections, or share destinations. Presence anomalies flush to D1 only at power-of-two checkpoints, bounding write amplification while keeping counts conservative between checkpoints.

Pricing references: Durable Objects pricing, WebSocket hibernation, and multi-Worker local development.

Open Graph image system

The site owns its social images rather than depending on Tailgraph or another hosted renderer:

  • @ethercorps/sveltekit-og and its Vite plugin bundle Satori/resvg WASM for Cloudflare Workers.
  • src/lib/social-meta.js is the registry for the six public page cards and the shared metadata
contract. Change OG_DESIGN_VERSION whenever a visual change should invalidate social caches.
  • src/lib/og/ contains card inputs, templates, rendering, committed open-licensed fonts, guarded
explicit-image fetching, and the static total-failure fallback.
  • The committed fonts are Newsreader Semibold, Noto Sans Regular, and Caveat Semibold. The card
template intentionally uses deterministic raw HTML/flex layout compatible with Satori; avoid introducing browser-only CSS or null template children without renderer tests.
  • /og/page/[key].png?v= serves home, About, Ideas, Podcasts, Portfolio, and
Subscribe cards.
  • /og/article/[slug].png?v=- resolves only public articles from the
persisted content manifest. Unknown, private, and malformed slugs return 404.
  • Article image/cover_image values may enhance the template. Only HTTPS JPEG, PNG, and WebP
inputs up to 4 MB, two megapixels, and 2048 pixels per edge are accepted. A 2.5-second deadline covers both headers and the streamed body. A bad or oversized image falls back to the no-image card rather than failing the request.
  • Generated PNGs are 1200×630, capped below 5 MB, and cached for one year as immutable. A complete
rendering failure returns src/lib/og/assets/notebook-fallback.png with X-OG-Fallback: 1 and a 60-second cache TTL so a transient failure does not poison the card for a year.
  • HEAD validates the public card without fetching its cover or rasterizing a PNG. OG cache keys
ignore query strings because card inputs come only from the manifest/registry; Worker version and content generation still invalidate the cache. Arbitrary v values cannot bypass it.
  • Cache-miss renders emit bounded og_render start/completion/fallback records with card kind,
image-present flag, and byte count. Use Cloudflare Tail's cpuTime and wallTime for timing; the Worker's JavaScript clock can remain fixed during CPU work. Logs contain no card content or URLs.

Every public page should use src/components/SocialMeta.svelte; do not add route-local duplicate Open Graph tags. Non-article pages use og:type=website, articles use article, and all metadata must contain absolute HTTPS URLs, dimensions, MIME type, and alt text. Tools, APIs, feeds, private pages, and errors deliberately do not receive generated cards.

Useful production checks:

curl -I "https://swyx.io/og/page/home.png?v=1"
curl -I "https://swyx.io/og/article/learn-in-public.png?v=spotcheck"
curl -fsS "https://swyx.io/og/page/home.png?v=spotcheck" -o /tmp/swyx-og.png
file /tmp/swyx-og.png

Deployment version and content generation invalidate internal OG cache entries. A fresh version query refreshes downstream social caches, but does not bypass the Worker's internal cache. After deployment, also test a fresh X draft and LinkedIn Post Inspector; previously shared URLs may retain network-owned caches.

The focused unit suite validates registry coverage, metadata versioning, input rejection, image fetch bounds/timeouts, escaping, and Unicode. It does not currently rasterize and snapshot every PNG variant, so production byte/dimension checks and visual inspection remain required after OG template or font changes.

Read counts and GA4

The public counter is intentionally an order-of-magnitude estimate, not a precise analytics system. The authoritative policy constants are in src/lib/read-counter.js; methodology and historical backfill notes live in docs/read-counter.md.

Current policy (v1-p005):

  • A browser must keep the page visible for 8 seconds; articles additionally require 25% scroll
depth.
  • A browser/page pair is deduplicated in local storage for 24 hours.
  • Eligible reads are sampled at 0.5%. Only sampled clients POST; each accepted sample atomically
adds the server-owned weight of 200 and increments sample_count in D1.
  • The API validates same-origin requests, rejects obvious bots and arbitrary/private content keys,
and requires the expected sample-weight header. The client cannot choose the persisted weight.
  • Unsampled engaged readers may GET the public total. The browser remembers the displayed count for
24 hours; the API is browser-cached for 5 minutes and edge-cached for 1 hour.
  • Counts are visible by default. A reader can hide them globally from any displayed counter; the
choice is stored locally and can be reversed with the adjacent “Show view count” control. Hiding the presentation does not disable anonymous counting or change GA privacy behavior.
  • Successful D1 increments are mirrored asynchronously to GA4 as engaged_read. GA failure,
timeout, or missing configuration never affects the counter response and is never retried.
  • Global Privacy Control and Do Not Track suppress GA delivery and identifier creation. The GA
payload uses a pseudonymous numeric client/session ID, denies advertising consent, and excludes IP, user agent, referrer, location, and user properties.

The production GA property is swyx - GA4 (property 391847479, web stream 5667734629, measurement ID G-TW6GTQ9Q4N). Its event-scoped custom metric is:

  • Name: Estimated reads
  • Event parameter: read_weight
  • Unit: Standard
GA4 is a secondary reporting mirror. D1 remains authoritative because Measurement Protocol delivery is best-effort and browser privacy/network behavior introduces systematic bias beyond the normal sampling error.

A separate monthly calibration Worker compares D1 sample deltas with GA4 delivery and stores a diagnostic report. Its setup, interpretation limits, and production queries are documented in docs/read-counter.md. In particular, its current session ratio is not an independent estimate of historical traffic and must not be used to rewrite the static lifetime backfill.

Capacity and cost envelope

The 0.5% policy is deliberately bounded for an expected maximum of 10 million reads/day:

| Engaged reads/day | Sampled POSTs + D1 writes/day | Approx. daily 95% sampling error | | ----------------: | ----------------------------: | -------------------------------: | | 1,000,000 | 5,000 | ±2.77% | | 5,000,000 | 25,000 | ±1.24% | | 10,000,000 | 50,000 | ±0.87% |

This is sampling error only; blocked JavaScript, dropped requests, bots, and privacy choices can create larger systematic differences. At 10 million/day, sampled writes stay below D1 Free's 100,000 writes/day and use about 1.5 million writes/month, within the Workers Paid/D1 Paid included allowances as of July 2026.

The remaining scale risk is the public count GET, not D1: every unique browser/page/day can cause one Worker request even when the response is served from Cache API. At all-unique traffic, the incremental counter-endpoint envelope is roughly $11/month at 1M/day, $47/month at 5M/day, and $92/month at 10M/day under July 2026 Workers pricing, before other dynamic Worker traffic. If the site approaches that range, publish an hourly/daily static count snapshot through static assets or R2/custom-domain caching so readers no longer call the Worker counter endpoint. Do not increase D1 precision merely because traffic rises; rough magnitude is the product requirement.

Operational limitations

  • A successful POST does not purge an already cached GET, so another reader may see a total that is
stale for the one-hour shared TTL (or its stale-while-revalidate window). This is acceptable for an approximate counter.
  • The browser records its 24-hour dedupe marker before sending the request. A transient failed POST
can therefore suppress that browser's retry until the next day; this favors cost and duplicate resistance over perfect delivery.
  • Same-origin, user-agent, finite-key, engagement, and weight checks are abuse friction rather than
authentication. A custom client can forge browser headers, but it cannot create arbitrary D1 rows or choose a larger increment.
  • D1 failure returns 503 from the counter API while the page itself remains usable; the component
fails silently and omits the number. GA4 failure never changes a successful D1 response.
  • Sitewide sampling converges quickly at scale, but individual long-tail pages can remain noisy for
much longer. Do not present per-page totals as audited measurements.

Changing the sampling policy

Treat the rate, weight, and policy version as one migration:

  1. Change READ_SAMPLE_RATE, READ_SAMPLE_WEIGHT, and READ_SAMPLING_POLICY together.
  2. Keep the server-owned header validation and D1 write weight aligned.
  3. Update the read-counter unit tests and docs/read-counter.md.
  4. Preserve existing rows; never rewrite old sampled counts as though they used the new policy.
  5. Recalculate the capacity table and GA custom-metric interpretation before deploying.
For a new GA stream, create a Measurement Protocol API secret in GA Admin, set the public measurement ID in wrangler.toml, upload the secret with wrangler secret put GA4_API_SECRET, and create the Estimated reads custom metric above. Validate a test event with Google's debug Measurement Protocol endpoint before relying on Realtime.

Production read-counter checks:

# Public count and caching; the second request should become an edge HIT
spot="readme-spotcheck"
curl -i "https://swyx.io/api/reads/learn-in-public?spot=$spot"
curl -i "https://swyx.io/api/reads/learn-in-public?spot=$spot"

Confirm deployed secret names without printing their values

npx wrangler secret list

Avoid casual production POST tests: every accepted sample intentionally adds 200 displayed reads. If one is necessary, record the before/after D1 values and use a real public key.

Deployment checklist

npm install
node --test tests/*.test.mjs
npm run check
npm run build
npx wrangler d1 migrations apply swyxdotio-read-counters --remote
npm run deploy:presence
npm run deploy:calibration
npm run deploy:monitor
npm run deploy

After deployment:

  1. Record the Git commit and Worker Version ID. npm run deploy requires a clean Git checkout,
builds it, and stores its commit SHA in the Cloudflare version tag and message. Cacheable dynamic responses expose the same SHA as X-Swyx-Commit.
  1. Fetch representative HTML, all six page-card endpoints, an article card with and without an
explicit image, and at least one read-count endpoint using fresh query versions.
  1. Confirm PNG signatures/dimensions, Cache-Control, Content-Type, and absence of
X-OG-Fallback on normal renders.
  1. Confirm the second identical read-count GET is an edge cache hit and POST responses remain
private, no-store.
  1. Check D1 rows and GA Realtime engaged_read independently. Neither alone proves the other
system is healthy.
  1. If using the hourly monitor, verify that ops_monitor_snapshots receives a fresh row and that
presence_monitor_hourly stays aggregate-only.
  1. Keep the worktree clean and never commit .dev.vars, GA API secrets, or Cloudflare tokens.

Commands

  • npm run dev — local dev server
  • npm run build — production build (Cloudflare adapter)
  • npm run deploy — build and deploy a clean checkout with its Git commit recorded in Cloudflare
  • npm run preview — preview with wrangler dev; use this rather than plain Vite when testing
local KV, D1, R2, Cache API, or Worker bindings
  • npm run deploy:monitor — deploy the hourly read/presence monitor Worker
  • node --test tests/*.test.mjs — fast unit and contract tests, including OG and read analytics
  • node tests/markdown.test.mjs — markdown renderer regression checks
  • npm test — Playwright e2e (requires GH content)

Live URL

See https://swyx.io

  • Netlify to Cloudflare DNS cutover notes
  • https://sw-yx.js.org/ old site when learning to code.
  • You can see previous iterations of the site from 2017 here: https://www.swyx.io/rewrite-2022
  • The last version of the 2022 site was preserved at https://github.com/swyxio/swyxdotio2022
  • The 2023 site is documented at https://www.swyx.io/rewrite-2023

Google sign-in and tenant boundaries

Local development

npm run dev automatically provides a Local developer tools account on HTTP localhost, 127.0.0.1, and [::1]. No Google setup or cookie injection is needed. The synthetic local-development identity is a regular member with its own cache/storage namespace, not the production owner or your Google data. Existing valid sessions take precedence. To test the real signed-out/Google flow, restart with TOOLS_DEV_AUTH=off npm run dev.

This requires Vite's build-time DEV flag and a loopback URL. It is unavailable in production builds, deployed previews, LAN hosts, or wrangler dev running a production build; those previews use normal Google/test-fixture sessions. Local sign-in does not supply storage bindings or provider keys, and no AI request starts automatically. Vite keeps remote bindings disabled.

Production sessions

Tools follow Google OpenID Connect through openid-client with PKCE, state, nonce, issuer/audience/expiry checks and signed ID-token verification. jose signs the seven-day HTTP-only tools session. No Google access/refresh tokens are retained. There is no password-login endpoint or legacy session fallback.

  • /draw and /box permanently redirect to /tools/draw and /tools/box.
Tool URLs are grouped under /tools; browser storage and API paths are unchanged.
  • /tools/draw: every Google sub gets its own Durable Object workspace. Only the
configured owner maps to the existing personal workspace, preserving production drawings. Requests cannot supply another tenant’s namespace.
  • Browser drawing caches, library, assistant history and generation history are
account-scoped. Old unscoped device caches are retained but never automatically imported into a Google account. Guest drawings are not silently uploaded on login.
  • Drawing writes require X-Tools-User matching the signed session, so an old tab
cannot write into another account after a session switch.
  • /tools/box text has no server persistence; signed-in opens have activity metadata.
Public articles and podcast feeds remain public.
  • Cloud AI is site-funded for every signed-in account, with durable admission
limits enforced before provider calls: 20 assistant turns/hour, 5 media jobs/hour, $2/day per account and $20/day site-wide in conservative estimated reservations. These caps apply to non-owner accounts. The server-configured Google owner is exempt from account/site quotas, assistant spending caps, and generation-run caps; owner spending does not consume the shared non-owner allowance. The exemption is bound to TOOLS_OWNER_GOOGLE_SUB, never an email or client flag. Owner UI suppresses funding, logging, provider-sharing and budget notices, but activity remains logged and actual errors and data-loss warnings remain visible. These estimates are not exact provider billing caps. Failed attempts retain their reservations. Provider job IDs are bound to their originating Google account.
  • AI admission/status logs contain account ID, request ID, model, timestamp, status
and estimated reserved cost only. Retention is 30 days with durable alarm cleanup; prompts/images/tokens/provider keys are never stored in operational logs. Limits and this disclosure are shown before use. GET /tools/api/ai/usage returns only the current account's counters and policy. No ledger means no paid request.
  • Podcast publishing, archive migration and the separately hosted Reclip redirect
remain owner-only. Multi-tenant podcast hosting and the external Reclip service still need their own product/data boundaries before opening them to all accounts.

Production setup

  1. Create a dedicated Google Cloud project and an External Google Auth app.
Request only openid, email, profile. No Drive/Gmail APIs or offline access.
  1. Create a Web application client with redirect URI
https://swyx.io/tools/auth/google/callback. For local OAuth testing register a separate localhost callback and set GOOGLE_REDIRECT_URI to the exact port.
  1. Publish the Google app’s audience for general sign-in rather than relying on a
testing-user allowlist. Set the homepage to https://swyx.io/tools and privacy URL to https://swyx.io/tools/privacy; satisfy Google’s domain/brand requirements.
  1. Store the four required secrets above with Wrangler; never commit/download
credentials into the repo or put them in browser storage. Generate a fresh signing key. Verify the owner’s immutable Google sub from a validated sign-in; never grant admin based on the first user to register.
  1. Build and test before deploying. Verify a real owner login and a different
account’s independent drawing workspace, then verify denied owner endpoints. Do not deploy with incomplete OAuth credentials and strand the current owner.
  1. Old password secrets may be removed from provider configuration only after
successful cutover and the rollback window. They are no longer read by code.

Auth pages/APIs bypass shared caches, use no-store, and suppress referrer data. Local tests use signed fixture identities only on localhost, never a production login bypass. Provider-token validation is covered separately with mocked Google responses and real test RSA signatures.

Google project

The auth-only project is swyx-io-tools (project number 31511070245), owned by [email protected]; no billing account is linked. Google branding is **swyx.io Tools, audience External, client swyx.io Tools Web**. Registered callbacks are production and http://localhost:4188/tools/auth/google/callback for verification. Deploy the drawing companion first whenever the AI ledger protocol changes, then the main Worker. The companion has no public data endpoint.

Tool logs

/tools/logs is the Google-authenticated activity dashboard. Everyone can inspect their own records; only the current TOOLS_OWNER_GOOGLE_SUB can select Everyone. Owner access is usage metadata only, not permission to read private drawing assets, prompts or generated content. The notice and privacy page disclose owner visibility of account names/emails and metadata to all users.

  • Quick views isolate AI, failures, pending requests, or meaningful actions with
tool opens hidden. Daily UTC activity and tool/model/action/account breakdowns drill into matching records. Request quick views expose metadata and related filters, with 50-record cursor pages. Totals cover all filtered records, not only the loaded page.
  • AI admissions/statuses are read directly from the existing quota ledger. They
are not actual invoices; provider token totals and usage outside swyx.io are unavailable. Pending means completion has not been recorded, not success.
  • Server instrumentation records cloud drawing changes, podcast uploads, and
Reclip launches. Browser reports cover Draw/Box opens, local image operations, design insert/export, and memes. Tool records are best-effort; offline, blocked, rate-limited or unavailable recording may leave gaps. No anonymous backfill.
  • Records and inactive account-directory entries are pruned after 30 days by the
companion’s durable alarm. Browser reporting has a separate 120-events/hour per-account limit and cannot spend or alter AI quotas.
  • GET /tools/api/logs accepts days=1|7|30, kind, tool, scope=mine|all,
status (including pending), source, exact model/action, opens=hide, metadata-only q (request ID/action/tool/model), and a UTC day within the period. Owner Everyone views can also filter by opaque account ID. Query parameters are validated and account scope is enforced before every query, aggregate, or export. Quick views, summary counts, daily activity, and top-20 tool/model/action/account breakdowns drill into the same bookmarkable filters; totals are never page-only.
  • GET /tools/api/logs/export?format=csv|json accepts the same filters and requires
the current account's X-Tools-User header. Downloads include **all matching records**, not only the first 50. Views over 10,000 rows return 413 with instructions to narrow filters; no partial file is generated. CSV cells are spreadsheet-formula safe; JSON includes filters, coverage, range, count, and cost definitions. snapshot fixes the creation-time cutoff to the displayed result. Outcomes can settle after a prior read, and expired records still obey 30-day retention. Pagination before cursors bind the full filter set, account, and cutoff; they cannot be used on export routes. all is server-authorized. Browser POST accepts only {id,action,status}, requires same origin plus X-Tools-User, and gets identity/time/provenance from the server. User IDs, profile fields and arbitrary payloads are rejected.

Generation metadata stays in the same usage ledger: tools_ai_generation_observations is a one-row extension keyed by the canonical usage ID, not another job scheduler. Logs join existing run/client-job IDs, immutable hosting adapter, and provider request ID. Media views filter adapter, modality, and run; run summaries count **matching admitted jobs**, not planned/unsubmitted candidates, and never combine the same run ID across accounts. Catalog cost estimates are distinct from budget reservations and provider bills. Observed request span and wait-until-running include queue/poll delays, not GPU execution time. Missing historical estimates/timings stay null; mixed run totals show coverage rather than inventing zero costs. Cancellation requested/confirmed/unsupported and bounded failure codes are separate from request outcomes. Polls do not add admissions or move terminal timestamps. No new inference provider or billing integration is enabled. GPU, cold-start, warm-idle and actual billed cost remain unavailable. The metadata extension is created additively and pruned with the existing 30-day usage records; existing quotas and owner exemptions are unchanged.

New tool integrations must extend the bounded vocabulary in src/lib/tools-activity.js. Use recordToolActivity(userId, action, status) only for browser-reported actions, and the server activity helpers for authoritative operations. Never pass prompts, images, page/file names, URLs, cookies, or keys. The logs API and dashboard never expose another account to ordinary users, even if they supply an account ID, owner scope, or another account’s cursor.

Chat with me