@anteros/core 0.0.40 → 0.0.41
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/database/audit.ts +193 -0
- package/database/collection.ts +3 -3
- package/database/deleteLog.ts +34 -0
- package/database/file.ts +3 -3
- package/database/mongodbadapter.ts +321 -147
- package/database/replication.ts +780 -0
- package/database/schema.ts +140 -125
- package/database/tenant.ts +4 -0
- package/database/vars.ts +405 -0
- package/database/workflow.ts +6 -6
- package/index.ts +10 -0
- package/lib/audit.ts +271 -0
- package/lib/auditResult.ts +120 -0
- package/lib/define.ts +20 -1
- package/lib/files.ts +64 -11
- package/lib/lifecycle.ts +141 -0
- package/lib/redact.ts +117 -0
- package/lib/workflow.ts +1 -1
- package/package.json +1 -1
- package/server/api.ts +109 -25
- package/server/boot.ts +185 -53
- package/server/flags.ts +136 -0
- package/server/hono.ts +1 -1
- package/tests/audit-file.test.ts +296 -0
- package/tests/audit-operations.test.ts +184 -0
- package/tests/audit-redact.test.ts +233 -0
- package/tests/audit-results.test.ts +188 -0
- package/tests/audit-retention-config.test.ts +76 -0
- package/tests/audit-run-action.test.ts +120 -0
- package/tests/audit.test.ts +320 -0
- package/tests/db-cache.test.ts +7 -7
- package/tests/file-collection-replication.test.ts +98 -0
- package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
- package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
- package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
- package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
- package/tests/fixtures/repl-only/app.ts +26 -0
- package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
- package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
- package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
- package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
- package/tests/fixtures/vars-tenant/vars/private.var.ts +6 -0
- package/tests/lifecycle.test.ts +96 -0
- package/tests/replication-control.test.ts +128 -0
- package/tests/replication-initial-sync.test.ts +86 -0
- package/tests/replication-only.test.ts +358 -0
- package/tests/replication.test.ts +188 -0
- package/tests/upload.test.ts +102 -1
- package/tests/vars-api.test.ts +123 -0
- package/tests/vars-replication.test.ts +95 -0
- package/tests/vars.test.ts +216 -0
- package/types/collection.d.ts +3 -0
- package/types/config.d.ts +64 -0
- package/types/file.d.ts +4 -1
- package/types/lifecycle.d.ts +71 -0
- package/types/replication.d.ts +199 -0
- package/types/tenant.d.ts +23 -0
- package/types/vars.d.ts +147 -0
- package/utils/func.ts +67 -52
package/lib/lifecycle.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import { cfg } from "../server/config";
|
|
4
|
+
import { useRest } from "../database/rest";
|
|
5
|
+
import { logger } from "../utils/logger";
|
|
6
|
+
import {
|
|
7
|
+
replicationNow,
|
|
8
|
+
getReplicationState,
|
|
9
|
+
resetReplication,
|
|
10
|
+
seedReplication,
|
|
11
|
+
} from "../database/replication";
|
|
12
|
+
import type { Lifecycle, LifecycleReason } from "../types/lifecycle";
|
|
13
|
+
import type { Tenant } from "../types/tenant";
|
|
14
|
+
import type { TenantReplicationApi } from "../types/replication";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Tenant-scoped replication API — like `rest`, the tenant is implicit. Built per
|
|
18
|
+
* tenant for the lifecycle context, so hooks never pass a tenant id.
|
|
19
|
+
*/
|
|
20
|
+
function tenantReplication(tenantId: string): TenantReplicationApi {
|
|
21
|
+
return {
|
|
22
|
+
now: () => replicationNow(tenantId),
|
|
23
|
+
state: (destinationId?: string, collection?: string) => getReplicationState(tenantId, destinationId, collection),
|
|
24
|
+
reset: (opts) => resetReplication(tenantId, opts),
|
|
25
|
+
seed: (value, opts) => seedReplication(tenantId, value, opts),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Lifecycles by tenant id — one `{tenant.dir}/lifecycle.ts` per tenant. */
|
|
30
|
+
const lifecycles = new Map<string, Lifecycle>();
|
|
31
|
+
/** Ensures `onDestroy` runs at most once per process. */
|
|
32
|
+
let destroyed = false;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Load `{tenant.dir}/lifecycle.ts` for every tenant. A missing file is fine —
|
|
36
|
+
* a tenant simply has no lifecycle.
|
|
37
|
+
*/
|
|
38
|
+
async function loadLifecycles(): Promise<void> {
|
|
39
|
+
lifecycles.clear();
|
|
40
|
+
destroyed = false;
|
|
41
|
+
for (const tenant of cfg.tenants ?? []) {
|
|
42
|
+
try {
|
|
43
|
+
const file = path.join(process.cwd(), tenant.dir, "lifecycle.ts");
|
|
44
|
+
if (!(await fs.exists(file))) continue;
|
|
45
|
+
const module = await import(file);
|
|
46
|
+
const lifecycle = module?.default;
|
|
47
|
+
if (lifecycle?._isLifecycle_) {
|
|
48
|
+
lifecycles.set(tenant.id, { ...lifecycle, _tenant_: tenant.id });
|
|
49
|
+
}
|
|
50
|
+
} catch (err: any) {
|
|
51
|
+
logger.file("error", "lifecycle: failed to load", { tenant: tenant.id, error: err?.message });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Tenant-scoped context passed to every hook. */
|
|
57
|
+
function buildContext(tenant: Tenant, extra?: Record<string, any>) {
|
|
58
|
+
return {
|
|
59
|
+
tenant,
|
|
60
|
+
rest: new useRest({ tenant_id: tenant.id }),
|
|
61
|
+
logger,
|
|
62
|
+
cfg,
|
|
63
|
+
replication: tenantReplication(tenant.id),
|
|
64
|
+
...(extra ?? {}),
|
|
65
|
+
} as any;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Run every `beforeBoot` hook, sequentially in `cfg.tenants` order. Blocking:
|
|
70
|
+
* a throwing hook aborts the boot (fail-fast).
|
|
71
|
+
*/
|
|
72
|
+
async function runBeforeBoot(): Promise<void> {
|
|
73
|
+
for (const tenant of cfg.tenants ?? []) {
|
|
74
|
+
const lifecycle = lifecycles.get(tenant.id);
|
|
75
|
+
if (!lifecycle?.enabled || !lifecycle.beforeBoot) continue;
|
|
76
|
+
try {
|
|
77
|
+
await lifecycle.beforeBoot(buildContext(tenant));
|
|
78
|
+
logger.file("lifecycle: beforeBoot", { tenant: tenant.id });
|
|
79
|
+
} catch (err: any) {
|
|
80
|
+
logger.error(`lifecycle beforeBoot failed for tenant '${tenant.id}'`, { error: err?.message, stack: err?.stack });
|
|
81
|
+
throw new Error(`[${tenant.id}] lifecycle beforeBoot failed: ${err?.message ?? err}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Run every `afterBoot` hook once the server is listening. Errors are logged
|
|
88
|
+
* but never fatal — the server is already serving.
|
|
89
|
+
*/
|
|
90
|
+
async function runAfterBoot(server: any, io: any): Promise<void> {
|
|
91
|
+
for (const tenant of cfg.tenants ?? []) {
|
|
92
|
+
const lifecycle = lifecycles.get(tenant.id);
|
|
93
|
+
if (!lifecycle?.enabled || !lifecycle.afterBoot) continue;
|
|
94
|
+
try {
|
|
95
|
+
await lifecycle.afterBoot(buildContext(tenant, { io, server }));
|
|
96
|
+
logger.file("lifecycle: afterBoot", { tenant: tenant.id });
|
|
97
|
+
} catch (err: any) {
|
|
98
|
+
logger.error(`lifecycle afterBoot failed for tenant '${tenant.id}'`, { error: err?.message });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Bound a promise with a timeout that rejects. */
|
|
104
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
|
105
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
106
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
107
|
+
timer = setTimeout(() => reject(new Error(message)), ms);
|
|
108
|
+
});
|
|
109
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)) as Promise<T>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Run every `onDestroy` hook on shutdown (once per process), before replication
|
|
114
|
+
* stops and DB clients close. Each hook is bounded by `destroyTimeout`.
|
|
115
|
+
*/
|
|
116
|
+
async function runOnDestroy(reason: LifecycleReason): Promise<void> {
|
|
117
|
+
if (destroyed) return;
|
|
118
|
+
destroyed = true;
|
|
119
|
+
|
|
120
|
+
for (const tenant of cfg.tenants ?? []) {
|
|
121
|
+
const lifecycle = lifecycles.get(tenant.id);
|
|
122
|
+
if (!lifecycle?.enabled || !lifecycle.onDestroy) continue;
|
|
123
|
+
try {
|
|
124
|
+
await withTimeout(
|
|
125
|
+
Promise.resolve(lifecycle.onDestroy(buildContext(tenant, { reason }))),
|
|
126
|
+
lifecycle.destroyTimeout ?? 10_000,
|
|
127
|
+
`lifecycle onDestroy timed out after ${lifecycle.destroyTimeout ?? 10_000}ms`,
|
|
128
|
+
);
|
|
129
|
+
logger.file("lifecycle: onDestroy", { tenant: tenant.id, reason });
|
|
130
|
+
} catch (err: any) {
|
|
131
|
+
logger.error(`lifecycle onDestroy failed for tenant '${tenant.id}'`, { error: err?.message });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export {
|
|
137
|
+
loadLifecycles,
|
|
138
|
+
runBeforeBoot,
|
|
139
|
+
runAfterBoot,
|
|
140
|
+
runOnDestroy,
|
|
141
|
+
};
|
package/lib/redact.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { cfg } from "../server/config";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keys whose value is replaced by `[redacted]` before an audit entry is stored
|
|
5
|
+
* (in `_audit_` **and** in the local file backup), at any depth.
|
|
6
|
+
*
|
|
7
|
+
* Override with `server.audit.redact: ['…']`, disable with `server.audit.redact: false`.
|
|
8
|
+
*/
|
|
9
|
+
const DEFAULT_REDACT_KEYS = [
|
|
10
|
+
'password',
|
|
11
|
+
'passwd',
|
|
12
|
+
'pwd',
|
|
13
|
+
'token',
|
|
14
|
+
'auth_token',
|
|
15
|
+
'access_token',
|
|
16
|
+
'refresh_token',
|
|
17
|
+
'id_token',
|
|
18
|
+
'authorization',
|
|
19
|
+
'apikey',
|
|
20
|
+
'api_key',
|
|
21
|
+
'key',
|
|
22
|
+
'secret',
|
|
23
|
+
'cookie',
|
|
24
|
+
'session_id',
|
|
25
|
+
'otp',
|
|
26
|
+
'pin',
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
const REDACTED = '[redacted]'
|
|
30
|
+
|
|
31
|
+
/** Depth guard — a pathological payload must never cost more than a bounded walk */
|
|
32
|
+
const MAX_DEPTH = 8
|
|
33
|
+
|
|
34
|
+
// Cache initialised for the default state (`redact` unset) — a sentinel like
|
|
35
|
+
// `undefined` would be indistinguishable from the real, unset configuration.
|
|
36
|
+
let cachedConfig: unknown = undefined
|
|
37
|
+
let cachedKeys: Set<string> | null = new Set(DEFAULT_REDACT_KEYS.map((key) => key.toLowerCase()))
|
|
38
|
+
|
|
39
|
+
/** Lower-cased redaction keys, or `null` when redaction is disabled */
|
|
40
|
+
function redactKeys(): Set<string> | null {
|
|
41
|
+
const configured = cfg.server?.audit?.redact
|
|
42
|
+
if (configured !== cachedConfig) {
|
|
43
|
+
cachedConfig = configured
|
|
44
|
+
cachedKeys = configured === false
|
|
45
|
+
? null
|
|
46
|
+
: new Set((configured ?? DEFAULT_REDACT_KEYS).map((key) => key.toLowerCase()))
|
|
47
|
+
}
|
|
48
|
+
return cachedKeys
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Is this key sensitive? Exact match, case-insensitive, `-`/`.` normalised to `_`
|
|
53
|
+
* (`api-key` → `api_key`), plus the usual header prefix (`x-api-key` → `api_key`).
|
|
54
|
+
*
|
|
55
|
+
* Deliberately **not** a substring match: `key` must not match `monkey`.
|
|
56
|
+
*/
|
|
57
|
+
function isSensitiveKey(name: string, keys: Set<string>): boolean {
|
|
58
|
+
const normalized = name.toLowerCase().replace(/[-.]/g, '_')
|
|
59
|
+
if (keys.has(normalized)) return true
|
|
60
|
+
if (normalized.startsWith('x_')) return keys.has(normalized.slice(2))
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Replace the value of every sensitive key (see `isSensitiveKey`), at any depth.
|
|
66
|
+
* Arrays are traversed; **non-plain objects** (`Date`, `ObjectId`, `Buffer`,
|
|
67
|
+
* `Map`…) are left untouched so stored types are never altered.
|
|
68
|
+
*
|
|
69
|
+
* Returns the **same reference** when nothing matched — the common case (no
|
|
70
|
+
* sensitive key in the payload) costs the walk but no allocation.
|
|
71
|
+
*/
|
|
72
|
+
function redact(value: any, keys: Set<string>, depth = 0): any {
|
|
73
|
+
if (value === null || typeof value !== 'object' || depth > MAX_DEPTH) return value
|
|
74
|
+
|
|
75
|
+
if (Array.isArray(value)) {
|
|
76
|
+
let changed = false
|
|
77
|
+
const out = value.map((item) => {
|
|
78
|
+
const next = redact(item, keys, depth + 1)
|
|
79
|
+
if (next !== item) changed = true
|
|
80
|
+
return next
|
|
81
|
+
})
|
|
82
|
+
return changed ? out : value
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const proto = Object.getPrototypeOf(value)
|
|
86
|
+
if (proto !== Object.prototype && proto !== null) return value
|
|
87
|
+
|
|
88
|
+
let changed = false
|
|
89
|
+
const out: Record<string, any> = {}
|
|
90
|
+
for (const [name, item] of Object.entries(value)) {
|
|
91
|
+
if (isSensitiveKey(name, keys)) {
|
|
92
|
+
out[name] = REDACTED
|
|
93
|
+
changed = true
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
const next = redact(item, keys, depth + 1)
|
|
97
|
+
if (next !== item) changed = true
|
|
98
|
+
out[name] = next
|
|
99
|
+
}
|
|
100
|
+
return changed ? out : value
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Redact an audit payload — `input`, `result`, `meta` or `request` */
|
|
104
|
+
function redactPayload(value: any): any {
|
|
105
|
+
const keys = redactKeys()
|
|
106
|
+
if (!keys || value === undefined || value === null) return value
|
|
107
|
+
return redact(value, keys)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export {
|
|
111
|
+
DEFAULT_REDACT_KEYS,
|
|
112
|
+
REDACTED,
|
|
113
|
+
redactKeys,
|
|
114
|
+
isSensitiveKey,
|
|
115
|
+
redactPayload,
|
|
116
|
+
redact,
|
|
117
|
+
}
|
package/lib/workflow.ts
CHANGED
|
@@ -28,7 +28,7 @@ async function syncWorkflows() {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
//
|
|
31
|
+
// Create the indexes on _workflows_ for the defined contexts
|
|
32
32
|
for (const [key, wf] of workflows) {
|
|
33
33
|
const tenantId = key.split(':')[0]
|
|
34
34
|
if (!wf.context || !tenantId) continue
|
package/package.json
CHANGED
package/server/api.ts
CHANGED
|
@@ -16,8 +16,11 @@ import type { ActivityInput } from "../types/activity";
|
|
|
16
16
|
import * as os from 'node:os';
|
|
17
17
|
import { basename } from 'node:path';
|
|
18
18
|
import { handleUpload, handleServe, handleDelete, type FileResult } from "../lib/files";
|
|
19
|
+
import { getVarsDefinition } from "../database/vars";
|
|
20
|
+
import { getTenant } from "../database/tenant";
|
|
19
21
|
const API_PREFIX = '/api/:tenant_id/:collection/:action';
|
|
20
22
|
const SERVICE_PREFIX = '/services/:tenant_id/:service/:action';
|
|
23
|
+
const VARS_PREFIX = '/vars/:tenant_id/:action';
|
|
21
24
|
const UPLOAD_PREFIX = '/upload/:tenant_id/:collection';
|
|
22
25
|
const FILES_PREFIX = '/files/:tenant_id/:collection/:file';
|
|
23
26
|
|
|
@@ -346,15 +349,14 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
346
349
|
}
|
|
347
350
|
|
|
348
351
|
// ── Access control ──
|
|
349
|
-
//
|
|
352
|
+
// Flaw 7: single implementation via evaluateAccess (checks expired/forged tokens)
|
|
350
353
|
await evaluateAccess(col?.api?.access as any, action, rest, 'Action', c);
|
|
351
354
|
|
|
352
355
|
// ── Execute action ──
|
|
353
356
|
if (col.actions?.[action]) {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
});
|
|
357
|
+
// Delegated to `rest.runAction` so the invocation is audited once,
|
|
358
|
+
// with the same context (`rest`, `io`, `data`, `token`, …)
|
|
359
|
+
response = await rest.runAction(collection, action, body?.data);
|
|
358
360
|
} else {
|
|
359
361
|
const handler = crudHandlers[action];
|
|
360
362
|
if (!handler) throw new AppError('Action `' + action + '` not found', { status: 400, code: 'ACTION_NOT_FOUND' });
|
|
@@ -382,6 +384,97 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
382
384
|
}
|
|
383
385
|
});
|
|
384
386
|
|
|
387
|
+
// ─── Vars API ───────────────────────────────────────────────────────
|
|
388
|
+
app.post(VARS_PREFIX, async (c) => {
|
|
389
|
+
let rest: InstanceType<typeof useRest> | undefined;
|
|
390
|
+
let tenant_id = '';
|
|
391
|
+
let action = '';
|
|
392
|
+
let ns = '';
|
|
393
|
+
let body: any;
|
|
394
|
+
const logStart = Date.now();
|
|
395
|
+
try {
|
|
396
|
+
const ContentType = c.req.header('Content-Type');
|
|
397
|
+
if (ContentType?.includes('application/json')) {
|
|
398
|
+
try { body = await c.req.json(); } catch {
|
|
399
|
+
throw new AppError('Invalid JSON body', { status: 400, code: 'INVALID_JSON_BODY' });
|
|
400
|
+
}
|
|
401
|
+
} else {
|
|
402
|
+
body = {};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const params = c.req.param() as { tenant_id: string; action: string };
|
|
406
|
+
tenant_id = params.tenant_id;
|
|
407
|
+
action = params.action;
|
|
408
|
+
ns = body?.ns;
|
|
409
|
+
|
|
410
|
+
if (!tenant_id) throw new AppError('Tenant ID is required', { status: 400, code: 'TENANT_ID_REQUIRED' });
|
|
411
|
+
if (!action) throw new AppError('Action is required', { status: 400, code: 'ACTION_REQUIRED' });
|
|
412
|
+
if (!ns) throw new AppError('Variables namespace is required', { status: 400, code: 'NAMESPACE_REQUIRED' });
|
|
413
|
+
if (!getTenant(tenant_id)) throw new AppError('Tenant not found', { status: 400, code: 'TENANT_NOT_FOUND' });
|
|
414
|
+
|
|
415
|
+
requestCtxStorage.set('tenant_id', tenant_id);
|
|
416
|
+
|
|
417
|
+
// Only namespaces declared with `define.Vars` are reachable, and their
|
|
418
|
+
// `api.access` gates every action (no rules = denied).
|
|
419
|
+
const definition = getVarsDefinition(tenant_id, ns);
|
|
420
|
+
if (!definition) throw new AppError(`Variables namespace '${ns}' not found`, { status: 400, code: 'NAMESPACE_NOT_FOUND' });
|
|
421
|
+
|
|
422
|
+
rest = new useRest({ internal: false, tenant_id });
|
|
423
|
+
await evaluateAccess(definition.api?.access as any, action, rest, `vars:${ns}`, c);
|
|
424
|
+
|
|
425
|
+
const vars = rest.vars;
|
|
426
|
+
const { key, value, meta, ttl, scope, where, entries, by } = body ?? {};
|
|
427
|
+
let response: any;
|
|
428
|
+
|
|
429
|
+
switch (action) {
|
|
430
|
+
case 'set': await vars.set(ns, key, value, { meta, ttl, scope }); response = { ok: true }; break;
|
|
431
|
+
case 'setMany': await vars.setMany(ns, entries ?? {}, { meta, ttl, scope }); response = { ok: true }; break;
|
|
432
|
+
case 'get': response = { value: await vars.get(ns, key, { scope }) }; break;
|
|
433
|
+
case 'entry': response = { entry: await vars.entry(ns, key, { scope }) }; break;
|
|
434
|
+
case 'entries': response = { entries: await vars.entries(ns, { scope, where }) }; break;
|
|
435
|
+
case 'all': response = { vars: await vars.all(ns, { scope, where }) }; break;
|
|
436
|
+
case 'del': response = { ok: await vars.del(ns, key, { scope }) }; break;
|
|
437
|
+
case 'has': response = { exists: await vars.has(ns, key, { scope }) }; break;
|
|
438
|
+
case 'incr': response = { value: await vars.incr(ns, key, by, { scope, ttl }) }; break;
|
|
439
|
+
case 'expire': response = { ok: await vars.expire(ns, key, ttl, { scope }) }; break;
|
|
440
|
+
case 'clear': response = { deleted: await vars.clear(ns, { scope }) }; break;
|
|
441
|
+
default: throw new AppError(`Action '${action}' not found`, { status: 400, code: 'ACTION_NOT_FOUND' });
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Audit — never store `value` / `meta` (they may hold secrets).
|
|
445
|
+
const accessToken = getAccessToken(c);
|
|
446
|
+
await logActivity({
|
|
447
|
+
rest,
|
|
448
|
+
tenant_id,
|
|
449
|
+
action,
|
|
450
|
+
collection: `_vars_:${ns}`,
|
|
451
|
+
status: 'success',
|
|
452
|
+
input: { ns, key, scope, ttl },
|
|
453
|
+
result: ['get', 'all', 'entry', 'entries'].includes(action) ? response : undefined,
|
|
454
|
+
duration: Date.now() - logStart,
|
|
455
|
+
token: { decoded: accessToken.decoded, value: null, provided: true, expired: false },
|
|
456
|
+
});
|
|
457
|
+
return c.json(response);
|
|
458
|
+
} catch (err: any) {
|
|
459
|
+
if (cfg?.debug) console.error(err);
|
|
460
|
+
if (rest && tenant_id && ns) {
|
|
461
|
+
const accessToken = getAccessToken(c);
|
|
462
|
+
await logActivity({
|
|
463
|
+
rest,
|
|
464
|
+
tenant_id,
|
|
465
|
+
action,
|
|
466
|
+
collection: `_vars_:${ns}`,
|
|
467
|
+
status: 'error',
|
|
468
|
+
input: { ns },
|
|
469
|
+
error: { message: err?.message, code: err?.code || 'INTERNAL_VARS_ERROR' },
|
|
470
|
+
duration: Date.now() - logStart,
|
|
471
|
+
token: { decoded: accessToken.decoded, value: null, provided: true, expired: false },
|
|
472
|
+
}).catch(() => {});
|
|
473
|
+
}
|
|
474
|
+
return errorResponse(c, err);
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
385
478
|
// ─── Service API ────────────────────────────────────────────────────
|
|
386
479
|
app.post(SERVICE_PREFIX, async (c) => {
|
|
387
480
|
try {
|
|
@@ -415,21 +508,10 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
415
508
|
const serviceAccess = serviceInstance.api?.access as Record<string, boolean | Function> | undefined;
|
|
416
509
|
await evaluateAccess(serviceAccess as any, action, rest, action, c);
|
|
417
510
|
|
|
418
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
try {
|
|
423
|
-
const response = await serviceInstance.actions[action]({
|
|
424
|
-
data: body?.data, error: fn.error, io, jwt: func.jwt, token: accessToken, rest,
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
await logActivity({ rest, tenant_id, action, collection: service, status: 'success', input: body?.data, result: response, duration: Date.now() - logStart, token: { decoded: accessToken.decoded, value: null, provided: true, expired: false } });
|
|
428
|
-
return c.json(response);
|
|
429
|
-
} catch (err: any) {
|
|
430
|
-
await logActivity({ rest, tenant_id, action, collection: service, status: 'error', input: body?.data, error: { message: err?.message, code: err?.code || 'INTERNAL_SERVICE_ERROR' }, duration: Date.now() - logStart, token: { decoded: accessToken.decoded, value: null, provided: true, expired: false } }).catch(() => {});
|
|
431
|
-
throw err;
|
|
432
|
-
}
|
|
511
|
+
// No `logActivity` here: the call is delegated to `rest.runService()`, which
|
|
512
|
+
// audits it once (`action: 'runService'`, `internal: false`, params only)
|
|
513
|
+
const response = await rest.runService(service, action, body?.data);
|
|
514
|
+
return c.json(response);
|
|
433
515
|
} catch (err: any) {
|
|
434
516
|
if (cfg?.debug) console.error(err)
|
|
435
517
|
return errorResponse(c, err);
|
|
@@ -469,12 +551,14 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
469
551
|
});
|
|
470
552
|
}
|
|
471
553
|
|
|
472
|
-
const formData = await c.req.parseBody({ all: true }); //
|
|
554
|
+
const formData = await c.req.parseBody({ all: true }); // repeated fields → arrays; a single occurrence stays a single value
|
|
473
555
|
|
|
474
556
|
// Collect files from both accepted field names (single or multiple)
|
|
475
557
|
const files: File[] = [];
|
|
476
558
|
for (const key of ['file', 'upload']) {
|
|
477
|
-
|
|
559
|
+
const raw = formData[key];
|
|
560
|
+
const values = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
|
561
|
+
for (const value of values) {
|
|
478
562
|
if (value instanceof File) files.push(value);
|
|
479
563
|
}
|
|
480
564
|
}
|
|
@@ -521,7 +605,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
521
605
|
}
|
|
522
606
|
await checkFileAccess(colServe?.api?.access as any, 'read', tenant_id, c);
|
|
523
607
|
|
|
524
|
-
//
|
|
608
|
+
// Flaw 18: validate the RAW path before basename (checks were dead code)
|
|
525
609
|
const rawFile = c.req.param('file') as string;
|
|
526
610
|
if (!rawFile || rawFile.startsWith('.') || rawFile.includes('..') || rawFile.includes('/') || rawFile.includes('\\')) {
|
|
527
611
|
throw new AppError('Invalid filename', { status: 400, code: 'INVALID_FILENAME' });
|
|
@@ -547,7 +631,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
547
631
|
c.header('Content-Type', result.mimetype);
|
|
548
632
|
if (result.size) c.header('Content-Length', String(result.size));
|
|
549
633
|
c.header('Cache-Control', 'public, max-age=31536000, immutable');
|
|
550
|
-
//
|
|
634
|
+
// Flaw 4: SVG served as attachment (no inline execution)
|
|
551
635
|
if (result.attachment) {
|
|
552
636
|
c.header('Content-Disposition', `attachment; filename="${filename}"`);
|
|
553
637
|
}
|
|
@@ -590,7 +674,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
590
674
|
// ─── Public config ──────────────────────────────────────────────────
|
|
591
675
|
app.get('/_dnax/config/:tenant_id', (c) => {
|
|
592
676
|
const t = c.get('token');
|
|
593
|
-
//
|
|
677
|
+
// Flaw 6: require a VERIFIED, non-expired token (not just any provided value)
|
|
594
678
|
if (!t?.decoded || t?.expired || !t?.provided) {
|
|
595
679
|
return c.json({ message: 'Authentication required', code: 'AUTH_REQUIRED' }, 401);
|
|
596
680
|
}
|