mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-05 06:05:28 +00:00
`logicsrc vault` (OpenCreds) lived only in ~/.config/logicsrc/opencreds. Lose the machine and the vault went with it, and a second machine had no way to get it. It now syncs to the logged-in account. Server (apps/pwa, /api/opencreds, session or lsk_ bearer): - opencreds_vaults: one per user. meta (key material already wrapped under the master password) + an encrypted folder list, each with a revision. - opencreds_items: one row per item, as the spec asks; envelope NULL is a tombstone so purges propagate; seq for incremental pulls. - Every write names its base revision; a stale one gets 409 with the current row. Writes are conditional and read back (each envelope's random IV identifies our write), so this needs nothing dialect-specific from SQLite or Postgres. - Stores ciphertext only. The server learns item count and type codes, as OpenCreds security.md already accepts. Client (@logicsrc/opencreds sync.ts, key-free except where noted): - Pull before every vault command, push after. Offline, the command still works and the change goes up next time. - Conflicts never lose data: the account's edit keeps the id and this machine's becomes "<name> (conflict copy)" (needs the unlocked key, so it waits otherwise). An edit beats a purge. Byte-identical envelopes are adopted, not split, so a lost sync.json is harmless. - Two different vaults (meta.createdAt differs) are never merged: refused, with `vault sync --use-remote` (backs this machine's up to .bak-NNN) or `--use-local`. - Remote item ids must be plain ids; anything else is ignored and never becomes a path. - Folder names are AES-GCM encrypted under the user key before upload. CLI (0.6.0): `logicsrc vault sync [--status|--use-remote|--use-local]`. Only the default vault syncs, and only when logged in; a --home or OPENCREDS_HOME vault stays local unless LOGICSRC_VAULT_SYNC=on; LOGICSRC_VAULT_SYNC=off disables it. The standalone `opencreds` binary gets no remote and never syncs. `init` on a machine that just downloaded the account's vault says to unlock it instead of suggesting --force. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
123 lines
6.5 KiB
JavaScript
123 lines
6.5 KiB
JavaScript
// /api/opencreds: the account copy of a personal vault. The server stores
|
|
// ciphertext and enforces one rule, optimistic revisions: a write based on a
|
|
// revision someone else already moved is refused with the current row.
|
|
process.env.DATABASE_URL = process.env.PWA_TEST_DATABASE_URL || ":memory:";
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { dirname, join } from "node:path";
|
|
import express from "express";
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const { db, run, isPostgres } = await import("../src/db.mjs");
|
|
const { opencredsRouter } = await import("../src/routes/opencreds.mjs");
|
|
|
|
if (isPostgres) {
|
|
await db.execute("DROP SCHEMA public CASCADE");
|
|
await db.execute("CREATE SCHEMA public");
|
|
}
|
|
for (const file of ["001_auth.sql", "004_opencreds_sync.sql"]) {
|
|
const sql = readFileSync(join(here, "..", "src", isPostgres ? "migrations-pg" : "migrations", file), "utf8");
|
|
for (const statement of sql.split(/;\s*$/m).map((s) => s.trim()).filter(Boolean)) await db.execute(statement);
|
|
}
|
|
for (const uid of ["u1", "u2"]) await run(`INSERT INTO users (id, email, created_at) VALUES (?,?,?)`, [uid, `${uid}@e.test`, Date.now()]);
|
|
|
|
const app = express();
|
|
app.use(express.json({ limit: "10mb" }));
|
|
app.use((req, _res, next) => { req.user = req.get("x-user") ? { id: req.get("x-user") } : null; next(); });
|
|
app.use(opencredsRouter);
|
|
const server = app.listen(0);
|
|
await new Promise((r) => server.once("listening", r));
|
|
const base = `http://127.0.0.1:${server.address().port}`;
|
|
test.after(() => new Promise((r) => server.close(r)));
|
|
|
|
async function call(user, method, path, body) {
|
|
const res = await fetch(base + path, {
|
|
method,
|
|
headers: { ...(user ? { "x-user": user } : {}), ...(body ? { "content-type": "application/json" } : {}) },
|
|
body: body ? JSON.stringify(body) : undefined
|
|
});
|
|
return { status: res.status, body: await res.json() };
|
|
}
|
|
const meta = (tag) => ({ opencreds: "0.1", namespace: "opencreds", createdAt: "2026-10-04T00:00:00Z", protectedUserKey: "pk-" + tag, protectedUserKeyIv: "iv" });
|
|
const env = (id, c) => ({ id, type: 1, ciphertext: c, iv: "iv-" + c });
|
|
|
|
test("no session, no key: 401", async () => {
|
|
assert.equal((await call(null, "GET", "/api/opencreds/vault")).status, 401);
|
|
});
|
|
|
|
test("meta: create once, update on the current revision, refuse a stale one", async () => {
|
|
assert.equal((await call("u1", "GET", "/api/opencreds/vault")).status, 404);
|
|
assert.deepEqual((await call("u1", "PUT", "/api/opencreds/vault/meta", { meta: meta("a"), baseRevision: 0 })).body, { ok: true, revision: 1 });
|
|
|
|
const again = await call("u1", "PUT", "/api/opencreds/vault/meta", { meta: meta("b"), baseRevision: 0 });
|
|
assert.equal(again.status, 409);
|
|
assert.equal(again.body.vault.meta.protectedUserKey, "pk-a");
|
|
|
|
assert.equal((await call("u1", "PUT", "/api/opencreds/vault/meta", { meta: meta("c"), baseRevision: 1 })).body.revision, 2);
|
|
const stale = await call("u1", "PUT", "/api/opencreds/vault/meta", { meta: meta("d"), baseRevision: 1 });
|
|
assert.equal(stale.status, 409);
|
|
assert.equal(stale.body.vault.metaRevision, 2);
|
|
});
|
|
|
|
test("items: insert, update, conflict returns the current row, tombstone, cursor", async () => {
|
|
let r = await call("u1", "PUT", "/api/opencreds/items", { changes: [{ id: "i1", envelope: env("i1", "c1"), baseRevision: 0 }, { id: "i2", envelope: env("i2", "x"), baseRevision: 0 }] });
|
|
assert.equal(r.body.applied.length, 2);
|
|
assert.equal(r.body.conflicts.length, 0);
|
|
|
|
r = await call("u1", "PUT", "/api/opencreds/items", { changes: [{ id: "i1", envelope: env("i1", "c2"), baseRevision: 1 }] });
|
|
assert.deepEqual(r.body.applied.map((a) => a.revision), [2]);
|
|
|
|
r = await call("u1", "PUT", "/api/opencreds/items", { changes: [{ id: "i1", envelope: env("i1", "stale"), baseRevision: 1 }] });
|
|
assert.equal(r.body.applied.length, 0);
|
|
assert.equal(r.body.conflicts[0].revision, 2);
|
|
assert.equal(r.body.conflicts[0].envelope.ciphertext, "c2");
|
|
|
|
r = await call("u1", "PUT", "/api/opencreds/items", { changes: [{ id: "i2", envelope: null, baseRevision: 1 }] });
|
|
assert.equal(r.body.applied[0].revision, 2);
|
|
|
|
const all = await call("u1", "GET", "/api/opencreds/items?since=0");
|
|
const byId = Object.fromEntries(all.body.items.map((i) => [i.id, i]));
|
|
assert.equal(byId.i1.envelope.ciphertext, "c2");
|
|
assert.equal(byId.i1.envelope.revision, 2);
|
|
assert.equal(byId.i2.envelope, null);
|
|
|
|
const later = await call("u1", "GET", `/api/opencreds/items?since=${byId.i2.seq}`);
|
|
assert.deepEqual(later.body.items.map((i) => i.id), ["i2"]);
|
|
|
|
const v = await call("u1", "GET", "/api/opencreds/vault");
|
|
assert.equal(v.body.vault.itemCount, 1);
|
|
});
|
|
|
|
test("an update to a row that does not exist is a conflict at revision 0", async () => {
|
|
const r = await call("u1", "PUT", "/api/opencreds/items", { changes: [{ id: "ghost", envelope: env("ghost", "g"), baseRevision: 3 }] });
|
|
assert.deepEqual(r.body.conflicts, [{ id: "ghost", envelope: null, revision: 0, seq: 0 }]);
|
|
});
|
|
|
|
test("one user's vault is invisible to another", async () => {
|
|
assert.equal((await call("u2", "GET", "/api/opencreds/vault")).status, 404);
|
|
assert.deepEqual((await call("u2", "GET", "/api/opencreds/items?since=0")).body.items, []);
|
|
const r = await call("u2", "PUT", "/api/opencreds/items", { changes: [{ id: "i1", envelope: env("i1", "u2"), baseRevision: 0 }] });
|
|
assert.equal(r.status, 409, "items need the user's own vault first");
|
|
});
|
|
|
|
test("malformed changes are refused before anything is written", async () => {
|
|
const mismatch = await call("u1", "PUT", "/api/opencreds/items", { changes: [{ id: "a", envelope: env("b", "x"), baseRevision: 0 }] });
|
|
assert.equal(mismatch.status, 422);
|
|
const noBase = await call("u1", "PUT", "/api/opencreds/items", { changes: [{ id: "a", envelope: env("a", "x") }] });
|
|
assert.equal(noBase.status, 422);
|
|
assert.equal((await call("u1", "PUT", "/api/opencreds/vault/meta", { meta: "nope", baseRevision: 0 })).status, 422);
|
|
});
|
|
|
|
test("folders follow the same revision rule", async () => {
|
|
assert.equal((await call("u1", "PUT", "/api/opencreds/vault/folders", { ciphertext: "f", iv: "i", baseRevision: 0 })).body.revision, 1);
|
|
assert.equal((await call("u1", "PUT", "/api/opencreds/vault/folders", { ciphertext: "g", iv: "i", baseRevision: 0 })).status, 409);
|
|
});
|
|
|
|
test("reset drops the vault and its items", async () => {
|
|
await call("u1", "DELETE", "/api/opencreds/vault");
|
|
assert.equal((await call("u1", "GET", "/api/opencreds/vault")).status, 404);
|
|
assert.deepEqual((await call("u1", "GET", "/api/opencreds/items?since=0")).body.items, []);
|
|
});
|