swyx's personal site, using:
- SvelteKit 2 + Svelte 5
- Tailwind 3 + Tailwind Typography
marked+shikifor 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
- 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 viasrc/hooks.server.jsuntils-maxageexpires, 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 asprivate, no-storeto avoid an additional cache layer outside the Worker. - Durable content manifest: the Worker reads the parsed GitHub Issues CMS data from the
CONTENT_MANIFESTKV 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.jsonomits 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.
| 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. |
GH_TOKEN— GitHub → Settings → Developer settings → Personal access tokens. A classic token withpublic_repo(orrepofor 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 theswyx.ioweb stream → Measurement Protocol API secrets. The measurement ID and API secret are different values.
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_SUBEach 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 missingGA4_API_SECRETwarning during a local build is expected when analytics delivery is not under test.
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).
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-mediaThe 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.
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.
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
npx wrangler deploy -c wrangler.tomlFor 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.
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:
- Start with
PUBLIC_PRESENCE_ADMISSION_RATE = "1"andPRESENCE_ENABLED = "true". - 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 build && npx wrangler deploy -c wrangler.toml. Keep the matchingwrangler.tomlvalue as an operational record. Existing rooms remain useful while roughly 90% of browsers avoid opening a socket at all. - For emergency shutdown, set
PRESENCE_ENABLED = "false", rebuild with public admission0, then deploy the main Worker. Pages, read counts, selection sharing, and local confetti continue to work. - 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.
The site owns its social images rather than depending on Tailgraph or another hosted renderer:
@ethercorps/sveltekit-ogand its Vite plugin bundle Satori/resvg WASM for Cloudflare Workers.src/lib/social-meta.jsis the registry for the six public page cards and the shared metadata contract. ChangeOG_DESIGN_VERSIONwhenever 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=<design-version>serves home, About, Ideas, Podcasts, Portfolio, and Subscribe cards./og/article/[slug].png?v=<updated-at>-<design-version>resolves only public articles from the persisted content manifest. Unknown, private, and malformed slugs return 404.- Article
image/cover_imagevalues may enhance the template. Only HTTPS JPEG, PNG, and WebP inputs up to 4 MB are accepted, with a 2.5-second fetch timeout. A bad 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.pngwithX-OG-Fallback: 1.
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.pngUse a fresh version query when spot-checking so an older immutable edge entry cannot hide the new renderer. 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.
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_countin 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.
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.
- 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.
Treat the rate, weight, and policy version as one migration:
- Change
READ_SAMPLE_RATE,READ_SAMPLE_WEIGHT, andREAD_SAMPLING_POLICYtogether. - Keep the server-owned header validation and D1 write weight aligned.
- Update the read-counter unit tests and
docs/read-counter.md. - Preserve existing rows; never rewrite old sampled counts as though they used the new policy.
- 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 listAvoid 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.
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
npx wrangler deployAfter deployment:
- Record the Git commit and Worker Version ID.
- 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.
- Confirm PNG signatures/dimensions,
Cache-Control,Content-Type, and absence ofX-OG-Fallbackon normal renders. - Confirm the second identical read-count GET is an edge cache hit and POST responses remain
private, no-store. - Check D1 rows and GA Realtime
engaged_readindependently. Neither alone proves the other system is healthy. - If using the hourly monitor, verify that
ops_monitor_snapshotsreceives a fresh row and thatpresence_monitor_hourlystays aggregate-only. - Keep the worktree clean and never commit
.dev.vars, GA API secrets, or Cloudflare tokens.
npm run dev— local dev servernpm run build— production build (Cloudflare adapter)npm run preview— preview withwrangler dev; use this rather than plain Vite when testing local KV, D1, R2, Cache API, or Worker bindingsnpm run deploy:monitor— deploy the hourly read/presence monitor Workernode --test tests/*.test.mjs— fast unit and contract tests, including OG and read analyticsnode tests/markdown.test.mjs— markdown renderer regression checksnpm test— Playwright e2e (requires GH content)
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
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.
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.
/drawand/boxpermanently redirect to/tools/drawand/tools/box. Tool URLs are grouped under/tools; browser storage and API paths are unchanged./tools/draw: every Googlesubgets its own Durable Object workspace. Only the configured owner maps to the existingpersonalworkspace, 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-Usermatching the signed session, so an old tab cannot write into another account after a session switch. /tools/boxtext 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/usagereturns 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.
- Create a dedicated Google Cloud project and an External Google Auth app.
Request only
openid,email,profile. No Drive/Gmail APIs or offline access. - 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 setGOOGLE_REDIRECT_URIto the exact port. - 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/toolsand privacy URL tohttps://swyx.io/tools/privacy; satisfy Google’s domain/brand requirements. - 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
subfrom a validated sign-in; never grant admin based on the first user to register. - 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.
- 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.
The auth-only project is swyx-io-tools (project number 31511070245), owned by
shawnthe1@gmail.com; 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.
/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.
- Filters: last 24 hours / 7 / 30 days, AI / tool actions, and tool. Owner scope is separate from filters. Daily UTC counts, estimated reserved cost, statuses and request IDs are available, 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/logsacceptsdays,kind,tool,scope=mine|all, andbefore.allis server-authorized. BrowserPOSTaccepts only{id,action,status}, requires same origin plusX-Tools-User, and gets identity/time/provenance from the server. User IDs, profile fields and arbitrary payloads are rejected.
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.