@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.
- package/README.md +1409 -11
- package/config/environment.js +99 -12
- package/dist/access-verdict.d.ts +85 -0
- package/dist/access-verdict.js +284 -0
- package/dist/commands.js +34 -0
- package/dist/dynamodb/connection.d.ts +31 -0
- package/dist/dynamodb/connection.js +28 -0
- package/dist/dynamodb/dynamodb-db.d.ts +142 -0
- package/dist/dynamodb/dynamodb-db.js +596 -0
- package/dist/dynamodb/operation-builder.d.ts +76 -0
- package/dist/dynamodb/operation-builder.js +116 -0
- package/dist/dynamodb/type-map.d.ts +31 -0
- package/dist/dynamodb/type-map.js +48 -0
- package/dist/hooks.d.ts +15 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +8 -0
- package/dist/main.d.ts +116 -0
- package/dist/main.js +129 -0
- package/dist/manage-record.js +268 -12
- package/dist/mysql/connection.d.ts +1 -0
- package/dist/mysql/mysql-db.d.ts +8 -0
- package/dist/mysql/mysql-db.js +44 -10
- package/dist/orm-request.d.ts +274 -3
- package/dist/orm-request.js +1259 -65
- package/dist/postgres/connection.d.ts +1 -0
- package/dist/postgres/connection.js +8 -6
- package/dist/postgres/postgres-db.d.ts +8 -0
- package/dist/postgres/postgres-db.js +44 -10
- package/dist/record.d.ts +16 -0
- package/dist/record.js +154 -6
- package/dist/relationships.js +1 -1
- package/dist/serializer.js +38 -2
- package/dist/setup-rest-server.js +51 -5
- package/dist/standalone-db.js +17 -5
- package/dist/store.d.ts +13 -1
- package/dist/store.js +65 -6
- package/dist/types/orm-types.d.ts +260 -0
- package/dist/utils.d.ts +44 -0
- package/dist/utils.js +47 -0
- package/package.json +16 -7
- package/src/access-verdict.ts +312 -0
- package/src/commands.ts +43 -0
- package/src/dynamodb/connection.ts +50 -0
- package/src/dynamodb/dynamodb-db.ts +811 -0
- package/src/dynamodb/operation-builder.ts +202 -0
- package/src/dynamodb/type-map.ts +54 -0
- package/src/hooks.ts +15 -1
- package/src/index.ts +10 -0
- package/src/main.ts +133 -0
- package/src/manage-record.ts +294 -18
- package/src/mysql/connection.ts +1 -0
- package/src/mysql/mysql-db.ts +44 -12
- package/src/orm-request.ts +1281 -67
- package/src/postgres/connection.ts +10 -6
- package/src/postgres/postgres-db.ts +44 -12
- package/src/record.ts +182 -6
- package/src/relationships.ts +1 -1
- package/src/serializer.ts +39 -2
- package/src/setup-rest-server.ts +59 -6
- package/src/standalone-db.ts +17 -6
- package/src/store.ts +68 -6
- package/src/types/orm-types.ts +268 -1
- package/src/types/stonyx-rest-server.d.ts +14 -1
- package/src/types/stonyx.d.ts +7 -1
- package/src/utils.ts +50 -0
- 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
|
-
/**
|
|
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();
|