atproto user agency toolkit for individuals and groups
1/**
2 * Declarative, deterministic, transport-agnostic policy engine.
3 *
4 * Loads a set of policies and evaluates them against DIDs to produce
5 * effective (merged) replication configurations. Optionally backed by
6 * PolicyStorage for SQLite persistence.
7 */
8
9import type {
10 Policy,
11 PolicySet,
12 PolicyTarget,
13 EffectivePolicy,
14 ReplicationGoals,
15 SyncConfig,
16 RetentionConfig,
17 StoredPolicy,
18 PolicyState,
19} from "./types.js";
20import {
21 DEFAULT_REPLICATION,
22 DEFAULT_SYNC,
23 DEFAULT_RETENTION,
24} from "./types.js";
25import type { PolicyStorage } from "./storage.js";
26
27export class PolicyEngine {
28 private policies: Policy[] = [];
29 private storage: PolicyStorage | null = null;
30
31 constructor(policySet?: PolicySet, storage?: PolicyStorage) {
32 if (storage) {
33 this.storage = storage;
34 }
35 if (policySet) {
36 this.load(policySet);
37 }
38 }
39
40 /**
41 * Load (or replace) the full set of policies.
42 * Validates that policy IDs are unique.
43 */
44 load(policySet: PolicySet): void {
45 const ids = new Set<string>();
46 for (const p of policySet.policies) {
47 if (ids.has(p.id)) {
48 throw new Error(`Duplicate policy ID: ${p.id}`);
49 }
50 ids.add(p.id);
51 }
52 this.policies = [...policySet.policies];
53 }
54
55 /**
56 * Add a single policy. Throws if a policy with the same ID already exists.
57 * If storage is present and policy is a StoredPolicy, auto-persists to DB.
58 */
59 addPolicy(policy: Policy): void {
60 if (this.policies.some((p) => p.id === policy.id)) {
61 throw new Error(`Duplicate policy ID: ${policy.id}`);
62 }
63 this.policies.push(policy);
64 if (this.storage && isStoredPolicy(policy)) {
65 this.storage.upsertPolicy(policy);
66 }
67 }
68
69 /**
70 * Remove a policy by ID. Returns true if removed, false if not found.
71 * Auto-deletes from DB if storage is present.
72 */
73 removePolicy(id: string): boolean {
74 const before = this.policies.length;
75 this.policies = this.policies.filter((p) => p.id !== id);
76 const removed = this.policies.length < before;
77 if (removed && this.storage) {
78 this.storage.deletePolicy(id);
79 }
80 return removed;
81 }
82
83 /**
84 * Get all loaded policies.
85 */
86 getPolicies(): readonly Policy[] {
87 return this.policies;
88 }
89
90 /**
91 * Export the current policies as a serializable PolicySet.
92 */
93 export(): PolicySet {
94 return {
95 version: 1,
96 policies: [...this.policies],
97 };
98 }
99
100 // ============================================
101 // Evaluation
102 // ============================================
103
104 /**
105 * Find all enabled policies that match the given DID.
106 */
107 matchingPolicies(did: string): Policy[] {
108 return this.policies.filter(
109 (p) => p.enabled && matchesTarget(p.target, did),
110 );
111 }
112
113 /**
114 * Evaluate and return the effective (merged) policy for a DID.
115 *
116 * Merging rules:
117 * - replication.minCopies: take the maximum (most protective)
118 * - replication.preferredPeers: union of all
119 * - sync.intervalSec: take the minimum (most frequent)
120 * - retention.maxAgeSec: take the maximum (keep longer), with 0 = forever winning
121 * - retention.keepHistory: true if any policy says true (most permissive)
122 * - priority: take the maximum
123 */
124 evaluate(did: string): EffectivePolicy {
125 const matching = this.matchingPolicies(did);
126
127 if (matching.length === 0) {
128 return {
129 did,
130 sourcePolicyIds: [],
131 replication: { ...DEFAULT_REPLICATION },
132 sync: { ...DEFAULT_SYNC },
133 retention: { ...DEFAULT_RETENTION },
134 priority: 0,
135 shouldReplicate: false,
136 };
137 }
138
139 const replication = mergeReplication(matching.map((p) => p.replication));
140 const sync = mergeSync(matching.map((p) => p.sync));
141 const retention = mergeRetention(matching.map((p) => p.retention));
142 const priority = Math.max(...matching.map((p) => p.priority));
143
144 return {
145 did,
146 sourcePolicyIds: matching.map((p) => p.id),
147 replication,
148 sync,
149 retention,
150 priority,
151 shouldReplicate: true,
152 };
153 }
154
155 /**
156 * Whether this DID should be replicated based on policies.
157 */
158 shouldReplicate(did: string): boolean {
159 return this.evaluate(did).shouldReplicate;
160 }
161
162 /**
163 * Get the replication configuration for a DID.
164 * Returns null if no policies match (should not replicate).
165 */
166 getReplicationConfig(did: string): {
167 replication: ReplicationGoals;
168 sync: SyncConfig;
169 retention: RetentionConfig;
170 priority: number;
171 } | null {
172 const effective = this.evaluate(did);
173 if (!effective.shouldReplicate) {
174 return null;
175 }
176 return {
177 replication: effective.replication,
178 sync: effective.sync,
179 retention: effective.retention,
180 priority: effective.priority,
181 };
182 }
183
184 /**
185 * Get the list of all DIDs that should be replicated based on
186 * policies with explicit DID lists. (Pattern/all targets cannot
187 * enumerate DIDs — they are evaluated on-demand.)
188 */
189 getExplicitDids(): string[] {
190 const dids = new Set<string>();
191 for (const p of this.policies) {
192 if (!p.enabled) continue;
193 if (p.target.type === "list") {
194 for (const did of p.target.dids) {
195 dids.add(did);
196 }
197 }
198 }
199 return [...dids];
200 }
201
202 // ============================================
203 // Persistence
204 // ============================================
205
206 /**
207 * Load all active policies from the database into memory.
208 * Merges with any policies already in memory (deduplicating by ID).
209 */
210 loadFromDb(): void {
211 if (!this.storage) return;
212 const stored = this.storage.loadActivePolicies();
213 const existingIds = new Set(this.policies.map((p) => p.id));
214 for (const p of stored) {
215 if (!existingIds.has(p.id)) {
216 this.policies.push(p);
217 existingIds.add(p.id);
218 }
219 }
220 }
221
222 /**
223 * Persist all in-memory StoredPolicy objects to the database.
224 */
225 persistAll(): void {
226 if (!this.storage) return;
227 for (const p of this.policies) {
228 if (isStoredPolicy(p)) {
229 this.storage.upsertPolicy(p);
230 }
231 }
232 }
233
234 /**
235 * Transition a policy's state and update both in-memory and DB.
236 * Returns false if the policy was not found.
237 */
238 transitionPolicy(id: string, newState: PolicyState): boolean {
239 const policy = this.policies.find((p) => p.id === id);
240 if (!policy || !isStoredPolicy(policy)) return false;
241
242 policy.state = newState;
243 const now = new Date().toISOString();
244 switch (newState) {
245 case "active":
246 policy.activatedAt = now;
247 break;
248 case "suspended":
249 policy.suspendedAt = now;
250 break;
251 case "terminated":
252 policy.terminatedAt = now;
253 break;
254 }
255
256 if (this.storage) {
257 this.storage.transitionState(id, newState);
258 }
259 return true;
260 }
261
262 /**
263 * Get a stored policy by ID (with lifecycle metadata).
264 * Returns null if the policy is not found or is not a StoredPolicy.
265 */
266 getStoredPolicy(id: string): StoredPolicy | null {
267 const policy = this.policies.find((p) => p.id === id);
268 if (policy && isStoredPolicy(policy)) return policy;
269 // Fall back to DB if available
270 if (this.storage) {
271 return this.storage.getPolicy(id);
272 }
273 return null;
274 }
275
276 /**
277 * Get all DIDs from active, enabled, list-target policies.
278 * This is the single source of truth for "which DIDs should be replicated".
279 */
280 getActiveDids(): string[] {
281 const dids = new Set<string>();
282 for (const p of this.policies) {
283 if (!p.enabled) continue;
284 // For StoredPolicy, check that state is active
285 if (isStoredPolicy(p) && p.state !== "active") continue;
286 if (p.target.type === "list") {
287 for (const did of p.target.dids) {
288 dids.add(did);
289 }
290 }
291 }
292 return [...dids];
293 }
294
295 /**
296 * Clear all policies from memory and storage.
297 * Used during full disconnect to wipe the node clean.
298 */
299 clear(): void {
300 this.policies = [];
301 if (this.storage) {
302 this.storage.deleteAll();
303 }
304 }
305
306 /**
307 * Get the underlying storage, if any.
308 */
309 getStorage(): PolicyStorage | null {
310 return this.storage;
311 }
312}
313
314// ============================================
315// Target matching (pure function)
316// ============================================
317
318/**
319 * Check if a DID matches a policy target.
320 */
321export function matchesTarget(target: PolicyTarget, did: string): boolean {
322 switch (target.type) {
323 case "all":
324 return true;
325 case "list":
326 return target.dids.includes(did);
327 case "pattern":
328 return did.startsWith(target.prefix);
329 }
330}
331
332// ============================================
333// Merging functions (pure)
334// ============================================
335
336function mergeReplication(goals: ReplicationGoals[]): ReplicationGoals {
337 const minCopies = Math.max(...goals.map((g) => g.minCopies));
338 const allPeers = new Set<string>();
339 for (const g of goals) {
340 if (g.preferredPeers) {
341 for (const peer of g.preferredPeers) {
342 allPeers.add(peer);
343 }
344 }
345 }
346 return {
347 minCopies,
348 preferredPeers: allPeers.size > 0 ? [...allPeers] : undefined,
349 };
350}
351
352function mergeSync(configs: SyncConfig[]): SyncConfig {
353 // Most frequent wins (smallest interval)
354 const intervalSec = Math.min(...configs.map((c) => c.intervalSec));
355 return { intervalSec };
356}
357
358/**
359 * Type guard: is this Policy actually a StoredPolicy with lifecycle fields?
360 */
361function isStoredPolicy(policy: Policy): policy is StoredPolicy {
362 return "state" in policy && "type" in policy && "source" in policy && "createdAt" in policy;
363}
364
365function mergeRetention(configs: RetentionConfig[]): RetentionConfig {
366 // 0 means "forever" — if any config says forever, keep forever
367 const hasForever = configs.some((c) => c.maxAgeSec === 0);
368 const maxAgeSec = hasForever
369 ? 0
370 : Math.max(...configs.map((c) => c.maxAgeSec));
371 // Most permissive: if any says keep history, keep it
372 const keepHistory = configs.some((c) => c.keepHistory);
373 return { maxAgeSec, keepHistory };
374}