Authorization
Declaring who may call each function, and the permission table behind it.
Every exposed function declares who may call it. There is no default — omitting
auth fails the build with the declaration site named.
auth: true, // any signed-in caller
auth: false, // deliberately public
auth: can("notes:delete"), // a capability — prefer this
auth: canAll("notes:write", "notes:publish"),
auth: roles("admin"), // a job title — the escape hatch
auth: policy("isOwner", (ctx) => ctx.identity.userId === owner),Being public is something you write down, not something you forget. The two mistakes do not cost the same, so one of them is a build failure.
Prefer can() over roles()
A function depends on being allowed to do something, not on who the caller is.
Naming a role in a descriptor spreads your org chart across the codebase —
widening what editor may do then means finding every function that mentioned
it. roles() stays for checks that genuinely are about identity; a break-glass
admin route reads better as roles("admin") than laundered through a
permission.
The permission table
Roles are what your identity provider issues. Permissions are what your code depends on. One table maps between them:
export const permissions = definePermissions({
member: ["notes:read", "notes:write"],
admin: ["notes:read", "notes:write", "notes:delete", "tenant:export"],
});The compiler checks every can() and roles() against it, so a mistyped
permission fails the build instead of denying every caller at runtime for a
reason nothing reports.
Wildcards disable that check beneath them. notes:* grants everything under
notes: — including notes:delet — so the compiler cannot call a typo a
mistake. Enumerate actions where you want them checked.
defineAuth additionally rejects a role that is issued but holds no
permissions, or holds permissions but is never issued. Both are silent at
runtime and are usually a typo.
When the check runs
| Before arguments are validated | A caller who may not invoke a function cannot probe its argument schema |
| At the storage authority | Not at the edge, so it cannot be routed around |
| On every recomputation | A user whose role is withdrawn stops receiving patches, rather than keeping a live feed |
unauthorized vs forbidden
Denials distinguish nobody is signed in (unauthorized — signing in would
help) from we know who you are, and the answer is no (forbidden). A client
that conflates them sends a signed-in user back to a login page forever.
Row-level checks stay in handlers
The gate answers "may this caller invoke this function", which is a property of the caller. Whether they may touch one particular row depends on that row:
handler: (ctx, args) => {
const note = ctx.db.table("notes").get(args.id);
if (note?.["authorId"] !== ctx.identity.userId) {
throw new HighXError("forbidden", "That note is not yours");
}
ctx.db.table("notes").delete(args.id);
},Auditing it
highx generate prints the policy beside every function and writes the whole
surface to .highx/auth.ts, so a test can assert on it:
query notes.list can("notes:read")
mutation notes.save can("notes:write")
mutation notes.purge roles("admin")