@formancy/data-server 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,620 @@
1
- # Temporary Holding Version
1
+ <!-- Part of Formancy Data: https://github.com/sharkysan/formancy.ai-data -->
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ # @formancy/data-server
4
+
5
+ The Formancy Data server: Fastify, like formancy's own. What exists today is the
6
+ foundation the record, lookup and publication routes will stand on, and two
7
+ routes — no more, because a route that answered before the operations behind it
8
+ existed would be documented and inert.
9
+
10
+ - **`createFileConfigurationStore(root)`** — published bundles as immutable,
11
+ versioned JSON files. Compare-and-swap is a hard link, so of two
12
+ administrators publishing from one base exactly one wins
13
+ ([0013](../../docs/decisions/0013-published-configuration-is-files-with-link-based-swap.md)).
14
+ A version whose file does not parse throws `UnparsableVersionError` from
15
+ `read`, so a publish over it can tell a damaged file from a read that
16
+ failed
17
+ ([0039](../../docs/decisions/0039-a-publish-says-what-happens-to-the-grants-of-reassigned-keys.md)).
18
+ - **`createIdentityVerifier(options)`** — the host application's JWT, verified
19
+ offline with a pinned algorithm, becomes an actor, roles and attributes such
20
+ as the tenant ([0014](../../docs/decisions/0014-host-identity-is-verified-offline.md)).
21
+ Its key is a shared secret, a public key, or a key set whose `keys()` is
22
+ asked on every token and must return a set `readKeySet` or a builder made
23
+ ([0059](../../docs/decisions/0059-the-host-identity-key-is-a-set-selected-by-kid-and-read-again-on-sighup.md)).
24
+ - **`readKeySet(text, algorithm)`** and **`openKeySetFile(path, algorithm)`**
25
+ — a JSON Web Key Set, each key selected by the kid a token names, read from
26
+ a file now and again whenever `readAgain()` is called: a valid set replaces
27
+ the one in force, anything else leaves it (0059).
28
+ - **`resolveSecret(reference)`** — `env:NAME` or `file:/absolute/path`. Never
29
+ the secret itself, and no error ever repeats one.
30
+ - **`createDataServer(options)`** — `GET /health`, and `GET /v1/whoami`, which
31
+ returns exactly the identity the server derived from the host's token so a
32
+ wrong issuer, audience or claim mapping shows before any form depends on it.
33
+ `runtime.origins` lists the origins the runtime plane answers across
34
+ origins, and the administrator's plane answers none (0060, *Across
35
+ origins* below).
36
+
37
+ - **`validateBundle(document)`** — a published version is the form, its
38
+ bindings, its policy and the snapshot they came from, checked on publish and
39
+ on every read: a hand-edited file is refused, not served
40
+ ([0019](../../docs/decisions/0019-a-published-form-is-checked-every-time-it-is-read.md)).
41
+ It never throws: bindings of another shape than it reads are a problem
42
+ like any other (0045). A version published before bindings version 2 is refused with "republish",
43
+ and so is a row filter on a column the snapshot's account may not read, or,
44
+ while the form offers create, a root filter on a column it may not
45
+ `INSERT`, which every create writes
46
+ ([0027](../../docs/decisions/0027-a-snapshot-says-what-its-account-may-do.md)).
47
+ A row filter on a column a filter cannot compare — a boolean, a float, a
48
+ time or a timestamp — or one the table lacks is refused the same way
49
+ ([0028](../../docs/decisions/0028-filters-labels-and-refusals-mean-the-same-on-both-engines.md)),
50
+ and so is a policy's `through` over a lookup that offers every row, by
51
+ `validatePolicy`, or over a text key or a lookup that does not configure,
52
+ by `throughProblems`; the planner asks both too
53
+ ([0043](../../docs/decisions/0043-a-child-forms-rows-are-reached-only-through-a-parent-its-policy-admits.md)).
54
+ Since [0030](../../docs/decisions/0030-presentation-is-a-patch-over-the-generated-base.md)
55
+ a bundle is format 2: it also keeps the generation request, the generated
56
+ base and the presentation chosen over it, and its form must be that base
57
+ with that presentation applied. Format 1 is still read and served, and no
58
+ longer published. Publish also checks that this server's generator writes
59
+ the stored base and bindings from the stored snapshot and request; reads
60
+ do not, so a later generator never makes a stored version corrupt. Reads
61
+ do check what the stored request alone decides — its title, root,
62
+ confirmed version column, pins over written fields and lookups — against
63
+ the base and bindings, because a regeneration generates from that request;
64
+ a pin taken out by hand is the one edit to it that no check short of the
65
+ generator can see. Publish and reads alike refuse a form or base that is
66
+ not in the spec version the generator writes, data-core's
67
+ `GENERATED_SPEC_VERSION`, or that carries a later version's construct,
68
+ because formancy's validator accepts every version its release speaks and
69
+ a host page's renderer may not
70
+ ([0042](../../docs/decisions/0042-generated-forms-stay-on-spec-3-after-spec-4-is-released.md)).
71
+ - **`DRIVER_FACTORIES`** — one connection factory per engine, opening every
72
+ connection through the adapter package's own `connect…` function, so a host
73
+ embedding the server never builds a pool from another copy of a driver
74
+ ([0025](../../docs/decisions/0025-each-adapter-owns-its-driver.md)).
75
+ - **`parseConnections` and `createConnectionRegistry`** — the allowlist of
76
+ databases a form may bind to, with secret references for passwords, opened
77
+ lazily through driver factories the composition root supplies.
78
+ - **`ROUTE_OPERATIONS`** and the type **`RuntimeOperation`** — the runtime
79
+ plane's routes, `METHOD url`, each with the name its audit event gives it:
80
+ what a host is allowed to send, which the host page's suite holds every
81
+ request of its journey to
82
+ ([0047](../../docs/decisions/0047-a-hosts-workspace-lists-opens-and-edits-records-in-linked-grids.md)).
83
+
84
+ - **The administrator's plane** — connections, discovery, proposals,
85
+ publication and drift, for a host token holding an administrator role
86
+ ([0020](../../docs/decisions/0020-administration-is-a-separate-plane.md)).
87
+ Registered only when the server is given a registry, a store and those roles.
88
+ Since [0030](../../docs/decisions/0030-presentation-is-a-patch-over-the-generated-base.md):
89
+ `GET /v1/forms/:id/versions` and `GET /v1/forms/:id/versions/:n` list and
90
+ read versions, each validated as the latest is; `POST
91
+ /v1/forms/:id/regenerations` rediscovers, regenerates from the stored
92
+ request, carries the presentation by the column or lookup each edit was
93
+ chosen for, and reports what it could not carry, lookups the runtime would
94
+ now refuse, keys that now name another column and the policy's problems
95
+ with the new bindings — and writes nothing; `POST
96
+ /v1/forms/:id/restorations` republishes an older version — the same
97
+ document, written as the store writes every version, so the same bytes for
98
+ a version this store wrote — only when drift against it blocks nothing. A
99
+ restore brings back that version's policy and is not a database rollback.
100
+ Since [0039](../../docs/decisions/0039-a-publish-says-what-happens-to-the-grants-of-reassigned-keys.md),
101
+ `POST /v1/forms/:id/versions` refuses, 422 `keys-reassigned` with the
102
+ `keys` and a sentence for each, a version whose policy gives a role, read
103
+ or write, on a key the version it replaces bound to another column or
104
+ lookup, or names it in `through`, unless the body's `keysConfirmed` lists
105
+ that key exactly as the regeneration's `keysReassigned` reported it. A
106
+ through is a grant without a role: re-pointed to another foreign key, its
107
+ filter scopes the form's rows through another parent
108
+ ([0043](../../docs/decisions/0043-a-child-forms-rows-are-reached-only-through-a-parent-its-policy-admits.md)).
109
+ A key with no role and no through left needs nothing, which is what
110
+ removing its grants looks like — a lookup's filter, which every lookup
111
+ field has, is not a grant by itself; a stale
112
+ base is still 409 `conflict` first; a restore is not asked, because it
113
+ brings a policy back with the bindings it was written for; and a publish
114
+ over a version the server no longer serves, because its file does not
115
+ parse or does not validate, is not checked. It compares with the version
116
+ replaced and no other, and by column or foreign-key name: grants removed
117
+ in one version and given back in the next, or carried to another table by
118
+ a publish that moves the form there, are not asked about.
119
+ Since [0062](../../docs/decisions/0062-a-published-version-moves-between-deployments-as-its-version-file.md),
120
+ `POST /v1/forms/:id/imports`, body `{ expectedBase, bundle,
121
+ keysConfirmed? }`, writes another deployment's version file -- read there
122
+ as `GET /v1/forms/:id/versions/:n` -- as a new version here, as it came:
123
+ 400 for a body of another shape; 422 `invalid-bundle` for what a publish
124
+ refuses, a connection this allowlist does not have by the same name among
125
+ it; 503 when the database cannot be read; 422 `other-engine` for a
126
+ database of another engine; 409 `incompatible` with only the blocking
127
+ changes; 422 `keys-reassigned` against this deployment's base, a stale
128
+ base the conflict first; 409 `conflict` with `current`. 201 answers
129
+ `{ version, drift }` (the type `Importation`). Nothing is renamed, since a
130
+ lookup's source name is made from the connection's. A restore over a
131
+ database of another engine is 422 `other-engine` too, where it was 500.
132
+ Every request on the plane is audited, a publish, a restore and an
133
+ import included
134
+ ([0033](../../docs/decisions/0033-the-administrator-plane-is-audited.md)):
135
+ see the audit trail below.
136
+
137
+ - **The runtime plane** — a published form, its records and its lookups, for
138
+ the host application's people, each request asking the policy
139
+ ([0022](../../docs/decisions/0022-the-runtime-plane-asks-the-policy-every-time.md)).
140
+ Registered only when the server is given a registry and a store. A lookup's
141
+ filter is scoped as a record request's is, so a trusted value its column
142
+ does not hold in that spelling is 403 `invalid-context` for both. A write
143
+ the database refused for a reason with no code of its own — a trigger's
144
+ error, a declined write — is 422 `refused`, which says sending it again
145
+ will be refused the same way; 503 `unavailable` is kept for what passes
146
+ (0028). A write sent to the database whose answer was lost is 502
147
+ `unknown-outcome` with `operation`, `record` and `version`: an update's
148
+ token and version as sent, a create's token when the insert names its key
149
+ -- a key the person types, a tenant pinned from the token -- and `null` when
150
+ the database numbers it. Each sentence says what is safe next, and the
151
+ route asks the adapter once and never again
152
+ ([0031](../../docs/decisions/0031-an-answer-lost-after-a-write-is-unknown.md)).
153
+ A read never answers one; an adapter that reported one for a read is a 500.
154
+ `e2e-lost-answer.integration.test.ts` drops the database's answer after the
155
+ commit, on both engines, and fails if the write crosses to the database a
156
+ second time.
157
+
158
+ A lookup's statement stops when its request is abandoned or its deadline
159
+ passes ([0061](../../docs/decisions/0061-a-lookup-statement-stops-when-its-request-is-abandoned-or-its-deadline-passes.md)). The route passes the adapter a signal that aborts
160
+ when the response or its socket closes before the answer
161
+ (`routes/abandoned.ts`), or when `lookupDeadlineMs` passes, counted from
162
+ when the adapter is asked for the statement, so a wait for a pooled
163
+ connection counts and reading the form and opening its connection do not;
164
+ the adapter stops the statement with its driver's own cancel. A search
165
+ past the deadline is 503 `lookup-timeout`, whose sentence says the same
166
+ search will take as long again; a resolve, which reads by key and so
167
+ waited rather than read too much, is 503 `unavailable`, which invites
168
+ another try, the log naming the deadline; one whose client left is
169
+ answered 499 `abandoned`, which nobody reads and the audit trail records;
170
+ each membership check inside a create or an update has a deadline of its
171
+ own and no abandonment, since a write goes on after its client leaves,
172
+ and past it is the usual 503 `unavailable`, nothing saved, the log saying
173
+ the deadline passed. A lookup that completes
174
+ is answered as before. On PostgreSQL the lookups run on a client of their
175
+ own, `application_name` `formancy-data lookups` (`drivers.ts`), so a
176
+ cancel PostgreSQL delivers late can stop only another lookup, never a
177
+ record's statement; each allowlisted PostgreSQL database may use up to
178
+ twice the connections. `routes/runtime-lookup-limits.integration.test.ts`
179
+ holds each on both engines with a statement waiting behind a lock.
180
+
181
+ Every record request, and opening a form, is decided over the form's
182
+ table as the catalog describes it then
183
+ ([0041](../../docs/decisions/0041-the-runtime-refuses-what-drift-blocks.md)),
184
+ with drift review's own rules, after the policy -- an actor it refuses
185
+ reaches no statement. A read and an update by an actor who may read take
186
+ the description from the read they make anyway; opening a form, a create,
187
+ and an update by an actor who may not read the record, or whose read
188
+ failed, describe the table in one statement, inside the write's sending.
189
+ What the description stops is 409 `drift`, with a sentence that says an
190
+ administrator must review the form and names no column: a read whose form
191
+ can no longer show its records faithfully -- its values never leave the
192
+ server -- and a write the form cannot make safely, with no insert, update
193
+ or membership check sent. The log says which changes, by kind and the
194
+ fields they affect. `GET /v1/forms/:id` lists only the operations the
195
+ database still allows and the person's context can scope -- a row
196
+ filter's attribute and a through's (0043) -- is 403 when that leaves
197
+ none, is 409 `drift` when the form cannot be read or
198
+ leaves the person nothing, and asks the database, so an unreachable one is
199
+ 503. A write the adapter refuses because the table moved since its
200
+ description is 409 `schema-changed`, and nothing was written. Lookups are
201
+ searched and resolved whatever drift says. `routes/runtime.test.ts` holds
202
+ the order and the counts over fake ports, and
203
+ `routes/runtime-drift.integration.test.ts` every change of `DRIFTING`
204
+ (`@formancy/data-fixtures`) on both engines.
205
+
206
+ `POST /v1/forms/:id/records/list` answers one page of the records a person
207
+ may read, `{ rows, hasMore }` and no total
208
+ ([0044](../../docs/decisions/0044-a-forms-records-are-listed-a-page-at-a-time-under-its-read-policy.md)):
209
+ a read of many, under the read filter and every through, each row what
210
+ `records/read` returns for its token, a POST so the search is in no URL,
211
+ and audited as `list` with no record and never the search, the sort or
212
+ the parent. `GET /v1/forms/:id` sends `list` -- what a page may be searched
213
+ and sorted by -- or `null` where the person would be refused one: a key
214
+ that can be NULL or that they may not read, rows nothing scopes though a
215
+ lookup's options are filtered, or a table that no longer holds the key
216
+ unique and NOT NULL. A page is decided over the description its own
217
+ statement carried, as a read is, and is also 409 `drift` when that
218
+ description no longer holds the key unique and NOT NULL; its log line
219
+ says `list`, as the audit does. `routes/runtime-lists.test.ts` holds the
220
+ route over fake ports.
221
+
222
+ `POST /v1/forms/:id/records/delete` deletes one record by its token and
223
+ the version its person read, `{ record, version }` with a write id, under
224
+ the policy's `operations.delete`
225
+ ([0050](../../docs/decisions/0050-a-record-is-deleted-one-at-a-time-by-its-token-and-the-version-its-person-read.md)):
226
+ the policy before any statement, the table described and the delete
227
+ planned over it, one guarded statement that deletes one row or none and
228
+ refuses a foreign key that would act on the delete unless the policy's
229
+ `cascades` names it with its action ([0051](../../docs/decisions/0051-a-delete-reaches-other-tables-only-through-foreign-keys-the-policy-names.md)). 200 `{ record,
230
+ deleted: true }`, the token of the row deleted and nothing of its values;
231
+ 404 not found or outside the person's rows; 409 `stale`, `drift` or
232
+ `schema-changed`; 422 `referenced` while other rows refer to it, naming
233
+ none -- the log names the key, at info, and no value -- or `refused`;
234
+ 503 "Nothing was deleted."; 502 `unknown-outcome`
235
+ with `operation: 'delete'`, the record and the version, where deleting
236
+ again with that version deletes nothing else. Never a field error.
237
+ Audited as `delete`, by the record's keyed hash. `GET /v1/forms/:id`
238
+ lists `delete` where the bindings offer it, the policy grants it and the
239
+ database allows it, with `deleting: { cascades, clears, restricted }` --
240
+ whether a key the policy accepts deletes or clears other rows, and
241
+ whether one may refuse the delete -- `null` otherwise. `routes/runtime-deletes.test.ts` holds the route over
242
+ fake ports, and `e2e.integration.test.ts` and
243
+ `e2e-lost-answer.integration.test.ts` on both engines.
244
+
245
+ `GET /v1/forms` answers the menu
246
+ ([0045](../../docs/decisions/0045-the-runtime-plane-lists-the-forms-a-person-may-read-and-nothing-about-the-others.md)):
247
+ every published form whose policy lets this person read, in codepoint
248
+ order of its id, with its title, the operations the policy grants of
249
+ what it offers as published -- what `GET /v1/forms/:id` starts from
250
+ before it asks the database -- and its children, the forms whose rows a
251
+ page may list below one of its records with that record's token as the
252
+ list's `parent`: derived by `childFields` from the bundles, and named
253
+ only where the person is offered that list and may read the field. It
254
+ says nothing about any other form. Every request reads and validates
255
+ every published form's newest version and reads each alone
256
+ (`menuForm`), leaving out, with a line in the log, a form whose version
257
+ no longer validates, does not parse or cannot be read, whose connection
258
+ the allowlist does not have, or whose bindings the menu cannot read;
259
+ a store that cannot list its forms is 500 `store-unavailable`, naming
260
+ no path. It asks no database, and costs about 2 ms a published form,
261
+ measured once in-process (0045), which any host token can ask for and
262
+ only the rate limit bounds. Audited as `form-list`, with no form,
263
+ version or record. `routes/runtime-menu.test.ts` holds the route over a
264
+ real store and a registry that refuses every connection;
265
+ `routes/runtime.test.ts` that a form it names opens with the operations
266
+ it named.
267
+
268
+ An update reads the record first and removes the echo it can prove
269
+ ([0022](../../docs/decisions/0022-the-runtime-plane-asks-the-policy-every-time.md)):
270
+ a field the actor may not write, equal to the record as read; and,
271
+ through `planUpdate`, an instant or a time the actor may write and read,
272
+ equal to the record as read at the version the update names
273
+ ([0040](../../docs/decisions/0040-instants-and-times-are-read-to-the-shape-and-an-unedited-one-is-never-written.md)).
274
+ Both adapters read those cut to formancy's shape, so written back they
275
+ would replace the stored fraction or seconds. A form whose only change was
276
+ such an echo is 400 `nothing-to-update`; an update carrying one is
277
+ answered with the read's own failure when the read before it failed, 503
278
+ for `unavailable`, 404 for `not-found`; and the read runs inside the
279
+ update's sending, so a resend is answered before anything is asked.
280
+ `routes/runtime-temporal.test.ts` holds each of these over fake ports. The
281
+ host suite's `temporal-round-trip*.test.ts` holds, on both engines, what
282
+ each engine then stores: the echo removed, a change written, nothing
283
+ written for echoes alone, and what a field or a record the clerk may not
284
+ read stores; the stale echo, the failed read and the resend run over the
285
+ fake ports only.
286
+
287
+ A write that arrives again is answered, not applied. Over HTTP/1.1,
288
+ Chromium and Firefox send a request again -- a POST too -- when a
289
+ connection they reused closed after the server read it and before any
290
+ byte of an answer, below the page, so a create whose answer the network
291
+ lost after the commit used to be stored twice behind one "Created"
292
+ (measured in Chromium, 0031; in both, 0058). WebKit on Linux sends every
293
+ POST on a new connection and never twice ([0058](../../docs/decisions/0058-the-browser-gates-run-in-chromium-firefox-and-webkit-and-each-fact-the-browsers-disagree-on-is-named-and-held.md)).
294
+ `@formancy/data-client` sends a new `formancy-write-id` with every create
295
+ and update; a request that arrives with an id this process has already
296
+ acted on, for the same person, form and operation, gets the first
297
+ sending's answer -- awaited, if it is still with the database -- and the
298
+ database is not asked again. The same id with another body is 400
299
+ `invalid-request`, and so is an id that is not one. Answers are kept for
300
+ ten minutes, at most 10,000 of them and 32 MiB, oldest dropped first. A
301
+ resend was measured arriving within 6 ms of the close, and a create's
302
+ answer at about 236 bytes (2026-10-09, the host page's browser gate);
303
+ ten minutes is a margin over that, not a measurement; on 2026-10-13, in
304
+ the seven passes of one run of the host page's gate on the shared machine
305
+ 0058's *Measured* names, Chromium's arrived 10.1 to 13.8 ms after the
306
+ close and Firefox's 20.6 to 27.3 ms. Held in this process only: a resend that reaches
307
+ another replica behind a balancer, or this one after a restart, is
308
+ applied again, and a request without an id is a write of its own every
309
+ time. The host page's browser gate measures the resend at the socket and
310
+ fails if the order is stored twice.
311
+
312
+ - **An operational audit trail** — one event per request on both planes,
313
+ each naming its `plane`, never a value, records named by a keyed hash
314
+ ([0023](../../docs/decisions/0023-the-audit-trail-is-operational-not-evidence.md)).
315
+ Not evidence, and the record says why. A create whose answer was lost is
316
+ named by the record it would have made, when the insert names its key; one
317
+ the database numbers names none. A write answered with an earlier
318
+ sending's answer is audited as `repeated`, with the record that answer
319
+ names, so a resend is not counted as a second write (0031).
320
+
321
+ On the administrator's plane
322
+ ([0033](../../docs/decisions/0033-the-administrator-plane-is-audited.md))
323
+ an event names the actor, the operation, the connection, the form and the
324
+ version read, compared, regenerated from or written; a publish, a
325
+ restore and an import also the base the administrator named, and a
326
+ restore the version it copied, which nothing on disk records. Refusals are events too: a 403
327
+ names the actor it refused. Never the bundle, the policy, a snapshot, a
328
+ drift report or a message. On both planes a connection is recorded only
329
+ when the allowlist knows it and a form id only when it has a form id's
330
+ shape, so a connection string typed into a path or a body never reaches
331
+ the trail. A bare host name, an IP address or a lower-case word typed
332
+ where a form id goes is a form id, though, and is recorded as typed.
333
+
334
+ An event is written once for every request that reaches a route of either
335
+ plane, however it ends: a client that disconnected while the route ran,
336
+ or whose request was pipelined behind another on a connection that
337
+ closed, included. The token is read after the body, so a body Fastify
338
+ refuses (400, 413 or 415, or a client gone mid-body) is audited with no
339
+ actor, whatever token it carried. What is answered before any route runs
340
+ no hook of either plane and is not audited: a path no route matches (404,
341
+ or 400 `path-not-plain` when it is not plain, below), a parameter
342
+ past the router's limit (414) or one whose percent-encoding does not
343
+ decode (400 `FST_ERR_BAD_URL`), the 503 Fastify sends while the server
344
+ closes, and what Node's HTTP parser refuses before Fastify sees a request
345
+ (431 for headers past its limit, 400 for a request it cannot parse). A
346
+ preflight is answered by a route of neither plane and is not audited
347
+ either (0060); a token-bearing request refused across origins on a
348
+ plane's route is that plane's event, with no actor, and on `/v1/whoami`
349
+ or `/health` is none. The
350
+ administrator's plane needs a sink
351
+ (`admin.audit.sink`) and does not start without one; the runtime's is
352
+ optional. A route registered on either plane without an audit name stops
353
+ the server from starting, or, registered above the plane's hooks, answers
354
+ 500 `unaudited-route` and runs nothing.
355
+
356
+ ## Running it
357
+
358
+ ```bash
359
+ FORMANCY_DATA_ISSUER=https://host.example \
360
+ FORMANCY_DATA_AUDIENCE=formancy-data \
361
+ FORMANCY_DATA_IDENTITY_SECRET=env:HOST_JWT_SECRET \
362
+ FORMANCY_DATA_ATTRIBUTES=tenant=tid \
363
+ node dist/main.mjs
364
+ ```
365
+
366
+ Every missing setting stops the process with a sentence naming it. The planes
367
+ are turned on by configuration:
368
+
369
+ | Variable | Turns on |
370
+ | --- | --- |
371
+ | `FORMANCY_DATA_STORE_DIR` and `FORMANCY_DATA_CONNECTIONS` | The runtime plane: published forms, records, lookups. The connections file is the allowlist (JSON); passwords in it are `env:` or `file:` references. |
372
+ | `FORMANCY_DATA_ADMIN_ROLES` | The administrator's plane, for tokens holding one of these comma-separated roles. Needs the two above. Its requests are audited to the same log as the runtime's, one `audit` line per event, told apart by `audit.plane`. |
373
+ | `FORMANCY_DATA_AUDIT_KEY` | Records named in the audit trail by a keyed hash. A secret reference. Without it, no record is named. |
374
+
375
+ Without the store and the allowlist the server verifies tokens and nothing
376
+ else, which is how a host is wired in before any form exists.
377
+
378
+ The host's identity is set by exactly one of three settings, beside
379
+ `FORMANCY_DATA_ISSUER`, `FORMANCY_DATA_AUDIENCE` and
380
+ `FORMANCY_DATA_ATTRIBUTES`
381
+ ([0014](../../docs/decisions/0014-host-identity-is-verified-offline.md),
382
+ [0059](../../docs/decisions/0059-the-host-identity-key-is-a-set-selected-by-kid-and-read-again-on-sighup.md)):
383
+
384
+ | Variable | Key |
385
+ | --- | --- |
386
+ | `FORMANCY_DATA_IDENTITY_SECRET` | A shared secret, HS256, as a secret reference. `FORMANCY_DATA_IDENTITY_ALGORITHM` is not read. |
387
+ | `FORMANCY_DATA_IDENTITY_PUBLIC_KEY_FILE` | A PEM public key, with `FORMANCY_DATA_IDENTITY_ALGORITHM` RS256, ES256 or EdDSA. HS256 beside it stops the process: a public key's text is never a shared secret. |
388
+ | `FORMANCY_DATA_IDENTITY_KEYS_FILE` | The absolute path of a JSON Web Key Set, with `FORMANCY_DATA_IDENTITY_ALGORITHM` RS256, ES256, EdDSA or HS256 for every key in it, read again on SIGHUP. |
389
+
390
+ `src/identity-settings.test.ts` holds what these accept and refuse. The two
391
+ one-key settings rotate by restart; a key set rotates as below.
392
+
393
+ | Variable | Sets |
394
+ | --- | --- |
395
+ | `FORMANCY_DATA_RATE_LIMIT` | Requests a minute each client address may send to every route but `/health`: a whole number of at least 1, 600 when unset. Anything else stops the process with a sentence naming the setting. Behind a reverse proxy every client shares the proxy's address, so the limit is one bucket for all of them: Fastify's `trustProxy` is not offered, and the setting raises that bucket rather than splitting it. |
396
+ | `FORMANCY_DATA_CORS_ORIGINS` | The origins a page may call the runtime plane from, comma-separated, each exactly as a browser sends `Origin`: `https://app.example`, or plain HTTP on `localhost`, `127.0.0.1` or `[::1]`. Empty or unset, none. A wildcard, a pattern, `null`, a path or a spelling no browser sends stops the process, and so does a list without the runtime plane. Read at start ([0060](../../docs/decisions/0060-the-runtime-plane-answers-the-origins-an-operator-lists-and-the-administrators-plane-none.md)); see *Across origins*, below. |
397
+ | `FORMANCY_DATA_LOOKUP_DEADLINE_MS` | How long a lookup statement may run, in milliseconds counted from when the adapter is asked for it, before the server stops it ([0061](../../docs/decisions/0061-a-lookup-statement-stops-when-its-request-is-abandoned-or-its-deadline-passes.md)): a whole number from 1 to 2147483647, the longest a Node timer holds. Unset, `DEFAULT_LOOKUP_DEADLINE_MS` from `@formancy/data-core`, the slowest lookup answer `docs/performance.md` measured, which describes that machine and data: a slower or larger deployment raises it. Anything else stops the process with a sentence naming the setting, and `createDataServer` refuses the same as `runtime.lookupDeadlineMs`. On SQL Server mssql's default request timeout of 15 s still cuts any deadline set above it, answered `unavailable`. |
398
+ | `PORT` | The port it listens on, 4390 when unset. |
399
+
400
+ `src/rate-limit.test.ts` holds what the rate-limit setting accepts and
401
+ refuses, `src/cors.test.ts` what the origin list does, and
402
+ `src/lookup-deadline.test.ts` what the lookup deadline accepts.
403
+
404
+ The log is JSON lines on standard output: Fastify's lines for each request,
405
+ and the audit trail. A request is logged by its method and the route it
406
+ matched, never by the path, the query or the Host header it was sent with,
407
+ because those are whatever a caller typed, token or none
408
+ ([0033](../../docs/decisions/0033-the-administrator-plane-is-audited.md));
409
+ a 404 is logged with no route. A request refused across origins names its
410
+ `Origin` only in the form a browser sends one, which holds no user
411
+ information, path or query (0060). `src/server-log.test.ts` fails when a
412
+ line holds what was sent.
413
+
414
+ A path is routed only when it is plain
415
+ ([0037](../../docs/decisions/0037-cloudflare-is-the-edge-not-the-runtime.md),
416
+ which [the Cloudflare guide](../../docs/deploy/cloudflare.md) rests on):
417
+ a `/`, then segments that are not empty, not `.` or `..`, and made of
418
+ letters, digits and `-._~!$&'()*+,;=:@` only -- no `%` escape, no backslash,
419
+ no doubled or trailing slash. Every other path is 400 `path-not-plain`
420
+ before any route's work: before the token is read and before either plane's
421
+ hooks, so an administrator's token sent with it runs nothing. The reason is
422
+ a proxy that chooses where to forward by the path after decoding it,
423
+ resolving its dot segments and collapsing its slashes, as cloudflared
424
+ 2026.10.0 was measured doing, while the router routes the path as sent: a
425
+ door that forwards only the runtime plane's routes would otherwise forward
426
+ `/v1/forms/a/versions/..%2F..%2Fb`, which the router runs as an
427
+ administrator's version read. No name the server accepts needs any of it,
428
+ because the studio and `@formancy/data-client` send names through
429
+ `encodeURIComponent`, which leaves a valid name as it is; a name that is not
430
+ valid and needed escaping now gets this 400 where it used to get the
431
+ route's 400, 401 or 404. The query is not looked at, since nothing routes by
432
+ it. A refusal counts against the rate limit, and on a path that matched a
433
+ route it is that plane's one audit event, status 400, outcome
434
+ `path-not-plain` and no actor; on a path that matched none it is no event.
435
+ An escape that does not decode is still the router's own 400
436
+ `FST_ERR_BAD_URL`, answered before this check and not counted.
437
+ `src/paths.test.ts` holds what is plain, and derives from the store's and
438
+ the allowlist's own checks that every form and connection id is.
439
+ `src/app.test.ts` sends the paths cloudflared read differently over a real
440
+ socket, because light-my-request resolves dot segments and backslashes
441
+ before the router would see them, and fails when one reaches a route, a
442
+ token is read for one, or one escapes the limit.
443
+
444
+ Every answer that runs the server's hooks says `Cache-Control: no-store`,
445
+ success, refusal or error alike, over anything a route set: a form id may
446
+ end in `.js`, which a CDN caches by extension, and a cache that does not
447
+ treat `Authorization` as private would hand one person's form or record to
448
+ the next. What is written before any hook carries none: the router's 400
449
+ for an escape that does not decode and its 414 for a parameter past its
450
+ limit, which `src/app.test.ts` holds, and, not exercised there, the 503
451
+ while the server closes and what Node's parser refuses. None of those holds
452
+ a form or a record.
453
+
454
+ The image (`packages/data-server/Dockerfile`) runs as `node` and owns
455
+ `/var/lib/formancy-data`, the directory to mount the store's volume on and to
456
+ name in `FORMANCY_DATA_STORE_DIR`. Docker gives a new named volume the owner
457
+ of the directory it is mounted on, so a volume there is the server's to
458
+ write; without that directory the first publish fails with `EACCES`, and the
459
+ CI container job fails if `node` cannot write it. The image declares no
460
+ `VOLUME`: mount one, or the published forms go with the container.
461
+
462
+ `compose.yaml`'s `stack` profile runs the image that way -- both planes, the
463
+ allowlist in `deploy/connections.json`, an audit key, and every secret a
464
+ `file:` reference into a volume -- behind one nginx that serves the studio
465
+ and the host page on the same origin as `/v1`, with a token minter beside it
466
+ for the demo. [`docs/getting-started.md`](../../docs/getting-started.md) walks
467
+ through it, and CI runs that guide as written
468
+ ([0032](../../docs/decisions/0032-a-clean-install-is-the-composed-stack-and-ci-runs-its-guide.md)).
469
+ It is an evaluation stack, not a deployment: the guide's section 8 says what
470
+ a deployment changes.
471
+
472
+ What each runtime request but a page of records costs through
473
+ `dist/main.mjs`, configured this way, on both engines and with the machine,
474
+ the database's size and the date it was measured on, is in
475
+ [`docs/performance.md`](../../docs/performance.md)
476
+ ([0034](../../docs/decisions/0034-performance-is-measured-through-the-shipped-server-and-held-without-a-clock.md));
477
+ lists came after those figures, and 0044 says what measuring them needs.
478
+
479
+ ### Rotating the identity key
480
+
481
+ A key set file is `{"keys":[…]}`, a provider's certs document as it is
482
+ published. Each key in force needs a `kid` of `A-Z a-z 0-9 . _ ~ + / = -`,
483
+ which selects it for a token that names it; a token without one is tried
484
+ against each key in turn. A key that cannot verify the pinned algorithm --
485
+ another `kty` or `crv`, a `use` other than `sig`, a `key_ops` without
486
+ `verify`, another `alg` -- is left out and named in the log with the member
487
+ that left it out, as Keycloak's `rsa-enc-generated` is `because: 'use'`. A
488
+ private key member anywhere, a kid missing or repeated among the keys in
489
+ force, a key that does not import, an RSA key under 2048 bits or with a
490
+ public exponent that is even or under 65537, an Ed25519 point of small order,
491
+ or an HS256 key under 32 bytes refuses the whole file, in a sentence naming
492
+ the key by kid or by place and never quoting it. A kid is logged as written,
493
+ so an HS256 key's kid is a name, never key material: one that is its own `k`,
494
+ or 43 base64 characters or more -- as long as a secret -- refuses the file.
495
+ The path must be a regular file; a FIFO or a directory is refused.
496
+
497
+ Moving from the secret or the public key is one command, run where the
498
+ server's environment is -- in the server's container, for example -- so it
499
+ reads the setting exactly as the server does:
500
+
501
+ ```bash
502
+ (umask 077; docker exec <server> node dist/identity-keys-cli.mjs convert host-1 > keys.json)
503
+ ```
504
+
505
+ It writes the key the server verifies with today, under the kid you give, and
506
+ says on standard error which settings to change. For HS256 the file is the
507
+ shared secret: keep it as `FORMANCY_DATA_IDENTITY_SECRET`'s file is kept,
508
+ readable only by the server's user or handed over as a Docker or Kubernetes
509
+ secret, and write every set after it with the same `umask`. Do not write the set by
510
+ hand from a secret: the secret's text is itself valid base64url, so pasted as
511
+ `k` it is accepted and is another key, and a secret file's bytes as read keep
512
+ the newline the server removes. Either way every token is refused.
513
+
514
+ To rotate:
515
+
516
+ 1. Write the new set beside the one in use, in the directory that is mounted
517
+ -- mount the directory, not the file: a file bind-mounted on its own keeps
518
+ the inode it was given, and on Docker 29.8.1 a file renamed over it on the
519
+ host was not seen inside the container.
520
+ 2. Check it with the server's own reading:
521
+ `docker exec <server> node dist/identity-keys-cli.mjs check /run/identity/next.json HS256`
522
+ prints `in force: …` and `left out: …` and exits 0, or prints the refusal
523
+ and exits 1.
524
+ 3. Rename it over the old one, and signal the server:
525
+ `docker kill --signal HUP <server>`, `docker compose kill -s HUP server`,
526
+ `kill -HUP 1` inside the container, or `ExecReload=/bin/kill -HUP $MAINPID`
527
+ under systemd.
528
+ 4. Read the line it logs: `identity keys read again` with the kids `added`,
529
+ `removed` and `replaced`, `identity keys read again; nothing changed`, or,
530
+ at `error`, `identity keys not read again; the set in force is unchanged`
531
+ with the reason. A failed read keeps the set in force, a key you removed
532
+ included, and the same file stops the next start until it is fixed --
533
+ which is why step 2 comes first. A read that never returns, from a
534
+ network mount that stalls, logs nothing, and every signal after it waits
535
+ behind it: no line within seconds is that.
536
+
537
+ On Kubernetes, mount the Secret as a volume and never through `subPath`,
538
+ which Kubernetes says "does not receive automated Secret updates"; a volume
539
+ is updated eventually, so wait until `check` inside the pod lists the new
540
+ kids before `kubectl exec <pod> -- kill -HUP 1`, then compare the logged
541
+ `added`, `removed` and `replaced` with what was written. A signal sent too
542
+ early logs that nothing changed. Neither has been run through a cluster here.
543
+
544
+ A provider's set reaches the file through the operator's own job -- a cron
545
+ entry, a systemd timer, a CronJob -- that fetches the document, writes it to
546
+ a temporary file, runs `check`, renames it into place, sends SIGHUP and reads
547
+ the line. The server fetches nothing (0014). Each instance reads its keys on
548
+ its own signal, and where no process can be sent one, natively on Windows, a
549
+ set is read only at start. The planes line at start names the kids in force
550
+ and what was left out. No line holds key material or a hash of the file.
551
+
552
+ ### Across origins
553
+
554
+ The studio, the host page and `/v1` on one origin is the default, and the
555
+ composed stack's. A host page on an origin of its own -- a static page whose
556
+ identity provider issues the token -- calls the runtime plane directly when
557
+ `FORMANCY_DATA_CORS_ORIGINS` lists that origin exactly
558
+ ([0060](../../docs/decisions/0060-the-runtime-plane-answers-the-origins-an-operator-lists-and-the-administrators-plane-none.md));
559
+ an embedder passes the same list as `runtime.origins`, held to the same rule.
560
+
561
+ - **What is granted.** Each runtime route has an `OPTIONS` route at its
562
+ path. A preflight is granted when its `Origin` is a listed origin byte for
563
+ byte, the method it asks for is the runtime route's at that path -- GET or
564
+ POST, never HEAD -- and reaches that route, and it asks for no header but
565
+ `authorization`, `content-type` and `formancy-write-id`, the ones
566
+ data-client sends that a browser asks about. The grant is 204 with that
567
+ origin, that method, those three headers, `Access-Control-Max-Age: 600` --
568
+ WebKit keeps a grant no longer -- and `Vary`. A browser may send any GET,
569
+ HEAD or POST to that URL on the grant without asking again: Chromium and
570
+ WebKit did, Firefox asked (measured by hand, 0060). Every answer of a
571
+ runtime route then carries `Access-Control-Allow-Origin` for a listed
572
+ origin, refusals included, and `Vary: Origin` for any; what is written
573
+ before any hook, the router's 414 and the 503 while closing among it,
574
+ carries neither.
575
+ - **What is refused.** Any other preflight is 403 `cross-origin-refused`
576
+ with no `Access-Control-*` header, which the page sees as a network
577
+ error: a read is `unreachable`, a write unknown though nothing was sent.
578
+ The administrator's plane, `/v1/whoami` and `/health` have no `OPTIONS`
579
+ route, so their preflight is the router's 404, and no answer of theirs
580
+ carries an origin. On every route, listed origins or none, a request with
581
+ an `Authorization` header whose `Sec-Fetch-Site` is `cross-site` or
582
+ `same-site` is refused 403 before its token is read unless its origin is
583
+ listed and its route is the runtime plane's: a browser that cached a grant
584
+ before its origin was removed sends without asking for up to ten minutes,
585
+ and those requests now do nothing. Counted by the limiter as every route
586
+ but `/health` is, and audited with no actor by the plane whose route it
587
+ reached; on `/v1/whoami` and `/health` no event. A path no route takes is
588
+ the router's 404, token or none. A request with no `Sec-Fetch-Site` --
589
+ every client that is not a
590
+ browser, and browsers before Chrome 76, Firefox 90 and Safari 16.4 -- and
591
+ a `same-origin` one are answered as before. A deployment that put CORS
592
+ headers on its own proxy has its cross-origin requests refused that way
593
+ until it lists their origins.
594
+ - **What it logs.** "preflight refused" and "cross-origin request refused",
595
+ at info, with the reason -- `origin not listed`, `method not offered on
596
+ this route`, `method reaches another route`, `header not allowed`, `not a
597
+ preflight`, `route not offered across origins` -- and the origin, but only
598
+ when it is one a browser could send: no user information, path or query.
599
+ - **What is never sent.** `Access-Control-Allow-Credentials`, so no cookie
600
+ crosses origins; `Access-Control-Expose-Headers`; and
601
+ `Access-Control-Allow-Private-Network`. Preflights are not rate-limited by
602
+ the server and not audited: they read no token and do no work.
603
+ - **What stops the server.** With origins listed, a GET, HEAD or POST route
604
+ of another plane or an embedder's whose pattern could match a path a
605
+ runtime route of any method matches -- a request a browser would send on
606
+ that route's grant -- a route of another method overlapping a runtime
607
+ route of its own method, and any route with a regex parameter, a
608
+ wildcard, two parameters in one segment or a constraint, stop it at
609
+ ready, rather than grant a preflight for one route and have another answer
610
+ a request riding it. The preflight's own check that the method reaches
611
+ the runtime route holds only a route registered before the server's own,
612
+ which createDataServer registers none of.
613
+
614
+ `src/cross-origin.test.ts` holds each of these over the real server, and
615
+ `src/server-log.test.ts` what the log names. No browser gate runs against a
616
+ server with origins listed yet; the one measurement in browsers was by hand.
617
+
618
+ ## Licence
619
+
620
+ Source-available, not open source. See [`LICENSE.md`](./LICENSE.md).