@anteros/core 0.0.39 → 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 +4 -4
- package/database/deleteLog.ts +34 -0
- package/database/field.crypto.ts +1 -1
- 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 +134 -33
- 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/crypto.test.ts +17 -17
- package/tests/db-cache.test.ts +7 -7
- package/tests/field-encryption.test.ts +25 -25
- package/tests/file-collection-replication.test.ts +98 -0
- package/tests/fixtures/index-tenant/collections/variants.model.ts +2 -2
- 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/index-spec.test.ts +5 -5
- 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 +239 -0
- 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
|
@@ -15,9 +15,12 @@ import { getTenantMiddlewares } from "../lib/middleware";
|
|
|
15
15
|
import type { ActivityInput } from "../types/activity";
|
|
16
16
|
import * as os from 'node:os';
|
|
17
17
|
import { basename } from 'node:path';
|
|
18
|
-
import { handleUpload, handleServe, handleDelete } from "../lib/files";
|
|
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);
|
|
@@ -457,7 +539,9 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
457
539
|
throw new AppError('Content-Type must be multipart/form-data', { code: 'INVALID_CONTENT_TYPE', status: 400 });
|
|
458
540
|
}
|
|
459
541
|
|
|
460
|
-
//
|
|
542
|
+
// Security: reject oversized bodies BEFORE parseBody buffers them in memory.
|
|
543
|
+
// `maxSize` doubles as the TOTAL request allowance → a multi-file upload must
|
|
544
|
+
// fit within it (each file is also validated individually in handleUpload).
|
|
461
545
|
const contentLength = Number(c.req.header('Content-Length') ?? 0);
|
|
462
546
|
const maxUploadSize = colUpload.upload?.maxSize ?? 10 * 1024 * 1024; // 10MB default
|
|
463
547
|
// multipart overhead (boundaries/headers) ≈ 64KB max
|
|
@@ -467,21 +551,38 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
467
551
|
});
|
|
468
552
|
}
|
|
469
553
|
|
|
470
|
-
const formData = await c.req.parseBody();
|
|
471
|
-
|
|
472
|
-
|
|
554
|
+
const formData = await c.req.parseBody({ all: true }); // repeated fields → arrays; a single occurrence stays a single value
|
|
555
|
+
|
|
556
|
+
// Collect files from both accepted field names (single or multiple)
|
|
557
|
+
const files: File[] = [];
|
|
558
|
+
for (const key of ['file', 'upload']) {
|
|
559
|
+
const raw = formData[key];
|
|
560
|
+
const values = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
|
561
|
+
for (const value of values) {
|
|
562
|
+
if (value instanceof File) files.push(value);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (!files.length) {
|
|
473
566
|
throw new AppError('No file provided. Use field name "file" or "upload".', { code: 'FILE_REQUIRED', status: 400 });
|
|
474
567
|
}
|
|
475
568
|
|
|
569
|
+
// Metadata: non-file fields. Single value → scalar (unchanged contract),
|
|
570
|
+
// repeated under the same name → array.
|
|
476
571
|
const data: Record<string, any> = {};
|
|
477
572
|
for (const [key, value] of Object.entries(formData)) {
|
|
478
|
-
if (key
|
|
479
|
-
|
|
480
|
-
|
|
573
|
+
if (key === 'file' || key === 'upload') continue;
|
|
574
|
+
const list = (Array.isArray(value) ? value : [value]) as any[];
|
|
575
|
+
if (list.some((v) => v instanceof File)) continue;
|
|
576
|
+
data[key] = list.length === 1 ? list[0] : list;
|
|
481
577
|
}
|
|
482
578
|
|
|
483
|
-
|
|
484
|
-
|
|
579
|
+
// One document per file (per-file validation: MIME, size, storage, replication).
|
|
580
|
+
// 1 file → single object (backward compatible), N files → array (SDK contract).
|
|
581
|
+
const results: FileResult[] = [];
|
|
582
|
+
for (const file of files) {
|
|
583
|
+
results.push(await handleUpload({ collection, tenant_id, file, data }));
|
|
584
|
+
}
|
|
585
|
+
return c.json(results.length === 1 ? results[0] : results);
|
|
485
586
|
} catch (err: any) {
|
|
486
587
|
if (cfg?.debug) console.error(err)
|
|
487
588
|
return errorResponse(c, err);
|
|
@@ -504,7 +605,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
504
605
|
}
|
|
505
606
|
await checkFileAccess(colServe?.api?.access as any, 'read', tenant_id, c);
|
|
506
607
|
|
|
507
|
-
//
|
|
608
|
+
// Flaw 18: validate the RAW path before basename (checks were dead code)
|
|
508
609
|
const rawFile = c.req.param('file') as string;
|
|
509
610
|
if (!rawFile || rawFile.startsWith('.') || rawFile.includes('..') || rawFile.includes('/') || rawFile.includes('\\')) {
|
|
510
611
|
throw new AppError('Invalid filename', { status: 400, code: 'INVALID_FILENAME' });
|
|
@@ -530,7 +631,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
530
631
|
c.header('Content-Type', result.mimetype);
|
|
531
632
|
if (result.size) c.header('Content-Length', String(result.size));
|
|
532
633
|
c.header('Cache-Control', 'public, max-age=31536000, immutable');
|
|
533
|
-
//
|
|
634
|
+
// Flaw 4: SVG served as attachment (no inline execution)
|
|
534
635
|
if (result.attachment) {
|
|
535
636
|
c.header('Content-Disposition', `attachment; filename="${filename}"`);
|
|
536
637
|
}
|
|
@@ -573,7 +674,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
573
674
|
// ─── Public config ──────────────────────────────────────────────────
|
|
574
675
|
app.get('/_dnax/config/:tenant_id', (c) => {
|
|
575
676
|
const t = c.get('token');
|
|
576
|
-
//
|
|
677
|
+
// Flaw 6: require a VERIFIED, non-expired token (not just any provided value)
|
|
577
678
|
if (!t?.decoded || t?.expired || !t?.provided) {
|
|
578
679
|
return c.json({ message: 'Authentication required', code: 'AUTH_REQUIRED' }, 401);
|
|
579
680
|
}
|