prism-mcp-server 6.5.2 → 7.0.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.
@@ -29,6 +29,7 @@ import { getLLMProvider } from "../utils/llm/factory.js";
29
29
  import { buildVaultDirectory } from "../utils/vaultExporter.js";
30
30
  import { redactSettings } from "../tools/commonHelpers.js";
31
31
  import { handleGraphRoutes } from "./graphRouter.js";
32
+ import { safeCompare, generateToken, isAuthenticated, createRateLimiter, } from "./authUtils.js";
32
33
  const PORT = parseInt(process.env.PRISM_DASHBOARD_PORT || "3000", 10);
33
34
  /** Read HTTP request body as string (Buffer-based to avoid GC thrash on large imports) */
34
35
  function readBody(req) {
@@ -75,50 +76,18 @@ export async function startDashboardServer() {
75
76
  const AUTH_ENABLED = AUTH_USER.length > 0 && AUTH_PASS.length > 0;
76
77
  const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
77
78
  const activeSessions = new Map(); // token → expiry timestamp
78
- /** Generate a random session token */
79
- function generateToken() {
80
- const chars = "abcdef0123456789";
81
- let token = "";
82
- for (let i = 0; i < 64; i++) {
83
- token += chars[Math.floor(Math.random() * chars.length)];
84
- }
85
- return token;
86
- }
87
- /** Timing-safe string comparison to prevent timing attacks */
88
- function safeCompare(a, b) {
89
- if (a.length !== b.length)
90
- return false;
91
- let result = 0;
92
- for (let i = 0; i < a.length; i++) {
93
- result |= a.charCodeAt(i) ^ b.charCodeAt(i);
94
- }
95
- return result === 0;
96
- }
97
- /** Check if request is authenticated (returns true if auth is disabled) */
98
- function isAuthenticated(req) {
99
- if (!AUTH_ENABLED)
100
- return true;
101
- // Check session cookie first
102
- const cookies = req.headers.cookie || "";
103
- const match = cookies.match(/prism_session=([a-f0-9]{64})/);
104
- if (match) {
105
- const token = match[1];
106
- const expiry = activeSessions.get(token);
107
- if (expiry && expiry > Date.now())
108
- return true;
109
- // Expired — clean up
110
- if (expiry)
111
- activeSessions.delete(token);
112
- }
113
- // Check Basic Auth header
114
- const authHeader = req.headers.authorization || "";
115
- if (authHeader.startsWith("Basic ")) {
116
- const decoded = Buffer.from(authHeader.slice(6), "base64").toString("utf-8");
117
- const [user, pass] = decoded.split(":");
118
- return safeCompare(user || "", AUTH_USER) && safeCompare(pass || "", AUTH_PASS);
119
- }
120
- return false;
121
- }
79
+ // Auth config object injectable for testing via authUtils.ts
80
+ const authConfig = {
81
+ authEnabled: AUTH_ENABLED,
82
+ authUser: AUTH_USER,
83
+ authPass: AUTH_PASS,
84
+ activeSessions,
85
+ };
86
+ // v6.5.1: Rate limiter for login endpoint — 5 attempts per 60 seconds per IP
87
+ const loginRateLimiter = createRateLimiter({
88
+ maxAttempts: 5,
89
+ windowMs: 60 * 1000,
90
+ });
122
91
  /** Render a styled login page matching the Mind Palace theme */
123
92
  function renderLoginPage() {
124
93
  return `<!DOCTYPE html>
@@ -170,9 +139,19 @@ return false;}
170
139
  `with TLS for remote access.`);
171
140
  }
172
141
  const httpServer = http.createServer(async (req, res) => {
173
- // CORS headers for local dev
174
- res.setHeader("Access-Control-Allow-Origin", "*");
175
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
142
+ // v6.5.1: CORS restrict origin when auth is enabled to prevent CSRF
143
+ if (AUTH_ENABLED) {
144
+ const origin = req.headers.origin || "";
145
+ // Only echo back the origin if present (browser-initiated requests)
146
+ if (origin) {
147
+ res.setHeader("Access-Control-Allow-Origin", origin);
148
+ res.setHeader("Access-Control-Allow-Credentials", "true");
149
+ }
150
+ }
151
+ else {
152
+ res.setHeader("Access-Control-Allow-Origin", "*");
153
+ }
154
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
176
155
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
177
156
  if (req.method === "OPTIONS") {
178
157
  res.writeHead(204);
@@ -181,12 +160,20 @@ return false;}
181
160
  // ─── v5.1: Auth login endpoint (always accessible) ───
182
161
  const reqUrl = new URL(req.url || "/", `http://${req.headers.host}`);
183
162
  if (AUTH_ENABLED && reqUrl.pathname === "/api/auth/login" && req.method === "POST") {
163
+ // v6.5.1: Rate limiting — prevent brute-force attacks
164
+ const clientIP = (req.socket?.remoteAddress || "unknown").replace(/^::ffff:/, "");
165
+ if (!loginRateLimiter.isAllowed(clientIP)) {
166
+ res.writeHead(429, { "Content-Type": "application/json" });
167
+ return res.end(JSON.stringify({ error: "Too many login attempts. Try again later." }));
168
+ }
184
169
  const body = await readBody(req);
185
170
  try {
186
171
  const { user, pass } = JSON.parse(body);
187
172
  if (safeCompare(user || "", AUTH_USER) && safeCompare(pass || "", AUTH_PASS)) {
188
173
  const token = generateToken();
189
174
  activeSessions.set(token, Date.now() + SESSION_TTL_MS);
175
+ // Reset rate limiter on successful login
176
+ loginRateLimiter.reset(clientIP);
190
177
  res.writeHead(200, {
191
178
  "Content-Type": "application/json",
192
179
  "Set-Cookie": `prism_session=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_TTL_MS / 1000}`,
@@ -198,8 +185,23 @@ return false;}
198
185
  res.writeHead(401, { "Content-Type": "application/json" });
199
186
  return res.end(JSON.stringify({ error: "Invalid credentials" }));
200
187
  }
188
+ // ─── v6.5.1: Logout endpoint — invalidates session server-side ───
189
+ if (AUTH_ENABLED && reqUrl.pathname === "/api/auth/logout" && req.method === "POST") {
190
+ // Extract and invalidate the session token from the cookie
191
+ const cookies = req.headers.cookie || "";
192
+ const match = cookies.match(/prism_session=([a-f0-9]{64})/);
193
+ if (match) {
194
+ activeSessions.delete(match[1]);
195
+ }
196
+ res.writeHead(200, {
197
+ "Content-Type": "application/json",
198
+ // Clear the cookie on the client side
199
+ "Set-Cookie": `prism_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`,
200
+ });
201
+ return res.end(JSON.stringify({ ok: true }));
202
+ }
201
203
  // ─── v5.1: Auth gate — block unauthenticated requests ───
202
- if (AUTH_ENABLED && !isAuthenticated(req)) {
204
+ if (AUTH_ENABLED && !isAuthenticated(req, authConfig)) {
203
205
  // For API calls, return 401 JSON
204
206
  if (reqUrl.pathname.startsWith("/api/")) {
205
207
  res.writeHead(401, { "Content-Type": "application/json" });
@@ -20,11 +20,14 @@ import * as fs from "fs";
20
20
  import * as path from "path";
21
21
  import * as os from "os";
22
22
  import { randomUUID } from "crypto";
23
+ import { AccessLogBuffer } from "../utils/accessLogBuffer.js";
24
+ import { PRISM_ACTR_BUFFER_FLUSH_MS } from "../config.js";
23
25
  import { getSetting as cfgGet, setSetting as cfgSet, getAllSettings as cfgGetAll } from "./configStorage.js";
24
26
  import { debugLog } from "../utils/logger.js";
25
27
  export class SqliteStorage {
26
28
  db;
27
29
  dbPath;
30
+ accessLogBuffer;
28
31
  // ─── Lifecycle ─────────────────────────────────────────────
29
32
  async initialize(dbPath) {
30
33
  // ─── DB Path Resolution ────────────────────────────────────────────
@@ -65,9 +68,18 @@ export class SqliteStorage {
65
68
  await this.db.execute("PRAGMA foreign_keys = ON");
66
69
  // Run all migrations
67
70
  await this.runMigrations();
71
+ // v7.0: Initialize the ACT-R access log write buffer.
72
+ // The buffer batches logAccess() calls into periodic single-INSERT flushes
73
+ // to prevent SQLite SQLITE_BUSY contention (Rule #1).
74
+ this.accessLogBuffer = new AccessLogBuffer(this.db, PRISM_ACTR_BUFFER_FLUSH_MS);
68
75
  debugLog(`[SqliteStorage] Initialized at ${this.dbPath}`);
69
76
  }
70
77
  async close() {
78
+ // v7.0: Drain the access log buffer before closing the DB connection.
79
+ // This ensures any buffered access events are persisted on shutdown.
80
+ if (this.accessLogBuffer) {
81
+ await this.accessLogBuffer.dispose();
82
+ }
71
83
  this.db.close();
72
84
  debugLog("[SqliteStorage] Closed");
73
85
  }
@@ -511,6 +523,39 @@ export class SqliteStorage {
511
523
  if (!e.message?.includes("duplicate column name"))
512
524
  throw e;
513
525
  }
526
+ // ─── v7.0 Migration: ACT-R Memory Access Log ──────────────
527
+ //
528
+ // REVIEWER NOTE: This table drives the ACT-R base-level activation
529
+ // formula: B_i = ln(Σ t_j^(-d)). Each row is a single "access" event
530
+ // recorded fire-and-forget via AccessLogBuffer.
531
+ //
532
+ // DESIGN:
533
+ // - INTEGER PRIMARY KEY = SQLite implicit rowid (fastest inserts)
534
+ // - entry_id NOT NULL FK → session_ledger (ON DELETE CASCADE)
535
+ // - accessed_at TEXT ISO-8601 — enables julianday() math in queries
536
+ // - context_hash TEXT — optional search query fingerprint for
537
+ // future "what queries retrieve this memory?" analytics
538
+ //
539
+ // INDEXES:
540
+ // - (entry_id, accessed_at DESC): covers the window-function query
541
+ // used by getAccessLog(). DESC order makes recent-first scans
542
+ // sequential reads (no reverse B-tree traversal).
543
+ // - (accessed_at): covers the retention sweep in pruneAccessLog().
544
+ //
545
+ // STORAGE: ~80 bytes/row → 100K accesses ≈ 8 MB → laptop-safe.
546
+ await this.db.execute(`
547
+ CREATE TABLE IF NOT EXISTS memory_access_log (
548
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
549
+ entry_id TEXT NOT NULL,
550
+ accessed_at TEXT NOT NULL DEFAULT (datetime('now')),
551
+ context_hash TEXT DEFAULT NULL,
552
+ FOREIGN KEY (entry_id) REFERENCES session_ledger(id) ON DELETE CASCADE
553
+ )
554
+ `);
555
+ await this.db.execute(`CREATE INDEX IF NOT EXISTS idx_access_log_entry_time
556
+ ON memory_access_log(entry_id, accessed_at DESC)`);
557
+ await this.db.execute(`CREATE INDEX IF NOT EXISTS idx_access_log_time
558
+ ON memory_access_log(accessed_at)`);
514
559
  // ─── v6.1 Migration: Integrity Check ──────────────────────
515
560
  //
516
561
  // REVIEWER NOTE: PRAGMA integrity_check scans the B-tree structure of
@@ -737,6 +782,17 @@ export class SqliteStorage {
737
782
  now,
738
783
  ],
739
784
  });
785
+ // ── v7.0 Rule #3: Creation = Access Seeding ─────────────────────
786
+ // Seed the access log with a single event at creation time so that
787
+ // brand-new entries have a non-empty access history. Without this,
788
+ // new entries would have B_i = -∞ (ln(0)) and never surface in
789
+ // ACT-R re-ranking until they're accessed at least once.
790
+ //
791
+ // Uses the buffer for consistency — the creation seed is batched
792
+ // with other access events and flushed on the next cycle.
793
+ if (this.accessLogBuffer) {
794
+ this.accessLogBuffer.push(id, 'creation_seed');
795
+ }
740
796
  // Return Supabase-compatible shape: handlers expect [{ id, ... }]
741
797
  return [{ id, project: entry.project, created_at: now }];
742
798
  }
@@ -960,12 +1016,14 @@ export class SqliteStorage {
960
1016
  if (!ids || ids.length === 0)
961
1017
  return;
962
1018
  const CHUNK_SIZE = 500;
1019
+ const now = new Date().toISOString(); // JS generates ISO-8601 with Z suffix
963
1020
  for (let i = 0; i < ids.length; i += CHUNK_SIZE) {
964
1021
  const chunk = ids.slice(i, i + CHUNK_SIZE);
965
1022
  const placeholders = chunk.map(() => "?").join(", ");
1023
+ // Pass 'now' as the first argument, followed by the chunk IDs
966
1024
  await this.db.execute({
967
- sql: `UPDATE session_ledger SET last_accessed_at = datetime('now') WHERE id IN (${placeholders})`,
968
- args: chunk,
1025
+ sql: `UPDATE session_ledger SET last_accessed_at = ? WHERE id IN (${placeholders})`,
1026
+ args: [now, ...chunk],
969
1027
  });
970
1028
  }
971
1029
  }
@@ -2653,4 +2711,79 @@ export class SqliteStorage {
2653
2711
  `temporal=${temporal}, keyword=${keyword}, provenance=${provenance}`);
2654
2712
  return { temporal, keyword, provenance };
2655
2713
  }
2714
+ // ─── v7.0: ACT-R Access Log Methods ────────────────────────────
2715
+ //
2716
+ // These three methods support the ACT-R base-level activation model.
2717
+ // Read the interface.ts docstrings for the full spec.
2718
+ /**
2719
+ * Record a memory access event (synchronous, fire-and-forget via buffer).
2720
+ * Rule #1: Write contention prevention.
2721
+ */
2722
+ logAccess(entryId, contextHash) {
2723
+ this.accessLogBuffer.push(entryId, contextHash);
2724
+ }
2725
+ /**
2726
+ * Batch-fetch access timestamps for multiple entries using window functions.
2727
+ * Rule #2: Prevents N+1 query explosion.
2728
+ *
2729
+ * SQL STRATEGY:
2730
+ * Uses ROW_NUMBER() OVER (PARTITION BY entry_id ORDER BY accessed_at DESC)
2731
+ * to rank accesses per entry, then filters to the top `maxPerEntry`.
2732
+ * This converts N separate queries into 1 query with O(N*K) work
2733
+ * where N = entries and K = max accesses per entry.
2734
+ *
2735
+ * We use a CTE subquery pattern because SQLite doesn't support
2736
+ * WHERE on a window function alias in the same SELECT scope.
2737
+ */
2738
+ async getAccessLog(entryIds, maxPerEntry = 50) {
2739
+ const result = new Map();
2740
+ if (entryIds.length === 0)
2741
+ return result;
2742
+ // Build parameterized IN clause
2743
+ const placeholders = entryIds.map(() => "?").join(", ");
2744
+ const rows = await this.db.execute({
2745
+ sql: `
2746
+ WITH ranked AS (
2747
+ SELECT
2748
+ entry_id,
2749
+ accessed_at,
2750
+ ROW_NUMBER() OVER (
2751
+ PARTITION BY entry_id
2752
+ ORDER BY accessed_at DESC
2753
+ ) AS rn
2754
+ FROM memory_access_log
2755
+ WHERE entry_id IN (${placeholders})
2756
+ )
2757
+ SELECT entry_id, accessed_at
2758
+ FROM ranked
2759
+ WHERE rn <= ?
2760
+ ORDER BY entry_id, accessed_at DESC
2761
+ `,
2762
+ args: [...entryIds, maxPerEntry],
2763
+ });
2764
+ // Assemble the Map from flat rows
2765
+ for (const row of rows.rows) {
2766
+ const entryId = row.entry_id;
2767
+ const accessedAt = new Date(row.accessed_at);
2768
+ if (!result.has(entryId)) {
2769
+ result.set(entryId, []);
2770
+ }
2771
+ result.get(entryId).push(accessedAt);
2772
+ }
2773
+ return result;
2774
+ }
2775
+ /**
2776
+ * Prune access log entries older than N days.
2777
+ * Called by the sleep-cycle scheduler to bound table growth.
2778
+ */
2779
+ async pruneAccessLog(olderThanDays) {
2780
+ const result = await this.db.execute({
2781
+ sql: `DELETE FROM memory_access_log
2782
+ WHERE accessed_at < datetime('now', ?)`,
2783
+ args: [`-${olderThanDays} days`],
2784
+ });
2785
+ const pruned = result.rowsAffected;
2786
+ debugLog(`[SqliteStorage] pruneAccessLog: removed ${pruned} entries older than ${olderThanDays} days`);
2787
+ return pruned;
2788
+ }
2656
2789
  }
@@ -15,6 +15,7 @@
15
15
  import { supabasePost, supabaseGet, supabaseRpc, supabasePatch, supabaseDelete, } from "../utils/supabaseApi.js";
16
16
  import { gzipSync, gunzipSync } from "node:zlib";
17
17
  import { debugLog } from "../utils/logger.js";
18
+ import { PRISM_USER_ID } from "../config.js";
18
19
  import { getSetting as cfgGet, setSetting as cfgSet, getAllSettings as cfgGetAll } from "./configStorage.js";
19
20
  import { runAutoMigrations } from "./supabaseMigrations.js";
20
21
  export class SupabaseStorage {
@@ -1118,4 +1119,80 @@ export class SupabaseStorage {
1118
1119
  return { temporal: 0, keyword: 0, provenance: 0 };
1119
1120
  }
1120
1121
  }
1122
+ // ─── v7.0: ACT-R Access Log (Supabase Parity) ───────────────
1123
+ //
1124
+ // Implements the same StorageBackend contract as SQLite:
1125
+ // - logAccess: fire-and-forget write
1126
+ // - getAccessLog: batch top-N timestamps per entry
1127
+ // - pruneAccessLog: retention delete count
1128
+ //
1129
+ // All operations route through SECURITY DEFINER RPCs for tenant-safe access.
1130
+ logAccess(entryId, contextHash) {
1131
+ supabaseRpc("prism_log_access", {
1132
+ p_user_id: PRISM_USER_ID,
1133
+ p_entry_id: entryId,
1134
+ p_accessed_at: new Date().toISOString(),
1135
+ p_context_hash: contextHash || null,
1136
+ }).catch((e) => {
1137
+ debugLog(`[SupabaseStorage] logAccess fallback/no-op: ${e.message}`);
1138
+ });
1139
+ }
1140
+ async getAccessLog(entryIds, maxPerEntry = 50) {
1141
+ const result = new Map();
1142
+ if (!entryIds || entryIds.length === 0)
1143
+ return result;
1144
+ try {
1145
+ const rows = await supabaseRpc("prism_get_access_log", {
1146
+ p_user_id: PRISM_USER_ID,
1147
+ p_entry_ids: entryIds,
1148
+ p_max_per_entry: maxPerEntry,
1149
+ });
1150
+ const data = Array.isArray(rows) ? rows : [];
1151
+ for (const row of data) {
1152
+ const entryId = row.entry_id;
1153
+ const accessedAtRaw = row.accessed_at;
1154
+ if (!entryId || !accessedAtRaw)
1155
+ continue;
1156
+ if (!result.has(entryId))
1157
+ result.set(entryId, []);
1158
+ result.get(entryId).push(new Date(accessedAtRaw));
1159
+ }
1160
+ return result;
1161
+ }
1162
+ catch (e) {
1163
+ debugLog(`[SupabaseStorage] getAccessLog fallback/no-op: ${e.message}`);
1164
+ return result;
1165
+ }
1166
+ }
1167
+ async pruneAccessLog(olderThanDays) {
1168
+ try {
1169
+ const rpcResult = await supabaseRpc("prism_prune_access_log", {
1170
+ p_older_than_days: olderThanDays,
1171
+ });
1172
+ if (typeof rpcResult === "number")
1173
+ return rpcResult;
1174
+ if (Array.isArray(rpcResult) && rpcResult.length > 0) {
1175
+ const first = rpcResult[0];
1176
+ if (typeof first === "number")
1177
+ return first;
1178
+ if (first && typeof first.deleted_count !== "undefined") {
1179
+ return Number(first.deleted_count) || 0;
1180
+ }
1181
+ if (first && typeof first.prism_prune_access_log !== "undefined") {
1182
+ return Number(first.prism_prune_access_log) || 0;
1183
+ }
1184
+ }
1185
+ if (rpcResult && typeof rpcResult.deleted_count !== "undefined") {
1186
+ return Number(rpcResult.deleted_count) || 0;
1187
+ }
1188
+ if (rpcResult && typeof rpcResult.prism_prune_access_log !== "undefined") {
1189
+ return Number(rpcResult.prism_prune_access_log) || 0;
1190
+ }
1191
+ return 0;
1192
+ }
1193
+ catch (e) {
1194
+ debugLog(`[SupabaseStorage] pruneAccessLog fallback/no-op: ${e.message}`);
1195
+ return 0;
1196
+ }
1197
+ }
1121
1198
  }
@@ -598,6 +598,129 @@ export const MIGRATIONS = [
598
598
  GRANT EXECUTE ON FUNCTION public.admin_get_deleted_entries(TEXT, TEXT, INTEGER) TO service_role;
599
599
  `
600
600
  },
601
+ {
602
+ version: 37,
603
+ name: "actr_access_log_parity",
604
+ sql: `
605
+ -- Migration 037: ACT-R Access Log parity for Supabase backend
606
+
607
+ CREATE TABLE IF NOT EXISTS public.memory_access_log (
608
+ id BIGSERIAL PRIMARY KEY,
609
+ entry_id UUID NOT NULL REFERENCES public.session_ledger(id) ON DELETE CASCADE,
610
+ accessed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
611
+ context_hash TEXT DEFAULT NULL
612
+ );
613
+
614
+ CREATE INDEX IF NOT EXISTS idx_memory_access_log_entry_time
615
+ ON public.memory_access_log(entry_id, accessed_at DESC);
616
+ CREATE INDEX IF NOT EXISTS idx_memory_access_log_time
617
+ ON public.memory_access_log(accessed_at);
618
+
619
+ -- Fire-and-forget insert path used by SupabaseStorage.logAccess.
620
+ -- Uses tenant + visibility gate; invalid entry/user pairs become no-op inserts.
621
+ CREATE OR REPLACE FUNCTION public.prism_log_access(
622
+ p_user_id TEXT,
623
+ p_entry_id UUID,
624
+ p_accessed_at TIMESTAMPTZ DEFAULT now(),
625
+ p_context_hash TEXT DEFAULT NULL
626
+ )
627
+ RETURNS VOID
628
+ LANGUAGE sql
629
+ SECURITY DEFINER
630
+ SET search_path = public
631
+ AS $inner$
632
+ INSERT INTO public.memory_access_log(entry_id, accessed_at, context_hash)
633
+ SELECT sl.id, COALESCE(p_accessed_at, now()), p_context_hash
634
+ FROM public.session_ledger sl
635
+ WHERE sl.id = p_entry_id
636
+ AND sl.user_id = p_user_id
637
+ AND sl.deleted_at IS NULL;
638
+ $inner$;
639
+
640
+ -- Batch top-N access log read for ACT-R base-level activation.
641
+ CREATE OR REPLACE FUNCTION public.prism_get_access_log(
642
+ p_user_id TEXT,
643
+ p_entry_ids UUID[],
644
+ p_max_per_entry INTEGER DEFAULT 50
645
+ )
646
+ RETURNS TABLE (
647
+ entry_id UUID,
648
+ accessed_at TIMESTAMPTZ
649
+ )
650
+ LANGUAGE sql
651
+ SECURITY DEFINER
652
+ SET search_path = public
653
+ AS $inner$
654
+ WITH ranked AS (
655
+ SELECT
656
+ mal.entry_id,
657
+ mal.accessed_at,
658
+ ROW_NUMBER() OVER (
659
+ PARTITION BY mal.entry_id
660
+ ORDER BY mal.accessed_at DESC
661
+ ) AS rn
662
+ FROM public.memory_access_log mal
663
+ JOIN public.session_ledger sl ON sl.id = mal.entry_id
664
+ WHERE sl.user_id = p_user_id
665
+ AND sl.deleted_at IS NULL
666
+ AND mal.entry_id = ANY(p_entry_ids)
667
+ )
668
+ SELECT r.entry_id, r.accessed_at
669
+ FROM ranked r
670
+ WHERE r.rn <= GREATEST(COALESCE(p_max_per_entry, 50), 1)
671
+ ORDER BY r.entry_id, r.accessed_at DESC;
672
+ $inner$;
673
+
674
+ -- Retention prune for scheduler Task 9.
675
+ CREATE OR REPLACE FUNCTION public.prism_prune_access_log(
676
+ p_older_than_days INTEGER DEFAULT 90
677
+ )
678
+ RETURNS INTEGER
679
+ LANGUAGE plpgsql
680
+ SECURITY DEFINER
681
+ SET search_path = public
682
+ AS $inner$
683
+ DECLARE
684
+ v_deleted INTEGER := 0;
685
+ BEGIN
686
+ IF p_older_than_days IS NULL OR p_older_than_days < 1 THEN
687
+ RAISE EXCEPTION 'p_older_than_days must be >= 1';
688
+ END IF;
689
+
690
+ DELETE FROM public.memory_access_log
691
+ WHERE accessed_at < now() - (p_older_than_days || ' days')::interval;
692
+
693
+ GET DIAGNOSTICS v_deleted = ROW_COUNT;
694
+ RETURN v_deleted;
695
+ END;
696
+ $inner$;
697
+
698
+ -- Strict parity with SQLite: seed one access event at ledger creation time.
699
+ CREATE OR REPLACE FUNCTION public.prism_seed_access_log_on_ledger_insert()
700
+ RETURNS TRIGGER
701
+ LANGUAGE plpgsql
702
+ SECURITY DEFINER
703
+ SET search_path = public
704
+ AS $inner$
705
+ BEGIN
706
+ INSERT INTO public.memory_access_log(entry_id, accessed_at, context_hash)
707
+ VALUES (NEW.id, now(), 'creation_seed');
708
+ RETURN NEW;
709
+ END;
710
+ $inner$;
711
+
712
+ DROP TRIGGER IF EXISTS trg_prism_seed_access_log ON public.session_ledger;
713
+ CREATE TRIGGER trg_prism_seed_access_log
714
+ AFTER INSERT ON public.session_ledger
715
+ FOR EACH ROW
716
+ EXECUTE FUNCTION public.prism_seed_access_log_on_ledger_insert();
717
+
718
+ GRANT EXECUTE ON FUNCTION public.prism_log_access(TEXT, UUID, TIMESTAMPTZ, TEXT) TO service_role, authenticated;
719
+ GRANT EXECUTE ON FUNCTION public.prism_get_access_log(TEXT, UUID[], INTEGER) TO service_role, authenticated;
720
+ GRANT EXECUTE ON FUNCTION public.prism_prune_access_log(INTEGER) TO service_role, authenticated;
721
+ GRANT EXECUTE ON FUNCTION public.prism_seed_access_log_on_ledger_insert() TO service_role, authenticated;
722
+ `
723
+ },
601
724
  ];
602
725
  /**
603
726
  * Current schema version — derived from the MIGRATIONS array.