@stonyx/orm 0.3.2-beta.16 → 0.3.2-beta.160

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 (66) hide show
  1. package/README.md +1409 -11
  2. package/config/environment.js +99 -12
  3. package/dist/access-verdict.d.ts +85 -0
  4. package/dist/access-verdict.js +284 -0
  5. package/dist/commands.js +34 -0
  6. package/dist/dynamodb/connection.d.ts +31 -0
  7. package/dist/dynamodb/connection.js +28 -0
  8. package/dist/dynamodb/dynamodb-db.d.ts +142 -0
  9. package/dist/dynamodb/dynamodb-db.js +596 -0
  10. package/dist/dynamodb/operation-builder.d.ts +76 -0
  11. package/dist/dynamodb/operation-builder.js +116 -0
  12. package/dist/dynamodb/type-map.d.ts +31 -0
  13. package/dist/dynamodb/type-map.js +48 -0
  14. package/dist/hooks.d.ts +15 -1
  15. package/dist/index.d.ts +3 -0
  16. package/dist/index.js +8 -0
  17. package/dist/main.d.ts +116 -0
  18. package/dist/main.js +129 -0
  19. package/dist/manage-record.js +268 -12
  20. package/dist/mysql/connection.d.ts +1 -0
  21. package/dist/mysql/mysql-db.d.ts +8 -0
  22. package/dist/mysql/mysql-db.js +44 -10
  23. package/dist/orm-request.d.ts +274 -3
  24. package/dist/orm-request.js +1259 -65
  25. package/dist/postgres/connection.d.ts +1 -0
  26. package/dist/postgres/connection.js +8 -6
  27. package/dist/postgres/postgres-db.d.ts +8 -0
  28. package/dist/postgres/postgres-db.js +44 -10
  29. package/dist/record.d.ts +16 -0
  30. package/dist/record.js +154 -6
  31. package/dist/relationships.js +1 -1
  32. package/dist/serializer.js +38 -2
  33. package/dist/setup-rest-server.js +51 -5
  34. package/dist/standalone-db.js +17 -5
  35. package/dist/store.d.ts +13 -1
  36. package/dist/store.js +65 -6
  37. package/dist/types/orm-types.d.ts +260 -0
  38. package/dist/utils.d.ts +44 -0
  39. package/dist/utils.js +47 -0
  40. package/package.json +16 -7
  41. package/src/access-verdict.ts +312 -0
  42. package/src/commands.ts +43 -0
  43. package/src/dynamodb/connection.ts +50 -0
  44. package/src/dynamodb/dynamodb-db.ts +811 -0
  45. package/src/dynamodb/operation-builder.ts +202 -0
  46. package/src/dynamodb/type-map.ts +54 -0
  47. package/src/hooks.ts +15 -1
  48. package/src/index.ts +10 -0
  49. package/src/main.ts +133 -0
  50. package/src/manage-record.ts +294 -18
  51. package/src/mysql/connection.ts +1 -0
  52. package/src/mysql/mysql-db.ts +44 -12
  53. package/src/orm-request.ts +1281 -67
  54. package/src/postgres/connection.ts +10 -6
  55. package/src/postgres/postgres-db.ts +44 -12
  56. package/src/record.ts +182 -6
  57. package/src/relationships.ts +1 -1
  58. package/src/serializer.ts +39 -2
  59. package/src/setup-rest-server.ts +59 -6
  60. package/src/standalone-db.ts +17 -6
  61. package/src/store.ts +68 -6
  62. package/src/types/orm-types.ts +268 -1
  63. package/src/types/stonyx-rest-server.d.ts +14 -1
  64. package/src/types/stonyx.d.ts +7 -1
  65. package/src/utils.ts +50 -0
  66. package/config/environment.ts +0 -91
