@stonyx/orm 0.3.2-alpha.6 → 0.3.2-alpha.60

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.
Files changed (53) hide show
  1. package/README.md +580 -10
  2. package/config/{environment.ts → environment.js} +8 -0
  3. package/dist/commands.js +34 -0
  4. package/dist/dynamodb/connection.d.ts +31 -0
  5. package/dist/dynamodb/connection.js +28 -0
  6. package/dist/dynamodb/dynamodb-db.d.ts +142 -0
  7. package/dist/dynamodb/dynamodb-db.js +596 -0
  8. package/dist/dynamodb/operation-builder.d.ts +76 -0
  9. package/dist/dynamodb/operation-builder.js +116 -0
  10. package/dist/dynamodb/type-map.d.ts +31 -0
  11. package/dist/dynamodb/type-map.js +48 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/main.d.ts +116 -0
  14. package/dist/main.js +129 -0
  15. package/dist/manage-record.js +34 -3
  16. package/dist/mysql/connection.d.ts +1 -0
  17. package/dist/mysql/mysql-db.d.ts +8 -0
  18. package/dist/mysql/mysql-db.js +44 -10
  19. package/dist/orm-request.d.ts +181 -3
  20. package/dist/orm-request.js +794 -47
  21. package/dist/postgres/connection.d.ts +1 -0
  22. package/dist/postgres/connection.js +8 -6
  23. package/dist/postgres/postgres-db.d.ts +8 -0
  24. package/dist/postgres/postgres-db.js +44 -10
  25. package/dist/record.js +7 -5
  26. package/dist/relationships.js +1 -1
  27. package/dist/serializer.js +38 -2
  28. package/dist/setup-rest-server.js +51 -5
  29. package/dist/store.d.ts +13 -1
  30. package/dist/store.js +65 -6
  31. package/dist/types/orm-types.d.ts +112 -0
  32. package/package.json +16 -7
  33. package/src/commands.ts +43 -0
  34. package/src/dynamodb/connection.ts +50 -0
  35. package/src/dynamodb/dynamodb-db.ts +811 -0
  36. package/src/dynamodb/operation-builder.ts +202 -0
  37. package/src/dynamodb/type-map.ts +54 -0
  38. package/src/index.ts +1 -0
  39. package/src/main.ts +133 -0
  40. package/src/manage-record.ts +41 -9
  41. package/src/mysql/connection.ts +1 -0
  42. package/src/mysql/mysql-db.ts +44 -12
  43. package/src/orm-request.ts +809 -50
  44. package/src/postgres/connection.ts +10 -6
  45. package/src/postgres/postgres-db.ts +44 -12
  46. package/src/record.ts +8 -5
  47. package/src/relationships.ts +1 -1
  48. package/src/serializer.ts +39 -2
  49. package/src/setup-rest-server.ts +59 -6
  50. package/src/store.ts +68 -6
  51. package/src/types/orm-types.ts +118 -0
  52. package/src/types/stonyx-rest-server.d.ts +14 -1
  53. package/src/types/stonyx.d.ts +7 -1
