@airoom/nextmin-node 0.1.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/LICENSE +49 -0
- package/README.md +178 -0
- package/dist/api/apiRouter.d.ts +65 -0
- package/dist/api/apiRouter.js +1548 -0
- package/dist/database/DatabaseAdapter.d.ts +14 -0
- package/dist/database/DatabaseAdapter.js +2 -0
- package/dist/database/InMemoryAdapter.d.ts +15 -0
- package/dist/database/InMemoryAdapter.js +71 -0
- package/dist/database/MongoAdapter.d.ts +52 -0
- package/dist/database/MongoAdapter.js +409 -0
- package/dist/files/FileStorageAdapter.d.ts +35 -0
- package/dist/files/FileStorageAdapter.js +2 -0
- package/dist/files/S3FileStorageAdapter.d.ts +30 -0
- package/dist/files/S3FileStorageAdapter.js +84 -0
- package/dist/files/filename.d.ts +5 -0
- package/dist/files/filename.js +40 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +87 -0
- package/dist/models/BaseModel.d.ts +44 -0
- package/dist/models/BaseModel.js +31 -0
- package/dist/policy/authorize.d.ts +25 -0
- package/dist/policy/authorize.js +305 -0
- package/dist/policy/conditions.d.ts +14 -0
- package/dist/policy/conditions.js +30 -0
- package/dist/policy/types.d.ts +53 -0
- package/dist/policy/types.js +2 -0
- package/dist/policy/utils.d.ts +9 -0
- package/dist/policy/utils.js +118 -0
- package/dist/schemas/Roles.json +64 -0
- package/dist/schemas/Settings.json +62 -0
- package/dist/schemas/Users.json +123 -0
- package/dist/services/SchemaService.d.ts +10 -0
- package/dist/services/SchemaService.js +46 -0
- package/dist/utils/DefaultDataInitializer.d.ts +21 -0
- package/dist/utils/DefaultDataInitializer.js +269 -0
- package/dist/utils/Logger.d.ts +12 -0
- package/dist/utils/Logger.js +79 -0
- package/dist/utils/SchemaLoader.d.ts +51 -0
- package/dist/utils/SchemaLoader.js +323 -0
- package/dist/utils/apiKey.d.ts +5 -0
- package/dist/utils/apiKey.js +14 -0
- package/dist/utils/fieldCodecs.d.ts +13 -0
- package/dist/utils/fieldCodecs.js +133 -0
- package/package.json +45 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.S3FileStorageAdapter = void 0;
|
|
4
|
+
const client_s3_1 = require("@aws-sdk/client-s3");
|
|
5
|
+
// Encode per path segment, keep slashes
|
|
6
|
+
function encodeKeySegments(key) {
|
|
7
|
+
return key.split('/').map(encodeURIComponent).join('/');
|
|
8
|
+
}
|
|
9
|
+
class S3FileStorageAdapter {
|
|
10
|
+
constructor(opts) {
|
|
11
|
+
this.name = 's3';
|
|
12
|
+
this.bucket = opts.bucket;
|
|
13
|
+
this.regionStr = opts.region; // <- store plain region
|
|
14
|
+
this.endpoint = opts.endpoint?.replace(/\/+$/, '');
|
|
15
|
+
this.forcePathStyle = opts.forcePathStyle ?? true; // sane default for MinIO
|
|
16
|
+
this.defaultACL = opts.defaultACL;
|
|
17
|
+
this.publicBaseUrl = opts.publicBaseUrl;
|
|
18
|
+
this.s3 = new client_s3_1.S3Client({
|
|
19
|
+
region: this.regionStr,
|
|
20
|
+
endpoint: this.endpoint,
|
|
21
|
+
forcePathStyle: this.forcePathStyle,
|
|
22
|
+
credentials: opts.credentials,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
async upload(input) {
|
|
26
|
+
if (!input.body)
|
|
27
|
+
throw new Error('S3 upload: "body" is required');
|
|
28
|
+
const key = input.key ?? this.randomKey();
|
|
29
|
+
// Build params without ACL by default
|
|
30
|
+
const params = {
|
|
31
|
+
Bucket: this.bucket,
|
|
32
|
+
Key: key,
|
|
33
|
+
Body: input.body,
|
|
34
|
+
ContentType: input.contentType,
|
|
35
|
+
Metadata: input.metadata,
|
|
36
|
+
CacheControl: input.contentType?.startsWith('image/')
|
|
37
|
+
? 'public, max-age=31536000, immutable'
|
|
38
|
+
: 'public, max-age=86400',
|
|
39
|
+
};
|
|
40
|
+
// Only attach ACL for S3-compatible endpoints that require it (e.g., MinIO)
|
|
41
|
+
// i.e., when you explicitly configured an endpoint AND set defaultACL
|
|
42
|
+
if (this.endpoint && this.defaultACL) {
|
|
43
|
+
params.ACL = this.defaultACL;
|
|
44
|
+
}
|
|
45
|
+
await this.s3.send(new client_s3_1.PutObjectCommand(params));
|
|
46
|
+
const url = this.getPublicUrl(key);
|
|
47
|
+
return {
|
|
48
|
+
provider: 's3',
|
|
49
|
+
bucket: this.bucket,
|
|
50
|
+
key,
|
|
51
|
+
url,
|
|
52
|
+
contentType: input.contentType,
|
|
53
|
+
size: Buffer.isBuffer(input.body) ? input.body.length : undefined,
|
|
54
|
+
metadata: input.metadata,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
async delete(key) {
|
|
58
|
+
await this.s3.send(new client_s3_1.DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
59
|
+
return { deleted: true };
|
|
60
|
+
}
|
|
61
|
+
getPublicUrl(key) {
|
|
62
|
+
const encKey = encodeKeySegments(key);
|
|
63
|
+
if (this.publicBaseUrl) {
|
|
64
|
+
return `${this.publicBaseUrl.replace(/\/+$/, '')}/${encKey}`;
|
|
65
|
+
}
|
|
66
|
+
if (this.endpoint) {
|
|
67
|
+
// MinIO / custom endpoint → path-style
|
|
68
|
+
return `${this.endpoint}/${this.bucket}/${encKey}`;
|
|
69
|
+
}
|
|
70
|
+
// AWS S3 standard
|
|
71
|
+
return `https://${this.bucket}.s3.${this.regionStr}.amazonaws.com/${encKey}`;
|
|
72
|
+
}
|
|
73
|
+
// ---- private helpers ----
|
|
74
|
+
randomKey() {
|
|
75
|
+
const ts = new Date();
|
|
76
|
+
const y = ts.getUTCFullYear();
|
|
77
|
+
const m = String(ts.getUTCMonth() + 1).padStart(2, '0');
|
|
78
|
+
const d = String(ts.getUTCDate()).padStart(2, '0');
|
|
79
|
+
const rand = Math.random().toString(36).slice(2, 10);
|
|
80
|
+
// You wanted keys like uploads/YYYY/MM/DD/uid.ext (ext is added by router)
|
|
81
|
+
return `uploads/${y}/${m}/${d}/${rand}`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
exports.S3FileStorageAdapter = S3FileStorageAdapter;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function sanitizeFilename(name: string): string;
|
|
2
|
+
export declare function withTimestampPrefix(name: string): string;
|
|
3
|
+
export declare function joinKey(...parts: string[]): string;
|
|
4
|
+
export declare function ensureExt(name: string, fallbackExt?: string): string;
|
|
5
|
+
export declare function extFromMime(mime: string): string | undefined;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.sanitizeFilename = sanitizeFilename;
|
|
4
|
+
exports.withTimestampPrefix = withTimestampPrefix;
|
|
5
|
+
exports.joinKey = joinKey;
|
|
6
|
+
exports.ensureExt = ensureExt;
|
|
7
|
+
exports.extFromMime = extFromMime;
|
|
8
|
+
function sanitizeFilename(name) {
|
|
9
|
+
const base = name.replace(/[/\\?%*:|"<>]/g, '-').replace(/\s+/g, '-');
|
|
10
|
+
return base.replace(/-+/g, '-').toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
function withTimestampPrefix(name) {
|
|
13
|
+
const ts = Date.now();
|
|
14
|
+
return `${ts}-${name}`;
|
|
15
|
+
}
|
|
16
|
+
function joinKey(...parts) {
|
|
17
|
+
return parts
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
.map((p) => p.replace(/^\/+|\/+$/g, ''))
|
|
20
|
+
.join('/');
|
|
21
|
+
}
|
|
22
|
+
function ensureExt(name, fallbackExt) {
|
|
23
|
+
const hasExt = /\.[A-Za-z0-9]{1,8}$/.test(name);
|
|
24
|
+
if (hasExt || !fallbackExt)
|
|
25
|
+
return name;
|
|
26
|
+
return `${name}.${fallbackExt.replace(/^\./, '')}`;
|
|
27
|
+
}
|
|
28
|
+
function extFromMime(mime) {
|
|
29
|
+
const map = {
|
|
30
|
+
'image/jpeg': 'jpg',
|
|
31
|
+
'image/png': 'png',
|
|
32
|
+
'image/webp': 'webp',
|
|
33
|
+
'image/gif': 'gif',
|
|
34
|
+
'application/pdf': 'pdf',
|
|
35
|
+
'text/plain': 'txt',
|
|
36
|
+
'text/csv': 'csv',
|
|
37
|
+
'video/mp4': 'mp4',
|
|
38
|
+
};
|
|
39
|
+
return map[mime];
|
|
40
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { APIRouterOptions } from './api/apiRouter';
|
|
2
|
+
export { MongoAdapter } from './database/MongoAdapter';
|
|
3
|
+
export { generateApiKey } from './utils/apiKey';
|
|
4
|
+
export * from './files/FileStorageAdapter';
|
|
5
|
+
export * from './files/S3FileStorageAdapter';
|
|
6
|
+
export * from './files/filename';
|
|
7
|
+
/**
|
|
8
|
+
* Compose the public router:
|
|
9
|
+
* - JSON & urlencoded body parsers
|
|
10
|
+
* - a guarded "body required" check for JSON/urlencoded ONLY
|
|
11
|
+
* - mounts APIRouter (which includes /files using multer)
|
|
12
|
+
*/
|
|
13
|
+
export declare function createNextMinRouter(options: APIRouterOptions): import("express-serve-static-core").Router;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
17
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
18
|
+
};
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.generateApiKey = exports.MongoAdapter = void 0;
|
|
21
|
+
exports.createNextMinRouter = createNextMinRouter;
|
|
22
|
+
const express_1 = __importDefault(require("express"));
|
|
23
|
+
const body_parser_1 = __importDefault(require("body-parser"));
|
|
24
|
+
const apiRouter_1 = require("./api/apiRouter");
|
|
25
|
+
var MongoAdapter_1 = require("./database/MongoAdapter");
|
|
26
|
+
Object.defineProperty(exports, "MongoAdapter", { enumerable: true, get: function () { return MongoAdapter_1.MongoAdapter; } });
|
|
27
|
+
var apiKey_1 = require("./utils/apiKey");
|
|
28
|
+
Object.defineProperty(exports, "generateApiKey", { enumerable: true, get: function () { return apiKey_1.generateApiKey; } });
|
|
29
|
+
__exportStar(require("./files/FileStorageAdapter"), exports);
|
|
30
|
+
__exportStar(require("./files/S3FileStorageAdapter"), exports);
|
|
31
|
+
__exportStar(require("./files/filename"), exports);
|
|
32
|
+
function isMultipart(req) {
|
|
33
|
+
const ct = (req.headers['content-type'] || '').toLowerCase();
|
|
34
|
+
return ct.startsWith('multipart/form-data');
|
|
35
|
+
}
|
|
36
|
+
function isJson(req) {
|
|
37
|
+
// req.is is safer if body-parser/express has set it up
|
|
38
|
+
return (Boolean(req.is?.('application/json')) ||
|
|
39
|
+
(req.headers['content-type'] || '')
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.startsWith('application/json'));
|
|
42
|
+
}
|
|
43
|
+
function isUrlEncoded(req) {
|
|
44
|
+
return (Boolean(req.is?.('application/x-www-form-urlencoded')) ||
|
|
45
|
+
(req.headers['content-type'] || '')
|
|
46
|
+
.toLowerCase()
|
|
47
|
+
.startsWith('application/x-www-form-urlencoded'));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Compose the public router:
|
|
51
|
+
* - JSON & urlencoded body parsers
|
|
52
|
+
* - a guarded "body required" check for JSON/urlencoded ONLY
|
|
53
|
+
* - mounts APIRouter (which includes /files using multer)
|
|
54
|
+
*/
|
|
55
|
+
function createNextMinRouter(options) {
|
|
56
|
+
const apiRouter = new apiRouter_1.APIRouter(options).getRouter();
|
|
57
|
+
const router = express_1.default.Router();
|
|
58
|
+
// ✅ Parse JSON & urlencoded (does NOT parse multipart/form-data)
|
|
59
|
+
router.use(body_parser_1.default.json({ limit: '2mb' }));
|
|
60
|
+
router.use(body_parser_1.default.urlencoded({ extended: true }));
|
|
61
|
+
// ✅ Require body ONLY for JSON / urlencoded requests
|
|
62
|
+
// Skip multipart (handled by multer inside APIRouter)
|
|
63
|
+
router.use((req, res, next) => {
|
|
64
|
+
// Allow non-mutating methods and preflight
|
|
65
|
+
if (!['POST', 'PUT', 'PATCH'].includes(req.method))
|
|
66
|
+
return next();
|
|
67
|
+
// Never block /files (multer needs to see raw multipart)
|
|
68
|
+
if (req.path.startsWith('/files') || isMultipart(req))
|
|
69
|
+
return next();
|
|
70
|
+
// Enforce body presence only for JSON or urlencoded
|
|
71
|
+
const mustHaveBody = isJson(req) || isUrlEncoded(req);
|
|
72
|
+
if (mustHaveBody) {
|
|
73
|
+
const empty = !req.body ||
|
|
74
|
+
(typeof req.body === 'object' && Object.keys(req.body).length === 0);
|
|
75
|
+
if (empty) {
|
|
76
|
+
return res.status(400).json({
|
|
77
|
+
error: true,
|
|
78
|
+
message: 'Request body is required for JSON/urlencoded requests.',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
next();
|
|
83
|
+
});
|
|
84
|
+
// ✅ Mount the actual API (includes /files with multer.any())
|
|
85
|
+
router.use(apiRouter);
|
|
86
|
+
return router;
|
|
87
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { DatabaseAdapter } from '../database/DatabaseAdapter';
|
|
2
|
+
export type SortDir = 1 | -1;
|
|
3
|
+
export type SortSpec = Record<string, SortDir>;
|
|
4
|
+
export type ReadOptions = {
|
|
5
|
+
sort?: SortSpec;
|
|
6
|
+
projection?: Record<string, 0 | 1>;
|
|
7
|
+
};
|
|
8
|
+
export interface Attributes {
|
|
9
|
+
type: string;
|
|
10
|
+
required?: boolean;
|
|
11
|
+
default?: any;
|
|
12
|
+
enum?: any[];
|
|
13
|
+
ref?: string;
|
|
14
|
+
select?: string[];
|
|
15
|
+
private?: boolean;
|
|
16
|
+
unique?: boolean;
|
|
17
|
+
sensitive?: boolean;
|
|
18
|
+
writeOnly?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface Schema {
|
|
21
|
+
modelName: string;
|
|
22
|
+
attributes: {
|
|
23
|
+
[key: string]: Attributes;
|
|
24
|
+
};
|
|
25
|
+
private?: boolean;
|
|
26
|
+
allowedMethods: {
|
|
27
|
+
create?: boolean;
|
|
28
|
+
read?: boolean;
|
|
29
|
+
update?: boolean;
|
|
30
|
+
delete?: boolean;
|
|
31
|
+
};
|
|
32
|
+
extends?: string;
|
|
33
|
+
}
|
|
34
|
+
export declare class BaseModel {
|
|
35
|
+
private schema;
|
|
36
|
+
private adapter;
|
|
37
|
+
private collectionName;
|
|
38
|
+
constructor(schema: Schema, adapter: DatabaseAdapter);
|
|
39
|
+
create(data: any, includePrivateFields?: boolean): Promise<any>;
|
|
40
|
+
read(query: any, limit?: number, skip?: number, includePrivateFields?: boolean, options?: ReadOptions): Promise<any[]>;
|
|
41
|
+
update(id: string, data: any, includePrivateFields?: boolean): Promise<any>;
|
|
42
|
+
delete(id: string, includePrivateFields?: boolean): Promise<any>;
|
|
43
|
+
count(query: any, includePrivateFields?: boolean): Promise<number>;
|
|
44
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BaseModel = void 0;
|
|
4
|
+
class BaseModel {
|
|
5
|
+
constructor(schema, adapter) {
|
|
6
|
+
this.schema = schema;
|
|
7
|
+
this.adapter = adapter;
|
|
8
|
+
this.collectionName = schema.modelName.toLowerCase();
|
|
9
|
+
}
|
|
10
|
+
async create(data, includePrivateFields) {
|
|
11
|
+
for (const [key, attr] of Object.entries(this.schema.attributes)) {
|
|
12
|
+
if (data[key] === undefined && attr.default !== undefined) {
|
|
13
|
+
data[key] = attr.default;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return await this.adapter.create(this.collectionName, data, this.schema, includePrivateFields);
|
|
17
|
+
}
|
|
18
|
+
async read(query, limit, skip, includePrivateFields, options) {
|
|
19
|
+
return await this.adapter.read(this.collectionName, query, limit, skip, this.schema, includePrivateFields, options);
|
|
20
|
+
}
|
|
21
|
+
async update(id, data, includePrivateFields) {
|
|
22
|
+
return await this.adapter.update(this.collectionName, id, data, this.schema, includePrivateFields);
|
|
23
|
+
}
|
|
24
|
+
async delete(id, includePrivateFields) {
|
|
25
|
+
return await this.adapter.delete(this.collectionName, id, this.schema, includePrivateFields);
|
|
26
|
+
}
|
|
27
|
+
async count(query, includePrivateFields) {
|
|
28
|
+
return await this.adapter.count(this.collectionName, query, this.schema, includePrivateFields);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
exports.BaseModel = BaseModel;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type Action = 'create' | 'read' | 'update' | 'delete';
|
|
2
|
+
export interface PolicyContext {
|
|
3
|
+
isAuthenticated: boolean;
|
|
4
|
+
role: string | null;
|
|
5
|
+
userId: string | null;
|
|
6
|
+
isSuperadmin: boolean;
|
|
7
|
+
apiKeyOk: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface Decision {
|
|
10
|
+
allow: boolean;
|
|
11
|
+
readMask?: string[];
|
|
12
|
+
writeDeny?: string[];
|
|
13
|
+
createDefaults?: Record<string, any>;
|
|
14
|
+
restrictions?: Record<string, any>;
|
|
15
|
+
queryFilter?: Record<string, any>;
|
|
16
|
+
exposePrivate?: boolean;
|
|
17
|
+
sensitiveMask?: string[];
|
|
18
|
+
}
|
|
19
|
+
export declare function authorize(modelNameLC: string, action: Action, schemaPolicy: any, ctx: PolicyContext, doc?: any): Decision;
|
|
20
|
+
export declare function applyReadMaskOne(doc: any, mask?: string[]): any;
|
|
21
|
+
export declare function applyReadMaskMany(docs: any[], mask?: string[]): any[];
|
|
22
|
+
export declare function stripWriteDeny(payload: any, deny?: string[]): any;
|
|
23
|
+
export declare function mergeCreateDefaults(payload: any, defs?: Record<string, any>): any;
|
|
24
|
+
export declare function enforceRestrictions(payload: any, restrictions?: Record<string, any>, _ctx?: PolicyContext): void;
|
|
25
|
+
export declare function andFilter(a?: Record<string, any>, b?: Record<string, any>): Record<string, any>;
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// src/policy/authorize.ts
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.authorize = authorize;
|
|
5
|
+
exports.applyReadMaskOne = applyReadMaskOne;
|
|
6
|
+
exports.applyReadMaskMany = applyReadMaskMany;
|
|
7
|
+
exports.stripWriteDeny = stripWriteDeny;
|
|
8
|
+
exports.mergeCreateDefaults = mergeCreateDefaults;
|
|
9
|
+
exports.enforceRestrictions = enforceRestrictions;
|
|
10
|
+
exports.andFilter = andFilter;
|
|
11
|
+
const EMPTY_DECISION = {
|
|
12
|
+
allow: false,
|
|
13
|
+
readMask: [],
|
|
14
|
+
writeDeny: [],
|
|
15
|
+
createDefaults: {},
|
|
16
|
+
restrictions: {},
|
|
17
|
+
queryFilter: {},
|
|
18
|
+
};
|
|
19
|
+
/* ----------------- privacy helpers ----------------- */
|
|
20
|
+
function shouldBypassPrivacy(schemaPolicy, role) {
|
|
21
|
+
if (!role)
|
|
22
|
+
return false;
|
|
23
|
+
const roles = schemaPolicy?.access?.bypassPrivacy?.roles;
|
|
24
|
+
if (!Array.isArray(roles))
|
|
25
|
+
return false;
|
|
26
|
+
return roles
|
|
27
|
+
.map((r) => String(r).toLowerCase())
|
|
28
|
+
.includes(String(role).toLowerCase());
|
|
29
|
+
}
|
|
30
|
+
function computeSensitiveMask(schemaPolicy) {
|
|
31
|
+
const negs = [];
|
|
32
|
+
for (const [name, attr] of Object.entries(schemaPolicy?.attributes || {})) {
|
|
33
|
+
if (!Array.isArray(attr) &&
|
|
34
|
+
(attr.sensitive === true || attr.writeOnly === true)) {
|
|
35
|
+
negs.push(`-${name}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return negs;
|
|
39
|
+
}
|
|
40
|
+
/* ----------------- general helpers ----------------- */
|
|
41
|
+
function asBool(v) {
|
|
42
|
+
return typeof v === 'boolean' ? v : undefined;
|
|
43
|
+
}
|
|
44
|
+
function ownerKey(schemaPolicy) {
|
|
45
|
+
const by = schemaPolicy?.conditions?.owner?.by;
|
|
46
|
+
return typeof by === 'string' && by.trim() ? by.trim() : 'id';
|
|
47
|
+
}
|
|
48
|
+
function ownerFilter(schemaPolicy, ctx) {
|
|
49
|
+
const key = ownerKey(schemaPolicy);
|
|
50
|
+
if (!ctx.userId)
|
|
51
|
+
return {};
|
|
52
|
+
return { [key]: ctx.userId };
|
|
53
|
+
}
|
|
54
|
+
function isOwnerDoc(schemaPolicy, ctx, doc) {
|
|
55
|
+
if (!doc || !ctx.userId)
|
|
56
|
+
return false;
|
|
57
|
+
const key = ownerKey(schemaPolicy);
|
|
58
|
+
const val = doc[key];
|
|
59
|
+
if (val == null)
|
|
60
|
+
return false;
|
|
61
|
+
const candidate = typeof val === 'object' ? (val.id ?? val._id ?? String(val)) : String(val);
|
|
62
|
+
return String(candidate) === String(ctx.userId);
|
|
63
|
+
}
|
|
64
|
+
/* =================================================== */
|
|
65
|
+
/* =============== MAIN AUTHORIZATION ================= */
|
|
66
|
+
/* =================================================== */
|
|
67
|
+
function authorize(modelNameLC, action, schemaPolicy, ctx, doc) {
|
|
68
|
+
// 0) action enabled at all?
|
|
69
|
+
const allowed = !!schemaPolicy?.allowedMethods?.[action];
|
|
70
|
+
if (!allowed)
|
|
71
|
+
return { ...EMPTY_DECISION, allow: false };
|
|
72
|
+
const access = schemaPolicy?.access || {};
|
|
73
|
+
const baseReadMask = [].concat(access?.readMask || []);
|
|
74
|
+
const baseWriteDeny = [].concat(access?.writeDeny || []);
|
|
75
|
+
const restrictionsBase = access?.restrictions || {};
|
|
76
|
+
const createDefaultsBase = access?.createDefaults || {};
|
|
77
|
+
const queryFilterBase = access?.queryFilter || {};
|
|
78
|
+
const bypass = shouldBypassPrivacy(schemaPolicy, ctx.role);
|
|
79
|
+
const sensitiveMask = computeSensitiveMask(schemaPolicy);
|
|
80
|
+
const mkMasks = (readMaskIn, writeDenyIn) => {
|
|
81
|
+
const fullMask = [...(readMaskIn || []), ...sensitiveMask];
|
|
82
|
+
const effectiveReadMask = bypass ? sensitiveMask : fullMask;
|
|
83
|
+
const effectiveWriteDeny = bypass ? [] : writeDenyIn || [];
|
|
84
|
+
return { effectiveReadMask, effectiveWriteDeny };
|
|
85
|
+
};
|
|
86
|
+
// 1) no access block → sensible defaults
|
|
87
|
+
if (!schemaPolicy?.access) {
|
|
88
|
+
const allowedByDefault = action === 'read' ? true : !!ctx.isAuthenticated;
|
|
89
|
+
const { effectiveReadMask, effectiveWriteDeny } = mkMasks(baseReadMask, baseWriteDeny);
|
|
90
|
+
return {
|
|
91
|
+
allow: allowedByDefault,
|
|
92
|
+
readMask: effectiveReadMask,
|
|
93
|
+
writeDeny: effectiveWriteDeny,
|
|
94
|
+
createDefaults: {},
|
|
95
|
+
restrictions: {},
|
|
96
|
+
queryFilter: action === 'read' ? {} : {},
|
|
97
|
+
exposePrivate: bypass, // will normally be false if no bypassPrivacy set
|
|
98
|
+
sensitiveMask,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// 2) PUBLIC
|
|
102
|
+
const pubRule = access?.public?.[action];
|
|
103
|
+
const pubBool = asBool(pubRule);
|
|
104
|
+
if (pubBool === true) {
|
|
105
|
+
const { effectiveReadMask, effectiveWriteDeny } = mkMasks(baseReadMask, baseWriteDeny);
|
|
106
|
+
return {
|
|
107
|
+
allow: true,
|
|
108
|
+
readMask: effectiveReadMask,
|
|
109
|
+
writeDeny: effectiveWriteDeny,
|
|
110
|
+
createDefaults: createDefaultsBase?.public || {},
|
|
111
|
+
restrictions: restrictionsBase?.public || {},
|
|
112
|
+
queryFilter: queryFilterBase?.public || {},
|
|
113
|
+
exposePrivate: bypass, // if caller has a bypass role, they get private fields (still masked by sensitive)
|
|
114
|
+
sensitiveMask,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
// if false → continue to roles/auth checks
|
|
118
|
+
// 3) ROLES
|
|
119
|
+
const roleName = (ctx.role || '').toLowerCase();
|
|
120
|
+
if (roleName && access?.roles?.[roleName]) {
|
|
121
|
+
const rule = access.roles[roleName][action];
|
|
122
|
+
const rBool = asBool(rule);
|
|
123
|
+
if (rBool === true) {
|
|
124
|
+
const { effectiveReadMask, effectiveWriteDeny } = mkMasks(baseReadMask, baseWriteDeny);
|
|
125
|
+
return {
|
|
126
|
+
allow: true,
|
|
127
|
+
readMask: effectiveReadMask,
|
|
128
|
+
writeDeny: effectiveWriteDeny,
|
|
129
|
+
createDefaults: createDefaultsBase?.roles?.[roleName] || {},
|
|
130
|
+
restrictions: restrictionsBase?.roles?.[roleName] || {},
|
|
131
|
+
queryFilter: queryFilterBase?.roles?.[roleName] || {},
|
|
132
|
+
exposePrivate: bypass,
|
|
133
|
+
sensitiveMask,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (rBool === false) {
|
|
137
|
+
const { effectiveReadMask, effectiveWriteDeny } = mkMasks(baseReadMask, baseWriteDeny);
|
|
138
|
+
return {
|
|
139
|
+
...EMPTY_DECISION,
|
|
140
|
+
readMask: effectiveReadMask,
|
|
141
|
+
writeDeny: effectiveWriteDeny,
|
|
142
|
+
exposePrivate: false,
|
|
143
|
+
sensitiveMask,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// role === 'owner'
|
|
147
|
+
if (rule === 'owner') {
|
|
148
|
+
const { effectiveReadMask, effectiveWriteDeny } = mkMasks(baseReadMask, baseWriteDeny);
|
|
149
|
+
if (action === 'read') {
|
|
150
|
+
if (doc) {
|
|
151
|
+
return {
|
|
152
|
+
allow: isOwnerDoc(schemaPolicy, ctx, doc),
|
|
153
|
+
readMask: effectiveReadMask,
|
|
154
|
+
writeDeny: effectiveWriteDeny,
|
|
155
|
+
createDefaults: {},
|
|
156
|
+
restrictions: {},
|
|
157
|
+
queryFilter: {},
|
|
158
|
+
exposePrivate: bypass,
|
|
159
|
+
sensitiveMask,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
allow: true,
|
|
164
|
+
readMask: effectiveReadMask,
|
|
165
|
+
writeDeny: effectiveWriteDeny,
|
|
166
|
+
createDefaults: {},
|
|
167
|
+
restrictions: {},
|
|
168
|
+
queryFilter: ownerFilter(schemaPolicy, ctx),
|
|
169
|
+
exposePrivate: bypass,
|
|
170
|
+
sensitiveMask,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
allow: isOwnerDoc(schemaPolicy, ctx, doc),
|
|
175
|
+
readMask: effectiveReadMask,
|
|
176
|
+
writeDeny: effectiveWriteDeny,
|
|
177
|
+
createDefaults: {},
|
|
178
|
+
restrictions: {},
|
|
179
|
+
queryFilter: {},
|
|
180
|
+
exposePrivate: bypass,
|
|
181
|
+
sensitiveMask,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// 4) AUTHENTICATED
|
|
186
|
+
const authRule = access?.authenticated?.[action];
|
|
187
|
+
const aBool = asBool(authRule);
|
|
188
|
+
if (aBool === true && ctx.isAuthenticated) {
|
|
189
|
+
const { effectiveReadMask, effectiveWriteDeny } = mkMasks(baseReadMask, baseWriteDeny);
|
|
190
|
+
return {
|
|
191
|
+
allow: true,
|
|
192
|
+
readMask: effectiveReadMask,
|
|
193
|
+
writeDeny: effectiveWriteDeny,
|
|
194
|
+
createDefaults: createDefaultsBase?.authenticated || {},
|
|
195
|
+
restrictions: restrictionsBase?.authenticated || {},
|
|
196
|
+
queryFilter: queryFilterBase?.authenticated || {},
|
|
197
|
+
exposePrivate: bypass,
|
|
198
|
+
sensitiveMask,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (aBool === false && ctx.isAuthenticated) {
|
|
202
|
+
const { effectiveReadMask, effectiveWriteDeny } = mkMasks(baseReadMask, baseWriteDeny);
|
|
203
|
+
return {
|
|
204
|
+
...EMPTY_DECISION,
|
|
205
|
+
readMask: effectiveReadMask,
|
|
206
|
+
writeDeny: effectiveWriteDeny,
|
|
207
|
+
exposePrivate: false,
|
|
208
|
+
sensitiveMask,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
if (authRule === 'owner' && ctx.isAuthenticated) {
|
|
212
|
+
const { effectiveReadMask, effectiveWriteDeny } = mkMasks(baseReadMask, baseWriteDeny);
|
|
213
|
+
if (action === 'read') {
|
|
214
|
+
if (doc) {
|
|
215
|
+
return {
|
|
216
|
+
allow: isOwnerDoc(schemaPolicy, ctx, doc),
|
|
217
|
+
readMask: effectiveReadMask,
|
|
218
|
+
writeDeny: effectiveWriteDeny,
|
|
219
|
+
createDefaults: {},
|
|
220
|
+
restrictions: {},
|
|
221
|
+
queryFilter: {},
|
|
222
|
+
exposePrivate: bypass,
|
|
223
|
+
sensitiveMask,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
allow: true,
|
|
228
|
+
readMask: effectiveReadMask,
|
|
229
|
+
writeDeny: effectiveWriteDeny,
|
|
230
|
+
createDefaults: {},
|
|
231
|
+
restrictions: {},
|
|
232
|
+
queryFilter: ownerFilter(schemaPolicy, ctx),
|
|
233
|
+
exposePrivate: bypass,
|
|
234
|
+
sensitiveMask,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
allow: isOwnerDoc(schemaPolicy, ctx, doc),
|
|
239
|
+
readMask: effectiveReadMask,
|
|
240
|
+
writeDeny: effectiveReadMask, // (typo guard: will be ignored by callers; leave as is)
|
|
241
|
+
createDefaults: {},
|
|
242
|
+
restrictions: {},
|
|
243
|
+
queryFilter: {},
|
|
244
|
+
exposePrivate: bypass,
|
|
245
|
+
sensitiveMask,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
// 5) default deny
|
|
249
|
+
const { effectiveReadMask, effectiveWriteDeny } = mkMasks(baseReadMask, baseWriteDeny);
|
|
250
|
+
return {
|
|
251
|
+
...EMPTY_DECISION,
|
|
252
|
+
readMask: effectiveReadMask,
|
|
253
|
+
writeDeny: effectiveWriteDeny,
|
|
254
|
+
exposePrivate: false,
|
|
255
|
+
sensitiveMask,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/* ===== Helpers consumed by the router ===== */
|
|
259
|
+
function applyReadMaskOne(doc, mask) {
|
|
260
|
+
if (!doc || !mask?.length)
|
|
261
|
+
return doc;
|
|
262
|
+
const result = { ...doc };
|
|
263
|
+
for (const m of mask) {
|
|
264
|
+
if (typeof m === 'string' && m.startsWith('-')) {
|
|
265
|
+
delete result[m.slice(1)];
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return result;
|
|
269
|
+
}
|
|
270
|
+
function applyReadMaskMany(docs, mask) {
|
|
271
|
+
if (!Array.isArray(docs) || !mask?.length)
|
|
272
|
+
return docs;
|
|
273
|
+
return docs.map((d) => applyReadMaskOne(d, mask));
|
|
274
|
+
}
|
|
275
|
+
function stripWriteDeny(payload, deny) {
|
|
276
|
+
if (!deny?.length || !payload)
|
|
277
|
+
return payload;
|
|
278
|
+
const out = { ...payload };
|
|
279
|
+
for (const k of deny)
|
|
280
|
+
delete out[k];
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
function mergeCreateDefaults(payload, defs) {
|
|
284
|
+
if (!defs || !Object.keys(defs).length)
|
|
285
|
+
return payload;
|
|
286
|
+
return { ...defs, ...payload };
|
|
287
|
+
}
|
|
288
|
+
function enforceRestrictions(payload, restrictions, _ctx) {
|
|
289
|
+
if (!payload || !restrictions)
|
|
290
|
+
return;
|
|
291
|
+
for (const [field, blocked] of Object.entries(restrictions)) {
|
|
292
|
+
if (Array.isArray(blocked) && blocked.includes(payload[field])) {
|
|
293
|
+
throw new Error(`Restricted value for field: ${field}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function andFilter(a, b) {
|
|
298
|
+
const A = a && Object.keys(a).length ? a : {};
|
|
299
|
+
const B = b && Object.keys(b).length ? b : {};
|
|
300
|
+
if (!Object.keys(A).length)
|
|
301
|
+
return B;
|
|
302
|
+
if (!Object.keys(B).length)
|
|
303
|
+
return A;
|
|
304
|
+
return { $and: [A, B] };
|
|
305
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare function buildConditionFilter(condition: string, opts: {
|
|
2
|
+
by?: string;
|
|
3
|
+
context: {
|
|
4
|
+
userId?: string | null;
|
|
5
|
+
tenantId?: string | null;
|
|
6
|
+
};
|
|
7
|
+
}): Record<string, any>;
|
|
8
|
+
export declare function checkRecordCondition(condition: string, record: any, opts: {
|
|
9
|
+
by?: string;
|
|
10
|
+
context: {
|
|
11
|
+
userId?: string | null;
|
|
12
|
+
tenantId?: string | null;
|
|
13
|
+
};
|
|
14
|
+
}): boolean;
|