@@ -0,0 +1,116 @@
1
+ /**
2
+ * DynamoDB operation parameter builders.
3
+ *
4
+ * Each function returns a plain-object "params" bag that can be passed
5
+ * directly to the corresponding DocumentClient command
6
+ * (PutCommand, GetCommand, UpdateCommand, DeleteCommand, ScanCommand, QueryCommand).
7
+ *
8
+ * All functions are pure — no SDK imports here; the caller wraps params
9
+ * in the appropriate Command class.
10
+ */
11
+ /**
12
+ * PutItem — optionally with a condition expression.
13
+ *
14
+ * Pass conditionExpression = 'attribute_not_exists(id)' to enforce uniqueness.
15
+ */
16
+ export function buildPutItem(tableName, item, conditionExpression) {
17
+ const params = { TableName: tableName, Item: item };
18
+ if (conditionExpression)
19
+ params.ConditionExpression = conditionExpression;
20
+ return params;
21
+ }
22
+ /**
23
+ * GetItem by primary key.
24
+ */
25
+ export function buildGetItem(tableName, key) {
26
+ return { TableName: tableName, Key: key };
27
+ }
28
+ /**
29
+ * UpdateItem with a SET expression built from the `updates` object.
30
+ * Only the supplied attributes are updated (diff-based call site).
31
+ */
32
+ export function buildUpdateItem(tableName, key, updates) {
33
+ const names = {};
34
+ const values = {};
35
+ const setClauses = [];
36
+ for (const [attr, val] of Object.entries(updates)) {
37
+ const nameAlias = `#${attr}`;
38
+ const valAlias = `:${attr}`;
39
+ names[nameAlias] = attr;
40
+ values[valAlias] = val;
41
+ setClauses.push(`${nameAlias} = ${valAlias}`);
42
+ }
43
+ return {
44
+ TableName: tableName,
45
+ Key: key,
46
+ UpdateExpression: `SET ${setClauses.join(', ')}`,
47
+ ExpressionAttributeNames: names,
48
+ ExpressionAttributeValues: values,
49
+ ReturnValues: 'NONE',
50
+ };
51
+ }
52
+ /**
53
+ * DeleteItem by primary key.
54
+ */
55
+ export function buildDeleteItem(tableName, key) {
56
+ return { TableName: tableName, Key: key };
57
+ }
58
+ /**
59
+ * ScanCommand params.
60
+ * If conditions are supplied they are rendered as a FilterExpression using AND.
61
+ */
62
+ export function buildScan(tableName, conditions, exclusiveStartKey) {
63
+ const params = { TableName: tableName };
64
+ if (exclusiveStartKey)
65
+ params.ExclusiveStartKey = exclusiveStartKey;
66
+ if (conditions && Object.keys(conditions).length > 0) {
67
+ const validEntries = Object.entries(conditions).filter(([, val]) => val !== undefined && val !== null);
68
+ if (validEntries.length > 0) {
69
+ const names = {};
70
+ const values = {};
71
+ const clauses = [];
72
+ for (const [attr, val] of validEntries) {
73
+ const nameAlias = `#${attr}`;
74
+ const valAlias = `:${attr}`;
75
+ names[nameAlias] = attr;
76
+ values[valAlias] = val;
77
+ clauses.push(`${nameAlias} = ${valAlias}`);
78
+ }
79
+ params.FilterExpression = clauses.join(' AND ');
80
+ params.ExpressionAttributeNames = names;
81
+ params.ExpressionAttributeValues = values;
82
+ }
83
+ }
84
+ return params;
85
+ }
86
+ /**
87
+ * QueryCommand params for a GSI.
88
+ * keyConditions must be in the form { attrName: value } and will be rendered
89
+ * as equality expressions joined by AND.
90
+ */
91
+ export function buildQuery(tableName, indexName, keyConditions, exclusiveStartKey) {
92
+ const validEntries = Object.entries(keyConditions).filter(([, val]) => val !== undefined && val !== null);
93
+ if (validEntries.length === 0) {
94
+ throw new Error('buildQuery: all keyCondition values are undefined/null');
95
+ }
96
+ const names = {};
97
+ const values = {};
98
+ const clauses = [];
99
+ for (const [attr, val] of validEntries) {
100
+ const nameAlias = `#${attr}`;
101
+ const valAlias = `:${attr}`;
102
+ names[nameAlias] = attr;
103
+ values[valAlias] = val;
104
+ clauses.push(`${nameAlias} = ${valAlias}`);
105
+ }
106
+ const params = {
107
+ TableName: tableName,
108
+ IndexName: indexName,
109
+ KeyConditionExpression: clauses.join(' AND '),
110
+ ExpressionAttributeNames: names,
111
+ ExpressionAttributeValues: values,
112
+ };
113
+ if (exclusiveStartKey)
114
+ params.ExclusiveStartKey = exclusiveStartKey;
115
+ return params;
116
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Maps ORM attribute types to DynamoDB scalar attribute types.
3
+ * DynamoDB DocumentClient auto-marshalls JS objects, so most values
4
+ * are sent as their native JS types. This map is used by the
5
+ * schema-introspector and startup provisioner for table/GSI creation.
6
+ */
7
+ export type DynamoScalarType = 'S' | 'N' | 'BOOL';
8
+ /**
9
+ * DynamoDB attribute-type string for a given ORM attr type.
10
+ * - string → S
11
+ * - number / float → N (stored as Number; DocumentClient handles it)
12
+ * - boolean → BOOL
13
+ * - date → S (ISO-8601 string — enables range queries)
14
+ * - timestamp → N (milliseconds since epoch)
15
+ * - passthrough/trim/etc → S (safe default)
16
+ *
17
+ * For key schema declarations only `S` and `N` are valid; BOOL
18
+ * is legal for attributes but never for a PK/SK.
19
+ */
20
+ declare const typeMap: Record<string, DynamoScalarType>;
21
+ /**
22
+ * Returns the DynamoDB attribute type for a given ORM type string.
23
+ * Defaults to 'S' for any unknown/custom type.
24
+ */
25
+ export declare function getDynamoType(attrType: string): DynamoScalarType;
26
+ /**
27
+ * Returns the DynamoDB key type ('S' | 'N') for use in KeySchema.
28
+ * BOOL cannot be a key attribute; anything that maps to BOOL falls back to 'S'.
29
+ */
30
+ export declare function getDynamoKeyType(attrType: string): 'S' | 'N';
31
+ export default typeMap;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Maps ORM attribute types to DynamoDB scalar attribute types.
3
+ * DynamoDB DocumentClient auto-marshalls JS objects, so most values
4
+ * are sent as their native JS types. This map is used by the
5
+ * schema-introspector and startup provisioner for table/GSI creation.
6
+ */
7
+ /**
8
+ * DynamoDB attribute-type string for a given ORM attr type.
9
+ * - string → S
10
+ * - number / float → N (stored as Number; DocumentClient handles it)
11
+ * - boolean → BOOL
12
+ * - date → S (ISO-8601 string — enables range queries)
13
+ * - timestamp → N (milliseconds since epoch)
14
+ * - passthrough/trim/etc → S (safe default)
15
+ *
16
+ * For key schema declarations only `S` and `N` are valid; BOOL
17
+ * is legal for attributes but never for a PK/SK.
18
+ */
19
+ const typeMap = {
20
+ string: 'S',
21
+ number: 'N',
22
+ float: 'N',
23
+ boolean: 'BOOL',
24
+ date: 'S',
25
+ timestamp: 'N',
26
+ passthrough: 'S',
27
+ trim: 'S',
28
+ uppercase: 'S',
29
+ ceil: 'N',
30
+ floor: 'N',
31
+ round: 'N',
32
+ };
33
+ /**
34
+ * Returns the DynamoDB attribute type for a given ORM type string.
35
+ * Defaults to 'S' for any unknown/custom type.
36
+ */
37
+ export function getDynamoType(attrType) {
38
+ return typeMap[attrType] ?? 'S';
39
+ }
40
+ /**
41
+ * Returns the DynamoDB key type ('S' | 'N') for use in KeySchema.
42
+ * BOOL cannot be a key attribute; anything that maps to BOOL falls back to 'S'.
43
+ */
44
+ export function getDynamoKeyType(attrType) {
45
+ const t = getDynamoType(attrType);
46
+ return t === 'N' ? 'N' : 'S';
47
+ }
48
+ export default typeMap;
package/dist/hooks.d.ts CHANGED
@@ -20,7 +20,21 @@ export interface HookContext {
20
20
  state?: Record<string, unknown>;
21
21
  /** Previous record state (available in update hooks). */
22
22
  oldState?: unknown;
23
- /** Target record ID for single-record operations. */
23
+ /**
24
+ * Target record ID for single-record operations.
25
+ *
26
+ * SET ONLY UNDER `delete`. `_withHooks` assigns this key in the two
27
+ * `operation === 'delete'` branches and nowhere else, so on `get`, `list`,
28
+ * `create` and `update` the key is ABSENT -- not `undefined`-valued, absent.
29
+ * A hook rule written as `ctx.recordId === '<id>'` never fires on an update;
30
+ * the addressed id is in `ctx.params`. Tracked as abofs/stonyx-orm#242.
31
+ *
32
+ * @see AccessContext.recordId in ./types/orm-types.ts -- an identically-named
33
+ * key on an identically-shaped context object, and NOT interchangeable with
34
+ * this one: it is present on every route `auth()` classifies, and spells
35
+ * absence as `null` rather than `undefined`. They differ in coverage on four
36
+ * of five operations, not only in the absence spelling.
37
+ */
24
38
  recordId?: string | number;
25
39
  /** Response data (available in after hooks). */
26
40
  response?: unknown;
package/dist/index.d.ts CHANGED
@@ -9,6 +9,9 @@ import { count, avg, sum, min, max } from './aggregates.js';
9
9
  export { default } from './main.js';
10
10
  export { store, relationships } from './main.js';
11
11
  export type { PersistErrorDetail } from './main.js';
12
+ export type { AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js';
13
+ export type { LinkageFilter } from './types/orm-types.js';
14
+ export { createLinkageFilter } from './access-verdict.js';
12
15
  export { Model, View, Serializer };
13
16
  export { attr, belongsTo, hasMany, createRecord, updateRecord };
14
17
  export { count, avg, sum, min, max };
package/dist/index.js CHANGED
@@ -23,6 +23,14 @@ import { createRecord, updateRecord } from './manage-record.js';
23
23
  import { count, avg, sum, min, max } from './aggregates.js';
24
24
  export { default } from './main.js';
25
25
  export { store, relationships } from './main.js';
26
+ // The request-scoped linkage-verdict factory (#234). PUBLIC on purpose: the
27
+ // README tells a consumer serializing a `Record` outside the REST layer to pass
28
+ // their own resolved `linkage` option, and without an exported factory the only
29
+ // way to follow that advice is to write a SECOND reading of `access()` in
30
+ // consumer code -- the exact "unreviewed second authorization vocabulary" that
31
+ // src/access-verdict.ts exists to prevent, reproduced where no reviewer sees it
32
+ // drift. Give them the one interpreter instead of an invitation to fork it.
33
+ export { createLinkageFilter } from './access-verdict.js';
26
34
  export { Model, View, Serializer }; // base classes
27
35
  export { attr, belongsTo, hasMany, createRecord, updateRecord }; // helpers
28
36
  export { count, avg, sum, min, max }; // aggregate helpers
package/dist/main.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import Store from './store.js';
2
+ import type { AccessFunction } from './types/orm-types.js';
2
3
  interface OrmOptions {
3
4
  dbType?: string;
4
5
  }
@@ -32,12 +33,127 @@ export default class Orm {
32
33
  views: Record<string, unknown>;
33
34
  transforms: Record<string, (value: unknown) => unknown>;
34
35
  warnings: Set<string>;
36
+ /**
37
+ * Model name -> the `access` predicate of the access class that CLAIMS that
38
+ * model (abofs/stonyx-orm#202).
39
+ *
40
+ * Not "that model's own predicate". One access class may claim many models
41
+ * -- `GlobalAccess` in this repo's fixtures declares five, and `models = '*'`
42
+ * claims every model in the store -- and it declares ONE `access` method, so
43
+ * the same function object is registered under every one of those keys.
44
+ * `getAccess('owner') === getAccess('animal')` is `true` there. The
45
+ * one-to-one guarantee below is key -> function, never function -> model,
46
+ * and a caller must not read a resolved predicate as being animal-specific.
47
+ * What makes the ANSWER model-specific is the context the caller passes and
48
+ * the predicate actually reading it -- see {@link Orm#getAccess}.
49
+ *
50
+ * NAMED FOR WHAT IT HOLDS. It was `accessFiles` through review, inherited
51
+ * from the function-local in `setup-rest-server.ts` where the values came
52
+ * straight out of `forEachFileImport` and "files" was defensible. The values
53
+ * are `AccessFunction`s, and the sibling public registries on this class
54
+ * (`models`, `serializers`, `views`, `transforms`) are all plural nouns of
55
+ * the thing held. Renamed here because #202 is the last moment it is free.
56
+ *
57
+ * Populated by `setup-rest-server.ts` at boot, from the access classes under
58
+ * `config.orm.paths.access`, BEFORE any route is mounted -- so it is complete
59
+ * and reachable before the first request can be served. The mapping is
60
+ * one-to-one by construction: setup-rest-server throws if two access classes
61
+ * claim the same model.
62
+ *
63
+ * Keys are model names as declared and stored (kebab-case, e.g.
64
+ * `'phone-number'`), NOT pluralised or mount-prefixed route names.
65
+ *
66
+ * WHY THIS EXISTS AS A FIELD. It used to be a function-local in
67
+ * setup-rest-server that was discarded when that function returned, so at
68
+ * request time there was no way to get from a model name to that model's
69
+ * predicate at all. Each `OrmRequest` held only its OWN model's predicate.
70
+ * That made cross-model authorization -- asking model X's predicate about a
71
+ * request routed to model Y -- inexpressible, which is the capability
72
+ * abofs/stonyx-orm#196 and abofs/stonyx-orm#207 are built on.
73
+ *
74
+ * Empty when the REST server is disabled, and PARTIAL when one access file
75
+ * failed to load (`setup-rest-server.ts` catches, warns and assigns whatever
76
+ * it had). So a missing key does NOT mean the model has no access class.
77
+ * Prefer {@link Orm#getAccess} over indexing this directly -- it is guarded
78
+ * against the prototype chain and this is not.
79
+ */
80
+ accessFunctions: Record<string, AccessFunction>;
35
81
  options: OrmOptions;
36
82
  sqlDb?: SqlDb;
37
83
  db?: OrmDB | SqlDb;
38
84
  private _persistErrorHandler;
39
85
  constructor(options?: OrmOptions);
40
86
  init(): Promise<void>;
87
+ /**
88
+ * Resolve the `access` predicate registered for a model name
89
+ * (abofs/stonyx-orm#202).
90
+ *
91
+ * This is the supported way to reach another model's predicate while
92
+ * servicing a request routed to a different model. Call it with the model
93
+ * name and invoke the result with the live request and an explicit context
94
+ * naming THAT model:
95
+ *
96
+ * ```js
97
+ * const predicate = Orm.instance.getAccess('animal');
98
+ * if (!predicate) return deny;
99
+ * const verdict = predicate(request, { model: 'animal', operation: 'read' });
100
+ * ```
101
+ *
102
+ * WHAT IT RESOLVES. The predicate of the access CLASS that claims the model,
103
+ * which is not necessarily specific to it: one class may claim many models
104
+ * and declares one `access` method, so
105
+ * `getAccess('owner') === getAccess('animal')` is `true` against this repo's
106
+ * fixture. See {@link Orm#accessFunctions}.
107
+ *
108
+ * `undefined` means NO PREDICATE COULD BE RESOLVED for that name. That
109
+ * includes a model whose access class failed to LOAD -- `setup-rest-server`
110
+ * catches, warns and publishes the partial map -- so it is not the same claim
111
+ * as "this model is unrestricted". Treat it as DENY, the same way
112
+ * `AccessContext.operation === undefined` is treated.
113
+ *
114
+ * PASSING THE CONTEXT MAKES A MODEL-CORRECT ANSWER POSSIBLE. It does not, on
115
+ * its own, make the answer model-correct: the resolved predicate has to READ
116
+ * the context. Measured against this repo's shipped access class on a request
117
+ * express dispatched to `GET /owners/angela`, asked about ANIMALS:
118
+ *
119
+ * ```
120
+ * getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
121
+ * -> record => record.id !== 'angela' && record.id !== 'restricted'
122
+ * ```
123
+ *
124
+ * The OWNERS filter, which returns `true` for animal 21 -- the record hidden
125
+ * on every animal surface. Under a mount that predicate recognises neither
126
+ * way it falls through to `['read', 'create', 'update', 'delete']`, a full
127
+ * CRUD grant. Either way: context supplied, answer not the animal answer,
128
+ * wrong in the GRANTING direction, because that predicate is arity-1 and
129
+ * identifies its collection from the request. AC9 asserts the first case on a
130
+ * live dispatch.
131
+ *
132
+ * Every predicate in this repo and in every consumer tree is arity-1 today,
133
+ * and there is no supported way for the caller to tell which kind it got; the
134
+ * boot-time arity warning that would surface it is abofs/stonyx-orm#213. Pass
135
+ * the context, and do not treat a resolved predicate's answer as
136
+ * model-specific until that predicate reads it.
137
+ *
138
+ * OWN PROPERTIES ONLY. A bare `this.accessFunctions[modelName]` walks the
139
+ * prototype chain, so `getAccess('constructor')` resolved `Object` and
140
+ * `getAccess('toString')` resolved `Object.prototype.toString` -- both
141
+ * callable, and the documented `predicate?.(request, ctx)` pattern then
142
+ * returned a TRUTHY value (`Object(request)` is the request), bypassing the
143
+ * `undefined`-means-deny contract entirely. Nothing in the ORM calls
144
+ * `getAccess` yet, so it was not exploitable as shipped -- but #207 takes the
145
+ * model name from the REQUEST BODY (`data.relationships.<key>.data.type`),
146
+ * which would have made a one-field body an authorization bypass. Guarded
147
+ * here at the read point rather than by constructing the map with a null
148
+ * prototype, because the field is public and reassignable and the guard has
149
+ * to hold whatever object it is holding.
150
+ *
151
+ * @param modelName - Model name as declared and stored (kebab-case).
152
+ * @returns The predicate, or `undefined` when no predicate could be resolved
153
+ * for that name. `undefined` is NOT "this model is unrestricted" -- see the
154
+ * note above. Treat it as deny.
155
+ */
156
+ getAccess(modelName: string): AccessFunction | undefined;
41
157
  startup(): Promise<void>;
42
158
  shutdown(): Promise<void>;
43
159
  static get db(): OrmDB | SqlDb;
package/dist/main.js CHANGED
@@ -38,6 +38,51 @@ export default class Orm {
38
38
  views = {};
39
39
  transforms = { ...baseTransforms };
40
40
  warnings = new Set();
41
+ /**
42
+ * Model name -> the `access` predicate of the access class that CLAIMS that
43
+ * model (abofs/stonyx-orm#202).
44
+ *
45
+ * Not "that model's own predicate". One access class may claim many models
46
+ * -- `GlobalAccess` in this repo's fixtures declares five, and `models = '*'`
47
+ * claims every model in the store -- and it declares ONE `access` method, so
48
+ * the same function object is registered under every one of those keys.
49
+ * `getAccess('owner') === getAccess('animal')` is `true` there. The
50
+ * one-to-one guarantee below is key -> function, never function -> model,
51
+ * and a caller must not read a resolved predicate as being animal-specific.
52
+ * What makes the ANSWER model-specific is the context the caller passes and
53
+ * the predicate actually reading it -- see {@link Orm#getAccess}.
54
+ *
55
+ * NAMED FOR WHAT IT HOLDS. It was `accessFiles` through review, inherited
56
+ * from the function-local in `setup-rest-server.ts` where the values came
57
+ * straight out of `forEachFileImport` and "files" was defensible. The values
58
+ * are `AccessFunction`s, and the sibling public registries on this class
59
+ * (`models`, `serializers`, `views`, `transforms`) are all plural nouns of
60
+ * the thing held. Renamed here because #202 is the last moment it is free.
61
+ *
62
+ * Populated by `setup-rest-server.ts` at boot, from the access classes under
63
+ * `config.orm.paths.access`, BEFORE any route is mounted -- so it is complete
64
+ * and reachable before the first request can be served. The mapping is
65
+ * one-to-one by construction: setup-rest-server throws if two access classes
66
+ * claim the same model.
67
+ *
68
+ * Keys are model names as declared and stored (kebab-case, e.g.
69
+ * `'phone-number'`), NOT pluralised or mount-prefixed route names.
70
+ *
71
+ * WHY THIS EXISTS AS A FIELD. It used to be a function-local in
72
+ * setup-rest-server that was discarded when that function returned, so at
73
+ * request time there was no way to get from a model name to that model's
74
+ * predicate at all. Each `OrmRequest` held only its OWN model's predicate.
75
+ * That made cross-model authorization -- asking model X's predicate about a
76
+ * request routed to model Y -- inexpressible, which is the capability
77
+ * abofs/stonyx-orm#196 and abofs/stonyx-orm#207 are built on.
78
+ *
79
+ * Empty when the REST server is disabled, and PARTIAL when one access file
80
+ * failed to load (`setup-rest-server.ts` catches, warns and assigns whatever
81
+ * it had). So a missing key does NOT mean the model has no access class.
82
+ * Prefer {@link Orm#getAccess} over indexing this directly -- it is guarded
83
+ * against the prototype chain and this is not.
84
+ */
85
+ accessFunctions = {};
41
86
  options;
42
87
  sqlDb;
43
88
  db;
@@ -54,6 +99,10 @@ export default class Orm {
54
99
  Orm.instance = this;
55
100
  }
56
101
  async init() {
102
+ // Self-register so log.db works even when @stonyx/orm is in the
103
+ // consumer's `dependencies` (stonyx loader only merges devDependencies).
104
+ const { logColor = 'white', logMethod = 'db' } = config.orm;
105
+ log.defineType(logMethod, logColor);
57
106
  const { paths, restServer } = config.orm;
58
107
  const promises = ['Model', 'Serializer', 'Transform'].map(type => {
59
108
  const lowerCaseType = type.toLowerCase();
@@ -115,6 +164,12 @@ export default class Orm {
115
164
  this.db = this.sqlDb;
116
165
  promises.push(this.sqlDb.init());
117
166
  }
167
+ else if (config.orm.dynamodb) {
168
+ const { default: DynamoDBDB } = await import('./dynamodb/dynamodb-db.js');
169
+ this.sqlDb = new DynamoDBDB();
170
+ this.db = this.sqlDb;
171
+ promises.push(this.sqlDb.init());
172
+ }
118
173
  else if (this.options.dbType !== 'none') {
119
174
  const db = new DB();
120
175
  this.db = db;
@@ -135,6 +190,80 @@ export default class Orm {
135
190
  Orm.ready = await Promise.all(promises);
136
191
  Orm.initialized = true;
137
192
  }
193
+ /**
194
+ * Resolve the `access` predicate registered for a model name
195
+ * (abofs/stonyx-orm#202).
196
+ *
197
+ * This is the supported way to reach another model's predicate while
198
+ * servicing a request routed to a different model. Call it with the model
199
+ * name and invoke the result with the live request and an explicit context
200
+ * naming THAT model:
201
+ *
202
+ * ```js
203
+ * const predicate = Orm.instance.getAccess('animal');
204
+ * if (!predicate) return deny;
205
+ * const verdict = predicate(request, { model: 'animal', operation: 'read' });
206
+ * ```
207
+ *
208
+ * WHAT IT RESOLVES. The predicate of the access CLASS that claims the model,
209
+ * which is not necessarily specific to it: one class may claim many models
210
+ * and declares one `access` method, so
211
+ * `getAccess('owner') === getAccess('animal')` is `true` against this repo's
212
+ * fixture. See {@link Orm#accessFunctions}.
213
+ *
214
+ * `undefined` means NO PREDICATE COULD BE RESOLVED for that name. That
215
+ * includes a model whose access class failed to LOAD -- `setup-rest-server`
216
+ * catches, warns and publishes the partial map -- so it is not the same claim
217
+ * as "this model is unrestricted". Treat it as DENY, the same way
218
+ * `AccessContext.operation === undefined` is treated.
219
+ *
220
+ * PASSING THE CONTEXT MAKES A MODEL-CORRECT ANSWER POSSIBLE. It does not, on
221
+ * its own, make the answer model-correct: the resolved predicate has to READ
222
+ * the context. Measured against this repo's shipped access class on a request
223
+ * express dispatched to `GET /owners/angela`, asked about ANIMALS:
224
+ *
225
+ * ```
226
+ * getAccess('animal')(ownersRequest, { model: 'animal', operation: 'read' })
227
+ * -> record => record.id !== 'angela' && record.id !== 'restricted'
228
+ * ```
229
+ *
230
+ * The OWNERS filter, which returns `true` for animal 21 -- the record hidden
231
+ * on every animal surface. Under a mount that predicate recognises neither
232
+ * way it falls through to `['read', 'create', 'update', 'delete']`, a full
233
+ * CRUD grant. Either way: context supplied, answer not the animal answer,
234
+ * wrong in the GRANTING direction, because that predicate is arity-1 and
235
+ * identifies its collection from the request. AC9 asserts the first case on a
236
+ * live dispatch.
237
+ *
238
+ * Every predicate in this repo and in every consumer tree is arity-1 today,
239
+ * and there is no supported way for the caller to tell which kind it got; the
240
+ * boot-time arity warning that would surface it is abofs/stonyx-orm#213. Pass
241
+ * the context, and do not treat a resolved predicate's answer as
242
+ * model-specific until that predicate reads it.
243
+ *
244
+ * OWN PROPERTIES ONLY. A bare `this.accessFunctions[modelName]` walks the
245
+ * prototype chain, so `getAccess('constructor')` resolved `Object` and
246
+ * `getAccess('toString')` resolved `Object.prototype.toString` -- both
247
+ * callable, and the documented `predicate?.(request, ctx)` pattern then
248
+ * returned a TRUTHY value (`Object(request)` is the request), bypassing the
249
+ * `undefined`-means-deny contract entirely. Nothing in the ORM calls
250
+ * `getAccess` yet, so it was not exploitable as shipped -- but #207 takes the
251
+ * model name from the REQUEST BODY (`data.relationships.<key>.data.type`),
252
+ * which would have made a one-field body an authorization bypass. Guarded
253
+ * here at the read point rather than by constructing the map with a null
254
+ * prototype, because the field is public and reassignable and the guard has
255
+ * to hold whatever object it is holding.
256
+ *
257
+ * @param modelName - Model name as declared and stored (kebab-case).
258
+ * @returns The predicate, or `undefined` when no predicate could be resolved
259
+ * for that name. `undefined` is NOT "this model is unrestricted" -- see the
260
+ * note above. Treat it as deny.
261
+ */
262
+ getAccess(modelName) {
263
+ if (!Object.hasOwn(this.accessFunctions, modelName))
264
+ return undefined;
265
+ return this.accessFunctions[modelName];
266
+ }
138
267
  async startup() {
139
268
  if (this.sqlDb)
140
269
  await this.sqlDb.startup();