atproto user agency toolkit for individuals and groups
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 374 lines 9.5 kB view raw
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}