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.
Files changed (36) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.en.md +1 -1
  3. package/README.md +1 -1
  4. package/docs/en/commands/observer-security.md +154 -0
  5. package/docs/en/commands/observer.md +30 -12
  6. package/docs/pt-BR/commands/observer-security.md +154 -0
  7. package/docs/pt-BR/commands/observer.md +30 -12
  8. package/hooks/observer-publish.mjs +3 -1
  9. package/package.json +2 -1
  10. package/packages/mcp/src/executor.mjs +35 -2
  11. package/packages/observer/package.json +16 -0
  12. package/packages/observer/src/audit.mjs +1 -0
  13. package/packages/observer/src/authz.mjs +38 -0
  14. package/packages/observer/src/encryption.mjs +75 -0
  15. package/packages/observer/src/index.mjs +7 -0
  16. package/packages/observer/src/policy.mjs +305 -0
  17. package/packages/observer/src/purge.mjs +100 -0
  18. package/packages/observer/src/redaction.mjs +54 -0
  19. package/packages/observer/src/retention.mjs +39 -0
  20. package/packages/observer/src/token-registry.mjs +122 -0
  21. package/schema/observer/006-observer-security.sql +64 -0
  22. package/schema/observer-policy-v1.schema.json +63 -0
  23. package/schema/sync-event-v1.schema.json +10 -0
  24. package/src/observer-auth.mjs +8 -0
  25. package/src/observer-privacy.mjs +7 -3
  26. package/src/observer-publish.mjs +31 -0
  27. package/src/observer-server.mjs +179 -20
  28. package/src/observer-sql-migrate.mjs +5 -2
  29. package/src/observer-sql-publish.mjs +114 -39
  30. package/src/observer-sql-store.mjs +299 -45
  31. package/src/observer-transcript-store.mjs +23 -8
  32. package/src/observer.mjs +145 -12
  33. package/src/sync-protocol.mjs +20 -0
  34. package/web/observer/app.mjs +107 -31
  35. package/web/observer/index.html +7 -0
  36. package/web/observer/styles.css +5 -0
package/src/observer.mjs CHANGED
@@ -1,4 +1,6 @@
1
1
  import { homedir } from 'node:os';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
2
4
  import { isAbsolute, resolve } from 'node:path';
3
5
  import { readObserverIndexSource } from './observer-store.mjs';
4
6
  import { buildProjectSnapshot } from './observer-snapshot.mjs';
@@ -6,15 +8,23 @@ import { compareMemoryParity } from './observer-memory-publish.mjs';
6
8
  import { publishObserverSql } from './observer-sql-publish.mjs';
7
9
  import { migrateObserverData } from './observer-sql-migrate.mjs';
8
10
  import { startObserverServer } from './observer-server.mjs';
9
- import { ensureObserverDatabase, migrateObserverDatabase, listSqlProjects, registerSqlProject, upsertSqlProjectSnapshot, OBSERVER_SQL_FILE, OBSERVER_SQL_SCHEMA_VERSION } from './observer-sql-store.mjs';
11
+ import { ensureObserverDatabase, listSqlProjects, readSqlProject, registerSqlProject, upsertSqlProjectSnapshot, OBSERVER_SQL_FILE, OBSERVER_SQL_SCHEMA_VERSION } from './observer-sql-store.mjs';
10
12
  import { resolveProjectVault } from '../packages/vault/src/project-vault.mjs';
11
13
  import { observerAuthHeaders, resolveObserverToken } from './observer-auth.mjs';
14
+ import { recordObserverAudit } from '../packages/observer/src/authz.mjs';
15
+ import { readObserverPolicy, saveObserverPolicy } from '../packages/observer/src/policy.mjs';
16
+ import { purgeObserverData } from '../packages/observer/src/purge.mjs';
17
+ import { runObserverRetention } from '../packages/observer/src/retention.mjs';
18
+ import { registerObserverToken, revokeObserverToken, rotateObserverToken } from '../packages/observer/src/token-registry.mjs';
19
+ import { observerEncryptionFromEnvironment } from '../packages/observer/src/encryption.mjs';
12
20
 
