@kehto/firewall 0.3.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/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # @kehto/firewall
2
+
3
+ Pure, WASM-ready behavioral firewall engine for the napplet protocol — zero dependencies, zero side effects.
4
+
5
+ > **Alpha status:** Kehto is an early runtime implementation for a draft NIP-5D
6
+ > protocol. The firewall engine API is not yet final; treat this package
7
+ > as current implementation guidance, not as a stable protocol guarantee.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pnpm add @kehto/firewall
13
+ ```
14
+
15
+ ## Overview
16
+
17
+ `@kehto/firewall` is Kehto's behavioral abuse-detection engine. It is the temporal complement to `@kehto/acl`: where ACL asks *"is this napplet statically allowed to perform this operation?"*, the firewall asks *"is this napplet abusing an operation over time?"*.
18
+
19
+ Every function is pure: config + state + observation in, decision + next state out. No I/O, no timers, no globals — the module is trivially compilable to WASM and is the single source of truth for behavioral-firewall decisions.
20
+
21
+ The core `evaluate(config, state, observation)` function implements:
22
+
23
+ - **Token-bucket rate limiting** per `(napplet dTag, opClass)` pair with O(1) lazy refill.
24
+ - **Init-burst guard** — catches a napplet flooding ops immediately after initialization.
25
+ - **Content matchers** — declarative rules matching op class, event kind, payload size, or focus state.
26
+ - **Focus multiplier** — tightens rate budgets for unfocused napplets without hard-blocking.
27
+ - **Rule precedence** — per-napplet policy override → op-class rule → global fallback → built-in defaults.
28
+
29
+ ## Quick Start
30
+
31
+ ```ts
32
+ import {
33
+ evaluate,
34
+ defaultConfig,
35
+ createState,
36
+ } from '@kehto/firewall';
37
+
38
+ const config = defaultConfig();
39
+ let state = createState();
40
+
41
+ const obs = {
42
+ napplet: 'chat',
43
+ opClass: 'relay:write',
44
+ focused: true,
45
+ now: Date.now(),
46
+ };
47
+
48
+ const result = evaluate(config, state, obs);
49
+ // result.decision: 'pass' | 'reject' | 'prompt'
50
+ // result.newState: updated counter state (original unchanged)
51
+
52
+ state = result.newState;
53
+ ```
54
+
55
+ ## Public API
56
+
57
+ ### Types
58
+ - `Observation` — normalized engine input (never a raw protocol envelope)
59
+ - `FirewallConfig` — immutable configuration container (rules + defaults)
60
+ - `FirewallState` — immutable counter state (token buckets + burst counters)
61
+ - `EvaluateResult` — `{ decision, action, ruleId, reason, newState }`
62
+ - `Decision` — `'pass' | 'reject' | 'prompt'`
63
+ - `Action` — `'flag' | 'block' | 'ignore'`
64
+ - `NappletPolicy` — `'allow' | 'deny' | 'ask'`
65
+ - `RateLimit`, `BurstGuard`, `ContentMatcher`, `NappletRules`
66
+ - `Bucket`, `BurstCounter`
67
+
68
+ ### Constants
69
+ - `DEFAULT_RATE_LIMIT`, `DEFAULT_BURST_GUARD`
70
+ - `DEFAULT_EXCEED_ACTION`, `DEFAULT_BURST_ACTION`
71
+ - `DEFAULT_UNFOCUSED_MULTIPLIER`
72
+
73
+ ### Core function
74
+ - `evaluate` — pure decision function (config + state + observation → result)
75
+ - `toKey` — derive the `napplet:opClass` bucket key
76
+
77
+ ### Config mutations
78
+ - `defaultConfig` — built-in conservative config
79
+ - `createState` — empty counter state
80
+ - `setPolicy`, `setRateLimit`, `addMatcher` — immutable config mutations
81
+ - `serialize`, `deserialize` — JSON round-trip for persistence
82
+
83
+ ## License
84
+
85
+ MIT
@@ -0,0 +1,586 @@
1
+ /**
2
+ * @kehto/firewall — Pure, side-effect-free type surface.
3
+ *
4
+ * All types are immutable (readonly members, Readonly<Record<>> for maps).
5
+ * No runtime logic lives here — defaults and constants with values live in defaults.ts.
6
+ * This module has zero imports: it is the leaf dependency of the package.
7
+ */
8
+ /**
9
+ * Normalized observation — the sole input surface of the pure firewall engine.
10
+ *
11
+ * The pure core NEVER parses protocol envelopes. Phase 81 is responsible for
12
+ * extracting these fields from raw message envelopes before calling evaluate().
13
+ *
14
+ * @param napplet - dTag — version-agnostic identity key ("any version").
15
+ * Keyed by dTag only (not dTag:hash) so rate limits are
16
+ * shared across all versions of the same napplet.
17
+ * @param opClass - Operation class string, e.g. 'relay:write', 'outbox:publish',
18
+ * 'intent:invoke'. Treated as an opaque key by the engine.
19
+ * @param kind - Nostr event kind (5 = delete). Present for publish-style ops.
20
+ * @param size - Payload byte size. Present when known (e.g. relay:write).
21
+ * @param initElapsedMs - Milliseconds since this napplet window initialized.
22
+ * Used by the init-burst guard (BURST-01).
23
+ * @param focused - Whether this napplet is the currently focused window.
24
+ * Shell-owned and forge-proof; never self-reported.
25
+ * @param msSinceFocusGain - Milliseconds since this napplet last gained focus.
26
+ * Used by content matchers (CONTENT-02).
27
+ * @param now - Injected timestamp (Unix ms). evaluate() MUST never read a
28
+ * wall clock — this field makes the function pure and deterministic.
29
+ */
30
+ interface Observation {
31
+ readonly napplet: string;
32
+ readonly opClass: string;
33
+ readonly kind?: number;
34
+ readonly size?: number;
35
+ readonly initElapsedMs?: number;
36
+ readonly focused: boolean;
37
+ readonly msSinceFocusGain?: number;
38
+ readonly now: number;
39
+ }
40
+ /**
41
+ * Action taken when a firewall rule is exceeded.
42
+ *
43
+ * - `'flag'` — pass the operation but emit an audit event (default for rate limits).
44
+ * - `'block'` — reject the operation (default for burst guard).
45
+ * - `'ignore'` — pass silently without any audit event.
46
+ */
47
+ type Action = 'flag' | 'block' | 'ignore';
48
+ /**
49
+ * Decision returned by evaluate() for the caller to act on.
50
+ *
51
+ * - `'pass'` — caller dispatches the operation (action may be `'flag'`).
52
+ * - `'reject'` — caller drops the operation and returns an error.
53
+ * - `'prompt'` — caller rejects current message and fires an async consent prompt.
54
+ */
55
+ type Decision = 'pass' | 'reject' | 'prompt';
56
+ /**
57
+ * Per-napplet policy posture — hard override for a specific dTag.
58
+ *
59
+ * - `'allow'` — always pass, bypassing rate/burst/content checks.
60
+ * - `'deny'` — always reject, regardless of budgets.
61
+ * - `'ask'` — always prompt for user consent.
62
+ */
63
+ type NappletPolicy = 'allow' | 'deny' | 'ask';
64
+ /**
65
+ * Token-bucket rate limit for a single (napplet, opClass) pair.
66
+ *
67
+ * The engine computes `refillRatePerMs = capacity / windowMs` lazily on
68
+ * each evaluate() call — it is never stored in state.
69
+ *
70
+ * @param capacity - Maximum tokens (operations) allowed per window.
71
+ * @param windowMs - Window duration in milliseconds.
72
+ * @param action - Exceed-action: what to do when the bucket is empty.
73
+ */
74
+ interface RateLimit {
75
+ readonly capacity: number;
76
+ readonly windowMs: number;
77
+ readonly action: Action;
78
+ }
79
+ /**
80
+ * Init-burst guard — caps the number of operations fired within the
81
+ * initialization window (initElapsedMs below windowMs).
82
+ *
83
+ * Modeled as a first-class field on NappletRules / FirewallConfig, NOT as
84
+ * a ContentMatcher (research Open Question 2 — resolved in favor of first-class).
85
+ *
86
+ * @param windowMs - The initialization window in milliseconds.
87
+ * @param maxOps - Maximum operations allowed inside the init window.
88
+ * @param action - Exceed-action; default is `'block'` (BURST-02).
89
+ */
90
+ interface BurstGuard {
91
+ readonly windowMs: number;
92
+ readonly maxOps: number;
93
+ readonly action: Action;
94
+ }
95
+ /**
96
+ * Declarative content matcher — matched against an Observation before rate checks.
97
+ *
98
+ * All fields except `id` and `action` are optional predicates; a matcher fires
99
+ * when ALL present predicates are satisfied (AND semantics).
100
+ *
101
+ * @param id - Stable unique identifier for this matcher rule.
102
+ * @param opClass - Restrict match to a specific operation class.
103
+ * @param kinds - Match if observation.kind is in this set (e.g. [5] for delete-spam).
104
+ * @param minSize - Match if observation.size >= minSize.
105
+ * @param focused - Match if observation.focused equals this value.
106
+ * @param maxMsSinceFocusGain - Match if observation.msSinceFocusGain <= maxMsSinceFocusGain.
107
+ * @param action - Action to apply when this matcher fires.
108
+ */
109
+ interface ContentMatcher {
110
+ readonly id: string;
111
+ readonly opClass?: string;
112
+ readonly kinds?: readonly number[];
113
+ readonly minSize?: number;
114
+ readonly focused?: boolean;
115
+ readonly maxMsSinceFocusGain?: number;
116
+ readonly action: Action;
117
+ }
118
+ /**
119
+ * Per-napplet firewall rules — keyed by dTag in FirewallConfig.napplets.
120
+ *
121
+ * @param policy - Optional hard policy override for this dTag.
122
+ * @param rateLimits - Map from opClass string to a per-op token-bucket limit.
123
+ * @param globalRate - Optional fallback rate limit applied to all op-classes
124
+ * that lack a specific entry in rateLimits (RATE-03).
125
+ */
126
+ interface NappletRules {
127
+ readonly policy?: NappletPolicy;
128
+ readonly rateLimits: Readonly<Record<string, RateLimit>>;
129
+ readonly globalRate?: RateLimit;
130
+ }
131
+ /**
132
+ * Complete firewall configuration — immutable container.
133
+ *
134
+ * All mutations (setPolicy, setRateLimit, addMatcher) return a new FirewallConfig;
135
+ * the original is never modified. Mirrors AclState's immutable-container shape.
136
+ *
137
+ * @param napplets - Per-napplet rule map keyed by dTag.
138
+ * @param matchers - Ordered list of content matchers (first-match wins).
139
+ * @param burstGuard - Global init-burst guard applied to all napplets.
140
+ * @param defaultRate - Global default token-bucket rate applied when no
141
+ * napplet-specific rule matches.
142
+ * @param unfocusedMultiplier - Fractional multiplier (e.g. 0.25) that tightens
143
+ * rate budgets for unfocused napplets (FOCUS-02).
144
+ * Focus alone NEVER hard-blocks; it only scales tokens.
145
+ */
146
+ interface FirewallConfig {
147
+ readonly napplets: Readonly<Record<string, NappletRules>>;
148
+ readonly matchers: readonly ContentMatcher[];
149
+ readonly burstGuard: BurstGuard;
150
+ readonly defaultRate: RateLimit;
151
+ readonly unfocusedMultiplier: number;
152
+ }
153
+ /**
154
+ * Token-bucket counter for a single (napplet, opClass) pair.
155
+ *
156
+ * The bucket is stored in FirewallState.buckets keyed as `napplet:opClass`.
157
+ * The refill math is lazy: tokens are only recomputed when evaluate() is called.
158
+ *
159
+ * @param tokens - Current available token count (may be fractional).
160
+ * @param lastRefill - Timestamp (Unix ms) of the last token-refill computation.
161
+ */
162
+ interface Bucket {
163
+ readonly tokens: number;
164
+ readonly lastRefill: number;
165
+ }
166
+ /**
167
+ * Init-burst operation counter for a single napplet.
168
+ *
169
+ * Stored in FirewallState.bursts keyed by napplet dTag.
170
+ *
171
+ * @param count - Number of operations observed within the current burst window.
172
+ * @param windowStart - Timestamp (Unix ms) when the current burst window began.
173
+ */
174
+ interface BurstCounter {
175
+ readonly count: number;
176
+ readonly windowStart: number;
177
+ }
178
+ /**
179
+ * Ephemeral firewall counter state — immutable snapshot.
180
+ *
181
+ * Counter state is never persisted (Phase 81 concern). It is reset on reload.
182
+ * All mutations return a new FirewallState via spread; the original is never modified.
183
+ *
184
+ * Mirrors AclState's Readonly<Record<string, AclEntry>> map shape.
185
+ *
186
+ * @param buckets - Token-bucket counters keyed as `napplet:opClass`.
187
+ * @param bursts - Init-burst counters keyed by napplet dTag.
188
+ */
189
+ interface FirewallState {
190
+ readonly buckets: Readonly<Record<string, Bucket>>;
191
+ readonly bursts: Readonly<Record<string, BurstCounter>>;
192
+ }
193
+ /**
194
+ * Result returned by evaluate() — the complete output of one firewall check.
195
+ *
196
+ * The caller uses `decision` to determine what to do:
197
+ * - `'pass'` → dispatch (action may be `'flag'` → also emit audit event).
198
+ * - `'reject'` → drop + return error.
199
+ * - `'prompt'` → reject now + fire consent prompt.
200
+ *
201
+ * @param decision - Primary disposition for the caller.
202
+ * @param action - The matched rule's exceed-action (informational for `'pass'`).
203
+ * @param ruleId - Identifier of the rule that made the decision (or 'default').
204
+ * @param reason - Human-readable reason string for logging/audit.
205
+ * @param newState - The updated FirewallState after this observation (original unchanged).
206
+ */
207
+ interface EvaluateResult {
208
+ readonly decision: Decision;
209
+ readonly action: Action;
210
+ readonly ruleId: string;
211
+ readonly reason: string;
212
+ readonly newState: FirewallState;
213
+ }
214
+
215
+ /**
216
+ * @kehto/firewall — Pure evaluate function.
217
+ *
218
+ * This module is PURE, SIDE-EFFECT-FREE, and NEVER reads a wall clock.
219
+ * `observation.now` is the only time source — no wall-clock reads, no I/O, no mutations.
220
+ *
221
+ * Designed for deterministic access control decisions that could be compiled to
222
+ * WASM without modification (the WASM-ready boundary).
223
+ */
224
+
225
+ /**
226
+ * Compute the token-bucket key from napplet dTag and operation class.
227
+ *
228
+ * Key shape: `${napplet}:${opClass}` — deliberately dTag-only (version-agnostic).
229
+ *
230
+ * DIVERGENCE FROM @kehto/acl: acl uses `dTag:hash` to distinguish napplet
231
+ * versions. The firewall intentionally omits the hash so rate budgets are shared
232
+ * across ALL versions of the same napplet dTag. This is the correct behavior for
233
+ * a behavioral abuse control: we want to track the napplet identity over time,
234
+ * not its specific loaded version.
235
+ *
236
+ * @param napplet - Napplet dTag (version-agnostic identity key)
237
+ * @param opClass - Operation class string (e.g. 'relay:write', 'outbox:publish')
238
+ * @returns Composite key string `napplet:opClass`
239
+ *
240
+ * @example
241
+ * ```ts
242
+ * toKey('chat', 'relay:write')
243
+ * // => 'chat:relay:write'
244
+ * ```
245
+ */
246
+ declare function toKey(napplet: string, opClass: string): string;
247
+ /**
248
+ * Evaluate a single firewall observation and return the access decision.
249
+ *
250
+ * PURE: no wall-clock reads (no system time APIs), no I/O, no mutation.
251
+ * All time comes from `observation.now`. The original `config` and `state`
252
+ * are NEVER modified — every `newState` is returned via immutable spread.
253
+ *
254
+ * ## Precedence order (A1 — POLICY-03, first-match-wins, most→least specific)
255
+ *
256
+ * 1. **Per-napplet policy** (`allow` / `deny` / `ask`) — hard override for the dTag.
257
+ * `allow` → pass (bypass everything); `deny` → reject (block); `ask` → prompt.
258
+ * Policy returns do NOT advance any counters; newState = input state.
259
+ *
260
+ * 2. **Init-burst guard** — if `observation.initElapsedMs` is defined and less than
261
+ * `config.burstGuard.windowMs`, the burst counter for this napplet is advanced.
262
+ * If the count exceeds `config.burstGuard.maxOps`, the burst action fires
263
+ * (default `block`). The advanced burst counter is returned in newState.
264
+ *
265
+ * 3. **Content matchers** — `config.matchers` are evaluated in order; the FIRST
266
+ * matcher whose declared conditions (opClass, kinds, size, focus, msSinceFocusGain)
267
+ * ALL hold fires its action. Matchers do NOT advance the token bucket.
268
+ *
269
+ * 4. **Per-napplet × op-class rate limit** (`config.napplets[napplet].rateLimits[opClass]`)
270
+ * with ruleId `'rate:opclass'`.
271
+ *
272
+ * 5. **Per-napplet global rate fallback** (`config.napplets[napplet].globalRate`)
273
+ * for op-classes with no specific rateLimits entry. ruleId `'rate:global'`.
274
+ *
275
+ * 6. **Global default rate** (`config.defaultRate`) — applied when no napplet-specific
276
+ * rule exists. ruleId `'rate:default'`.
277
+ *
278
+ * ## Unfocused multiplier (A2 — FOCUS-02)
279
+ *
280
+ * When `observation.focused === false`, the effective bucket capacity is tightened:
281
+ * `effectiveCapacity = limit.capacity * config.unfocusedMultiplier`.
282
+ * Refill rate is derived as `effectiveCapacity / windowMs` so the drip also tightens
283
+ * proportionally. The bucket KEY stays stable (`napplet:opClass`, no focus suffix).
284
+ * Because the multiplier is always `> 0`, an unfocused napplet's budget is reduced
285
+ * but never zero — **focus alone NEVER hard-blocks**.
286
+ *
287
+ * @param config - Immutable firewall configuration
288
+ * @param state - Current ephemeral counter state (never mutated)
289
+ * @param observation - Normalized observation (the sole input surface — CORE-02)
290
+ * @returns Decision result with updated counter state
291
+ *
292
+ * @example
293
+ * ```ts
294
+ * import { evaluate, defaultConfig, createState } from '@kehto/firewall';
295
+ *
296
+ * const config = defaultConfig();
297
+ * const state = createState();
298
+ * const obs = {
299
+ * napplet: 'chat',
300
+ * opClass: 'relay:write',
301
+ * focused: true,
302
+ * now: injectedTimestamp, // caller supplies time; evaluate() never reads a clock
303
+ * };
304
+ *
305
+ * const result = evaluate(config, state, obs);
306
+ * // result.decision === 'pass'
307
+ * // result.newState has an updated token bucket for 'chat:relay:write'
308
+ * ```
309
+ */
310
+ declare function evaluate(config: FirewallConfig, state: FirewallState, observation: Observation): EvaluateResult;
311
+
312
+ /**
313
+ * @kehto/firewall — Pure config mutation functions and serialization.
314
+ *
315
+ * Every mutation function takes a FirewallConfig and returns a NEW FirewallConfig.
316
+ * The original config is never modified. No side effects, no I/O.
317
+ *
318
+ * Mirrors the role of @kehto/acl's mutations.ts: immutable spread-return mutations
319
+ * (grant/revoke pattern), plain JSON.stringify serialize, and defensive deserialize
320
+ * with shape validation and defaultConfig() fallback (V5 input validation — T-80-01).
321
+ */
322
+
323
+ /**
324
+ * Set the hard policy posture for a specific napplet (dTag).
325
+ *
326
+ * Returns a new FirewallConfig with `napplets[napplet].policy` set to `policy`.
327
+ * If the napplet has no existing entry, a fresh entry is created.
328
+ * The original config is never modified.
329
+ *
330
+ * @param config - Current firewall config
331
+ * @param napplet - Napplet dTag to configure
332
+ * @param policy - Policy posture ('allow' | 'deny' | 'ask')
333
+ * @returns New FirewallConfig with the policy set
334
+ *
335
+ * @example
336
+ * ```ts
337
+ * const cfg2 = setPolicy(cfg, 'chat', 'deny');
338
+ * // cfg2.napplets['chat'].policy === 'deny'
339
+ * // cfg is unchanged
340
+ * ```
341
+ */
342
+ declare function setPolicy(config: FirewallConfig, napplet: string, policy: NappletPolicy): FirewallConfig;
343
+ /**
344
+ * Set a per-napplet, per-opClass token-bucket rate limit.
345
+ *
346
+ * Returns a new FirewallConfig with `napplets[napplet].rateLimits[opClass]`
347
+ * set to `limit`. If the napplet has no existing entry, a fresh entry is created.
348
+ * The original config is never modified.
349
+ *
350
+ * @param config - Current firewall config
351
+ * @param napplet - Napplet dTag to configure
352
+ * @param opClass - Operation class string (e.g. 'relay:write', 'outbox:write')
353
+ * @param limit - Token-bucket rate limit to apply
354
+ * @returns New FirewallConfig with the rate limit set
355
+ *
356
+ * @example
357
+ * ```ts
358
+ * const cfg2 = setRateLimit(cfg, 'chat', 'relay:write', { capacity: 10, windowMs: 5000, action: 'block' });
359
+ * // cfg2.napplets['chat'].rateLimits['relay:write'].capacity === 10
360
+ * ```
361
+ */
362
+ declare function setRateLimit(config: FirewallConfig, napplet: string, opClass: string, limit: RateLimit): FirewallConfig;
363
+ /**
364
+ * Set a per-napplet global fallback rate limit (RATE-03).
365
+ *
366
+ * The global rate is applied to all op-classes for this napplet that lack a
367
+ * specific `rateLimits` entry. This provides a single budget covering all
368
+ * unlisted operations rather than relying solely on the config-wide `defaultRate`.
369
+ *
370
+ * Returns a new FirewallConfig with `napplets[napplet].globalRate` set.
371
+ * The original config is never modified.
372
+ *
373
+ * @param config - Current firewall config
374
+ * @param napplet - Napplet dTag to configure
375
+ * @param limit - Global fallback rate limit for this napplet
376
+ * @returns New FirewallConfig with the global rate set
377
+ *
378
+ * @example
379
+ * ```ts
380
+ * const cfg2 = setGlobalRate(cfg, 'chat', { capacity: 30, windowMs: 30000, action: 'flag' });
381
+ * // cfg2.napplets['chat'].globalRate is set; other napplets unaffected
382
+ * ```
383
+ */
384
+ declare function setGlobalRate(config: FirewallConfig, napplet: string, limit: RateLimit): FirewallConfig;
385
+ /**
386
+ * Append a content matcher to the firewall config.
387
+ *
388
+ * Matchers are evaluated in order; the first match wins (POLICY-03). Returns a
389
+ * new FirewallConfig with `matcher` appended to the end of `config.matchers`.
390
+ * The original config is never modified.
391
+ *
392
+ * @param config - Current firewall config
393
+ * @param matcher - Content matcher to append
394
+ * @returns New FirewallConfig with the matcher appended
395
+ *
396
+ * @example
397
+ * ```ts
398
+ * const cfg2 = addMatcher(cfg, { id: 'delete-spam', opClass: 'relay:write', kinds: [5], action: 'block' });
399
+ * // cfg2.matchers.length === cfg.matchers.length + 1
400
+ * ```
401
+ */
402
+ declare function addMatcher(config: FirewallConfig, matcher: ContentMatcher): FirewallConfig;
403
+ /**
404
+ * Serialize a FirewallConfig to a JSON string.
405
+ *
406
+ * Pure function — no I/O. The persistence adapter in @kehto/shell (Phase 81)
407
+ * uses this to write config to localStorage or other backends.
408
+ *
409
+ * @param config - Firewall config to serialize
410
+ * @returns JSON string representation
411
+ *
412
+ * @example
413
+ * ```ts
414
+ * const json = serialize(config);
415
+ * localStorage.setItem('kehto:firewall', json);
416
+ * ```
417
+ */
418
+ declare function serialize(config: FirewallConfig): string;
419
+ /**
420
+ * Deserialize a FirewallConfig from a JSON string.
421
+ *
422
+ * Defensive parse: tries JSON.parse, then shape-validates every top-level and
423
+ * nested field (napplets map, matchers array, burstGuard, defaultRate, and
424
+ * unfocusedMultiplier). Rebuilds a validated config from the parsed data.
425
+ *
426
+ * On ANY failure (invalid JSON, missing fields, wrong types, invalid action
427
+ * values, malformed nested structures) falls through to `defaultConfig()`.
428
+ * This function NEVER throws.
429
+ *
430
+ * Security control for T-80-01 (Tampering — persisted config string):
431
+ * the config string is the only untrusted input to @kehto/firewall. Poisoned
432
+ * or malformed strings always produce a safe, valid default config.
433
+ *
434
+ * @param json - JSON string to parse (may be untrusted)
435
+ * @returns Parsed and validated FirewallConfig, or defaultConfig() on any failure
436
+ *
437
+ * @example
438
+ * ```ts
439
+ * const json = localStorage.getItem('kehto:firewall') ?? '';
440
+ * const config = deserialize(json);
441
+ * // config is always a valid FirewallConfig — never throws
442
+ * ```
443
+ */
444
+ declare function deserialize(json: string): FirewallConfig;
445
+
446
+ /**
447
+ * Default exceed-action for rate limits: `flag` (pass + audit).
448
+ *
449
+ * Conservative, allow-and-audit default — operations are never silently blocked
450
+ * on first deployment. The shell hears about violations via audit events without
451
+ * disrupting the napplet experience (CORE-04).
452
+ *
453
+ * @example
454
+ * ```ts
455
+ * const limit: RateLimit = { capacity: 60, windowMs: 60_000, action: DEFAULT_EXCEED_ACTION };
456
+ * ```
457
+ */
458
+ declare const DEFAULT_EXCEED_ACTION: Action;
459
+ /**
460
+ * Default action for the init-burst guard: `block`.
461
+ *
462
+ * The burst guard is the one documented exception to the conservative `flag`
463
+ * default. A napplet that fires more than `DEFAULT_BURST_MAX_OPS` operations
464
+ * within its initialization window is almost certainly misbehaving and should
465
+ * be stopped immediately (BURST-02).
466
+ *
467
+ * @example
468
+ * ```ts
469
+ * const guard: BurstGuard = { windowMs: DEFAULT_BURST_WINDOW_MS, maxOps: DEFAULT_BURST_MAX_OPS, action: DEFAULT_BURST_ACTION };
470
+ * ```
471
+ */
472
+ declare const DEFAULT_BURST_ACTION: Action;
473
+ /**
474
+ * Fractional capacity multiplier applied to unfocused napplets: `0.25`.
475
+ *
476
+ * Scales the effective token-bucket capacity for napplets that are not the
477
+ * currently focused window. Chosen at 1/4 so background napplets can still
478
+ * make legitimate low-rate requests while a sustained high-rate attack from a
479
+ * background napplet is throttled quickly.
480
+ *
481
+ * MUST remain strictly greater than 0 — focus alone must NEVER hard-block a
482
+ * napplet (FOCUS-02 invariant). A value of 0 would be equivalent to an
483
+ * unconditional deny for all unfocused napplets.
484
+ *
485
+ * @example
486
+ * ```ts
487
+ * const effectiveCapacity = limit.capacity * (obs.focused ? 1 : DEFAULT_UNFOCUSED_MULTIPLIER);
488
+ * ```
489
+ */
490
+ declare const DEFAULT_UNFOCUSED_MULTIPLIER = 0.25;
491
+ /**
492
+ * Default token-bucket capacity: 60 operations per window.
493
+ *
494
+ * 60 ops/minute is generous for typical napplet relay interactions (reading
495
+ * profiles, publishing notes) while providing a clear ceiling against rapid
496
+ * automated relay flooding. Conservative without being restrictive for
497
+ * well-behaved napplets (CORE-04).
498
+ *
499
+ * @example
500
+ * ```ts
501
+ * const limit: RateLimit = { capacity: DEFAULT_RATE_CAPACITY, windowMs: DEFAULT_RATE_WINDOW_MS, action: DEFAULT_EXCEED_ACTION };
502
+ * ```
503
+ */
504
+ declare const DEFAULT_RATE_CAPACITY = 60;
505
+ /**
506
+ * Default token-bucket window: 60 000 ms (1 minute).
507
+ *
508
+ * A 1-minute rolling window pairs with `DEFAULT_RATE_CAPACITY` (60 ops) to
509
+ * give each napplet 1 op/second sustained throughput — sufficient for relay
510
+ * reads, publish flows, and intent invocations under normal usage (CORE-04).
511
+ *
512
+ * @example
513
+ * ```ts
514
+ * const refillRatePerMs = DEFAULT_RATE_CAPACITY / DEFAULT_RATE_WINDOW_MS; // 0.001 ops/ms
515
+ * ```
516
+ */
517
+ declare const DEFAULT_RATE_WINDOW_MS = 60000;
518
+ /**
519
+ * Default init-burst guard window: 3 000 ms (3 seconds).
520
+ *
521
+ * The initialization window covers the first few seconds after a napplet loads.
522
+ * Legitimate napplets bootstrap with a small number of setup requests; a napplet
523
+ * that fires many ops in the first 3 seconds is exhibiting burst-attack behavior
524
+ * (BURST-01, BURST-02).
525
+ *
526
+ * @example
527
+ * ```ts
528
+ * const guard: BurstGuard = { windowMs: DEFAULT_BURST_WINDOW_MS, maxOps: DEFAULT_BURST_MAX_OPS, action: DEFAULT_BURST_ACTION };
529
+ * ```
530
+ */
531
+ declare const DEFAULT_BURST_WINDOW_MS = 3000;
532
+ /**
533
+ * Default init-burst guard operation cap: 20 operations.
534
+ *
535
+ * 20 ops within the first 3 seconds is more than enough for any legitimate
536
+ * initialization sequence (subscribe, fetch profile, query relays). Exceeding
537
+ * this indicates automated request flooding and triggers the `block` action
538
+ * (BURST-02). Value chosen to tolerate brief SDK setup bursts while stopping
539
+ * malicious behavior.
540
+ *
541
+ * @example
542
+ * ```ts
543
+ * const guard: BurstGuard = { windowMs: DEFAULT_BURST_WINDOW_MS, maxOps: DEFAULT_BURST_MAX_OPS, action: DEFAULT_BURST_ACTION };
544
+ * ```
545
+ */
546
+ declare const DEFAULT_BURST_MAX_OPS = 20;
547
+ /**
548
+ * Assemble the built-in default FirewallConfig.
549
+ *
550
+ * Returns a fresh config applying conservative rate/burst limits to every
551
+ * napplet out of the box. No per-napplet rules or content matchers are
552
+ * pre-configured — those are added via the mutation functions in config.ts.
553
+ *
554
+ * The exceed-action default is `flag` (CORE-04 — allow + audit). The
555
+ * init-burst guard default is `block` (BURST-02 — the documented exception).
556
+ *
557
+ * @returns A new FirewallConfig with conservative built-in limits
558
+ *
559
+ * @example
560
+ * ```ts
561
+ * const config = defaultConfig();
562
+ * // config.defaultRate.action === 'flag'
563
+ * // config.burstGuard.action === 'block'
564
+ * // config.unfocusedMultiplier === 0.25
565
+ * ```
566
+ */
567
+ declare function defaultConfig(): FirewallConfig;
568
+ /**
569
+ * Create an empty FirewallState with no token-bucket or burst counters.
570
+ *
571
+ * Returns a fresh ephemeral counter state. Counter state is never persisted
572
+ * (Phase 81 concern) — it is reset on reload and rebuilt as observations arrive.
573
+ *
574
+ * Mirrors the `createState()` factory pattern from @kehto/acl/mutations.ts.
575
+ *
576
+ * @returns A new FirewallState with empty buckets and bursts maps
577
+ *
578
+ * @example
579
+ * ```ts
580
+ * const state = createState();
581
+ * // { buckets: {}, bursts: {} }
582
+ * ```
583
+ */
584
+ declare function createState(): FirewallState;
585
+
586
+ export { type Action, type Bucket, type BurstCounter, type BurstGuard, type ContentMatcher, DEFAULT_BURST_ACTION, DEFAULT_BURST_MAX_OPS, DEFAULT_BURST_WINDOW_MS, DEFAULT_EXCEED_ACTION, DEFAULT_RATE_CAPACITY, DEFAULT_RATE_WINDOW_MS, DEFAULT_UNFOCUSED_MULTIPLIER, type Decision, type EvaluateResult, type FirewallConfig, type FirewallState, type NappletPolicy, type NappletRules, type Observation, type RateLimit, addMatcher, createState, defaultConfig, deserialize, evaluate, serialize, setGlobalRate, setPolicy, setRateLimit, toKey };
package/dist/index.js ADDED
@@ -0,0 +1,313 @@
1
+ // src/evaluate.ts
2
+ function toKey(napplet, opClass) {
3
+ return `${napplet}:${opClass}`;
4
+ }
5
+ function actionToDecision(action) {
6
+ if (action === "block") return "reject";
7
+ return "pass";
8
+ }
9
+ function evaluate(config, state, observation) {
10
+ const { napplet, opClass, now } = observation;
11
+ const nappletRules = config.napplets[napplet];
12
+ const policy = nappletRules?.policy;
13
+ if (policy === "allow") {
14
+ return {
15
+ decision: "pass",
16
+ action: "ignore",
17
+ ruleId: "policy:allow",
18
+ reason: `napplet ${napplet} has allow policy \u2014 bypassing all checks`,
19
+ newState: state
20
+ };
21
+ }
22
+ if (policy === "deny") {
23
+ return {
24
+ decision: "reject",
25
+ action: "block",
26
+ ruleId: "policy:deny",
27
+ reason: `napplet ${napplet} has deny policy \u2014 always rejected`,
28
+ newState: state
29
+ };
30
+ }
31
+ if (policy === "ask") {
32
+ return {
33
+ decision: "prompt",
34
+ action: "block",
35
+ ruleId: "policy:ask",
36
+ reason: `napplet ${napplet} has ask policy \u2014 prompting for consent`,
37
+ newState: state
38
+ };
39
+ }
40
+ const { initElapsedMs } = observation;
41
+ if (initElapsedMs !== void 0 && initElapsedMs < config.burstGuard.windowMs) {
42
+ const existingBurst = state.bursts[napplet];
43
+ const newBurst = {
44
+ count: (existingBurst?.count ?? 0) + 1,
45
+ windowStart: existingBurst?.windowStart ?? now
46
+ };
47
+ const newBursts = { ...state.bursts, [napplet]: newBurst };
48
+ if (newBurst.count > config.burstGuard.maxOps) {
49
+ const burstAction = config.burstGuard.action;
50
+ return {
51
+ decision: actionToDecision(burstAction),
52
+ action: burstAction,
53
+ ruleId: "burst",
54
+ reason: `napplet ${napplet} exceeded init-burst limit (${newBurst.count} > ${config.burstGuard.maxOps} ops within ${config.burstGuard.windowMs}ms)`,
55
+ newState: { ...state, bursts: newBursts }
56
+ };
57
+ }
58
+ state = { ...state, bursts: newBursts };
59
+ }
60
+ for (const matcher of config.matchers) {
61
+ if (matcher.opClass !== void 0 && matcher.opClass !== opClass) continue;
62
+ if (matcher.kinds !== void 0) {
63
+ if (observation.kind === void 0) continue;
64
+ if (!matcher.kinds.includes(observation.kind)) continue;
65
+ }
66
+ if (matcher.minSize !== void 0) {
67
+ if (observation.size === void 0) continue;
68
+ if (observation.size < matcher.minSize) continue;
69
+ }
70
+ if (matcher.focused !== void 0 && matcher.focused !== observation.focused) continue;
71
+ if (matcher.maxMsSinceFocusGain !== void 0) {
72
+ if (observation.msSinceFocusGain === void 0) continue;
73
+ if (observation.msSinceFocusGain > matcher.maxMsSinceFocusGain) continue;
74
+ }
75
+ const matcherAction = matcher.action;
76
+ return {
77
+ decision: actionToDecision(matcherAction),
78
+ action: matcherAction,
79
+ ruleId: `matcher:${matcher.id}`,
80
+ reason: `content matcher '${matcher.id}' fired`,
81
+ newState: state
82
+ };
83
+ }
84
+ let rateLimit = config.defaultRate;
85
+ let rateLimitRuleId = "rate:default";
86
+ if (nappletRules) {
87
+ const opClassLimit = nappletRules.rateLimits[opClass];
88
+ if (opClassLimit) {
89
+ rateLimit = opClassLimit;
90
+ rateLimitRuleId = "rate:opclass";
91
+ } else if (nappletRules.globalRate) {
92
+ rateLimit = nappletRules.globalRate;
93
+ rateLimitRuleId = "rate:global";
94
+ }
95
+ }
96
+ const effectiveCapacity = observation.focused ? rateLimit.capacity : rateLimit.capacity * config.unfocusedMultiplier;
97
+ const refillRatePerMs = effectiveCapacity / rateLimit.windowMs;
98
+ const bucketKey = toKey(napplet, opClass);
99
+ const existingBucket = state.buckets[bucketKey];
100
+ const lastRefill = existingBucket?.lastRefill ?? now;
101
+ const initialTokens = existingBucket?.tokens ?? effectiveCapacity;
102
+ const elapsed = Math.max(0, now - lastRefill);
103
+ const tokens = Math.min(effectiveCapacity, initialTokens + elapsed * refillRatePerMs);
104
+ if (tokens >= 1) {
105
+ const nextBucket = { tokens: tokens - 1, lastRefill: now };
106
+ return {
107
+ decision: "pass",
108
+ action: "ignore",
109
+ ruleId: rateLimitRuleId,
110
+ reason: `within budget (${rateLimitRuleId})`,
111
+ newState: {
112
+ ...state,
113
+ buckets: { ...state.buckets, [bucketKey]: nextBucket }
114
+ }
115
+ };
116
+ } else {
117
+ const nextBucket = { tokens, lastRefill: now };
118
+ const exceedAction = rateLimit.action;
119
+ return {
120
+ decision: actionToDecision(exceedAction),
121
+ action: exceedAction,
122
+ ruleId: rateLimitRuleId,
123
+ reason: `rate limit exceeded (${rateLimitRuleId}): ${tokens.toFixed(4)} tokens available, need 1`,
124
+ newState: {
125
+ ...state,
126
+ buckets: { ...state.buckets, [bucketKey]: nextBucket }
127
+ }
128
+ };
129
+ }
130
+ }
131
+
132
+ // src/defaults.ts
133
+ var DEFAULT_EXCEED_ACTION = "flag";
134
+ var DEFAULT_BURST_ACTION = "block";
135
+ var DEFAULT_UNFOCUSED_MULTIPLIER = 0.25;
136
+ var DEFAULT_RATE_CAPACITY = 60;
137
+ var DEFAULT_RATE_WINDOW_MS = 6e4;
138
+ var DEFAULT_BURST_WINDOW_MS = 3e3;
139
+ var DEFAULT_BURST_MAX_OPS = 20;
140
+ function defaultConfig() {
141
+ return {
142
+ napplets: {},
143
+ matchers: [],
144
+ burstGuard: {
145
+ windowMs: DEFAULT_BURST_WINDOW_MS,
146
+ maxOps: DEFAULT_BURST_MAX_OPS,
147
+ action: DEFAULT_BURST_ACTION
148
+ },
149
+ defaultRate: {
150
+ capacity: DEFAULT_RATE_CAPACITY,
151
+ windowMs: DEFAULT_RATE_WINDOW_MS,
152
+ action: DEFAULT_EXCEED_ACTION
153
+ },
154
+ unfocusedMultiplier: DEFAULT_UNFOCUSED_MULTIPLIER
155
+ };
156
+ }
157
+ function createState() {
158
+ return { buckets: {}, bursts: {} };
159
+ }
160
+
161
+ // src/config.ts
162
+ function getNapplet(config, napplet) {
163
+ const existing = config.napplets[napplet];
164
+ if (existing) return existing;
165
+ return { rateLimits: {} };
166
+ }
167
+ function setPolicy(config, napplet, policy) {
168
+ const entry = getNapplet(config, napplet);
169
+ return {
170
+ ...config,
171
+ napplets: {
172
+ ...config.napplets,
173
+ [napplet]: { ...entry, policy }
174
+ }
175
+ };
176
+ }
177
+ function setRateLimit(config, napplet, opClass, limit) {
178
+ const entry = getNapplet(config, napplet);
179
+ return {
180
+ ...config,
181
+ napplets: {
182
+ ...config.napplets,
183
+ [napplet]: {
184
+ ...entry,
185
+ rateLimits: { ...entry.rateLimits, [opClass]: limit }
186
+ }
187
+ }
188
+ };
189
+ }
190
+ function setGlobalRate(config, napplet, limit) {
191
+ const entry = getNapplet(config, napplet);
192
+ return {
193
+ ...config,
194
+ napplets: {
195
+ ...config.napplets,
196
+ [napplet]: { ...entry, globalRate: limit }
197
+ }
198
+ };
199
+ }
200
+ function addMatcher(config, matcher) {
201
+ return { ...config, matchers: [...config.matchers, matcher] };
202
+ }
203
+ function serialize(config) {
204
+ return JSON.stringify(config);
205
+ }
206
+ var VALID_ACTIONS = ["flag", "block", "ignore"];
207
+ function isValidAction(v) {
208
+ return VALID_ACTIONS.includes(v);
209
+ }
210
+ function isValidRateLimit(v) {
211
+ if (typeof v !== "object" || v === null) return false;
212
+ const r = v;
213
+ return typeof r["capacity"] === "number" && isFinite(r["capacity"]) && typeof r["windowMs"] === "number" && isFinite(r["windowMs"]) && isValidAction(r["action"]);
214
+ }
215
+ function isValidBurstGuard(v) {
216
+ if (typeof v !== "object" || v === null) return false;
217
+ const b = v;
218
+ return typeof b["windowMs"] === "number" && isFinite(b["windowMs"]) && typeof b["maxOps"] === "number" && isFinite(b["maxOps"]) && isValidAction(b["action"]);
219
+ }
220
+ function isValidContentMatcher(v) {
221
+ if (typeof v !== "object" || v === null) return false;
222
+ const m = v;
223
+ if (typeof m["id"] !== "string") return false;
224
+ if (!isValidAction(m["action"])) return false;
225
+ if ("opClass" in m && typeof m["opClass"] !== "string") return false;
226
+ if ("minSize" in m && typeof m["minSize"] !== "number") return false;
227
+ if ("focused" in m && typeof m["focused"] !== "boolean") return false;
228
+ if ("maxMsSinceFocusGain" in m && typeof m["maxMsSinceFocusGain"] !== "number") return false;
229
+ if ("kinds" in m) {
230
+ if (!Array.isArray(m["kinds"])) return false;
231
+ if (!m["kinds"].every((k) => typeof k === "number")) return false;
232
+ }
233
+ return true;
234
+ }
235
+ function isValidNappletRules(v) {
236
+ if (typeof v !== "object" || v === null) return false;
237
+ const n = v;
238
+ if (typeof n["rateLimits"] !== "object" || n["rateLimits"] === null) return false;
239
+ for (const limit of Object.values(n["rateLimits"])) {
240
+ if (!isValidRateLimit(limit)) return false;
241
+ }
242
+ if ("policy" in n) {
243
+ const p = n["policy"];
244
+ if (p !== "allow" && p !== "deny" && p !== "ask") return false;
245
+ }
246
+ if ("globalRate" in n && !isValidRateLimit(n["globalRate"])) return false;
247
+ return true;
248
+ }
249
+ function deserialize(json) {
250
+ try {
251
+ const parsed = JSON.parse(json);
252
+ if (typeof parsed !== "object" || parsed === null || typeof parsed.napplets !== "object" || parsed.napplets === null || !Array.isArray(parsed.matchers) || !isValidBurstGuard(parsed.burstGuard) || !isValidRateLimit(parsed.defaultRate) || typeof parsed.unfocusedMultiplier !== "number" || !isFinite(parsed.unfocusedMultiplier)) {
253
+ return defaultConfig();
254
+ }
255
+ const napplets = {};
256
+ for (const [key, value] of Object.entries(parsed.napplets)) {
257
+ if (!isValidNappletRules(value)) return defaultConfig();
258
+ const raw = value;
259
+ const rateLimits = {};
260
+ for (const [opClass, limit] of Object.entries(raw.rateLimits)) {
261
+ rateLimits[opClass] = limit;
262
+ }
263
+ const entry = { rateLimits };
264
+ const withPolicy = raw.policy !== void 0 ? { ...entry, policy: raw.policy } : entry;
265
+ const withGlobalRate = raw.globalRate !== void 0 ? { ...withPolicy, globalRate: raw.globalRate } : withPolicy;
266
+ napplets[key] = withGlobalRate;
267
+ }
268
+ const matchers = [];
269
+ for (const item of parsed.matchers) {
270
+ if (!isValidContentMatcher(item)) return defaultConfig();
271
+ matchers.push(item);
272
+ }
273
+ const bg = parsed.burstGuard;
274
+ const dr = parsed.defaultRate;
275
+ return {
276
+ napplets,
277
+ matchers,
278
+ burstGuard: {
279
+ windowMs: bg["windowMs"],
280
+ maxOps: bg["maxOps"],
281
+ action: bg["action"]
282
+ },
283
+ defaultRate: {
284
+ capacity: dr["capacity"],
285
+ windowMs: dr["windowMs"],
286
+ action: dr["action"]
287
+ },
288
+ unfocusedMultiplier: parsed.unfocusedMultiplier
289
+ };
290
+ } catch {
291
+ }
292
+ return defaultConfig();
293
+ }
294
+ export {
295
+ DEFAULT_BURST_ACTION,
296
+ DEFAULT_BURST_MAX_OPS,
297
+ DEFAULT_BURST_WINDOW_MS,
298
+ DEFAULT_EXCEED_ACTION,
299
+ DEFAULT_RATE_CAPACITY,
300
+ DEFAULT_RATE_WINDOW_MS,
301
+ DEFAULT_UNFOCUSED_MULTIPLIER,
302
+ addMatcher,
303
+ createState,
304
+ defaultConfig,
305
+ deserialize,
306
+ evaluate,
307
+ serialize,
308
+ setGlobalRate,
309
+ setPolicy,
310
+ setRateLimit,
311
+ toKey
312
+ };
313
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/evaluate.ts","../src/defaults.ts","../src/config.ts"],"sourcesContent":["/**\n * @kehto/firewall — Pure evaluate function.\n *\n * This module is PURE, SIDE-EFFECT-FREE, and NEVER reads a wall clock.\n * `observation.now` is the only time source — no wall-clock reads, no I/O, no mutations.\n *\n * Designed for deterministic access control decisions that could be compiled to\n * WASM without modification (the WASM-ready boundary).\n */\n\nimport type {\n Observation,\n FirewallConfig,\n FirewallState,\n Bucket,\n BurstCounter,\n EvaluateResult,\n Action,\n Decision,\n} from './types.js';\n\n/**\n * Compute the token-bucket key from napplet dTag and operation class.\n *\n * Key shape: `${napplet}:${opClass}` — deliberately dTag-only (version-agnostic).\n *\n * DIVERGENCE FROM @kehto/acl: acl uses `dTag:hash` to distinguish napplet\n * versions. The firewall intentionally omits the hash so rate budgets are shared\n * across ALL versions of the same napplet dTag. This is the correct behavior for\n * a behavioral abuse control: we want to track the napplet identity over time,\n * not its specific loaded version.\n *\n * @param napplet - Napplet dTag (version-agnostic identity key)\n * @param opClass - Operation class string (e.g. 'relay:write', 'outbox:publish')\n * @returns Composite key string `napplet:opClass`\n *\n * @example\n * ```ts\n * toKey('chat', 'relay:write')\n * // => 'chat:relay:write'\n * ```\n */\nexport function toKey(napplet: string, opClass: string): string {\n return `${napplet}:${opClass}`;\n}\n\n/**\n * Map a rule Action to the caller-facing Decision.\n *\n * - `'flag'` → `'pass'` (caller dispatches + emits audit event)\n * - `'block'` → `'reject'` (caller drops the operation)\n * - `'ignore'` → `'pass'` (caller dispatches silently)\n */\nfunction actionToDecision(action: Action): Decision {\n if (action === 'block') return 'reject';\n return 'pass'; // 'flag' and 'ignore' both pass\n}\n\n/**\n * Evaluate a single firewall observation and return the access decision.\n *\n * PURE: no wall-clock reads (no system time APIs), no I/O, no mutation.\n * All time comes from `observation.now`. The original `config` and `state`\n * are NEVER modified — every `newState` is returned via immutable spread.\n *\n * ## Precedence order (A1 — POLICY-03, first-match-wins, most→least specific)\n *\n * 1. **Per-napplet policy** (`allow` / `deny` / `ask`) — hard override for the dTag.\n * `allow` → pass (bypass everything); `deny` → reject (block); `ask` → prompt.\n * Policy returns do NOT advance any counters; newState = input state.\n *\n * 2. **Init-burst guard** — if `observation.initElapsedMs` is defined and less than\n * `config.burstGuard.windowMs`, the burst counter for this napplet is advanced.\n * If the count exceeds `config.burstGuard.maxOps`, the burst action fires\n * (default `block`). The advanced burst counter is returned in newState.\n *\n * 3. **Content matchers** — `config.matchers` are evaluated in order; the FIRST\n * matcher whose declared conditions (opClass, kinds, size, focus, msSinceFocusGain)\n * ALL hold fires its action. Matchers do NOT advance the token bucket.\n *\n * 4. **Per-napplet × op-class rate limit** (`config.napplets[napplet].rateLimits[opClass]`)\n * with ruleId `'rate:opclass'`.\n *\n * 5. **Per-napplet global rate fallback** (`config.napplets[napplet].globalRate`)\n * for op-classes with no specific rateLimits entry. ruleId `'rate:global'`.\n *\n * 6. **Global default rate** (`config.defaultRate`) — applied when no napplet-specific\n * rule exists. ruleId `'rate:default'`.\n *\n * ## Unfocused multiplier (A2 — FOCUS-02)\n *\n * When `observation.focused === false`, the effective bucket capacity is tightened:\n * `effectiveCapacity = limit.capacity * config.unfocusedMultiplier`.\n * Refill rate is derived as `effectiveCapacity / windowMs` so the drip also tightens\n * proportionally. The bucket KEY stays stable (`napplet:opClass`, no focus suffix).\n * Because the multiplier is always `> 0`, an unfocused napplet's budget is reduced\n * but never zero — **focus alone NEVER hard-blocks**.\n *\n * @param config - Immutable firewall configuration\n * @param state - Current ephemeral counter state (never mutated)\n * @param observation - Normalized observation (the sole input surface — CORE-02)\n * @returns Decision result with updated counter state\n *\n * @example\n * ```ts\n * import { evaluate, defaultConfig, createState } from '@kehto/firewall';\n *\n * const config = defaultConfig();\n * const state = createState();\n * const obs = {\n * napplet: 'chat',\n * opClass: 'relay:write',\n * focused: true,\n * now: injectedTimestamp, // caller supplies time; evaluate() never reads a clock\n * };\n *\n * const result = evaluate(config, state, obs);\n * // result.decision === 'pass'\n * // result.newState has an updated token bucket for 'chat:relay:write'\n * ```\n */\nexport function evaluate(\n config: FirewallConfig,\n state: FirewallState,\n observation: Observation,\n): EvaluateResult {\n const { napplet, opClass, now } = observation;\n\n const nappletRules = config.napplets[napplet];\n const policy = nappletRules?.policy;\n\n if (policy === 'allow') {\n return {\n decision: 'pass',\n action: 'ignore',\n ruleId: 'policy:allow',\n reason: `napplet ${napplet} has allow policy — bypassing all checks`,\n newState: state,\n };\n }\n\n if (policy === 'deny') {\n return {\n decision: 'reject',\n action: 'block',\n ruleId: 'policy:deny',\n reason: `napplet ${napplet} has deny policy — always rejected`,\n newState: state,\n };\n }\n\n if (policy === 'ask') {\n return {\n decision: 'prompt',\n action: 'block',\n ruleId: 'policy:ask',\n reason: `napplet ${napplet} has ask policy — prompting for consent`,\n newState: state,\n };\n }\n\n const { initElapsedMs } = observation;\n\n if (initElapsedMs !== undefined && initElapsedMs < config.burstGuard.windowMs) {\n // Advance the burst counter for this napplet\n const existingBurst: BurstCounter | undefined = state.bursts[napplet];\n const newBurst: BurstCounter = {\n count: (existingBurst?.count ?? 0) + 1,\n windowStart: existingBurst?.windowStart ?? now,\n };\n\n const newBursts = { ...state.bursts, [napplet]: newBurst };\n\n if (newBurst.count > config.burstGuard.maxOps) {\n const burstAction = config.burstGuard.action;\n return {\n decision: actionToDecision(burstAction),\n action: burstAction,\n ruleId: 'burst',\n reason: `napplet ${napplet} exceeded init-burst limit (${newBurst.count} > ${config.burstGuard.maxOps} ops within ${config.burstGuard.windowMs}ms)`,\n newState: { ...state, bursts: newBursts },\n };\n }\n\n // Burst count advanced but not exceeded — continue to next tier with updated bursts\n // We update state to carry the burst counter forward\n state = { ...state, bursts: newBursts };\n }\n\n for (const matcher of config.matchers) {\n if (matcher.opClass !== undefined && matcher.opClass !== opClass) continue;\n\n // Check kinds condition (observation.kind must be in the set)\n if (matcher.kinds !== undefined) {\n if (observation.kind === undefined) continue;\n if (!matcher.kinds.includes(observation.kind)) continue;\n }\n\n if (matcher.minSize !== undefined) {\n if (observation.size === undefined) continue;\n if (observation.size < matcher.minSize) continue;\n }\n\n if (matcher.focused !== undefined && matcher.focused !== observation.focused) continue;\n\n if (matcher.maxMsSinceFocusGain !== undefined) {\n if (observation.msSinceFocusGain === undefined) continue;\n if (observation.msSinceFocusGain > matcher.maxMsSinceFocusGain) continue;\n }\n\n // All conditions satisfied — this matcher fires\n const matcherAction = matcher.action;\n return {\n decision: actionToDecision(matcherAction),\n action: matcherAction,\n ruleId: `matcher:${matcher.id}`,\n reason: `content matcher '${matcher.id}' fired`,\n newState: state,\n };\n }\n\n // Resolve the applicable RateLimit (precedence: op-class > global > default)\n let rateLimit = config.defaultRate;\n let rateLimitRuleId = 'rate:default';\n\n if (nappletRules) {\n const opClassLimit = nappletRules.rateLimits[opClass];\n if (opClassLimit) {\n rateLimit = opClassLimit;\n rateLimitRuleId = 'rate:opclass';\n } else if (nappletRules.globalRate) {\n rateLimit = nappletRules.globalRate;\n rateLimitRuleId = 'rate:global';\n }\n }\n\n // Apply focus multiplier to capacity (A2 — never zeroes the budget)\n const effectiveCapacity = observation.focused\n ? rateLimit.capacity\n : rateLimit.capacity * config.unfocusedMultiplier;\n\n // Refill rate derived from effective capacity (drip tightens proportionally with focus)\n const refillRatePerMs = effectiveCapacity / rateLimit.windowMs;\n\n // Lazy-init bucket: absent key means fresh napplet starting full at `now`\n const bucketKey = toKey(napplet, opClass);\n const existingBucket: Bucket | undefined = state.buckets[bucketKey];\n\n // RESEARCH Pattern 2 token-bucket math:\n // lazy `lastRefill || now` init — a fresh key starts FULL at effectiveCapacity at now\n const lastRefill = existingBucket?.lastRefill ?? now;\n const initialTokens = existingBucket?.tokens ?? effectiveCapacity;\n\n // Clamp negative clock skew to 0 (T-80-03 mitigation)\n const elapsed = Math.max(0, now - lastRefill);\n\n // Refill tokens up to effectiveCapacity, keep fractional tokens\n const tokens = Math.min(effectiveCapacity, initialTokens + elapsed * refillRatePerMs);\n\n if (tokens >= 1) {\n // Within budget — spend one token and pass\n // ruleId encodes the resolution path so callers and tests can observe which tier resolved\n const nextBucket: Bucket = { tokens: tokens - 1, lastRefill: now };\n return {\n decision: 'pass',\n action: 'ignore',\n ruleId: rateLimitRuleId,\n reason: `within budget (${rateLimitRuleId})`,\n newState: {\n ...state,\n buckets: { ...state.buckets, [bucketKey]: nextBucket },\n },\n };\n } else {\n // Exceeded budget — apply the rule's exceed-action; still update bucket (no token spent)\n const nextBucket: Bucket = { tokens, lastRefill: now };\n const exceedAction = rateLimit.action;\n return {\n decision: actionToDecision(exceedAction),\n action: exceedAction,\n ruleId: rateLimitRuleId,\n reason: `rate limit exceeded (${rateLimitRuleId}): ${tokens.toFixed(4)} tokens available, need 1`,\n newState: {\n ...state,\n buckets: { ...state.buckets, [bucketKey]: nextBucket },\n },\n };\n }\n}\n","\nimport type { Action, FirewallConfig, FirewallState } from './types.js';\n\n/**\n * Default exceed-action for rate limits: `flag` (pass + audit).\n *\n * Conservative, allow-and-audit default — operations are never silently blocked\n * on first deployment. The shell hears about violations via audit events without\n * disrupting the napplet experience (CORE-04).\n *\n * @example\n * ```ts\n * const limit: RateLimit = { capacity: 60, windowMs: 60_000, action: DEFAULT_EXCEED_ACTION };\n * ```\n */\nexport const DEFAULT_EXCEED_ACTION: Action = 'flag';\n\n/**\n * Default action for the init-burst guard: `block`.\n *\n * The burst guard is the one documented exception to the conservative `flag`\n * default. A napplet that fires more than `DEFAULT_BURST_MAX_OPS` operations\n * within its initialization window is almost certainly misbehaving and should\n * be stopped immediately (BURST-02).\n *\n * @example\n * ```ts\n * const guard: BurstGuard = { windowMs: DEFAULT_BURST_WINDOW_MS, maxOps: DEFAULT_BURST_MAX_OPS, action: DEFAULT_BURST_ACTION };\n * ```\n */\nexport const DEFAULT_BURST_ACTION: Action = 'block';\n\n/**\n * Fractional capacity multiplier applied to unfocused napplets: `0.25`.\n *\n * Scales the effective token-bucket capacity for napplets that are not the\n * currently focused window. Chosen at 1/4 so background napplets can still\n * make legitimate low-rate requests while a sustained high-rate attack from a\n * background napplet is throttled quickly.\n *\n * MUST remain strictly greater than 0 — focus alone must NEVER hard-block a\n * napplet (FOCUS-02 invariant). A value of 0 would be equivalent to an\n * unconditional deny for all unfocused napplets.\n *\n * @example\n * ```ts\n * const effectiveCapacity = limit.capacity * (obs.focused ? 1 : DEFAULT_UNFOCUSED_MULTIPLIER);\n * ```\n */\nexport const DEFAULT_UNFOCUSED_MULTIPLIER = 0.25;\n\n/**\n * Default token-bucket capacity: 60 operations per window.\n *\n * 60 ops/minute is generous for typical napplet relay interactions (reading\n * profiles, publishing notes) while providing a clear ceiling against rapid\n * automated relay flooding. Conservative without being restrictive for\n * well-behaved napplets (CORE-04).\n *\n * @example\n * ```ts\n * const limit: RateLimit = { capacity: DEFAULT_RATE_CAPACITY, windowMs: DEFAULT_RATE_WINDOW_MS, action: DEFAULT_EXCEED_ACTION };\n * ```\n */\nexport const DEFAULT_RATE_CAPACITY = 60;\n\n/**\n * Default token-bucket window: 60 000 ms (1 minute).\n *\n * A 1-minute rolling window pairs with `DEFAULT_RATE_CAPACITY` (60 ops) to\n * give each napplet 1 op/second sustained throughput — sufficient for relay\n * reads, publish flows, and intent invocations under normal usage (CORE-04).\n *\n * @example\n * ```ts\n * const refillRatePerMs = DEFAULT_RATE_CAPACITY / DEFAULT_RATE_WINDOW_MS; // 0.001 ops/ms\n * ```\n */\nexport const DEFAULT_RATE_WINDOW_MS = 60_000;\n\n/**\n * Default init-burst guard window: 3 000 ms (3 seconds).\n *\n * The initialization window covers the first few seconds after a napplet loads.\n * Legitimate napplets bootstrap with a small number of setup requests; a napplet\n * that fires many ops in the first 3 seconds is exhibiting burst-attack behavior\n * (BURST-01, BURST-02).\n *\n * @example\n * ```ts\n * const guard: BurstGuard = { windowMs: DEFAULT_BURST_WINDOW_MS, maxOps: DEFAULT_BURST_MAX_OPS, action: DEFAULT_BURST_ACTION };\n * ```\n */\nexport const DEFAULT_BURST_WINDOW_MS = 3_000;\n\n/**\n * Default init-burst guard operation cap: 20 operations.\n *\n * 20 ops within the first 3 seconds is more than enough for any legitimate\n * initialization sequence (subscribe, fetch profile, query relays). Exceeding\n * this indicates automated request flooding and triggers the `block` action\n * (BURST-02). Value chosen to tolerate brief SDK setup bursts while stopping\n * malicious behavior.\n *\n * @example\n * ```ts\n * const guard: BurstGuard = { windowMs: DEFAULT_BURST_WINDOW_MS, maxOps: DEFAULT_BURST_MAX_OPS, action: DEFAULT_BURST_ACTION };\n * ```\n */\nexport const DEFAULT_BURST_MAX_OPS = 20;\n\n/**\n * Assemble the built-in default FirewallConfig.\n *\n * Returns a fresh config applying conservative rate/burst limits to every\n * napplet out of the box. No per-napplet rules or content matchers are\n * pre-configured — those are added via the mutation functions in config.ts.\n *\n * The exceed-action default is `flag` (CORE-04 — allow + audit). The\n * init-burst guard default is `block` (BURST-02 — the documented exception).\n *\n * @returns A new FirewallConfig with conservative built-in limits\n *\n * @example\n * ```ts\n * const config = defaultConfig();\n * // config.defaultRate.action === 'flag'\n * // config.burstGuard.action === 'block'\n * // config.unfocusedMultiplier === 0.25\n * ```\n */\nexport function defaultConfig(): FirewallConfig {\n return {\n napplets: {},\n matchers: [],\n burstGuard: {\n windowMs: DEFAULT_BURST_WINDOW_MS,\n maxOps: DEFAULT_BURST_MAX_OPS,\n action: DEFAULT_BURST_ACTION,\n },\n defaultRate: {\n capacity: DEFAULT_RATE_CAPACITY,\n windowMs: DEFAULT_RATE_WINDOW_MS,\n action: DEFAULT_EXCEED_ACTION,\n },\n unfocusedMultiplier: DEFAULT_UNFOCUSED_MULTIPLIER,\n };\n}\n\n/**\n * Create an empty FirewallState with no token-bucket or burst counters.\n *\n * Returns a fresh ephemeral counter state. Counter state is never persisted\n * (Phase 81 concern) — it is reset on reload and rebuilt as observations arrive.\n *\n * Mirrors the `createState()` factory pattern from @kehto/acl/mutations.ts.\n *\n * @returns A new FirewallState with empty buckets and bursts maps\n *\n * @example\n * ```ts\n * const state = createState();\n * // { buckets: {}, bursts: {} }\n * ```\n */\nexport function createState(): FirewallState {\n return { buckets: {}, bursts: {} };\n}\n","/**\n * @kehto/firewall — Pure config mutation functions and serialization.\n *\n * Every mutation function takes a FirewallConfig and returns a NEW FirewallConfig.\n * The original config is never modified. No side effects, no I/O.\n *\n * Mirrors the role of @kehto/acl's mutations.ts: immutable spread-return mutations\n * (grant/revoke pattern), plain JSON.stringify serialize, and defensive deserialize\n * with shape validation and defaultConfig() fallback (V5 input validation — T-80-01).\n */\n\nimport type {\n FirewallConfig,\n NappletRules,\n RateLimit,\n ContentMatcher,\n NappletPolicy,\n Action,\n} from './types.js';\nimport { defaultConfig } from './defaults.js';\n\n/**\n * Return the existing NappletRules for `napplet`, or a fresh empty entry.\n * Internal helper — not exported. Mirrors acl's `getEntry` (mutations.ts:33-42).\n */\nfunction getNapplet(config: FirewallConfig, napplet: string): NappletRules {\n const existing = config.napplets[napplet];\n if (existing) return existing;\n return { rateLimits: {} };\n}\n\n/**\n * Set the hard policy posture for a specific napplet (dTag).\n *\n * Returns a new FirewallConfig with `napplets[napplet].policy` set to `policy`.\n * If the napplet has no existing entry, a fresh entry is created.\n * The original config is never modified.\n *\n * @param config - Current firewall config\n * @param napplet - Napplet dTag to configure\n * @param policy - Policy posture ('allow' | 'deny' | 'ask')\n * @returns New FirewallConfig with the policy set\n *\n * @example\n * ```ts\n * const cfg2 = setPolicy(cfg, 'chat', 'deny');\n * // cfg2.napplets['chat'].policy === 'deny'\n * // cfg is unchanged\n * ```\n */\nexport function setPolicy(\n config: FirewallConfig,\n napplet: string,\n policy: NappletPolicy,\n): FirewallConfig {\n const entry = getNapplet(config, napplet);\n return {\n ...config,\n napplets: {\n ...config.napplets,\n [napplet]: { ...entry, policy },\n },\n };\n}\n\n/**\n * Set a per-napplet, per-opClass token-bucket rate limit.\n *\n * Returns a new FirewallConfig with `napplets[napplet].rateLimits[opClass]`\n * set to `limit`. If the napplet has no existing entry, a fresh entry is created.\n * The original config is never modified.\n *\n * @param config - Current firewall config\n * @param napplet - Napplet dTag to configure\n * @param opClass - Operation class string (e.g. 'relay:write', 'outbox:write')\n * @param limit - Token-bucket rate limit to apply\n * @returns New FirewallConfig with the rate limit set\n *\n * @example\n * ```ts\n * const cfg2 = setRateLimit(cfg, 'chat', 'relay:write', { capacity: 10, windowMs: 5000, action: 'block' });\n * // cfg2.napplets['chat'].rateLimits['relay:write'].capacity === 10\n * ```\n */\nexport function setRateLimit(\n config: FirewallConfig,\n napplet: string,\n opClass: string,\n limit: RateLimit,\n): FirewallConfig {\n const entry = getNapplet(config, napplet);\n return {\n ...config,\n napplets: {\n ...config.napplets,\n [napplet]: {\n ...entry,\n rateLimits: { ...entry.rateLimits, [opClass]: limit },\n },\n },\n };\n}\n\n/**\n * Set a per-napplet global fallback rate limit (RATE-03).\n *\n * The global rate is applied to all op-classes for this napplet that lack a\n * specific `rateLimits` entry. This provides a single budget covering all\n * unlisted operations rather than relying solely on the config-wide `defaultRate`.\n *\n * Returns a new FirewallConfig with `napplets[napplet].globalRate` set.\n * The original config is never modified.\n *\n * @param config - Current firewall config\n * @param napplet - Napplet dTag to configure\n * @param limit - Global fallback rate limit for this napplet\n * @returns New FirewallConfig with the global rate set\n *\n * @example\n * ```ts\n * const cfg2 = setGlobalRate(cfg, 'chat', { capacity: 30, windowMs: 30000, action: 'flag' });\n * // cfg2.napplets['chat'].globalRate is set; other napplets unaffected\n * ```\n */\nexport function setGlobalRate(\n config: FirewallConfig,\n napplet: string,\n limit: RateLimit,\n): FirewallConfig {\n const entry = getNapplet(config, napplet);\n return {\n ...config,\n napplets: {\n ...config.napplets,\n [napplet]: { ...entry, globalRate: limit },\n },\n };\n}\n\n/**\n * Append a content matcher to the firewall config.\n *\n * Matchers are evaluated in order; the first match wins (POLICY-03). Returns a\n * new FirewallConfig with `matcher` appended to the end of `config.matchers`.\n * The original config is never modified.\n *\n * @param config - Current firewall config\n * @param matcher - Content matcher to append\n * @returns New FirewallConfig with the matcher appended\n *\n * @example\n * ```ts\n * const cfg2 = addMatcher(cfg, { id: 'delete-spam', opClass: 'relay:write', kinds: [5], action: 'block' });\n * // cfg2.matchers.length === cfg.matchers.length + 1\n * ```\n */\nexport function addMatcher(config: FirewallConfig, matcher: ContentMatcher): FirewallConfig {\n return { ...config, matchers: [...config.matchers, matcher] };\n}\n\n/**\n * Serialize a FirewallConfig to a JSON string.\n *\n * Pure function — no I/O. The persistence adapter in @kehto/shell (Phase 81)\n * uses this to write config to localStorage or other backends.\n *\n * @param config - Firewall config to serialize\n * @returns JSON string representation\n *\n * @example\n * ```ts\n * const json = serialize(config);\n * localStorage.setItem('kehto:firewall', json);\n * ```\n */\nexport function serialize(config: FirewallConfig): string {\n return JSON.stringify(config);\n}\n\nconst VALID_ACTIONS: Action[] = ['flag', 'block', 'ignore'];\n\nfunction isValidAction(v: unknown): v is Action {\n return VALID_ACTIONS.includes(v as Action);\n}\n\nfunction isValidRateLimit(v: unknown): v is RateLimit {\n if (typeof v !== 'object' || v === null) return false;\n const r = v as Record<string, unknown>;\n return (\n typeof r['capacity'] === 'number' &&\n isFinite(r['capacity'] as number) &&\n typeof r['windowMs'] === 'number' &&\n isFinite(r['windowMs'] as number) &&\n isValidAction(r['action'])\n );\n}\n\nfunction isValidBurstGuard(v: unknown): boolean {\n if (typeof v !== 'object' || v === null) return false;\n const b = v as Record<string, unknown>;\n return (\n typeof b['windowMs'] === 'number' &&\n isFinite(b['windowMs'] as number) &&\n typeof b['maxOps'] === 'number' &&\n isFinite(b['maxOps'] as number) &&\n isValidAction(b['action'])\n );\n}\n\nfunction isValidContentMatcher(v: unknown): v is ContentMatcher {\n if (typeof v !== 'object' || v === null) return false;\n const m = v as Record<string, unknown>;\n if (typeof m['id'] !== 'string') return false;\n if (!isValidAction(m['action'])) return false;\n // Optional fields — if present, validate types\n if ('opClass' in m && typeof m['opClass'] !== 'string') return false;\n if ('minSize' in m && typeof m['minSize'] !== 'number') return false;\n if ('focused' in m && typeof m['focused'] !== 'boolean') return false;\n if ('maxMsSinceFocusGain' in m && typeof m['maxMsSinceFocusGain'] !== 'number') return false;\n if ('kinds' in m) {\n if (!Array.isArray(m['kinds'])) return false;\n if (!(m['kinds'] as unknown[]).every((k) => typeof k === 'number')) return false;\n }\n return true;\n}\n\nfunction isValidNappletRules(v: unknown): v is NappletRules {\n if (typeof v !== 'object' || v === null) return false;\n const n = v as Record<string, unknown>;\n // rateLimits must be an object\n if (typeof n['rateLimits'] !== 'object' || n['rateLimits'] === null) return false;\n for (const limit of Object.values(n['rateLimits'] as Record<string, unknown>)) {\n if (!isValidRateLimit(limit)) return false;\n }\n // Optional policy\n if ('policy' in n) {\n const p = n['policy'];\n if (p !== 'allow' && p !== 'deny' && p !== 'ask') return false;\n }\n // Optional globalRate\n if ('globalRate' in n && !isValidRateLimit(n['globalRate'])) return false;\n return true;\n}\n\n/**\n * Deserialize a FirewallConfig from a JSON string.\n *\n * Defensive parse: tries JSON.parse, then shape-validates every top-level and\n * nested field (napplets map, matchers array, burstGuard, defaultRate, and\n * unfocusedMultiplier). Rebuilds a validated config from the parsed data.\n *\n * On ANY failure (invalid JSON, missing fields, wrong types, invalid action\n * values, malformed nested structures) falls through to `defaultConfig()`.\n * This function NEVER throws.\n *\n * Security control for T-80-01 (Tampering — persisted config string):\n * the config string is the only untrusted input to @kehto/firewall. Poisoned\n * or malformed strings always produce a safe, valid default config.\n *\n * @param json - JSON string to parse (may be untrusted)\n * @returns Parsed and validated FirewallConfig, or defaultConfig() on any failure\n *\n * @example\n * ```ts\n * const json = localStorage.getItem('kehto:firewall') ?? '';\n * const config = deserialize(json);\n * // config is always a valid FirewallConfig — never throws\n * ```\n */\nexport function deserialize(json: string): FirewallConfig {\n try {\n const parsed = JSON.parse(json);\n\n // Top-level shape check\n if (\n typeof parsed !== 'object' ||\n parsed === null ||\n typeof parsed.napplets !== 'object' ||\n parsed.napplets === null ||\n !Array.isArray(parsed.matchers) ||\n !isValidBurstGuard(parsed.burstGuard) ||\n !isValidRateLimit(parsed.defaultRate) ||\n typeof parsed.unfocusedMultiplier !== 'number' ||\n !isFinite(parsed.unfocusedMultiplier)\n ) {\n return defaultConfig();\n }\n\n const napplets: Record<string, NappletRules> = {};\n for (const [key, value] of Object.entries(parsed.napplets as Record<string, unknown>)) {\n if (!isValidNappletRules(value)) return defaultConfig();\n // The type guard above narrows `value` to NappletRules.\n const raw = value;\n const rateLimits: Record<string, RateLimit> = {};\n for (const [opClass, limit] of Object.entries(raw.rateLimits)) {\n rateLimits[opClass] = limit;\n }\n const entry: NappletRules = { rateLimits };\n const withPolicy: NappletRules = raw.policy !== undefined\n ? { ...entry, policy: raw.policy }\n : entry;\n const withGlobalRate: NappletRules = raw.globalRate !== undefined\n ? { ...withPolicy, globalRate: raw.globalRate }\n : withPolicy;\n napplets[key] = withGlobalRate;\n }\n\n const matchers: ContentMatcher[] = [];\n for (const item of parsed.matchers as unknown[]) {\n if (!isValidContentMatcher(item)) return defaultConfig();\n matchers.push(item as ContentMatcher);\n }\n\n // Rebuild burstGuard and defaultRate from validated parsed data\n const bg = parsed.burstGuard as Record<string, unknown>;\n const dr = parsed.defaultRate as Record<string, unknown>;\n\n return {\n napplets,\n matchers,\n burstGuard: {\n windowMs: bg['windowMs'] as number,\n maxOps: bg['maxOps'] as number,\n action: bg['action'] as Action,\n },\n defaultRate: {\n capacity: dr['capacity'] as number,\n windowMs: dr['windowMs'] as number,\n action: dr['action'] as Action,\n },\n unfocusedMultiplier: parsed.unfocusedMultiplier as number,\n };\n } catch {\n // Invalid JSON — fall through to default\n }\n return defaultConfig();\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@kehto/firewall",
3
+ "version": "0.3.0",
4
+ "description": "Pure, WASM-ready behavioral firewall engine for the napplet protocol — zero dependencies, zero side effects",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "sideEffects": false,
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "dependencies": {},
23
+ "peerDependencies": {
24
+ "@napplet/core": "^0.12.0"
25
+ },
26
+ "devDependencies": {
27
+ "@napplet/core": "^0.12.0",
28
+ "tsup": "^8.5.0",
29
+ "typescript": "^5.9.3"
30
+ },
31
+ "scripts": {
32
+ "build": "tsup",
33
+ "type-check": "tsc --noEmit",
34
+ "test:unit": "vitest run --config ../../vitest.config.ts"
35
+ },
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/kehto/web.git",
40
+ "directory": "packages/firewall"
41
+ },
42
+ "keywords": [
43
+ "nostr",
44
+ "napplet",
45
+ "kehto",
46
+ "firewall",
47
+ "rate-limit"
48
+ ]
49
+ }