@directive-run/knowledge 0.2.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/LICENSE +21 -0
- package/README.md +63 -0
- package/ai/ai-adapters.md +250 -0
- package/ai/ai-agents-streaming.md +269 -0
- package/ai/ai-budget-resilience.md +235 -0
- package/ai/ai-communication.md +281 -0
- package/ai/ai-debug-observability.md +243 -0
- package/ai/ai-guardrails-memory.md +332 -0
- package/ai/ai-mcp-rag.md +288 -0
- package/ai/ai-multi-agent.md +274 -0
- package/ai/ai-orchestrator.md +227 -0
- package/ai/ai-security.md +293 -0
- package/ai/ai-tasks.md +261 -0
- package/ai/ai-testing-evals.md +378 -0
- package/api-skeleton.md +5 -0
- package/core/anti-patterns.md +382 -0
- package/core/constraints.md +263 -0
- package/core/core-patterns.md +228 -0
- package/core/error-boundaries.md +322 -0
- package/core/multi-module.md +315 -0
- package/core/naming.md +283 -0
- package/core/plugins.md +344 -0
- package/core/react-adapter.md +262 -0
- package/core/resolvers.md +357 -0
- package/core/schema-types.md +262 -0
- package/core/system-api.md +271 -0
- package/core/testing.md +257 -0
- package/core/time-travel.md +238 -0
- package/dist/index.cjs +111 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +10 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +102 -0
- package/dist/index.js.map +1 -0
- package/examples/ab-testing.ts +385 -0
- package/examples/ai-checkpoint.ts +509 -0
- package/examples/ai-guardrails.ts +319 -0
- package/examples/ai-orchestrator.ts +589 -0
- package/examples/async-chains.ts +287 -0
- package/examples/auth-flow.ts +371 -0
- package/examples/batch-resolver.ts +341 -0
- package/examples/checkers.ts +589 -0
- package/examples/contact-form.ts +176 -0
- package/examples/counter.ts +393 -0
- package/examples/dashboard-loader.ts +512 -0
- package/examples/debounce-constraints.ts +105 -0
- package/examples/dynamic-modules.ts +293 -0
- package/examples/error-boundaries.ts +430 -0
- package/examples/feature-flags.ts +220 -0
- package/examples/form-wizard.ts +347 -0
- package/examples/fraud-analysis.ts +663 -0
- package/examples/goal-heist.ts +341 -0
- package/examples/multi-module.ts +57 -0
- package/examples/newsletter.ts +241 -0
- package/examples/notifications.ts +210 -0
- package/examples/optimistic-updates.ts +317 -0
- package/examples/pagination.ts +260 -0
- package/examples/permissions.ts +337 -0
- package/examples/provider-routing.ts +403 -0
- package/examples/server.ts +316 -0
- package/examples/shopping-cart.ts +422 -0
- package/examples/sudoku.ts +630 -0
- package/examples/theme-locale.ts +204 -0
- package/examples/time-machine.ts +225 -0
- package/examples/topic-guard.ts +306 -0
- package/examples/url-sync.ts +333 -0
- package/examples/websocket.ts +404 -0
- package/package.json +65 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
// Example: provider-routing
|
|
2
|
+
// Source: examples/provider-routing/src/main.ts
|
|
3
|
+
// Extracted for AI rules — DOM wiring stripped
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Smart Provider Router — Constraint-Based Provider Routing & Fallback
|
|
7
|
+
*
|
|
8
|
+
* 3 mock providers (OpenAI, Anthropic, Ollama). Constraint router selects based
|
|
9
|
+
* on cost, error rates, circuit state. Provider fallback chain.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
type ModuleSchema,
|
|
14
|
+
createModule,
|
|
15
|
+
createSystem,
|
|
16
|
+
t,
|
|
17
|
+
} from "@directive-run/core";
|
|
18
|
+
import {
|
|
19
|
+
type CircuitState,
|
|
20
|
+
createCircuitBreaker,
|
|
21
|
+
devtoolsPlugin,
|
|
22
|
+
} from "@directive-run/core/plugins";
|
|
23
|
+
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// Types
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
interface ProviderStats {
|
|
29
|
+
name: string;
|
|
30
|
+
callCount: number;
|
|
31
|
+
errorCount: number;
|
|
32
|
+
totalCost: number;
|
|
33
|
+
avgLatencyMs: number;
|
|
34
|
+
circuitState: CircuitState;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface TimelineEntry {
|
|
38
|
+
time: number;
|
|
39
|
+
event: string;
|
|
40
|
+
detail: string;
|
|
41
|
+
type: "route" | "error" | "fallback" | "circuit" | "info" | "success";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ============================================================================
|
|
45
|
+
// Mock Providers
|
|
46
|
+
// ============================================================================
|
|
47
|
+
|
|
48
|
+
const PROVIDERS = {
|
|
49
|
+
openai: { name: "OpenAI", costPer1k: 0.03, baseLatency: 200 },
|
|
50
|
+
anthropic: { name: "Anthropic", costPer1k: 0.025, baseLatency: 250 },
|
|
51
|
+
ollama: { name: "Ollama", costPer1k: 0.001, baseLatency: 400 },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const providerErrors: Record<string, boolean> = {
|
|
55
|
+
openai: false,
|
|
56
|
+
anthropic: false,
|
|
57
|
+
ollama: false,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const circuitBreakers = {
|
|
61
|
+
openai: createCircuitBreaker({
|
|
62
|
+
name: "openai",
|
|
63
|
+
failureThreshold: 3,
|
|
64
|
+
recoveryTimeMs: 5000,
|
|
65
|
+
halfOpenMaxRequests: 2,
|
|
66
|
+
onStateChange: (from, to) =>
|
|
67
|
+
}),
|
|
68
|
+
anthropic: createCircuitBreaker({
|
|
69
|
+
name: "anthropic",
|
|
70
|
+
failureThreshold: 3,
|
|
71
|
+
recoveryTimeMs: 5000,
|
|
72
|
+
halfOpenMaxRequests: 2,
|
|
73
|
+
onStateChange: (from, to) =>
|
|
74
|
+
}),
|
|
75
|
+
ollama: createCircuitBreaker({
|
|
76
|
+
name: "ollama",
|
|
77
|
+
failureThreshold: 3,
|
|
78
|
+
recoveryTimeMs: 5000,
|
|
79
|
+
halfOpenMaxRequests: 2,
|
|
80
|
+
onStateChange: (from, to) =>
|
|
81
|
+
}),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// ============================================================================
|
|
85
|
+
// Timeline
|
|
86
|
+
// ============================================================================
|
|
87
|
+
|
|
88
|
+
const timeline: TimelineEntry[] = [];
|
|
89
|
+
|
|
90
|
+
function addTimeline(
|
|
91
|
+
event: string,
|
|
92
|
+
detail: string,
|
|
93
|
+
type: TimelineEntry["type"],
|
|
94
|
+
) {
|
|
95
|
+
timeline.unshift({ time: Date.now(), event, detail, type });
|
|
96
|
+
if (timeline.length > 50) {
|
|
97
|
+
timeline.length = 50;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ============================================================================
|
|
102
|
+
// Schema
|
|
103
|
+
// ============================================================================
|
|
104
|
+
|
|
105
|
+
const schema = {
|
|
106
|
+
facts: {
|
|
107
|
+
openaiStats: t.object<ProviderStats>(),
|
|
108
|
+
anthropicStats: t.object<ProviderStats>(),
|
|
109
|
+
ollamaStats: t.object<ProviderStats>(),
|
|
110
|
+
budgetRemaining: t.number(),
|
|
111
|
+
budgetTotal: t.number(),
|
|
112
|
+
preferCheapest: t.boolean(),
|
|
113
|
+
lastProvider: t.string(),
|
|
114
|
+
totalRequests: t.number(),
|
|
115
|
+
lastError: t.string(),
|
|
116
|
+
},
|
|
117
|
+
derivations: {
|
|
118
|
+
openaiCircuit: t.string<CircuitState>(),
|
|
119
|
+
anthropicCircuit: t.string<CircuitState>(),
|
|
120
|
+
ollamaCircuit: t.string<CircuitState>(),
|
|
121
|
+
cheapestAvailable: t.string(),
|
|
122
|
+
allDown: t.boolean(),
|
|
123
|
+
},
|
|
124
|
+
events: {
|
|
125
|
+
toggleProviderError: { provider: t.string() },
|
|
126
|
+
setBudget: { value: t.number() },
|
|
127
|
+
togglePreferCheapest: {},
|
|
128
|
+
resetStats: {},
|
|
129
|
+
},
|
|
130
|
+
requirements: {},
|
|
131
|
+
} satisfies ModuleSchema;
|
|
132
|
+
|
|
133
|
+
// ============================================================================
|
|
134
|
+
// Module
|
|
135
|
+
// ============================================================================
|
|
136
|
+
|
|
137
|
+
function defaultStats(name: string): ProviderStats {
|
|
138
|
+
return {
|
|
139
|
+
name,
|
|
140
|
+
callCount: 0,
|
|
141
|
+
errorCount: 0,
|
|
142
|
+
totalCost: 0,
|
|
143
|
+
avgLatencyMs: 0,
|
|
144
|
+
circuitState: "CLOSED",
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const routerModule = createModule("router", {
|
|
149
|
+
schema,
|
|
150
|
+
|
|
151
|
+
init: (facts) => {
|
|
152
|
+
facts.openaiStats = defaultStats("OpenAI");
|
|
153
|
+
facts.anthropicStats = defaultStats("Anthropic");
|
|
154
|
+
facts.ollamaStats = defaultStats("Ollama");
|
|
155
|
+
facts.budgetRemaining = 1.0;
|
|
156
|
+
facts.budgetTotal = 1.0;
|
|
157
|
+
facts.preferCheapest = false;
|
|
158
|
+
facts.lastProvider = "";
|
|
159
|
+
facts.totalRequests = 0;
|
|
160
|
+
facts.lastError = "";
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
derive: {
|
|
164
|
+
openaiCircuit: () => circuitBreakers.openai.getState(),
|
|
165
|
+
anthropicCircuit: () => circuitBreakers.anthropic.getState(),
|
|
166
|
+
ollamaCircuit: () => circuitBreakers.ollama.getState(),
|
|
167
|
+
cheapestAvailable: () => {
|
|
168
|
+
const available: { name: string; cost: number }[] = [];
|
|
169
|
+
for (const [id, config] of Object.entries(PROVIDERS)) {
|
|
170
|
+
const breaker = circuitBreakers[id as keyof typeof circuitBreakers];
|
|
171
|
+
if (breaker.isAllowed()) {
|
|
172
|
+
available.push({ name: id, cost: config.costPer1k });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
available.sort((a, b) => a.cost - b.cost);
|
|
176
|
+
|
|
177
|
+
return available.length > 0 ? available[0]!.name : "none";
|
|
178
|
+
},
|
|
179
|
+
allDown: () =>
|
|
180
|
+
!circuitBreakers.openai.isAllowed() &&
|
|
181
|
+
!circuitBreakers.anthropic.isAllowed() &&
|
|
182
|
+
!circuitBreakers.ollama.isAllowed(),
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
events: {
|
|
186
|
+
toggleProviderError: (_facts, { provider }) => {
|
|
187
|
+
providerErrors[provider] = !providerErrors[provider];
|
|
188
|
+
},
|
|
189
|
+
setBudget: (facts, { value }) => {
|
|
190
|
+
facts.budgetRemaining = value;
|
|
191
|
+
facts.budgetTotal = value;
|
|
192
|
+
},
|
|
193
|
+
togglePreferCheapest: (facts) => {
|
|
194
|
+
facts.preferCheapest = !facts.preferCheapest;
|
|
195
|
+
},
|
|
196
|
+
resetStats: (facts) => {
|
|
197
|
+
facts.openaiStats = defaultStats("OpenAI");
|
|
198
|
+
facts.anthropicStats = defaultStats("Anthropic");
|
|
199
|
+
facts.ollamaStats = defaultStats("Ollama");
|
|
200
|
+
facts.budgetRemaining = facts.budgetTotal;
|
|
201
|
+
facts.lastProvider = "";
|
|
202
|
+
facts.totalRequests = 0;
|
|
203
|
+
facts.lastError = "";
|
|
204
|
+
providerErrors.openai = false;
|
|
205
|
+
providerErrors.anthropic = false;
|
|
206
|
+
providerErrors.ollama = false;
|
|
207
|
+
circuitBreakers.openai.reset();
|
|
208
|
+
circuitBreakers.anthropic.reset();
|
|
209
|
+
circuitBreakers.ollama.reset();
|
|
210
|
+
timeline.length = 0;
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// ============================================================================
|
|
216
|
+
// System
|
|
217
|
+
// ============================================================================
|
|
218
|
+
|
|
219
|
+
const system = createSystem({
|
|
220
|
+
module: routerModule,
|
|
221
|
+
debug: { runHistory: true },
|
|
222
|
+
plugins: [devtoolsPlugin({ name: "provider-routing" })],
|
|
223
|
+
});
|
|
224
|
+
system.start();
|
|
225
|
+
|
|
226
|
+
// ============================================================================
|
|
227
|
+
// Routing Logic
|
|
228
|
+
// ============================================================================
|
|
229
|
+
|
|
230
|
+
function selectProvider(): string | null {
|
|
231
|
+
const budget = system.facts.budgetRemaining as number;
|
|
232
|
+
const preferCheapest = system.facts.preferCheapest as boolean;
|
|
233
|
+
|
|
234
|
+
// Collect available providers (circuit breaker allows + within budget)
|
|
235
|
+
const available: { id: string; cost: number }[] = [];
|
|
236
|
+
for (const [id, config] of Object.entries(PROVIDERS)) {
|
|
237
|
+
const breaker = circuitBreakers[id as keyof typeof circuitBreakers];
|
|
238
|
+
if (breaker.isAllowed() && budget >= config.costPer1k) {
|
|
239
|
+
available.push({ id, cost: config.costPer1k });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (available.length === 0) {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (preferCheapest) {
|
|
248
|
+
available.sort((a, b) => a.cost - b.cost);
|
|
249
|
+
|
|
250
|
+
return available[0]!.id;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Default: prefer openai > anthropic > ollama
|
|
254
|
+
const priority = ["openai", "anthropic", "ollama"];
|
|
255
|
+
for (const id of priority) {
|
|
256
|
+
if (available.find((a) => a.id === id)) {
|
|
257
|
+
return id;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return available[0]!.id;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function executeProvider(providerId: string): Promise<boolean> {
|
|
265
|
+
const breaker = circuitBreakers[providerId as keyof typeof circuitBreakers];
|
|
266
|
+
const config = PROVIDERS[providerId as keyof typeof PROVIDERS]!;
|
|
267
|
+
const statsKey = `${providerId}Stats` as
|
|
268
|
+
| "openaiStats"
|
|
269
|
+
| "anthropicStats"
|
|
270
|
+
| "ollamaStats";
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
await breaker.execute(async () => {
|
|
274
|
+
await new Promise((resolve) =>
|
|
275
|
+
setTimeout(resolve, config.baseLatency + Math.random() * 100),
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
if (providerErrors[providerId]) {
|
|
279
|
+
throw new Error(`${config.name}: simulated error`);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const stats = system.facts[statsKey] as ProviderStats;
|
|
284
|
+
const cost = config.costPer1k;
|
|
285
|
+
const latency = config.baseLatency + Math.random() * 100;
|
|
286
|
+
system.facts[statsKey] = {
|
|
287
|
+
...stats,
|
|
288
|
+
callCount: stats.callCount + 1,
|
|
289
|
+
totalCost: Math.round((stats.totalCost + cost) * 1000) / 1000,
|
|
290
|
+
avgLatencyMs: Math.round(
|
|
291
|
+
(stats.avgLatencyMs * stats.callCount + latency) /
|
|
292
|
+
(stats.callCount + 1),
|
|
293
|
+
),
|
|
294
|
+
circuitState: breaker.getState(),
|
|
295
|
+
};
|
|
296
|
+
system.facts.budgetRemaining =
|
|
297
|
+
Math.round(((system.facts.budgetRemaining as number) - cost) * 1000) /
|
|
298
|
+
1000;
|
|
299
|
+
system.facts.lastError = "";
|
|
300
|
+
|
|
301
|
+
return true;
|
|
302
|
+
} catch (err) {
|
|
303
|
+
const stats = system.facts[statsKey] as ProviderStats;
|
|
304
|
+
system.facts[statsKey] = {
|
|
305
|
+
...stats,
|
|
306
|
+
errorCount: stats.errorCount + 1,
|
|
307
|
+
circuitState: breaker.getState(),
|
|
308
|
+
};
|
|
309
|
+
system.facts.lastError = err instanceof Error ? err.message : String(err);
|
|
310
|
+
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function sendRequest() {
|
|
316
|
+
system.facts.totalRequests = (system.facts.totalRequests as number) + 1;
|
|
317
|
+
|
|
318
|
+
const providerId = selectProvider();
|
|
319
|
+
if (!providerId) {
|
|
320
|
+
system.facts.lastError = "All providers unavailable or over budget";
|
|
321
|
+
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
system.facts.lastProvider = providerId;
|
|
326
|
+
|
|
327
|
+
const success = await executeProvider(providerId);
|
|
328
|
+
if (success) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Primary failed — try fallback
|
|
333
|
+
const fallbackId = selectProvider();
|
|
334
|
+
if (fallbackId && fallbackId !== providerId) {
|
|
335
|
+
system.facts.lastProvider = fallbackId;
|
|
336
|
+
await executeProvider(fallbackId);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ============================================================================
|
|
341
|
+
// DOM References
|
|
342
|
+
// ============================================================================
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
// ============================================================================
|
|
346
|
+
// Render
|
|
347
|
+
// ============================================================================
|
|
348
|
+
|
|
349
|
+
function escapeHtml(text: string): string {
|
|
350
|
+
|
|
351
|
+
return div.innerHTML;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function circuitBadge(state: CircuitState): string {
|
|
355
|
+
const cls =
|
|
356
|
+
state === "CLOSED" ? "closed" : state === "OPEN" ? "open" : "half-open";
|
|
357
|
+
|
|
358
|
+
return `<span class="pr-circuit-badge ${cls}">${state}</span>`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
stats: ProviderStats,
|
|
362
|
+
state: CircuitState,
|
|
363
|
+
): void {
|
|
364
|
+
${circuitBadge(state)}
|
|
365
|
+
<span style="font-size:0.55rem;color:var(--brand-text-dim)">${stats.callCount} calls, ${stats.errorCount} err, $${stats.totalCost}</span>
|
|
366
|
+
`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
// ============================================================================
|
|
371
|
+
// Subscribe
|
|
372
|
+
// ============================================================================
|
|
373
|
+
|
|
374
|
+
const allKeys = [
|
|
375
|
+
...Object.keys(schema.facts),
|
|
376
|
+
...Object.keys(schema.derivations),
|
|
377
|
+
];
|
|
378
|
+
system.subscribe(allKeys, render);
|
|
379
|
+
|
|
380
|
+
setInterval(render, 1000);
|
|
381
|
+
|
|
382
|
+
// ============================================================================
|
|
383
|
+
// Controls
|
|
384
|
+
// ============================================================================
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
for (const id of ["openai", "anthropic", "ollama"]) {
|
|
388
|
+
system.events.toggleProviderError({ provider: id });
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
"input",
|
|
393
|
+
(e) => {
|
|
394
|
+
system.events.setBudget({ value });
|
|
395
|
+
},
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
// ============================================================================
|
|
400
|
+
// Initial Render
|
|
401
|
+
// ============================================================================
|
|
402
|
+
|
|
403
|
+
render();
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// Example: server
|
|
2
|
+
// Source: examples/server/src/server.ts
|
|
3
|
+
// Pure module file — no DOM wiring
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Directive Server Example
|
|
7
|
+
*
|
|
8
|
+
* An Express API demonstrating Directive's server-side features:
|
|
9
|
+
* - Distributable snapshots with TTL
|
|
10
|
+
* - Signed snapshot verification (HMAC-SHA256)
|
|
11
|
+
* - Cryptographic audit trail
|
|
12
|
+
* - GDPR/CCPA compliance tooling
|
|
13
|
+
*
|
|
14
|
+
* Run: npx tsx --watch src/server.ts
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
createAuditTrail,
|
|
19
|
+
createCompliance,
|
|
20
|
+
createInMemoryComplianceStorage,
|
|
21
|
+
} from "@directive-run/ai";
|
|
22
|
+
import {
|
|
23
|
+
createSystem,
|
|
24
|
+
isSnapshotExpired,
|
|
25
|
+
signSnapshot,
|
|
26
|
+
verifySnapshotSignature,
|
|
27
|
+
} from "@directive-run/core";
|
|
28
|
+
import express from "express";
|
|
29
|
+
import { userProfile } from "./module.js";
|
|
30
|
+
|
|
31
|
+
const app = express();
|
|
32
|
+
app.use(express.json());
|
|
33
|
+
|
|
34
|
+
// ============================================================================
|
|
35
|
+
// Shared Infrastructure
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
const SIGNING_SECRET =
|
|
39
|
+
process.env.SIGNING_SECRET ?? "dev-secret-change-in-production";
|
|
40
|
+
|
|
41
|
+
// Audit trail – shared across requests, acts as a Directive plugin
|
|
42
|
+
const audit = createAuditTrail({
|
|
43
|
+
maxEntries: 10_000,
|
|
44
|
+
retentionMs: 7 * 24 * 60 * 60 * 1000, // 7 days
|
|
45
|
+
piiMasking: {
|
|
46
|
+
enabled: true,
|
|
47
|
+
types: ["email", "name"],
|
|
48
|
+
redactionStyle: "masked",
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Compliance – in-memory storage for this example (use a DB adapter in production)
|
|
53
|
+
const compliance = createCompliance({
|
|
54
|
+
storage: createInMemoryComplianceStorage(),
|
|
55
|
+
consentPurposes: ["analytics", "marketing", "personalization"],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// In-memory snapshot cache (use Redis in production)
|
|
59
|
+
const snapshotCache = new Map<
|
|
60
|
+
string,
|
|
61
|
+
{ snapshot: unknown; cachedAt: number }
|
|
62
|
+
>();
|
|
63
|
+
const CACHE_TTL_MS = 60_000; // 1 minute
|
|
64
|
+
|
|
65
|
+
// ============================================================================
|
|
66
|
+
// Helper: Per-Request System Factory
|
|
67
|
+
// ============================================================================
|
|
68
|
+
|
|
69
|
+
function createUserSystem(userId: string) {
|
|
70
|
+
const system = createSystem({
|
|
71
|
+
module: userProfile,
|
|
72
|
+
plugins: [audit.createPlugin()],
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
system.start();
|
|
76
|
+
system.events.loadUser({ userId });
|
|
77
|
+
|
|
78
|
+
return system;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ============================================================================
|
|
82
|
+
// GET /snapshot/:userId
|
|
83
|
+
// Distributable Snapshots with TTL
|
|
84
|
+
// ============================================================================
|
|
85
|
+
|
|
86
|
+
app.get("/snapshot/:userId", async (req, res) => {
|
|
87
|
+
const { userId } = req.params;
|
|
88
|
+
|
|
89
|
+
// Check cache first
|
|
90
|
+
const cached = snapshotCache.get(userId);
|
|
91
|
+
if (cached && Date.now() - cached.cachedAt < CACHE_TTL_MS) {
|
|
92
|
+
res.json({ source: "cache", snapshot: cached.snapshot });
|
|
93
|
+
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Create a per-request system, settle it, then export a distributable snapshot
|
|
98
|
+
const system = createUserSystem(userId);
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
await system.settle(5000);
|
|
102
|
+
|
|
103
|
+
const snapshot = system.getDistributableSnapshot({
|
|
104
|
+
includeDerivations: ["effectivePlan", "canUseFeature", "isReady"],
|
|
105
|
+
ttlSeconds: 3600,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Cache it
|
|
109
|
+
snapshotCache.set(userId, { snapshot, cachedAt: Date.now() });
|
|
110
|
+
|
|
111
|
+
res.json({ source: "fresh", snapshot });
|
|
112
|
+
} catch (error) {
|
|
113
|
+
res.status(500).json({ error: "Failed to settle system" });
|
|
114
|
+
} finally {
|
|
115
|
+
system.destroy();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ============================================================================
|
|
120
|
+
// POST /snapshot/:userId/verify
|
|
121
|
+
// Signed Snapshot Verification
|
|
122
|
+
// ============================================================================
|
|
123
|
+
|
|
124
|
+
app.post("/snapshot/:userId/verify", async (req, res) => {
|
|
125
|
+
const { snapshot } = req.body;
|
|
126
|
+
|
|
127
|
+
if (!snapshot) {
|
|
128
|
+
res.status(400).json({ error: "Missing snapshot in request body" });
|
|
129
|
+
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Sign a fresh snapshot for this user
|
|
134
|
+
const system = createUserSystem(req.params.userId);
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
await system.settle(5000);
|
|
138
|
+
|
|
139
|
+
const freshSnapshot = system.getDistributableSnapshot({
|
|
140
|
+
includeDerivations: ["effectivePlan", "canUseFeature", "isReady"],
|
|
141
|
+
ttlSeconds: 3600,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Sign the snapshot with HMAC-SHA256
|
|
145
|
+
const signed = await signSnapshot(freshSnapshot, SIGNING_SECRET);
|
|
146
|
+
|
|
147
|
+
// Verify the provided snapshot's signature
|
|
148
|
+
if (snapshot.signature) {
|
|
149
|
+
const isValid = await verifySnapshotSignature(snapshot, SIGNING_SECRET);
|
|
150
|
+
const isExpired = isSnapshotExpired(snapshot);
|
|
151
|
+
|
|
152
|
+
res.json({
|
|
153
|
+
signatureValid: isValid,
|
|
154
|
+
expired: isExpired,
|
|
155
|
+
signedSnapshot: signed,
|
|
156
|
+
});
|
|
157
|
+
} else {
|
|
158
|
+
// No signature on the incoming snapshot – just return a signed version
|
|
159
|
+
res.json({
|
|
160
|
+
signatureValid: null,
|
|
161
|
+
expired: false,
|
|
162
|
+
signedSnapshot: signed,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
res.status(500).json({ error: "Verification failed" });
|
|
167
|
+
} finally {
|
|
168
|
+
system.destroy();
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ============================================================================
|
|
173
|
+
// GET /audit
|
|
174
|
+
// Query Audit Entries
|
|
175
|
+
// ============================================================================
|
|
176
|
+
|
|
177
|
+
app.get("/audit", (req, res) => {
|
|
178
|
+
const { eventType, since, actorId, limit } = req.query;
|
|
179
|
+
|
|
180
|
+
// biome-ignore lint/suspicious/noExplicitAny: eventType comes from query string
|
|
181
|
+
const entries = audit.getEntries({
|
|
182
|
+
eventTypes: eventType ? [eventType as any] : undefined,
|
|
183
|
+
since: since ? Number(since) : undefined,
|
|
184
|
+
actorId: actorId as string | undefined,
|
|
185
|
+
limit: limit ? Number(limit) : 50,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
res.json({
|
|
189
|
+
count: entries.length,
|
|
190
|
+
entries,
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ============================================================================
|
|
195
|
+
// GET /audit/verify
|
|
196
|
+
// Verify Audit Hash Chain Integrity
|
|
197
|
+
// ============================================================================
|
|
198
|
+
|
|
199
|
+
app.get("/audit/verify", async (_req, res) => {
|
|
200
|
+
const result = await audit.verifyChain();
|
|
201
|
+
|
|
202
|
+
res.json({
|
|
203
|
+
chainValid: result.valid,
|
|
204
|
+
entriesVerified: result.entriesVerified,
|
|
205
|
+
brokenAt: result.brokenAt ?? null,
|
|
206
|
+
verifiedAt: new Date(result.verifiedAt).toISOString(),
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ============================================================================
|
|
211
|
+
// POST /compliance/:subjectId/export
|
|
212
|
+
// GDPR Article 20 – Data Export
|
|
213
|
+
// ============================================================================
|
|
214
|
+
|
|
215
|
+
app.post("/compliance/:subjectId/export", async (req, res) => {
|
|
216
|
+
const { subjectId } = req.params;
|
|
217
|
+
|
|
218
|
+
// Record consent for analytics before exporting
|
|
219
|
+
await compliance.consent.grant(subjectId, "analytics", {
|
|
220
|
+
source: "api-request",
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const result = await compliance.exportData({
|
|
224
|
+
subjectId,
|
|
225
|
+
format: "json",
|
|
226
|
+
includeAudit: true,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
if (result.success) {
|
|
230
|
+
res.json({
|
|
231
|
+
subjectId,
|
|
232
|
+
exportedAt: new Date(result.exportedAt).toISOString(),
|
|
233
|
+
expiresAt: result.expiresAt
|
|
234
|
+
? new Date(result.expiresAt).toISOString()
|
|
235
|
+
: null,
|
|
236
|
+
recordCount: result.recordCount,
|
|
237
|
+
checksum: result.checksum,
|
|
238
|
+
data: JSON.parse(result.data),
|
|
239
|
+
});
|
|
240
|
+
} else {
|
|
241
|
+
res.status(500).json({ error: "Export failed" });
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// ============================================================================
|
|
246
|
+
// POST /compliance/:subjectId/delete
|
|
247
|
+
// GDPR Article 17 – Right to Erasure
|
|
248
|
+
// ============================================================================
|
|
249
|
+
|
|
250
|
+
app.post("/compliance/:subjectId/delete", async (req, res) => {
|
|
251
|
+
const { subjectId } = req.params;
|
|
252
|
+
const { reason } = req.body;
|
|
253
|
+
|
|
254
|
+
const result = await compliance.deleteData({
|
|
255
|
+
subjectId,
|
|
256
|
+
scope: "all",
|
|
257
|
+
reason: reason ?? "GDPR Article 17 request",
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
if (result.success) {
|
|
261
|
+
res.json({
|
|
262
|
+
subjectId,
|
|
263
|
+
deletedAt: new Date(result.deletedAt).toISOString(),
|
|
264
|
+
recordsAffected: result.recordsAffected,
|
|
265
|
+
certificate: result.certificate,
|
|
266
|
+
});
|
|
267
|
+
} else {
|
|
268
|
+
res.status(500).json({ error: "Deletion failed" });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ============================================================================
|
|
273
|
+
// GET /health
|
|
274
|
+
// Health Check
|
|
275
|
+
// ============================================================================
|
|
276
|
+
|
|
277
|
+
app.get("/health", (_req, res) => {
|
|
278
|
+
const auditStats = audit.getStats();
|
|
279
|
+
|
|
280
|
+
res.json({
|
|
281
|
+
status: "ok",
|
|
282
|
+
audit: {
|
|
283
|
+
totalEntries: auditStats.totalEntries,
|
|
284
|
+
oldestEntry: auditStats.oldestEntry
|
|
285
|
+
? new Date(auditStats.oldestEntry).toISOString()
|
|
286
|
+
: null,
|
|
287
|
+
newestEntry: auditStats.newestEntry
|
|
288
|
+
? new Date(auditStats.newestEntry).toISOString()
|
|
289
|
+
: null,
|
|
290
|
+
chainIntegrity: auditStats.chainIntegrity,
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// ============================================================================
|
|
296
|
+
// Start
|
|
297
|
+
// ============================================================================
|
|
298
|
+
|
|
299
|
+
const PORT = Number(process.env.PORT ?? 3000);
|
|
300
|
+
|
|
301
|
+
app.listen(PORT, () => {
|
|
302
|
+
console.log(`Directive server example running on http://localhost:${PORT}`);
|
|
303
|
+
console.log();
|
|
304
|
+
console.log("Endpoints:");
|
|
305
|
+
console.log(
|
|
306
|
+
" GET /snapshot/:userId Distributable snapshot with TTL",
|
|
307
|
+
);
|
|
308
|
+
console.log(" POST /snapshot/:userId/verify Sign and verify snapshots");
|
|
309
|
+
console.log(" GET /audit Query audit entries");
|
|
310
|
+
console.log(" GET /audit/verify Verify hash chain integrity");
|
|
311
|
+
console.log(" POST /compliance/:subjectId/export GDPR data export");
|
|
312
|
+
console.log(" POST /compliance/:subjectId/delete GDPR right to erasure");
|
|
313
|
+
console.log(" GET /health Health check");
|
|
314
|
+
console.log();
|
|
315
|
+
console.log("Try: curl http://localhost:3000/snapshot/user-1");
|
|
316
|
+
});
|