wendkeep 0.87.0 → 0.88.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/CHANGELOG.md +18 -0
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/docs/en/commands/observer-security.md +154 -0
- package/docs/en/commands/observer.md +30 -12
- package/docs/pt-BR/commands/observer-security.md +154 -0
- package/docs/pt-BR/commands/observer.md +30 -12
- package/hooks/observer-publish.mjs +3 -1
- package/package.json +2 -1
- package/packages/mcp/src/executor.mjs +35 -2
- package/packages/observer/package.json +16 -0
- package/packages/observer/src/audit.mjs +1 -0
- package/packages/observer/src/authz.mjs +38 -0
- package/packages/observer/src/encryption.mjs +75 -0
- package/packages/observer/src/index.mjs +7 -0
- package/packages/observer/src/policy.mjs +305 -0
- package/packages/observer/src/purge.mjs +100 -0
- package/packages/observer/src/redaction.mjs +54 -0
- package/packages/observer/src/retention.mjs +39 -0
- package/packages/observer/src/token-registry.mjs +122 -0
- package/schema/observer/006-observer-security.sql +64 -0
- package/schema/observer-policy-v1.schema.json +63 -0
- package/schema/sync-event-v1.schema.json +10 -0
- package/src/observer-auth.mjs +8 -0
- package/src/observer-privacy.mjs +7 -3
- package/src/observer-publish.mjs +31 -0
- package/src/observer-server.mjs +179 -20
- package/src/observer-sql-migrate.mjs +5 -2
- package/src/observer-sql-publish.mjs +114 -39
- package/src/observer-sql-store.mjs +299 -45
- package/src/observer-transcript-store.mjs +23 -8
- package/src/observer.mjs +145 -12
- package/src/sync-protocol.mjs +20 -0
- package/web/observer/app.mjs +107 -31
- package/web/observer/index.html +7 -0
- package/web/observer/styles.css +5 -0
package/src/observer-server.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
-
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
3
2
|
import { readFileSync } from 'node:fs';
|
|
4
3
|
import { fileURLToPath } from 'node:url';
|
|
5
4
|
import { gunzipSync } from 'node:zlib';
|
|
@@ -12,9 +11,8 @@ import { validateMemoryEvent } from './observer-memory.mjs';
|
|
|
12
11
|
import {
|
|
13
12
|
OBSERVER_SQL_FILE,
|
|
14
13
|
OBSERVER_SQL_SCHEMA_VERSION,
|
|
15
|
-
|
|
14
|
+
bootstrapObserverDatabase,
|
|
16
15
|
ingestObserverEvents,
|
|
17
|
-
migrateObserverDatabase,
|
|
18
16
|
readSqlProject,
|
|
19
17
|
readSqlProjectOverview,
|
|
20
18
|
readSqlProjectSnapshot,
|
|
@@ -32,6 +30,11 @@ import {
|
|
|
32
30
|
upsertSqlProjectSnapshot,
|
|
33
31
|
} from './observer-sql-store.mjs';
|
|
34
32
|
import { migrateObserverContainerData } from './observer-sql-migrate.mjs';
|
|
33
|
+
import { authorizeObserverPrincipal, recordObserverAudit } from '../packages/observer/src/authz.mjs';
|
|
34
|
+
import { ensureObserverBootstrapToken, resolveObserverPrincipal } from '../packages/observer/src/token-registry.mjs';
|
|
35
|
+
import { readObserverPolicy, saveObserverPolicy } from '../packages/observer/src/policy.mjs';
|
|
36
|
+
import { purgeObserverData } from '../packages/observer/src/purge.mjs';
|
|
37
|
+
import { runObserverRetention } from '../packages/observer/src/retention.mjs';
|
|
35
38
|
|
|
36
39
|
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
37
40
|
const MAX_BODY_BYTES = MAX_SNAPSHOT_BYTES + 4096;
|
|
@@ -50,13 +53,6 @@ function loopbackOnly(host) {
|
|
|
50
53
|
return LOOPBACK_HOSTS.has(String(host || '').toLowerCase());
|
|
51
54
|
}
|
|
52
55
|
|
|
53
|
-
function safeTokenEqual(actual, expected) {
|
|
54
|
-
if (!actual || !expected) return false;
|
|
55
|
-
const left = createHash('sha256').update(String(actual)).digest();
|
|
56
|
-
const right = createHash('sha256').update(String(expected)).digest();
|
|
57
|
-
return timingSafeEqual(left, right);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
56
|
function bearerToken(req) {
|
|
61
57
|
const match = String(req.headers.authorization || '').match(/^Bearer\s+(.+)$/i);
|
|
62
58
|
return match?.[1] || '';
|
|
@@ -173,6 +169,32 @@ function projectIdFrom(parts) {
|
|
|
173
169
|
return parts[0] === 'v1' && parts[1] === 'projects' && parts[2] ? parts[2] : '';
|
|
174
170
|
}
|
|
175
171
|
|
|
172
|
+
function observerEndpointCapability(method, parts) {
|
|
173
|
+
const action = String(method || 'GET').toUpperCase();
|
|
174
|
+
const resource = parts[3] || '';
|
|
175
|
+
if (parts.length === 2 && action === 'GET') return 'project:read';
|
|
176
|
+
if (parts.length === 3) return action === 'GET' ? 'project:read' : 'project:write';
|
|
177
|
+
if (resource === 'ingest') return 'ingest:write';
|
|
178
|
+
if (resource === 'usage') {
|
|
179
|
+
if (parts[4] === 'calls') return 'usage:calls:read';
|
|
180
|
+
if (parts[4] === 'breakdown') return 'usage:breakdown:read';
|
|
181
|
+
return 'usage:summary:read';
|
|
182
|
+
}
|
|
183
|
+
if (resource === 'transcripts') return 'transcript:read';
|
|
184
|
+
if (resource === 'sync') return action === 'GET' ? 'sync:read' : 'sync:write';
|
|
185
|
+
if (resource === 'memory') {
|
|
186
|
+
if (action !== 'GET') return 'memory:write';
|
|
187
|
+
return parts[4] === 'tree' ? 'memory:metadata:read' : 'memory:content:read';
|
|
188
|
+
}
|
|
189
|
+
if (resource === 'snapshot' || resource === 'snapshots') return 'snapshot:write';
|
|
190
|
+
if (resource === 'security') return 'security:admin';
|
|
191
|
+
return 'project:read';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function sensitiveCapability(capability) {
|
|
195
|
+
return ['usage:calls:read', 'transcript:read', 'memory:content:read', 'audit:read', 'security:admin'].includes(capability);
|
|
196
|
+
}
|
|
197
|
+
|
|
176
198
|
function ensureSqlProjectRegistration(dataDir, sqlDb, projectId) {
|
|
177
199
|
try {
|
|
178
200
|
if (readSqlProject(sqlDb, projectId)) return true;
|
|
@@ -215,7 +237,9 @@ export async function startObserverServer({
|
|
|
215
237
|
port = 8787,
|
|
216
238
|
dataDir,
|
|
217
239
|
allowNonLoopback = false,
|
|
218
|
-
token =
|
|
240
|
+
token = '',
|
|
241
|
+
bootstrap = {},
|
|
242
|
+
security = {},
|
|
219
243
|
} = {}) {
|
|
220
244
|
if (!loopbackOnly(host) && !allowNonLoopback) {
|
|
221
245
|
throw new Error(`Observer HTTP aceita somente host loopback; recebido: ${host}`);
|
|
@@ -226,9 +250,30 @@ export async function startObserverServer({
|
|
|
226
250
|
throw error;
|
|
227
251
|
}
|
|
228
252
|
if (!dataDir) throw new Error('dataDir é obrigatório.');
|
|
229
|
-
const sqlDb =
|
|
230
|
-
|
|
231
|
-
|
|
253
|
+
const { db: sqlDb, databaseMigration, protectedDataMigration } = bootstrapObserverDatabase(dataDir, { security: {
|
|
254
|
+
policy: security.policy || null,
|
|
255
|
+
encryption: security.encryption || null,
|
|
256
|
+
enforcePolicy: Boolean(security.enabled),
|
|
257
|
+
} });
|
|
258
|
+
const bootstrapToken = token
|
|
259
|
+
? ensureObserverBootstrapToken(sqlDb, {
|
|
260
|
+
token,
|
|
261
|
+
tokenId: bootstrap.tokenId,
|
|
262
|
+
role: bootstrap.role,
|
|
263
|
+
projectIds: bootstrap.projectIds,
|
|
264
|
+
scopes: bootstrap.scopes,
|
|
265
|
+
expiresAt: bootstrap.expiresAt,
|
|
266
|
+
now: security.clock?.().toISOString?.() || new Date().toISOString(),
|
|
267
|
+
})
|
|
268
|
+
: null;
|
|
269
|
+
const legacyMigration = migrateObserverContainerData(dataDir, {
|
|
270
|
+
database: sqlDb,
|
|
271
|
+
security: {
|
|
272
|
+
policy: security.policy || null,
|
|
273
|
+
encryption: security.encryption || null,
|
|
274
|
+
enforcePolicy: Boolean(security.enabled),
|
|
275
|
+
},
|
|
276
|
+
});
|
|
232
277
|
const registered = [
|
|
233
278
|
...listRegisteredObserverProjects(dataDir),
|
|
234
279
|
...readObserverIndexSource(dataDir).projects.map((item) => ({
|
|
@@ -249,12 +294,7 @@ export async function startObserverServer({
|
|
|
249
294
|
errorResponse(res, authority.status, authority.code, authority.message);
|
|
250
295
|
return;
|
|
251
296
|
}
|
|
252
|
-
const authenticated = safeTokenEqual(bearerToken(req), token);
|
|
253
297
|
const mutating = !['GET', 'HEAD', 'OPTIONS'].includes(String(req.method || '').toUpperCase());
|
|
254
|
-
if ((mutating || !loopbackOnly(host)) && !authenticated) {
|
|
255
|
-
errorResponse(res, 401, 'observer_auth_required', 'Bearer token válido é obrigatório para esta operação.');
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
298
|
if (req.method === 'GET' && pathname === '/healthz') {
|
|
259
299
|
json(res, 200, {
|
|
260
300
|
ok: true,
|
|
@@ -265,9 +305,17 @@ export async function startObserverServer({
|
|
|
265
305
|
file: OBSERVER_SQL_FILE,
|
|
266
306
|
schema_version: OBSERVER_SQL_SCHEMA_VERSION,
|
|
267
307
|
migrations: databaseMigration.applied.length,
|
|
308
|
+
protected_data_migration: protectedDataMigration,
|
|
268
309
|
legacy_migration: legacyMigration,
|
|
269
310
|
ready: true,
|
|
270
311
|
},
|
|
312
|
+
bootstrap: bootstrapToken ? {
|
|
313
|
+
token_id: bootstrapToken.token_id,
|
|
314
|
+
role: bootstrapToken.role,
|
|
315
|
+
project_ids: bootstrapToken.project_ids,
|
|
316
|
+
expires_at: bootstrapToken.expires_at,
|
|
317
|
+
active: !bootstrapToken.revoked && !bootstrapToken.expired,
|
|
318
|
+
} : null,
|
|
271
319
|
});
|
|
272
320
|
return;
|
|
273
321
|
}
|
|
@@ -285,10 +333,57 @@ export async function startObserverServer({
|
|
|
285
333
|
return;
|
|
286
334
|
}
|
|
287
335
|
|
|
336
|
+
let requestPrincipal = null;
|
|
337
|
+
{
|
|
338
|
+
const projectId = projectIdFrom(parts);
|
|
339
|
+
const capability = observerEndpointCapability(req.method, parts);
|
|
340
|
+
const suppliedToken = bearerToken(req);
|
|
341
|
+
const mustAuthenticate = mutating || !loopbackOnly(host) || Boolean(security.requireLoopbackAuth)
|
|
342
|
+
|| sensitiveCapability(capability) || Boolean(suppliedToken);
|
|
343
|
+
const authorizationActive = Boolean(security.enabled) || mustAuthenticate;
|
|
344
|
+
const principal = resolveObserverPrincipal(sqlDb, suppliedToken, {
|
|
345
|
+
now: security.clock?.().toISOString?.() || new Date().toISOString(),
|
|
346
|
+
});
|
|
347
|
+
if (!principal.ok && authorizationActive) {
|
|
348
|
+
if (projectId && sensitiveCapability(capability)) recordObserverAudit(sqlDb, {
|
|
349
|
+
projectId,
|
|
350
|
+
tokenId: principal.token_id || '',
|
|
351
|
+
capability,
|
|
352
|
+
outcome: 'denied',
|
|
353
|
+
occurredAt: security.clock?.().toISOString?.() || new Date().toISOString(),
|
|
354
|
+
metadata: { route: pathname, method: req.method || 'GET', reason: principal.code || 'observer_auth_required' },
|
|
355
|
+
});
|
|
356
|
+
const authenticationCode = principal.code === 'observer_token_missing'
|
|
357
|
+
? 'observer_auth_required'
|
|
358
|
+
: principal.code || 'observer_auth_required';
|
|
359
|
+
errorResponse(res, 401, authenticationCode, 'Bearer token válido é obrigatório para esta operação.');
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (principal.ok && authorizationActive) {
|
|
363
|
+
const authorized = authorizeObserverPrincipal(principal, { projectId: projectId || principal.project_ids?.[0] || '*', capability });
|
|
364
|
+
if (!authorized.ok) {
|
|
365
|
+
if (projectId) recordObserverAudit(sqlDb, {
|
|
366
|
+
projectId, tokenId: principal.token_id, capability, outcome: 'denied',
|
|
367
|
+
occurredAt: security.clock?.().toISOString?.() || new Date().toISOString(),
|
|
368
|
+
metadata: { route: pathname, method: req.method || 'GET', reason: authorized.code },
|
|
369
|
+
});
|
|
370
|
+
errorResponse(res, authorized.status, authorized.code, 'Token sem autorização para este projeto/capability.');
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
requestPrincipal = principal;
|
|
374
|
+
if (projectId && sensitiveCapability(capability)) recordObserverAudit(sqlDb, {
|
|
375
|
+
projectId, tokenId: principal.token_id, capability, outcome: 'allowed',
|
|
376
|
+
occurredAt: security.clock?.().toISOString?.() || new Date().toISOString(),
|
|
377
|
+
metadata: { route: pathname, method: req.method || 'GET' },
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
288
382
|
if (parts.length === 2 && req.method === 'GET') {
|
|
289
383
|
json(res, 200, {
|
|
290
384
|
schema_version: 1,
|
|
291
385
|
projects: listSqlProjects(sqlDb)
|
|
386
|
+
.filter((project) => !requestPrincipal || requestPrincipal.project_ids?.includes('*') || requestPrincipal.project_ids?.includes(project.project_id))
|
|
292
387
|
.map((project) => readSqlProjectOverview(sqlDb, project.project_id))
|
|
293
388
|
.sort((a, b) => a.projectId.localeCompare(b.projectId)),
|
|
294
389
|
});
|
|
@@ -361,6 +456,66 @@ export async function startObserverServer({
|
|
|
361
456
|
return;
|
|
362
457
|
}
|
|
363
458
|
|
|
459
|
+
if (parts[3] === 'security') {
|
|
460
|
+
if (!ensureSqlProjectRegistration(dataDir, sqlDb, projectId)) {
|
|
461
|
+
errorResponse(res, 404, 'project_not_found', 'projeto não encontrado: ' + projectId);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (parts.length === 4 && req.method === 'GET') {
|
|
465
|
+
const currentTime = security.clock?.().toISOString?.() || new Date().toISOString();
|
|
466
|
+
const tokens = sqlDb.prepare(`SELECT
|
|
467
|
+
COUNT(*) AS total,
|
|
468
|
+
SUM(CASE WHEN revoked_at IS NULL AND expires_at > ? THEN 1 ELSE 0 END) AS active,
|
|
469
|
+
SUM(CASE WHEN revoked_at IS NOT NULL THEN 1 ELSE 0 END) AS revoked,
|
|
470
|
+
SUM(CASE WHEN revoked_at IS NULL AND expires_at <= ? THEN 1 ELSE 0 END) AS expired
|
|
471
|
+
FROM observer_tokens WHERE project_ids_json LIKE ? OR project_ids_json LIKE '%"*"%'`)
|
|
472
|
+
.get(currentTime, currentTime, `%"${projectId}"%`);
|
|
473
|
+
const recentAudit = sqlDb.prepare(`SELECT audit_id, token_id, capability, outcome, occurred_at, metadata_json
|
|
474
|
+
FROM observer_access_audit WHERE project_id = ? ORDER BY occurred_at DESC LIMIT 50`).all(projectId)
|
|
475
|
+
.map((row) => ({ ...row, metadata: parseJson(row.metadata_json), metadata_json: undefined }));
|
|
476
|
+
json(res, 200, {
|
|
477
|
+
schema_version: 1,
|
|
478
|
+
project_id: projectId,
|
|
479
|
+
policy: readObserverPolicy(sqlDb, projectId),
|
|
480
|
+
tokens: { total: Number(tokens.total) || 0, active: Number(tokens.active) || 0, revoked: Number(tokens.revoked) || 0, expired: Number(tokens.expired) || 0 },
|
|
481
|
+
audit: recentAudit,
|
|
482
|
+
encryption: { configured: Boolean(security.encryption), required: Boolean(security.encryption?.required), key_id: security.encryption?.keyId || '' },
|
|
483
|
+
});
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (parts.length === 5 && parts[4] === 'policy' && req.method === 'PUT') {
|
|
487
|
+
const body = parseJson(await readBody(req));
|
|
488
|
+
json(res, 200, { schema_version: 1, project_id: projectId, policy: saveObserverPolicy(sqlDb, projectId, body) });
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (parts.length === 5 && parts[4] === 'purge' && req.method === 'POST') {
|
|
492
|
+
const body = parseJson(await readBody(req));
|
|
493
|
+
const result = purgeObserverData(sqlDb, {
|
|
494
|
+
projectId,
|
|
495
|
+
before: body.before,
|
|
496
|
+
classes: body.classes,
|
|
497
|
+
dryRun: body.dry_run === true,
|
|
498
|
+
operationId: body.operation_id || '',
|
|
499
|
+
now: security.clock?.().toISOString?.() || new Date().toISOString(),
|
|
500
|
+
});
|
|
501
|
+
json(res, body.dry_run === true ? 200 : 201, result);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (parts.length === 5 && parts[4] === 'retention' && req.method === 'POST') {
|
|
505
|
+
const body = parseJson(await readBody(req));
|
|
506
|
+
const storedPolicy = readObserverPolicy(sqlDb, projectId);
|
|
507
|
+
const result = runObserverRetention(sqlDb, {
|
|
508
|
+
projectId,
|
|
509
|
+
policy: storedPolicy.retention,
|
|
510
|
+
clock: () => security.clock?.() || new Date(),
|
|
511
|
+
dryRun: body.dry_run === true,
|
|
512
|
+
operationId: body.operation_id || '',
|
|
513
|
+
});
|
|
514
|
+
json(res, body.dry_run === true ? 200 : 201, result);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
364
519
|
if (parts.length === 4 && parts[3] === 'sync' && req.method === 'GET') {
|
|
365
520
|
if (!ensureSqlProjectRegistration(dataDir, sqlDb, projectId)) {
|
|
366
521
|
errorResponse(res, 404, 'project_not_found', 'projeto não encontrado: ' + projectId);
|
|
@@ -511,7 +666,11 @@ export async function startObserverServer({
|
|
|
511
666
|
errorResponse(res, 404, 'not_found', 'rota não encontrada.');
|
|
512
667
|
} catch (error) {
|
|
513
668
|
if (res.headersSent) return;
|
|
514
|
-
const status = error?.code === 'payload_too_large'
|
|
669
|
+
const status = error?.code === 'payload_too_large'
|
|
670
|
+
? 413
|
|
671
|
+
: ['invalid_json', 'invalid_content_encoding', 'observer_policy_invalid', 'observer_purge_invalid', 'observer_retention_invalid'].includes(error?.code)
|
|
672
|
+
? 400
|
|
673
|
+
: 500;
|
|
515
674
|
errorResponse(res, status, error?.code || 'observer_error', error?.message || 'erro interno do Observer.');
|
|
516
675
|
}
|
|
517
676
|
});
|
|
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from '
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { parseSessionCost } from './cost.mjs';
|
|
5
5
|
import {
|
|
6
|
+
configureObserverDatabaseSecurity,
|
|
6
7
|
ensureObserverDatabase,
|
|
7
8
|
ingestObserverEvents,
|
|
8
9
|
registerSqlProject,
|
|
@@ -237,11 +238,12 @@ export function sessionEvents({ projectId, logicalPath, content, cost, revision
|
|
|
237
238
|
return { events, sessionId, rollups: events.filter((event) => event.kind === 'usage.rollup').length, summaryOnly: transcriptId ? 1 : 0 };
|
|
238
239
|
}
|
|
239
240
|
|
|
240
|
-
export function migrateObserverData({ dataDir, vaultBase, projectId, projectName = projectId, transcriptSources = {}, database = null } = {}) {
|
|
241
|
+
export function migrateObserverData({ dataDir, vaultBase, projectId, projectName = projectId, transcriptSources = {}, database = null, security = null } = {}) {
|
|
241
242
|
if (!dataDir || !vaultBase || !projectId) throw new Error('dataDir, vaultBase e projectId são obrigatórios.');
|
|
242
243
|
const db = database || ensureObserverDatabase(dataDir);
|
|
243
244
|
const ownsDatabase = !database;
|
|
244
245
|
try {
|
|
246
|
+
if (security) configureObserverDatabaseSecurity(db, security);
|
|
245
247
|
registerSqlProject(db, { projectId, projectName });
|
|
246
248
|
const stats = { project_id: projectId, documents: 0, sessions: 0, rollups: 0, summary_only_transcripts: 0, accepted: 0, duplicates: 0, conflicts: 0, rejected: 0 };
|
|
247
249
|
const sourceEvents = readMemoryEvents(dataDir, projectId);
|
|
@@ -298,7 +300,7 @@ export function migrateObserverData({ dataDir, vaultBase, projectId, projectName
|
|
|
298
300
|
} finally { if (ownsDatabase) db.close(); }
|
|
299
301
|
}
|
|
300
302
|
|
|
301
|
-
export function migrateObserverContainerData(dataDir, { database = null } = {}) {
|
|
303
|
+
export function migrateObserverContainerData(dataDir, { database = null, security = null } = {}) {
|
|
302
304
|
const memoryRoot = join(dataDir, 'memory');
|
|
303
305
|
if (!existsSync(memoryRoot)) return { skipped: true, projects: 0, documents: 0, events: 0 };
|
|
304
306
|
const markerPath = join(dataDir, 'observer-sql-legacy-migration.json');
|
|
@@ -321,6 +323,7 @@ export function migrateObserverContainerData(dataDir, { database = null } = {})
|
|
|
321
323
|
projectId,
|
|
322
324
|
projectName: project.project_name || projectId,
|
|
323
325
|
database,
|
|
326
|
+
security,
|
|
324
327
|
});
|
|
325
328
|
stats.projects += 1;
|
|
326
329
|
stats.documents += result.documents;
|