Guard / foldkit-guard

Sixty seconds

ts
import { Effect, Option, Schema } from 'effect'
import { Entity, Expr } from 'foldkit-entity'
import { Guards } from 'foldkit-guard'

const User = Schema.Struct({
  id: Schema.String,
  role: Schema.Literals(['admin', 'member']),
  projectIds: Schema.Array(Schema.String),
})

const TodoFields = Entity.define(
  'Todo',
  Schema.Struct({
    id: Schema.String,
    ownerId: Schema.String,
    projectId: Schema.String,
    title: Schema.String,
  }),
)

// Shared: names and predicates, which are data.
const G = Guards.declare(
  'Todos',
  User,
  guard => ({
    isAdmin: guard.principal(),
    isOwner: guard.row(TodoFields, p => Expr.eq(TodoFields.fields.ownerId, p.id)),
    inProject: guard.row(TodoFields, p => Expr.in(TodoFields.fields.projectId, p.projectIds)),
  }),
  { key: user => user.id, unguarded: 'deny' },
)

// Server: the body of every principal guard, and nothing else.
const Policy = G.implement({
  isAdmin: principal => Option.exists(principal, user => user.role === 'admin'),
})

Effect.runSync(
  Policy.decide(G.isOwner, {
    principal: Option.some({ id: 'ada', role: 'member', projectIds: [] }),
    interaction: 'Read',
    row: { id: 't1', ownerId: 'ada', projectId: 'p1', title: 'Write the README' },
  }),
) // true

What each call does, and does not:

  • Guards.declare reads nothing and stores nothing. It builds each row guard's predicate once, with placeholders (p.id is the input principal.id), and checks it reads only its Entity's fields. Branching on a placeholder (p.role === 'admin') is a type error: there is no value yet.
  • G.implement is the only place a body exists. A missing or extra key is a type error, and refused at runtime for a caller the types do not reach.
  • Policy.decide is an Effect of a Decision: true, false, or { allowed: false, reason } for a refusal the caller may read. A visitor is Option.none(), and passes no row guard that reads a principal's fields; one over the row alone (isPublished) holds for a visitor too.
  • G.Principal is the server's Context key for who is asking, an Option of the principal; G.key(user) names the person, for what is theirs whatever their claims, such as a cache snapshot (see Who is asking).