@@ -1,4 +1,183 @@
1
+ /**
2
+ * REST request handling and access enforcement for @stonyx/orm.
3
+ *
4
+ * ---------------------------------------------------------------------------
5
+ * THE `access()` CONTRACT: `access(request, { model, operation })`
6
+ * ---------------------------------------------------------------------------
7
+ * `auth()` calls your predicate with TWO arguments. The second is the access
8
+ * CONTEXT -- the structural facts about the request, which the framework
9
+ * already holds and which you should read INSTEAD of parsing anything:
10
+ *
11
+ * context.model The model this route was mounted for, as a model name:
12
+ * kebab-case, exactly as declared under
13
+ * `config.orm.paths.model` and keyed in the store --
14
+ * `'owner'`, `'animal'`, `'phone-number'`. NOT the
15
+ * pluralised, dasherized, mount-prefixed ROUTE name. It is
16
+ * read from the OrmRequest instance, fixed at mount time,
17
+ * and no request can influence it.
18
+ *
19
+ * context.operation The operation being authorised. Exactly one of the four
20
+ * verbs `'read'`, `'create'`, `'update'`, `'delete'` --
21
+ * no second vocabulary ON THIS PATH, and never an HTTP
22
+ * method name like `'GET'`. These are the same four
23
+ * strings the permission-array return shape is written in
24
+ * (`['read', 'create']`), because both come from the one
25
+ * `methodAccessMap` below.
26
+ *
27
+ * NOT the hook vocabulary. `HookContext.operation`
28
+ * (`src/hooks.ts`, documented under "Hook Context Object"
29
+ * in the README) carries `'list' | 'get' | 'create' |
30
+ * 'update' | 'delete'` on an identically-named key of an
31
+ * identically-shaped context object, and the access
32
+ * vocabulary collapses `list` and `get` into `'read'`. For
33
+ * one `GET /animals/1` a hook sees `'get'` and `access()`
34
+ * sees `'read'`, so a predicate cannot tell a collection
35
+ * read from a record read. `AccessOperation` makes
36
+ * `operation === 'get'` a compile error for a TypeScript
37
+ * consumer, because a predicate that stops matching falls
38
+ * through to the permission array -- the misreading is
39
+ * fail-open shaped.
40
+ *
41
+ * `undefined` when the dispatched method has no entry in
42
+ * that map. Express delivers `HEAD` to the `GET` handler,
43
+ * so this is reachable. It is left undefined rather than
44
+ * defaulted on purpose -- a fabricated `'read'` would turn
45
+ * an unclassified request into an authorised one. Treat
46
+ * `undefined` as "not classified" and deny.
47
+ *
48
+ * So a consumer writes `if (model === 'owner' && operation === 'read')`. There
49
+ * is no string to parse, no variant to miss, and no way to fail open through a
50
+ * URL shape nobody anticipated.
51
+ *
52
+ * WHAT THE CONTEXT DOES NOT TELL YOU: WHICH SURFACE. It names the model and
53
+ * the verb, not the route. Measured over the live router, six surfaces produce
54
+ * one identical context:
55
+ *
56
+ * GET /owners { model: 'owner', operation: 'read' }
57
+ * GET /owners/gina { model: 'owner', operation: 'read' }
58
+ * GET /owners/gina/pets { model: 'owner', operation: 'read' }
59
+ * GET /owners/gina/relationships/pets { model: 'owner', operation: 'read' }
60
+ * GET /owners/archived { model: 'owner', operation: 'read' }
61
+ * GET /owners/gina?include=pets { model: 'owner', operation: 'read' }
62
+ *
63
+ * So a rule that depends on the SUB-PATH still needs `request.path` -- which is
64
+ * mount-relative and query-free, and is the one read of argument one the
65
+ * warning below sanctions. This repo's own fixture has such a rule: its
66
+ * `/archived` deny cannot be expressed from the context alone, and a predicate
67
+ * migrated to context-only would silently drop it, turning a deny into an
68
+ * allow. The related-resource and `?include=` surfaces serve ANOTHER model's
69
+ * records under `model: 'owner'`, and the context gives no signal of that
70
+ * (abofs/stonyx-orm#196).
71
+ *
72
+ * `record` IS NOT IN THIS CONTEXT, deliberately. `auth()` runs after route
73
+ * matching but BEFORE any handler executes (`@stonyx/rest-server`
74
+ * `src/request.ts:58-60`), so nothing has been fetched yet -- supplying a
75
+ * record would force a pre-fetch on every request, a second store hit and an
76
+ * ordering change in the middle of an authorization path. It is also
77
+ * unnecessary: the FUNCTION return shape already is the per-record hook. Return
78
+ * `(record) => boolean` and the handlers apply it to every record the request
79
+ * touches. Auth-time and record-time are separate decision points.
80
+ *
81
+ * THE SECOND ARGUMENT IS ADDITIVE. JavaScript ignores extra arguments, so an
82
+ * existing `access(request)` predicate keeps working exactly as before. The
83
+ * warning immediately below is therefore still live: `request` is still
84
+ * argument ONE, and reading it is still how predicates fail open.
85
+ *
86
+ * To reach ANOTHER model's predicate -- e.g. to check an animal while servicing
87
+ * an owners route -- use the boot-time registry:
88
+ *
89
+ * const predicate = Orm.instance.getAccess('animal');
90
+ * if (!predicate) return deny;
91
+ * const verdict = predicate(request, { model: 'animal', operation: 'read' });
92
+ *
93
+ * `undefined` means NO PREDICATE COULD BE RESOLVED for that name -- which
94
+ * includes the case where the model has an access class that failed to load,
95
+ * because `setup-rest-server.ts` catches a load failure, warns, and publishes
96
+ * whatever partial map it had. It does NOT mean the model is unrestricted.
97
+ * Treat it as DENY, the same way `operation === undefined` is treated above.
98
+ *
99
+ * PASSING THE CONTEXT MAKES A MODEL-CORRECT ANSWER POSSIBLE. It does not make
100
+ * the answer model-correct on its own -- the resolved predicate has to READ it.
101
+ * Measured against this repo's own shipped access class, on a request express
102
+ * dispatched to `GET /owners/angela`, asked about ANIMALS:
103
+ *
104
+ * getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
105
+ * -> record => record.id !== 'angela' && record.id !== 'restricted'
106
+ *
107
+ * That is the OWNERS filter, and it returns `true` for animal 21 -- the record
108
+ * hidden on every animal surface. Under a mount that predicate recognises
109
+ * neither way it is worse: it falls through to
110
+ * `['read', 'create', 'update', 'delete']`, a full CRUD grant. Either way the
111
+ * context was supplied and the answer is not the animal answer, and it is wrong
112
+ * in the GRANTING direction, because that predicate is arity-1 and identifies
113
+ * its collection from the request. (The first of these is asserted on a live
114
+ * dispatch by AC9 in test/integration/orm-test.ts.)
115
+ *
116
+ * Every predicate in this repo and in every consumer tree is arity-1 on the day
117
+ * this ships, and the caller has no supported way to tell which kind it got --
118
+ * the boot-time arity warning that would surface it is abofs/stonyx-orm#213.
119
+ * So: pass the context, and do not treat a resolved predicate's answer as
120
+ * model-specific until that predicate has been migrated to read the context.
121
+ *
122
+ * ---------------------------------------------------------------------------
123
+ * DO NOT RECONSTRUCT THE REQUEST PATH INSIDE `access()`.
124
+ * ---------------------------------------------------------------------------
125
+ * `auth()` below hands your `access(request)` a raw transport artifact and asks
126
+ * you to work out which collection it addresses. Every attempt to do that by
127
+ * parsing the request target has failed OPEN. Five distinct variants of the
128
+ * same three-line example have now been found, each after the previous was
129
+ * fixed, by five different people:
130
+ *
131
+ * 1. `request.url` is mount-relative under `RestServer.mountRoute`, so a
132
+ * prefix match against it is ALWAYS false.
133
+ * 2. `request.originalUrl` carries the query string, so an anchored equality
134
+ * check misses `/owners?filter[age]=30`.
135
+ * 3. The router is a bare `express()` (`caseSensitive: false`) while a
136
+ * hand-written matcher is case-SENSITIVE, so `GET /OwNeRs/angela` walks
137
+ * past it. Router-side: abofs/stonyx-rest-server#47.
138
+ * 4. Under a configured `ORM_REST_ROUTE` a hard-coded `/owners` matches
139
+ * nothing -- environment-specifically, which is worse.
140
+ * 5. HTTP/1.1 permits an ABSOLUTE-FORM request-target. Express routes on
141
+ * `parseurl(req).pathname`, but `originalUrl` is the raw target, so
142
+ * `GET http://anything.example/owners/angela` reaches the handler with
143
+ * `originalUrl === 'http://anything.example/owners/angela'`. A `/owners`
144
+ * prefix match is false, `access()` falls through to whatever it returns
145
+ * last, and the record comes back in full. It walks past a hard
146
+ * `return false` deny the same way.
147
+ *
148
+ * The fix is not a sixth rule. It is to stop parsing:
149
+ *
150
+ * `request.baseUrl` is the mount Express ACTUALLY MATCHED when it dispatched
151
+ * the request. It carries no query string, it is not mount-relative, it is
152
+ * unaffected by absolute-form, and it already includes the configured
153
+ * `ORM_REST_ROUTE` prefix -- so there is nothing to derive and nothing to
154
+ * join. Compare it lower-cased (the router matched case-insensitively) and
155
+ * fail CLOSED when it is absent. Use `request.path` -- mount-relative and
156
+ * query-free -- if you need to distinguish sub-paths.
157
+ *
158
+ * `?? ''` is not a defence. It converts an absent request target into an empty
159
+ * string, which matches no collection, which falls through to the permission
160
+ * array -- a total grant. An input you cannot identify must DENY.
161
+ *
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
+ * THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
165
+ * the operation and the record. Prefer the array shape (`['read']`) or `false`
166
+ * until #202 lands; the function shape is what requires any matching at all.
167
+ *
168
+ * The enforcement gates in this file (GATE 0/1/2, the create rollback, the
169
+ * per-handler `isDenied` re-checks) are correct independently of that -- they
170
+ * enforce whatever predicate you return. The stopgap is the part where YOU have
171
+ * to work out which predicate to return.
172
+ *
173
+ * AND A PREDICATE IS NOT A GUARANTEE THAT A HIDDEN RECORD CANNOT BE MODIFIED.
174
+ * It is evaluated against the record the route is ADDRESSED TO, on that model
175
+ * only. A write to a DIFFERENT collection can still re-parent a hidden record
176
+ * and de-hide it -- abofs/stonyx-orm#207, which is blocked on #202 and #196.
177
+ * See `### Known limitations` in README.
178
+ */
1
179
  import { Request } from '@stonyx/rest-server';
180
+ import type { AccessFunction } from './types/orm-types.js';
2
181
  interface OrmRequest$ extends Request {
3
182
  protocol?: string;
4
183
  method: string;
@@ -13,13 +192,12 @@ interface OrmRequest$ extends Request {
13
192
  };
14
193
  get(header: string): string;
15
194
  }
16
- type AccessMethod = string | boolean | string[] | ((record: unknown) => boolean);
17
195
  type HandlerFn = (request: OrmRequest$, state: {
18
196
  [key: string]: unknown;
19
197
  }) => unknown | Promise<unknown>;
20
198
  export default class OrmRequest extends Request {
21
199
  model: string;
22
- access: (request: unknown) => AccessMethod;
200
+ access: AccessFunction;
23
201
  handlers: {
24
202
  [key: string]: {
25
203
  [key: string]: HandlerFn;
@@ -27,7 +205,7 @@ export default class OrmRequest extends Request {
27
205
  };
28
206
  constructor({ model, access }: {
29
207
  model: string;
30
- access: (request: unknown) => AccessMethod;
208
+ access: AccessFunction;
31
209
  });
32
210
  private _withHooks;
33
211
  private _generateRelationshipRoutes;