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.
- package/README.md +150 -42
- package/dist/backgroundScheduler.js +26 -1
- package/dist/config.js +22 -0
- package/dist/dashboard/authUtils.js +150 -0
- package/dist/dashboard/server.js +50 -48
- package/dist/storage/sqlite.js +135 -2
- package/dist/storage/supabase.js +77 -0
- package/dist/storage/supabaseMigrations.js +123 -0
- package/dist/tools/graphHandlers.js +98 -12
- package/dist/tools/ledgerHandlers.js +2 -2
- package/dist/utils/accessLogBuffer.js +159 -0
- package/dist/utils/actrActivation.js +197 -0
- package/dist/utils/cognitiveMemory.js +86 -25
- package/dist/utils/tracing.js +26 -1
- package/package.json +5 -2
package/dist/dashboard/server.js
CHANGED
|
@@ -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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
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" });
|
package/dist/storage/sqlite.js
CHANGED
|
@@ -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 =
|
|
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
|
}
|
package/dist/storage/supabase.js
CHANGED
|
@@ -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.
|