Skip to content

v5 data platform, Phases 3 and 4: writes on Postgres, accounts on database sessions - #35

Merged
philosophercode merged 4 commits into
mainfrom
v5/data-platform-phase-3-4
Sep 24, 2026
Merged

philosophercode merged 4 commits into
mainfrom
v5/data-platform-phase-3-4

Conversation

@philosophercode

Copy link
Copy Markdown
Owner

Phases 3 and 4 of the v5 data platform spec (§9). The last writes leave Notion, and the app gets real accounts.

Production is unaffected — it is pinned to isam-frozen while this lands. Merging this deploys to the main preview only.

Phase 3 — the writes

  • Tickets, corrections and project submissions write Postgres (maintenance_logs, feedback, projects + project_tools), with created_by / updated_by stamped and cache tags invalidated on write. The Notion retry-without-reporter_email workaround and the raw Notion fetch in the flags capability are gone.
  • POST /api/uploads replaces the Notion upload: Vercel Blob plus an attachments row. file_upload_id becomes attachmentId, a Postgres uuid, which ripples through the chat photo hint, ChatFab and ProjectSubmitForm.
  • /api/cron/daily does the Postgres export, the blob retention prune and the orphaned-attachment sweep.
  • The Phase 2 bridge is retired. src/lib/data/notion-ids.ts has no callers left.

Phase 4 — the accounts

  • Better Auth on the Drizzle adapter with database sessions and the admin plugin. The stateless makerlab.identity cookie is gone.
  • Roles live in the users table, not in AUTH_STAFF_EMAILS / AUTH_ADMIN_EMAILS, which are retired. AUTH_SUPER_ADMIN_EMAILS stays as the bootstrap floor.
  • can() and the access-control declaration, role in /api/identity, requiredPermission on capabilities, replacing PR Pre-ISAM fixes: photos reach the assistant, staff-only intake, dead button removed #31's ad-hoc minimumRole.
  • /admin/users, audit_events, and sign-in required to submit a project. Browsing stays anonymous.

Three security findings, found by review and closed by verification

Three independent reviewers read the diff; two adversarial verifiers then tried to reproduce each finding against the fixed code rather than trusting the fix reports.

  1. The admin plugin's endpoints were publicly mounted. /api/auth/admin/set-role reached every write /admin/users performs, with no audit event and none of the guards. Now refused at the catch-all with admin_api_not_exposed. Verified across 14 path spellings — encoded, cased, traversal, trailing-slash — each asserting the target's stored row was untouched.
  2. /api/uploads handed anonymous callers permanent public Blob URLs. curl -F kind=resource was arbitrary file hosting on the deployment's blob host. Public kinds now each require a permission; chat and maintenance stay anonymous and private, so a visitor with no account can still send a photo and file a ticket. Verified end to end with no cookie.
  3. The super-admin floor could not actually change a role. better-auth authorizes against the stored row, which the floor overrode only in can(), so a floor director got an opaque failed on every write — the lock-out the floor exists to undo. reconcileSuperAdminFloor now writes the floor onto the row inside authorize().

One bug the verification itself found

reconcileSuperAdminFloor committed its row UPDATE and then wrote audit events unguarded, so an unreachable audit_events made setUserRole answer "That did not save. Nothing was changed" over a row that had just been promoted and un-banned. Fixed in bb8c6ae; both regression tests confirmed red against the unguarded version.

Decisions recorded in the amendment

  • §3.4 is settled: the environment variable wins. A banned address on the floor resolves super_admin and its ban is lifted on the first admin write. The alternative made the documented recovery a dead end. One narrow case the env var cannot rescue is documented rather than fixed.
  • Phase 6 keeps the Workflow SDK; eve was rejected — it consumes the same SDK rather than replacing it, and wants ai@7, Node 24 and an HTTP eval target.
  • Phase 6 is sized for Hobby's 300s step ceiling: four searches and four fetches, two steps, a 240s abort, 25 items a batch.
  • Phase 7 leaves the plan (not code) and Phase 9 waits.

Verification

Every command below run with DATABASE_URL, AUTH_SECRET, GOOGLE_*, BLOB_READ_WRITE_TOKEN, CRON_SECRET, AUTH_SUPER_ADMIN_EMAILS and the Notion variables all unset:

  • lint — 0 errors, 3 pre-existing warnings
  • typecheck — clean
  • vitest97 files, 1358 tests
  • playwright49 passed on a production build
  • spec:coverage — 73 items, 0 undocumented
  • build — succeeds with no database

Needs a person before this reaches production

  1. NeonDATABASE_URL (Production and Preview). Without it the app serves the PGlite demo seed and says so.
  2. Blob storeBLOB_READ_WRITE_TOKEN. Uploads refuse honestly until then.
  3. Google OAuth clientGOOGLE_CLIENT_ID / _SECRET, plus AUTH_SECRET and AUTH_SUPER_ADMIN_EMAILS=ies22@cornell.edu.
  4. npm run import:notion, then npm run verify-import.

