proactive-gate 0.1.2 → 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.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Runs the language-neutral fixtures under spec/fixtures against a gate. The
3
+ * TypeScript test suite and `proactive-gate replay --fixtures` both use it.
4
+ */
5
+ import { readdir, readFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import { createGate } from "./gate.js";
8
+ import { MemoryStore } from "./stores.js";
9
+ export async function loadFixtures(dir) {
10
+ const files = [];
11
+ const walk = async (d) => {
12
+ for (const entry of await readdir(d, { withFileTypes: true })) {
13
+ const path = join(d, entry.name);
14
+ if (entry.isDirectory())
15
+ await walk(path);
16
+ else if (entry.name.endsWith(".json"))
17
+ files.push(path);
18
+ }
19
+ };
20
+ await walk(dir);
21
+ files.sort();
22
+ return Promise.all(files.map(async (f) => JSON.parse(await readFile(f, "utf8"))));
23
+ }
24
+ export async function readSkips(file) {
25
+ const skips = new Map();
26
+ let text = "";
27
+ try {
28
+ text = await readFile(file, "utf8");
29
+ }
30
+ catch {
31
+ return skips;
32
+ }
33
+ for (const line of text.split("\n")) {
34
+ const trimmed = line.trim();
35
+ if (!trimmed || trimmed.startsWith("#"))
36
+ continue;
37
+ const [name, ...reason] = trimmed.split("#");
38
+ skips.set(name.trim(), reason.join("#").trim());
39
+ }
40
+ return skips;
41
+ }
42
+ const iso = (d) => (d ? d.toISOString() : undefined);
43
+ /** Runs one fixture and returns the list of mismatches, empty when it conforms. */
44
+ export async function runFixture(fixture) {
45
+ const failures = [];
46
+ const store = new MemoryStore();
47
+ const prefix = fixture.policy.keyPrefix ?? "pg:";
48
+ for (const [key, value] of Object.entries(fixture.store_seed ?? {}))
49
+ await store.set(prefix + key, value);
50
+ const gate = createGate({ policy: fixture.policy, store });
51
+ for (const [i, t] of fixture.tests.entries()) {
52
+ const at = `${fixture.name} [${i}] ${t.description}`;
53
+ const input = { user: t.input.user, candidate: t.input.candidate, now: new Date(t.input.now) };
54
+ const decision = await gate.evaluate(input);
55
+ const e = t.expect;
56
+ const check = (field, actual, expected) => {
57
+ if (JSON.stringify(actual) !== JSON.stringify(expected))
58
+ failures.push(`${at}: ${field} expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
59
+ };
60
+ check("allowed", decision.allowed, e.allowed);
61
+ check("trace", decision.trace.map((x) => x.id), e.trace);
62
+ check("rejectedBy", decision.rejectedBy, e.rejectedBy);
63
+ check("deferredBy", decision.deferredBy, e.deferredBy);
64
+ check("retryAt", iso(decision.retryAt), e.retryAt);
65
+ if (e.surfaces)
66
+ check("surfaces", decision.surfaces, e.surfaces);
67
+ check("deliverAt", iso(decision.deliverAt), e.deliverAt);
68
+ if (e.shadowed)
69
+ check("shadowed", decision.shadowed, e.shadowed);
70
+ if (e.nearLimit)
71
+ check("nearLimit", decision.nearLimit, e.nearLimit);
72
+ if (e.reason_pattern && !(decision.reason && new RegExp(e.reason_pattern).test(decision.reason)))
73
+ failures.push(`${at}: reason ${JSON.stringify(decision.reason)} does not match /${e.reason_pattern}/`);
74
+ if (t.commit) {
75
+ const committed = await gate.commit(decision, input);
76
+ if (e.commit !== undefined)
77
+ check("commit", committed, e.commit);
78
+ }
79
+ for (const [key, value] of Object.entries(e.store_after ?? {}))
80
+ check(`store ${key}`, await store.get(prefix + key), value);
81
+ }
82
+ return failures;
83
+ }
@@ -1,11 +1,12 @@
1
- import type { Candidate, Check, Decision, EvaluateInput, GateOptions, OutcomeEvent, UserState } from "./types.js";
1
+ import type { Candidate, Check, Decision, EvaluateInput, GateHooks, GateOptions, OutcomeEvent, Policy, Store, UserState } from "./types.js";
2
2
  export interface Gate {
3
3
  /** Run every check in order. Never throws for a check failure; see the trace. */
4
4
  evaluate(input: EvaluateInput): Promise<Decision>;
5
5
  /**
6
- * Call right before you actually send. Atomically consumes one unit of the
7
- * user's daily budget when a dailyBudget check is configured, and returns
8
- * false if the budget was exhausted by a concurrent delivery in the meantime.
6
+ * Call right before you actually send. Consumes one unit of every budget-like
7
+ * check, in order, and returns false if a unit was taken by a concurrent
8
+ * delivery in the meantime. Idempotent on decision.id: a second call returns
9
+ * the first result without consuming again.
9
10
  */
10
11
  commit(decision: Decision, input: EvaluateInput): Promise<boolean>;
11
12
  /** Tell the gate what happened after delivery, so cooldowns can learn. */
@@ -17,4 +18,11 @@ export interface Gate {
17
18
  }>;
18
19
  readonly checks: readonly Check[];
19
20
  }
20
- export declare function createGate(options: GateOptions): Gate;
21
+ /** createGate accepts explicit checks or a JSON policy (see spec/schema/policy.schema.json). */
22
+ export interface PolicyGateOptions {
23
+ policy: Policy;
24
+ store?: Store;
25
+ onDecision?: (decision: Decision) => void;
26
+ hooks?: GateHooks;
27
+ }
28
+ export declare function createGate(options: GateOptions | PolicyGateOptions): Gate;
package/dist/src/gate.js CHANGED
@@ -1,4 +1,5 @@
1
- import { budgetKey, dismissalKey, weeklyBudgetKey, DAY_SECONDS } from "./checks.js";
1
+ import { budgetKey, dismissalKey, DAY_SECONDS } from "./checks.js";
2
+ import { compilePolicy } from "./policy.js";
2
3
  import { MemoryStore } from "./stores.js";
3
4
  class PrefixedStore {
4
5
  inner;
@@ -12,32 +13,59 @@ class PrefixedStore {
12
13
  incr(key, ttl) { return this.inner.incr(this.prefix + key, ttl); }
13
14
  del(key) { return this.inner.del(this.prefix + key); }
14
15
  }
16
+ const COMMIT_TTL = 2 * DAY_SECONDS;
15
17
  export function createGate(options) {
16
- const store = new PrefixedStore(options.store ?? new MemoryStore(), options.keyPrefix ?? "pg:");
17
- const onStoreError = options.onStoreError ?? "open";
18
- const checks = [...options.checks];
19
- const budgetChecks = checks.filter((c) => c.id === "dailyBudget" || c.id === "weeklyBudget");
18
+ if ("policy" in options && "checks" in options)
19
+ throw new Error("createGate takes either checks or policy, not both");
20
+ const resolved = "policy" in options
21
+ ? { ...compilePolicy(options.policy), ...(options.store ? { store: options.store } : {}), ...(options.onDecision ? { onDecision: options.onDecision } : {}), ...(options.hooks ? { hooks: options.hooks } : {}) }
22
+ : options;
23
+ const store = new PrefixedStore(resolved.store ?? new MemoryStore(), resolved.keyPrefix ?? "pg:");
24
+ const onStoreError = resolved.onStoreError ?? "open";
25
+ const hooks = resolved.hooks ?? {};
26
+ const checks = [...resolved.checks];
27
+ let sequence = 0;
28
+ const consumers = checks.filter((c) => typeof c.consume === "function");
29
+ const callHook = async (name, ctx, check, ...rest) => {
30
+ const hook = hooks[name];
31
+ if (!hook)
32
+ return;
33
+ try {
34
+ await hook(...(ctx ? [ctx, check, ...rest] : rest));
35
+ }
36
+ catch (error) {
37
+ if (name !== "error" && ctx && check)
38
+ await callHook("error", ctx, check, error);
39
+ }
40
+ };
20
41
  const evaluate = async (input) => {
21
42
  const now = input.now ?? new Date();
22
43
  const priority = input.candidate.priority ?? "normal";
23
44
  const trace = [];
45
+ const shadowed = [];
46
+ const nearLimit = [];
24
47
  let surfaces = pickSurfaces(input.user, input.candidate);
25
48
  let deliverAt;
26
- const finish = (partial) => {
49
+ const finish = async (partial) => {
27
50
  const decision = {
51
+ id: `${input.user.id}:${input.candidate.id}:${now.toISOString()}#${++sequence}`,
28
52
  allowed: false,
29
53
  userId: input.user.id,
30
54
  candidateId: input.candidate.id,
31
55
  surfaces: [],
56
+ shadowed,
57
+ nearLimit,
32
58
  trace,
33
59
  evaluatedAt: now,
34
60
  ...partial,
35
61
  };
36
- options.onDecision?.(decision);
62
+ resolved.onDecision?.(decision);
63
+ await callHook("finally", null, null, decision);
37
64
  return decision;
38
65
  };
39
66
  for (const check of checks) {
40
67
  const ctx = { user: input.user, candidate: input.candidate, now, priority, store, surfaces };
68
+ await callHook("before", ctx, check);
41
69
  const started = performance.now();
42
70
  let outcome;
43
71
  try {
@@ -45,6 +73,7 @@ export function createGate(options) {
45
73
  }
46
74
  catch (error) {
47
75
  const message = error instanceof Error ? error.message : String(error);
76
+ await callHook("error", ctx, check, error);
48
77
  if (onStoreError === "closed") {
49
78
  trace.push({ id: check.id, outcome: "reject", reason: `check threw (${message}); failing closed`, ms: elapsed(started) });
50
79
  return finish({ rejectedBy: check.id, reason: `check "${check.id}" failed and the gate fails closed: ${message}` });
@@ -52,15 +81,30 @@ export function createGate(options) {
52
81
  trace.push({ id: check.id, outcome: "skip", reason: `check threw (${message}); failing open`, ms: elapsed(started) });
53
82
  continue;
54
83
  }
55
- if (check.nonRejecting && outcome.kind === "reject") {
56
- // A non-rejecting check that tries to reject is a bug in the check, not a decision about the user.
57
- trace.push({ id: check.id, outcome: "skip", reason: `non-rejecting check returned reject (${outcome.reason}); ignored`, ms: elapsed(started) });
84
+ const ms = elapsed(started);
85
+ await callHook("after", ctx, check, outcome, ms);
86
+ if (check.nonRejecting && (outcome.kind === "reject" || outcome.kind === "defer")) {
87
+ // A non-rejecting check that tries to stop evaluation is a bug in the check, not a decision about the user.
88
+ trace.push({ id: check.id, outcome: "skip", reason: `non-rejecting check returned ${outcome.kind} (${outcome.reason}); ignored`, ms });
58
89
  continue;
59
90
  }
60
- trace.push({ id: check.id, outcome: outcome.kind, ...(("reason" in outcome && outcome.reason) ? { reason: outcome.reason } : {}), ms: elapsed(started) });
61
- if (outcome.kind === "reject") {
62
- return finish({ rejectedBy: check.id, reason: outcome.reason });
91
+ const stops = outcome.kind === "reject" || outcome.kind === "defer";
92
+ const entry = { id: check.id, outcome: outcome.kind, ms };
93
+ if ("reason" in outcome && outcome.reason)
94
+ entry.reason = outcome.reason;
95
+ if (stops && check.shadow)
96
+ entry.shadow = true;
97
+ trace.push(entry);
98
+ if (outcome.kind === "pass" && outcome.nearLimit)
99
+ nearLimit.push({ check: check.id, ...outcome.nearLimit });
100
+ if (stops && check.shadow) {
101
+ shadowed.push(check.id);
102
+ continue;
63
103
  }
104
+ if (outcome.kind === "reject")
105
+ return finish({ rejectedBy: check.id, reason: outcome.reason });
106
+ if (outcome.kind === "defer")
107
+ return finish({ deferredBy: check.id, retryAt: outcome.retryAt, reason: outcome.reason });
64
108
  if (outcome.kind === "adjust") {
65
109
  if (outcome.deliverAt)
66
110
  deliverAt = outcome.deliverAt;
@@ -73,20 +117,25 @@ export function createGate(options) {
73
117
  const commit = async (decision, input) => {
74
118
  if (!decision.allowed)
75
119
  return false;
76
- if (!budgetChecks.length)
120
+ if (!consumers.length)
77
121
  return true;
78
- const now = input.now ?? new Date();
122
+ const now = input.now ?? decision.evaluatedAt;
123
+ const priority = input.candidate.priority ?? "normal";
124
+ const marker = `commit:${decision.id}`;
79
125
  try {
80
- for (const check of budgetChecks) {
81
- const key = check.id === "weeklyBudget"
82
- ? weeklyBudgetKey(input.user.id, now, input.user.timezone)
83
- : budgetKey(input.user.id, now, input.user.timezone);
84
- const used = await store.incr(key, check.id === "weeklyBudget" ? 8 * DAY_SECONDS : 2 * DAY_SECONDS);
85
- const limit = readLimit(check);
86
- if (limit !== undefined && used > limit)
87
- return false;
126
+ const seen = await store.get(marker);
127
+ if (seen !== null)
128
+ return seen === "1";
129
+ let ok = true;
130
+ for (const check of consumers) {
131
+ const ctx = { user: input.user, candidate: input.candidate, now, priority, store, surfaces: decision.surfaces };
132
+ if (!(await check.consume(ctx))) {
133
+ ok = false;
134
+ break;
135
+ }
88
136
  }
89
- return true;
137
+ await store.set(marker, ok ? "1" : "0", COMMIT_TTL);
138
+ return ok;
90
139
  }
91
140
  catch {
92
141
  return onStoreError === "open";
@@ -121,7 +170,3 @@ function pickSurfaces(user, candidate) {
121
170
  return wanted.filter((s) => allowed.has(s));
122
171
  }
123
172
  const elapsed = (started) => Math.round((performance.now() - started) * 1000) / 1000;
124
- /** dailyBudget() closes over its limit; expose it through a well-known property for commit(). */
125
- function readLimit(check) {
126
- return typeof check.limit === "number" ? check.limit : undefined;
127
- }
@@ -1,8 +1,11 @@
1
1
  export { createGate } from "./gate.js";
2
- export type { Gate } from "./gate.js";
2
+ export type { Gate, PolicyGateOptions } from "./gate.js";
3
+ export { compilePolicy, KNOWN_CHECKS } from "./policy.js";
4
+ export { presets } from "./presets.js";
5
+ export type { Preset } from "./presets.js";
3
6
  export { MemoryStore, RedisStore, SqliteStore } from "./stores.js";
4
7
  export type { RedisLike } from "./stores.js";
5
8
  export * as checks from "./checks.js";
6
- export { defaultChecks, localClock, inWindow, budgetKey, weeklyBudgetKey, dismissalKey } from "./checks.js";
9
+ export { defaultChecks, localClock, inWindow, budgetKey, weeklyBudgetKey, monthlyBudgetKey, dismissalKey } from "./checks.js";
7
10
  export { PRIORITY_RANK } from "./types.js";
8
- export type { Candidate, Check, CheckContext, CheckOutcome, Decision, EvaluateInput, GateOptions, OutcomeEvent, Priority, Store, Surface, TraceEntry, UserState, } from "./types.js";
11
+ export type { Candidate, Check, CheckContext, CheckOutcome, Decision, EvaluateInput, GateHooks, GateOptions, OutcomeEvent, Policy, PolicyEntry, Priority, Store, Surface, TraceEntry, UserState, } from "./types.js";
package/dist/src/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  export { createGate } from "./gate.js";
2
+ export { compilePolicy, KNOWN_CHECKS } from "./policy.js";
3
+ export { presets } from "./presets.js";
2
4
  export { MemoryStore, RedisStore, SqliteStore } from "./stores.js";
3
5
  export * as checks from "./checks.js";
4
- export { defaultChecks, localClock, inWindow, budgetKey, weeklyBudgetKey, dismissalKey } from "./checks.js";
6
+ export { defaultChecks, localClock, inWindow, budgetKey, weeklyBudgetKey, monthlyBudgetKey, dismissalKey } from "./checks.js";
5
7
  export { PRIORITY_RANK } from "./types.js";
@@ -0,0 +1,16 @@
1
+ export declare const FRAMEWORKS: readonly ["ai-sdk", "mastra", "langchain", "openai-agents", "none"];
2
+ export type Framework = (typeof FRAMEWORKS)[number];
3
+ /** The order LILA runs, as a policy document. A preset is appended when one is named. */
4
+ export declare function buildPolicy(preset?: string): Record<string, unknown>;
5
+ export declare function snippetFor(framework: Framework, file: string): string;
6
+ export declare function presetLines(): string;
7
+ export declare function listText(): string;
8
+ /** Everything init writes and prints, as data, so the test does not need a filesystem. */
9
+ export declare function plan(options: {
10
+ preset?: string;
11
+ framework?: Framework;
12
+ out: string;
13
+ }): {
14
+ policy: string;
15
+ message: string;
16
+ };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `proactive-gate init` writes a policy you can read and edit, and prints the
3
+ * few lines that wire it into the framework you named. The goal is that the
4
+ * distance between "npm i" and a gate that actually runs is one command.
5
+ */
6
+ import { presets } from "./presets.js";
7
+ export const FRAMEWORKS = ["ai-sdk", "mastra", "langchain", "openai-agents", "none"];
8
+ /** The order LILA runs, as a policy document. A preset is appended when one is named. */
9
+ export function buildPolicy(preset) {
10
+ const checks = [
11
+ { id: "consent" },
12
+ { id: "enabled" },
13
+ { id: "mode", allow: ["normal"] },
14
+ { id: "snooze", defer: true },
15
+ { id: "mute" },
16
+ { id: "intensity" },
17
+ { id: "quietHours", priorityFloor: "critical" },
18
+ { id: "trustRamp", days: 7, minPriority: "high" },
19
+ { id: "dismissalCooldown", dismissals: 3, withinDays: 30, silenceDays: 7 },
20
+ { id: "dailyBudget", limit: 5, bypassPriority: "critical" },
21
+ ];
22
+ if (preset)
23
+ checks.splice(checks.length - 1, 0, { preset });
24
+ return { specVersion: "1.0.0", onStoreError: "open", checks };
25
+ }
26
+ const SNIPPETS = {
27
+ "ai-sdk": (file) => `import { readFile } from "node:fs/promises";
28
+ import { createGate } from "proactive-gate";
29
+ import { gateToolApproval } from "proactive-gate/ai-sdk";
30
+
31
+ const gate = createGate({ policy: JSON.parse(await readFile("${file}", "utf8")) });
32
+ const approve = gateToolApproval({ gate, toInput: (call) => call.input.gate });
33
+
34
+ // Give the send tool needsApproval: true, then answer each request:
35
+ const { approved, reason } = await approve(call);
36
+ // addToolApprovalResponse({ id: call.approvalId, approved, reason })`,
37
+ mastra: (file) => `import { readFile } from "node:fs/promises";
38
+ import { createGate } from "proactive-gate";
39
+ import { gateProcessor } from "proactive-gate/mastra";
40
+
41
+ const gate = createGate({ policy: JSON.parse(await readFile("${file}", "utf8")) });
42
+
43
+ // In the agent definition:
44
+ // outputProcessors: [gateProcessor({ gate, toInput: ({ messages }) => ({ user, candidate }) })]
45
+ // A rejection calls abort(reason) before the result reaches the user.`,
46
+ langchain: (file) => `import { readFile } from "node:fs/promises";
47
+ import { createGate } from "proactive-gate";
48
+ import { gateMiddleware } from "proactive-gate/langchain";
49
+
50
+ const gate = createGate({ policy: JSON.parse(await readFile("${file}", "utf8")) });
51
+
52
+ // createAgent({
53
+ // tools: [sendMessage],
54
+ // middleware: [gateMiddleware({ gate, tools: ["send_message"], toInput: (call) => call.args.gate })],
55
+ // })
56
+ // A rejection returns a tool message carrying the reason instead of running the tool.`,
57
+ "openai-agents": (file) => `import { readFile } from "node:fs/promises";
58
+ import { createGate } from "proactive-gate";
59
+ import { gateToolInputGuardrail } from "proactive-gate/openai-agents";
60
+
61
+ const gate = createGate({ policy: JSON.parse(await readFile("${file}", "utf8")) });
62
+
63
+ // tool({
64
+ // name: "send_message",
65
+ // inputGuardrails: [gateToolInputGuardrail({ gate, toInput: (input) => input.gate })],
66
+ // })
67
+ // A rejection trips the wire with the reason in outputInfo.`,
68
+ none: (file) => `import { readFile } from "node:fs/promises";
69
+ import { createGate } from "proactive-gate";
70
+
71
+ const gate = createGate({ policy: JSON.parse(await readFile("${file}", "utf8")) });
72
+
73
+ const decision = await gate.evaluate({ user, candidate });
74
+ if (decision.allowed && (await gate.commit(decision, { user, candidate }))) {
75
+ await send(candidate);
76
+ } else {
77
+ log.info({ reason: decision.reason, rejectedBy: decision.rejectedBy }, "not sent");
78
+ }`,
79
+ };
80
+ export function snippetFor(framework, file) {
81
+ return SNIPPETS[framework](file);
82
+ }
83
+ export function presetLines() {
84
+ return Object.entries(presets)
85
+ .map(([name, preset]) => ` ${name.padEnd(28)}${preset.sources[0] ?? ""}`)
86
+ .join("\n");
87
+ }
88
+ export function listText() {
89
+ return `presets (append one to the policy, or leave it out):\n${presetLines()}\n\nframeworks: ${FRAMEWORKS.join(", ")}`;
90
+ }
91
+ /** Everything init writes and prints, as data, so the test does not need a filesystem. */
92
+ export function plan(options) {
93
+ if (options.preset && !presets[options.preset]) {
94
+ throw new Error(`unknown preset "${options.preset}"; known presets: ${Object.keys(presets).join(", ")}`);
95
+ }
96
+ const framework = options.framework ?? "none";
97
+ if (!FRAMEWORKS.includes(framework)) {
98
+ throw new Error(`unknown framework "${framework}"; known frameworks: ${FRAMEWORKS.join(", ")}`);
99
+ }
100
+ const preset = options.preset ? presets[options.preset] : undefined;
101
+ const lines = [
102
+ `wrote ${options.out}`,
103
+ "",
104
+ preset
105
+ ? `preset ${options.preset}: ${preset.note}\nsources:\n${preset.sources.map((s) => ` ${s}`).join("\n")}\n`
106
+ : "no preset: the ten checks above are the default order. `proactive-gate init --list` shows the platform and legal presets.\n",
107
+ `wire it in (${framework}):`,
108
+ "",
109
+ snippetFor(framework, options.out),
110
+ "",
111
+ `then replay a day against it before you ship:`,
112
+ ` npx proactive-gate replay day.jsonl --policy ${options.out} --commit`,
113
+ ];
114
+ return { policy: `${JSON.stringify(buildPolicy(options.preset), null, 2)}\n`, message: lines.join("\n") };
115
+ }
@@ -0,0 +1,8 @@
1
+ import type { Check, GateOptions, Policy } from "./types.js";
2
+ type Options = Record<string, unknown>;
3
+ type Factory = (options: Options) => Check;
4
+ /** Every check a JSON policy may name, with the options it reads. */
5
+ export declare const KNOWN_CHECKS: Record<string, Factory>;
6
+ /** Compile a JSON policy into gate options. Throws on unknown ids, unknown presets, or an unsupported specVersion. */
7
+ export declare function compilePolicy(policy: Policy): GateOptions;
8
+ export {};
@@ -0,0 +1,74 @@
1
+ import * as checks from "./checks.js";
2
+ import { presets } from "./presets.js";
3
+ const num = (o, key) => (typeof o[key] === "number" ? o[key] : undefined);
4
+ const str = (o, key) => (typeof o[key] === "string" ? o[key] : undefined);
5
+ const bool = (o, key) => (typeof o[key] === "boolean" ? o[key] : undefined);
6
+ const prio = (o, key) => str(o, key);
7
+ const strs = (o, key) => (Array.isArray(o[key]) ? o[key] : undefined);
8
+ const opt = (value, key) => (value === undefined ? {} : { [key]: value });
9
+ const budgetOptions = (o) => ({ ...opt(num(o, "limit"), "limit"), ...opt(prio(o, "bypassPriority"), "bypassPriority"), ...opt(num(o, "nearLimit"), "nearLimit") });
10
+ /** Every check a JSON policy may name, with the options it reads. */
11
+ export const KNOWN_CHECKS = {
12
+ killSwitch: (o) => checks.killSwitch(() => bool(o, "on") === true),
13
+ consent: () => checks.consent(),
14
+ enabled: () => checks.enabled(),
15
+ mode: (o) => checks.mode({ allow: strs(o, "allow") ?? ["normal"] }),
16
+ snooze: (o) => checks.snooze({ ...opt(bool(o, "defer"), "defer") }),
17
+ mute: () => checks.mute(),
18
+ intensity: (o) => (o.floors ? checks.intensity(o.floors) : checks.intensity()),
19
+ quietHours: (o) => checks.quietHours({ ...opt(prio(o, "priorityFloor"), "priorityFloor") }),
20
+ trustRamp: (o) => checks.trustRamp({ ...opt(num(o, "days"), "days"), ...opt(prio(o, "minPriority"), "minPriority") }),
21
+ dismissalCooldown: (o) => checks.dismissalCooldown({ ...opt(num(o, "dismissals"), "dismissals"), ...opt(num(o, "withinDays"), "withinDays"), ...opt(num(o, "silenceDays"), "silenceDays") }),
22
+ adaptiveTiming: () => checks.adaptiveTiming(),
23
+ dailyBudget: (o) => checks.dailyBudget(budgetOptions(o)),
24
+ weeklyBudget: (o) => checks.weeklyBudget(budgetOptions(o)),
25
+ monthlyBudget: (o) => checks.monthlyBudget(budgetOptions(o)),
26
+ utilityFloor: (o) => checks.utilityFloor({ costFalseAlarm: num(o, "costFalseAlarm") ?? 1, costMissedHelp: num(o, "costMissedHelp") ?? 1 }),
27
+ boundedDeferral: (o) => checks.boundedDeferral({ ...opt(num(o, "lambda"), "lambda"), ...opt(num(o, "interruptCost"), "interruptCost"), ...opt(num(o, "staleness"), "staleness"), ...opt(num(o, "boundSeconds"), "boundSeconds") }),
28
+ allowedWindow: (o) => checks.allowedWindow({ start: str(o, "start") ?? "08:00", end: str(o, "end") ?? "21:00", timezone: str(o, "timezone") ?? "user", ...opt(prio(o, "priorityFloor"), "priorityFloor"), ...opt(str(o, "id"), "id") }),
29
+ requiresConsent: (o) => checks.requiresConsent({ name: str(o, "name") ?? "consent", ...(o.when ? { when: o.when } : {}), ...opt(str(o, "id"), "id") }),
30
+ rateLimit: (o) => checks.rateLimit({ limit: num(o, "limit") ?? 1, perSeconds: num(o, "perSeconds") ?? 1, ...opt(str(o, "keyBy"), "keyBy"), ...opt(str(o, "id"), "id") }),
31
+ recentInteraction: (o) => checks.recentInteraction({ withinHours: num(o, "withinHours") ?? 48 }),
32
+ windowBudget: (o) => checks.windowBudget({ limit: num(o, "limit") ?? 1, withinHours: num(o, "withinHours") ?? 48 }),
33
+ };
34
+ const SUPPORTED_MAJOR = 1;
35
+ /** Compile a JSON policy into gate options. Throws on unknown ids, unknown presets, or an unsupported specVersion. */
36
+ export function compilePolicy(policy) {
37
+ const major = Number(String(policy.specVersion).split(".")[0]);
38
+ if (!Number.isInteger(major) || major !== SUPPORTED_MAJOR) {
39
+ throw new Error(`policy specVersion ${policy.specVersion} is not supported; this package implements spec ${SUPPORTED_MAJOR}.x`);
40
+ }
41
+ if (!Array.isArray(policy.checks) || !policy.checks.length)
42
+ throw new Error("policy.checks must be a non-empty array");
43
+ const compiled = [];
44
+ for (const entry of policy.checks) {
45
+ const { shadow, ...rest } = entry;
46
+ let built;
47
+ if (typeof rest.preset === "string") {
48
+ const preset = presets[rest.preset];
49
+ if (!preset)
50
+ throw new Error(`unknown preset "${rest.preset}"; known presets: ${Object.keys(presets).join(", ")}`);
51
+ const { preset: _name, ...options } = rest;
52
+ built = preset(options);
53
+ }
54
+ else if (typeof rest.id === "string") {
55
+ const factory = KNOWN_CHECKS[rest.id];
56
+ if (!factory)
57
+ throw new Error(`unknown check "${rest.id}"; known checks: ${Object.keys(KNOWN_CHECKS).join(", ")}`);
58
+ const { id: _id, ...options } = rest;
59
+ built = [factory(options)];
60
+ }
61
+ else {
62
+ throw new Error("each policy entry needs an id or a preset");
63
+ }
64
+ if (shadow)
65
+ for (const c of built)
66
+ c.shadow = true;
67
+ compiled.push(...built);
68
+ }
69
+ return {
70
+ checks: compiled,
71
+ ...(policy.onStoreError ? { onStoreError: policy.onStoreError } : {}),
72
+ ...(policy.keyPrefix !== undefined ? { keyPrefix: policy.keyPrefix } : {}),
73
+ };
74
+ }
@@ -0,0 +1,9 @@
1
+ import type { Check } from "./types.js";
2
+ export interface Preset {
3
+ (options?: Record<string, unknown>): Check[];
4
+ /** Primary sources the numbers come from. */
5
+ sources: string[];
6
+ /** What the preset encodes and what it leaves out. */
7
+ note: string;
8
+ }
9
+ export declare const presets: Record<string, Preset>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Presets: the platform quotas and legal limits people ship against, as ordered
3
+ * check lists. Reviewable defaults, not legal advice. Every number sits next to
4
+ * its source; several official sources disagree with each other (the Kakao
5
+ * evening boundary is quoted as 20:00, 20:50 and 20:55), so read the note and
6
+ * decide for your own deployment.
7
+ */
8
+ import * as c from "./checks.js";
9
+ const define = (build, sources, note) => {
10
+ const preset = ((options = {}) => build(options));
11
+ preset.sources = sources;
12
+ preset.note = note;
13
+ return preset;
14
+ };
15
+ const LINE_PLANS = { communication: 200, light: 5000, standard: 30000 };
16
+ export const presets = {
17
+ lineMessagingApi: define((o) => {
18
+ const plan = typeof o.plan === "string" ? o.plan : "communication";
19
+ const limit = LINE_PLANS[plan];
20
+ if (limit === undefined)
21
+ throw new Error(`lineMessagingApi: unknown plan "${plan}", known: ${Object.keys(LINE_PLANS).join(", ")}`);
22
+ return [c.consent(), c.monthlyBudget({ limit, nearLimit: 0.9 })];
23
+ }, ["https://developers.line.biz/en/docs/messaging-api/pricing/", "https://developers.line.biz/en/reference/messaging-api/"], "Monthly push messages per plan for Japan: communication 200, light 5,000, standard 30,000; replies are free and not counted. Multicast and broadcast request rates are not encoded."),
24
+ wechatSubscriptionMessage: define(() => [c.requiresConsent({ name: "subscription" }), c.windowBudget({ limit: 1, withinHours: 24 * 365 })], ["https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/subscribe-message-overview.html"], "One-time subscription: exactly one message per opt-in; set user.lastInboundAt to the opt-in instant. Long-term subscriptions for government, medical, transport, finance and education categories are not encoded."),
25
+ wechatCustomerService: define(() => [c.recentInteraction({ withinHours: 48 }), c.windowBudget({ limit: 5, withinHours: 48 })], ["https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/customer-message/send.html"], "Mini program customer-service messages: within 48 hours of the user's last message, at most 5 in that window."),
26
+ wechatTemplateMessage: define(() => [c.requiresConsent({ name: "templateTrigger" }), c.rateLimit({ limit: 3, perSeconds: 24 * 3600, keyBy: "user", id: "rate:template" })], ["https://developers.weixin.qq.com/doc/service/guide/product/template_message/Template_Message_Operation_Specifications.html"], "Template messages only after a user action (consents.templateTrigger) and no more than three repeated templates a day; marketing templates are not allowed at all."),
27
+ wecomAppMessage: define(() => [c.rateLimit({ limit: 30, perSeconds: 60, id: "rate:30/min" }), c.rateLimit({ limit: 1000, perSeconds: 3600, id: "rate:1000/h" })], ["https://developer.work.weixin.qq.com/document/path/96212"], "WeCom application messages per app per member: 30 a minute and 1,000 an hour; the platform drops the excess silently, this preset refuses it with a reason."),
28
+ kakaoAlimtalk: define(() => [c.consent()], ["https://kakaobusiness.gitbook.io/main/ad/infotalk"], "AlimTalk is informational and carries no time-of-day limit; consent is the only gate."),
29
+ kakaoBrandMessage: define(() => [c.requiresConsent({ name: "ad" }), c.allowedWindow({ start: "08:00", end: "20:50", timezone: "Asia/Seoul", id: "window:kakao" })], ["https://kakaobusiness.gitbook.io/main/ad/moment/messagead/channelmessage/new/send"], "Brand messages need advertising consent and go out 08:00 to 20:50 Korea time regardless of the recipient's location. Official sources also quote 20:00 and 20:55; 20:50 is the stricter documented value."),
30
+ krNetworkAct50: define(() => [c.requiresConsent({ name: "ad" }), c.requiresConsent({ name: "night", when: { start: "21:00", end: "08:00", timezone: "user" } })], ["https://www.law.go.kr", "https://developers.fingerpush.com/biz-message/console/ads-guide"], "Network Act article 50: prior consent for advertising, and a separate consent for 21:00 to 08:00 (email is exempt). The two-year re-confirmation is not encoded."),
31
+ jpAntiSpamLaw: define(() => [c.requiresConsent({ name: "optIn" })], ["https://www.soumu.go.jp/main_sosiki/cybersecurity/kokumin/basic/legal/08/"], "Opt-in since 2008 with sender identity and an opt-out route. There is no time-of-day rule in the law; a Japanese quiet-hours window would be etiquette, so none is encoded."),
32
+ cnMinorMode: define(() => {
33
+ const window = c.allowedWindow({ start: "06:00", end: "22:00", timezone: "Asia/Shanghai", id: "window:minor" });
34
+ const budget = c.dailyBudget({ limit: 1 });
35
+ const adult = () => ({ kind: "pass", reason: "not a minor" });
36
+ return [
37
+ { id: window.id, run: (ctx) => (ctx.user.minor ? window.run(ctx) : adult(ctx)) },
38
+ { id: budget.id, limit: budget.limit, run: (ctx) => (ctx.user.minor ? budget.run(ctx) : adult(ctx)), consume: (ctx) => (ctx.user.minor ? budget.consume(ctx) : Promise.resolve(true)) },
39
+ ];
40
+ }, ["https://www.cac.gov.cn/2024-11/15/c_1733364304749288.htm", "https://www.cac.gov.cn/2022-01/04/c_1642894606364259.htm"], "Minor mode: no service 22:00 to 06:00 China time and a daily budget of one when user.minor is true; adults pass both checks. Per-age daily durations are not encoded."),
41
+ usTcpa: define(() => [c.allowedWindow({ start: "08:00", end: "21:00", timezone: "user", id: "window:tcpa" })], ["https://www.law.cornell.edu/cfr/text/47/64.1200"], "47 CFR 64.1200: no solicitation before 8 a.m. or after 9 p.m. at the called party's local time."),
42
+ euEprivacy: define(() => [{ ...c.requiresConsent({ name: "marketing" }), run: (ctx) => (ctx.user.existingCustomer ? { kind: "pass", reason: "existing customer, soft opt-in" } : c.requiresConsent({ name: "marketing" }).run(ctx)) }], ["https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32002L0058"], "Directive 2002/58/EC article 13: prior consent for direct marketing, with the soft opt-in for existing customers (user.existingCustomer)."),
43
+ telegramBot: define(() => [c.rateLimit({ limit: 1, perSeconds: 1, keyBy: "channel", id: "rate:1/s" }), c.rateLimit({ limit: 20, perSeconds: 60, keyBy: "channel", id: "rate:20/min" })], ["https://core.telegram.org/bots/faq"], "One message a second per chat and twenty a minute per group, keyed by candidate.channel. The broadcast rate of roughly thirty a second is not encoded."),
44
+ slackApp: define(() => [c.rateLimit({ limit: 1, perSeconds: 1, keyBy: "channel", id: "rate:1/s" })], ["https://docs.slack.dev/apis/web-api/rate-limits/"], "chat.postMessage: one message a second per channel, keyed by candidate.channel."),
45
+ };
@@ -1,5 +1,3 @@
1
- import { createRequire } from "node:module";
2
- const require = createRequire(import.meta.url);
3
1
  /** In-process store. Correct for one instance, wrong the moment you scale out. */
4
2
  export class MemoryStore {
5
3
  clock;
@@ -71,13 +69,12 @@ export class SqliteStore {
71
69
  database;
72
70
  clock;
73
71
  constructor(path, clock = () => Date.now()) {
74
- let DatabaseSync;
75
- try {
76
- ({ DatabaseSync } = require("node:sqlite"));
77
- }
78
- catch {
72
+ // Resolved lazily so the module also loads where node:sqlite does not exist (older Node, a browser bundle).
73
+ const loader = globalThis.process?.getBuiltinModule;
74
+ const mod = loader ? loader("node:sqlite") : undefined;
75
+ const DatabaseSync = mod?.DatabaseSync;
76
+ if (!DatabaseSync)
79
77
  throw new Error("SqliteStore requires Node.js 22.5 or newer.");
80
- }
81
78
  this.database = new DatabaseSync(path);
82
79
  this.clock = clock;
83
80
  this.database.exec("CREATE TABLE IF NOT EXISTS proactive_gate_store (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL, expires_at INTEGER)");