Skip to content

Offline forms

Paid add-on. A form can be filled in with no network. The answers stay on the device, encrypted, and go out when the connection comes back.

This document describes what the module guarantees and what it refuses. The user guide gives the short version, under “Filling in without a network”.

The idea

The input is kept in the browser, in IndexedDB, encrypted. Nothing is sent to the server before the send: no server-side draft, no email, no tracking. The site learns that this input exists only at the moment it becomes a submission.

The setting lives in the form’s settings, under offline:

{
  "offline": {
    "enabled": true,
    "retention_days": 30
  }
}

Retention can be shortened, never lengthened: thirty days at most. A draft is a copy of personal data placed on somebody’s device, and letting “three years” be set from a box in the interface would put that choice in the hands of the person who does not bear it.

What the encryption protects — and what it does not

This is the point where it would be easy to promise too much.

The key is produced in the browser, in AES-GCM 256, and marked non extractable. No script can read its bytes — not even an injected script: crypto.subtle refuses to export a key created that way. It therefore cannot leave the device.

What is protected: the contents of drafts at rest. A profile backup, a disk someone looks through, a glance in the inspector show nothing but ciphertext. That is the real risk of input left for several days on a shared machine — a reception desk, a field tablet.

What is not: a script running on the origin can use the key, and therefore decrypt. The encryption is not a protection against script injection; it prevents the key being exfiltrated and the storage being read in the clear, nothing more.

Every write uses a fresh initialisation vector: reusing one with the same key, in AES-GCM, would amount to publishing the difference between two drafts.

Without a secure context, nothing is kept

crypto.subtle does not exist over plain HTTP, and IndexedDB is missing in some browsers and in private browsing. In those cases the module writes nothing: the form behaves exactly as before.

The fallback is therefore not “keep it in the clear”. Input stored in the clear on somebody’s disk is worse than the absence of the feature, and nobody would have been warned.

What is never kept

Passwords, files, signatures and payments. And, beyond that list, everything the core declares sensitive — SensitiveValues is authoritative, so that a type added to that list tomorrow stops being kept without anyone having to think about it.

  • File: the value does not designate the contents but a file uploaded to the server. Offline, the upload did not happen.
  • Signature: the stroke binds the person; keeping it pending would amount to keeping a signature detached from what it signs.
  • Payment: the payment is held with the gateway, online.
  • Password: already sensitive to the core.

The exclusion happens before the write, not after: writing and then erasing would leave an interval, however short, during which the value exists on disk. The server publishes the ids concerned in the markup, and the builder warns the administrator while they build — rather than letting them find out, when the network comes back, that an attachment has disappeared.

The submission token is not kept either: last week’s draft token is worth nothing any more, and a fresh one is requested at the moment of sending.

One draft, one submission

That is the module’s guarantee, and the browser cannot give it: it loses the answer at the very moment the network drops, and then cannot tell “never sent” from “sent, answer lost”.

It therefore holds server-side. The browser draws a random send id — thirty-two bytes — and the slf_offline_submissions table makes it its primary key. Two submissions from the same draft are structurally impossible.

The reservation precedes the submission. The insert happens before calling the handler, with an entry that is still unknown. Two simultaneous sends — two tabs, a network returning while the queue drains — cannot get through the door together: it is the insert failure that decides, not a read followed by a write between which anything can happen.

A refused submission releases its reservation: an expired captcha or an empty required field must not condemn for good a draft the visitor will be able to correct.

A send already processed receives a success, with no confirmation to display — the visitor saw theirs on the first send. What the queue is waiting for there is permission to forget that draft.

That table contains no answers: a randomly drawn id, the submission it produced, a date. It is not a server-side draft store, which the plan rules out: the row is born after the entry, never before. It disappears with the submission it designates, and a daily task erases those that exceed the maximum retention.

The deferred send route

The plan asked for the normal submission route to be called. The module calls the normal handler — same validations, same anti-spam, same payment guard, same events, same entry — through a Pro route that is only a door onto it. Nothing of the pipeline is duplicated.

What that door adds, and what the core’s route cannot give, is an idempotent answer. If a second send received an error, the queue would hold on to it and block everything; if it received a success without verification, it would create a second entry.

Getting that from the core’s route would have meant introducing the notion of a send id into it — that is, naming a paid feature inside the core, for a single consumer.

The route requires the mode to be active on the form it targets: without that check, it would offer every form on the site a second entrance its author had not opened. The id must be sixty-four hexadecimal characters — a guessed id would allow somebody to claim the answer of a send that is not theirs.

The send is held, not intercepted

The module does not intercept the submission before the core: it uses context.hold(), the extension point the payments module already uses to hold a send back until the payment is confirmed. Short-circuiting the core’s listeners would have meant reproducing afterwards everything they do.

Resuming and erasing

An earlier draft is offered, never imposed: filling the form in without saying so would make somebody believe they had typed something they had not. Two buttons — resume, erase — and erasing is immediate and local.

Empty drafts are not kept: a form opened and then left without anything being filled in would otherwise fill the list with entries nobody recognises.

What the module does not do

No synchronisation between devices, no automatic server-side storage, no edit-conflict handling, no operation without IndexedDB. No installable application either: the plan reserves that for a later version, after this one.

A draft does not leave the device and the browser where it was typed. Changing browser, clearing the site’s data or switching to private browsing makes it disappear — that is the counterpart of placing nothing on the server.