AUTH_STAFF_EMAILS and AUTH_ADMIN_EMAILS are no longer read. Bootstrap with the super-admin floor, sign in, promote everyone else from /admin/users.

Residual risks, accepted not fixed

A lost audit event survives only as a console.error — no retry, no outbox. report_issue reports photo loss only when every photo is lost, where projects now reports partial loss. POST /api/uploads parses the body before checking the permission, so an anonymous caller can make the server read 18 MB before its 401.

How it was built

Two workflows: eight agents for the build, then five for the review fixes and adversarial verification, with the last bug and the amendment done by hand.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AjvfabH9CRceoC9GfjjSvE

philosophercode and others added 4 commits September 22, 2026 15:58
Phase 3 moves the last writes off Notion. Tickets land in maintenance_logs,
corrections in feedback, project submissions in projects and project_tools.
POST /api/uploads replaces the Notion upload with Vercel Blob plus an
attachments row, and /api/cron/daily does the backup, the blob retention
prune and the orphaned-attachment sweep. The Phase 2 notion-ids bridge has
no callers left.

Phase 4 gives the app real accounts. Better Auth moves onto the Drizzle
adapter with database sessions and the admin plugin; the stateless
makerlab.identity cookie is gone. Roles live in the users table instead of
the AUTH_STAFF_EMAILS and AUTH_ADMIN_EMAILS env lists, with
AUTH_SUPER_ADMIN_EMAILS kept as the bootstrap floor. /admin/users changes a
role, audit_events records that it happened, and submitting a project now
requires sign-in while browsing stays anonymous.

All of it runs with no credentials: PGlite serves the writes, an unset blob
token makes uploads refuse rather than pretend, and an unset CRON_SECRET
makes the cron route refuse. 96 files, 1331 unit tests, 49 E2E on a
production build, spec:coverage 0 undocumented.

Three major review findings are outstanding and fixed in the next commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjvfabH9CRceoC9GfjjSvE
Three reviews returned nine findings on this branch. Four of them described
code that was already here — the review had read an earlier working state and
the fixes had been amended into 1c967c4 itself, whose message still claims they
are outstanding. Nothing to do for those but say so, which the spec amendment
now does, along with the warning that every line number in those reports is off
by the size of the fixes they missed.

Four held up.

The super-admin floor now beats a ban, not just a demotion. identityFromSession
read the floor after the ban check, so a listed address whose row was banned
resolved anonymous: a recovery for exactly half of what the floor promises.
It is read first now. It cannot rescue a sign-in — the admin plugin throws
BANNED_USER from a hook that runs ahead of anything this app can register — so
reconcileSuperAdminFloor also lifts the ban off the row, and the first admin
write a recovered director performs makes ordinary sign-in work again. This
contradicts one sentence in spec 3.4 and the amendment flags it as the lab's
call rather than settling it quietly.

An audit write that fails after the change committed is now a warning on a
success. It was awaited unguarded after auth.api.setRole had returned, so a
transient failure threw and the island restored the old role over a database
holding the new one. AdminActionResult gains warning on its ok variant, and
both islands keep the new value and say what was not recorded. A change that
landed minus a guarantee is never ok: false — a refusal is what the islands
answer by rolling back.

POST /api/projects reports photosSubmitted and photosAttached, and the form
says so on the confirmation. A form left open overnight submits ids the cron
has already swept, and thanking a student for pictures nobody has is the quiet
lie Article 4 forbids. Partial loss counts, unlike the maintenance path.

/projects/new tells three identity states apart instead of two. A failed
/api/identity fetch was read as "not signed in", so a 429 replaced the whole
form with a sign-in wall; anonymous comes back as a 200 with a role, so null
means only "could not ask". Unavailable keeps the form up with a notice and a
retry, and the server stays the authority.

Documentation the three passes deferred: the spec amendment entry, and
AGENTS.md on the floor's ban exception, floor-role.ts, admin_api_not_exposed
and the warning channel Phase 5 should reuse.

Gate observed green with every environment variable unset: lint 0 errors
(3 pre-existing warnings), typecheck clean, vitest 97 files / 1356 tests,
playwright 49 passed, spec:coverage 73 items / 0 undocumented, build succeeds
with no database. Each code fix confirmed red with the fix reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjvfabH9CRceoC9GfjjSvE
Adversarial verification of 3c76839 found the guard it had just added to
actions.ts missing one function away. reconcileSuperAdminFloor commits its
row UPDATE and then writes two audit events unguarded, so an unreachable
audit table threw past a committed change: authorize() caught it and
returned "failed", which the page renders as "That did not save. Nothing
was changed" — over a row that had just been promoted and, since 3c76839,
un-banned. A ban lifted with no trail and the UI saying nothing happened.

The function now reports {changed, audited} instead of a bare boolean, and
guards its audit writes the way actions.ts already guards its own. The gap
rides back through authorize() as the existing audit_unavailable warning,
so both halves of a two-write action answer the admin's one question: did
the trail record this. A success with a gap keeps the value the database
holds; only the row UPDATE itself still throws.

