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. Writeis 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'sUpdategoverns setting it on creation too, unless the member names aCreate.- An interaction the application names (
Interaction.make('Archive')) is the row's, underinteractions, 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 asvalueinstead, 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 itsonerelation'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, whichdeclarerequires so the choice is written down:'deny'refuses it (the matrix printsclosed),'allow'lets everyone (open).