13
21
  export const OBSERVER_HELP = `wendkeep observer — Observer local multi-projeto
14
22
 
15
23
  Uso:
16
24
  wendkeep observer serve [--data-dir P] [--host 127.0.0.1] [--port 8787]
17
- [--allow-non-loopback] [--token TOKEN]
25
+ [--allow-non-loopback] [--require-loopback-auth] [--require-encryption] [--token TOKEN]
26
+ [--bootstrap-token-id ID] [--bootstrap-role ROLE]
27
+ [--bootstrap-projects P1,P2] [--bootstrap-scopes S1,S2] --bootstrap-expires-at ISO
18
28
  wendkeep observer register --project P [--vault V] [--data-dir D] [--json]
19
29
  wendkeep observer publish --project P [--vault V] [--data-dir D] [--json]
20
30
  wendkeep observer reconcile --project P [--vault V] [--data-dir D] [--url U]
@@ -22,6 +32,14 @@ Uso:
22
32
  wendkeep observer memory import --project P [--vault V] [--url U] [--token TOKEN]
23
33
  [--capture-level metadata|messages|full-transcript] [--json]
24
34
  wendkeep observer status [--data-dir D] [--json]
35
+ wendkeep observer security token create --project-id P --role R --scopes S
36
+ --token-env ENV --expires-at ISO [--token-id ID] [--reason TEXT] [--json]
37
+ wendkeep observer security token rotate --project-id P --token-id ID --token-env ENV
38
+ --expires-at ISO [--new-token-id ID] [--reason TEXT] [--json]
39
+ wendkeep observer security token revoke --project-id P --token-id ID [--reason TEXT] [--json]
40
+ wendkeep observer security policy set --project-id P --file policy.json [--json]
41
+ wendkeep observer security purge --project-id P --before ISO --classes C [--dry-run] [--operation-id ID] [--json]
42
+ wendkeep observer security retention run --project-id P [--dry-run] [--operation-id ID] [--observed-at ISO] [--json]
25
43
 
26
44
  O Observer local pode manter snapshots operacionais e uma cópia completa da memória em volume
27
45
  Docker. O comando memory import faz a primeira migração de um vault para o container.
@@ -34,6 +52,10 @@ function optionValue(argv, name) {
34
52
  return argv.find((item) => item.startsWith(`${name}=`))?.slice(name.length + 1) || '';
35
53
  }
36
54
 
55
+ function csv(value) {
56
+ return String(value || '').split(',').map((item) => item.trim()).filter(Boolean);
57
+ }
58
+
37
59
  function dataDir(argv) {
38
60
  return resolve(optionValue(argv, '--data-dir')
39
61
  || process.env.WENDKEEP_OBSERVER_DATA_DIR
@@ -62,23 +84,22 @@ function summary(index) {
62
84
  };
63
85
  }
64
86
 
65
- function databaseSummary(dir) {
66
- const db = ensureObserverDatabase(dir);
87
+ function databaseSummary(dir, security) {
88
+ const db = ensureObserverDatabase(dir, { security });
67
89
  try {
68
- const migrations = migrateObserverDatabase(db);
69
90
  return {
70
91
  engine: 'sqlite',
71
92
  file: OBSERVER_SQL_FILE,
72
93
  schema_version: OBSERVER_SQL_SCHEMA_VERSION,
73
- migrations: migrations.applied.length,
94
+ migrations: Number(db.prepare('SELECT COUNT(*) AS count FROM schema_migrations').get().count),
74
95
  projects: listSqlProjects(db).length,
75
96
  ready: true,
76
97
  };
77
98
  } finally { db.close(); }
78
99
  }
79
100
 
80
- function sqlProjectsSummary(dir) {
81
- const db = ensureObserverDatabase(dir);
101
+ function sqlProjectsSummary(dir, security) {
102
+ const db = ensureObserverDatabase(dir, { security });
82
103
  try {
83
104
  return listSqlProjects(db).map((project) => ({
84
105
  projectId: project.project_id,
@@ -99,21 +120,133 @@ export async function runObserver(argv = [], { write = (chunk) => process.stdout
99
120
  }
100
121
  const dir = dataDir(argv);
101
122
  const token = resolveObserverToken(optionValue(argv, '--token'));
123
+ const databaseSecurity = {
124
+ encryption: observerEncryptionFromEnvironment({
125
+ required: argv.includes('--require-encryption') || process.env.WENDKEEP_OBSERVER_REQUIRE_ENCRYPTION === '1',
126
+ }),
127
+ };
128
+
129
+ if (sub === 'security') {
130
+ const domain = argv[1] || '';
131
+ const action = argv[2] || '';
132
+ const projectId = optionValue(argv, '--project-id');
133
+ if (!projectId) throw new Error('observer security: --project-id é obrigatório.');
134
+ const db = ensureObserverDatabase(dir, { security: databaseSecurity });
135
+ try {
136
+ readSqlProject(db, projectId);
137
+ if (domain === 'token' && action === 'create') {
138
+ const envName = optionValue(argv, '--token-env');
139
+ const rawToken = envName ? String(process.env[envName] || '') : '';
140
+ if (!envName || !rawToken) throw new Error('observer security token create: --token-env deve apontar para um segredo não vazio.');
141
+ const created = registerObserverToken(db, {
142
+ tokenId: optionValue(argv, '--token-id') || randomBytes(12).toString('hex'),
143
+ token: rawToken,
144
+ role: optionValue(argv, '--role'),
145
+ projectIds: [projectId],
146
+ scopes: optionValue(argv, '--scopes').split(',').map((item) => item.trim()).filter(Boolean),
147
+ expiresAt: optionValue(argv, '--expires-at'),
148
+ });
149
+ recordObserverAudit(db, {
150
+ projectId, tokenId: created.token_id, capability: 'security:recovery', outcome: 'created',
151
+ metadata: { route: 'offline-cli', method: 'LOCAL', reason: optionValue(argv, '--reason') || 'token create' },
152
+ });
153
+ print(created, asJson, write);
154
+ return 0;
155
+ }
156
+ if (domain === 'token' && action === 'revoke') {
157
+ const tokenId = optionValue(argv, '--token-id');
158
+ const revoked = revokeObserverToken(db, { tokenId });
159
+ recordObserverAudit(db, {
160
+ projectId, tokenId, capability: 'security:recovery', outcome: revoked.revoked ? 'revoked' : 'not-found',
161
+ metadata: { route: 'offline-cli', method: 'LOCAL', reason: optionValue(argv, '--reason') || 'token revoke' },
162
+ });
163
+ print(revoked, asJson, write);
164
+ return revoked.revoked ? 0 : 1;
165
+ }
166
+ if (domain === 'token' && action === 'rotate') {
167
+ const envName = optionValue(argv, '--token-env');
168
+ const rawToken = envName ? String(process.env[envName] || '') : '';
169
+ if (!envName || !rawToken) throw new Error('observer security token rotate: --token-env deve apontar para um segredo não vazio.');
170
+ const rotated = rotateObserverToken(db, {
171
+ tokenId: optionValue(argv, '--token-id'),
172
+ newTokenId: optionValue(argv, '--new-token-id') || randomBytes(12).toString('hex'),
173
+ newToken: rawToken,
174
+ expiresAt: optionValue(argv, '--expires-at'),
175
+ });
176
+ recordObserverAudit(db, {
177
+ projectId, tokenId: rotated.token_id, capability: 'security:recovery', outcome: 'rotated',
178
+ metadata: { route: 'offline-cli', method: 'LOCAL', reason: optionValue(argv, '--reason') || 'token rotate' },
179
+ });
180
+ print(rotated, asJson, write);
181
+ return 0;
182
+ }
183
+ if (domain === 'policy' && action === 'set') {
184
+ const path = optionValue(argv, '--file');
185
+ if (!path) throw new Error('observer security policy set: --file é obrigatório.');
186
+ const policy = saveObserverPolicy(db, projectId, JSON.parse(readFileSync(resolve(path), 'utf8')));
187
+ print({ schema_version: 1, project_id: projectId, policy }, asJson, write);
188
+ return 0;
189
+ }
190
+ if (domain === 'policy' && action === 'show') {
191
+ print({ schema_version: 1, project_id: projectId, policy: readObserverPolicy(db, projectId) }, asJson, write);
192
+ return 0;
193
+ }
194
+ if (domain === 'purge') {
195
+ const result = purgeObserverData(db, {
196
+ projectId,
197
+ before: optionValue(argv, '--before'),
198
+ classes: optionValue(argv, '--classes').split(',').map((item) => item.trim()).filter(Boolean),
199
+ operationId: optionValue(argv, '--operation-id'),
200
+ dryRun: argv.includes('--dry-run'),
201
+ });
202
+ print(result, asJson, write);
203
+ return 0;
204
+ }
205
+ if (domain === 'retention' && action === 'run') {
206
+ const policy = readObserverPolicy(db, projectId);
207
+ const observedAt = optionValue(argv, '--observed-at');
208
+ const result = runObserverRetention(db, {
209
+ projectId,
210
+ policy: policy.retention,
211
+ clock: () => observedAt ? new Date(observedAt) : new Date(),
212
+ operationId: optionValue(argv, '--operation-id'),
213
+ dryRun: argv.includes('--dry-run'),
214
+ });
215
+ print(result, asJson, write);
216
+ return 0;
217
+ }
218
+ throw new Error(`observer security: operação desconhecida: ${domain} ${action}`.trim());
219
+ } finally { db.close(); }
220
+ }
102
221
 
103
222
  if (sub === 'status') {
104
223
  const legacy = summary(readObserverIndexSource(dir));
105
- print({ ...legacy, projects: sqlProjectsSummary(dir), legacy_projects: legacy.projects, database: databaseSummary(dir) }, asJson, write);
224
+ print({ ...legacy, projects: sqlProjectsSummary(dir, databaseSecurity), legacy_projects: legacy.projects, database: databaseSummary(dir, databaseSecurity) }, asJson, write);
106
225
  return 0;
107
226
  }
108
227
 
109
228
  if (sub === 'serve') {
110
229
  const host = optionValue(argv, '--host') || '127.0.0.1';
230
+ const encryption = databaseSecurity.encryption;
231
+ const secureMode = argv.includes('--require-loopback-auth') || Boolean(encryption);
111
232
  const server = await startObserverServer({
112
233
  dataDir: dir,
113
234
  host,
114
235
  port: Number(optionValue(argv, '--port') || 8787),
115
236
  allowNonLoopback: argv.includes('--allow-non-loopback'),
116
237
  token,
238
+ bootstrap: {
239
+ tokenId: optionValue(argv, '--bootstrap-token-id') || process.env.WENDKEEP_OBSERVER_BOOTSTRAP_TOKEN_ID || '',
240
+ role: optionValue(argv, '--bootstrap-role') || process.env.WENDKEEP_OBSERVER_BOOTSTRAP_ROLE || 'admin',
241
+ projectIds: csv(optionValue(argv, '--bootstrap-projects') || process.env.WENDKEEP_OBSERVER_BOOTSTRAP_PROJECTS),
242
+ scopes: csv(optionValue(argv, '--bootstrap-scopes') || process.env.WENDKEEP_OBSERVER_BOOTSTRAP_SCOPES || '*'),
243
+ expiresAt: optionValue(argv, '--bootstrap-expires-at') || process.env.WENDKEEP_OBSERVER_BOOTSTRAP_EXPIRES_AT || '',
244
+ },
245
+ security: {
246
+ enabled: secureMode,
247
+ requireLoopbackAuth: argv.includes('--require-loopback-auth'),
248
+ encryption,
249
+ },
117
250
  });
118
251
  const address = server.address();
119
252
  process.stdout.write(`wendkeep observer listening: http://${address.address}:${address.port}\n`);
