00TL;DR
The cabinet stopped looking like a homepage trying to sell you a tool and started being a tool — the kind you sign into, with folders, with private files, with sharing-by-email like Google Docs.
- 9commits shipped
- 3new tables (
users,folders,shares) - 1OAuth provider wired (Google)
- 5production bugs ironed out as I went
- +1.7K / −400lines net in
worker.ts - 0data lost on the second migration
01What shipped
Six concrete things that didn't exist this morning:
users, folders, shares tables. Documents and tokens get user_id. Visibility is per-doc.auth extension on the users table, OAuth client + secret in env, session cookie set./raw./agents.md.02The landing page, before and after
The signature change. The cabinet's homepage stopped being a sales pitch and became its actual content: the gallery.
A personal tool shouldn't pitch. It should show you what it is, and let you in.
03The doc viewer
Opening a doc used to be confusing — /d/:slug was raw HTML, and /d/:slug/about was a separate metadata page with version history. So if you wanted to read the doc and see who could access it and jump between versions, you had to navigate three places. The new viewer folds it all into one URL.
The right rail collapses to a tiny "History" pill in the corner. The chevron closes it; localStorage remembers your preference. The iframe is sandboxed (allow-scripts allow-same-origin allow-popups allow-forms) so docs that fetch their own APIs keep working.
Old /d/:slug/about URLs still work — they 301 to the new viewer. Raw HTML moved to /d/:slug/raw — that's what iframes embed and what curl/agents fetch.
04Visibility & sharing
Every doc has a visibility field — private | shared | public — and an associated shares table for per-grantee email + read/write role. Click below to walk the three states:
Visibility, in three flavors
Inviting someone by email automatically bumps a private doc to shared, the way Google Docs does. The share modal is reachable from two places: in the cabinet, right-click a file → Share / visibility…, or open the doc and click Share in the top bar. Both call the same /api/me/documents/:slug/shares endpoint.
One bug bit me here, late in the session. The ACL helper read the calling user's email from c.get('auth').email — except teenybase only populates that when verified=true, and the verified flag wasn't reliably propagating. So a freshly-signed-in user who had been granted access via email saw 403 when they opened the link. Fix: pull email from the users row directly (always present once you're signed in) and case-fold both insert and lookup.
// before — auth.email was null when verified didn't propagate const email = auth?.email || null const email = emailFor(c, user) // user.email > auth.email > null, lowercased // SQL — case-fold both sides WHERE granted_email = ? WHERE LOWER(granted_email) = LOWER(?)
05Ping the cabinet
This page lives in the cabinet — saved via the same flow it documents. Click below to fetch its public version metadata. Same path Claude would use to remember it later.
Public versions endpoint
liveThis works for any reader. Reading this doc requires whatever its visibility says — but the metadata endpoint follows the same ACL, so it's transparent: if you can read the doc, you can read its history.
06Timeline
The actual order of events. Click any commit to expand.
-
Schema migrationd2c6390d
Addedusers(withauthextension on it for OAuth),folders(per-user, nestable),shares(document_id + email + can_write). Altereddocumentsto adduser_id,folder_id,visibility. Alteredtokensto adduser_id. SQLite did the column-add via a table-rebuild dance — fine for new rows, but the migration'sINSERT INTO new SELECT FROM olddropped Thariq's article (recovered the session report from a local copy). -
Finder UI + Google SSO + agent flowa98b5683
Massive rewrite ofworker.ts. New homepage gates by sign-in state. Logged-in shell is a Finder: titlebar with breadcrumbs and user dropdown, toolbar with Connect/New folder + view switcher, sidebar with Locations + Folders, main pane with icon view (iframe thumbnails) and list view. Context menu on right-click (Open / View history / Copy link / Rename / Move / Share / Delete). Share modal with three radios + email invites. Agent tokens scope to user. New/api/me/*endpoints for folder + doc + share CRUD. -
Layout fix + landing redesign080bbbe8
First deploy had three bugs visible in your screenshot: (1)grid-template-rows: auto 1frfor 3 children pushed the toolbar into the middle, (2) the "+ New folder" SVG had no size constraint and grew to fill its flex parent, (3) the landing page was a gated splash card. Fixed all three:auto auto 1fr, sidebar SVG sizing rule, and the gallery-first homepage you see now. -
OAuth route 404 — catch-all middleware fixmiddleware
Subtle one. I'd addeduserApp.use('*', ...)to ensure auth was initialized for non-/api/*routes. Side effect: the wildcard middleware intercepted teenybase's/api/v1/table/users/auth/oauth/googlemount, returning 404 for the entire OAuth flow. Fixed by removing the catch-all and inliningensureAuth(c)insidecurrentUser()instead — same correctness, no route shadowing. -
Docs-style chromed viewer1b21314f
Replaced the separate/d/:slug/aboutpage with a unified viewer at/d/:slug. Top navbar: ← back, title, vis pill (clickable for owners), Share, history toggle, more menu (Copy link / Open raw / Rename / Delete), avatar. Iframe holds the rendered doc, sandboxed for safety. Right rail lists versions; click a row → navigate to/d/:slug/v/:n. Rail collapses to a tiny floating pill in the corner; preference stays in localStorage./aboutURLs 301 to the viewer. Raw HTML now lives at/d/:slug/raw. -
Iframe sandbox tweakf5d879ec
Initial sandbox wasallow-scripts allow-popups allow-forms allow-modals— noallow-same-origin. That's the safer default, but it broke docs whose internal JS callsfetch('/api/...')(like the live ping demo on the previous session report). Addedallow-same-originback. Trade-off: cookies are httpOnly so still inaccessible; localStorage in the iframe can read parent's storage, but the cabinet doesn't store anything sensitive there. -
Share ACL email fix23e40666
You signed in asmjsong2021@gmail.comafter granting that email access — and got a 403. Why:c.get('auth').emailonly populates when teenybase'sverifiedflag is true, and our mapping (verified: 'email_verified') wasn't propagating reliably. Fix: read the email fromusers.emailvia the DB lookup we already do, and case-fold both share inserts and ACL lookups. Now any signed-in user can match a granted_email regardless of casing. -
Favicon + "Make your own" ribbon85bab1e7
Inline-SVG favicon: rounded-corner vermillion square with four stacked bars echoing the wordmark. Mounted at/favicon.svg(and/favicon.icoserving the same body); referenced from everysiteHead(). Diagonal "⚡ Make your own" ribbon at bottom-right of the public pages, linking/agents.md— teenybase's auto-mounted clone-this hook. Same ribbon CSS shape teenyapp's empty template ships with, so visitors and AI agents that recognize the pattern can clone the project. -
You asked for the changelog. You're reading it.+1
Saved via the samePOST /api/saveflow this changelog documents. Visibility public so it shows up in the gallery. The Ping demo above fetches/api/docs/session-2026-05-10-personal-cabinet/versions— it'll return one version (this one) the first time, then more if you re-publish.
07Known issues
documents and versions. Returns "Could not serialize object of type PreparedQuery". The custom routes the cabinet UI uses are unaffected — homepage, browser, ACL all work. But the stock /api/v1/table/documents/list endpoint 400s. Likely a teenybase bug triggered by something in the new schema; tracking it as a deferred fix.
auth_username and auth_password field "usages" even for OAuth-only setups. They stay NULL for Google-authed users. Annoying but harmless.
08Meta
/api/save · live at /d/session-2026-05-10-personal-cabinet · history in the right rail