@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/LICENSE.md +156 -0
- package/NOTICE +9 -0
- package/README.md +619 -2
- package/dist/drivers-DtJsZ_HW.mjs +3328 -0
- package/dist/drivers-DtJsZ_HW.mjs.map +1 -0
- package/dist/identity-keys-cli.d.mts +1 -0
- package/dist/identity-keys-cli.mjs +69 -0
- package/dist/identity-keys-cli.mjs.map +1 -0
- package/dist/identity-settings-BlQY9gHy.mjs +165 -0
- package/dist/identity-settings-BlQY9gHy.mjs.map +1 -0
- package/dist/index.d.mts +733 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +3 -0
- package/dist/main.d.mts +1 -0
- package/dist/main.mjs +189 -0
- package/dist/main.mjs.map +1 -0
- package/dist/secrets-DhKHG-Y9.mjs +689 -0
- package/dist/secrets-DhKHG-Y9.mjs.map +1 -0
- package/package.json +52 -3
package/README.md
CHANGED
|
@@ -1,3 +1,620 @@
|
|
|
1
|
-
|
|
1
|
+
<!-- Part of Formancy Data: https://github.com/sharkysan/formancy.ai-data -->
|
|
2
2
|
|
|
3
|
-
|
|
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).
|