@@ -179,7 +312,7 @@ export async function runObserver(argv = [], { write = (chunk) => process.stdout
179
312
  const snapshot = buildProjectSnapshot({ vaultBase: vault, projectRoot: root });
180
313
  const url = optionValue(argv, '--url') || process.env.WENDKEEP_OBSERVER_URL || '';
181
314
  if (!url) {
182
- const db = ensureObserverDatabase(dir);
315
+ const db = ensureObserverDatabase(dir, { security: databaseSecurity });
183
316
  let migration;
184
317
  try {
185
318
  migration = migrateObserverData({
@@ -231,7 +364,7 @@ export async function runObserver(argv = [], { write = (chunk) => process.stdout
231
364
  const snapshot = buildProjectSnapshot({ vaultBase: vault, projectRoot: root });
232
365
 
233
366
  if (sub === 'register') {
234
- const db = ensureObserverDatabase(dir);
367
+ const db = ensureObserverDatabase(dir, { security: databaseSecurity });
235
368
  let sql;
236
369
  try {
237
370
  sql = registerSqlProject(db, {
@@ -245,7 +378,7 @@ export async function runObserver(argv = [], { write = (chunk) => process.stdout
245
378
  return 0;
246
379
  }
247
380
 
248
- const db = ensureObserverDatabase(dir);
381
+ const db = ensureObserverDatabase(dir, { security: databaseSecurity });
249
382
  let migration;
250
383
  try {
251
384
  migration = migrateObserverData({
@@ -73,9 +73,25 @@ function validatePrivatePayload(payload) {
73
73
  }
74
74
  }
75
75
 
76
+ function normalizePolicyRef(value) {
77
+ if (value == null) return null;
78
+ const keys = Object.keys(value).sort();
79
+ if (keys.join(',') !== 'hash,policy_id,version'
80
+ || !HASH_PATTERN.test(String(value.hash || ''))
81
+ || !Number.isSafeInteger(Number(value.version)) || Number(value.version) < 1) {
82
+ throw syncError('WENDKEEP_SYNC_POLICY_REF_INVALID', 'policy_ref must contain only policy_id, version and sha256 hash');
83
+ }
84
+ return {
85
+ policy_id: requiredText(value.policy_id, 'policy_ref.policy_id', 160),
86
+ version: Number(value.version),
87
+ hash: String(value.hash),
88
+ };
89
+ }
90
+
76
91
  export function createSyncEvent({
77
92
  projectId, recordKey, revision, baseRevision, payload = null, causalParentIds = [],
78
93
  actorId, deviceId, leaseId = '', observedAt, operation = 'put', privacy = 'shared',
94
+ policyRef = null,
79
95
  } = {}) {
80
96
  const project_id = requiredText(projectId, 'project_id', 160);
81
97
  const record_key = requiredText(recordKey, 'record_key', 2048);
@@ -108,6 +124,7 @@ export function createSyncEvent({
108
124
  observed_at: timestamp.toISOString(),
109
125
  operation,
110
126
  privacy,
127
+ ...(policyRef ? { policy_ref: normalizePolicyRef(policyRef) } : {}),
111
128
  payload: operation === 'tombstone' ? null : structuredClone(payload),
112
129
  };
113
130
  return { ...draft, event_id: syncSha256(draft).slice(7) };
@@ -137,6 +154,7 @@ export function validateSyncEvent(event, { projectId = '' } = {}) {
137
154
  observedAt: event.observed_at,
138
155
  operation: event.operation,
139
156
  privacy: event.privacy,
157
+ policyRef: event.policy_ref || null,
140
158
  });
141
159
  return event;
142
160
  }
@@ -153,6 +171,7 @@ function eventCandidate(event) {
153
171
  observed_at: event.observed_at,
154
172
  operation: event.operation,
155
173
  privacy: event.privacy,
174
+ ...(event.policy_ref ? { policy_ref: structuredClone(event.policy_ref) } : {}),
156
175
  payload: structuredClone(event.payload),
157
176
  };
158
177
  }
@@ -168,6 +187,7 @@ function recordFromEvent(event, { conflicted = false } = {}) {
168
187
  observed_at: event.observed_at,
169
188
  operation: event.operation,
170
189
  privacy: event.privacy,
190
+ ...(event.policy_ref ? { policy_ref: structuredClone(event.policy_ref) } : {}),
171
191
  payload: structuredClone(event.payload),
172
192
  tombstone: event.operation === 'tombstone',
173
193
  conflicted,
@@ -75,9 +75,15 @@ export function classifyRefreshError(error = {}, hasModels = false) {
75
75
  };
76
76
  }
77
77
 
78
- async function requestJson(fetchImpl, url) {
78
+ export function observerDashboardHeaders(token = '') {
79
+ return token
80
+ ? { Accept: 'application/json', Authorization: `Bearer ${String(token)}` }
81
+ : { Accept: 'application/json' };
82
+ }
83
+
84
+ async function requestJson(fetchImpl, url, token = '') {
79
85
  const response = await fetchImpl(url, {
80
- headers: { Accept: 'application/json' },
86
+ headers: observerDashboardHeaders(token),
81
87
  });
82
88
  if (!response.ok) {
83
89
  const error = new Error(`Observer respondeu HTTP ${response.status}.`);
@@ -87,11 +93,11 @@ async function requestJson(fetchImpl, url) {
87
93
  return response.json();
88
94
  }
89
95
 
90
- export async function loadProjectMemory(fetchImpl = globalThis.fetch, projectId = '') {
96
+ export async function loadProjectMemory(fetchImpl = globalThis.fetch, projectId = '', token = '') {
91
97
  const id = encodeURIComponent(projectId);
92
98
  const [tree, sync] = await Promise.all([
93
- requestJson(fetchImpl, '/v1/projects/' + id + '/memory/tree'),
94
- requestJson(fetchImpl, '/v1/projects/' + id + '/sync'),
99
+ requestJson(fetchImpl, '/v1/projects/' + id + '/memory/tree', token),
100
+ requestJson(fetchImpl, '/v1/projects/' + id + '/sync', token),
95
101
  ]);
96
102
  return { tree, sync };
97
103
  }
@@ -107,13 +113,13 @@ export function usageQuery(filters = {}) {
107
113
  return query ? `?${query}` : '';
108
114
  }
109
115
 
110
- export async function loadProjectUsage(fetchImpl = globalThis.fetch, projectId = '', filters = {}) {
116
+ export async function loadProjectUsage(fetchImpl = globalThis.fetch, projectId = '', filters = {}, token = '') {
111
117
  const id = encodeURIComponent(projectId);
112
118
  const query = usageQuery(filters);
113
119
  const [summary, breakdown, calls] = await Promise.all([
114
- requestJson(fetchImpl, `/v1/projects/${id}/usage/summary${query}`),
115
- requestJson(fetchImpl, `/v1/projects/${id}/usage/breakdown${query}`),
116
- requestJson(fetchImpl, `/v1/projects/${id}/usage/calls${query}`),
120
+ requestJson(fetchImpl, `/v1/projects/${id}/usage/summary${query}`, token),
121
+ requestJson(fetchImpl, `/v1/projects/${id}/usage/breakdown${query}`, token),
122
+ requestJson(fetchImpl, `/v1/projects/${id}/usage/calls${query}`, token),
117
123
  ]);
118
124
  return { summary, breakdown, calls, filters: { ...filters } };
119
125
  }
@@ -163,29 +169,53 @@ export function buildUsageViewModel(usage = {}) {
163
169
  };
164
170
  }
165
171
 
166
- export async function loadMemoryDocument(fetchImpl = globalThis.fetch, projectId = '', logicalPath = '') {
172
+ export async function loadMemoryDocument(fetchImpl = globalThis.fetch, projectId = '', logicalPath = '', token = '') {
167
173
  const query = new URLSearchParams({ path: logicalPath });
168
- return requestJson(fetchImpl, '/v1/projects/' + encodeURIComponent(projectId) + '/memory/document?' + query.toString());
174
+ return requestJson(fetchImpl, '/v1/projects/' + encodeURIComponent(projectId) + '/memory/document?' + query.toString(), token);
169
175
  }
170
176
 
171
- export async function loadProjectTranscript(fetchImpl = globalThis.fetch, projectId = '', transcriptId = '') {
172
- return requestJson(fetchImpl, `/v1/projects/${encodeURIComponent(projectId)}/transcripts/${encodeURIComponent(transcriptId)}`);
177
+ export async function loadProjectTranscript(fetchImpl = globalThis.fetch, projectId = '', transcriptId = '', token = '') {
178
+ return requestJson(fetchImpl, `/v1/projects/${encodeURIComponent(projectId)}/transcripts/${encodeURIComponent(transcriptId)}`, token);
173
179
  }
174
180
 
175
- export async function searchProjectMemory(fetchImpl = globalThis.fetch, projectId = '', query = '') {
181
+ export async function searchProjectMemory(fetchImpl = globalThis.fetch, projectId = '', query = '', token = '') {
176
182
  const params = new URLSearchParams({ q: query });
177
- return requestJson(fetchImpl, '/v1/projects/' + encodeURIComponent(projectId) + '/memory/search?' + params.toString());
183
+ return requestJson(fetchImpl, '/v1/projects/' + encodeURIComponent(projectId) + '/memory/search?' + params.toString(), token);
178
184
  }
179
185
 
180
- export async function loadDashboardData(fetchImpl = globalThis.fetch) {
181
- const index = await requestJson(fetchImpl, '/v1/projects');
186
+ export async function loadDashboardData(fetchImpl = globalThis.fetch, token = '') {
187
+ const index = await requestJson(fetchImpl, '/v1/projects', token);
182
188
  const projects = Array.isArray(index?.projects) ? index.projects : [];
183
189
  return Promise.all(projects.map(async (summary) => {
184
- const detail = await requestJson(fetchImpl, `/v1/projects/${encodeURIComponent(summary.projectId)}`);
190
+ const detail = await requestJson(fetchImpl, `/v1/projects/${encodeURIComponent(summary.projectId)}`, token);
185
191
  return buildProjectViewModel(summary, detail, new Date());
186
192
  }));
187
193
  }
188
194
 
195
+ export async function loadProjectSecurity(fetchImpl = globalThis.fetch, projectId = '', token = '') {
196
+ return requestJson(fetchImpl, `/v1/projects/${encodeURIComponent(projectId)}/security`, token);
197
+ }
198
+
199
+ export async function loadMemoryExport(fetchImpl = globalThis.fetch, projectId = '', token = '') {
200
+ return requestJson(fetchImpl, `/v1/projects/${encodeURIComponent(projectId)}/memory/export`, token);
201
+ }
202
+
203
+ export function buildObserverSecurityViewModel(payload = {}) {
204
+ return {
205
+ tokenCount: Number(payload.tokens?.total || 0),
206
+ activeTokens: Number(payload.tokens?.active || 0),
207
+ revokedTokens: Number(payload.tokens?.revoked || 0),
208
+ encryptionRequired: payload.encryption?.required === true,
209
+ encryptionConfigured: payload.encryption?.configured === true,
210
+ policy: payload.policy || {},
211
+ recentAudit: (Array.isArray(payload.audit) ? payload.audit : []).map((row) => ({
212
+ capability: String(row.capability || ''),
213
+ outcome: String(row.outcome || ''),
214
+ occurredAt: String(row.occurred_at || ''),
215
+ })),
216
+ };
217
+ }
218
+
189
219
  export function buildProjectViewModel(summary = {}, detail = {}, now = new Date()) {
190
220
  const snapshot = detail.snapshot || {};
191
221
  const session = snapshot.session || {};
@@ -455,7 +485,7 @@ function renderChanges(container, projectId, documents) {
455
485
  container.replaceChildren(heading, list);
456
486
  }
457
487
 
458
- function renderSync(container, sync, projectId = '') {
488
+ function renderSync(container, sync, projectId = '', onExport = null) {
459
489
  const heading = node('div', 'workspace-section-heading');
460
490
  heading.append(node('p', 'eyebrow', 'SYNC CONTROL'), node('h2', '', 'Sincronização'));
461
491
  const facts = node('div', 'detail-facts');
@@ -465,11 +495,35 @@ function renderSync(container, sync, projectId = '') {
465
495
  fact('Conflitos', sync?.conflict_count || 0),
466
496
  );
467
497
  const note = node('div', 'sync-callout', sync?.conflict_count ? 'Existem conflitos que exigem revisão.' : 'A memória local está acompanhando o container.');
468
- const exportLink = node('a', 'workspace-open', 'Exportar cópia read-only →');
469
- exportLink.href = '/v1/projects/' + encodeURIComponent(projectId) + '/memory/export';
470
- exportLink.target = '_blank';
471
- exportLink.rel = 'noopener';
472
- container.replaceChildren(heading, facts, note, exportLink);
498
+ const exportButton = node('button', 'workspace-open', 'Exportar cópia sanitizada →');
499
+ exportButton.type = 'button';
500
+ exportButton.addEventListener('click', async () => {
501
+ try {
502
+ const payload = await onExport?.();
503
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
504
+ const url = URL.createObjectURL(blob);
505
+ const link = document.createElement('a');
506
+ link.href = url;
507
+ link.download = `wendkeep-observer-${projectId}.json`;
508
+ link.click();
509
+ URL.revokeObjectURL(url);
510
+ } catch { exportButton.textContent = 'Exportação não autorizada'; }
511
+ });
512
+ container.replaceChildren(heading, facts, note, exportButton);
513
+ }
514
+
515
+ function renderSecurity(container, payload) {
516
+ const view = buildObserverSecurityViewModel(payload);
517
+ const heading = node('div', 'workspace-section-heading');
518
+ heading.append(node('p', 'eyebrow', 'OBSERVER SECURITY'), node('h2', '', 'Política e acesso'));
519
+ const grid = node('div', 'detail-facts');
520
+ grid.append(
521
+ fact('Tokens ativos', view.activeTokens),
522
+ fact('Tokens revogados', view.revokedTokens),
523
+ fact('Criptografia', view.encryptionConfigured ? (view.encryptionRequired ? 'obrigatória' : 'configurada') : 'não configurada'),
524
+ );
525
+ const policy = node('pre', 'usage-transcript', JSON.stringify(view.policy, null, 2));
526
+ container.replaceChildren(heading, grid, node('h3', '', 'Política efetiva'), policy);
473
527
  }
474
528
 
475
529
  function formatNumber(value) {
@@ -670,6 +724,7 @@ function startDashboardV2() {
670
724
  memory: new Map(),
671
725
  usage: new Map(),
672
726
  usageFilters: new Map(),
727
+ token: '',
673
728
  route: parseObserverRoute(globalThis.location?.hash || ''),
674
729
  };
675
730
  const dashboardError = byId('dashboard-error');
@@ -726,12 +781,12 @@ function startDashboardV2() {
726
781
  workspaceContent.replaceChildren(node('div', 'workspace-error', message || 'Não foi possível carregar a memória.'));
727
782
  };
728
783
  const ensureProjectMemory = async (projectId) => {
729
- if (!state.memory.has(projectId)) state.memory.set(projectId, await loadProjectMemory(fetchJson, projectId));
784
+ if (!state.memory.has(projectId)) state.memory.set(projectId, await loadProjectMemory(fetchJson, projectId, state.token));
730
785
  return state.memory.get(projectId);
731
786
  };
732
787
  const ensureProjectUsage = async (projectId) => {
733
788
  const filters = state.usageFilters.get(projectId) || {};
734
- const usage = await loadProjectUsage(fetchJson, projectId, filters);
789
+ const usage = await loadProjectUsage(fetchJson, projectId, filters, state.token);
735
790
  state.usage.set(projectId, usage);
736
791
  return usage;
737
792
  };
@@ -752,7 +807,7 @@ function startDashboardV2() {
752
807
  node('p', 'eyebrow', 'MEMORY SEARCH'),
753
808
  node('h2', '', query ? 'Resultados para “' + query + '”' : 'Buscar na memória'),
754
809
  );
755
- const results = query ? (await searchProjectMemory(fetchJson, model.projectId, query)).results || [] : [];
810
+ const results = query ? (await searchProjectMemory(fetchJson, model.projectId, query, state.token)).results || [] : [];
756
811
  const list = node('div', 'memory-document-list');
757
812
  renderDocumentRows(list, model.projectId, results, query ? 'Nenhum documento contém esse termo.' : 'Digite um termo para pesquisar.');
758
813
  workspaceContent?.replaceChildren(heading, list);
@@ -776,7 +831,7 @@ function startDashboardV2() {
776
831
  setWorkspaceHeader(model, memory, route);
777
832
  const documents = memory.tree?.documents || [];
778
833
  if (route.kind === 'document') {
779
- const payload = await loadMemoryDocument(fetchJson, model.projectId, route.logicalPath);
834
+ const payload = await loadMemoryDocument(fetchJson, model.projectId, route.logicalPath, state.token);
780
835
  renderReader(workspaceContent, buildMemoryDocumentViewModel(payload, payload.content));
781
836
  return;
782
837
  }
@@ -787,12 +842,18 @@ function startDashboardV2() {
787
842
  state.usageFilters.set(model.projectId, filters);
788
843
  renderWorkspaceRoute({ ...route });
789
844
  },
790
- onTranscript: (transcriptId) => loadProjectTranscript(fetchJson, model.projectId, transcriptId),
845
+ onTranscript: (transcriptId) => loadProjectTranscript(fetchJson, model.projectId, transcriptId, state.token),
791
846
  });
792
847
  } else if (route.section === 'sessions') renderSessions(workspaceContent, model.projectId, documents);
793
848
  else if (route.section === 'memory') renderMemory(workspaceContent, model.projectId, documents);
794
849
  else if (route.section === 'changes') renderChanges(workspaceContent, model.projectId, documents);
795
- else if (route.section === 'sync') renderSync(workspaceContent, memory.sync, model.projectId);
850
+ else if (route.section === 'sync') renderSync(
851
+ workspaceContent,
852
+ memory.sync,
853
+ model.projectId,
854
+ () => loadMemoryExport(fetchJson, model.projectId, state.token),
855
+ );
856
+ else if (route.section === 'security') renderSecurity(workspaceContent, await loadProjectSecurity(fetchJson, model.projectId, state.token));
796
857
  else renderWorkspaceOverview(workspaceContent, model, memory);
797
858
  } catch (error) {
798
859
  showWorkspaceError(error.message);
@@ -815,7 +876,7 @@ function startDashboardV2() {
815
876
  const refresh = async () => {
816
877
  setConnection('is-warning', 'Sincronizando');
817
878
  try {
818
- const models = await loadDashboardData(fetchJson);
879
+ const models = await loadDashboardData(fetchJson, state.token);
819
880
  state.models = models;
820
881
  if (!state.selectedId || !models.some((model) => model.projectId === state.selectedId)) state.selectedId = models[0]?.projectId || '';
821
882
  setHidden(dashboardError, true);
@@ -831,6 +892,21 @@ function startDashboardV2() {
831
892
  }
832
893
  };
833
894
  byId('refresh-button')?.addEventListener('click', refresh);
895
+ byId('observer-auth-form')?.addEventListener('submit', (event) => {
896
+ event.preventDefault();
897
+ state.token = byId('observer-token-input')?.value || '';
898
+ state.memory.clear();
899
+ state.usage.clear();
900
+ refresh();
901
+ });
902
+ byId('observer-token-clear')?.addEventListener('click', () => {
903
+ state.token = '';
904
+ const input = byId('observer-token-input');
905
+ if (input) input.value = '';
906
+ state.memory.clear();
907
+ state.usage.clear();
908
+ refresh();
909
+ });
834
910
  byId('project-filter')?.addEventListener('input', (event) => {
835
911
  state.filter = event.target.value;
836
912
  renderProjectList(state.models, state.selectedId, state.filter);
@@ -24,6 +24,12 @@
24
24
  <div class="connection-cluster" aria-live="polite">
25
25
  <span id="connection-dot" class="connection-dot is-offline" aria-hidden="true"></span>
26
26
  <span id="connection-label">Conectando</span>
27
+ <form id="observer-auth-form" class="observer-auth-form" autocomplete="off">
28
+ <label class="sr-only" for="observer-token-input">Token do Observer</label>
29
+ <input id="observer-token-input" type="password" placeholder="Token" autocomplete="off" spellcheck="false">
30
+ <button type="submit">Conectar</button>
31
+ <button id="observer-token-clear" type="button" aria-label="Limpar token">×</button>
32
+ </form>
27
33
  <form id="global-search-form" class="global-search">
28
34
  <label class="sr-only" for="global-search">Buscar na memória</label>
29
35
  <span aria-hidden="true">⌕</span>
@@ -101,6 +107,7 @@
101
107
  <a data-workspace-section="memory" href="#project/project-a/memory">Memória</a>
102
108
  <a data-workspace-section="changes" href="#project/project-a/changes">Changes</a>
103
109
  <a data-workspace-section="sync" href="#project/project-a/sync">Sincronização</a>
110
+ <a data-workspace-section="security" href="#project/project-a/security">Segurança</a>
104
111
  </nav>
105
112
  <div id="workspace-meta" class="workspace-meta"></div>
106
113
  </aside>
@@ -57,6 +57,10 @@ button:focus-visible, input:focus-visible, a:focus-visible { outline: 2px solid
57
57
  .brand-name { display: block; font-family: Georgia, serif; font-size: 1.06rem; font-weight: 500; letter-spacing: -.02em; }
58
58
  .eyebrow { margin: 0 0 7px; color: var(--faint); font-family: "Bahnschrift", sans-serif; font-size: .66rem; font-weight: 700; letter-spacing: .16em; line-height: 1; text-transform: uppercase; }
59
59
  .connection-cluster { display: flex; align-items: center; gap: 9px; color: var(--muted); font-size: .78rem; }
60
+ .observer-auth-form { display: flex; align-items: center; gap: 4px; margin-left: 8px; }
61
+ .observer-auth-form input { width: 112px; padding: 7px 9px; border: 1px solid var(--line); border-radius: 8px; background: rgba(7, 16, 21, .65); color: var(--text); }
62
+ .observer-auth-form button { padding: 7px 9px; border: 1px solid var(--line); border-radius: 8px; background: transparent; color: var(--muted); cursor: pointer; }
63
+ .observer-auth-form button:hover { border-color: var(--mint); color: var(--mint-bright); }
60
64
  .connection-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); box-shadow: 0 0 0 4px rgba(94, 119, 116, .12); }
61
65
  .connection-dot.is-online { background: var(--mint); box-shadow: 0 0 0 4px rgba(156, 224, 198, .1), 0 0 18px rgba(156, 224, 198, .7); }
62
66
  .connection-dot.is-warning { background: var(--amber); box-shadow: 0 0 0 4px rgba(246, 189, 114, .1); }
@@ -232,6 +236,7 @@ input::placeholder { color: var(--faint); }
232
236
  @media (max-width: 560px) {
233
237
  .topbar { min-height: 76px; }
234
238
  .connection-cluster > #connection-label { display: none; }
239
+ .observer-auth-form input { width: 80px; }
235
240
  .global-search { margin-left: 5px; }
236
241
  .global-search input { width: 92px; }
237
242
  .hero-row { align-items: start; flex-direction: column; }