Guard / foldkit-guard

Attaching guards to an Entity

A guard set names rules; attaching says where each one applies, per surface (the row, a member, a relation) and per interaction. Here a todo also has a relation, project, which a client follows to its Project; it is stored as the same projectId the row guard reads, and Project has no guards, so anyone may read one:

ts
import { Relation } from 'foldkit-entity'
import { Interaction } from 'foldkit-guard'

const ProjectFields = Entity.define(
  'Project',
  Schema.Struct({ id: Schema.String, name: Schema.String }),
)
const Related = Entity.relate(
  { Todo: TodoFields, Project: ProjectFields },
  { Todo: { project: Relation.one(ProjectFields) } },
)
const Project = Related.Project

const Archive = Interaction.make('Archive')

const Todo = Related.Todo.pipe(
  Guards.attach(G, {
    row: {
      Read: [G.isOwner, G.inProject, G.isAdmin],
      Write: [G.isOwner],
    },
    interactions: {
      [Archive.key]: Guards.on([G.isAdmin], Guards.explain('Only an admin may archive.')),
    },
    members: { ownerId: { Update: [G.isAdmin] } },
  }),
  Guards.may, // the server's answer to what a reader may do: see What the client may do
)

Guards.matrix([Todo])
// Todo.Read: isOwner | inProject | isAdmin → not_found
// Todo.Create: isOwner → forbidden
// Todo.Update: isOwner → forbidden
// Todo.Delete: isOwner → forbidden
// Todo.Archive: isAdmin → explain("Only an admin may archive.")
// Todo.ownerId.Create: isAdmin → forbidden
// Todo.ownerId.Update: isAdmin → forbidden
  • A surface's list is a disjunction: "owner, or project member, or admin". Guards.all(a, b) is a conjunction as one guard. always: [...] is ANDed after every surface's list and cannot be out-voted: a tenant boundary or "signed in" belongs there.
  • Write is shorthand for Create, Update and Delete. Beside it, a slot of its own (Delete) adds its rules to Write's, and its refusal replaces Write's, whichever is written first. A member's Update governs setting it on creation too, unless the member names a Create.
  • An interaction the application names (Interaction.make('Archive')) is the row's, under interactions, keyed by [Archive.key], so a misspelling is a type error. Only a Read inherits.
  • Each surface refuses its own way by default: a row Read answers not found, so existence does not leak; a member or relation Read omits the value; a write is forbidden. Guards.on(guards, Guards.redact(value)) shows a hidden member as value instead, typed by the member's schema; Guards.on(guards, Guards.explain(reason)) gives a refused write a sentence.
  • Guards.inherit('project') makes a row's Read its one relation's Read.
  • Every key is checked against the Entity: a member it lacks, a row guard over another Entity, a guard of another set, or a misspelled interaction is a type error where it is written.
  • Attaching is metadata: the result is the same Entity (Entity.same), and attaching twice appends to each surface's list and replaces its refusal.
  • Guards.guardFields(Todo, 'Read') names the members a row interaction's guards read, so a change to one is known to change who may.
  • A surface with no guards on a guarded Entity falls to the set's unguarded, which declare requires so the choice is written down: 'deny' refuses it (the matrix prints closed), 'allow' lets everyone (open).