mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-04 13:45:40 +00:00
vault: autosync the personal vault to the logicsrc account (#226)
`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>
This commit is contained in:
parent
5bebc50beb
commit
d38db62e8b
14 changed files with 1293 additions and 7 deletions
26
apps/pwa/src/migrations-pg/004_opencreds_sync.sql
Normal file
26
apps/pwa/src/migrations-pg/004_opencreds_sync.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
-- Postgres copy of migrations/004_opencreds_sync.sql (INTEGER -> bigint).
|
||||
-- The personal vault (`logicsrc vault`, OpenCreds), synced to the account;
|
||||
-- ciphertext and wrapped key material only.
|
||||
|
||||
create table if not exists opencreds_vaults (
|
||||
user_id text PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
meta text NOT NULL,
|
||||
meta_revision bigint NOT NULL,
|
||||
folders text,
|
||||
folders_revision bigint NOT NULL DEFAULT 0,
|
||||
created_at bigint NOT NULL,
|
||||
updated_at bigint NOT NULL
|
||||
);
|
||||
|
||||
create table if not exists opencreds_items (
|
||||
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
id text NOT NULL,
|
||||
type bigint,
|
||||
envelope text,
|
||||
revision bigint NOT NULL,
|
||||
seq bigint NOT NULL,
|
||||
updated_at bigint NOT NULL,
|
||||
PRIMARY KEY (user_id, id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_opencreds_items_seq ON opencreds_items(user_id, seq);
|
||||
32
apps/pwa/src/migrations/004_opencreds_sync.sql
Normal file
32
apps/pwa/src/migrations/004_opencreds_sync.sql
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
-- The personal vault (`logicsrc vault`, OpenCreds), synced to the account.
|
||||
-- Zero-knowledge like credshare: meta holds only key material wrapped under
|
||||
-- the master password, folders and items are AES-GCM ciphertext under the
|
||||
-- vault's user key. The server can count items and read their type code
|
||||
-- (OpenCreds security.md accepts that), and nothing else.
|
||||
|
||||
-- One vault per user. meta_revision / folders_revision are optimistic locks:
|
||||
-- a write names the revision it was based on and loses if someone moved it.
|
||||
CREATE TABLE IF NOT EXISTS opencreds_vaults (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
meta TEXT NOT NULL,
|
||||
meta_revision INTEGER NOT NULL,
|
||||
folders TEXT,
|
||||
folders_revision INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- One row per item, as the spec asks. envelope NULL is a tombstone (purged),
|
||||
-- kept so the purge reaches every other machine. seq orders changes per user
|
||||
-- for incremental pulls.
|
||||
CREATE TABLE IF NOT EXISTS opencreds_items (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
id TEXT NOT NULL,
|
||||
type INTEGER,
|
||||
envelope TEXT,
|
||||
revision INTEGER NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_opencreds_items_seq ON opencreds_items(user_id, seq);
|
||||
154
apps/pwa/src/routes/opencreds.mjs
Normal file
154
apps/pwa/src/routes/opencreds.mjs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
// Sync for the personal vault (`logicsrc vault`, OpenCreds) — one per account.
|
||||
//
|
||||
// Zero-knowledge: the CLI uploads the vault's meta (key material wrapped under
|
||||
// the master password), an encrypted folder list, and item envelopes. Nothing
|
||||
// here can decrypt any of it, and nothing here needs to.
|
||||
//
|
||||
// Concurrency is optimistic. Every write names the revision it was based on;
|
||||
// a write against a revision someone else already moved is answered with the
|
||||
// current row instead of being applied, and the client resolves it. That is
|
||||
// the reason the spec stores one row per item: two machines editing two
|
||||
// different passwords both win, and two editing the same one find out.
|
||||
//
|
||||
// Auth: browser session or `Bearer lsk_…` (the logicsrc CLI). Mounted at /api/opencreds.
|
||||
import { Router } from "express";
|
||||
import { get, all, run } from "../db.mjs";
|
||||
import { bearer, userForApiKey } from "../lib/apikey.mjs";
|
||||
|
||||
export const opencredsRouter = Router();
|
||||
|
||||
/** Upper bound on one push, in items. The client batches below this. */
|
||||
export const MAX_ITEMS_PER_PUSH = 500;
|
||||
|
||||
function api(handler) {
|
||||
return async (req, res) => {
|
||||
const user = req.user || (await userForApiKey(bearer(req)));
|
||||
if (!user) return res.status(401).json({ error: "Not authenticated. Run: logicsrc login" });
|
||||
try {
|
||||
await handler(req, res, user);
|
||||
} catch (e) {
|
||||
console.error("opencreds:", e);
|
||||
res.status(500).json({ error: e.message || String(e) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
const nonNegInt = (v) => Number.isInteger(v) && v >= 0;
|
||||
|
||||
function vaultJson(row) {
|
||||
return {
|
||||
meta: JSON.parse(row.meta),
|
||||
metaRevision: Number(row.meta_revision),
|
||||
folders: row.folders ? { ...JSON.parse(row.folders), revision: Number(row.folders_revision) } : null,
|
||||
updatedAt: Number(row.updated_at)
|
||||
};
|
||||
}
|
||||
|
||||
function itemJson(row) {
|
||||
const envelope = row.envelope ? JSON.parse(row.envelope) : null;
|
||||
const revision = Number(row.revision);
|
||||
return { id: row.id, envelope: envelope && { ...envelope, revision }, revision, seq: Number(row.seq) };
|
||||
}
|
||||
|
||||
const vaultRow = (userId) => get(`SELECT * FROM opencreds_vaults WHERE user_id = ?`, [userId]);
|
||||
const itemRow = (userId, id) => get(`SELECT * FROM opencreds_items WHERE user_id = ? AND id = ?`, [userId, id]);
|
||||
const NEXT_SEQ = `(SELECT COALESCE(MAX(seq), 0) + 1 FROM opencreds_items WHERE user_id = ?)`;
|
||||
|
||||
// ---- the vault: meta + folders ----
|
||||
opencredsRouter.get("/api/opencreds/vault", api(async (_req, res, user) => {
|
||||
const row = await vaultRow(user.id);
|
||||
if (!row) return res.status(404).json({ vault: null });
|
||||
const counts = await get(
|
||||
`SELECT COUNT(*) AS n, MAX(seq) AS seq FROM opencreds_items WHERE user_id = ? AND envelope IS NOT NULL`, [user.id]);
|
||||
res.json({ vault: { ...vaultJson(row), itemCount: Number(counts?.n || 0) } });
|
||||
}));
|
||||
|
||||
opencredsRouter.put("/api/opencreds/vault/meta", api(async (req, res, user) => {
|
||||
const { meta, baseRevision } = req.body || {};
|
||||
if (!isObject(meta) || typeof meta.protectedUserKey !== "string" || !nonNegInt(baseRevision)) {
|
||||
return res.status(422).json({ error: "Expected { meta, baseRevision }." });
|
||||
}
|
||||
const text = JSON.stringify(meta), now = Date.now();
|
||||
if (baseRevision === 0) {
|
||||
await run(`INSERT INTO opencreds_vaults (user_id, meta, meta_revision, created_at, updated_at) VALUES (?,?,1,?,?) ON CONFLICT (user_id) DO NOTHING`,
|
||||
[user.id, text, now, now]);
|
||||
} else {
|
||||
await run(`UPDATE opencreds_vaults SET meta = ?, meta_revision = meta_revision + 1, updated_at = ? WHERE user_id = ? AND meta_revision = ?`,
|
||||
[text, now, user.id, baseRevision]);
|
||||
}
|
||||
// Read back: our exact meta at base+1 means this write is the one that landed.
|
||||
const row = await vaultRow(user.id);
|
||||
if (row && row.meta === text && Number(row.meta_revision) === baseRevision + 1) {
|
||||
return res.json({ ok: true, revision: baseRevision + 1 });
|
||||
}
|
||||
res.status(409).json({ ok: false, vault: row ? vaultJson(row) : null });
|
||||
}));
|
||||
|
||||
opencredsRouter.put("/api/opencreds/vault/folders", api(async (req, res, user) => {
|
||||
const { ciphertext, iv, baseRevision } = req.body || {};
|
||||
if (typeof ciphertext !== "string" || typeof iv !== "string" || !nonNegInt(baseRevision)) {
|
||||
return res.status(422).json({ error: "Expected { ciphertext, iv, baseRevision }." });
|
||||
}
|
||||
if (!(await vaultRow(user.id))) return res.status(409).json({ ok: false, error: "Push the vault meta first." });
|
||||
const text = JSON.stringify({ ciphertext, iv });
|
||||
await run(`UPDATE opencreds_vaults SET folders = ?, folders_revision = folders_revision + 1, updated_at = ? WHERE user_id = ? AND folders_revision = ?`,
|
||||
[text, Date.now(), user.id, baseRevision]);
|
||||
const row = await vaultRow(user.id);
|
||||
if (row.folders === text && Number(row.folders_revision) === baseRevision + 1) {
|
||||
return res.json({ ok: true, revision: baseRevision + 1 });
|
||||
}
|
||||
res.status(409).json({ ok: false, vault: vaultJson(row) });
|
||||
}));
|
||||
|
||||
// Replace the account's vault (`logicsrc vault sync --use-local`): drop it and
|
||||
// every item so the next push starts from revision 0.
|
||||
opencredsRouter.delete("/api/opencreds/vault", api(async (_req, res, user) => {
|
||||
await run(`DELETE FROM opencreds_items WHERE user_id = ?`, [user.id]);
|
||||
await run(`DELETE FROM opencreds_vaults WHERE user_id = ?`, [user.id]);
|
||||
res.json({ ok: true });
|
||||
}));
|
||||
|
||||
// ---- items ----
|
||||
// `since` is inclusive: a seq can repeat under concurrent pushes, and an item
|
||||
// seen twice at the same revision is a no-op on the client.
|
||||
opencredsRouter.get("/api/opencreds/items", api(async (req, res, user) => {
|
||||
const since = Number.parseInt(String(req.query.since ?? "0"), 10) || 0;
|
||||
const rows = await all(`SELECT * FROM opencreds_items WHERE user_id = ? AND seq >= ? ORDER BY seq`, [user.id, since]);
|
||||
const items = rows.map(itemJson);
|
||||
res.json({ items, cursor: items.reduce((max, i) => Math.max(max, i.seq), since) });
|
||||
}));
|
||||
|
||||
opencredsRouter.put("/api/opencreds/items", api(async (req, res, user) => {
|
||||
const changes = Array.isArray(req.body?.changes) ? req.body.changes : null;
|
||||
if (!changes) return res.status(422).json({ error: "Expected { changes: [{ id, envelope|null, baseRevision }] }." });
|
||||
if (changes.length > MAX_ITEMS_PER_PUSH) return res.status(413).json({ error: `At most ${MAX_ITEMS_PER_PUSH} items per push.` });
|
||||
for (const c of changes) {
|
||||
const ok = isObject(c) && typeof c.id === "string" && c.id.length > 0 && c.id.length <= 128 && nonNegInt(c.baseRevision) &&
|
||||
(c.envelope === null || (isObject(c.envelope) && c.envelope.id === c.id && typeof c.envelope.ciphertext === "string" && typeof c.envelope.iv === "string"));
|
||||
if (!ok) return res.status(422).json({ error: `Malformed change${isObject(c) && c.id ? ` for ${c.id}` : ""}.` });
|
||||
}
|
||||
if (!(await vaultRow(user.id))) return res.status(409).json({ error: "Push the vault meta first." });
|
||||
|
||||
const applied = [], conflicts = [];
|
||||
for (const c of changes) {
|
||||
// The server's revision is authoritative; the client's local counter is not stored.
|
||||
const envelope = c.envelope ? JSON.stringify((({ revision: _r, ...rest }) => rest)(c.envelope)) : null;
|
||||
const type = c.envelope && Number.isInteger(c.envelope.type) ? c.envelope.type : null;
|
||||
const now = Date.now();
|
||||
if (c.baseRevision === 0) {
|
||||
await run(`INSERT INTO opencreds_items (user_id, id, type, envelope, revision, seq, updated_at) VALUES (?,?,?,?,1,${NEXT_SEQ},?) ON CONFLICT (user_id, id) DO NOTHING`,
|
||||
[user.id, c.id, type, envelope, user.id, now]);
|
||||
} else {
|
||||
await run(`UPDATE opencreds_items SET type = ?, envelope = ?, revision = revision + 1, seq = ${NEXT_SEQ}, updated_at = ? WHERE user_id = ? AND id = ? AND revision = ?`,
|
||||
[type, envelope, user.id, now, user.id, c.id, c.baseRevision]);
|
||||
}
|
||||
const row = await itemRow(user.id, c.id);
|
||||
if (row && (row.envelope ?? null) === envelope && Number(row.revision) === c.baseRevision + 1) {
|
||||
applied.push({ id: c.id, revision: Number(row.revision), seq: Number(row.seq) });
|
||||
} else {
|
||||
conflicts.push(row ? itemJson(row) : { id: c.id, envelope: null, revision: 0, seq: 0 });
|
||||
}
|
||||
}
|
||||
res.json({ applied, conflicts });
|
||||
}));
|
||||
|
|
@ -10,6 +10,7 @@ import { authRouter } from "./routes/auth.mjs";
|
|||
import { passkeyRouter } from "./routes/passkey.mjs";
|
||||
import { coinpayRouter } from "./routes/coinpay.mjs";
|
||||
import { credshareRouter } from "./routes/credshare.mjs";
|
||||
import { opencredsRouter } from "./routes/opencreds.mjs";
|
||||
import { cliRouter } from "./routes/cli.mjs";
|
||||
import { pagesRouter } from "./routes/pages.mjs";
|
||||
|
||||
|
|
@ -18,6 +19,9 @@ app.disable("x-powered-by");
|
|||
if (config.secure) app.set("trust proxy", 1); // Railway terminates TLS
|
||||
|
||||
// body parsing — keep the raw body for HMAC signature verification
|
||||
// A vault push carries hundreds of encrypted items; parse it under a larger cap
|
||||
// first. The global parser below skips a body that is already parsed.
|
||||
app.use("/api/opencreds", express.json({ limit: "10mb" }));
|
||||
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString("utf8"); } }));
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
app.use(cookieParser());
|
||||
|
|
@ -46,6 +50,7 @@ app.use(authRouter); // GET / (+ /auth/login|register|logout)
|
|||
app.use(passkeyRouter);
|
||||
app.use(coinpayRouter);
|
||||
app.use(credshareRouter); // /api/credshare/* (session or lsk_ Bearer)
|
||||
app.use(opencredsRouter); // /api/opencreds/* — personal vault sync (session or lsk_ Bearer)
|
||||
app.use(cliRouter); // /cli/authorize, /cli/token, /api/me
|
||||
app.use(pagesRouter); // /dashboard, /teams/*, /settings
|
||||
|
||||
|
|
|
|||
123
apps/pwa/test/opencreds-sync.test.mjs
Normal file
123
apps/pwa/test/opencreds-sync.test.mjs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
// /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, []);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue