ReviewOS

stacks/reportshq

Accounts, projects, and membership: models, auth, and settings UI

#4
Open chris opened this 8 days ago ·
8 days ago

Context

Account model mirrors loghq: a user owns projects; collaborators are invited by email (so invites work pre-signup) with token-based acceptance. loghq's project_members / project_invites are raw SQL tables without models because the migration generator renumbered when models were added - that workaround is banned here. Every table comes from a model; the generator gap is fixed in #5 first.

Reference: loghq app/Models/{User,Project}.ts, app/Invites/invites.ts, app/Support/access.ts, routes/{auth,projects,users}.ts. Stacks ships Team/TeamMember/TeamInvitation default models in storage/framework/defaults/app/Models/ - evaluate whether overriding those beats bespoke ProjectMember/ProjectInvite models; pick one and document why in AGENTS.md.

Scope

  • User model with useAuth + billable traits (billing itself lands in #17).
  • Project model: owner-scoped, unique revocable write-only ingest_key (rhq_... prefix), deliberately no useApi trait (loghq skips it on Project to avoid unscoped CRUD; same here). Owner-scoped CRUD actions + routes instead.
  • ProjectMember + ProjectInvite as real models (email-keyed membership, tokened invites, expiry, revocation) - requires #5.
  • Two token kinds, cleanly separated: public ingest key (write-only) vs bearer token (read/manage), with loghq's cookie + localStorage SSR-aware session pattern (resources/stores/session.ts).
  • Auth flows: register, login, logout, password reset; social login via @stacksjs/socials (port loghq's Auth/Social{Redirect,Callback}Action); passkeys/2FA if the useAuth trait gives them cheaply.
  • Auth + settings UI: sign-in/sign-up pages, account page, project settings (members, invites, key rotation) - follow the tokens/utilities from #3 and the AuthGuard partial pattern.
  • Factories, seeders, and feature tests: authz matrix (owner vs member vs stranger), invite lifecycle, key rotation.

Acceptance

  • buddy generate:migrations && buddy migrate produces the full schema from models alone; zero .sql files written by hand.
  • Feature tests cover the authz matrix and pass.

Depends on #2, #5. Part of #1 (M2 Accounts and data). Ground rules: see #1.

8 days ago

Data layer, authorisation and invites are done and tested (f3bb3a0, e423200). The UI half of this issue is not, and is described at the bottom.

Models. Project, ProjectMember and ProjectInvite are real models, so the schema is generated, not hand-written; #5 confirmed the renumbering problem that forced loghq into raw SQL is fixed upstream. User was published with buddy publish:model and switched to billable: true for #17, with useApi removed: the generated CRUD is not scoped to the caller, so index would list every account on the instance.

Membership is keyed by user_id, not email. loghq keys its equivalent by email so an invite grants access the moment someone signs up with a matching address. That is convenient, and it also makes the email column an authorisation credential: any path that lets a user set their own address without verifying it becomes a way into another tenant's data. Here the invite carries the email, is tokened and expires, and accepting one is the only thing that writes a seat.

app/Support/access.ts owns the predicate for the whole product, so the routes, the page actions and later the report engine cannot drift on the rule. One query answers "who is this to this project" rather than an ownership check followed by a fetch, which has a time-of-check to time-of-use gap and answers "no such project" differently from "not yours", telling a stranger whether an id is real. The owner is projects.owner_id and never a seat row, so ownership survives holding no membership.

Two modelling bugs were caught by reading the generated SQL before applying it: belongsTo: ['User'] plus an explicit owner_id gave projects two columns for one relationship, and the same on ProjectInvite added a user_id beside invited_by_id. Both are now declared through the relation with an explicit foreignKey.

46 feature tests, covering the matrix (stranger, member, admin, owner, unauthenticated against read, administer, own), soft-deleted projects reading as gone even to their owner, a seat on one project granting nothing on another, the full invite lifecycle (expiry, revocation, reuse, supersession, re-invite refreshing rather than stacking), the owner being unremovable, and key rotation being owner-only.

A driver bug surfaced here and is fixed upstream. The first run of these tests failed with owners locked out of their own projects. bun:sqlite treats $1 as a name, not an index: handed an array it binds values in order of first appearance and ignores the numbers, so accessFor, which repeats $2 for the owner check and the seat check, bound a project id where a user id belonged. Silently, with no error. Fixed in bun-query-builder@fa55399, released as 0.2.30, pinned here via an override until the framework's own floor ships (stacksjs/stacks@b2ababd raises it). tests/feature/driver-placeholders.test.ts is a canary so a downgrade fails loudly instead of as a permission answer nobody double-checks.

Two more fixes landed upstream on the way: buddy publish:model User produced a file that could not run, because it copies a model importing ../password-policy and left the policy behind (stacksjs/stacks@20e2513), and the scaffold's CI ran the test suite with no migrated database, so any app's first feature test fails in beforeAll with "no such table" (stacksjs/stacks@e36cf38).

Routes are registered behind auth at the registry level, so a route added later cannot be published unauthenticated by omission. A caller who may not see a project gets 404 rather than 403, since "forbidden" confirms the id exists. The ingest key is returned only to someone who may administer the project, plus once at creation where the snippet is copied.

Still open on this issue:

  • Auth flows end to end. The framework mounts register, login, logout, password reset and passkey routes automatically, so the surface exists; it has not been exercised against this app, and social login is not wired.
  • The UI: sign-in and sign-up pages, the account page, and project settings (members, invites, key rotation). These want the app shell and the AuthGuard pattern from #13, so building them here would mean building that shell twice. I would rather do them once, in #13, against the real navigation.
8 days ago

Most of this is now done. Progress, and what is deliberately left.

Done

  • User (useAuth + billable), Project (owner-scoped, revocable rhq_ key, no useApi), ProjectMember and ProjectInvite as real models. Every table came from a model; no hand-written SQL.
  • Two token kinds kept separate: the public write-only ingest key, and the session token that reads and manages.
  • Auth flows: register, login, logout (24fe23d). The session is the framework's HttpOnly cookie carrying the same personal access token the API uses. Sign-out revokes the token rather than only clearing the cookie, because somebody signs out when a credential may have been copied.
  • Sign-in, sign-up and account pages, and a project settings screen (6089a44) with members, invites, revocation, key rotation and an automatic-reports switch.
  • Feature tests: the owner/admin/member/stranger matrix, invite lifecycle, key rotation, and now the auth surface (17 tests) and settings permissions.

Two things worth recording, because both were invisible until the app was opened in a browser rather than curled.

/login did not exist at all. The guard redirected there, so every signed-out visitor landed on a 404 that helpfully listed the app's own routes, and the only way into the product was minting a token by hand.

Worse: the guard could never have worked. It looked for the session cookie in document.cookie, and the framework sets that cookie HttpOnly by design, so a page script never sees it no matter who is signed in. Every authenticated visitor was bounced to /login, and once /login existed, signing in produced a redirect loop between the two pages. Every page had been returning 200 with correct markup the whole time. The server is the only side that can read that cookie, so the server now decides: a view renders #auth-required when it could not identify the visitor, and the guard reads that instead of guessing.

Sign-in carries its own rate limits, which fail closed - the opposite of the ingest limiter, on purpose. Ingest fails open because losing a customer's data to our own broken counter is worse than a bounded batch getting through; here the thing behind the counter is everybody's password. Per-identifier and per-address windows, because a password list against one account and one guess against a thousand accounts look nothing alike from a single counter. Wrong password and unknown account return the same status and the same words, tested in both directions, so the form cannot be used to find out who has an account here.

Left open, and why

  • Password reset. It needs mail delivery, which is not configured until #21. Shipping a 'check your email' that sends nothing is worse than not offering it yet.
  • Social login via @stacksjs/socials. No blocker beyond scope; it wants its own pass with the provider config.
  • Passkeys / 2FA. The useAuth trait exposes them, but 'cheaply' turned out not to be true once enrolment and recovery UI are counted, and half a second factor is worse than none.

Keeping this open for those three.

Sign in to comment on this issue.