Two smaller things the same pass turned up:

An upload refused for want of a session showed the route's English to a
reader in another language. /projects/new tells three identity states
apart now, so picking a photo while signed out is a routine path rather
than an edge case; the 401 gets signInRequiredError, as submit already did.

A test comment claimed the permission check "refuses before reading the
file, so the refusal costs nothing". req.formData() has already parsed the
body by then. The assertion was right and the explanation was not.

Verified with every credential unset: lint 0 errors (3 pre-existing
warnings), typecheck clean, 97 files / 1358 tests, spec:coverage 73 items /
0 undocumented. The two new regression tests were confirmed red against the
unguarded version, failing with exactly {ok: false, error: "failed"}.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjvfabH9CRceoC9GfjjSvE
The floor beats a ban, decided rather than left open: the alternative makes
the documented recovery a dead end, and the cost is already held by anyone
who can deploy. The one case the environment variable cannot rescue — a ban
applied before the address joined the list, whose sessions are already gone —
is written down rather than fixed.

Phase 6 keeps the Workflow SDK. eve was considered and rejected: it consumes
the same SDK rather than replacing it, and it wants ai@7, Node 24 and an HTTP
target for its evals, none of which this app can give it. The phase is sized
for Hobby's 300s step ceiling — four searches and four fetches, two steps,
a 240s abort, 25 items a batch — and carries the two traps found while
checking: mapWithConcurrency diverges on replay, and vi.mock cannot reach
step code.

Phase 7 leaves the plan (Isaac and Luis, not code) and Phase 9 waits until
the app is otherwise good. Phase 8 stays, and still needs its two open
questions answered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjvfabH9CRceoC9GfjjSvE
Copilot AI lite review requested due to automatic review settings September 22, 2026 21:20
@vercel

vercel Bot commented Sep 22, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated
makerlab-tools Ready Ready Preview Sep 22, 2026 9:21pm UTC
makerlab-tools-v5 Ready Ready Preview Sep 22, 2026 9:21pm UTC

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 57d7ea3e71

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread v5/messages/en.json
"label": "Demo data",
"body": "This catalogue is built-in sample data, not {institution}'s real inventory. Notion is not configured."
},
"admin": {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add translations for every new message key

The new admin namespace, the header's admin label, and most new project-form messages exist only in en.json; the other 11 locale files add only the upload-unavailable messages. Consequently, non-English users receive English fallback text throughout the new admin and sign-in flows, and existing translations such as Spanish projectForm.requiredError still incorrectly require a name even though that field was removed. Update all locale files with the complete set of changed keys.

AGENTS.md reference: AGENTS.md:L84-L84

Useful? React with 👍 / 👎.

Comment on lines +267 to +271
if (target.role === "super_admin") {
const remaining = await countUsersWithRole("super_admin", {
excludeUserId: target.id,
});
if (remaining === 0) return "last_super_admin";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Serialize the last-super-admin check with the demotion

In a deployment without a configured floor and with exactly two unbanned super admins, concurrent demotions can both observe the other account in this count and then both commit through auth.api.setRole, leaving no super admin able to reverse the changes. The count and role update need a shared lock or another atomic database invariant rather than separate read and write operations.

AGENTS.md reference: v5/AGENTS.md:L176-L179

Useful? React with 👍 / 👎.

Comment on lines +63 to +67
const pathnames = orphans.map((row) => row.blobPathname);
await store.del(pathnames);

const rowsDeleted = await deleteAttachments(
orphans.map((row) => row.id),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent cleanup from deleting attachments claimed concurrently

When a submission claims an attachment after listOrphanedAttachments returns but before this Blob deletion completes, cleanup still deletes its bytes and then deletes its now-owned row because deleteAttachments has no owner_id IS NULL guard. This race is particularly plausible for a form left open past the 24-hour cutoff and makes the submission report the photo as attached even though cleanup has destroyed it; claiming and cleanup must coordinate atomically.

AGENTS.md reference: v5/AGENTS.md:L65-L68

Useful? React with 👍 / 👎.

// upload route moves to Blob this is the normal case, because the ids in
// the hint are still Notion file_upload ids and no `attachments` row
// answers to them.
const photosLost = photoIds.length > 0 && record.photosAttached === 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Report partially lost maintenance photos

When multiple photo IDs are supplied and only some remain claimable—for example, one upload expired while another did not—photosAttached is nonzero, so photosLost is false and the assistant reports an unqualified success. Compare the claimed count with the number of submitted distinct valid IDs so the student is warned whenever any requested photo did not attach.

AGENTS.md reference: v5/AGENTS.md:L68-L73

Useful? React with 👍 / 👎.

This branch was successfully deployed

2 active deployments
Preview – makerlab-tools 57d7ea3e Deployed Sep 22, 2026 by vercel[bot]
Preview – makerlab-tools-v5 57d7ea3e Deployed Sep 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants