@liminalfunctions/framework 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mocharc.json +5 -0
- package/dist/F_Client_Collection_Registry.d.ts +18 -0
- package/dist/F_Client_Collection_Registry.js +36 -0
- package/dist/F_Client_Collection_Registry.js.map +1 -0
- package/dist/F_Collection.d.ts +21 -0
- package/dist/F_Collection.js +36 -0
- package/dist/F_Collection.js.map +1 -0
- package/dist/F_Collection_Registry.d.ts +11 -0
- package/dist/F_Collection_Registry.js +18 -0
- package/dist/F_Collection_Registry.js.map +1 -0
- package/dist/F_Compile.d.ts +4 -0
- package/dist/F_Compile.js +298 -0
- package/dist/F_Compile.js.map +1 -0
- package/dist/F_Security_Models/F_SM_Open_Access.d.ts +11 -0
- package/dist/F_Security_Models/F_SM_Open_Access.js +14 -0
- package/dist/F_Security_Models/F_SM_Open_Access.js.map +1 -0
- package/dist/F_Security_Models/F_SM_Ownership.d.ts +12 -0
- package/dist/F_Security_Models/F_SM_Ownership.js +46 -0
- package/dist/F_Security_Models/F_SM_Ownership.js.map +1 -0
- package/dist/F_Security_Models/F_SM_Role_Membership.d.ts +19 -0
- package/dist/F_Security_Models/F_SM_Role_Membership.js +73 -0
- package/dist/F_Security_Models/F_SM_Role_Membership.js.map +1 -0
- package/dist/F_Security_Models/F_Security_Model.d.ts +41 -0
- package/dist/F_Security_Models/F_Security_Model.js +29 -0
- package/dist/F_Security_Models/F_Security_Model.js.map +1 -0
- package/dist/code_generation/generate_client_library.d.ts +4 -0
- package/dist/code_generation/generate_client_library.js +158 -0
- package/dist/code_generation/generate_client_library.js.map +1 -0
- package/dist/code_generation/templates/.gitignore.mustache +383 -0
- package/dist/code_generation/templates/collection.mustache +106 -0
- package/dist/code_generation/templates/main.mustache +24 -0
- package/dist/code_generation/templates/package.json.mustache +18 -0
- package/dist/code_generation/templates/tsconfig.json.mustache +14 -0
- package/dist/code_generation/templates/types.mustache +4 -0
- package/dist/code_generation/templates/utils.ts.mustache +17 -0
- package/dist/code_generation/utils/tab_indent.d.ts +1 -0
- package/dist/code_generation/utils/tab_indent.js +4 -0
- package/dist/code_generation/utils/tab_indent.js.map +1 -0
- package/dist/code_generation/utils/type_from_zod.d.ts +2 -0
- package/dist/code_generation/utils/type_from_zod.js +102 -0
- package/dist/code_generation/utils/type_from_zod.js.map +1 -0
- package/dist/utils/cache.d.ts +13 -0
- package/dist/utils/cache.js +101 -0
- package/dist/utils/cache.js.map +1 -0
- package/dist/utils/mongoose_from_zod.d.ts +13 -0
- package/dist/utils/mongoose_from_zod.js +164 -0
- package/dist/utils/mongoose_from_zod.js.map +1 -0
- package/dist/utils/pretty_print_zod.d.ts +2 -0
- package/dist/utils/pretty_print_zod.js +63 -0
- package/dist/utils/pretty_print_zod.js.map +1 -0
- package/dist/utils/query_object_to_mongodb_query.d.ts +3 -0
- package/dist/utils/query_object_to_mongodb_query.js +61 -0
- package/dist/utils/query_object_to_mongodb_query.js.map +1 -0
- package/dist/utils/query_validator_from_zod.d.ts +6 -0
- package/dist/utils/query_validator_from_zod.js +216 -0
- package/dist/utils/query_validator_from_zod.js.map +1 -0
- package/package.json +36 -0
- package/src/F_Collection.ts +50 -0
- package/src/F_Collection_Registry.ts +29 -0
- package/src/F_Compile.ts +368 -0
- package/src/F_Security_Models/F_SM_Open_Access.ts +21 -0
- package/src/F_Security_Models/F_SM_Ownership.ts +72 -0
- package/src/F_Security_Models/F_SM_Role_Membership.ts +87 -0
- package/src/F_Security_Models/F_Security_Model.ts +85 -0
- package/src/code_generation/generate_client_library.ts +197 -0
- package/src/code_generation/templates/.gitignore.mustache +383 -0
- package/src/code_generation/templates/collection.mustache +106 -0
- package/src/code_generation/templates/main.mustache +24 -0
- package/src/code_generation/templates/package.json.mustache +18 -0
- package/src/code_generation/templates/tsconfig.json.mustache +14 -0
- package/src/code_generation/templates/types.mustache +4 -0
- package/src/code_generation/templates/utils.ts.mustache +17 -0
- package/src/code_generation/utils/tab_indent.ts +3 -0
- package/src/code_generation/utils/type_from_zod.ts +140 -0
- package/src/utils/cache.ts +149 -0
- package/src/utils/mongoose_from_zod.ts +191 -0
- package/src/utils/pretty_print_zod.ts +75 -0
- package/src/utils/query_object_to_mongodb_query.ts +73 -0
- package/src/utils/query_validator_from_zod.ts +246 -0
- package/test/0_0_mongoose_from_zod.test.ts +260 -0
- package/test/0_1_query_validator_from_zod.test.ts +518 -0
- package/test/0_2_query_validator_to_mongodb_query.test.ts +365 -0
- package/test/0_3_cache.test.ts +204 -0
- package/test/1_0_basic_server.test.ts +530 -0
- package/test/1_1_security_ownership.test.ts +328 -0
- package/test/1_2_role_membership.test.ts +731 -0
- package/test/2_0_client_library_basic_type_generation.test.ts +444 -0
- package/test/2_0_client_library_query_type_generation.test.ts +352 -0
- package/test/2_1_client_library_generation.test.ts +255 -0
- package/test/tmp/dist/Brief_News_Category.d.ts +16 -0
- package/test/tmp/dist/Brief_News_Category.js +85 -0
- package/test/tmp/dist/Brief_News_Category.js.map +1 -0
- package/test/tmp/dist/Client.d.ts +19 -0
- package/test/tmp/dist/Client.js +97 -0
- package/test/tmp/dist/Client.js.map +1 -0
- package/test/tmp/dist/Institution.d.ts +18 -0
- package/test/tmp/dist/Institution.js +94 -0
- package/test/tmp/dist/Institution.js.map +1 -0
- package/test/tmp/dist/Project.d.ts +16 -0
- package/test/tmp/dist/Project.js +85 -0
- package/test/tmp/dist/Project.js.map +1 -0
- package/test/tmp/dist/index.d.ts +4 -0
- package/test/tmp/dist/index.js +14 -0
- package/test/tmp/dist/index.js.map +1 -0
- package/test/tmp/dist/types/brief_news_category.d.ts +7 -0
- package/test/tmp/dist/types/brief_news_category.js +2 -0
- package/test/tmp/dist/types/brief_news_category.js.map +1 -0
- package/test/tmp/dist/types/brief_news_category_post.d.ts +7 -0
- package/test/tmp/dist/types/brief_news_category_post.js +2 -0
- package/test/tmp/dist/types/brief_news_category_post.js.map +1 -0
- package/test/tmp/dist/types/brief_news_category_put.d.ts +7 -0
- package/test/tmp/dist/types/brief_news_category_put.js +2 -0
- package/test/tmp/dist/types/brief_news_category_put.js.map +1 -0
- package/test/tmp/dist/types/brief_news_category_query.d.ts +26 -0
- package/test/tmp/dist/types/brief_news_category_query.js +2 -0
- package/test/tmp/dist/types/brief_news_category_query.js.map +1 -0
- package/test/tmp/dist/types/client.d.ts +5 -0
- package/test/tmp/dist/types/client.js +2 -0
- package/test/tmp/dist/types/client.js.map +1 -0
- package/test/tmp/dist/types/client_post.d.ts +5 -0
- package/test/tmp/dist/types/client_post.js +2 -0
- package/test/tmp/dist/types/client_post.js.map +1 -0
- package/test/tmp/dist/types/client_put.d.ts +5 -0
- package/test/tmp/dist/types/client_put.js +2 -0
- package/test/tmp/dist/types/client_put.js.map +1 -0
- package/test/tmp/dist/types/client_query.d.ts +18 -0
- package/test/tmp/dist/types/client_query.js +2 -0
- package/test/tmp/dist/types/client_query.js.map +1 -0
- package/test/tmp/dist/types/institution.d.ts +4 -0
- package/test/tmp/dist/types/institution.js +2 -0
- package/test/tmp/dist/types/institution.js.map +1 -0
- package/test/tmp/dist/types/institution_post.d.ts +4 -0
- package/test/tmp/dist/types/institution_post.js +2 -0
- package/test/tmp/dist/types/institution_post.js.map +1 -0
- package/test/tmp/dist/types/institution_put.d.ts +4 -0
- package/test/tmp/dist/types/institution_put.js +2 -0
- package/test/tmp/dist/types/institution_put.js.map +1 -0
- package/test/tmp/dist/types/institution_query.d.ts +14 -0
- package/test/tmp/dist/types/institution_query.js +2 -0
- package/test/tmp/dist/types/institution_query.js.map +1 -0
- package/test/tmp/dist/types/project.d.ts +7 -0
- package/test/tmp/dist/types/project.js +2 -0
- package/test/tmp/dist/types/project.js.map +1 -0
- package/test/tmp/dist/types/project_post.d.ts +7 -0
- package/test/tmp/dist/types/project_post.js +2 -0
- package/test/tmp/dist/types/project_post.js.map +1 -0
- package/test/tmp/dist/types/project_put.d.ts +7 -0
- package/test/tmp/dist/types/project_put.js +2 -0
- package/test/tmp/dist/types/project_put.js.map +1 -0
- package/test/tmp/dist/types/project_query.d.ts +27 -0
- package/test/tmp/dist/types/project_query.js +2 -0
- package/test/tmp/dist/types/project_query.js.map +1 -0
- package/test/tmp/dist/utils/utils.d.ts +11 -0
- package/test/tmp/dist/utils/utils.js +13 -0
- package/test/tmp/dist/utils/utils.js.map +1 -0
- package/test/tmp/package-lock.json +573 -0
- package/test/tmp/package.json +18 -0
- package/test/tmp/src/Brief_News_Category.ts +94 -0
- package/test/tmp/src/Client.ts +106 -0
- package/test/tmp/src/Institution.ts +103 -0
- package/test/tmp/src/Project.ts +94 -0
- package/test/tmp/src/index.ts +20 -0
- package/test/tmp/src/types/brief_news_category.ts +7 -0
- package/test/tmp/src/types/brief_news_category_post.ts +7 -0
- package/test/tmp/src/types/brief_news_category_put.ts +7 -0
- package/test/tmp/src/types/brief_news_category_query.ts +26 -0
- package/test/tmp/src/types/client.ts +5 -0
- package/test/tmp/src/types/client_post.ts +5 -0
- package/test/tmp/src/types/client_put.ts +5 -0
- package/test/tmp/src/types/client_query.ts +18 -0
- package/test/tmp/src/types/institution.ts +4 -0
- package/test/tmp/src/types/institution_post.ts +4 -0
- package/test/tmp/src/types/institution_put.ts +4 -0
- package/test/tmp/src/types/institution_query.ts +14 -0
- package/test/tmp/src/types/project.ts +7 -0
- package/test/tmp/src/types/project_post.ts +7 -0
- package/test/tmp/src/types/project_put.ts +7 -0
- package/test/tmp/src/types/project_query.ts +27 -0
- package/test/tmp/src/utils/utils.ts +17 -0
- package/test/tmp/tsconfig.json +14 -0
- package/tsconfig.json +14 -0
package/src/F_Compile.ts
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import * as z from "zod/v4";
|
|
2
|
+
import { Router, Request, Response } from "express";
|
|
3
|
+
import { isValidObjectId } from "mongoose";
|
|
4
|
+
|
|
5
|
+
import { F_Collection } from "./F_Collection.js";
|
|
6
|
+
import { F_Security_Model, Authenticated_Request } from "./F_Security_Models/F_Security_Model.js";
|
|
7
|
+
import { query_object_to_mongodb_limits, query_object_to_mongodb_query } from "./utils/query_object_to_mongodb_query.js";
|
|
8
|
+
import { z_mongodb_id } from "./utils/mongoose_from_zod.js";
|
|
9
|
+
|
|
10
|
+
export function compile<Collection_ID extends string, ZodSchema extends z.ZodObject>(app: Router, collection: F_Collection<Collection_ID, ZodSchema>, api_prefix: string){
|
|
11
|
+
for(let access_layers of collection.access_layers){
|
|
12
|
+
|
|
13
|
+
/*app.use((req, res, next) => {
|
|
14
|
+
console.log(`${req.method} ${req.originalUrl}`)
|
|
15
|
+
next();
|
|
16
|
+
})*/
|
|
17
|
+
|
|
18
|
+
let base_layers_path_components = access_layers.layers.flatMap(ele => [ele, ':' + ele]);
|
|
19
|
+
|
|
20
|
+
let get_one_path = [
|
|
21
|
+
api_prefix,
|
|
22
|
+
...base_layers_path_components,
|
|
23
|
+
`${collection.collection_id}/:document_id`
|
|
24
|
+
].join('/')
|
|
25
|
+
|
|
26
|
+
// get individual document
|
|
27
|
+
app.get(get_one_path, async (req: Request, res: Response) => {
|
|
28
|
+
// ensure the the document ID passed in is valid so that mongodb doesn't have a cow
|
|
29
|
+
if (!isValidObjectId(req.params.document_id)) {
|
|
30
|
+
res.status(400);
|
|
31
|
+
res.json({ error: `${req.params.document_id} is not a valid document ID.` });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let find = { '_id': req.params.document_id } as { [key: string]: any }
|
|
36
|
+
for(let layer of access_layers.layers){
|
|
37
|
+
find[`${layer}_id`] = req.params[layer];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let permissive_security_model = await F_Security_Model.model_with_permission(access_layers.security_models, req, res, find, 'get');
|
|
41
|
+
if (!permissive_security_model) {
|
|
42
|
+
res.status(403);
|
|
43
|
+
res.json({ error: `You do not have permission to fetch documents from ${req.params.document_type}.` });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let document;
|
|
48
|
+
try {
|
|
49
|
+
//@ts-expect-error
|
|
50
|
+
document = await collection.mongoose_model.findOne(find, undefined, { 'lean': true });
|
|
51
|
+
} catch(err){
|
|
52
|
+
res.status(500);
|
|
53
|
+
res.json({ error: `there was a novel error` });
|
|
54
|
+
console.error(err);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!document) {
|
|
59
|
+
let sendable = await permissive_security_model.handle_empty_query_results(req, res, 'get');
|
|
60
|
+
res.json(sendable);
|
|
61
|
+
} else {
|
|
62
|
+
//await req.schema.handle_pre_send(req, document);
|
|
63
|
+
res.json({ data: document });
|
|
64
|
+
}
|
|
65
|
+
//await req.schema.fire_api_event('get', req, [document]);
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
let get_multiple_path = [
|
|
70
|
+
api_prefix,
|
|
71
|
+
...base_layers_path_components,
|
|
72
|
+
collection.collection_id
|
|
73
|
+
].join('/')
|
|
74
|
+
|
|
75
|
+
app.get(get_multiple_path, async (req: Request, res: Response) => {
|
|
76
|
+
let validated_query_args: { [key: string]: any } ;
|
|
77
|
+
try {
|
|
78
|
+
validated_query_args = collection.query_validator_server.parse(req.query);
|
|
79
|
+
} catch(err){
|
|
80
|
+
if(err instanceof z.ZodError){
|
|
81
|
+
res.status(400);
|
|
82
|
+
res.json({ error: err.issues });
|
|
83
|
+
return;
|
|
84
|
+
} else {
|
|
85
|
+
console.error(err);
|
|
86
|
+
res.status(500);
|
|
87
|
+
res.json({ error: `there was a novel error` });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let find = query_object_to_mongodb_query(validated_query_args) as { [key: string]: any };
|
|
93
|
+
for(let layer of access_layers.layers){
|
|
94
|
+
find[`${layer}_id`] = req.params[layer];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let permissive_security_model = await F_Security_Model.model_with_permission(access_layers.security_models, req, res, find, 'get');
|
|
98
|
+
if (!permissive_security_model) {
|
|
99
|
+
res.status(403);
|
|
100
|
+
res.json({ error: `You do not have permission to fetch documents from ${req.params.document_type}.` });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let documents;
|
|
105
|
+
try {
|
|
106
|
+
//@ts-expect-error
|
|
107
|
+
let query = collection.mongoose_model.find(find, undefined, { 'lean': true });
|
|
108
|
+
let fetch = query_object_to_mongodb_limits(query, collection.query_validator_server);
|
|
109
|
+
documents = await fetch;
|
|
110
|
+
} catch(err){
|
|
111
|
+
if (err.name == 'CastError') {
|
|
112
|
+
res.status(400);
|
|
113
|
+
res.json({ error: 'one of the IDs you passed to the query was not a valid MongoDB object ID.' });
|
|
114
|
+
return;
|
|
115
|
+
} else {
|
|
116
|
+
res.status(500);
|
|
117
|
+
res.send({error: 'there was a novel error'});
|
|
118
|
+
console.error(err);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!documents) {
|
|
124
|
+
let sendable = await permissive_security_model.handle_empty_query_results(req, res, 'get');
|
|
125
|
+
res.json(sendable);
|
|
126
|
+
} else {
|
|
127
|
+
//await req.schema.handle_pre_send(req, document);
|
|
128
|
+
res.json({ data: documents });
|
|
129
|
+
}
|
|
130
|
+
//await req.schema.fire_api_event('get', req, [document]);
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
let put_path = [
|
|
134
|
+
api_prefix,
|
|
135
|
+
...base_layers_path_components,
|
|
136
|
+
`${collection.collection_id}/:document_id`
|
|
137
|
+
].join('/')
|
|
138
|
+
|
|
139
|
+
app.put(put_path, async (req, res) => {
|
|
140
|
+
if (!isValidObjectId(req.params.document_id)) {
|
|
141
|
+
res.status(400);
|
|
142
|
+
res.json({ error: `${req.params.document_id} is not a valid document ID.` });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let find = { '_id': req.params.document_id } as { [key: string]: any } ;
|
|
147
|
+
for(let layer of access_layers.layers){
|
|
148
|
+
find[`${layer}_id`] = req.params[layer];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
let permissive_security_model = await F_Security_Model.model_with_permission(access_layers.security_models, req, res, find, 'update');
|
|
153
|
+
if (!permissive_security_model) {
|
|
154
|
+
res.status(403);
|
|
155
|
+
res.json({ error: `You do not have permission to fetch documents from ${req.params.document_type}.` });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if(collection.mongoose_schema.updated_by?.type === String) {
|
|
160
|
+
// if the security schema required the user to be logged in, then req.auth.user_id will not be null
|
|
161
|
+
if((req as Authenticated_Request).auth?.user_id){
|
|
162
|
+
req.body.updated_by = (req as Authenticated_Request).auth?.user_id;
|
|
163
|
+
} else {
|
|
164
|
+
req.body.updated_by = null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if(collection.mongoose_schema.updated_at?.type === Date) {
|
|
169
|
+
req.body.updated_at = new Date();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// TODO: it might be possible to build a validator that matches mongoDB's update
|
|
173
|
+
// syntax to allow for targeted updating of nested stuff
|
|
174
|
+
let validated_request_body;
|
|
175
|
+
try {
|
|
176
|
+
validated_request_body = await collection.put_validator.parse(req.body);
|
|
177
|
+
} catch(err){
|
|
178
|
+
if(err instanceof z.ZodError){
|
|
179
|
+
res.status(400);
|
|
180
|
+
res.json({ error: err.issues });
|
|
181
|
+
return;
|
|
182
|
+
} else {
|
|
183
|
+
console.error(err);
|
|
184
|
+
res.status(500);
|
|
185
|
+
res.json({ error: `there was a novel error` });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// if you're accessing the document from /x/:x/y/:y, then you can't change x or y. Note that this does mean if you can access
|
|
191
|
+
// the document from /x/:x, then you'd be able to change y.
|
|
192
|
+
for(let layer of access_layers.layers){
|
|
193
|
+
//@ts-expect-error
|
|
194
|
+
if(validated_request_body[`${layer}_id`] && validated_request_body[`${layer}_id`] !== req.params[layer]){
|
|
195
|
+
res.status(403);
|
|
196
|
+
res.json({ error: `The system does not support changing the ${layer}_id of the document with this endpoint.` });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/*let { error: pre_save_error } = await req.schema.handle_pre_save(req, value);
|
|
202
|
+
if (pre_save_error) {
|
|
203
|
+
res.status(400);
|
|
204
|
+
res.json({ error: pre_save_error.message });
|
|
205
|
+
return;
|
|
206
|
+
}*/
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
let results;
|
|
210
|
+
try {
|
|
211
|
+
//@ts-expect-error
|
|
212
|
+
results = await collection.mongoose_model.findOneAndUpdate(find, validated_request_body, { returnDocument: 'after', lean: true });
|
|
213
|
+
} catch(err){
|
|
214
|
+
res.status(500);
|
|
215
|
+
res.json({ error: `there was a novel error` });
|
|
216
|
+
console.error(err);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!results) {
|
|
221
|
+
let sendable = await permissive_security_model.handle_empty_query_results(req, res, 'update');
|
|
222
|
+
res.json(sendable);
|
|
223
|
+
} else {
|
|
224
|
+
res.json({ data: results });
|
|
225
|
+
}
|
|
226
|
+
//await req.schema.fire_api_event('update', req, results);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
let post_path = [
|
|
230
|
+
api_prefix,
|
|
231
|
+
...base_layers_path_components,
|
|
232
|
+
`${collection.collection_id}`
|
|
233
|
+
].join('/')
|
|
234
|
+
|
|
235
|
+
app.post(post_path, async (req, res) => {
|
|
236
|
+
// I'd like to have a validator here. I think it might need to be a map or record validator?
|
|
237
|
+
let permissive_security_model = await F_Security_Model.model_with_permission(access_layers.security_models, req, res, undefined, 'create');
|
|
238
|
+
if (!permissive_security_model) {
|
|
239
|
+
res.status(403);
|
|
240
|
+
res.json({ error: `You do not have permission to fetch documents from ${req.params.document_type}.` });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if(collection.mongoose_schema.updated_by?.type === String) {
|
|
245
|
+
// if the security schema required the user to be logged in, then req.auth.user_id will not be null
|
|
246
|
+
if((req as Authenticated_Request).auth?.user_id){
|
|
247
|
+
req.body.updated_by = (req as Authenticated_Request).auth?.user_id;
|
|
248
|
+
} else {
|
|
249
|
+
req.body.updated_by = null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if(collection.mongoose_schema.updated_at?.type === Date) {
|
|
254
|
+
req.body.updated_at = new Date();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if(collection.mongoose_schema.created_by?.type === String) {
|
|
258
|
+
// if the security schema required the user to be logged in, then req.auth.user_id will not be null
|
|
259
|
+
if((req as Authenticated_Request).auth?.user_id){
|
|
260
|
+
req.body.created_by = (req as Authenticated_Request).auth?.user_id;
|
|
261
|
+
} else {
|
|
262
|
+
req.body.created_by = null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if(collection.mongoose_schema.created_at?.type === Date) {
|
|
267
|
+
req.body.created_at = new Date();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let validated_request_body;
|
|
271
|
+
try {
|
|
272
|
+
validated_request_body = await collection.post_validator.parse(req.body);
|
|
273
|
+
} catch(err){
|
|
274
|
+
if(err instanceof z.ZodError){
|
|
275
|
+
res.status(400);
|
|
276
|
+
res.json({ error: err.issues });
|
|
277
|
+
return;
|
|
278
|
+
} else {
|
|
279
|
+
console.error(err);
|
|
280
|
+
res.status(500);
|
|
281
|
+
res.json({ error: `there was a novel error` });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// if you're accessing the document from /x/:x/y/:y, then you can't change x or y. Note that this does mean if you can access
|
|
287
|
+
// the document from /x/:x, then you'd be able to change y.
|
|
288
|
+
for(let layer of access_layers.layers){
|
|
289
|
+
//@ts-expect-error
|
|
290
|
+
if(validated_request_body[`${layer}_id`] && validated_request_body[`${layer}_id`] !== req.params[layer]){
|
|
291
|
+
res.status(403);
|
|
292
|
+
res.json({ error: `The system does not support changing the ${layer}_id of the document with this endpoint.` });
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/*let { error: pre_save_error } = await req.schema.handle_pre_save(req, validated_request_body);
|
|
298
|
+
if (pre_save_error) {
|
|
299
|
+
res.status(400);
|
|
300
|
+
res.json({ error: pre_save_error.message });
|
|
301
|
+
return;
|
|
302
|
+
}*/
|
|
303
|
+
|
|
304
|
+
let results;
|
|
305
|
+
try {
|
|
306
|
+
results = await collection.mongoose_model.create(validated_request_body);
|
|
307
|
+
} catch(err){
|
|
308
|
+
res.status(500);
|
|
309
|
+
res.json({ error: `there was a novel error` });
|
|
310
|
+
console.error(err);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (!results) {
|
|
315
|
+
let sendable = await permissive_security_model.handle_empty_query_results(req, res, 'create');
|
|
316
|
+
res.json(sendable);
|
|
317
|
+
} else {
|
|
318
|
+
res.json({ data: results });
|
|
319
|
+
}
|
|
320
|
+
//await req.schema.fire_api_event('create', req, results);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
let delete_path = [
|
|
324
|
+
api_prefix,
|
|
325
|
+
...base_layers_path_components,
|
|
326
|
+
`${collection.collection_id}/:document_id`
|
|
327
|
+
].join('/')
|
|
328
|
+
|
|
329
|
+
app.delete(delete_path, async (req, res) => {
|
|
330
|
+
if (!isValidObjectId(req.params.document_id)) {
|
|
331
|
+
res.status(400);
|
|
332
|
+
res.json({ error: `${req.params.document_id} is not a valid document ID.` });
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
let find = { '_id': req.params.document_id } as { [key: string]: any } ;
|
|
337
|
+
for(let layer of access_layers.layers){
|
|
338
|
+
find[`${layer}_id`] = req.params[layer];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let permissive_security_model = await F_Security_Model.model_with_permission(access_layers.security_models, req, res, find, 'delete');
|
|
342
|
+
if (!permissive_security_model) {
|
|
343
|
+
res.status(403);
|
|
344
|
+
res.json({ error: `You do not have permission to fetch documents from ${req.params.document_type}.` });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
let results;
|
|
349
|
+
try {
|
|
350
|
+
//@ts-expect-error
|
|
351
|
+
results = await collection.mongoose_model.findOneAndDelete(find, {lean: true });
|
|
352
|
+
} catch(err){
|
|
353
|
+
res.status(500);
|
|
354
|
+
res.json({ error: `there was a novel error` });
|
|
355
|
+
console.error(err);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (!results) {
|
|
360
|
+
let sendable = await permissive_security_model.handle_empty_query_results(req, res, 'delete');
|
|
361
|
+
res.json(sendable);
|
|
362
|
+
} else {
|
|
363
|
+
res.json({ data: results });
|
|
364
|
+
}
|
|
365
|
+
// await req.schema.fire_api_event('delete', req, results);
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as z from "zod/v4";
|
|
2
|
+
import { Request, Response } from "express";
|
|
3
|
+
import { Empty_Query_Possibilities, F_Security_Model, Operation } from "./F_Security_Model.js";
|
|
4
|
+
import { F_Collection } from "../F_Collection.js";
|
|
5
|
+
|
|
6
|
+
export class F_SM_Open_Access<Collection_ID extends string, ZodSchema extends z.ZodObject> extends F_Security_Model<Collection_ID, ZodSchema> {
|
|
7
|
+
|
|
8
|
+
constructor(collection: F_Collection<Collection_ID, ZodSchema>){
|
|
9
|
+
super(collection);
|
|
10
|
+
this.needs_auth_user = false;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async has_permission(req: Request, res: Response, find: {[key: string]: any}, operation: Operation): Promise<boolean> {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async handle_empty_query_results(req: Request, res: Response, operation: Operation): Promise<Empty_Query_Possibilities> {
|
|
18
|
+
return { data: null }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as z from "zod/v4";
|
|
2
|
+
import { Request, Response } from "express";
|
|
3
|
+
import { F_Collection } from "../F_Collection.js";
|
|
4
|
+
import { Authenticated_Request, Empty_Query_Possibilities, F_Security_Model, Operation } from "./F_Security_Model.js";
|
|
5
|
+
|
|
6
|
+
export class F_SM_Ownership<Collection_ID extends string, ZodSchema extends z.ZodObject> extends F_Security_Model<Collection_ID, ZodSchema> {
|
|
7
|
+
user_id_field: string;
|
|
8
|
+
|
|
9
|
+
constructor(collection: F_Collection<Collection_ID, ZodSchema>, user_id_field = 'user_id'){
|
|
10
|
+
super(collection);
|
|
11
|
+
this.needs_auth_user = true;
|
|
12
|
+
this.user_id_field = user_id_field;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async has_permission(req: Authenticated_Request, res: Response, find: {[key: string]: any}, operation: Operation): Promise<boolean> {
|
|
16
|
+
let user_id = '' + req.auth.user_id;
|
|
17
|
+
|
|
18
|
+
if (operation === 'get') {
|
|
19
|
+
// if we're fetching a specific document by its ID, it's valid to get
|
|
20
|
+
// it as long as we modify the find so that it only returns documents
|
|
21
|
+
// owned by the current user
|
|
22
|
+
if (req.params.document_id) {
|
|
23
|
+
find[this.user_id_field] = user_id;
|
|
24
|
+
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// if we're fetching a document and filtering by the user's ID already,
|
|
29
|
+
// then this security model is satisfied
|
|
30
|
+
if (find[this.user_id_field] === user_id) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// if we're updating a specific document, it's valid as long as we modify the
|
|
36
|
+
// find so that it only modifies documents owned by the current user
|
|
37
|
+
if (operation === 'update') {
|
|
38
|
+
find[this.user_id_field] = user_id;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// if we're creating a document, it's valid as long as it's owned by the current
|
|
43
|
+
// user.
|
|
44
|
+
if (operation === 'create') {
|
|
45
|
+
if (req.body[this.user_id_field] === user_id) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// if we're deleting a specific document, it's valid as long as we modify the
|
|
51
|
+
// find so that it only deletes documents owned by the current user
|
|
52
|
+
if (operation === 'delete') {
|
|
53
|
+
find[this.user_id_field] = user_id;
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async handle_empty_query_results(req: Request, res: Response, operation: Operation): Promise<Empty_Query_Possibilities> {
|
|
61
|
+
if (req.params.document_id) {
|
|
62
|
+
let document_result = await this.collection.mongoose_model.findById(req.params.document_id);
|
|
63
|
+
|
|
64
|
+
if (document_result) {
|
|
65
|
+
res.status(403);
|
|
66
|
+
return { error: `You do not have permission to ${operation} documents from ${req.params.document_type}.` };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { data: null };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import * as z from "zod/v4";
|
|
2
|
+
import { Request, Response } from "express";
|
|
3
|
+
import { F_Collection } from "../F_Collection.js";
|
|
4
|
+
import { Cache } from "../utils/cache.js";
|
|
5
|
+
import { Authenticated_Request, Empty_Query_Possibilities, F_Security_Model, Operation } from "./F_Security_Model.js";
|
|
6
|
+
import mongoose from "mongoose";
|
|
7
|
+
|
|
8
|
+
let operation_permission_map = {
|
|
9
|
+
'get': 'read',
|
|
10
|
+
'create': 'create',
|
|
11
|
+
'update': 'update',
|
|
12
|
+
'delete': 'delete'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class F_SM_Role_Membership<Collection_ID extends string, ZodSchema extends z.ZodObject> extends F_Security_Model<Collection_ID, ZodSchema> {
|
|
16
|
+
user_id_field: string;
|
|
17
|
+
role_id_field: string;
|
|
18
|
+
layer_collection_id: string;
|
|
19
|
+
role_membership_collection: F_Collection<string, any>;
|
|
20
|
+
role_membership_cache: Cache<any>;
|
|
21
|
+
role_collection: F_Collection<string, any>;
|
|
22
|
+
role_cache: Cache<any>;
|
|
23
|
+
|
|
24
|
+
constructor(collection: F_Collection<Collection_ID, ZodSchema>,
|
|
25
|
+
layer_collection: F_Collection<string, any>,
|
|
26
|
+
role_membership_collection: F_Collection<string, any>,
|
|
27
|
+
role_collection: F_Collection<string, any>,
|
|
28
|
+
role_membership_cache?: Cache<any>,
|
|
29
|
+
role_cache?: Cache<any>,
|
|
30
|
+
user_id_field = 'user_id',
|
|
31
|
+
role_id_field = 'role_id',
|
|
32
|
+
){
|
|
33
|
+
super(collection);
|
|
34
|
+
this.needs_auth_user = true;
|
|
35
|
+
this.user_id_field = user_id_field;
|
|
36
|
+
this.role_id_field = role_id_field;
|
|
37
|
+
this.layer_collection_id = layer_collection.collection_id;
|
|
38
|
+
this.role_membership_collection = role_membership_collection;
|
|
39
|
+
this.role_membership_cache = role_membership_cache ?? new Cache(60);
|
|
40
|
+
this.role_collection = role_collection;
|
|
41
|
+
this.role_cache = role_cache ?? new Cache(60);
|
|
42
|
+
|
|
43
|
+
if(!this.role_collection.mongoose_schema.permissions){
|
|
44
|
+
throw new Error(`could not find field "permissions" on role collection. Permissions should be an object of the format {[key: collection_id]: ('read'| 'create'| 'update'| 'delete')[]}`)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async has_permission(req: Authenticated_Request, res: Response, find: {[key: string]: any}, operation: Operation): Promise<boolean> {
|
|
49
|
+
let user_id = req.auth.user_id;
|
|
50
|
+
let layer_id = req.params[this.layer_collection_id];
|
|
51
|
+
|
|
52
|
+
// return the role membership associated with the layer. This uses the cache heavily, so it should be
|
|
53
|
+
// a cheap operation even though it makes an extra database query. Use the cache's first_fetch_then_refresh
|
|
54
|
+
// method so that we aren't keeping out-of-date auth data in the cache.
|
|
55
|
+
let role_membership = await this.role_membership_cache.first_fetch_then_refresh(`${user_id}-${layer_id}`, async () => {
|
|
56
|
+
let role_memberships = await this.role_membership_collection.mongoose_model.find({
|
|
57
|
+
[this.user_id_field]: user_id,
|
|
58
|
+
[`${this.layer_collection_id}_id`]: new mongoose.Types.ObjectId(layer_id)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
if(role_memberships.length > 1){
|
|
62
|
+
console.warn(`in F_SM_Role_Membership, more than one role membership for user ${user_id} at layer ${this.layer_collection_id} found.`)
|
|
63
|
+
}
|
|
64
|
+
return role_memberships[0];
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
if(!role_membership){ return false; }
|
|
68
|
+
if(!role_membership[this.role_id_field]){ console.warn(`role membership collection ${this.role_membership_collection.collection_id} did not have role ID filed ${this.role_id_field}`); return false;}
|
|
69
|
+
|
|
70
|
+
// return the role associated with the role membership. This uses the cache heavily, so it should be
|
|
71
|
+
// a cheap operation even though it makes an extra database query. Use the cache's first_fetch_then_refresh
|
|
72
|
+
// method so that we aren't keeping out-of-date auth data in the cache.
|
|
73
|
+
let role = await this.role_cache.first_fetch_then_refresh(role_membership[this.role_id_field], async () => {
|
|
74
|
+
let role = await this.role_collection.mongoose_model.findById(role_membership[this.role_id_field]);
|
|
75
|
+
return role;
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
if(!role){ return false; }
|
|
79
|
+
if(!role.permissions){ console.warn(`role collection ${this.role_collection.collection_id} was missing its permissions field`); return false; }
|
|
80
|
+
if(!role.permissions[this.collection.collection_id]){ console.warn(`role collection ${this.role_collection.collection_id} was missing its permissions.${this.collection.collection_id} field`); return false; }
|
|
81
|
+
return role.permissions[this.collection.collection_id].includes(operation_permission_map[operation]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async handle_empty_query_results(req: Request, res: Response, operation: Operation): Promise<Empty_Query_Possibilities> {
|
|
85
|
+
return { data: null };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
|
|
2
|
+
import * as z from "zod/v4";
|
|
3
|
+
import { Router, Request, Response } from "express";
|
|
4
|
+
import { F_Collection } from "../F_Collection.js";
|
|
5
|
+
|
|
6
|
+
export type Operation = 'get' | 'update' | 'create' | 'delete';
|
|
7
|
+
|
|
8
|
+
export abstract class F_Security_Model<Collection_ID extends string, ZodSchema extends z.ZodObject> {
|
|
9
|
+
collection: F_Collection<Collection_ID, ZodSchema>;
|
|
10
|
+
needs_auth_user: boolean
|
|
11
|
+
static auth_fetcher: (req: Request) => Promise<Auth_Data | undefined>
|
|
12
|
+
|
|
13
|
+
constructor(collection: F_Collection<Collection_ID, ZodSchema>) {
|
|
14
|
+
this.collection = collection;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Returns true if this security model can grant permission to perform the operation.
|
|
19
|
+
* This may be accomplished by modifying the mongodb filter that will be applied during
|
|
20
|
+
* the fetch/update: for example, a security model that allows modifying only members of
|
|
21
|
+
* a certain institution might add { institution_id: xxxxx } to the find.
|
|
22
|
+
* @param req
|
|
23
|
+
* @param res
|
|
24
|
+
* @param find
|
|
25
|
+
* @param operation
|
|
26
|
+
*/
|
|
27
|
+
abstract has_permission(req: Request, res: Response, find: {[key: string]: any}, operation: Operation): Promise<boolean>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* In the event that no documents are returned by the mongodb operation, it's necessary to find
|
|
31
|
+
* out if this was caused by modifications to the mongodb filter, or if it was caused by a lack
|
|
32
|
+
* of documents to operate on.
|
|
33
|
+
*
|
|
34
|
+
* In the event that there are documents but the user lacks permission to act on them, this method
|
|
35
|
+
* is expected to set the response status to 403.
|
|
36
|
+
*
|
|
37
|
+
* @param req
|
|
38
|
+
* @param res
|
|
39
|
+
* @param operation
|
|
40
|
+
*/
|
|
41
|
+
abstract handle_empty_query_results(req: Request, res: Response, operation: Operation): Promise<Empty_Query_Possibilities>;
|
|
42
|
+
|
|
43
|
+
static set_auth_fetcher(fetcher: (req: Request) => Promise<Auth_Data | undefined>){
|
|
44
|
+
F_Security_Model.auth_fetcher = fetcher;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
static async has_permission(models: F_Security_Model<string, any>[], req: Request, res: Response, find: {[key: string]: any}, operation: Operation){ console.error(`F_Security_Model.has_permission is deprecated in favor of F_Security_Model.model_with_permission.`); return await F_Security_Model.model_with_permission(models, req, res, find, operation); }
|
|
48
|
+
static async model_with_permission(models: F_Security_Model<string, any>[], req: Request, res: Response, find: {[key: string]: any}, operation: Operation): Promise<F_Security_Model<string, any>> {
|
|
49
|
+
let has_attempted_authenticating_user = false;
|
|
50
|
+
|
|
51
|
+
for (let security_model of models) {
|
|
52
|
+
// if the security model needs user auth data, fetch it.
|
|
53
|
+
if (security_model.needs_auth_user && !has_attempted_authenticating_user) {
|
|
54
|
+
has_attempted_authenticating_user = true;
|
|
55
|
+
(req as Authenticated_Request).auth = await F_Security_Model.auth_fetcher(req);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// if the user auth data fetch failed, and the security model needs that data, don't bother using the security model.
|
|
59
|
+
if (security_model.needs_auth_user && !(req as Authenticated_Request).auth) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// if the security model is a good candidate, return it.
|
|
64
|
+
if (await security_model.has_permission(req, res, find, operation)) {
|
|
65
|
+
return security_model;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type Auth_Data = {
|
|
73
|
+
user_id: string,
|
|
74
|
+
layers: {
|
|
75
|
+
layer_id: string,
|
|
76
|
+
permissions: {[key: string]: Operation[]},
|
|
77
|
+
special_permissions: {[key: string]: string[]}
|
|
78
|
+
}[]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type Authenticated_Request = Request & {
|
|
82
|
+
auth: Auth_Data
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type Empty_Query_Possibilities = { data: null} | { error: string };
|