Skip to content

Use the branded ID schemas in server-function validators instead of casting #770

Description

@InfinityBowman

The branded ID Zod schemas in packages/shared/src/ids.ts have never been used. Server functions validate IDs as bare strings and then cast the brand back on.

Current state

ids.ts exports each ID twice on purpose -- as a Zod schema and as a type -- and carries an eslint-disable at the top of the file specifically to permit that idiom. Its docblock (lines 16-22) instructs:

From an external boundary (route param, request body, search param) -- run the value through the matching Zod schema (UserId.parse(raw)) or use the schema in a validateSearch / validateParams config.

Inside trusted code, prefer assignment-position type assertions only when crossing a boundary the type system can't see -- never to silence a type error in the middle of a function.

Measured across packages/web/src and packages/workers/src:

Value imports of '@corates/shared/ids':                    0
Calls to UserId.parse / OrgId.parse / ProjectId.parse:     0
Type-only imports of '@corates/shared/ids':               40

What the code does instead, packages/web/src/server/functions/org-projects.functions.ts:22-29:

.validator(z.object({ orgId: z.string(), name: z.string().trim().min(1).max(255) }))
.handler(async ({ data, context: { session, db } }) => {
  const { orgId, ...projectData } = data;
  return createOrgProject(session, db, orgId as OrgId, projectData);
});

Roughly 31 sites: org-projects.functions.ts (15), admin-orgs.functions.ts (11), dev-tools.functions.ts (5).

Why it matters

No live bug. Authorization is enforced by requireOrgMembership / requireProjectAccess, not by the brand, and z.string().brand() erases at runtime.

The cost is that the brand is switched off at the one place it would pay for itself. Every field arrives as string and is cast positionally into multi-argument calls:

deleteProjectById(session, db, data.orgId as OrgId, data.projectId as ProjectId)

Transpose those two arguments and the compiler is silent, because both sides are casts from string. That is verbatim the bug class ids.ts:8 says brands exist to prevent ("passed projectId where userId was expected"). The codebase pays the full ceremony cost of branded IDs and gets compile-time protection only on Drizzle-to-Drizzle paths.

Done when

  • *.functions.ts validators use the branded schemas: z.object({ orgId: OrgId, projectId: ProjectId })
  • data.orgId is already OrgId in the handler, so the ~31 casts delete themselves
  • ids.ts documents something the codebase actually does

Effort: 1-2 hours. Zero runtime change.

Also resolves a Source of Truth conflict: ids.ts currently documents a workflow that has never been used.


Amendment: the brand carries no runtime validation

z.string().brand<'UserId'>() is type-level only. .brand() adds nothing at runtime, so:

UserId.parse('')          // succeeds
UserId.parse('not-an-id') // succeeds
UserId.parse(someOrgId)   // succeeds

Adopting the schemas in the validators still closes the real hole -- a transposed orgId/projectId stops compiling -- and that is worth doing on its own. But the .parse() prescribed by the ids.ts docblock is a no-op guard. Do not finish this issue believing the boundary is now validated.

Second decision to make while in here: do these IDs get actual format validation? It has to be per-ID, because the formats differ:

  • crypto.randomUUID() -- ProjectId, ProjectMemberId, FeedbackId, NotificationId, ContactSubmissionId, OrgAccessGrantId. A .uuid() refinement is correct for these.
  • Better Auth's own id format -- UserId, OrgId, MemberId. A .uuid() here would be wrong; a length/charset bound is the most that applies.

For comparison, cf-sync-engine (same author) validates its ids for real: MAX_ID_LENGTH, rejection on empty and NUL, and TABLE_NAME_RE on table names, each throwing with a useful message. That is the standard to match if the parse is going to exist at all.

Decision (2026-09-12): adopt the schemas and stop there. Per-ID format refinements are speculative until some code path reads an ID from a boundary that requireOrgMembership / requireProjectAccess does not already check. Reopen that half if such a path appears.


Part of #778 (TypeScript correctness target state and tracker).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions