@stonyx/orm 0.3.2-beta.155 → 0.3.2-beta.157
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 +243 -71
- package/dist/hooks.d.ts +15 -1
- package/dist/orm-request.d.ts +106 -23
- package/dist/orm-request.js +149 -23
- package/dist/types/orm-types.d.ts +95 -0
- package/package.json +1 -1
- package/src/hooks.ts +15 -1
- package/src/orm-request.ts +149 -23
- package/src/types/orm-types.ts +96 -0
package/dist/orm-request.js
CHANGED
|
@@ -69,6 +69,15 @@
|
|
|
69
69
|
* records under `model: 'owner'`, and the context gives no signal of that
|
|
70
70
|
* (abofs/stonyx-orm#196).
|
|
71
71
|
*
|
|
72
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
|
|
73
|
+
* CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
|
|
74
|
+
* now also carries `recordId` -- the DECODED route-parameter id, see
|
|
75
|
+
* `AccessContext.recordId` in ./types/orm-types.ts -- so the fixture's
|
|
76
|
+
* `/archived` deny IS expressible from the context alone, and the shipped
|
|
77
|
+
* sample no longer reads `request.path` at all. Retiring this wording WITH the
|
|
78
|
+
* measurement that retires it, rather than by deletion, is
|
|
79
|
+
* abofs/stonyx-orm#238.
|
|
80
|
+
*
|
|
72
81
|
* `record` IS NOT IN THIS CONTEXT, deliberately. `auth()` runs after route
|
|
73
82
|
* matching but BEFORE any handler executes (`@stonyx/rest-server`
|
|
74
83
|
* `src/request.ts:58-60`), so nothing has been fetched yet -- supplying a
|
|
@@ -98,8 +107,8 @@
|
|
|
98
107
|
*
|
|
99
108
|
* PASSING THE CONTEXT MAKES A MODEL-CORRECT ANSWER POSSIBLE. It does not make
|
|
100
109
|
* the answer model-correct on its own -- the resolved predicate has to READ it.
|
|
101
|
-
* Measured against
|
|
102
|
-
*
|
|
110
|
+
* Measured against an ARITY-1 predicate, on a request express dispatched to
|
|
111
|
+
* `GET /owners/angela`, asked about ANIMALS:
|
|
103
112
|
*
|
|
104
113
|
* getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
|
|
105
114
|
* -> record => record.id !== 'angela' && record.id !== 'restricted'
|
|
@@ -110,23 +119,30 @@
|
|
|
110
119
|
* `['read', 'create', 'update', 'delete']`, a full CRUD grant. Either way the
|
|
111
120
|
* context was supplied and the answer is not the animal answer, and it is wrong
|
|
112
121
|
* in the GRANTING direction, because that predicate is arity-1 and identifies
|
|
113
|
-
* its collection from the request. (
|
|
114
|
-
*
|
|
122
|
+
* its collection from the request. (Asserted on a live dispatch by AC9 in
|
|
123
|
+
* test/integration/orm-test.ts, against a deliberately arity-1 predicate.)
|
|
115
124
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* the
|
|
125
|
+
* This repo's own sample access class has since been MIGRATED to read the
|
|
126
|
+
* context (abofs/stonyx-orm#222), so `getAccess('animal')` here now answers
|
|
127
|
+
* with the animal filter. That is not true of a consumer tree: an arity-1
|
|
128
|
+
* predicate keeps working -- the second argument is additive -- and the caller
|
|
129
|
+
* has no supported way to tell which kind it got. The boot-time arity warning
|
|
130
|
+
* that surfaces one is abofs/stonyx-orm#221.
|
|
119
131
|
* So: pass the context, and do not treat a resolved predicate's answer as
|
|
120
132
|
* model-specific until that predicate has been migrated to read the context.
|
|
121
133
|
*
|
|
122
134
|
* ---------------------------------------------------------------------------
|
|
123
135
|
* DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
|
|
124
136
|
* ---------------------------------------------------------------------------
|
|
125
|
-
* `auth()` below hands your
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
137
|
+
* You do not have to. `auth()` below hands your predicate the ACCESS CONTEXT as
|
|
138
|
+
* argument two, and `context.model` already names the collection -- see the
|
|
139
|
+
* contract section above. Argument ONE is still the raw transport artifact, and
|
|
140
|
+
* everything from here to the end of this banner is the record of what happened
|
|
141
|
+
* when predicates worked the collection out from it. IT IS HISTORY, NOT
|
|
142
|
+
* GUIDANCE: do not write any of it into a new predicate. Every attempt to
|
|
143
|
+
* identify the collection by parsing the request target has failed OPEN. Five
|
|
144
|
+
* distinct variants of the same three-line example have now been found, each
|
|
145
|
+
* after the previous was fixed, by five different people:
|
|
130
146
|
*
|
|
131
147
|
* 1. `request.url` is mount-relative under `RestServer.mountRoute`, so a
|
|
132
148
|
* prefix match against it is ALWAYS false.
|
|
@@ -145,22 +161,89 @@
|
|
|
145
161
|
* last, and the record comes back in full. It walks past a hard
|
|
146
162
|
* `return false` deny the same way.
|
|
147
163
|
*
|
|
148
|
-
* The fix is not a sixth rule. It is to
|
|
164
|
+
* The fix is not a sixth rule, and it is not a better string to match. It is to
|
|
165
|
+
* stop identifying the collection at all: read `context.model`. That is a claim
|
|
166
|
+
* about IDENTIFYING THE COLLECTION, not about the sample as a whole -- the
|
|
167
|
+
* `/archived` SUB-PATH rule is still a string match, and abofs/stonyx-orm#228 is
|
|
168
|
+
* a sixth spelling that gets past it.
|
|
169
|
+
*
|
|
170
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237: the `/archived` rule is
|
|
171
|
+
* no longer a string match against the request target -- it compares the
|
|
172
|
+
* decoded `recordId` the framework supplies -- and abofs/stonyx-orm#228 is
|
|
173
|
+
* CLOSED. Retirement of this wording: abofs/stonyx-orm#238.
|
|
174
|
+
*
|
|
175
|
+
* An intermediate revision of the sample read `request.baseUrl` -- the mount
|
|
176
|
+
* Express ACTUALLY MATCHED. That closed all five variants (no query string,
|
|
177
|
+
* not mount-relative, unaffected by absolute-form, already carrying the
|
|
178
|
+
* configured `ORM_REST_ROUTE` prefix), but it was a transport artifact
|
|
179
|
+
* standing in for a structural fact and the sample no longer does it.
|
|
180
|
+
* `context.model` IS the structural fact, so variants 1, 2, 4 and 5 are
|
|
181
|
+
* unconstructible against a migrated predicate rather than handled.
|
|
182
|
+
*
|
|
183
|
+
* VARIANT 3 SURVIVES, and is deliberately not in that list. It is the general
|
|
184
|
+
* shape "a hand-written matcher normalises differently from the router", and a
|
|
185
|
+
* migrated predicate still runs one string comparison for any SUB-PATH rule --
|
|
186
|
+
* in the shipped sample, the `/archived` deny. That comparison folds case but
|
|
187
|
+
* does not decode, so `GET /owners/%61rchived` steps past it. See the
|
|
188
|
+
* normalisation paragraph below and abofs/stonyx-orm#228.
|
|
189
|
+
*
|
|
190
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237. Variant 3 lived in that
|
|
191
|
+
* one string comparison, and the comparison is gone: the sample compares the
|
|
192
|
+
* decoded `recordId`. Left standing rather than edited because the same
|
|
193
|
+
* "variant 3 survives" wording sits at four sites -- this header, README.md
|
|
194
|
+
* twice, and test/sample/access/global-access.ts -- three of which SHIP, so
|
|
195
|
+
* retiring one of four leaves the shipped copies contradicting each other.
|
|
196
|
+
* Retiring all four WITH their measurement is abofs/stonyx-orm#238.
|
|
197
|
+
*
|
|
198
|
+
* ONE READ OF ARGUMENT ONE SURVIVES, AND IT MUST: `request.path`. It is
|
|
199
|
+
* mount-relative and query-free, and it is for rules that distinguish SUB-PATHS
|
|
200
|
+
* beneath the mount. The context names which model and which verb, NOT which
|
|
201
|
+
* route, so the sample's `/archived` deny cannot be expressed from the context
|
|
202
|
+
* alone and a context-ONLY rewrite would silently turn that deny into an allow.
|
|
203
|
+
*
|
|
204
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237: NO read of argument one
|
|
205
|
+
* survives in the shipped sample. `recordId` names WHICH RECORD the route was
|
|
206
|
+
* addressed to, so the `/archived` deny is expressible from the context alone
|
|
207
|
+
* -- and it still must not be dropped; expressible is not optional. Retirement
|
|
208
|
+
* of this wording: abofs/stonyx-orm#238.
|
|
209
|
+
*
|
|
210
|
+
* NORMALISE THE WAY THE ROUTER DOES, AND CASE-FOLDING ALONE IS NOT THAT. The
|
|
211
|
+
* sample lower-cases before comparing, because a matcher stricter than the
|
|
212
|
+
* case-insensitive router can be stepped around. That closes the case gap only.
|
|
213
|
+
* Express sets `request.path` from the RAW, UNDECODED pathname while the router
|
|
214
|
+
* DECODES `:id`, so `GET /owners/%61rchived` reaches a `path === '/archived'`
|
|
215
|
+
* comparison as `/%61rchived` and walks past the deny. That gap is live in the
|
|
216
|
+
* sample and is tracked as abofs/stonyx-orm#228; the `.toLowerCase()` is not a
|
|
217
|
+
* complete normalisation recipe. Compare record ids at their real case.
|
|
149
218
|
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
219
|
+
* DO NOT FOLLOW THE PARAGRAPH ABOVE. SUPERSEDED 2026-09-01 BY
|
|
220
|
+
* abofs/stonyx-orm#236/#237, and flagged here rather than merely dated because
|
|
221
|
+
* it is an INSTRUCTION, not a stale observation. `.toLowerCase()` on the access
|
|
222
|
+
* path was measured WRONG IN BOTH DIRECTIONS AT ONCE: with a distinct owner
|
|
223
|
+
* seeded at `ARCHIVED`, `GET /owners/ARCHIVED` was a false DENY on the wrong
|
|
224
|
+
* record and `GET /owners/%41RCHIVED` a false ALLOW on that same record. A
|
|
225
|
+
* record id is a VALUE, not a literal route segment, and express's
|
|
226
|
+
* `case sensitive routing` governs literal segments only. Compare
|
|
227
|
+
* `context.recordId` AS IT ARRIVES: do not case-fold it, do not decode it, do
|
|
228
|
+
* not derive it from `request.path`. `AccessContext.recordId` in
|
|
229
|
+
* ./types/orm-types.ts is the contract and says "Do NOT case-fold it"; the same
|
|
230
|
+
* published tarball ships both files, and THIS paragraph is the one that is
|
|
231
|
+
* wrong. Retiring it WITH its measurement is abofs/stonyx-orm#238.
|
|
157
232
|
*
|
|
158
233
|
* `?? ''` is not a defence. It converts an absent request target into an empty
|
|
159
234
|
* string, which matches no collection, which falls through to the permission
|
|
160
|
-
* array -- a total grant. An input you cannot identify must DENY
|
|
235
|
+
* array -- a total grant. An input you cannot identify must DENY, and that
|
|
236
|
+
* applies to BOTH arguments: since #202 the guard and the read can sit on
|
|
237
|
+
* different objects, and a guard on argument two does not protect a read of
|
|
238
|
+
* argument one. The sample returns `false` for an absent `model` AND for an
|
|
239
|
+
* absent or non-string `request.path`, rather than falling through either way.
|
|
240
|
+
*
|
|
241
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237 as to WHAT is guarded --
|
|
242
|
+
* the principle is unchanged. The sample no longer reads `request.path`, so it
|
|
243
|
+
* returns `false` for an absent `model` AND for an absent `recordId`
|
|
244
|
+
* (`undefined`, the one spelling `auth()` never produces). Retirement of this
|
|
245
|
+
* wording: abofs/stonyx-orm#238.
|
|
161
246
|
*
|
|
162
|
-
* THAT IS STILL A STOPGAP. `baseUrl` closes all five variants, but it is a
|
|
163
|
-
* transport artifact being asked to stand in for a structural fact.
|
|
164
247
|
* THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
|
|
165
248
|
* the operation and the record. Prefer the array shape (`['read']`) or `false`
|
|
166
249
|
* until #202 lands; the function shape is what requires any matching at all.
|
|
@@ -1234,10 +1317,53 @@ export default class OrmRequest extends Request {
|
|
|
1234
1317
|
// src/types/orm-types.ts. Nothing is fetched at this point and adding a
|
|
1235
1318
|
// lookup here would put a store read in the middle of an authorization
|
|
1236
1319
|
// path. The function return shape below IS the per-record hook.
|
|
1320
|
+
//
|
|
1321
|
+
// -------------------------------------------------------------------------
|
|
1322
|
+
// #236 -- `recordId`, the DECODED route-parameter id, for the same reason.
|
|
1323
|
+
//
|
|
1324
|
+
// WHICH RECORD is the third structural fact the framework already holds and
|
|
1325
|
+
// the consumer was left to re-derive, and re-deriving it failed OPEN. The
|
|
1326
|
+
// documented sample compared `request.path` -- the RAW, undecoded pathname
|
|
1327
|
+
// -- against a literal `/archived`, while the router DECODES `:id`. So
|
|
1328
|
+
// `GET /owners/%61rchived` walked past the deny and was dispatched as the
|
|
1329
|
+
// record `archived`: 200 with the record in full, and DELETE answered 204
|
|
1330
|
+
// with the record destroyed, unauthenticated. Four spellings measured, all
|
|
1331
|
+
// four through; 255 non-canonical spellings of that 8-character id decode
|
|
1332
|
+
// to the same key, so this was never a deny-list of one.
|
|
1333
|
+
//
|
|
1334
|
+
// TWO CONSUMER-SIDE NORMALISATIONS WERE MEASURED WRONG IN OPPOSITE
|
|
1335
|
+
// DIRECTIONS, which is the argument for doing it once, here.
|
|
1336
|
+
// `.toLowerCase()` case-folds a route-parameter VALUE on the axis that
|
|
1337
|
+
// governs literal SEGMENTS: with a distinct owner seeded at `ARCHIVED`,
|
|
1338
|
+
// `GET /owners/ARCHIVED` was a false DENY on the wrong record and
|
|
1339
|
+
// `GET /owners/%41RCHIVED` a false ALLOW on that same one.
|
|
1340
|
+
// `decodeURIComponent(request.path)` decodes THEN splits while the router
|
|
1341
|
+
// splits THEN decodes, so it over-denied `/owners/archived%2fx` -- 403 for
|
|
1342
|
+
// a genuinely distinct record. Failing closed there was luck, not design.
|
|
1343
|
+
//
|
|
1344
|
+
// `getId(request.params)` AND NOT `request.params.id`, for exactly the
|
|
1345
|
+
// reason `operation` is a `methodAccessMap` lookup: it is the SAME single
|
|
1346
|
+
// coercion the store lookup one layer down performs, so the predicate and
|
|
1347
|
+
// the dispatch cannot disagree about which record a request addresses.
|
|
1348
|
+
// The raw string would reintroduce that divergence on hex-shaped ids --
|
|
1349
|
+
// `GET /animals/0x2391` looks up record `9105`.
|
|
1350
|
+
//
|
|
1351
|
+
// NOTHING HERE PARSES THE REQUEST TARGET EITHER. `request.params` is what
|
|
1352
|
+
// the router matched, so a mount prefix, an absolute-form target, a query
|
|
1353
|
+
// string or a case-varied mount cannot move this value -- the same
|
|
1354
|
+
// guarantee `model` carries, by the same means.
|
|
1355
|
+
//
|
|
1356
|
+
// `null` and not `undefined` on a collection route, so the KEY IS ALWAYS
|
|
1357
|
+
// PRESENT -- the rule `operation`'s own docblock already establishes. A
|
|
1358
|
+
// context reaching a predicate WITHOUT the key therefore did not come from
|
|
1359
|
+
// here; it was hand-assembled by a caller resolving the predicate through
|
|
1360
|
+
// `Orm.instance.getAccess()`, and that absence stays deniable only because
|
|
1361
|
+
// `auth()` never produces it.
|
|
1237
1362
|
// -------------------------------------------------------------------------
|
|
1238
1363
|
const context = {
|
|
1239
1364
|
model: this.model,
|
|
1240
1365
|
operation: methodAccessMap[request.method],
|
|
1366
|
+
recordId: request.params && 'id' in request.params ? getId(request.params) : null,
|
|
1241
1367
|
};
|
|
1242
1368
|
let access;
|
|
1243
1369
|
try {
|
|
@@ -242,6 +242,101 @@ export interface AccessContext {
|
|
|
242
242
|
* from one that classified the request and found nothing.
|
|
243
243
|
*/
|
|
244
244
|
operation: AccessOperation | undefined;
|
|
245
|
+
/**
|
|
246
|
+
* The record this route was addressed to, as the store key -- or `null` on a
|
|
247
|
+
* collection route, which is addressed to no record (abofs/stonyx-orm#236).
|
|
248
|
+
*
|
|
249
|
+
* IT IS ALREADY DECODED, AND THAT IS THE WHOLE POINT. Express decodes route
|
|
250
|
+
* PARAMETERS while leaving `request.path` raw, so a consumer comparing
|
|
251
|
+
* `request.path` against a literal compares an undecoded string against a
|
|
252
|
+
* decoded dispatch. `GET /owners/%61rchived` reached such a comparison as
|
|
253
|
+
* `/%61rchived`, walked past a `/archived` deny, and was dispatched as the
|
|
254
|
+
* record `archived` -- 200 with the record in full, and `DELETE` destroyed
|
|
255
|
+
* it, unauthenticated. 255 non-canonical spellings of an 8-character id
|
|
256
|
+
* decode to the same key, so a deny-list of spellings is the wrong shape.
|
|
257
|
+
*
|
|
258
|
+
* SO DO NOT NORMALISE THIS, AND DO NOT NORMALISE ANYTHING ELSE INSTEAD:
|
|
259
|
+
*
|
|
260
|
+
* - Do NOT decode it. Express decodes exactly ONCE, which is what a route
|
|
261
|
+
* parameter means. `GET /owners/%2561rchived` is the legitimate id
|
|
262
|
+
* `%61rchived`, not a second-order spelling of `archived`; a predicate that
|
|
263
|
+
* decoded until stable would deny a record it was never asked about.
|
|
264
|
+
* - Do NOT case-fold it. A record id is a VALUE, not a literal route segment,
|
|
265
|
+
* and express's `case sensitive routing` governs literal segments only.
|
|
266
|
+
* With a distinct owner seeded at `ARCHIVED`, `.toLowerCase()` was measured
|
|
267
|
+
* wrong in BOTH directions at once: `GET /owners/ARCHIVED` 403 (a false
|
|
268
|
+
* deny, on the wrong record) and `GET /owners/%41RCHIVED` 200 (a false
|
|
269
|
+
* allow, on that same record).
|
|
270
|
+
* - Do NOT derive it from `request.path` or the request target. Decoding the
|
|
271
|
+
* whole path decodes THEN splits, while the router splits THEN decodes, so
|
|
272
|
+
* `/owners/archived%2fx` -- a genuinely distinct record whose id is
|
|
273
|
+
* `archived/x` -- was measured over-denied 403.
|
|
274
|
+
*
|
|
275
|
+
* IT IS `getId(request.params)`, BYTE FOR BYTE -- the same single coercion
|
|
276
|
+
* the store lookup uses, exactly as `operation` is the same `methodAccessMap`
|
|
277
|
+
* lookup the permission-array branch uses. The predicate and the dispatch
|
|
278
|
+
* therefore cannot disagree about which record a request addresses. Handing
|
|
279
|
+
* over the raw `request.params.id` instead would reintroduce that divergence
|
|
280
|
+
* on hex-shaped ids: `GET /animals/0x2391` looks up record `9105`.
|
|
281
|
+
*
|
|
282
|
+
* It inherits abofs/stonyx-orm#209 along with that coercion -- on a model
|
|
283
|
+
* declaring `id = attr('string')`, `'9107'` arrives here as the number
|
|
284
|
+
* `9107`. That is consistency WITH THE LOOKUP, which is the property this key
|
|
285
|
+
* exists to buy; it is not a defect to repair here.
|
|
286
|
+
*
|
|
287
|
+
* `null`, not `undefined`, on a collection route -- and the KEY IS ALWAYS
|
|
288
|
+
* PRESENT, the same rule `operation` states above. `auth()` always sets it,
|
|
289
|
+
* so a context arriving WITHOUT the key did not come from `auth()`: it was
|
|
290
|
+
* hand-assembled by a caller resolving the predicate through
|
|
291
|
+
* `Orm.instance.getAccess()`. That absence stays a distinguishable, deniable
|
|
292
|
+
* signal only because the framework never produces it.
|
|
293
|
+
*
|
|
294
|
+
* IT DISAGREES WITH THE HOOK VOCABULARY, AND NOT ONLY ON THE ABSENCE
|
|
295
|
+
* SPELLING. `HookContext.recordId` (`src/hooks.ts`) is an identically-named
|
|
296
|
+
* key on an identically-shaped context object, which is the exact
|
|
297
|
+
* configuration that makes `operation` fail-open shaped -- a hook sees
|
|
298
|
+
* `'get'` where `access()` sees `'read'`. An earlier revision of THIS
|
|
299
|
+
* docblock asserted the opposite ("here they AGREE... they differ in ONE way
|
|
300
|
+
* and it is the absence spelling"). That was measured false, in the fail-open
|
|
301
|
+
* direction, and it is corrected here rather than deleted.
|
|
302
|
+
*
|
|
303
|
+
* MEASURED over the live dispatch, before-hooks registered for all five
|
|
304
|
+
* operations on one model:
|
|
305
|
+
*
|
|
306
|
+
* before:list key ABSENT ('recordId' in context === false)
|
|
307
|
+
* before:get key ABSENT params={"id":"visible1"}
|
|
308
|
+
* before:create key ABSENT
|
|
309
|
+
* before:update key ABSENT params={"id":"visible2"}
|
|
310
|
+
* before:delete recordId="visible3"
|
|
311
|
+
* after:delete recordId="visible3"
|
|
312
|
+
*
|
|
313
|
+
* `_withHooks` assigns `context.recordId` at exactly TWO sites in
|
|
314
|
+
* `src/orm-request.ts`, and BOTH sit inside an `operation === 'delete'`
|
|
315
|
+
* branch. So the two keys differ in COVERAGE, on four of five operations: on
|
|
316
|
+
* a hook context the key is absent for get, list, create and update, while
|
|
317
|
+
* this key is present on every route `auth()` classifies. The absence
|
|
318
|
+
* spelling is the smaller half of the difference, not the whole of it.
|
|
319
|
+
*
|
|
320
|
+
* AND THAT INVERTS THE ARGUMENT ABOVE WHEN IT IS READ ACROSS THE TWO. Here,
|
|
321
|
+
* a missing `recordId` means "did not come from `auth()`" and is deniable.
|
|
322
|
+
* On a hook context it means "this is a get / list / create / update" -- an
|
|
323
|
+
* ordinary request. A consumer who writes the hook-side half of the same
|
|
324
|
+
* rule --
|
|
325
|
+
*
|
|
326
|
+
* beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)
|
|
327
|
+
*
|
|
328
|
+
* -- gets a deny that NEVER FIRES: measured, `PATCH /owners/visible2` -> 200,
|
|
329
|
+
* with `ctx.recordId === undefined` while the addressed record sits in
|
|
330
|
+
* `ctx.params`. The hook side is abofs/stonyx-orm#242 and is deliberately not
|
|
331
|
+
* repaired here. A predicate must not read `undefined` here as "collection",
|
|
332
|
+
* and nothing in this contract makes it safe to read the two keys as one key.
|
|
333
|
+
*
|
|
334
|
+
* IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
|
|
335
|
+
* `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
|
|
336
|
+
* carry `recordId: 'gina'`; the related-resource gap is abofs/stonyx-orm#196
|
|
337
|
+
* and is untouched by this key.
|
|
338
|
+
*/
|
|
339
|
+
recordId: string | number | null;
|
|
245
340
|
}
|
|
246
341
|
/**
|
|
247
342
|
* A consumer `access()` predicate.
|
package/package.json
CHANGED
package/src/hooks.ts
CHANGED
|
@@ -37,7 +37,21 @@ export interface HookContext {
|
|
|
37
37
|
state?: Record<string, unknown>;
|
|
38
38
|
/** Previous record state (available in update hooks). */
|
|
39
39
|
oldState?: unknown;
|
|
40
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Target record ID for single-record operations.
|
|
42
|
+
*
|
|
43
|
+
* SET ONLY UNDER `delete`. `_withHooks` assigns this key in the two
|
|
44
|
+
* `operation === 'delete'` branches and nowhere else, so on `get`, `list`,
|
|
45
|
+
* `create` and `update` the key is ABSENT -- not `undefined`-valued, absent.
|
|
46
|
+
* A hook rule written as `ctx.recordId === '<id>'` never fires on an update;
|
|
47
|
+
* the addressed id is in `ctx.params`. Tracked as abofs/stonyx-orm#242.
|
|
48
|
+
*
|
|
49
|
+
* @see AccessContext.recordId in ./types/orm-types.ts -- an identically-named
|
|
50
|
+
* key on an identically-shaped context object, and NOT interchangeable with
|
|
51
|
+
* this one: it is present on every route `auth()` classifies, and spells
|
|
52
|
+
* absence as `null` rather than `undefined`. They differ in coverage on four
|
|
53
|
+
* of five operations, not only in the absence spelling.
|
|
54
|
+
*/
|
|
41
55
|
recordId?: string | number;
|
|
42
56
|
/** Response data (available in after hooks). */
|
|
43
57
|
response?: unknown;
|
package/src/orm-request.ts
CHANGED
|
@@ -69,6 +69,15 @@
|
|
|
69
69
|
* records under `model: 'owner'`, and the context gives no signal of that
|
|
70
70
|
* (abofs/stonyx-orm#196).
|
|
71
71
|
*
|
|
72
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
|
|
73
|
+
* CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
|
|
74
|
+
* now also carries `recordId` -- the DECODED route-parameter id, see
|
|
75
|
+
* `AccessContext.recordId` in ./types/orm-types.ts -- so the fixture's
|
|
76
|
+
* `/archived` deny IS expressible from the context alone, and the shipped
|
|
77
|
+
* sample no longer reads `request.path` at all. Retiring this wording WITH the
|
|
78
|
+
* measurement that retires it, rather than by deletion, is
|
|
79
|
+
* abofs/stonyx-orm#238.
|
|
80
|
+
*
|
|
72
81
|
* `record` IS NOT IN THIS CONTEXT, deliberately. `auth()` runs after route
|
|
73
82
|
* matching but BEFORE any handler executes (`@stonyx/rest-server`
|
|
74
83
|
* `src/request.ts:58-60`), so nothing has been fetched yet -- supplying a
|
|
@@ -98,8 +107,8 @@
|
|
|
98
107
|
*
|
|
99
108
|
* PASSING THE CONTEXT MAKES A MODEL-CORRECT ANSWER POSSIBLE. It does not make
|
|
100
109
|
* the answer model-correct on its own -- the resolved predicate has to READ it.
|
|
101
|
-
* Measured against
|
|
102
|
-
*
|
|
110
|
+
* Measured against an ARITY-1 predicate, on a request express dispatched to
|
|
111
|
+
* `GET /owners/angela`, asked about ANIMALS:
|
|
103
112
|
*
|
|
104
113
|
* getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
|
|
105
114
|
* -> record => record.id !== 'angela' && record.id !== 'restricted'
|
|
@@ -110,23 +119,30 @@
|
|
|
110
119
|
* `['read', 'create', 'update', 'delete']`, a full CRUD grant. Either way the
|
|
111
120
|
* context was supplied and the answer is not the animal answer, and it is wrong
|
|
112
121
|
* in the GRANTING direction, because that predicate is arity-1 and identifies
|
|
113
|
-
* its collection from the request. (
|
|
114
|
-
*
|
|
122
|
+
* its collection from the request. (Asserted on a live dispatch by AC9 in
|
|
123
|
+
* test/integration/orm-test.ts, against a deliberately arity-1 predicate.)
|
|
115
124
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* the
|
|
125
|
+
* This repo's own sample access class has since been MIGRATED to read the
|
|
126
|
+
* context (abofs/stonyx-orm#222), so `getAccess('animal')` here now answers
|
|
127
|
+
* with the animal filter. That is not true of a consumer tree: an arity-1
|
|
128
|
+
* predicate keeps working -- the second argument is additive -- and the caller
|
|
129
|
+
* has no supported way to tell which kind it got. The boot-time arity warning
|
|
130
|
+
* that surfaces one is abofs/stonyx-orm#221.
|
|
119
131
|
* So: pass the context, and do not treat a resolved predicate's answer as
|
|
120
132
|
* model-specific until that predicate has been migrated to read the context.
|
|
121
133
|
*
|
|
122
134
|
* ---------------------------------------------------------------------------
|
|
123
135
|
* DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
|
|
124
136
|
* ---------------------------------------------------------------------------
|
|
125
|
-
* `auth()` below hands your
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
137
|
+
* You do not have to. `auth()` below hands your predicate the ACCESS CONTEXT as
|
|
138
|
+
* argument two, and `context.model` already names the collection -- see the
|
|
139
|
+
* contract section above. Argument ONE is still the raw transport artifact, and
|
|
140
|
+
* everything from here to the end of this banner is the record of what happened
|
|
141
|
+
* when predicates worked the collection out from it. IT IS HISTORY, NOT
|
|
142
|
+
* GUIDANCE: do not write any of it into a new predicate. Every attempt to
|
|
143
|
+
* identify the collection by parsing the request target has failed OPEN. Five
|
|
144
|
+
* distinct variants of the same three-line example have now been found, each
|
|
145
|
+
* after the previous was fixed, by five different people:
|
|
130
146
|
*
|
|
131
147
|
* 1. `request.url` is mount-relative under `RestServer.mountRoute`, so a
|
|
132
148
|
* prefix match against it is ALWAYS false.
|
|
@@ -145,22 +161,89 @@
|
|
|
145
161
|
* last, and the record comes back in full. It walks past a hard
|
|
146
162
|
* `return false` deny the same way.
|
|
147
163
|
*
|
|
148
|
-
* The fix is not a sixth rule. It is to
|
|
164
|
+
* The fix is not a sixth rule, and it is not a better string to match. It is to
|
|
165
|
+
* stop identifying the collection at all: read `context.model`. That is a claim
|
|
166
|
+
* about IDENTIFYING THE COLLECTION, not about the sample as a whole -- the
|
|
167
|
+
* `/archived` SUB-PATH rule is still a string match, and abofs/stonyx-orm#228 is
|
|
168
|
+
* a sixth spelling that gets past it.
|
|
169
|
+
*
|
|
170
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237: the `/archived` rule is
|
|
171
|
+
* no longer a string match against the request target -- it compares the
|
|
172
|
+
* decoded `recordId` the framework supplies -- and abofs/stonyx-orm#228 is
|
|
173
|
+
* CLOSED. Retirement of this wording: abofs/stonyx-orm#238.
|
|
174
|
+
*
|
|
175
|
+
* An intermediate revision of the sample read `request.baseUrl` -- the mount
|
|
176
|
+
* Express ACTUALLY MATCHED. That closed all five variants (no query string,
|
|
177
|
+
* not mount-relative, unaffected by absolute-form, already carrying the
|
|
178
|
+
* configured `ORM_REST_ROUTE` prefix), but it was a transport artifact
|
|
179
|
+
* standing in for a structural fact and the sample no longer does it.
|
|
180
|
+
* `context.model` IS the structural fact, so variants 1, 2, 4 and 5 are
|
|
181
|
+
* unconstructible against a migrated predicate rather than handled.
|
|
182
|
+
*
|
|
183
|
+
* VARIANT 3 SURVIVES, and is deliberately not in that list. It is the general
|
|
184
|
+
* shape "a hand-written matcher normalises differently from the router", and a
|
|
185
|
+
* migrated predicate still runs one string comparison for any SUB-PATH rule --
|
|
186
|
+
* in the shipped sample, the `/archived` deny. That comparison folds case but
|
|
187
|
+
* does not decode, so `GET /owners/%61rchived` steps past it. See the
|
|
188
|
+
* normalisation paragraph below and abofs/stonyx-orm#228.
|
|
189
|
+
*
|
|
190
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237. Variant 3 lived in that
|
|
191
|
+
* one string comparison, and the comparison is gone: the sample compares the
|
|
192
|
+
* decoded `recordId`. Left standing rather than edited because the same
|
|
193
|
+
* "variant 3 survives" wording sits at four sites -- this header, README.md
|
|
194
|
+
* twice, and test/sample/access/global-access.ts -- three of which SHIP, so
|
|
195
|
+
* retiring one of four leaves the shipped copies contradicting each other.
|
|
196
|
+
* Retiring all four WITH their measurement is abofs/stonyx-orm#238.
|
|
197
|
+
*
|
|
198
|
+
* ONE READ OF ARGUMENT ONE SURVIVES, AND IT MUST: `request.path`. It is
|
|
199
|
+
* mount-relative and query-free, and it is for rules that distinguish SUB-PATHS
|
|
200
|
+
* beneath the mount. The context names which model and which verb, NOT which
|
|
201
|
+
* route, so the sample's `/archived` deny cannot be expressed from the context
|
|
202
|
+
* alone and a context-ONLY rewrite would silently turn that deny into an allow.
|
|
203
|
+
*
|
|
204
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237: NO read of argument one
|
|
205
|
+
* survives in the shipped sample. `recordId` names WHICH RECORD the route was
|
|
206
|
+
* addressed to, so the `/archived` deny is expressible from the context alone
|
|
207
|
+
* -- and it still must not be dropped; expressible is not optional. Retirement
|
|
208
|
+
* of this wording: abofs/stonyx-orm#238.
|
|
209
|
+
*
|
|
210
|
+
* NORMALISE THE WAY THE ROUTER DOES, AND CASE-FOLDING ALONE IS NOT THAT. The
|
|
211
|
+
* sample lower-cases before comparing, because a matcher stricter than the
|
|
212
|
+
* case-insensitive router can be stepped around. That closes the case gap only.
|
|
213
|
+
* Express sets `request.path` from the RAW, UNDECODED pathname while the router
|
|
214
|
+
* DECODES `:id`, so `GET /owners/%61rchived` reaches a `path === '/archived'`
|
|
215
|
+
* comparison as `/%61rchived` and walks past the deny. That gap is live in the
|
|
216
|
+
* sample and is tracked as abofs/stonyx-orm#228; the `.toLowerCase()` is not a
|
|
217
|
+
* complete normalisation recipe. Compare record ids at their real case.
|
|
149
218
|
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
219
|
+
* DO NOT FOLLOW THE PARAGRAPH ABOVE. SUPERSEDED 2026-09-01 BY
|
|
220
|
+
* abofs/stonyx-orm#236/#237, and flagged here rather than merely dated because
|
|
221
|
+
* it is an INSTRUCTION, not a stale observation. `.toLowerCase()` on the access
|
|
222
|
+
* path was measured WRONG IN BOTH DIRECTIONS AT ONCE: with a distinct owner
|
|
223
|
+
* seeded at `ARCHIVED`, `GET /owners/ARCHIVED` was a false DENY on the wrong
|
|
224
|
+
* record and `GET /owners/%41RCHIVED` a false ALLOW on that same record. A
|
|
225
|
+
* record id is a VALUE, not a literal route segment, and express's
|
|
226
|
+
* `case sensitive routing` governs literal segments only. Compare
|
|
227
|
+
* `context.recordId` AS IT ARRIVES: do not case-fold it, do not decode it, do
|
|
228
|
+
* not derive it from `request.path`. `AccessContext.recordId` in
|
|
229
|
+
* ./types/orm-types.ts is the contract and says "Do NOT case-fold it"; the same
|
|
230
|
+
* published tarball ships both files, and THIS paragraph is the one that is
|
|
231
|
+
* wrong. Retiring it WITH its measurement is abofs/stonyx-orm#238.
|
|
157
232
|
*
|
|
158
233
|
* `?? ''` is not a defence. It converts an absent request target into an empty
|
|
159
234
|
* string, which matches no collection, which falls through to the permission
|
|
160
|
-
* array -- a total grant. An input you cannot identify must DENY
|
|
235
|
+
* array -- a total grant. An input you cannot identify must DENY, and that
|
|
236
|
+
* applies to BOTH arguments: since #202 the guard and the read can sit on
|
|
237
|
+
* different objects, and a guard on argument two does not protect a read of
|
|
238
|
+
* argument one. The sample returns `false` for an absent `model` AND for an
|
|
239
|
+
* absent or non-string `request.path`, rather than falling through either way.
|
|
240
|
+
*
|
|
241
|
+
* SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237 as to WHAT is guarded --
|
|
242
|
+
* the principle is unchanged. The sample no longer reads `request.path`, so it
|
|
243
|
+
* returns `false` for an absent `model` AND for an absent `recordId`
|
|
244
|
+
* (`undefined`, the one spelling `auth()` never produces). Retirement of this
|
|
245
|
+
* wording: abofs/stonyx-orm#238.
|
|
161
246
|
*
|
|
162
|
-
* THAT IS STILL A STOPGAP. `baseUrl` closes all five variants, but it is a
|
|
163
|
-
* transport artifact being asked to stand in for a structural fact.
|
|
164
247
|
* THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
|
|
165
248
|
* the operation and the record. Prefer the array shape (`['read']`) or `false`
|
|
166
249
|
* until #202 lands; the function shape is what requires any matching at all.
|
|
@@ -1354,10 +1437,53 @@ export default class OrmRequest extends Request {
|
|
|
1354
1437
|
// src/types/orm-types.ts. Nothing is fetched at this point and adding a
|
|
1355
1438
|
// lookup here would put a store read in the middle of an authorization
|
|
1356
1439
|
// path. The function return shape below IS the per-record hook.
|
|
1440
|
+
//
|
|
1441
|
+
// -------------------------------------------------------------------------
|
|
1442
|
+
// #236 -- `recordId`, the DECODED route-parameter id, for the same reason.
|
|
1443
|
+
//
|
|
1444
|
+
// WHICH RECORD is the third structural fact the framework already holds and
|
|
1445
|
+
// the consumer was left to re-derive, and re-deriving it failed OPEN. The
|
|
1446
|
+
// documented sample compared `request.path` -- the RAW, undecoded pathname
|
|
1447
|
+
// -- against a literal `/archived`, while the router DECODES `:id`. So
|
|
1448
|
+
// `GET /owners/%61rchived` walked past the deny and was dispatched as the
|
|
1449
|
+
// record `archived`: 200 with the record in full, and DELETE answered 204
|
|
1450
|
+
// with the record destroyed, unauthenticated. Four spellings measured, all
|
|
1451
|
+
// four through; 255 non-canonical spellings of that 8-character id decode
|
|
1452
|
+
// to the same key, so this was never a deny-list of one.
|
|
1453
|
+
//
|
|
1454
|
+
// TWO CONSUMER-SIDE NORMALISATIONS WERE MEASURED WRONG IN OPPOSITE
|
|
1455
|
+
// DIRECTIONS, which is the argument for doing it once, here.
|
|
1456
|
+
// `.toLowerCase()` case-folds a route-parameter VALUE on the axis that
|
|
1457
|
+
// governs literal SEGMENTS: with a distinct owner seeded at `ARCHIVED`,
|
|
1458
|
+
// `GET /owners/ARCHIVED` was a false DENY on the wrong record and
|
|
1459
|
+
// `GET /owners/%41RCHIVED` a false ALLOW on that same one.
|
|
1460
|
+
// `decodeURIComponent(request.path)` decodes THEN splits while the router
|
|
1461
|
+
// splits THEN decodes, so it over-denied `/owners/archived%2fx` -- 403 for
|
|
1462
|
+
// a genuinely distinct record. Failing closed there was luck, not design.
|
|
1463
|
+
//
|
|
1464
|
+
// `getId(request.params)` AND NOT `request.params.id`, for exactly the
|
|
1465
|
+
// reason `operation` is a `methodAccessMap` lookup: it is the SAME single
|
|
1466
|
+
// coercion the store lookup one layer down performs, so the predicate and
|
|
1467
|
+
// the dispatch cannot disagree about which record a request addresses.
|
|
1468
|
+
// The raw string would reintroduce that divergence on hex-shaped ids --
|
|
1469
|
+
// `GET /animals/0x2391` looks up record `9105`.
|
|
1470
|
+
//
|
|
1471
|
+
// NOTHING HERE PARSES THE REQUEST TARGET EITHER. `request.params` is what
|
|
1472
|
+
// the router matched, so a mount prefix, an absolute-form target, a query
|
|
1473
|
+
// string or a case-varied mount cannot move this value -- the same
|
|
1474
|
+
// guarantee `model` carries, by the same means.
|
|
1475
|
+
//
|
|
1476
|
+
// `null` and not `undefined` on a collection route, so the KEY IS ALWAYS
|
|
1477
|
+
// PRESENT -- the rule `operation`'s own docblock already establishes. A
|
|
1478
|
+
// context reaching a predicate WITHOUT the key therefore did not come from
|
|
1479
|
+
// here; it was hand-assembled by a caller resolving the predicate through
|
|
1480
|
+
// `Orm.instance.getAccess()`, and that absence stays deniable only because
|
|
1481
|
+
// `auth()` never produces it.
|
|
1357
1482
|
// -------------------------------------------------------------------------
|
|
1358
1483
|
const context: AccessContext = {
|
|
1359
1484
|
model: this.model,
|
|
1360
1485
|
operation: methodAccessMap[request.method],
|
|
1486
|
+
recordId: request.params && 'id' in request.params ? getId(request.params) : null,
|
|
1361
1487
|
};
|
|
1362
1488
|
|
|
1363
1489
|
let access: AccessMethod;
|