@tekki-forge/runtime-api 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/README.md +5 -0
- package/dist/access.d.ts +15 -0
- package/dist/access.js +51 -0
- package/dist/access.js.map +1 -0
- package/dist/api-keys.d.ts +140 -0
- package/dist/api-keys.js +361 -0
- package/dist/api-keys.js.map +1 -0
- package/dist/backup.d.ts +142 -0
- package/dist/backup.js +280 -0
- package/dist/backup.js.map +1 -0
- package/dist/bootstrap.d.ts +57 -0
- package/dist/bootstrap.js +191 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/comments.d.ts +139 -0
- package/dist/comments.js +370 -0
- package/dist/comments.js.map +1 -0
- package/dist/computed.d.ts +27 -0
- package/dist/computed.js +174 -0
- package/dist/computed.js.map +1 -0
- package/dist/controllers.d.ts +309 -0
- package/dist/controllers.js +634 -0
- package/dist/controllers.js.map +1 -0
- package/dist/dashboard.d.ts +58 -0
- package/dist/dashboard.js +258 -0
- package/dist/dashboard.js.map +1 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +55 -0
- package/dist/errors.js.map +1 -0
- package/dist/events.d.ts +39 -0
- package/dist/events.js +36 -0
- package/dist/events.js.map +1 -0
- package/dist/extension.d.ts +103 -0
- package/dist/extension.js +8 -0
- package/dist/extension.js.map +1 -0
- package/dist/import.d.ts +69 -0
- package/dist/import.js +347 -0
- package/dist/import.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/jobs.d.ts +41 -0
- package/dist/jobs.js +10 -0
- package/dist/jobs.js.map +1 -0
- package/dist/notifications.d.ts +61 -0
- package/dist/notifications.js +195 -0
- package/dist/notifications.js.map +1 -0
- package/dist/plugin.d.ts +43 -0
- package/dist/plugin.js +4 -0
- package/dist/plugin.js.map +1 -0
- package/dist/query.d.ts +29 -0
- package/dist/query.js +192 -0
- package/dist/query.js.map +1 -0
- package/dist/saved-views.d.ts +42 -0
- package/dist/saved-views.js +130 -0
- package/dist/saved-views.js.map +1 -0
- package/dist/search.d.ts +17 -0
- package/dist/search.js +42 -0
- package/dist/search.js.map +1 -0
- package/dist/seed.d.ts +12 -0
- package/dist/seed.js +132 -0
- package/dist/seed.js.map +1 -0
- package/dist/serialize.d.ts +37 -0
- package/dist/serialize.js +165 -0
- package/dist/serialize.js.map +1 -0
- package/dist/service.d.ts +168 -0
- package/dist/service.js +757 -0
- package/dist/service.js.map +1 -0
- package/dist/teams.d.ts +63 -0
- package/dist/teams.js +131 -0
- package/dist/teams.js.map +1 -0
- package/dist/testing.d.ts +63 -0
- package/dist/testing.js +420 -0
- package/dist/testing.js.map +1 -0
- package/package.json +61 -0
package/dist/service.js
ADDED
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { allowsRecord, authorize, effectiveRoles, evaluate, scopeAllows, teamKey } from "@tekki-forge/runtime-permissions";
|
|
3
|
+
import { validateInput } from "@tekki-forge/runtime-schema";
|
|
4
|
+
import { BadRequestException, ConflictException, ForbiddenException, NotFoundException, NotImplementedException, UnauthorizedException, } from "@nestjs/common";
|
|
5
|
+
import { queryableFields, unreadableFields, unwritableFields } from "./access.js";
|
|
6
|
+
import { ComputedEngine } from "./computed.js";
|
|
7
|
+
import { EventBus } from "./events.js";
|
|
8
|
+
import { NotificationService } from "./notifications.js";
|
|
9
|
+
import { QueryError, parseListQuery, searchWhere } from "./query.js";
|
|
10
|
+
import { diff, label, manyIds, relationInclude, serialize, toCsv, toPrismaData } from "./serialize.js";
|
|
11
|
+
import { ApiKeyService } from "./api-keys.js";
|
|
12
|
+
import { teamIdsOf } from "./teams.js";
|
|
13
|
+
const EXPORT_LIMIT = 10_000;
|
|
14
|
+
const TX = { timeout: 20_000, maxWait: 10_000 };
|
|
15
|
+
const invalid = (issues) => new BadRequestException({ statusCode: 400, error: "Bad Request", message: "Validation failed", issues });
|
|
16
|
+
export class ForgeService {
|
|
17
|
+
options;
|
|
18
|
+
app;
|
|
19
|
+
prisma;
|
|
20
|
+
auth;
|
|
21
|
+
socialProviders;
|
|
22
|
+
events;
|
|
23
|
+
computed;
|
|
24
|
+
notifications;
|
|
25
|
+
apiKeys;
|
|
26
|
+
/** Services shared between plugins and exposed to hooks via ctx.use(name). */
|
|
27
|
+
shared = new Map();
|
|
28
|
+
byPath;
|
|
29
|
+
byName;
|
|
30
|
+
byStorage;
|
|
31
|
+
extensions;
|
|
32
|
+
constructor(options) {
|
|
33
|
+
this.options = options;
|
|
34
|
+
this.app = options.app;
|
|
35
|
+
this.prisma = options.prisma;
|
|
36
|
+
this.auth = options.auth;
|
|
37
|
+
this.socialProviders = options.socialProviders ?? [];
|
|
38
|
+
this.events = options.events ?? new EventBus();
|
|
39
|
+
this.computed = new ComputedEngine(options.app);
|
|
40
|
+
this.apiKeys = new ApiKeyService(options.app, options.prisma);
|
|
41
|
+
this.notifications = new NotificationService(options.app, options.prisma, options.mail, (entity, principal) => this.policyCheck(entity, { principal, user: null, session: null }));
|
|
42
|
+
if (options.app.notifications.length)
|
|
43
|
+
this.events.on("*", (e) => this.notifications.handle(e), "notification rules");
|
|
44
|
+
this.byPath = new Map(options.app.entities.map((e) => [e.path, e]));
|
|
45
|
+
this.byName = new Map(options.app.entities.map((e) => [e.name, e]));
|
|
46
|
+
this.byStorage = new Map(options.app.entities.map((e) => [e.storageName, e]));
|
|
47
|
+
this.extensions = new Map((options.extensions ?? []).map((x) => [x.entity, x]));
|
|
48
|
+
for (const x of options.extensions ?? []) {
|
|
49
|
+
const entity = this.byName.get(x.entity);
|
|
50
|
+
if (!entity)
|
|
51
|
+
throw new Error(`[forge] extension for unknown entity "${x.entity}"`);
|
|
52
|
+
for (const a of entity.actions) {
|
|
53
|
+
if (!x.actions?.[a.name])
|
|
54
|
+
console.warn(`[forge] ${entity.name}: action "${a.name}" has no handler in its extension file`);
|
|
55
|
+
}
|
|
56
|
+
for (const t of Object.keys(x.transitions ?? {})) {
|
|
57
|
+
if (!entity.workflow?.transitions.some((wt) => wt.name === t))
|
|
58
|
+
throw new Error(`[forge] ${entity.name}: extension configures unknown transition "${t}"`);
|
|
59
|
+
}
|
|
60
|
+
for (const [pattern, handler] of Object.entries(x.on ?? {}))
|
|
61
|
+
this.events.on(pattern, handler, `${entity.name} extension`);
|
|
62
|
+
}
|
|
63
|
+
for (const e of options.app.entities) {
|
|
64
|
+
const rules = [...Object.values(e.permissions), ...e.fields.flatMap((f) => [f.access?.read, f.access?.write]), ...(e.workflow?.transitions.map((t) => t.permission) ?? [])];
|
|
65
|
+
const policies = new Set(rules.flatMap((p) => p?.policies ?? []));
|
|
66
|
+
for (const p of policies) {
|
|
67
|
+
if (!this.extensions.get(e.name)?.policies?.[p])
|
|
68
|
+
throw new Error(`[forge] ${e.name}: policy "${p}" is referenced in forge.config.ts but not implemented in its extension file`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Plugins attach behaviour (hooks, actions, event handlers) to the entities they contribute. */
|
|
73
|
+
addExtension(ext) {
|
|
74
|
+
const entity = this.byName.get(ext.entity);
|
|
75
|
+
if (!entity)
|
|
76
|
+
throw new Error(`[forge] extension for unknown entity "${ext.entity}"`);
|
|
77
|
+
const existing = this.extensions.get(ext.entity);
|
|
78
|
+
this.extensions.set(ext.entity, {
|
|
79
|
+
entity: ext.entity,
|
|
80
|
+
hooks: composeHooks(existing?.hooks, ext.hooks),
|
|
81
|
+
actions: { ...existing?.actions, ...ext.actions },
|
|
82
|
+
policies: { ...existing?.policies, ...ext.policies },
|
|
83
|
+
transitions: { ...existing?.transitions, ...ext.transitions },
|
|
84
|
+
});
|
|
85
|
+
for (const [pattern, handler] of Object.entries(ext.on ?? {}))
|
|
86
|
+
this.events.on(pattern, handler, `${ext.entity} extension`);
|
|
87
|
+
}
|
|
88
|
+
/** Used by the storage plugin during setup. */
|
|
89
|
+
useFileStore(store) {
|
|
90
|
+
this.options.files = store;
|
|
91
|
+
}
|
|
92
|
+
/** Startup check once plugins have been set up. */
|
|
93
|
+
assertReady() {
|
|
94
|
+
for (const e of this.app.entities) {
|
|
95
|
+
if (e.fields.some((f) => f.file) && !this.options.files) {
|
|
96
|
+
throw new Error(`[forge] ${e.name} has file/image fields but no file storage is set up — add the storage plugin (forge add storage)`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
entity(path) {
|
|
101
|
+
const e = this.byPath.get(path);
|
|
102
|
+
if (!e)
|
|
103
|
+
throw new NotFoundException(`Unknown resource "${path}"`);
|
|
104
|
+
return e;
|
|
105
|
+
}
|
|
106
|
+
entityByName(name) {
|
|
107
|
+
return this.byName.get(name);
|
|
108
|
+
}
|
|
109
|
+
/** The entity rows were stored under (`files.entity`, `audit_logs.entity`…): stable across renames. */
|
|
110
|
+
entityByStorageName(storageName) {
|
|
111
|
+
return this.byStorage.get(storageName);
|
|
112
|
+
}
|
|
113
|
+
// ─── Request context ────────────────────────────────────────────────────────
|
|
114
|
+
async resolveContext(headers, ip) {
|
|
115
|
+
// Scoped API keys: `Authorization: Bearer fk_…` (a bad key is a 401, never an anonymous request).
|
|
116
|
+
const authorization = headers.get("authorization");
|
|
117
|
+
if (authorization && /^bearer\s+fk_/i.test(authorization))
|
|
118
|
+
return this.apiKeys.resolve(authorization.replace(/^bearer\s+/i, ""), ip);
|
|
119
|
+
const result = await this.auth.api.getSession({ headers }).catch(() => null);
|
|
120
|
+
if (!result?.user)
|
|
121
|
+
return { principal: null, user: null, session: null, ...(ip ? { ip } : {}) };
|
|
122
|
+
const { user, session } = result;
|
|
123
|
+
let memberRole = null;
|
|
124
|
+
let organizationId = null;
|
|
125
|
+
if (this.app.tenancy.enabled && session.activeOrganizationId) {
|
|
126
|
+
const member = await this.prisma.member.findFirst({ where: { organizationId: session.activeOrganizationId, userId: user.id } });
|
|
127
|
+
if (member) {
|
|
128
|
+
memberRole = member.role;
|
|
129
|
+
organizationId = member.organizationId;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const teamIds = this.app.teams?.enabled && organizationId ? await teamIdsOf(this.prisma, user.id, organizationId) : undefined;
|
|
133
|
+
return {
|
|
134
|
+
principal: { id: user.id, roles: effectiveRoles(user.role, memberRole, this.app.tenancy.enabled), organizationId, ...(teamIds ? { teamIds } : {}) },
|
|
135
|
+
user: { id: user.id, name: user.name, email: user.email, role: user.role ?? null },
|
|
136
|
+
session: { id: session.id, activeOrganizationId: session.activeOrganizationId ?? null, impersonatedBy: session.impersonatedBy ?? null },
|
|
137
|
+
...(ip ? { ip } : {}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
get mail() {
|
|
141
|
+
return this.options.mail;
|
|
142
|
+
}
|
|
143
|
+
hookContext(entity, ctx, db = this.prisma) {
|
|
144
|
+
return {
|
|
145
|
+
...ctx,
|
|
146
|
+
entity,
|
|
147
|
+
prisma: db,
|
|
148
|
+
notify: (userIds, input) => this.notifications.notify(userIds, { organizationId: ctx.principal?.organizationId ?? null, ...input }),
|
|
149
|
+
...(this.options.mail ? { mail: this.options.mail } : {}),
|
|
150
|
+
use: (name) => this.shared.get(name),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
// ─── Authorization helpers ──────────────────────────────────────────────────
|
|
154
|
+
require(decision) {
|
|
155
|
+
if (decision.allowed)
|
|
156
|
+
return;
|
|
157
|
+
if (decision.reason === "unauthenticated")
|
|
158
|
+
throw new UnauthorizedException("Sign in to continue");
|
|
159
|
+
throw new ForbiddenException("You do not have permission to perform this action");
|
|
160
|
+
}
|
|
161
|
+
authorizeOp(entity, op, ctx) {
|
|
162
|
+
const decision = authorize(entity, op, ctx.principal);
|
|
163
|
+
this.require(decision);
|
|
164
|
+
this.requireTenant(entity, ctx);
|
|
165
|
+
return decision;
|
|
166
|
+
}
|
|
167
|
+
requireTenant(entity, ctx) {
|
|
168
|
+
if (!entity.features.tenant)
|
|
169
|
+
return;
|
|
170
|
+
if (!ctx.principal)
|
|
171
|
+
throw new UnauthorizedException("Sign in to continue");
|
|
172
|
+
if (!ctx.principal.organizationId)
|
|
173
|
+
throw new ForbiddenException("No active organization — create or select an organization first");
|
|
174
|
+
}
|
|
175
|
+
baseWhere(entity, ctx, trashed = "without") {
|
|
176
|
+
const where = [];
|
|
177
|
+
if (entity.features.tenant)
|
|
178
|
+
where.push({ organizationId: ctx.principal?.organizationId ?? "__none__" });
|
|
179
|
+
if (entity.features.softDelete) {
|
|
180
|
+
if (trashed === "without")
|
|
181
|
+
where.push({ deletedAt: null });
|
|
182
|
+
if (trashed === "only")
|
|
183
|
+
where.push({ deletedAt: { not: null } });
|
|
184
|
+
}
|
|
185
|
+
return where;
|
|
186
|
+
}
|
|
187
|
+
/** Prisma condition restricting a conditional decision (owner / policies) on lists. */
|
|
188
|
+
scopeWhere(entity, decision, ctx) {
|
|
189
|
+
if (decision.scope === "all")
|
|
190
|
+
return [];
|
|
191
|
+
const or = [];
|
|
192
|
+
if (decision.owner && ctx.principal && entity.ownerField) {
|
|
193
|
+
const fk = entity.fields.find((f) => f.name === entity.ownerField).relation.foreignKey;
|
|
194
|
+
or.push({ [fk]: ctx.principal.id });
|
|
195
|
+
}
|
|
196
|
+
const teamFk = decision.team && ctx.principal ? teamKey(entity) : undefined;
|
|
197
|
+
if (teamFk)
|
|
198
|
+
or.push({ [teamFk]: { in: ctx.principal.teamIds ?? [] } });
|
|
199
|
+
const ext = this.extensions.get(entity.name);
|
|
200
|
+
for (const name of decision.policies) {
|
|
201
|
+
const w = ext?.policies?.[name]?.where?.(this.hookContext(entity, ctx));
|
|
202
|
+
if (w)
|
|
203
|
+
or.push(w);
|
|
204
|
+
}
|
|
205
|
+
return [or.length ? { OR: or } : { id: { in: [] } }];
|
|
206
|
+
}
|
|
207
|
+
/** Full read scope for the caller: tenancy, soft delete and row-level permissions. */
|
|
208
|
+
readWhere(entity, ctx, trashed = "without") {
|
|
209
|
+
const decision = this.authorizeOp(entity, "read", ctx);
|
|
210
|
+
return [...this.baseWhere(entity, ctx, trashed), ...this.scopeWhere(entity, decision, ctx)];
|
|
211
|
+
}
|
|
212
|
+
policyCheck(entity, ctx) {
|
|
213
|
+
const ext = this.extensions.get(entity.name);
|
|
214
|
+
return (name, record) => ext?.policies?.[name]?.check({ ...this.hookContext(entity, ctx), record }) ?? false;
|
|
215
|
+
}
|
|
216
|
+
queryAccess(entity, ctx) {
|
|
217
|
+
const restricted = entity.fields.some((f) => f.access?.read);
|
|
218
|
+
return { ...(restricted ? { queryable: queryableFields(entity, ctx.principal) } : {}), userId: ctx.principal?.id ?? null };
|
|
219
|
+
}
|
|
220
|
+
/** Load a record the caller may apply `op` (or `rule`) to; 404 when they cannot even see it. Raw Prisma row. */
|
|
221
|
+
async loadFor(entity, id, op, ctx, opts = {}) {
|
|
222
|
+
const decision = opts.rule ? evaluate(opts.rule, ctx.principal) : authorize(entity, op, ctx.principal);
|
|
223
|
+
this.require(decision);
|
|
224
|
+
// Actions and transitions use their own rule; API key scopes still apply.
|
|
225
|
+
if (ctx.principal?.scopes && !scopeAllows(ctx.principal.scopes, entity.path, op))
|
|
226
|
+
throw new ForbiddenException("This API key has no scope for this operation");
|
|
227
|
+
this.requireTenant(entity, ctx);
|
|
228
|
+
const record = await this.delegate(entity).findFirst({
|
|
229
|
+
where: { AND: [{ id }, ...this.baseWhere(entity, ctx, opts.trashed ?? "without")] },
|
|
230
|
+
include: relationInclude(entity),
|
|
231
|
+
});
|
|
232
|
+
if (!record)
|
|
233
|
+
throw new NotFoundException(`${entity.label} not found`);
|
|
234
|
+
const check = this.policyCheck(entity, ctx);
|
|
235
|
+
const readable = await allowsRecord(entity, authorize(entity, "read", ctx.principal), ctx.principal, record, check);
|
|
236
|
+
if (!readable)
|
|
237
|
+
throw new NotFoundException(`${entity.label} not found`);
|
|
238
|
+
if (!(await allowsRecord(entity, decision, ctx.principal, record, check))) {
|
|
239
|
+
throw new ForbiddenException("You do not have permission to perform this action");
|
|
240
|
+
}
|
|
241
|
+
return record;
|
|
242
|
+
}
|
|
243
|
+
delegate(entity, db = this.prisma) {
|
|
244
|
+
const d = db[entity.delegate];
|
|
245
|
+
if (!d)
|
|
246
|
+
throw new Error(`[forge] Prisma client has no model "${entity.model}" — run \`forge generate\` and \`forge db migrate\``);
|
|
247
|
+
return d;
|
|
248
|
+
}
|
|
249
|
+
/** Serialize for this caller, dropping fields they may not read. */
|
|
250
|
+
async present(entity, record, ctx) {
|
|
251
|
+
const omit = entity.fields.some((f) => f.access?.read) ? await unreadableFields(entity, ctx.principal, record, this.policyCheck(entity, ctx)) : undefined;
|
|
252
|
+
return serialize(entity, record, { ...(omit ? { omit } : {}), apiPrefix: this.app.api.prefix });
|
|
253
|
+
}
|
|
254
|
+
// ─── Validation ─────────────────────────────────────────────────────────────
|
|
255
|
+
/** Explain why read-only keys cannot be written instead of a generic "unknown field". */
|
|
256
|
+
rejectReadOnly(entity, body) {
|
|
257
|
+
if (!body || typeof body !== "object")
|
|
258
|
+
return;
|
|
259
|
+
const issues = [];
|
|
260
|
+
for (const f of entity.fields) {
|
|
261
|
+
const keys = new Set([f.apiName, f.name]);
|
|
262
|
+
if (![...keys].some((k) => k in body))
|
|
263
|
+
continue;
|
|
264
|
+
if (f.computed)
|
|
265
|
+
issues.push({ path: f.apiName, message: `${f.label} is computed (${f.computed.expression}) and cannot be set` });
|
|
266
|
+
else if (entity.workflow?.field === f.name) {
|
|
267
|
+
const names = entity.workflow.transitions.map((t) => t.name).join(", ");
|
|
268
|
+
issues.push({ path: f.apiName, message: `${f.label} changes through workflow transitions (${names}), not by editing` });
|
|
269
|
+
}
|
|
270
|
+
else if (f.readonly && !f.hidden)
|
|
271
|
+
issues.push({ path: f.apiName, message: `${f.label} is read-only` });
|
|
272
|
+
}
|
|
273
|
+
if (issues.length)
|
|
274
|
+
throw invalid(issues);
|
|
275
|
+
}
|
|
276
|
+
validate(entity, mode, body) {
|
|
277
|
+
this.rejectReadOnly(entity, body);
|
|
278
|
+
const result = validateInput(entity, mode, body);
|
|
279
|
+
if (!result.success)
|
|
280
|
+
throw invalid(result.issues);
|
|
281
|
+
return toPrismaData(entity, result.data, mode);
|
|
282
|
+
}
|
|
283
|
+
finalizeJson(data) {
|
|
284
|
+
for (const [k, v] of Object.entries(data)) {
|
|
285
|
+
if (v && typeof v === "object" && v.__forgeDbNull) {
|
|
286
|
+
if (this.options.dbNull !== undefined)
|
|
287
|
+
data[k] = this.options.dbNull;
|
|
288
|
+
else
|
|
289
|
+
delete data[k];
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return data;
|
|
293
|
+
}
|
|
294
|
+
async relationTargetsExist(f, ids, ctx) {
|
|
295
|
+
if (ids.length === 0)
|
|
296
|
+
return true;
|
|
297
|
+
const rel = f.relation;
|
|
298
|
+
const principal = ctx.principal;
|
|
299
|
+
let found;
|
|
300
|
+
if (rel.entity === "User") {
|
|
301
|
+
found = await this.prisma.user.count({
|
|
302
|
+
where: this.app.tenancy.enabled ? { id: { in: ids }, members: { some: { organizationId: principal.organizationId } } } : { id: { in: ids } },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
else if (rel.entity === "Team") {
|
|
306
|
+
found = await this.prisma.team.count({ where: { id: { in: ids }, organizationId: principal.organizationId ?? "__none__" } });
|
|
307
|
+
}
|
|
308
|
+
else if (rel.entity === "Organization") {
|
|
309
|
+
found = ids.every((id) => id === principal.organizationId) ? ids.length : 0;
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
const target = this.byName.get(rel.entity);
|
|
313
|
+
found = await this.delegate(target).count({ where: { AND: [{ id: { in: ids } }, ...this.baseWhere(target, ctx)] } });
|
|
314
|
+
}
|
|
315
|
+
return found === new Set(ids).size;
|
|
316
|
+
}
|
|
317
|
+
/** Field-level write permissions, ownership assignment and cross-tenant integrity of relations and files. */
|
|
318
|
+
async checkWrite(entity, data, ctx, existing) {
|
|
319
|
+
const denied = await unwritableFields(entity, data, ctx.principal, existing ?? data, this.policyCheck(entity, ctx));
|
|
320
|
+
if (denied.length)
|
|
321
|
+
throw new ForbiddenException(`You cannot change ${denied.map((f) => f.label).join(", ")}`);
|
|
322
|
+
const issues = [];
|
|
323
|
+
for (const f of entity.fields) {
|
|
324
|
+
const problem = f.relation?.type === "many-to-many"
|
|
325
|
+
? await this.checkManyToMany(f, data, ctx)
|
|
326
|
+
: f.file
|
|
327
|
+
? await this.checkFile(entity, f, data, ctx, existing)
|
|
328
|
+
: f.relation
|
|
329
|
+
? await this.checkManyToOne(f, data, ctx, existing)
|
|
330
|
+
: null;
|
|
331
|
+
if (problem)
|
|
332
|
+
issues.push(problem);
|
|
333
|
+
}
|
|
334
|
+
if (issues.length)
|
|
335
|
+
throw invalid(issues);
|
|
336
|
+
}
|
|
337
|
+
async checkManyToMany(f, data, ctx) {
|
|
338
|
+
const ids = manyIds(data, f);
|
|
339
|
+
if (!ids || (await this.relationTargetsExist(f, ids, ctx)))
|
|
340
|
+
return null;
|
|
341
|
+
return { path: f.apiName, message: `Some ${f.label.toLowerCase()} were not found` };
|
|
342
|
+
}
|
|
343
|
+
async checkFile(entity, f, data, ctx, existing) {
|
|
344
|
+
const fileId = data[f.apiName];
|
|
345
|
+
if (typeof fileId !== "string" || existing?.[f.apiName] === fileId)
|
|
346
|
+
return null;
|
|
347
|
+
const error = await this.options.files.validateAttachment(this.prisma, { entity, field: f, fileId, ctx, ...(existing ? { recordId: existing.id } : {}) });
|
|
348
|
+
return error ? { path: f.apiName, message: error } : null;
|
|
349
|
+
}
|
|
350
|
+
async checkManyToOne(f, data, ctx, existing) {
|
|
351
|
+
const rel = f.relation;
|
|
352
|
+
const value = data[rel.foreignKey];
|
|
353
|
+
if (value === undefined || value === null || existing?.[rel.foreignKey] === value)
|
|
354
|
+
return null;
|
|
355
|
+
const principal = ctx.principal;
|
|
356
|
+
if (rel.owner && value !== actingUserId(ctx) && !principal.roles.includes("admin")) {
|
|
357
|
+
throw new ForbiddenException(`Only admins can assign the ${f.label.toLowerCase()} to another user`);
|
|
358
|
+
}
|
|
359
|
+
if (rel.entity === "Team" && !principal.roles.includes("admin") && !(principal.teamIds ?? []).includes(value)) {
|
|
360
|
+
throw new ForbiddenException("You can only assign records to teams you belong to");
|
|
361
|
+
}
|
|
362
|
+
return (await this.relationTargetsExist(f, [value], ctx)) ? null : { path: rel.foreignKey, message: `${f.label} not found` };
|
|
363
|
+
}
|
|
364
|
+
/** Attach newly referenced files; returns ids of files no longer referenced (released after commit). */
|
|
365
|
+
async syncFiles(db, entity, record, previous) {
|
|
366
|
+
const released = [];
|
|
367
|
+
for (const f of entity.fields) {
|
|
368
|
+
if (!f.file)
|
|
369
|
+
continue;
|
|
370
|
+
const now = record?.[f.apiName] ?? null;
|
|
371
|
+
const before = previous?.[f.apiName] ?? null;
|
|
372
|
+
if (now === before)
|
|
373
|
+
continue;
|
|
374
|
+
if (now)
|
|
375
|
+
await this.options.files.attach(db, { entity, field: f, fileId: now, recordId: record.id });
|
|
376
|
+
if (before)
|
|
377
|
+
released.push(before);
|
|
378
|
+
}
|
|
379
|
+
return released;
|
|
380
|
+
}
|
|
381
|
+
async audit(db, entity, ctx, action, recordId, changes) {
|
|
382
|
+
if (!entity.features.audit)
|
|
383
|
+
return;
|
|
384
|
+
await db.auditLog.create({
|
|
385
|
+
data: {
|
|
386
|
+
entity: entity.storageName,
|
|
387
|
+
recordId,
|
|
388
|
+
action,
|
|
389
|
+
// API keys are recorded as the actor themselves, never as their creator.
|
|
390
|
+
userId: ctx.apiKey ? null : (ctx.principal?.id ?? null),
|
|
391
|
+
...(ctx.apiKey ? { apiKeyId: ctx.apiKey.id } : {}),
|
|
392
|
+
...(this.app.tenancy.enabled ? { organizationId: ctx.principal?.organizationId ?? null } : {}),
|
|
393
|
+
changes: changes ? { ...changes, ...(ctx.session?.impersonatedBy ? { _impersonatedBy: ctx.session.impersonatedBy } : {}) } : undefined,
|
|
394
|
+
ipAddress: ctx.ip ?? null,
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
async emit(entity, action, ctx, record, extra) {
|
|
399
|
+
const event = {
|
|
400
|
+
id: randomUUID(),
|
|
401
|
+
type: `${entity.name}.${action}`,
|
|
402
|
+
entity: entity.name,
|
|
403
|
+
action,
|
|
404
|
+
record: record ? serialize(entity, record, { apiPrefix: this.app.api.prefix }) : null,
|
|
405
|
+
actor: ctx.user ? { id: ctx.user.id, name: ctx.user.name } : ctx.apiKey ? { id: `apikey:${ctx.apiKey.id}`, name: `API key "${ctx.apiKey.name}"` } : null,
|
|
406
|
+
organizationId: ctx.principal?.organizationId ?? record?.organizationId ?? null,
|
|
407
|
+
occurredAt: new Date().toISOString(),
|
|
408
|
+
...extra,
|
|
409
|
+
};
|
|
410
|
+
await this.events.emit(event);
|
|
411
|
+
}
|
|
412
|
+
tx(fn) {
|
|
413
|
+
return this.prisma.$transaction(fn, TX);
|
|
414
|
+
}
|
|
415
|
+
// ─── CRUD ───────────────────────────────────────────────────────────────────
|
|
416
|
+
async list(entity, query, ctx) {
|
|
417
|
+
const decision = this.authorizeOp(entity, "read", ctx);
|
|
418
|
+
const q = this.parse(entity, query, ctx);
|
|
419
|
+
const where = { AND: [...this.baseWhere(entity, ctx, q.trashed), ...this.scopeWhere(entity, decision, ctx), ...q.where] };
|
|
420
|
+
const [total, rows] = await this.prisma.$transaction([
|
|
421
|
+
this.delegate(entity).count({ where }),
|
|
422
|
+
this.delegate(entity).findMany({ where, orderBy: q.orderBy, skip: (q.page - 1) * q.pageSize, take: q.pageSize, include: relationInclude(entity) }),
|
|
423
|
+
]);
|
|
424
|
+
return {
|
|
425
|
+
data: await Promise.all(rows.map((r) => this.present(entity, r, ctx))),
|
|
426
|
+
meta: { page: q.page, pageSize: q.pageSize, total, pages: Math.max(1, Math.ceil(total / q.pageSize)) },
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
async exportCsv(entity, query, ctx) {
|
|
430
|
+
if (!entity.features.export)
|
|
431
|
+
throw new NotFoundException("Export is disabled for this resource");
|
|
432
|
+
const decision = this.authorizeOp(entity, "read", ctx);
|
|
433
|
+
const q = this.parse(entity, { ...query, page: 1, pageSize: 1 }, ctx);
|
|
434
|
+
const rows = await this.delegate(entity).findMany({
|
|
435
|
+
where: { AND: [...this.baseWhere(entity, ctx, q.trashed), ...this.scopeWhere(entity, decision, ctx), ...q.where] },
|
|
436
|
+
orderBy: q.orderBy,
|
|
437
|
+
take: EXPORT_LIMIT,
|
|
438
|
+
include: relationInclude(entity),
|
|
439
|
+
});
|
|
440
|
+
// Columns the caller can never read are dropped; per-record restrictions leave empty cells.
|
|
441
|
+
const queryable = queryableFields(entity, ctx.principal);
|
|
442
|
+
const hidden = new Set(entity.fields.filter((f) => f.access?.read && !queryable.has(f.name) && !evaluate(f.access.read, ctx.principal).allowed).map((f) => f.name));
|
|
443
|
+
return toCsv(entity, await Promise.all(rows.map((r) => this.present(entity, r, ctx))), hidden);
|
|
444
|
+
}
|
|
445
|
+
async get(entity, id, ctx, query = {}) {
|
|
446
|
+
this.authorizeOp(entity, "read", ctx);
|
|
447
|
+
const trashed = entity.features.softDelete && query.trashed === "with" ? "with" : "without";
|
|
448
|
+
return this.present(entity, await this.loadFor(entity, id, "read", ctx, { trashed }), ctx);
|
|
449
|
+
}
|
|
450
|
+
async create(entity, body, ctx, options = {}) {
|
|
451
|
+
const decision = this.authorizeOp(entity, "create", ctx);
|
|
452
|
+
let data = this.validate(entity, "create", body);
|
|
453
|
+
if (entity.ownerField) {
|
|
454
|
+
const fk = entity.fields.find((f) => f.name === entity.ownerField).relation.foreignKey;
|
|
455
|
+
data[fk] ??= actingUserId(ctx);
|
|
456
|
+
}
|
|
457
|
+
if (entity.workflow)
|
|
458
|
+
data[entity.workflow.field] = entity.workflow.initial;
|
|
459
|
+
await this.checkWrite(entity, data, ctx);
|
|
460
|
+
if (decision.scope === "conditional" && !(await allowsRecord(entity, decision, ctx.principal, data, this.policyCheck(entity, ctx)))) {
|
|
461
|
+
throw new ForbiddenException("You do not have permission to perform this action");
|
|
462
|
+
}
|
|
463
|
+
const hooks = this.extensions.get(entity.name)?.hooks;
|
|
464
|
+
const include = relationInclude(entity);
|
|
465
|
+
const record = await this.tx(async (db) => {
|
|
466
|
+
data = (await hooks?.beforeCreate?.({ ...this.hookContext(entity, ctx, db), data })) ?? data;
|
|
467
|
+
if (entity.features.tenant)
|
|
468
|
+
data.organizationId = ctx.principal.organizationId;
|
|
469
|
+
let row = await this.delegate(entity, db).create({ data: this.finalizeJson(data), include });
|
|
470
|
+
row = await this.computed.refresh(db, entity, row, include);
|
|
471
|
+
await this.syncFiles(db, entity, row, null);
|
|
472
|
+
await this.computed.refreshParents(db, entity, null, row);
|
|
473
|
+
await this.audit(db, entity, ctx, options.auditAction ?? "create", row.id, { after: serialize(entity, row) });
|
|
474
|
+
return row;
|
|
475
|
+
});
|
|
476
|
+
await this.emit(entity, "created", ctx, record, { recordId: record.id });
|
|
477
|
+
await hooks?.afterCreate?.({ ...this.hookContext(entity, ctx), record });
|
|
478
|
+
return this.present(entity, record, ctx);
|
|
479
|
+
}
|
|
480
|
+
async update(entity, id, body, ctx) {
|
|
481
|
+
this.authorizeOp(entity, "update", ctx);
|
|
482
|
+
const existing = await this.loadFor(entity, id, "update", ctx);
|
|
483
|
+
let data = this.validate(entity, "update", body);
|
|
484
|
+
const nulls = entity.fields.filter((f) => f.required && f.apiName in data && data[f.apiName] === null).map((f) => ({ path: f.apiName, message: "Required" }));
|
|
485
|
+
if (nulls.length)
|
|
486
|
+
throw invalid(nulls);
|
|
487
|
+
await this.checkWrite(entity, data, ctx, existing);
|
|
488
|
+
const hooks = this.extensions.get(entity.name)?.hooks;
|
|
489
|
+
const include = relationInclude(entity);
|
|
490
|
+
let released = [];
|
|
491
|
+
const record = await this.tx(async (db) => {
|
|
492
|
+
data = (await hooks?.beforeUpdate?.({ ...this.hookContext(entity, ctx, db), data, existing })) ?? data;
|
|
493
|
+
let row = await this.delegate(entity, db).update({ where: { id }, data: this.finalizeJson(data), include });
|
|
494
|
+
row = await this.computed.refresh(db, entity, row, include);
|
|
495
|
+
released = await this.syncFiles(db, entity, row, existing);
|
|
496
|
+
await this.computed.refreshParents(db, entity, existing, row);
|
|
497
|
+
const changes = diff(serialize(entity, existing), serialize(entity, row));
|
|
498
|
+
if (Object.keys(changes).length)
|
|
499
|
+
await this.audit(db, entity, ctx, "update", id, changes);
|
|
500
|
+
return row;
|
|
501
|
+
});
|
|
502
|
+
if (released.length)
|
|
503
|
+
await this.options.files.release(released);
|
|
504
|
+
await this.emit(entity, "updated", ctx, record, {
|
|
505
|
+
recordId: id,
|
|
506
|
+
previous: serialize(entity, existing, { apiPrefix: this.app.api.prefix }),
|
|
507
|
+
changes: diff(serialize(entity, existing), serialize(entity, record)),
|
|
508
|
+
});
|
|
509
|
+
await hooks?.afterUpdate?.({ ...this.hookContext(entity, ctx), record, previous: existing });
|
|
510
|
+
return this.present(entity, record, ctx);
|
|
511
|
+
}
|
|
512
|
+
async remove(entity, id, ctx) {
|
|
513
|
+
this.authorizeOp(entity, "delete", ctx);
|
|
514
|
+
const record = await this.loadFor(entity, id, "delete", ctx);
|
|
515
|
+
const hooks = this.extensions.get(entity.name)?.hooks;
|
|
516
|
+
let released = [];
|
|
517
|
+
await this.tx(async (db) => {
|
|
518
|
+
await hooks?.beforeDelete?.({ ...this.hookContext(entity, ctx, db), record });
|
|
519
|
+
if (entity.features.softDelete)
|
|
520
|
+
await this.delegate(entity, db).update({ where: { id }, data: { deletedAt: new Date() } });
|
|
521
|
+
else {
|
|
522
|
+
await this.delegate(entity, db).delete({ where: { id } });
|
|
523
|
+
released = entity.fields.filter((f) => f.file && record[f.apiName]).map((f) => record[f.apiName]);
|
|
524
|
+
}
|
|
525
|
+
await this.computed.refreshParents(db, entity, record, null);
|
|
526
|
+
await this.audit(db, entity, ctx, entity.features.softDelete ? "soft-delete" : "delete", id, { before: serialize(entity, record) });
|
|
527
|
+
});
|
|
528
|
+
if (released.length)
|
|
529
|
+
await this.options.files.release(released);
|
|
530
|
+
await this.emit(entity, "deleted", ctx, record, { recordId: id });
|
|
531
|
+
await hooks?.afterDelete?.({ ...this.hookContext(entity, ctx), record });
|
|
532
|
+
}
|
|
533
|
+
async bulkDelete(entity, body, ctx) {
|
|
534
|
+
if (!entity.features.bulkDelete)
|
|
535
|
+
throw new NotFoundException("Bulk delete is disabled for this resource");
|
|
536
|
+
const ids = body?.ids;
|
|
537
|
+
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 500 || !ids.every((i) => typeof i === "string")) {
|
|
538
|
+
throw new BadRequestException("ids must be an array of 1–500 record ids");
|
|
539
|
+
}
|
|
540
|
+
let deleted = 0;
|
|
541
|
+
for (const id of new Set(ids)) {
|
|
542
|
+
await this.remove(entity, id, ctx);
|
|
543
|
+
deleted++;
|
|
544
|
+
}
|
|
545
|
+
return { deleted };
|
|
546
|
+
}
|
|
547
|
+
async restore(entity, id, ctx) {
|
|
548
|
+
if (!entity.features.softDelete)
|
|
549
|
+
throw new NotFoundException(`${entity.label} does not support restore`);
|
|
550
|
+
this.authorizeOp(entity, "update", ctx);
|
|
551
|
+
await this.loadFor(entity, id, "update", ctx, { trashed: "only" });
|
|
552
|
+
const include = relationInclude(entity);
|
|
553
|
+
const record = await this.tx(async (db) => {
|
|
554
|
+
const row = await this.delegate(entity, db).update({ where: { id }, data: { deletedAt: null }, include });
|
|
555
|
+
await this.computed.refreshParents(db, entity, null, row);
|
|
556
|
+
await this.audit(db, entity, ctx, "restore", id, {});
|
|
557
|
+
return row;
|
|
558
|
+
});
|
|
559
|
+
await this.emit(entity, "restored", ctx, record, { recordId: id });
|
|
560
|
+
return this.present(entity, record, ctx);
|
|
561
|
+
}
|
|
562
|
+
async transition(entity, id, name, body, ctx) {
|
|
563
|
+
const wf = entity.workflow;
|
|
564
|
+
const t = wf?.transitions.find((x) => x.name === name);
|
|
565
|
+
if (!wf || !t)
|
|
566
|
+
throw new NotFoundException(`Unknown transition "${name}"`);
|
|
567
|
+
const record = await this.loadFor(entity, id, "update", ctx, { rule: t.permission });
|
|
568
|
+
const from = record[wf.field];
|
|
569
|
+
if (t.from.length && !t.from.includes(from)) {
|
|
570
|
+
throw new ConflictException(`Cannot "${t.label}" a ${entity.label.toLowerCase()} that is ${from} (allowed from: ${t.from.join(", ")})`);
|
|
571
|
+
}
|
|
572
|
+
const ext = this.extensions.get(entity.name)?.transitions?.[name];
|
|
573
|
+
const verdict = await ext?.guard?.({ ...this.hookContext(entity, ctx), record, input: (body ?? {}) });
|
|
574
|
+
if (verdict === false || typeof verdict === "string")
|
|
575
|
+
throw new ConflictException(typeof verdict === "string" ? verdict : `"${t.label}" is not possible right now`);
|
|
576
|
+
const include = relationInclude(entity);
|
|
577
|
+
const updated = await this.tx(async (db) => {
|
|
578
|
+
let row = await this.delegate(entity, db).update({ where: { id }, data: { [wf.field]: t.to }, include });
|
|
579
|
+
row = await this.computed.refresh(db, entity, row, include);
|
|
580
|
+
await this.computed.refreshParents(db, entity, record, row);
|
|
581
|
+
await this.audit(db, entity, ctx, `transition:${name}`, id, { [wf.field]: { from, to: t.to } });
|
|
582
|
+
return row;
|
|
583
|
+
});
|
|
584
|
+
await this.emit(entity, "transitioned", ctx, updated, {
|
|
585
|
+
recordId: id,
|
|
586
|
+
name,
|
|
587
|
+
previous: serialize(entity, record, { apiPrefix: this.app.api.prefix }),
|
|
588
|
+
changes: { [wf.field]: { from, to: t.to } },
|
|
589
|
+
});
|
|
590
|
+
await ext?.after?.({ ...this.hookContext(entity, ctx), record: updated, previous: record, input: (body ?? {}) });
|
|
591
|
+
return this.present(entity, updated, ctx);
|
|
592
|
+
}
|
|
593
|
+
async runAction(entity, id, name, body, ctx) {
|
|
594
|
+
const action = entity.actions.find((a) => a.name === name);
|
|
595
|
+
if (!action)
|
|
596
|
+
throw new NotFoundException(`Unknown action "${name}"`);
|
|
597
|
+
const handler = this.extensions.get(entity.name)?.actions?.[name];
|
|
598
|
+
if (!handler)
|
|
599
|
+
throw new NotImplementedException(`Action "${name}" has no handler — implement it in ${entity.name.toLowerCase()}.extension.ts`);
|
|
600
|
+
const record = await this.loadFor(entity, id, "update", ctx, { rule: action.permission });
|
|
601
|
+
const result = await handler({ ...this.hookContext(entity, ctx), record, input: (body ?? {}) });
|
|
602
|
+
// Actions may change the record through prisma directly; keep computed values and parents current.
|
|
603
|
+
const fresh = await this.delegate(entity).findUnique({ where: { id }, include: relationInclude(entity) });
|
|
604
|
+
if (fresh) {
|
|
605
|
+
await this.tx(async (db) => {
|
|
606
|
+
const row = await this.computed.refresh(db, entity, fresh);
|
|
607
|
+
await this.computed.refreshParents(db, entity, record, row);
|
|
608
|
+
await this.audit(db, entity, ctx, `action:${name}`, id, {});
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
await this.emit(entity, "action", ctx, fresh, { recordId: id, name });
|
|
612
|
+
return { ok: true, result: result ?? null };
|
|
613
|
+
}
|
|
614
|
+
async lookup(entity, query, ctx) {
|
|
615
|
+
const decision = this.authorizeOp(entity, "read", ctx);
|
|
616
|
+
const where = [...this.baseWhere(entity, ctx), ...this.scopeWhere(entity, decision, ctx)];
|
|
617
|
+
const ids = typeof query.ids === "string" && query.ids ? query.ids.split(",") : undefined;
|
|
618
|
+
if (ids)
|
|
619
|
+
where.push({ id: { in: ids.slice(0, 100) } });
|
|
620
|
+
const search = typeof query.search === "string" ? searchWhere(entity, query.search, this.queryAccess(entity, ctx)) : undefined;
|
|
621
|
+
if (search)
|
|
622
|
+
where.push(search);
|
|
623
|
+
const displayIsField = entity.fields.some((f) => f.name === entity.display);
|
|
624
|
+
const rows = await this.delegate(entity).findMany({
|
|
625
|
+
where: { AND: where },
|
|
626
|
+
take: ids ? 100 : 20,
|
|
627
|
+
orderBy: displayIsField ? { [entity.display]: "asc" } : { id: "asc" },
|
|
628
|
+
select: { id: true, ...(displayIsField ? { [entity.display]: true } : {}) },
|
|
629
|
+
});
|
|
630
|
+
return { data: rows.map((r) => ({ id: r.id, label: label(entity, r) })) };
|
|
631
|
+
}
|
|
632
|
+
async lookupUsers(query, ctx) {
|
|
633
|
+
if (!ctx.principal)
|
|
634
|
+
throw new UnauthorizedException("Sign in to continue");
|
|
635
|
+
const where = [];
|
|
636
|
+
if (this.app.tenancy.enabled)
|
|
637
|
+
where.push({ members: { some: { organizationId: ctx.principal.organizationId ?? "__none__" } } });
|
|
638
|
+
const ids = typeof query.ids === "string" && query.ids ? query.ids.split(",") : undefined;
|
|
639
|
+
if (ids)
|
|
640
|
+
where.push({ id: { in: ids.slice(0, 100) } });
|
|
641
|
+
if (typeof query.search === "string" && query.search.trim()) {
|
|
642
|
+
const t = query.search.trim();
|
|
643
|
+
where.push({ OR: [{ name: { contains: t, mode: "insensitive" } }, { email: { contains: t, mode: "insensitive" } }] });
|
|
644
|
+
}
|
|
645
|
+
const rows = await this.prisma.user.findMany({ where: { AND: where }, take: ids ? 100 : 20, orderBy: { name: "asc" }, select: { id: true, name: true } });
|
|
646
|
+
return { data: rows.map((u) => ({ id: u.id, label: u.name })) };
|
|
647
|
+
}
|
|
648
|
+
async stats(ctx) {
|
|
649
|
+
const out = [];
|
|
650
|
+
for (const entity of this.app.entities) {
|
|
651
|
+
const decision = authorize(entity, "read", ctx.principal);
|
|
652
|
+
if (!decision.allowed || (entity.features.tenant && !ctx.principal?.organizationId))
|
|
653
|
+
continue;
|
|
654
|
+
const count = await this.delegate(entity).count({ where: { AND: [...this.baseWhere(entity, ctx), ...this.scopeWhere(entity, decision, ctx)] } });
|
|
655
|
+
out.push({ entity: entity.name, path: entity.path, label: entity.pluralLabel, count });
|
|
656
|
+
}
|
|
657
|
+
return { data: out };
|
|
658
|
+
}
|
|
659
|
+
async auditLogs(query, ctx) {
|
|
660
|
+
if (!ctx.principal)
|
|
661
|
+
throw new UnauthorizedException("Sign in to continue");
|
|
662
|
+
if (!ctx.principal.roles.includes("admin"))
|
|
663
|
+
throw new ForbiddenException("Only admins can view audit logs");
|
|
664
|
+
if (ctx.principal.scopes && !scopeAllows(ctx.principal.scopes, "audit-logs", "read"))
|
|
665
|
+
throw new ForbiddenException("This API key has no scope for the audit log");
|
|
666
|
+
const page = Math.max(1, Number(query.page) || 1);
|
|
667
|
+
const pageSize = Math.min(200, Math.max(1, Number(query.pageSize) || 50));
|
|
668
|
+
const where = {};
|
|
669
|
+
if (this.app.tenancy.enabled)
|
|
670
|
+
where.organizationId = ctx.principal.organizationId ?? "__none__";
|
|
671
|
+
for (const key of ["entity", "recordId", "userId", "action", "apiKeyId"])
|
|
672
|
+
if (typeof query[key] === "string" && query[key] && (key !== "apiKeyId" || /^[0-9a-f-]{36}$/i.test(query[key])))
|
|
673
|
+
where[key] = query[key];
|
|
674
|
+
// Rows are keyed on the storage name; the API speaks entity names.
|
|
675
|
+
if (typeof where.entity === "string")
|
|
676
|
+
where.entity = this.byName.get(where.entity)?.storageName ?? where.entity;
|
|
677
|
+
const [total, rows] = await this.prisma.$transaction([
|
|
678
|
+
this.prisma.auditLog.count({ where }),
|
|
679
|
+
this.prisma.auditLog.findMany({
|
|
680
|
+
where,
|
|
681
|
+
orderBy: { createdAt: "desc" },
|
|
682
|
+
skip: (page - 1) * pageSize,
|
|
683
|
+
take: pageSize,
|
|
684
|
+
include: { user: { select: { id: true, name: true, email: true } }, apiKey: { select: { id: true, name: true, prefix: true } } },
|
|
685
|
+
}),
|
|
686
|
+
]);
|
|
687
|
+
// Values of fields the viewer may not read are redacted from change history.
|
|
688
|
+
const data = rows.map((row) => {
|
|
689
|
+
const entity = this.byStorage.get(row.entity);
|
|
690
|
+
const log = entity ? { ...row, entity: entity.name } : row;
|
|
691
|
+
if (!entity || !log.changes)
|
|
692
|
+
return log;
|
|
693
|
+
const secret = entity.fields.filter((f) => f.access?.read && !evaluate(f.access.read, ctx.principal).allowed);
|
|
694
|
+
if (!secret.length)
|
|
695
|
+
return log;
|
|
696
|
+
const redact = (obj) => {
|
|
697
|
+
if (!obj)
|
|
698
|
+
return obj;
|
|
699
|
+
const out = { ...obj };
|
|
700
|
+
for (const f of secret)
|
|
701
|
+
for (const k of [f.name, f.apiName])
|
|
702
|
+
if (k in out)
|
|
703
|
+
out[k] = "[redacted]";
|
|
704
|
+
return out;
|
|
705
|
+
};
|
|
706
|
+
const changes = redact(log.changes);
|
|
707
|
+
if (changes.after)
|
|
708
|
+
changes.after = redact(changes.after);
|
|
709
|
+
if (changes.before)
|
|
710
|
+
changes.before = redact(changes.before);
|
|
711
|
+
return { ...log, changes };
|
|
712
|
+
});
|
|
713
|
+
return { data, meta: { page, pageSize, total, pages: Math.max(1, Math.ceil(total / pageSize)) } };
|
|
714
|
+
}
|
|
715
|
+
parse(entity, query, ctx) {
|
|
716
|
+
try {
|
|
717
|
+
return parseListQuery(entity, query, this.queryAccess(entity, ctx));
|
|
718
|
+
}
|
|
719
|
+
catch (err) {
|
|
720
|
+
if (err instanceof QueryError)
|
|
721
|
+
throw new BadRequestException({ statusCode: 400, error: "Bad Request", message: err.message });
|
|
722
|
+
throw err;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
/** The person a write is made for: the signed-in user, or the creator of the API key in use. */
|
|
727
|
+
export function actingUserId(ctx) {
|
|
728
|
+
return ctx.apiKey ? (ctx.apiKey.createdById ?? "") : ctx.principal.id;
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Run both hook sets: the added hooks first (e.g. plugin limits), then the existing ones (your extension).
|
|
732
|
+
* before* hooks may return replacement data, which is passed along the chain.
|
|
733
|
+
*/
|
|
734
|
+
export function composeHooks(existing, added) {
|
|
735
|
+
if (!existing)
|
|
736
|
+
return added;
|
|
737
|
+
if (!added)
|
|
738
|
+
return existing;
|
|
739
|
+
const out = {};
|
|
740
|
+
const keys = new Set([...Object.keys(existing), ...Object.keys(added)]);
|
|
741
|
+
for (const key of keys) {
|
|
742
|
+
const first = added[key];
|
|
743
|
+
const second = existing[key];
|
|
744
|
+
if (!first || !second) {
|
|
745
|
+
out[key] = first ?? second;
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
out[key] = async (ctx) => {
|
|
749
|
+
const r1 = await first(ctx);
|
|
750
|
+
const next = r1 !== undefined && "data" in ctx ? { ...ctx, data: r1 } : ctx;
|
|
751
|
+
const r2 = await second(next);
|
|
752
|
+
return r2 !== undefined ? r2 : r1;
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
return out;
|
|
756
|
+
}
|
|
757
|
+
//# sourceMappingURL=service.js.map
|