Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/api/src/domains/limb/LimbAccessPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,23 @@
*/

import type { LimbAccessEntry, LimbAuthLevel, LimbCapability } from '@cat-cafe/shared';
import type { SqliteLimbPersistence } from './SqliteLimbPersistence.js';

export class LimbAccessPolicy {
private readonly policies = new Map<string, LimbAccessEntry>();
private readonly persistence?: SqliteLimbPersistence;

constructor(persistence?: SqliteLimbPersistence) {
this.persistence = persistence;
}

/** Load persisted policies into memory (call after persistence.initialize()) */
initialize(): void {
if (!this.persistence) return;
for (const e of this.persistence.loadAccessPolicies()) {
this.policies.set(LimbAccessPolicy.key(e.catId, e.nodeId, e.capability), e);
}
}

private static key(catId: string, nodeId: string, capability: string): string {
return `${catId}:${nodeId}:${capability}`;
Expand All @@ -17,6 +31,7 @@ export class LimbAccessPolicy {
/** 设置权限条目(覆盖已有) */
setPolicy(entry: LimbAccessEntry): void {
this.policies.set(LimbAccessPolicy.key(entry.catId, entry.nodeId, entry.capability), entry);
this.persistence?.upsertAccessPolicy(entry);
}

/** 检查显式策略(未配置返回 null) */
Expand Down
17 changes: 17 additions & 0 deletions packages/api/src/domains/limb/LimbPairingStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import { randomUUID } from 'node:crypto';
import type { LimbCapability } from '@cat-cafe/shared';
import type { SqliteLimbPersistence } from './SqliteLimbPersistence.js';

export interface PairingRequest {
requestId: string;
Expand All @@ -32,6 +33,19 @@ export interface CreatePairingParams {

export class LimbPairingStore {
private readonly requests = new Map<string, PairingRequest>();
private readonly persistence?: SqliteLimbPersistence;

constructor(persistence?: SqliteLimbPersistence) {
this.persistence = persistence;
}

/** Load persisted pairings into memory (call after persistence.initialize()) */
initialize(): void {
if (!this.persistence) return;
for (const p of this.persistence.loadPairings()) {
this.requests.set(p.requestId, p);
}
}

createRequest(params: CreatePairingParams): PairingRequest {
// Check for duplicate nodeId in pending/approved
Expand All @@ -50,6 +64,7 @@ export class LimbPairingStore {
};

this.requests.set(request.requestId, request);
this.persistence?.upsertPairing(request);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist approved-pairing reconnect updates

Ensure reconnect-time mutations are written through to SQLite: createRequest() only persists when a new request is created, but the reconnect flow in registerLimbNodeRoutes mutates existing approved requests (notably endpointUrl) 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 👍 / 👎.

return request;
}

Expand All @@ -59,6 +74,7 @@ export class LimbPairingStore {
if (req.status === 'approved') return req; // Idempotent
req.status = 'approved';
req.decidedAt = Date.now();
this.persistence?.upsertPairing(req);
return req;
}

Expand All @@ -67,6 +83,7 @@ export class LimbPairingStore {
if (!req) return false;
req.status = 'rejected';
req.decidedAt = Date.now();
this.persistence?.upsertPairing(req);
return true;
}

Expand Down
146 changes: 146 additions & 0 deletions packages/api/src/domains/limb/SqliteLimbPersistence.ts
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;
}
}
32 changes: 30 additions & 2 deletions packages/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1003,17 +1003,45 @@ async function main(): Promise<void> {
const { LimbAccessPolicy } = await import('./domains/limb/LimbAccessPolicy.js');
const { LimbLeaseManager } = await import('./domains/limb/LimbLeaseManager.js');
const { LimbActionLog } = await import('./domains/limb/LimbActionLog.js');

// #331: SQLite persistence for limb pairings + access policies
const { SqliteLimbPersistence } = await import('./domains/limb/SqliteLimbPersistence.js');
const limbDbPath = process.env.LIMB_DB ?? resolve(repoRoot, 'limb.sqlite');
const limbPersistence = new SqliteLimbPersistence(limbDbPath);
limbPersistence.initialize();

const limbAccessPolicy = new LimbAccessPolicy(limbPersistence);
limbAccessPolicy.initialize();

const limbRegistry = new LimbRegistry();
limbRegistry.setDeps({
accessPolicy: new LimbAccessPolicy(),
accessPolicy: limbAccessPolicy,
leaseManager: new LimbLeaseManager(),
actionLog: new LimbActionLog(),
});

// F126 Phase C: Pairing store + limb node routes for remote devices
const { LimbPairingStore } = await import('./domains/limb/LimbPairingStore.js');
const { registerLimbNodeRoutes } = await import('./routes/limb-node-routes.js');
const limbPairingStore = new LimbPairingStore();
const limbPairingStore = new LimbPairingStore(limbPersistence);
limbPairingStore.initialize();

// #331: Recover approved pairings as offline RemoteLimbNode stubs
const { RemoteLimbNode } = await import('./domains/limb/RemoteLimbNode.js');
for (const pairing of limbPairingStore.getApproved()) {
if (!limbRegistry.getNode(pairing.nodeId)) {
const stub = new RemoteLimbNode({
nodeId: pairing.nodeId,
displayName: pairing.displayName,
platform: pairing.platform,
capabilities: pairing.capabilities,
endpointUrl: pairing.endpointUrl,
apiKey: pairing.apiKey,
});
await limbRegistry.register(stub);
limbRegistry.updateStatus(pairing.nodeId, 'offline');
}
}
registerLimbNodeRoutes(app, { limbRegistry, pairingStore: limbPairingStore });

const callbackOpts = {
Expand Down
41 changes: 41 additions & 0 deletions packages/api/test/limb-access-policy-persistence.test.js
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');
});
});
78 changes: 78 additions & 0 deletions packages/api/test/limb-pairing-store-persistence.test.js
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');
});
});
Loading