Guard / foldkit-guard

Serving writes

A declared write (Mutation.write) is served by RemoteServer.write with no handler. Wrap its writer, and the write rules are checked before it lands:

ts
import { Write } from 'foldkit-entity'
import { Mutation } from 'foldkit-remote'
import { writer } from 'foldkit-remote-drizzle'

const EditTodo = Mutation.write(
  'EditTodo',
  Write.update(Entity.input(Todo, Schema.Struct({ id: Schema.String, title: Schema.String })), {
    id: 'id',
  }),
)
RemoteServer.write(EditTodo, GuardServer.writer(Todo, Policy, writer(Db.Todo)))
  • An update or a delete reads the row first, as its writer may see it. An update of a row they may not see is refused; a delete of one changes nothing and answers it gone, as for a row already deleted.
  • The row's rule for the command must hold over the row, and an update's over the row as it would be after it too: a write cannot move a row out of its writer's reach.
  • Each member the write sets needs its own Create or Update rule to hold, and each relation it points elsewhere its Unlink and Link. What a relation was linked to is read as stored (the writer's linked), since a link to a row hidden from its writer reads to them as none; a writer that cannot say treats every link it sees as none as one that may be there, so changing it needs Unlink too.
  • A create is checked over the values it proposes. A guard that reads a field it leaves unset does not hold, whatever it asks (isNull and isNotNull alike): the create never said what the field holds.
  • A refusal is Forbidden, with the sentence Guards.explain gave; the write never runs.
  • The answer is read back under the binding's visible and the Entity's member Read rules: the row as its writer may now see it, with no more fields than the write set, and a row the write hid from them in deleted, so the client takes it out of its lists instead of placing it.

The binding must have a visible rule, which is how the writer reads a row as its writer may see it; the writer refuses one without. That the rule is GuardDrizzle.visible's is what GuardServer.validate checks of the Entity's Source, so give the writer that Source's binding.

A client should not choose whose a new row is. presets sets a field from who is asking on every create, before the rules are checked, so a rule over it holds of the creator:

ts
GuardServer.writer(Todo, Policy, writer(Db.Todo), { presets: { ownerId: user => user.id } })

The create's input leaves ownerId out; an input that sets it is refused as the server's own mistake. A visitor has no one to preset from, and is refused even where the rules would let anyone create. A preset must be a field of the Entity, of that field's type.

A handler writes what its code writes, so it is guarded where it is defined: GuardServer.mutation is RemoteServer.mutation with the row's rule for one interaction checked before the handler runs.

ts
const ArchiveTodo = Mutation.make('ArchiveTodo', { Input: { id: Schema.String }, Output: {} })

GuardServer.mutation(
  ArchiveTodo,
  { entity: Todo, policy: Policy, interaction: Archive.key, id: input => input.id, source: TodoSource },
  ({ input, row }) => Effect.succeed({ output: {} }), // `row`: the todo, as its writer sees it
)
  • The row id names is read through source, which must apply the Entity's Read rules (it is refused where it is defined otherwise); a row the writer may not see is refused, and the rule must hold over the rest.
  • With no id (a create), the rule must hold of who is asking alone. An id that answers an Option serves create and edit in one mutation (one Crud.editor for both): Option.none() is a create, checked against the row's Create rule.
  • What the handler writes is its own to check: members it sets, relations it points. fields reads more of the row for it.
  • Each row of the Entity its answer carries is read back through source, its authorize and filter applied, as a declared write's is: the answer shows the writer only what they may now see of the fields it named, and a row the handler hid from them answers as deleted.