-
Notifications
You must be signed in to change notification settings - Fork 181
feat(limb): persist pairing & access-policy state across API restarts #332
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
xu75
wants to merge
4
commits into
zts212653:main
Choose a base branch
from
xu75:feat/limb-persistence
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
fd74dfc
feat(limb): add SqliteLimbPersistence — schema + CRUD for pairings an…
xu75 690150f
feat(limb): wire SQLite persistence into PairingStore and AccessPolicy
xu75 6043c0a
feat(limb): wire SQLite persistence in index.ts + startup recovery
xu75 e3a8ec9
test(limb): add integration test for full persistence lifecycle
xu75 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| /** | ||
| * SqliteLimbPersistence — #331 Limb state persistence | ||
| * | ||
| * Write-through SQLite backing for LimbPairingStore and LimbAccessPolicy. | ||
| * Follows SqliteEvidenceStore pattern: lazy init, WAL mode, versioned migrations. | ||
| */ | ||
|
|
||
| import Database from 'better-sqlite3'; | ||
| import type { LimbAccessEntry } from '@cat-cafe/shared'; | ||
| import type { PairingRequest } from './LimbPairingStore.js'; | ||
|
|
||
| const SCHEMA_V1 = ` | ||
| CREATE TABLE IF NOT EXISTS limb_schema_version ( | ||
| version INTEGER PRIMARY KEY, | ||
| applied_at TEXT NOT NULL | ||
| ); | ||
|
|
||
| CREATE TABLE IF NOT EXISTS limb_pairings ( | ||
| requestId TEXT PRIMARY KEY, | ||
| nodeId TEXT NOT NULL, | ||
| displayName TEXT NOT NULL, | ||
| platform TEXT NOT NULL, | ||
| endpointUrl TEXT NOT NULL, | ||
| capabilities TEXT NOT NULL, | ||
| status TEXT NOT NULL, | ||
| createdAt INTEGER NOT NULL, | ||
| decidedAt INTEGER, | ||
| apiKey TEXT NOT NULL UNIQUE | ||
| ); | ||
|
|
||
| CREATE TABLE IF NOT EXISTS limb_access_policies ( | ||
| catId TEXT NOT NULL, | ||
| nodeId TEXT NOT NULL, | ||
| capability TEXT NOT NULL, | ||
| authLevel TEXT NOT NULL, | ||
| PRIMARY KEY (catId, nodeId, capability) | ||
| ); | ||
| `; | ||
|
|
||
| export class SqliteLimbPersistence { | ||
| private db: Database.Database | null = null; | ||
| private readonly dbPath: string; | ||
|
|
||
| constructor(dbPath: string) { | ||
| this.dbPath = dbPath; | ||
| } | ||
|
|
||
| initialize(): void { | ||
| this.db = new Database(this.dbPath); | ||
| this.db.pragma('journal_mode = WAL'); | ||
| this.db.pragma('foreign_keys = ON'); | ||
| this.db.pragma('busy_timeout = 5000'); | ||
| this.applyMigrations(); | ||
| } | ||
|
|
||
| private applyMigrations(): void { | ||
| const db = this.getDb(); | ||
| const version = | ||
| (() => { | ||
| try { | ||
| return (db.prepare('SELECT MAX(version) as v FROM limb_schema_version').get() as { v: number | null })?.v ?? 0; | ||
| } catch { | ||
| return 0; | ||
| } | ||
| })(); | ||
|
|
||
| if (version < 1) { | ||
| db.exec(SCHEMA_V1); | ||
| db.prepare('INSERT INTO limb_schema_version (version, applied_at) VALUES (?, ?)').run(1, new Date().toISOString()); | ||
| } | ||
| } | ||
|
|
||
| private getDb(): Database.Database { | ||
| if (!this.db) throw new Error('SqliteLimbPersistence not initialized — call initialize() first'); | ||
| return this.db; | ||
| } | ||
|
|
||
| upsertPairing(p: PairingRequest): void { | ||
| this.getDb() | ||
| .prepare( | ||
| `INSERT INTO limb_pairings (requestId, nodeId, displayName, platform, endpointUrl, capabilities, status, createdAt, decidedAt, apiKey) | ||
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | ||
| ON CONFLICT(requestId) DO UPDATE SET | ||
| nodeId=excluded.nodeId, displayName=excluded.displayName, platform=excluded.platform, | ||
| endpointUrl=excluded.endpointUrl, capabilities=excluded.capabilities, status=excluded.status, | ||
| decidedAt=excluded.decidedAt, apiKey=excluded.apiKey`, | ||
| ) | ||
| .run( | ||
| p.requestId, | ||
| p.nodeId, | ||
| p.displayName, | ||
| p.platform, | ||
| p.endpointUrl, | ||
| JSON.stringify(p.capabilities), | ||
| p.status, | ||
| p.createdAt, | ||
| p.decidedAt ?? null, | ||
| p.apiKey, | ||
| ); | ||
| } | ||
|
|
||
| deletePairing(requestId: string): void { | ||
| this.getDb().prepare('DELETE FROM limb_pairings WHERE requestId = ?').run(requestId); | ||
| } | ||
|
|
||
| loadPairings(): PairingRequest[] { | ||
| const rows = this.getDb().prepare('SELECT * FROM limb_pairings').all() as Array<Record<string, unknown>>; | ||
| return rows.map((r) => ({ | ||
| requestId: r.requestId as string, | ||
| nodeId: r.nodeId as string, | ||
| displayName: r.displayName as string, | ||
| platform: r.platform as string, | ||
| endpointUrl: r.endpointUrl as string, | ||
| capabilities: JSON.parse(r.capabilities as string), | ||
| status: r.status as PairingRequest['status'], | ||
| createdAt: r.createdAt as number, | ||
| decidedAt: (r.decidedAt as number | null) ?? undefined, | ||
| apiKey: r.apiKey as string, | ||
| })); | ||
| } | ||
|
|
||
| upsertAccessPolicy(e: LimbAccessEntry): void { | ||
| this.getDb() | ||
| .prepare( | ||
| `INSERT INTO limb_access_policies (catId, nodeId, capability, authLevel) | ||
| VALUES (?, ?, ?, ?) | ||
| ON CONFLICT(catId, nodeId, capability) DO UPDATE SET authLevel=excluded.authLevel`, | ||
| ) | ||
| .run(e.catId, e.nodeId, e.capability, e.authLevel); | ||
| } | ||
|
|
||
| loadAccessPolicies(): LimbAccessEntry[] { | ||
| const rows = this.getDb().prepare('SELECT * FROM limb_access_policies').all() as Array<Record<string, unknown>>; | ||
| return rows.map((r) => ({ | ||
| catId: r.catId as string, | ||
| nodeId: r.nodeId as string, | ||
| capability: r.capability as string, | ||
| authLevel: r.authLevel as LimbAccessEntry['authLevel'], | ||
| })); | ||
| } | ||
|
|
||
| close(): void { | ||
| this.db?.close(); | ||
| this.db = null; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import { mkdtempSync, rmSync } from 'node:fs'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
| import { afterEach, beforeEach, describe, it } from 'node:test'; | ||
|
|
||
| const { SqliteLimbPersistence } = await import('../dist/domains/limb/SqliteLimbPersistence.js'); | ||
| const { LimbAccessPolicy } = await import('../dist/domains/limb/LimbAccessPolicy.js'); | ||
|
|
||
| describe('LimbAccessPolicy with persistence', () => { | ||
| let tempDir; | ||
| let dbPath; | ||
| let persistence; | ||
|
|
||
| beforeEach(() => { | ||
| tempDir = mkdtempSync(join(tmpdir(), 'limb-policy-')); | ||
| dbPath = join(tempDir, 'limb.sqlite'); | ||
| persistence = new SqliteLimbPersistence(dbPath); | ||
| persistence.initialize(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| persistence.close(); | ||
| rmSync(tempDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| it('round-trips access policy across instances', () => { | ||
| const policy1 = new LimbAccessPolicy(persistence); | ||
| policy1.setPolicy({ catId: 'cat-1', nodeId: 'node-1', capability: 'shell', authLevel: 'leased' }); | ||
|
|
||
| const policy2 = new LimbAccessPolicy(persistence); | ||
| policy2.initialize(); | ||
| assert.equal(policy2.check('cat-1', 'node-1', 'shell'), 'leased'); | ||
| }); | ||
|
|
||
| it('works without persistence (backward compat)', () => { | ||
| const policy = new LimbAccessPolicy(); | ||
| policy.setPolicy({ catId: 'cat-2', nodeId: 'node-2', capability: 'fs', authLevel: 'gated' }); | ||
| assert.equal(policy.check('cat-2', 'node-2', 'fs'), 'gated'); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import { mkdtempSync, rmSync } from 'node:fs'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
| import { afterEach, beforeEach, describe, it } from 'node:test'; | ||
|
|
||
| const { SqliteLimbPersistence } = await import('../dist/domains/limb/SqliteLimbPersistence.js'); | ||
| const { LimbPairingStore } = await import('../dist/domains/limb/LimbPairingStore.js'); | ||
|
|
||
| describe('LimbPairingStore with persistence', () => { | ||
| let tempDir; | ||
| let dbPath; | ||
| let persistence; | ||
|
|
||
| beforeEach(() => { | ||
| tempDir = mkdtempSync(join(tmpdir(), 'limb-pairing-')); | ||
| dbPath = join(tempDir, 'limb.sqlite'); | ||
| persistence = new SqliteLimbPersistence(dbPath); | ||
| persistence.initialize(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| persistence.close(); | ||
| rmSync(tempDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| it('round-trips approved pairing across store instances', () => { | ||
| const store1 = new LimbPairingStore(persistence); | ||
| const req = store1.createRequest({ | ||
| nodeId: 'n1', | ||
| displayName: 'Node 1', | ||
| platform: 'linux', | ||
| endpointUrl: 'http://localhost:8080', | ||
| capabilities: [{ cap: 'shell', commands: ['exec'], authLevel: 'free' }], | ||
| }); | ||
| store1.approve(req.requestId); | ||
|
|
||
| // New store from same DB | ||
| const store2 = new LimbPairingStore(persistence); | ||
| store2.initialize(); | ||
| const approved = store2.getApproved(); | ||
| assert.equal(approved.length, 1); | ||
| assert.equal(approved[0].nodeId, 'n1'); | ||
| assert.equal(approved[0].status, 'approved'); | ||
| }); | ||
|
|
||
| it('works without persistence (backward compat)', () => { | ||
| const store = new LimbPairingStore(); | ||
| const req = store.createRequest({ | ||
| nodeId: 'n2', | ||
| displayName: 'Node 2', | ||
| platform: 'darwin', | ||
| endpointUrl: 'http://localhost:9090', | ||
| capabilities: [], | ||
| }); | ||
| assert.equal(store.getPending().length, 1); | ||
| store.approve(req.requestId); | ||
| assert.equal(store.getApproved().length, 1); | ||
| }); | ||
|
|
||
| it('rejected pairings also persist', () => { | ||
| const store1 = new LimbPairingStore(persistence); | ||
| const req = store1.createRequest({ | ||
| nodeId: 'n3', | ||
| displayName: 'Node 3', | ||
| platform: 'linux', | ||
| endpointUrl: 'http://localhost:7070', | ||
| capabilities: [], | ||
| }); | ||
| store1.reject(req.requestId); | ||
|
|
||
| const store2 = new LimbPairingStore(persistence); | ||
| store2.initialize(); | ||
| assert.equal(store2.getPending().length, 0); | ||
| assert.equal(store2.getApproved().length, 0); | ||
| assert.equal(store2.get(req.requestId)?.status, 'rejected'); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ensure reconnect-time mutations are written through to SQLite:
createRequest()only persists when a new request is created, but the reconnect flow inregisterLimbNodeRoutesmutates existing approved requests (notablyendpointUrl) in place. With this commit’s restart recovery, those in-memory changes are lost on process restart, so recovered stubs can point to stale endpoints and route invokes to the wrong/unreachable host until another full re-register occurs.Useful? React with 👍 / 👎.