Compare commits

..
1 Commits
Author SHA1 Message Date
codingetandClaude bc787a208a feat: add PostgreSQL backend
Mirrors the node:sqlite sub-backend structure with full migration support.
Uses native pg types (UUID, JSONB, TIMESTAMPTZ) and $1/$2 parameterisation
via internal ? placeholders converted at execution time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 10:42:29 +00:00
97 changed files with 345 additions and 7205 deletions
-9
View File
@@ -1,9 +0,0 @@
name: Setup
description: Set up the Node version required by this repository. Requires actions/checkout to have already run.
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
-124
View File
@@ -1,124 +0,0 @@
name: CI
on:
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
install-and-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.gitea/actions/setup
- name: Clean install dependencies
run: npm ci
- name: Production build
run: npm run build
- name: Archive dependencies
run: tar -czf node_modules.tar.gz node_modules
- uses: actions/upload-artifact@v3
with:
name: node_modules-${{ github.run_id }}
path: node_modules.tar.gz
- name: Archive build artifacts
run: tar -czf build-artifacts.tar.gz dist
- uses: actions/upload-artifact@v3
with:
name: build-artifacts-${{ github.run_id }}
path: build-artifacts.tar.gz
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.gitea/actions/setup
- name: Clean install dependencies
run: npm ci
- name: Lint
run: npm run lint
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.gitea/actions/setup
- name: Clean install dependencies
run: npm ci
- name: Check formatting
run: npm run format:check
typecheck-source:
runs-on: ubuntu-latest
needs: install-and-build
steps:
- uses: actions/checkout@v4
- uses: ./.gitea/actions/setup
- uses: actions/download-artifact@v3
with:
name: node_modules-${{ github.run_id }}
- uses: actions/download-artifact@v3
with:
name: build-artifacts-${{ github.run_id }}
- name: Restore dependencies and build artifacts
run: |
tar -xzf node_modules.tar.gz
tar -xzf build-artifacts.tar.gz
- name: Typecheck source
run: npm run typecheck
typecheck-tests:
runs-on: ubuntu-latest
needs: install-and-build
steps:
- uses: actions/checkout@v4
- uses: ./.gitea/actions/setup
- uses: actions/download-artifact@v3
with:
name: node_modules-${{ github.run_id }}
- name: Restore dependencies
run: tar -xzf node_modules.tar.gz
- name: Typecheck tests
run: npm run typecheck:test
test:
runs-on: ubuntu-latest
needs: install-and-build
steps:
- uses: actions/checkout@v4
- uses: ./.gitea/actions/setup
- uses: actions/download-artifact@v3
with:
name: node_modules-${{ github.run_id }}
- name: Restore dependencies
run: tar -xzf node_modules.tar.gz
- name: Run full test suite
run: npm test
-1
View File
@@ -1 +0,0 @@
22
-2
View File
@@ -1,2 +0,0 @@
dist
node_modules
-22
View File
@@ -1,22 +0,0 @@
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: ["dist/**", "node_modules/**"],
},
eslint.configs.recommended,
tseslint.configs.recommended,
{
rules: {
"no-control-regex": "off",
"no-empty": "off",
"no-fallthrough": "off",
"prefer-const": "off",
"@typescript-eslint/no-empty-object-type": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-asserted-optional-chain": "off",
"@typescript-eslint/no-unused-vars": "off",
},
},
);
+20 -1306
View File
File diff suppressed because it is too large Load Diff
+3 -21
View File
@@ -9,10 +9,7 @@
"abode-migrate": "dist/bin/abode-migrate.cjs",
"abode-repl": "dist/bin/abode-repl.cjs",
"abode-web": "dist/bin/abode-web.cjs",
"abode-tui": "dist/bin/abode-tui.cjs",
"abode-export": "dist/bin/abode-export.cjs",
"abode-import": "dist/bin/abode-import.cjs",
"abode-inspect": "dist/bin/abode-inspect.cjs"
"abode-tui": "dist/bin/abode-tui.cjs"
},
"scripts": {
"repl": "tsx --import ./src/meta/dev/register.ts",
@@ -21,19 +18,8 @@
"abode-web": "tsx --import ./src/meta/dev/register.ts --import ./src/meta/dev/webhot.ts src/bin/abode-web.ts",
"abode-tui": "tsx --import ./src/meta/dev/register.ts --import ./src/meta/dev/silenthot.ts src/bin/abode-tui.ts",
"abode-sources": "tsx --import ./src/meta/dev/register.ts src/bin/abode-sources.ts",
"abode-export": "tsx --import ./src/meta/dev/register.ts src/bin/abode-export.ts",
"abode-import": "tsx --import ./src/meta/dev/register.ts src/bin/abode-import.ts",
"abode-inspect": "tsx --import ./src/meta/dev/register.ts src/bin/abode-inspect.ts",
"build": "NODE_ENV=production npm run build:impl",
"build:impl": "rm -rf dist && tsx node_modules/.bin/webpack && chmod +x dist/bin/* && chmod -x dist/bin/*.*",
"test": "node --import tsx/esm --import ./src/meta/dev/register.ts --test $(find test -name '*.test.ts' | sort)",
"test:backends": "node --import tsx/esm --import ./src/meta/dev/register.ts --test $(find test/backends -name '*.test.ts' | sort)",
"test:shared": "node --import tsx/esm --import ./src/meta/dev/register.ts --test $(find test/backends -name 'index.test.ts' | sort)",
"test:tools": "node --import tsx/esm --import ./src/meta/dev/register.ts --test $(find test/tools -name '*.test.ts' | sort)",
"typecheck": "tsc --noEmit",
"typecheck:test": "tsc --noEmit -p tsconfig.test.json",
"lint": "eslint .",
"format:check": "prettier --check ."
"build:impl": "rm -rf dist && tsx node_modules/.bin/webpack && chmod +x dist/bin/* && chmod -x dist/bin/*.*"
},
"dependencies": {
"@koa/bodyparser": "^6.0.0",
@@ -52,25 +38,21 @@
"react-redux": "^9.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
"@types/better-sqlite3": "^7.6.13",
"@types/pg": "^8.20.0",
"@types/koa": "^3.0.0",
"@types/koa__router": "^12.0.4",
"@types/pg": "^8.20.0",
"@types/react": "^19.1.12",
"@types/webpack-bundle-analyzer": "^4.7.0",
"copy-webpack-plugin": "^13.0.1",
"css-loader": "^7.1.2",
"dynohot": "^2.1.1",
"eslint": "^9.39.5",
"mini-css-extract-plugin": "^2.9.4",
"prettier": "^3.6.2",
"raw-loader": "^4.0.2",
"scss-loader": "^0.0.1",
"ts-loader": "^9.5.4",
"tsx": "^4.20.5",
"typescript": "^5.9.2",
"typescript-eslint": "^8.65.0",
"val-loader": "^6.0.0",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^6.0.1"
-97
View File
@@ -1,97 +0,0 @@
import { createWriteStream } from "node:fs";
import { pipeline } from "node:stream/promises";
import { getDbInterface } from "../db/index.js";
import { isExportable, type ExportFilter } from "../db/types/ExportImport.js";
import { isExportKind } from "../db/export/filter.js";
const args = process.argv.slice(2);
function printUsage(err: boolean | string = false): never {
const log = (err ? console.error : console.log).bind(console);
if (typeof err === "string") {
log(`Error: ${err}`);
log("");
}
log("Usage:");
log("\tabode-export --help");
log(
"\tabode-export <database-url> [--kinds=user,abode,...] [--exclude-kinds=...] \\",
);
log("\t [--abodes=aid,...] [--users=uid,...] [--out=file|-]");
process.exit(err ? 1 : 0);
}
if (["-h", "--help", "help"].some((x) => args.includes(x))) printUsage();
let url: string | undefined;
const flags = new Map<string, string>();
for (const arg of args) {
if (arg.startsWith("--")) {
const eq = arg.indexOf("=");
if (eq === -1) printUsage(`invalid flag: ${arg}`);
flags.set(arg.slice(2, eq), arg.slice(eq + 1));
} else if (url === undefined) {
url = arg;
} else {
printUsage("too many arguments");
}
}
if (!url) printUsage("missing <database-url>");
const knownFlags = ["kinds", "exclude-kinds", "abodes", "users", "out"];
for (const key of flags.keys()) {
if (!knownFlags.includes(key)) printUsage(`unknown flag: --${key}`);
}
function parseList(value: string | undefined): string[] | undefined {
if (value === undefined) return undefined;
return value.split(",").filter(Boolean);
}
function parseKinds(value: string | undefined) {
const list = parseList(value);
if (!list) return undefined;
const bad = list.filter((k) => !isExportKind(k));
if (bad.length) printUsage(`invalid kind(s): ${bad.join(", ")}`);
return list.filter(isExportKind);
}
const filter: ExportFilter = {};
const kinds = parseKinds(flags.get("kinds"));
if (kinds) filter.kinds = kinds;
const excludeKinds = parseKinds(flags.get("exclude-kinds"));
if (excludeKinds) filter.excludeKinds = excludeKinds;
const abodes = parseList(flags.get("abodes"));
if (abodes) filter.abodes = abodes;
const users = parseList(flags.get("users"));
if (users) filter.users = users;
const db = await getDbInterface(url);
if (!isExportable(db)) {
console.error(`Error: backend '${db.name}' does not support export`);
process.exit(1);
}
const out = flags.get("out") ?? "-";
const dest =
out === "-" ? process.stdout : createWriteStream(out, { encoding: "utf8" });
const ac = new AbortController();
const onSignal = () => ac.abort();
process.on("SIGINT", onSignal);
process.on("SIGTERM", onSignal);
try {
await pipeline(db.export({ filter, signal: ac.signal }), dest);
} catch (e) {
if (ac.signal.aborted) {
console.error("Export aborted");
process.exit(130);
}
throw e;
} finally {
process.off("SIGINT", onSignal);
process.off("SIGTERM", onSignal);
await db.close().catch(() => {});
}
process.exit(0);
-111
View File
@@ -1,111 +0,0 @@
import { createReadStream } from "node:fs";
import { getDbInterface } from "../db/index.js";
import { isExportKind } from "../db/export/filter.js";
import { isImportable, type ExportFilter } from "../db/types/ExportImport.js";
const args = process.argv.slice(2);
function printUsage(err: boolean | string = false): never {
const log = (err ? console.error : console.log).bind(console);
if (typeof err === "string") {
log(`Error: ${err}`);
log("");
}
log("Usage:");
log("\tabode-import --help");
log("\tabode-import <database-url> <input-file|-> [--kinds=...] \\");
log(
"\t [--exclude-kinds=...] [--abodes=aid,...] [--users=uid,...]",
);
log("");
log(
"The target must be a local backend (sqlite or postgres), already migrated",
);
log("(run abode-migrate first). Remote (api) targets are not importable.");
process.exit(err ? 1 : 0);
}
if (["-h", "--help", "help"].some((x) => args.includes(x))) printUsage();
const positional: string[] = [];
const flags = new Map<string, string>();
for (const arg of args) {
if (arg.startsWith("--")) {
const eq = arg.indexOf("=");
if (eq === -1) printUsage(`invalid flag: ${arg}`);
flags.set(arg.slice(2, eq), arg.slice(eq + 1));
} else {
positional.push(arg);
}
}
const url = positional[0];
const input = positional[1];
if (!url) printUsage("missing <database-url>");
if (!input) printUsage("missing <input-file|->");
if (positional.length > 2) printUsage("too many arguments");
const knownFlags = ["kinds", "exclude-kinds", "abodes", "users"];
for (const key of flags.keys()) {
if (!knownFlags.includes(key)) printUsage(`unknown flag: --${key}`);
}
function parseList(value: string | undefined): string[] | undefined {
if (value === undefined) return undefined;
return value.split(",").filter(Boolean);
}
function parseKinds(value: string | undefined) {
const list = parseList(value);
if (!list) return undefined;
const bad = list.filter((k) => !isExportKind(k));
if (bad.length) printUsage(`invalid kind(s): ${bad.join(", ")}`);
return list.filter(isExportKind);
}
const filter: ExportFilter = {};
const kinds = parseKinds(flags.get("kinds"));
if (kinds) filter.kinds = kinds;
const excludeKinds = parseKinds(flags.get("exclude-kinds"));
if (excludeKinds) filter.excludeKinds = excludeKinds;
const abodes = parseList(flags.get("abodes"));
if (abodes) filter.abodes = abodes;
const users = parseList(flags.get("users"));
if (users) filter.users = users;
// Resolve the backend generically. Import lives on the local backends (sqlite,
// postgres); the remote (api) interface has no `import`, so `isImportable`
// keeps it from ever running over HTTP.
const db = await getDbInterface(url);
if (!isImportable(db)) {
console.error(`Error: backend '${db.name}' does not support import`);
await db.close().catch(() => {});
process.exit(1);
}
const source = input === "-" ? process.stdin : createReadStream(input);
const ac = new AbortController();
const onSignal = () => ac.abort();
process.on("SIGINT", onSignal);
process.on("SIGTERM", onSignal);
try {
const result = await db.import(source, { filter, signal: ac.signal });
const total = Object.values(result.counts).reduce((a, b) => a + b, 0);
console.log(`Imported ${total} record(s):`);
for (const [kind, count] of Object.entries(result.counts)) {
console.log(`- ${kind}: ${count}`);
}
} catch (e) {
if (ac.signal.aborted) {
console.error("Import aborted; no changes committed");
process.exit(130);
}
throw e;
} finally {
process.off("SIGINT", onSignal);
process.off("SIGTERM", onSignal);
await db.close().catch(() => {});
}
process.exit(0);
-68
View File
@@ -1,68 +0,0 @@
import { createReadStream } from "node:fs";
import { inspectExportStream } from "../db/export/inspect.js";
import { isExportKind } from "../db/export/filter.js";
import type { ExportKind } from "../db/types/ExportImport.js";
const args = process.argv.slice(2);
function printUsage(err: boolean | string = false): never {
const log = (err ? console.error : console.log).bind(console);
if (typeof err === "string") {
log(`Error: ${err}`);
log("");
}
log("Usage:");
log("\tabode-inspect --help");
log("\tabode-inspect <input-file|-> [--stop-after=note,...]");
process.exit(err ? 1 : 0);
}
if (["-h", "--help", "help"].some((x) => args.includes(x))) printUsage();
let input: string | undefined;
const flags = new Map<string, string>();
for (const arg of args) {
if (arg.startsWith("--")) {
const eq = arg.indexOf("=");
if (eq === -1) printUsage(`invalid flag: ${arg}`);
flags.set(arg.slice(2, eq), arg.slice(eq + 1));
} else if (input === undefined) {
input = arg;
} else {
printUsage("too many arguments");
}
}
if (!input) printUsage("missing <input-file|->");
for (const key of flags.keys()) {
if (key !== "stop-after") printUsage(`unknown flag: --${key}`);
}
let stopAfterKinds: ExportKind[] | undefined;
const rawStop = flags.get("stop-after");
if (rawStop !== undefined) {
const list = rawStop.split(",").filter(Boolean);
const bad = list.filter((k) => !isExportKind(k));
if (bad.length) printUsage(`invalid kind(s): ${bad.join(", ")}`);
stopAfterKinds = list.filter(isExportKind);
}
const source = input === "-" ? process.stdin : createReadStream(input);
const { counts, meta } = await inspectExportStream(source, { stopAfterKinds });
if (meta) {
console.log("Meta:");
console.log(`- format version: ${meta.v ?? "?"}`);
console.log(`- exported at: ${meta.exportedAt ?? "?"}`);
console.log(`- source backend: ${meta.source ?? "?"}`);
if (meta.filter && Object.keys(meta.filter).length) {
console.log(`- effective filter: ${JSON.stringify(meta.filter)}`);
}
}
console.log("Counts:");
const entries = Object.entries(counts);
if (!entries.length) console.log("(none)");
for (const [kind, count] of entries) console.log(`- ${kind}: ${count}`);
process.exit(0);
+3 -3
View File
@@ -40,7 +40,7 @@ switch (cmd) {
if (!current.length) console.log("(none)");
for (const migration of current) {
console.log(
`- ${migration.id} (${migration.name}) applied at ${migration.applied_at}`,
`- ${migration.id} (${migration.name}) applied at ${migration.applied_at}`
);
}
break;
@@ -73,7 +73,7 @@ switch (cmd) {
password: await hashPassword("changeme"),
});
console.log(
`Created user 'admin@codi.moe' (${uid}) with password 'changeme' and admin flag`,
`Created user 'admin@codi.moe' (${uid}) with password 'changeme' and admin flag`
);
}
users = await db.listUsers();
@@ -88,7 +88,7 @@ switch (cmd) {
expires_at: null,
});
console.log(
`Created apikey '${token}' (${apikey.kid}) with permissions admin, all and no expiry`,
`Created apikey '${token}' (${apikey.kid}) with permissions admin, all and no expiry`
);
}
}
+4 -4
View File
@@ -21,7 +21,7 @@ if (args.length > 1) printUsage("too many arguments");
console.log(
`Compiled with ${compiledSources.length} sources:`,
compiledSources.join(", "),
compiledSources.join(", ")
);
const url = args[0] ?? "abode://";
@@ -31,7 +31,7 @@ for (const source of sources) {
console.log(`- ${source.name}`);
console.log(
" - protocols:",
source.protocols.map((x) => `'${x}'`).join(" "),
source.protocols.map((x) => `'${x}'`).join(" ")
);
const match = source.checkUrl(url);
console.log(` - matches url: ${match}`);
@@ -41,7 +41,7 @@ for (const source of sources) {
console.log(
` - generates an interface named ${db.name} ${
db.backend ? "with" : "without"
} backend`,
} backend`
);
await db.close().catch(console.error);
} catch (e) {
@@ -53,7 +53,7 @@ for (const source of sources) {
console.log(
` - generates a migrator knowing ${
db.listAvailableMigrations().length
} migrations`,
} migrations`
);
} catch (e) {
console.error(e);
+2 -2
View File
@@ -30,7 +30,7 @@ const bgColor = await new Promise<string>((ok, ko) => {
process.stdin.once("data", (chunk) => {
const result = chunk.toString("utf8");
const match = result.match(
/^\u001b]11;rgb:([0-9a-fA-F]+)\/([0-9a-fA-F]+)\/([0-9a-fA-F]+)$/,
/^\u001b]11;rgb:([0-9a-fA-F]+)\/([0-9a-fA-F]+)\/([0-9a-fA-F]+)$/
);
if (!match) return ko("Didn't recognize terminal bg color");
const [r, g, b] = match
@@ -78,7 +78,7 @@ if (import.meta.hot) {
import.meta.hot.accept("../tui/App.js", (mod) => {
fullscreenApp.instance.rerender(
(mod.app as typeof app)({ db, bgColor, store }),
(mod.app as typeof app)({ db, bgColor, store })
);
});
}
+14 -68
View File
@@ -25,11 +25,8 @@ import type {
CreateUser,
UpdateUser,
} from "../types/User.js";
import { Readable } from "node:stream";
import type { ReadableStream as WebReadableStream } from "node:stream/web";
import type { Exportable, ExportOptions } from "../types/ExportImport.js";
export class ApiInterface implements DbInterface, Exportable {
export class ApiInterface implements DbInterface {
#root: string;
#headers: Record<string, string>;
#readonly: boolean;
@@ -42,7 +39,7 @@ export class ApiInterface implements DbInterface, Exportable {
}: {
headers?: Record<string, string>;
readonly?: boolean;
} = {},
} = {}
) {
if (root.endsWith("/")) root = root.slice(0, -1);
this.#root = root;
@@ -58,7 +55,7 @@ export class ApiInterface implements DbInterface, Exportable {
.map((part) => {
if (part.startsWith(":")) {
const value = remaining.get(part.slice(1));
remaining.delete(part.slice(1));
remaining.delete(part);
if (value === undefined)
throw new Error(`Missing ${part} in params`);
return encodeURIComponent(value);
@@ -85,7 +82,7 @@ export class ApiInterface implements DbInterface, Exportable {
params?: Record<string, string>;
body?: unknown;
headers?: Record<string, string>;
} = {},
} = {}
): Promise<T> {
const resolvedHeaders = { ...this.#headers, ...headers };
if (body !== undefined) {
@@ -116,7 +113,6 @@ export class ApiInterface implements DbInterface, Exportable {
throw new Error(`${res.status} ${res.statusText} ${text}`);
}
}
if (res.status === 204) return undefined as T;
return res.json();
}
@@ -250,7 +246,7 @@ export class ApiInterface implements DbInterface, Exportable {
});
}
async createApikey(
apikey: CreateApikey,
apikey: CreateApikey
): Promise<[ClientApikey, `at_${string}`]> {
this.#checkReadonly();
const { apikey: key, token } = await this.#call<{
@@ -270,74 +266,24 @@ export class ApiInterface implements DbInterface, Exportable {
}
async listNotes(): Promise<PartialNote[]> {
return this.#call("GET", "/notes");
throw new Error("Unimplemented");
}
async getNoteById(nid: string): Promise<Note> {
return this.#call("GET", "/notes/:nid", { params: { nid } });
throw new Error("Unimplemented");
}
async deleteNoteById(nid: string): Promise<void> {
this.#checkReadonly();
await this.#call("DELETE", "/notes/:nid", { params: { nid } });
throw new Error("Unimplemented");
}
async createNote(note: CreateNote, _ctx: { uid: string }): Promise<Note> {
this.#checkReadonly();
return this.#call("POST", "/abodes/:aid/notes", {
params: { aid: note.aid },
body: note,
});
async createNote(note: CreateNote): Promise<Note> {
throw new Error("Unimplemented");
}
async updateNote(note: UpdateNote, _ctx: { uid: string }): Promise<Note> {
this.#checkReadonly();
return this.#call("PATCH", "/notes/:nid", {
params: { nid: note.nid },
body: note,
});
async updateNote(note: UpdateNote): Promise<Note> {
throw new Error("Unimplemented");
}
async listNotesByAbodeId(aid: string): Promise<PartialNote[]> {
return this.#call("GET", "/abodes/:aid/notes", { params: { aid } });
throw new Error("Unimplemented");
}
async listNotesByUserId(uid: string): Promise<PartialNote[]> {
return this.#call("GET", "/users/:uid/notes", { params: { uid } });
}
export(options: ExportOptions = {}): NodeJS.ReadableStream {
// Streaming NDJSON bypasses the JSON-only `#call` helper: the raw
// `fetch` gets the caller's `signal` directly, so aborting cancels the
// underlying HTTP request itself.
const { filter, signal } = options;
const sp = new URLSearchParams();
if (filter?.kinds) sp.set("kinds", filter.kinds.join(","));
if (filter?.excludeKinds)
sp.set("excludeKinds", filter.excludeKinds.join(","));
if (filter?.abodes) sp.set("abodes", filter.abodes.join(","));
if (filter?.users) sp.set("users", filter.users.join(","));
const query = sp.toString();
const url = this.#root + "/export" + (query ? `?${query}` : "");
const headers = { ...this.#headers };
async function* generate(): AsyncGenerator<Buffer | Uint8Array> {
const res = await fetch(url, { method: "GET", headers, signal });
if (!res.ok) {
const text = await res.text();
switch (res.status) {
case 400:
throw new InvalidAbodeError();
case 401:
throw new NotAuthorizedAbodeError();
case 403:
throw new ReadonlyAbodeError();
case 404:
throw new NotFoundAbodeError();
case 409:
throw new ConflictAbodeError();
default:
throw new Error(`${res.status} ${res.statusText} ${text}`);
}
}
if (!res.body) return;
yield* Readable.fromWeb(res.body as WebReadableStream<Uint8Array>);
}
return Readable.from(generate());
throw new Error("Unimplemented");
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { apiProtocols } from "./url.js";
const getApi = () =>
import(/* webpackChunkName: 'dbsource-api' */ "./getdb.static.js").then(
(x) => x.default,
(x) => x.default
);
const getApiDynamic: GetDbDynamic = {
+2 -4
View File
@@ -17,9 +17,7 @@ export function parseApiUrl(url: string) {
urlObj.href = urlObj.href.replace(/^abode\+/, "");
const readonly = (urlObj.searchParams.get("readonly") ?? "0") !== "0";
const headers = Object.fromEntries(
[...urlObj.searchParams.entries()].filter(
([param]) => param !== "readonly",
),
[...urlObj.searchParams.entries()].filter(([param]) => param !== "readonly")
);
if (urlObj.username) {
headers["Authorization"] =
@@ -28,7 +26,7 @@ export function parseApiUrl(url: string) {
[
decodeURIComponent(urlObj.username),
decodeURIComponent(urlObj.password),
].join(":"),
].join(":")
);
urlObj.username = "";
urlObj.password = "";
+1 -1
View File
@@ -15,7 +15,7 @@ export async function getDbSources(url: string): Promise<GetDbStatic[]> {
.catch(() => null)
.then((dbSource) => {
if (dbSource) dbSources.push(dbSource);
}),
})
);
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ import type { GetDbStatic } from "./types/GetDb.js";
let rawGetDbSources: typeof getDbSources | undefined = undefined;
const getGetDbSources = () =>
import(/* webpackChunkName: 'dbsources' */ "./dbSources.static.js").then(
(x) => x.getDbSources,
(x) => x.getDbSources
);
export async function getDbSources(url: string): Promise<GetDbStatic[]> {
-82
View File
@@ -1,82 +0,0 @@
import {
EXPORT_KIND_ORDER,
type ExportFilter,
type ExportKind,
} from "../types/ExportImport.js";
const EXPORT_KINDS = new Set<ExportKind>(EXPORT_KIND_ORDER);
export function isExportKind(x: unknown): x is ExportKind {
return typeof x === "string" && EXPORT_KINDS.has(x as ExportKind);
}
/** Whether a `kind` survives a filter's `kinds`/`excludeKinds` rules. */
export function kindAllowed(
filter: ExportFilter | undefined,
kind: ExportKind,
): boolean {
if (!filter) return true;
if (filter.kinds && !filter.kinds.includes(kind)) return false;
if (filter.excludeKinds && filter.excludeKinds.includes(kind)) return false;
return true;
}
/**
* Whether an individual record passes a filter's `abodes`/`users`/`apikeys`
* allowlists. `abodes` scopes abode/resident/note (by aid); `users` scopes user
* (by uid); `apikey` records are scoped by `apikeys` when present, else by
* `users`. An absent allowlist means "unrestricted".
*/
export function recordAllowed(
filter: ExportFilter | undefined,
kind: ExportKind,
record: { uid?: string; aid?: string },
): boolean {
if (!filter) return true;
switch (kind) {
case "user":
return !filter.users || filter.users.includes(record.uid as string);
case "apikey": {
const allow = filter.apikeys ?? filter.users;
return !allow || allow.includes(record.uid as string);
}
case "abode":
case "resident":
case "note":
return !filter.abodes || filter.abodes.includes(record.aid as string);
}
}
function intersectList<T extends string>(a?: T[], b?: T[]): T[] | undefined {
if (!a) return b;
if (!b) return a;
const bs = new Set(b);
return a.filter((x) => bs.has(x));
}
function unionList<T extends string>(a?: T[], b?: T[]): T[] | undefined {
if (!a) return b;
if (!b) return a;
return [...new Set([...a, ...b])];
}
/**
* Combine two filters as a hard intersection: the result can never permit more
* than either input. An absent allowlist is treated as "unrestricted", so
* intersecting it with a present one yields the present one. `excludeKinds`
* are unioned (either exclusion still excludes).
*/
export function intersectExportFilters(
a: ExportFilter | null | undefined,
b: ExportFilter | null | undefined,
): ExportFilter {
if (!a) return b ?? {};
if (!b) return a;
return {
kinds: intersectList(a.kinds, b.kinds),
excludeKinds: unionList(a.excludeKinds, b.excludeKinds),
abodes: intersectList(a.abodes, b.abodes),
users: intersectList(a.users, b.users),
apikeys: intersectList(a.apikeys, b.apikeys),
};
}
-60
View File
@@ -1,60 +0,0 @@
import readline from "node:readline";
import type {
ExportFilter,
ExportKind,
ExportMeta,
} from "../types/ExportImport.js";
import { isExportKind } from "./filter.js";
export type InspectResult = {
counts: Partial<Record<ExportKind, number>>;
meta?: Partial<ExportMeta> & { filter?: ExportFilter };
};
/**
* Tally the kinds/counts contained in an NDJSON export stream without ever
* touching a database. Works on any stream — a file, {@link ApiInterface}'s
* export, or a pipe straight from {@link SqliteInterface}'s export.
*
* If `stopAfterKinds` is given, reading stops as soon as at least one record of
* every requested kind has been seen, rather than draining to EOF — useful for
* probing large dumps ("does this contain notes at all?").
*/
export async function inspectExportStream(
source: NodeJS.ReadableStream,
options: { signal?: AbortSignal; stopAfterKinds?: ExportKind[] } = {},
): Promise<InspectResult> {
const { signal, stopAfterKinds } = options;
const counts: Partial<Record<ExportKind, number>> = {};
let meta: InspectResult["meta"];
const rl = readline.createInterface({
input: source,
crlfDelay: Infinity,
signal,
});
try {
for await (const raw of rl) {
signal?.throwIfAborted();
const line = raw.trim();
if (!line) continue;
let parsed: { kind?: unknown; data?: unknown };
try {
parsed = JSON.parse(line);
} catch {
continue;
}
if (parsed.kind === "meta") {
meta = parsed.data as InspectResult["meta"];
continue;
}
if (!isExportKind(parsed.kind)) continue;
counts[parsed.kind] = (counts[parsed.kind] ?? 0) + 1;
if (stopAfterKinds && stopAfterKinds.every((k) => counts[k])) break;
}
} finally {
rl.close();
}
return { counts, meta };
}
+44 -317
View File
@@ -35,9 +35,6 @@ import {
selectClientUsers,
selectResident,
selectResidents,
selectNote,
selectNotes,
selectPartialNotes,
} from "./query.js";
import type {
CreateNote,
@@ -46,22 +43,8 @@ import type {
UpdateNote,
} from "../types/Note.js";
import type { WrappedPgClient } from "./pool.js";
import { Readable } from "node:stream";
import readline from "node:readline";
import {
EXPORT_KIND_ORDER,
type Exportable,
type ExportKind,
type ExportOptions,
type Importable,
type ImportOptions,
type ImportResult,
} from "../types/ExportImport.js";
import { isExportKind, kindAllowed, recordAllowed } from "../export/filter.js";
export class PostgresInterface
implements BackendDbInterface, Exportable, Importable
{
export class PostgresInterface implements BackendDbInterface {
#db: WrappedPgClient;
constructor(db: WrappedPgClient) {
@@ -109,7 +92,7 @@ export class PostgresInterface
async getUserByEmail(email: string): Promise<ClientUser> {
const user = await selectClientUser(
this.#db,
sql`u."email" = ${{ text: email }}`,
sql`u."email" = ${{ text: email }}`
);
if (!user) throw new NotFoundAbodeError();
return user;
@@ -128,7 +111,7 @@ export class PostgresInterface
SELECT "uid", "email", "name", "flags", "created_at", "updated_at", "password"
FROM "users"
WHERE "email" = ${{ text: email }}
`,
`
);
if (!rawUser) throw new NotFoundAbodeError();
if (rawUser.password.startsWith("#")) throw new ConflictAbodeError();
@@ -142,7 +125,7 @@ export class PostgresInterface
sql`
DELETE FROM "users"
WHERE "uid" = ${{ uuid: id }}
`,
`
);
if (!changes) throw new NotFoundAbodeError();
}
@@ -162,10 +145,10 @@ export class PostgresInterface
${{ text: user.password }},
${{ jsonb: user.flags }}
)
`,
`
);
return this.#getUserById(uid, tx);
}),
})
);
}
async updateUser(user: UpdateUser): Promise<ClientUser> {
@@ -178,8 +161,7 @@ export class PostgresInterface
if ("name" in user && user.name !== undefined)
updates.push(sql`"name" = ${{ text: user.name }}`);
if ("password" in user && user.password !== undefined) {
if (!isValidUserPassword(user.password))
throw new InvalidAbodeError();
if (!isValidUserPassword(user.password)) throw new InvalidAbodeError();
if (user.password.startsWith("#")) {
await tx.run(sql`
DELETE FROM "apikeys"
@@ -203,11 +185,11 @@ export class PostgresInterface
"updated_at" = NOW(),
${joinSql(updates, sql`, `)}
WHERE "uid" = ${{ uuid: user.uid }}
`,
`
);
if (!changes) throw new NotFoundAbodeError();
return this.#getUserById(user.uid, tx);
}),
})
);
}
@@ -228,7 +210,7 @@ export class PostgresInterface
sql`
DELETE FROM "abodes"
WHERE "aid" = ${{ uuid: id }}
`,
`
);
if (!changes) throw new NotFoundAbodeError();
}
@@ -246,10 +228,10 @@ export class PostgresInterface
${{ uuid: ctx.uid }},
${{ uuid: ctx.uid }}
)
`,
`
);
return this.#getAbodeById(aid, tx);
}),
})
);
}
async updateAbode(abode: UpdateAbode, ctx: { uid: string }): Promise<Abode> {
@@ -268,11 +250,11 @@ export class PostgresInterface
"updated_by" = ${{ uuid: ctx.uid }},
${joinSql(updates, sql`, `)}
WHERE "aid" = ${{ uuid: abode.aid }}
`,
`
);
if (!changes) throw new NotFoundAbodeError();
return this.#getAbodeById(abode.aid, tx);
}),
})
);
}
@@ -288,11 +270,11 @@ export class PostgresInterface
async #getResidentById(
uid: string,
aid: string,
db: WrappedPgClient,
db: WrappedPgClient
): Promise<Resident> {
const resident = await selectResident(
db,
sql`"uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}`,
sql`"uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}`
);
if (!resident) throw new NotFoundAbodeError();
return resident;
@@ -306,13 +288,13 @@ export class PostgresInterface
sql`
DELETE FROM "residents"
WHERE "uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}
`,
`
);
if (!changes) throw new NotFoundAbodeError();
}
async createResident(
resident: CreateResident,
ctx: { uid: string },
ctx: { uid: string }
): Promise<Resident> {
this.#checkReadonly();
return this.#db.rethrow(() =>
@@ -327,15 +309,15 @@ export class PostgresInterface
${{ uuid: ctx.uid }},
${{ uuid: ctx.uid }}
)
`,
`
);
return this.#getResidentById(resident.uid, resident.aid, tx);
}),
})
);
}
async updateResident(
resident: updateResident,
ctx: { uid: string },
ctx: { uid: string }
): Promise<Resident> {
this.#checkReadonly();
const updates = calcUpdates({
@@ -354,11 +336,11 @@ export class PostgresInterface
WHERE
"uid" = ${{ uuid: resident.uid }}
AND "aid" = ${{ uuid: resident.aid }}
`,
`
);
if (!changes) throw new NotFoundAbodeError();
return this.#getResidentById(resident.uid, resident.aid, tx);
}),
})
);
}
@@ -368,7 +350,7 @@ export class PostgresInterface
sql`
JOIN "residents" r ON u."uid" = r."uid"
WHERE r."aid" = ${{ uuid: id }}
`,
`
);
}
async listAbodesByUserId(id: string): Promise<Abode[]> {
@@ -377,7 +359,7 @@ export class PostgresInterface
sql`
JOIN "residents" r ON a."aid" = r."aid"
WHERE r."uid" = ${{ uuid: id }}
`,
`
);
}
@@ -426,27 +408,19 @@ export class PostgresInterface
`);
}
async deleteSession(token: `as_${string}`): Promise<void> {
this.#checkReadonly();
await this.#db.run(sql`
DELETE FROM "sessions"
WHERE "token" = ${{ text: token }}
`);
}
async #getApikeyByToken(
token: `at_${string}`,
db: WrappedPgClient,
db: WrappedPgClient
): Promise<ClientApikey> {
const apikey = await selectClientApikey(
db,
sql`k."token" = ${{ text: token }}`,
sql`k."token" = ${{ text: token }}`
);
if (!apikey) throw new NotFoundAbodeError();
return apikey;
}
async getUserByApikey(
token: `at_${string}`,
token: `at_${string}`
): Promise<[ClientUser, ClientApikey]> {
const apikey = await this.#getApikeyByToken(token, this.#db);
if (
@@ -463,13 +437,13 @@ export class PostgresInterface
async getApikeyById(kid: string): Promise<ClientApikey> {
const apikey = await selectClientApikey(
this.#db,
sql`k."kid" = ${{ uuid: kid }}`,
sql`k."kid" = ${{ uuid: kid }}`
);
if (!apikey) throw new NotFoundAbodeError();
return apikey;
}
async createApikey(
apikey: CreateApikey,
apikey: CreateApikey
): Promise<[ClientApikey, `at_${string}`]> {
this.#checkReadonly();
const token = createApikeyToken();
@@ -477,7 +451,7 @@ export class PostgresInterface
let expires = apikey.expires_at;
if (expires === undefined)
expires = new Date(
new Date().getTime() + 1000 * 60 * 60 * 24 * 365,
new Date().getTime() + 1000 * 60 * 60 * 24 * 365
).toISOString();
if (expires && new Date(expires).getTime() < Date.now())
throw new InvalidAbodeError();
@@ -505,271 +479,24 @@ export class PostgresInterface
}
async listNotes(): Promise<PartialNote[]> {
return selectPartialNotes(this.#db);
throw new Error("Unimplemented");
}
async #getNoteById(nid: string, db: WrappedPgClient): Promise<Note> {
const note = await selectNote(db, sql`n."nid" = ${{ uuid: nid }}`);
if (!note) throw new NotFoundAbodeError();
return note;
async getNoteById(_nid: string): Promise<Note> {
throw new Error("Unimplemented");
}
async getNoteById(nid: string): Promise<Note> {
return this.#getNoteById(nid, this.#db);
async deleteNoteById(_nid: string): Promise<void> {
throw new Error("Unimplemented");
}
async deleteNoteById(nid: string): Promise<void> {
this.#checkReadonly();
const { changes } = await this.#db.run(sql`
DELETE FROM "notes" WHERE "nid" = ${{ uuid: nid }}
`);
if (!changes) throw new NotFoundAbodeError();
async createNote(_note: CreateNote, _ctx: { uid: string }): Promise<Note> {
throw new Error("Unimplemented");
}
async createNote(note: CreateNote, ctx: { uid: string }): Promise<Note> {
this.#checkReadonly();
const nid = crypto.randomUUID();
return this.#db.rethrow(() =>
this.#db.multi(async (tx) => {
await tx.run(sql`
INSERT INTO "notes"("nid", "aid", "name", "content", "properties", "created_by", "updated_by")
VALUES(
${{ uuid: nid }}, ${{ uuid: note.aid }}, ${{ text: note.name }},
${{ text: note.content ?? "" }}, ${{ jsonb: note.properties }},
${{ uuid: ctx.uid }}, ${{ uuid: ctx.uid }}
)
`);
return this.#getNoteById(nid, tx);
}),
);
async updateNote(_note: UpdateNote, _ctx: { uid: string }): Promise<Note> {
throw new Error("Unimplemented");
}
async updateNote(note: UpdateNote, ctx: { uid: string }): Promise<Note> {
this.#checkReadonly();
const updates = calcUpdates({
name: (value: string) => sql`"name" = ${{ text: value }}`,
content: (value: string) => sql`"content" = ${{ text: value }}`,
properties: (value: object) => sql`"properties" = ${{ jsonb: value }}`,
})(note);
if (!updates.length) throw new InvalidAbodeError();
return this.#db.rethrow(() =>
this.#db.multi(async (tx) => {
const { changes } = await tx.run(sql`
UPDATE "notes"
SET "updated_at" = NOW(), "updated_by" = ${{ uuid: ctx.uid }},
${joinSql(updates, sql`, `)}
WHERE "nid" = ${{ uuid: note.nid }}
`);
if (!changes) throw new NotFoundAbodeError();
return this.#getNoteById(note.nid, tx);
}),
);
async listNotesByAbodeId(_aid: string): Promise<PartialNote[]> {
throw new Error("Unimplemented");
}
async listNotesByAbodeId(aid: string): Promise<PartialNote[]> {
return selectPartialNotes(this.#db, sql`n."aid" = ${{ uuid: aid }}`);
}
async listNotesByUserId(uid: string): Promise<PartialNote[]> {
return selectPartialNotes(this.#db, sql`n."created_by" = ${{ uuid: uid }}`);
}
export(options: ExportOptions = {}): NodeJS.ReadableStream {
const { filter, signal } = options;
const db = this.#db;
const source = this.name;
// Emission follows the FK-safe EXPORT_KIND_ORDER (part of the wire
// contract; each `load()` is a single query, run lazily and skipped once
// the destination aborts.
const loaders: Partial<
Record<ExportKind, () => Promise<{ uid?: string; aid?: string }[]>>
> = {
user: () => selectClientUsers(db),
abode: () => selectAbodes(db),
resident: () => selectResidents(db),
apikey: () => selectClientApikeys(db),
note: () => selectNotes(db),
};
async function* generate(): AsyncGenerator<string> {
if (signal?.aborted) return;
yield JSON.stringify({
kind: "meta",
data: {
v: 1,
exportedAt: new Date().toISOString(),
source,
filter: filter ?? {},
},
}) + "\n";
try {
for (const kind of EXPORT_KIND_ORDER) {
if (signal?.aborted) return;
const load = loaders[kind];
if (!load) continue;
if (!kindAllowed(filter, kind)) continue;
for (const row of await load()) {
if (signal?.aborted) return;
if (recordAllowed(filter, kind, row)) {
yield JSON.stringify({ kind, data: row }) + "\n";
}
}
}
} catch (e) {
if (signal?.aborted) return;
yield JSON.stringify({
kind: "error",
data: {
message: e instanceof Error ? e.message : String(e),
code: e instanceof Error ? e.name : undefined,
},
}) + "\n";
}
}
return Readable.from(generate());
}
async import(
source: NodeJS.ReadableStream,
options: ImportOptions = {},
): Promise<ImportResult> {
this.#checkReadonly();
const { filter, signal } = options;
const counts: Partial<Record<ExportKind, number>> = {};
const rl = readline.createInterface({
input: source,
crlfDelay: Infinity,
signal,
});
// Postgres holds FK enforcement (unlike sqlite, which we toggle off): the
// FK-safe insertion order keeps a full dump valid.
// The transaction commits only if the whole stream is consumed cleanly; an
// error/abort rolls it back via `multi`.
try {
await this.#db.rethrow(() =>
this.#db.multi(async (tx) => {
for await (const raw of rl) {
signal?.throwIfAborted();
const line = raw.trim();
if (!line) continue;
let parsed: { kind?: unknown; data?: unknown };
try {
parsed = JSON.parse(line);
} catch {
throw new InvalidAbodeError();
}
if (parsed.kind === "meta") continue;
if (parsed.kind === "error") {
throw new Error(
`export stream reported an error: ${
(parsed.data as { message?: string })?.message ?? "unknown"
}`,
);
}
if (!isExportKind(parsed.kind)) continue;
if (!kindAllowed(filter, parsed.kind)) continue;
const data = parsed.data as { uid?: string; aid?: string };
if (!recordAllowed(filter, parsed.kind, data)) continue;
await this.#importRecord(tx, parsed.kind, parsed.data);
counts[parsed.kind] = (counts[parsed.kind] ?? 0) + 1;
}
// An abort while blocked on the source closes readline without
// throwing, so re-check before the transaction commits.
signal?.throwIfAborted();
}),
);
} finally {
rl.close();
}
return { counts };
}
async #importRecord(
tx: WrappedPgClient,
kind: ExportKind,
data: unknown,
): Promise<void> {
switch (kind) {
case "user": {
const u = data as ClientUser;
// `password` is never exported; imported users land on the schema
// default ('#unset') and must reset before they can log in.
await tx.run(sql`
INSERT INTO "users"("uid", "email", "name", "flags", "created_at", "updated_at")
VALUES(
${{ uuid: u.uid }},
${{ text: u.email }},
${{ text: u.name }},
${{ jsonb: u.flags }},
${{ date: u.created_at }},
${{ date: u.updated_at }}
)
`);
break;
}
case "abode": {
const a = data as Abode;
await tx.run(sql`
INSERT INTO "abodes"("aid", "name", "created_at", "created_by", "updated_at", "updated_by")
VALUES(
${{ uuid: a.aid }},
${{ text: a.name }},
${{ date: a.created_at }},
${a.created_by ? { uuid: a.created_by } : { null: true }},
${{ date: a.updated_at }},
${a.updated_by ? { uuid: a.updated_by } : { null: true }}
)
`);
break;
}
case "resident": {
const r = data as Resident;
await tx.run(sql`
INSERT INTO "residents"("uid", "aid", "flags", "created_at", "created_by", "updated_at", "updated_by")
VALUES(
${{ uuid: r.uid }},
${{ uuid: r.aid }},
${{ jsonb: r.flags }},
${{ date: r.created_at }},
${r.created_by ? { uuid: r.created_by } : { null: true }},
${{ date: r.updated_at }},
${r.updated_by ? { uuid: r.updated_by } : { null: true }}
)
`);
break;
}
case "apikey": {
const k = data as ClientApikey;
// `token` is never exported; mint a fresh unique one so the record's
// metadata (kid/permissions/expiry) survives even though the original
// secret cannot.
await tx.run(sql`
INSERT INTO "apikeys"("uid", "kid", "token", "name", "permissions", "created_at", "expires_at")
VALUES(
${{ uuid: k.uid }},
${{ uuid: k.kid }},
${{ text: createApikeyToken() }},
${{ text: k.name }},
${{ jsonb: k.permissions }},
${{ date: k.created_at }},
${k.expires_at ? { date: k.expires_at } : { null: true }}
)
`);
break;
}
case "note": {
const n = data as Note;
await tx.run(sql`
INSERT INTO "notes"("nid", "aid", "name", "content", "properties", "created_at", "created_by", "updated_at", "updated_by")
VALUES(
${{ uuid: n.nid }}, ${{ uuid: n.aid }}, ${{ text: n.name }},
${{ text: n.content ?? "" }}, ${{ jsonb: n.properties }},
${{ date: n.created_at }},
${n.created_by ? { uuid: n.created_by } : { null: true }},
${{ date: n.updated_at }},
${n.updated_by ? { uuid: n.updated_by } : { null: true }}
)
`);
break;
}
}
async listNotesByUserId(_uid: string): Promise<PartialNote[]> {
throw new Error("Unimplemented");
}
}
+9 -9
View File
@@ -5,7 +5,7 @@ import type {
} from "../types/Migrator.js";
import { init, migrations } from "./migrations/index.js";
import { pgToDate } from "./cast.js";
import { rollbackQuietly, WrappedPgTx, WrappedPool } from "./pool.js";
import { WrappedPool } from "./pool.js";
import { sql, toPositional } from "./sql.js";
export class PostgresMigrator implements Migrator {
@@ -24,7 +24,7 @@ export class PostgresMigrator implements Migrator {
`SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = '_migrations'
) AS "exists"`,
) AS "exists"`
);
if (!existsResult.rows[0]?.exists) return null;
@@ -33,7 +33,7 @@ export class PostgresMigrator implements Migrator {
name: string;
applied_at: Date;
}>(
`SELECT "id", "name", "applied_at" FROM "_migrations" ORDER BY "id" ASC`,
`SELECT "id", "name", "applied_at" FROM "_migrations" ORDER BY "id" ASC`
);
return result.rows.map((x) => ({
...x,
@@ -73,16 +73,17 @@ export class PostgresMigrator implements Migrator {
throw new Error(`Applied migration ${id} (${name}) not known`);
if (migration.name !== name)
throw new Error(
`Applied migration ${id} (${name}) has a different name from expected (${migration.name})`,
`Applied migration ${id} (${name}) has a different name from expected (${migration.name})`
);
}
const start = migrations.findIndex((x) => x.id === current.at(-1)?.id) + 1;
const start =
migrations.findIndex((x) => x.id === current.at(-1)?.id) + 1;
const end = migrations.indexOf(target) + 1;
if (end < start) {
throw new Error(
`Cannot migrate backward, at ${current.at(-1)?.id ?? 0}, going to ${target.id}`,
`Cannot migrate backward, at ${current.at(-1)?.id ?? 0}, going to ${target.id}`
);
}
@@ -96,7 +97,6 @@ export class PostgresMigrator implements Migrator {
for (const migration of toApply) {
console.log(`Applying migration ${migration.id} (${migration.name})`);
const client = await this.#pool._pool.connect();
const tx = new WrappedPgTx(client, false);
try {
await client.query("BEGIN");
for (const part of migration.parts) {
@@ -104,7 +104,7 @@ export class PostgresMigrator implements Migrator {
if ("sql" in part) {
await client.query(part.sql);
} else {
await part.apply(tx);
await part.apply(this.#pool);
}
}
const recordSql = sql`
@@ -114,7 +114,7 @@ export class PostgresMigrator implements Migrator {
await client.query(toPositional(recordSql._sql), recordSql._vars);
await client.query("COMMIT");
} catch (e) {
await rollbackQuietly(client);
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
-71
View File
@@ -1,12 +1,6 @@
import type { Abode } from "../types/Abode.js";
import type { ApikeyPermissions, ClientApikey } from "../types/Apikey.js";
import type { Resident, ResidentFlags } from "../types/Resident.js";
import type {
Note,
NoteProperties,
PartialNote,
PartialNoteProperties,
} from "../types/Note.js";
import type { ClientUser, PartialUser, UserFlags } from "../types/User.js";
export function pgToDate(d: Date | string): string {
@@ -141,68 +135,3 @@ export function pgToClientApikey(apikey: {
expires_at: apikey.expires_at ? pgToDate(apikey.expires_at) : null,
};
}
const validNoteTypes = new Set(["note"]);
function pgToNoteProperties(properties: unknown): NoteProperties {
if (
typeof properties !== "object" ||
!properties ||
Array.isArray(properties)
)
return {};
const value = properties as Record<string, unknown>;
return validNoteTypes.has(value.type as string)
? { type: value.type as "note" }
: {};
}
function pgToPartialNoteProperties(properties: unknown): PartialNoteProperties {
return { type: pgToNoteProperties(properties).type ?? "note" };
}
export function pgToNote(note: {
nid: string;
aid: string;
name: string;
content: string;
properties: unknown;
created_at: Date | string;
created_by: string | null;
updated_at: Date | string;
updated_by: string | null;
}): Note {
return {
nid: note.nid,
aid: note.aid,
name: note.name,
content: note.content,
properties: pgToNoteProperties(note.properties),
created_at: pgToDate(note.created_at),
created_by: note.created_by,
updated_at: pgToDate(note.updated_at),
updated_by: note.updated_by,
};
}
export function pgToPartialNote(note: {
nid: string;
aid: string;
name: string;
properties: unknown;
created_at: Date | string;
created_by: string | null;
updated_at: Date | string;
updated_by: string | null;
}): PartialNote {
return {
nid: note.nid,
aid: note.aid,
name: note.name,
properties: pgToPartialNoteProperties(note.properties),
created_at: pgToDate(note.created_at),
created_by: note.created_by,
updated_at: pgToDate(note.updated_at),
updated_by: note.updated_by,
};
}
+39 -40
View File
@@ -29,20 +29,12 @@ async function rethrow<R>(fn: () => Promise<R>): Promise<R> {
}
}
// Rollback on a broken connection can itself throw; the original error is
// the one worth surfacing.
export async function rollbackQuietly(client: pg.PoolClient): Promise<void> {
try {
await client.query("ROLLBACK");
} catch {}
}
abstract class WrappedPgBase implements WrappedPgClient {
#queryable: pg.Pool | pg.PoolClient;
class WrappedPgTx implements WrappedPgClient {
#client: pg.PoolClient;
#readonly: boolean;
constructor(queryable: pg.Pool | pg.PoolClient, readonly_: boolean) {
this.#queryable = queryable;
constructor(client: pg.PoolClient, readonly_: boolean) {
this.#client = client;
this.#readonly = readonly_;
}
@@ -50,14 +42,10 @@ abstract class WrappedPgBase implements WrappedPgClient {
return this.#readonly;
}
abstract destroy(): Promise<void>;
abstract multi<R>(fn: (tx: WrappedPgClient) => Promise<R>): Promise<R>;
async destroy(): Promise<void> {}
async all<R>(stmt: SqlCode): Promise<R[]> {
const result = await this.#queryable.query(
toPositional(stmt._sql),
stmt._vars,
);
const result = await this.#client.query(toPositional(stmt._sql), stmt._vars);
return result.rows as R[];
}
@@ -68,61 +56,72 @@ abstract class WrappedPgBase implements WrappedPgClient {
}
async run(stmt: SqlCode): Promise<{ changes: number }> {
const result = await this.#queryable.query(
toPositional(stmt._sql),
stmt._vars,
);
const result = await this.#client.query(toPositional(stmt._sql), stmt._vars);
return { changes: result.rowCount ?? 0 };
}
multi<R>(_fn: (tx: WrappedPgClient) => Promise<R>): Promise<R> {
throw new Error("Nested transactions not supported");
}
rethrow = rethrow;
}
export class WrappedPgTx extends WrappedPgBase {
constructor(client: pg.PoolClient, readonly_: boolean) {
super(client, readonly_);
}
async destroy(): Promise<void> {}
multi<R>(_fn: (tx: WrappedPgClient) => Promise<R>): Promise<R> {
throw new Error("Nested transactions not supported");
}
}
export class WrappedPool extends WrappedPgBase {
export class WrappedPool implements WrappedPgClient {
#pool: pg.Pool;
#readonly: boolean;
constructor(connectionStringOrPool: string | pg.Pool, readonly_ = false) {
const pool =
this.#pool =
typeof connectionStringOrPool === "string"
? new pg.Pool({ connectionString: connectionStringOrPool })
: connectionStringOrPool;
super(pool, readonly_);
this.#pool = pool;
this.#readonly = readonly_;
}
get _pool(): pg.Pool {
return this.#pool;
}
get readonly(): boolean {
return this.#readonly;
}
async destroy(): Promise<void> {
await this.#pool.end();
}
async all<R>(stmt: SqlCode): Promise<R[]> {
const result = await this.#pool.query(toPositional(stmt._sql), stmt._vars);
return result.rows as R[];
}
async get<R>(stmt: SqlCode): Promise<R | null> {
const rows = await this.all<R>(stmt);
if (rows.length > 1) throw new Error("Multiple results");
return rows[0] ?? null;
}
async run(stmt: SqlCode): Promise<{ changes: number }> {
const result = await this.#pool.query(toPositional(stmt._sql), stmt._vars);
return { changes: result.rowCount ?? 0 };
}
async multi<R>(fn: (tx: WrappedPgClient) => Promise<R>): Promise<R> {
const client = await this.#pool.connect();
const tx = new WrappedPgTx(client, this.readonly);
const tx = new WrappedPgTx(client, this.#readonly);
try {
await client.query("BEGIN");
const result = await fn(tx);
await client.query("COMMIT");
return result;
} catch (e) {
await rollbackQuietly(client);
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
rethrow = rethrow;
}
+16 -67
View File
@@ -2,14 +2,11 @@ import type { Abode } from "../types/Abode.js";
import type { ClientApikey } from "../types/Apikey.js";
import type { Resident } from "../types/Resident.js";
import type { ClientUser } from "../types/User.js";
import type { Note, PartialNote } from "../types/Note.js";
import {
pgToAbode,
pgToClientApikey,
pgToClientUser,
pgToResident,
pgToNote,
pgToPartialNote,
} from "./cast.js";
import type { WrappedPgClient } from "./pool.js";
import { sql, type SqlCode } from "./sql.js";
@@ -29,18 +26,20 @@ const sqlClientUser = sql`
export async function selectClientUser(
db: WrappedPgClient,
where: SqlCode,
where: SqlCode
): Promise<ClientUser | null> {
const raw = await db.get<RawClientUser>(sql`${sqlClientUser} WHERE ${where}`);
const raw = await db.get<RawClientUser>(
sql`${sqlClientUser} WHERE ${where}`
);
if (raw) return pgToClientUser(raw);
return null;
}
export async function selectClientUsers(
db: WrappedPgClient,
rest?: SqlCode,
rest?: SqlCode
): Promise<ClientUser[]> {
const rows = await db.all<RawClientUser>(
rest ? sql`${sqlClientUser} ${rest}` : sqlClientUser,
rest ? sql`${sqlClientUser} ${rest}` : sqlClientUser
);
return rows.map(pgToClientUser);
}
@@ -60,7 +59,7 @@ const sqlAbode = sql`
export async function selectAbode(
db: WrappedPgClient,
where: SqlCode,
where: SqlCode
): Promise<Abode | null> {
const raw = await db.get<RawAbode>(sql`${sqlAbode} WHERE ${where}`);
if (raw) return pgToAbode(raw);
@@ -68,10 +67,10 @@ export async function selectAbode(
}
export async function selectAbodes(
db: WrappedPgClient,
rest?: SqlCode,
rest?: SqlCode
): Promise<Abode[]> {
const rows = await db.all<RawAbode>(
rest ? sql`${sqlAbode} ${rest}` : sqlAbode,
rest ? sql`${sqlAbode} ${rest}` : sqlAbode
);
return rows.map(pgToAbode);
}
@@ -92,7 +91,7 @@ const sqlResident = sql`
export async function selectResident(
db: WrappedPgClient,
where: SqlCode,
where: SqlCode
): Promise<Resident | null> {
const raw = await db.get<RawResident>(sql`${sqlResident} WHERE ${where}`);
if (raw) return pgToResident(raw);
@@ -100,10 +99,10 @@ export async function selectResident(
}
export async function selectResidents(
db: WrappedPgClient,
where?: SqlCode,
where?: SqlCode
): Promise<Resident[]> {
const rows = await db.all<RawResident>(
where ? sql`${sqlResident} WHERE ${where}` : sqlResident,
where ? sql`${sqlResident} WHERE ${where}` : sqlResident
);
return rows.map(pgToResident);
}
@@ -123,70 +122,20 @@ const sqlClientApikey = sql`
export async function selectClientApikey(
db: WrappedPgClient,
where: SqlCode,
where: SqlCode
): Promise<ClientApikey | null> {
const raw = await db.get<RawClientApikey>(
sql`${sqlClientApikey} WHERE ${where}`,
sql`${sqlClientApikey} WHERE ${where}`
);
if (raw) return pgToClientApikey(raw);
return null;
}
export async function selectClientApikeys(
db: WrappedPgClient,
where?: SqlCode,
where: SqlCode
): Promise<ClientApikey[]> {
const rows = await db.all<RawClientApikey>(
where ? sql`${sqlClientApikey} WHERE ${where}` : sqlClientApikey,
sql`${sqlClientApikey} WHERE ${where}`
);
return rows.map(pgToClientApikey);
}
type RawNote = {
nid: string;
aid: string;
name: string;
content: string;
properties: unknown;
created_at: Date;
created_by: string | null;
updated_at: Date;
updated_by: string | null;
};
const sqlNote = sql`
SELECT n."nid", n."aid", n."name", n."content", n."properties",
n."created_at", n."created_by", n."updated_at", n."updated_by"
FROM "notes" n
`;
type RawPartialNote = Omit<RawNote, "content">;
const sqlPartialNote = sql`
SELECT n."nid", n."aid", n."name", n."properties",
n."created_at", n."created_by", n."updated_at", n."updated_by"
FROM "notes" n
`;
export async function selectNote(
db: WrappedPgClient,
where: SqlCode,
): Promise<Note | null> {
const raw = await db.get<RawNote>(sql`${sqlNote} WHERE ${where}`);
return raw ? pgToNote(raw) : null;
}
export async function selectNotes(
db: WrappedPgClient,
where?: SqlCode,
): Promise<Note[]> {
const rows = await db.all<RawNote>(
where ? sql`${sqlNote} WHERE ${where}` : sqlNote,
);
return rows.map(pgToNote);
}
export async function selectPartialNotes(
db: WrappedPgClient,
where?: SqlCode,
): Promise<PartialNote[]> {
const rows = await db.all<RawPartialNote>(
where ? sql`${sqlPartialNote} WHERE ${where}` : sqlPartialNote,
);
return rows.map(pgToPartialNote);
}
+1 -4
View File
@@ -65,7 +65,7 @@ export function calcUpdates<T extends object>(updater: {
for (const [prop, update] of Object.entries(updater)) {
if (prop in obj) {
updates.push(
(update as (value: unknown) => SqlCode)(obj[prop as keyof T]!),
(update as (value: unknown) => SqlCode)(obj[prop as keyof T]!)
);
}
}
@@ -73,9 +73,6 @@ export function calcUpdates<T extends object>(updater: {
};
}
// Rewrites every literal `?` into a numbered placeholder, so queries must
// not contain Postgres's JSONB `?` / `?|` / `?&` operators (use
// `jsonb_exists`, `jsonb_exists_any`, `jsonb_exists_all` instead).
export function toPositional(sql: string): string {
let i = 0;
return sql.replace(/\?/g, () => `$${++i}`);
+1 -4
View File
@@ -9,10 +9,7 @@ export function isPgUrl(url: string): boolean {
}
}
export function parsePgUrl(url: string): {
connectionString: string;
readonly: boolean;
} {
export function parsePgUrl(url: string): { connectionString: string; readonly: boolean } {
if (!isPgUrl(url)) throw new Error("Not a postgres: URL");
const urlObj = new URL(url);
const readonly = (urlObj.searchParams.get("readonly") ?? "0") !== "0";
+34 -322
View File
@@ -33,9 +33,6 @@ import {
selectClientApikeys,
selectClientUser,
selectClientUsers,
selectNote,
selectNotes,
selectPartialNotes,
selectResident,
selectResidents,
} from "./query.js";
@@ -46,22 +43,8 @@ import type {
UpdateNote,
} from "../types/Note.js";
import type { WrappedDb } from "./impl/types.js";
import { Readable } from "node:stream";
import readline from "node:readline";
import {
EXPORT_KIND_ORDER,
type Exportable,
type ExportKind,
type ExportOptions,
type Importable,
type ImportOptions,
type ImportResult,
} from "../types/ExportImport.js";
import { isExportKind, kindAllowed, recordAllowed } from "../export/filter.js";
export class SqliteInterface
implements BackendDbInterface, Exportable, Importable
{
export class SqliteInterface implements BackendDbInterface {
#db: WrappedDb;
constructor(db: WrappedDb) {
@@ -125,7 +108,7 @@ export class SqliteInterface
SELECT "uid", "email", "name", json("flags") AS "flags", "created_at", "updated_at", "password"
FROM "users"
WHERE "email" = ${{ text: email }}
`,
`
);
if (!rawUser) throw new NotFoundAbodeError();
if (rawUser.password.startsWith("#")) throw new ConflictAbodeError();
@@ -139,7 +122,7 @@ export class SqliteInterface
sql`
DELETE FROM "users"
WHERE "uid" = ${{ uuid: id }}
`,
`
);
if (!changes) throw new NotFoundAbodeError();
}
@@ -157,10 +140,10 @@ export class SqliteInterface
${{ text: user.email }},
${{ text: user.name }},
${{ text: user.password }},${{ jsonb: user.flags }})
`,
`
);
return this.#getUserById(uid);
}),
})
);
}
async updateUser(user: UpdateUser): Promise<ClientUser> {
@@ -194,11 +177,11 @@ export class SqliteInterface
"updated_at" = datetime('now', 'localtime', 'subsec'),
${joinSql(updates, sql`, `)}
WHERE "uid" = ${{ uuid: user.uid }}
`,
`
);
if (!changes) throw new NotFoundAbodeError();
return this.#getUserById(user.uid);
}),
})
);
}
@@ -219,7 +202,7 @@ export class SqliteInterface
sql`
DELETE FROM "abodes"
WHERE "aid" = ${{ uuid: id }}
`,
`
);
if (!changes) throw new NotFoundAbodeError();
}
@@ -237,10 +220,10 @@ export class SqliteInterface
${{ uuid: ctx.uid }},
${{ uuid: ctx.uid }}
)
`,
`
);
return this.#getAbodeById(aid);
}),
})
);
}
async updateAbode(abode: UpdateAbode, ctx: { uid: string }): Promise<Abode> {
@@ -259,11 +242,11 @@ export class SqliteInterface
"updated_by" = ${{ uuid: ctx.uid }},
${joinSql(updates, sql`, `)}
WHERE "aid" = ${{ uuid: abode.aid }}
`,
`
);
if (!changes) throw new NotFoundAbodeError();
return this.#getAbodeById(abode.aid);
}),
})
);
}
@@ -279,7 +262,7 @@ export class SqliteInterface
#getResidentById(uid: string, aid: string): Resident {
const resident = selectResident(
this.#db,
sql`"uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}`,
sql`"uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}`
);
if (!resident) throw new NotFoundAbodeError();
return resident;
@@ -293,13 +276,13 @@ export class SqliteInterface
sql`
DELETE FROM "residents"
WHERE "uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}
`,
`
);
if (!changes) throw new NotFoundAbodeError();
}
async createResident(
resident: CreateResident,
ctx: { uid: string },
ctx: { uid: string }
): Promise<Resident> {
this.#checkReadonly();
return this.#db.rethrow(() =>
@@ -314,15 +297,15 @@ export class SqliteInterface
${{ uuid: ctx.uid }},
${{ uuid: ctx.uid }}
)
`,
`
);
return this.#getResidentById(resident.uid, resident.aid);
}),
})
);
}
async updateResident(
resident: updateResident,
ctx: { uid: string },
ctx: { uid: string }
): Promise<Resident> {
this.#checkReadonly();
const updates = calcUpdates({
@@ -336,16 +319,16 @@ export class SqliteInterface
UPDATE "residents"
SET
"updated_at" = datetime('now', 'localtime', 'subsec'),
"updated_by" = ${{ uuid: ctx.uid }},
"updated_by" = ${{ uuid: ctx.uid }}
${joinSql(updates, sql`, `)}
WHERE
"uid" = ${{ uuid: resident.uid }}
AND "aid" = ${{ uuid: resident.aid }}
`,
`
);
if (!changes) throw new NotFoundAbodeError();
return this.#getResidentById(resident.uid, resident.aid);
}),
})
);
}
@@ -355,7 +338,7 @@ export class SqliteInterface
sql`
JOIN "residents" r ON u."uid" = r."uid"
WHERE r."aid" = ${{ uuid: id }}
`,
`
);
}
async listAbodesByUserId(id: string): Promise<Abode[]> {
@@ -364,7 +347,7 @@ export class SqliteInterface
sql`
JOIN "residents" r ON a."aid" = r."aid"
WHERE r."uid" = ${{ uuid: id }}
`,
`
);
}
@@ -409,24 +392,17 @@ export class SqliteInterface
WHERE "uid" = ${{ uuid: uid }}
`);
}
async deleteSession(token: `as_${string}`): Promise<void> {
this.#checkReadonly();
this.#db.run(sql`
DELETE FROM "sessions"
WHERE "token" = ${{ text: token }}
`);
}
#getApikeyByToken(token: `at_${string}`): ClientApikey {
const apikey = selectClientApikey(
this.#db,
sql`"token" = ${{ text: token }}`,
sql`"token" = ${{ text: token }}`
);
if (!apikey) throw new NotFoundAbodeError();
return apikey;
}
async getUserByApikey(
token: `at_${string}`,
token: `at_${string}`
): Promise<[ClientUser, ClientApikey]> {
const apikey = this.#getApikeyByToken(token);
if (
@@ -446,7 +422,7 @@ export class SqliteInterface
return apikey;
}
async createApikey(
apikey: CreateApikey,
apikey: CreateApikey
): Promise<[ClientApikey, `at_${string}`]> {
this.#checkReadonly();
const token = createApikeyToken();
@@ -454,7 +430,7 @@ export class SqliteInterface
let expires = apikey.expires_at;
if (expires === undefined)
expires = new Date(
new Date().getTime() + 1000 * 60 * 60 * 24 * 365,
new Date().getTime() + 1000 * 60 * 60 * 24 * 365
).toISOString();
if (expires && new Date(expires).getTime() < Date.now())
throw new InvalidAbodeError();
@@ -482,288 +458,24 @@ export class SqliteInterface
}
async listNotes(): Promise<PartialNote[]> {
return selectPartialNotes(this.#db);
}
#getNoteById(nid: string): Note {
const note = selectNote(this.#db, sql`n."nid" = ${{ uuid: nid }}`);
if (!note) throw new NotFoundAbodeError();
return note;
throw new Error("Unimplemented");
}
async getNoteById(nid: string): Promise<Note> {
return this.#getNoteById(nid);
throw new Error("Unimplemented");
}
async deleteNoteById(nid: string): Promise<void> {
this.#checkReadonly();
const { changes } = this.#db.run(
sql`DELETE FROM "notes" WHERE "nid" = ${{ uuid: nid }}`,
);
if (!changes) throw new NotFoundAbodeError();
throw new Error("Unimplemented");
}
async createNote(note: CreateNote, ctx: { uid: string }): Promise<Note> {
this.#checkReadonly();
const nid = crypto.randomUUID();
return this.#db.rethrow(() =>
this.#db.multi(() => {
this.#db.run(sql`
INSERT INTO "notes"("nid", "aid", "name", "content", "properties", "created_by", "updated_by")
VALUES(
${{ uuid: nid }},
${{ uuid: note.aid }},
${{ text: note.name }},
${{ text: note.content ?? "" }},
${{ jsonb: note.properties }},
${{ uuid: ctx.uid }},
${{ uuid: ctx.uid }}
)
`);
return this.#getNoteById(nid);
}),
);
throw new Error("Unimplemented");
}
async updateNote(note: UpdateNote, ctx: { uid: string }): Promise<Note> {
this.#checkReadonly();
const updates = calcUpdates({
name: (value: string) => sql`"name" = ${{ text: value }}`,
content: (value: string) => sql`"content" = ${{ text: value }}`,
properties: (value: object) => sql`"properties" = ${{ jsonb: value }}`,
})(note);
if (!updates.length) throw new InvalidAbodeError();
return this.#db.rethrow(() =>
this.#db.multi(() => {
const { changes } = this.#db.run(sql`
UPDATE "notes"
SET
"updated_at" = datetime('now', 'localtime', 'subsec'),
"updated_by" = ${{ uuid: ctx.uid }},
${joinSql(updates, sql`, `)}
WHERE "nid" = ${{ uuid: note.nid }}
`);
if (!changes) throw new NotFoundAbodeError();
return this.#getNoteById(note.nid);
}),
);
throw new Error("Unimplemented");
}
async listNotesByAbodeId(aid: string): Promise<PartialNote[]> {
return selectPartialNotes(this.#db, sql`n."aid" = ${{ uuid: aid }}`);
throw new Error("Unimplemented");
}
async listNotesByUserId(uid: string): Promise<PartialNote[]> {
return selectPartialNotes(this.#db, sql`n."created_by" = ${{ uuid: uid }}`);
}
export(options: ExportOptions = {}): NodeJS.ReadableStream {
const { filter, signal } = options;
const db = this.#db;
const source = this.name;
// Per-table full materialization + JS-side per-record yielding (each
// `load()` is exactly one `WrappedDb.all()`). Kept lazy so the first query
// only fires once the destination starts pulling, and skipped entirely
// once the signal is aborted — no further reads after the destination
// goes away. Emission follows the FK-safe EXPORT_KIND_ORDER (part of the
// wire contract; see ExportImport.ts).
const loaders: Record<ExportKind, () => { uid?: string; aid?: string }[]> =
{
user: () => selectClientUsers(db),
abode: () => selectAbodes(db),
resident: () => selectResidents(db),
apikey: () => selectClientApikeys(db),
note: () => selectNotes(db),
};
async function* generate(): AsyncGenerator<string> {
if (signal?.aborted) return;
yield JSON.stringify({
kind: "meta",
data: {
v: 1,
exportedAt: new Date().toISOString(),
source,
filter: filter ?? {},
},
}) + "\n";
try {
for (const kind of EXPORT_KIND_ORDER) {
if (signal?.aborted) return;
if (!kindAllowed(filter, kind)) continue;
for (const row of loaders[kind]()) {
if (signal?.aborted) return;
if (recordAllowed(filter, kind, row)) {
yield JSON.stringify({ kind, data: row }) + "\n";
}
}
}
} catch (e) {
// Aborts unwind via early `return`, never here; a genuine mid-stream
// failure is surfaced as a trailing sentinel line (HTTP 200 headers
// are already flushed, so `convertError` can no longer apply).
if (signal?.aborted) return;
yield JSON.stringify({
kind: "error",
data: {
message: e instanceof Error ? e.message : String(e),
code: e instanceof Error ? e.name : undefined,
},
}) + "\n";
}
}
return Readable.from(generate());
}
async import(
source: NodeJS.ReadableStream,
options: ImportOptions = {},
): Promise<ImportResult> {
this.#checkReadonly();
const { filter, signal } = options;
const db = this.#db;
const counts: Partial<Record<ExportKind, number>> = {};
const rl = readline.createInterface({
input: source,
crlfDelay: Infinity,
signal,
});
// Bulk restore trusts the export's referential integrity, and a filtered
// dump may legitimately reference `created_by`/`updated_by` users outside
// its scope. Suppress FK enforcement for the duration (can only be toggled
// outside a transaction) and restore it in `finally`.
db.run(sql`PRAGMA foreign_keys = OFF`);
db.run(sql`BEGIN`);
try {
for await (const raw of rl) {
signal?.throwIfAborted();
const line = raw.trim();
if (!line) continue;
let parsed: { kind?: unknown; data?: unknown };
try {
parsed = JSON.parse(line);
} catch {
throw new InvalidAbodeError();
}
if (parsed.kind === "meta") continue;
if (parsed.kind === "error") {
throw new Error(
`export stream reported an error: ${
(parsed.data as { message?: string })?.message ?? "unknown"
}`,
);
}
if (!isExportKind(parsed.kind)) continue;
if (!kindAllowed(filter, parsed.kind)) continue;
const data = parsed.data as { uid?: string; aid?: string };
if (!recordAllowed(filter, parsed.kind, data)) continue;
this.#importRecord(parsed.kind, parsed.data);
counts[parsed.kind] = (counts[parsed.kind] ?? 0) + 1;
}
// An abort while blocked on the source closes readline without throwing,
// so re-check before committing to guarantee no partial commit.
signal?.throwIfAborted();
db.run(sql`COMMIT`);
} catch (e) {
try {
db.run(sql`ROLLBACK`);
} catch {
/* already rolled back */
}
throw e;
} finally {
rl.close();
db.run(sql`PRAGMA foreign_keys = ON`);
}
return { counts };
}
#importRecord(kind: ExportKind, data: unknown): void {
const db = this.#db;
switch (kind) {
case "user": {
const u = data as ClientUser;
// `password` is never exported; imported users land on the schema
// default ('#unset') and must reset before they can log in.
db.run(sql`
INSERT INTO "users"("uid", "email", "name", "flags", "created_at", "updated_at")
VALUES(
${{ uuid: u.uid }},
${{ text: u.email }},
${{ text: u.name }},
${{ jsonb: u.flags }},
${{ date: u.created_at }},
${{ date: u.updated_at }}
)
`);
break;
}
case "abode": {
const a = data as Abode;
db.run(sql`
INSERT INTO "abodes"("aid", "name", "created_at", "created_by", "updated_at", "updated_by")
VALUES(
${{ uuid: a.aid }},
${{ text: a.name }},
${{ date: a.created_at }},
${a.created_by ? { uuid: a.created_by } : { null: true }},
${{ date: a.updated_at }},
${a.updated_by ? { uuid: a.updated_by } : { null: true }}
)
`);
break;
}
case "resident": {
const r = data as Resident;
db.run(sql`
INSERT INTO "residents"("uid", "aid", "flags", "created_at", "created_by", "updated_at", "updated_by")
VALUES(
${{ uuid: r.uid }},
${{ uuid: r.aid }},
${{ jsonb: r.flags }},
${{ date: r.created_at }},
${r.created_by ? { uuid: r.created_by } : { null: true }},
${{ date: r.updated_at }},
${r.updated_by ? { uuid: r.updated_by } : { null: true }}
)
`);
break;
}
case "apikey": {
const k = data as ClientApikey;
// `token` is never exported; mint a fresh unique one so the record's
// metadata (kid/permissions/expiry) survives even though the original
// secret cannot.
db.run(sql`
INSERT INTO "apikeys"("uid", "kid", "token", "name", "permissions", "created_at", "expires_at")
VALUES(
${{ uuid: k.uid }},
${{ uuid: k.kid }},
${{ text: createApikeyToken() }},
${{ text: k.name }},
${{ jsonb: k.permissions }},
${{ date: k.created_at }},
${k.expires_at ? { date: k.expires_at } : { null: true }}
)
`);
break;
}
case "note": {
const n = data as Note;
db.run(sql`
INSERT INTO "notes"("nid", "aid", "name", "content", "properties", "created_at", "created_by", "updated_at", "updated_by")
VALUES(
${{ uuid: n.nid }},
${{ uuid: n.aid }},
${{ text: n.name }},
${{ text: n.content ?? "" }},
${{ jsonb: n.properties }},
${{ date: n.created_at }},
${n.created_by ? { uuid: n.created_by } : { null: true }},
${{ date: n.updated_at }},
${n.updated_by ? { uuid: n.updated_by } : { null: true }}
)
`);
break;
}
}
throw new Error("Unimplemented");
}
}
+6 -5
View File
@@ -16,11 +16,12 @@ export class SqliteMigrator implements Migrator {
}
#listAppliedMigrations():
{ id: number; name: string; applied_at: string }[] | null {
| { id: number; name: string; applied_at: string }[]
| null {
try {
return this.#db
.all<{ id: number; name: string; applied_at: string }>(
sql`SELECT "id", "name", "applied_at" FROM "_migrations" ORDER BY "id" ASC`,
sql`SELECT "id", "name", "applied_at" FROM "_migrations" ORDER BY "id" ASC`
)
.map((x) => ({ ...x, applied_at: sqliteToDate(x.applied_at) }));
} catch (e) {
@@ -58,7 +59,7 @@ export class SqliteMigrator implements Migrator {
throw new Error(`Applied migration ${id} (${name}) not known`);
if (migration.name !== name)
throw new Error(
`Applied migration ${id} (${name}) has a different name from expected (${migration.name})`,
`Applied migration ${id} (${name}) has a different name from expected (${migration.name})`
);
}
@@ -69,7 +70,7 @@ export class SqliteMigrator implements Migrator {
throw new Error(
`Cannot migrate backward, at ${current.at(-1)?.id ?? 0}, going to ${
target.id
}`,
}`
);
}
@@ -93,7 +94,7 @@ export class SqliteMigrator implements Migrator {
sql`
INSERT INTO "_migrations"("id", "name")
VALUES (${{ int: migration.id }}, ${{ text: migration.name }})
`,
`
);
this.#db.run(sql`COMMIT`);
} catch (e) {
+2 -77
View File
@@ -1,12 +1,5 @@
import type { Abode } from "../types/Abode.js";
import type { ApikeyPermissions, ClientApikey } from "../types/Apikey.js";
import type {
Note,
NoteProperties,
NoteType,
PartialNote,
PartialNoteProperties,
} from "../types/Note.js";
import type { Resident, ResidentFlags } from "../types/Resident.js";
import type { ClientUser, PartialUser, UserFlags } from "../types/User.js";
@@ -22,7 +15,7 @@ export function uuidToSqlite(uuid: string) {
}
export function sqliteToUuid(uuid: Buffer | Uint8Array) {
const hex = (uuid instanceof Buffer ? uuid : Buffer.from(uuid)).toString(
"hex",
"hex"
);
return [
hex.slice(0, 8),
@@ -126,7 +119,7 @@ export function sqliteToResident(resident: {
const defaultApikeyPermissions: ApikeyPermissions = {};
export function sqliteToApikeyPermissions(
permissions: string,
permissions: string
): ApikeyPermissions {
const parsed = JSON.parse(permissions);
const out = { ...defaultApikeyPermissions };
@@ -167,71 +160,3 @@ export function sqliteToClientApikey(apikey: {
expires_at: apikey.expires_at ? sqliteToDate(apikey.expires_at) : null,
};
}
const validNoteTypes = new Set<NoteType>(["note"]);
export function sqliteToNoteProperties(props: string): NoteProperties {
const parsed = JSON.parse(props);
const out: NoteProperties = {};
if (
typeof parsed === "object" &&
parsed &&
!Array.isArray(parsed) &&
validNoteTypes.has(parsed.type)
) {
out.type = parsed.type;
}
return out;
}
export function sqliteToPartialNoteProperties(
props: string,
): PartialNoteProperties {
const base = sqliteToNoteProperties(props);
return { type: base.type ?? "note" };
}
export function sqliteToNote(note: {
nid: Buffer | Uint8Array;
aid: Buffer | Uint8Array;
name: string;
content: string;
properties: string;
created_at: string;
created_by: Buffer | Uint8Array | null;
updated_at: string;
updated_by: Buffer | Uint8Array | null;
}): Note {
return {
nid: sqliteToUuid(note.nid),
aid: sqliteToUuid(note.aid),
name: note.name,
content: note.content,
properties: sqliteToNoteProperties(note.properties),
created_at: sqliteToDate(note.created_at),
created_by: note.created_by ? sqliteToUuid(note.created_by) : null,
updated_at: sqliteToDate(note.updated_at),
updated_by: note.updated_by ? sqliteToUuid(note.updated_by) : null,
};
}
export function sqliteToPartialNote(note: {
nid: Buffer | Uint8Array;
aid: Buffer | Uint8Array;
name: string;
properties: string;
created_at: string;
created_by: Buffer | Uint8Array | null;
updated_at: string;
updated_by: Buffer | Uint8Array | null;
}): PartialNote {
return {
nid: sqliteToUuid(note.nid),
aid: sqliteToUuid(note.aid),
name: note.name,
properties: sqliteToPartialNoteProperties(note.properties),
created_at: sqliteToDate(note.created_at),
created_by: note.created_by ? sqliteToUuid(note.created_by) : null,
updated_at: sqliteToDate(note.updated_at),
updated_by: note.updated_by ? sqliteToUuid(note.updated_by) : null,
};
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { sqliteProtocols } from "./url.js";
const getSqlite = () =>
import(/* webpackChunkName: 'dbsource-sqlite' */ "./getdb.static.js").then(
(x) => x.default,
(x) => x.default
);
const getSqliteDynamic: GetDbDynamic = {
+1 -2
View File
@@ -6,11 +6,10 @@ import type { WrappedDb, WrappedDbOptions } from "./types.js";
function getDatabase(
path: string,
options?: Omit<sqlite.Options, "nativeBinding">,
options?: Omit<sqlite.Options, "nativeBinding">
): sqlite.Database {
if (!natives.sqlite) throw new Error("No natives found for better-sqlite3");
options = { ...options };
if (typeof options.readonly !== "boolean") delete options.readonly;
if (typeof options.timeout !== "number") delete options.timeout;
const db = new Sqlite(path, { ...options, nativeBinding: natives.sqlite });
db.exec(pragma);
+1 -1
View File
@@ -4,7 +4,7 @@ import { node, bs3 } from "./implementations.js";
export function getWrappedDb(
kind: "any" | "node" | "bs3",
path: string,
options: WrappedDbOptions,
options: WrappedDbOptions
): WrappedDb {
if (kind === "node") {
if (!node) throw new Error("Requesting unavailable node backend");
+8 -56
View File
@@ -1,14 +1,11 @@
import type { Abode } from "../types/Abode.js";
import type { ClientApikey } from "../types/Apikey.js";
import type { Note, PartialNote } from "../types/Note.js";
import type { Resident } from "../types/Resident.js";
import type { ClientUser } from "../types/User.js";
import {
sqliteToAbode,
sqliteToClientApikey,
sqliteToClientUser,
sqliteToNote,
sqliteToPartialNote,
sqliteToResident,
} from "./cast.js";
import type { WrappedDb } from "./impl/types.js";
@@ -29,7 +26,7 @@ const sqlClientUser = sql`
export function selectClientUser(
db: WrappedDb,
where: SqlCode,
where: SqlCode
): ClientUser | null {
const rawUser = db.get<RawClientUser>(sql`${sqlClientUser} WHERE ${where}`);
if (rawUser) return sqliteToClientUser(rawUser);
@@ -37,7 +34,7 @@ export function selectClientUser(
}
export function selectClientUsers(db: WrappedDb, rest?: SqlCode): ClientUser[] {
const rawUsers = db.all<RawClientUser>(
rest ? sql`${sqlClientUser} ${rest}` : sqlClientUser,
rest ? sql`${sqlClientUser} ${rest}` : sqlClientUser
);
return rawUsers.map(sqliteToClientUser);
}
@@ -62,7 +59,7 @@ export function selectAbode(db: WrappedDb, where: SqlCode): Abode | null {
}
export function selectAbodes(db: WrappedDb, rest?: SqlCode): Abode[] {
const rawAbodes = db.all<RawAbode>(
rest ? sql`${sqlAbode} ${rest}` : sqlAbode,
rest ? sql`${sqlAbode} ${rest}` : sqlAbode
);
return rawAbodes.map(sqliteToAbode);
}
@@ -88,7 +85,7 @@ export function selectResident(db: WrappedDb, where: SqlCode): Resident | null {
}
export function selectResidents(db: WrappedDb, where?: SqlCode): Resident[] {
const rawResidents = db.all<RawResident>(
where ? sql`${sqlResident} WHERE ${where}` : sqlResident,
where ? sql`${sqlResident} WHERE ${where}` : sqlResident
);
return rawResidents.map(sqliteToResident);
}
@@ -108,65 +105,20 @@ const sqlClientApikey = sql`
export function selectClientApikey(
db: WrappedDb,
where: SqlCode,
where: SqlCode
): ClientApikey | null {
const rawApikey = db.get<RawClientApikey>(
sql`${sqlClientApikey} WHERE ${where}`,
sql`${sqlClientApikey} WHERE ${where}`
);
if (rawApikey) return sqliteToClientApikey(rawApikey);
return null;
}
export function selectClientApikeys(
db: WrappedDb,
where?: SqlCode,
where: SqlCode
): ClientApikey[] {
const rawApikeys = db.all<RawClientApikey>(
where ? sql`${sqlClientApikey} WHERE ${where}` : sqlClientApikey,
sql`${sqlClientApikey} WHERE ${where}`
);
return rawApikeys.map(sqliteToClientApikey);
}
type RawNote = {
nid: Buffer | Uint8Array;
aid: Buffer | Uint8Array;
name: string;
content: string;
properties: string;
created_at: string;
created_by: Buffer | Uint8Array | null;
updated_at: string;
updated_by: Buffer | Uint8Array | null;
};
const sqlNote = sql`
SELECT n."nid", n."aid", n."name", n."content", json(n."properties") AS "properties",
n."created_at", n."created_by", n."updated_at", n."updated_by"
FROM "notes" n
`;
type RawPartialNote = Omit<RawNote, "content">;
const sqlPartialNote = sql`
SELECT n."nid", n."aid", n."name", json(n."properties") AS "properties",
n."created_at", n."created_by", n."updated_at", n."updated_by"
FROM "notes" n
`;
export function selectNote(db: WrappedDb, where: SqlCode): Note | null {
const raw = db.get<RawNote>(sql`${sqlNote} WHERE ${where}`);
if (raw) return sqliteToNote(raw);
return null;
}
export function selectNotes(db: WrappedDb, where?: SqlCode): Note[] {
const raws = db.all<RawNote>(
where ? sql`${sqlNote} WHERE ${where}` : sqlNote,
);
return raws.map(sqliteToNote);
}
export function selectPartialNotes(
db: WrappedDb,
where?: SqlCode,
): PartialNote[] {
const raws = db.all<RawPartialNote>(
where ? sql`${sqlPartialNote} WHERE ${where}` : sqlPartialNote,
);
return raws.map(sqliteToPartialNote);
}
+1 -1
View File
@@ -65,7 +65,7 @@ export function calcUpdates<T extends object>(updater: {
for (const [prop, update] of Object.entries(updater)) {
if (prop in obj) {
updates.push(
(update as (value: unknown) => SqlCode)(obj[prop as keyof T]!),
(update as (value: unknown) => SqlCode)(obj[prop as keyof T]!)
);
}
}
+3 -3
View File
@@ -27,7 +27,7 @@ export function isSqliteUrl(url: string) {
}
export function parseSqliteUrl(
url: string,
url: string
): ["any" | "node" | "bs3", string, WrappedDbOptions] {
if (!isSqliteUrl(url)) throw new Error("Not sqlite: protocol");
const urlObj = new URL(url);
@@ -39,7 +39,7 @@ export function parseSqliteUrl(
urlObj.protocol === "node+sqlite:"
? "node"
: urlObj.protocol === "bs3+sqlite:"
? "bs3"
: "any";
? "bs3"
: "any";
return [kind, urlObj.pathname, options];
}
+3 -5
View File
@@ -40,11 +40,11 @@ export interface DbInterface {
deleteResidentById(uid: string, aid: string): Promise<void>;
createResident(
resident: CreateResident,
ctx: { uid: string },
ctx: { uid: string }
): Promise<Resident>;
updateResident(
resident: updateResident,
ctx: { uid: string },
ctx: { uid: string }
): Promise<Resident>;
// list residents by member
@@ -85,7 +85,6 @@ export interface BackendDbInterface extends DbInterface {
// auth by session
getUserBySession(token: `as_${string}`): Promise<ClientUser>;
createSession(uid: string): Promise<`as_${string}`>;
deleteSession(token: `as_${string}`): Promise<void>;
// auth by apikey
getUserByApikey(token: `at_${string}`): Promise<[ClientUser, ClientApikey]>;
@@ -99,12 +98,11 @@ export function isBackendInterface(db: DbInterface): db is BackendDbInterface {
"getUserByLogin",
"getUserBySession",
"createSession",
"deleteSession",
"getUserByApikey",
] as const
).every(
(x) =>
x in db && typeof (db as Partial<BackendDbInterface>)[x] === "function",
x in db && typeof (db as Partial<BackendDbInterface>)[x] === "function"
)
);
}
-110
View File
@@ -1,110 +0,0 @@
import type { DbInterface } from "./DbInterface.js";
/**
* The kinds of records that can travel through an export/import stream.
*
* `Session` is intentionally excluded: it has no CRUD/list surface in
* {@link DbInterface} and is ephemeral/non-portable between instances.
*/
export type ExportKind = "user" | "abode" | "resident" | "apikey" | "note";
/**
* Canonical order in which record kinds are emitted into an export stream, and
* the order in which an importer may safely apply them.
*
* This ordering is **part of the wire contract**, not an implementation
* detail. It is FK-safe: every foreign key points only at a kind that appears
* earlier (or at the same kind, earlier in the stream), so an importer that
* inserts records one-by-one with referential integrity enforced never
* forward-references a row it hasn't inserted yet:
*
* - `abode.created_by`/`updated_by` → `user`
* - `resident.uid` → `user`, `resident.aid` → `abode`
* - `apikey.uid` → `user`
* - `note.aid` → `abode`, `note.created_by`/`updated_by` → `user`
*
* The sqlite backend can afford to relax this (it suspends FK enforcement for
* the load), but the postgres backend relies on it: it inserts sequentially
* with constraints live. Every {@link Exportable} MUST emit in this order, and
* reordering it is a breaking change to the format.
*/
export const EXPORT_KIND_ORDER = [
"user",
"abode",
"resident",
"apikey",
"note",
] as const satisfies readonly ExportKind[];
export type ExportFilter = {
/** Include only these kinds; omit = all kinds. */
kinds?: ExportKind[];
/** Excluded after `kinds` is applied. */
excludeKinds?: ExportKind[];
/** aid allowlist — scopes abode/resident/note. */
abodes?: string[];
/** uid allowlist — scopes user (and apikey, unless `apikeys` is set). */
users?: string[];
/**
* uid allowlist scoping apikey records specifically. When set it takes
* precedence over `users` for the `apikey` kind — used to export a
* non-admin's own keys while still exporting co-residents' *user* records
* for referential integrity, without leaking their apikey metadata. Absent =
* fall back to `users`.
*/
apikeys?: string[];
};
export interface ExportOptions {
filter?: ExportFilter;
signal?: AbortSignal;
}
export interface Exportable {
/** Produce a stream of NDJSON lines (one JSON envelope per line). */
export(options?: ExportOptions): NodeJS.ReadableStream;
}
export interface ImportOptions {
filter?: ExportFilter;
signal?: AbortSignal;
}
export interface ImportResult {
counts: Partial<Record<ExportKind, number>>;
}
export interface Importable {
import(
source: NodeJS.ReadableStream,
options?: ImportOptions,
): Promise<ImportResult>;
}
/**
* The NDJSON envelope written/read for every line. The leading line is a
* `meta` record; the record lines that follow are grouped by kind in
* {@link EXPORT_KIND_ORDER} (an FK-safe order importers may rely on); a
* trailing `error` record may appear if the source failed after streaming had
* already begun.
*/
export type ExportEnvelope =
| { kind: "meta"; data: ExportMeta }
| { kind: ExportKind; data: unknown }
| { kind: "error"; data: { message: string; code?: string } };
export type ExportMeta = {
v: number;
exportedAt: string;
source: string;
/** The *effective* filter actually applied (may be narrower than requested). */
filter: ExportFilter;
};
export function isExportable(db: DbInterface): db is DbInterface & Exportable {
return typeof (db as Partial<Exportable>).export === "function";
}
export function isImportable(db: DbInterface): db is DbInterface & Importable {
return typeof (db as Partial<Importable>).import === "function";
}
+1 -1
View File
@@ -27,7 +27,7 @@ export type LoginUser = {
};
export function isValidUserPassword(
password: string,
password: string
): password is User["password"] {
if (password.startsWith("#")) {
return ["unset"].includes(password.slice(1));
+1 -1
View File
@@ -2,7 +2,7 @@ if (import.meta.hot) {
import.meta.hot.on("message", (msg) => {
if (
msg.includes(
"A pending update was not accepted, and reached the root module:",
"A pending update was not accepted, and reached the root module:"
)
) {
throw new Error("[hot] Restarting due to unaccepted pending update");
+4 -4
View File
@@ -32,23 +32,23 @@ async function getPackageJsonDir(path: string): Promise<string | null> {
export async function findNative(
module: string,
native: string,
native: string
): Promise<string> {
const path = await getPackageJsonDir(
createRequire(import.meta.url).resolve(module),
createRequire(import.meta.url).resolve(module)
);
if (!path) throw new Error(`Cannot find module directory for ${module}`);
const file = await find(path, native);
if (!file)
throw new Error(
`Cannot find native ${native} of package ${module} in ${path}`,
`Cannot find native ${native} of package ${module} in ${path}`
);
return file;
}
export async function tryFindNative(
module: string,
native: string,
native: string
): Promise<string | null> {
try {
return await findNative(module, native);
+2 -2
View File
@@ -22,8 +22,8 @@ export async function webpack(): Promise<{ code: string }> {
let code: string = standaloneCode(
validator,
Object.fromEntries(
Object.entries(schemas).map(([id, schema]) => [id, schema.$id]),
),
Object.entries(schemas).map(([id, schema]) => [id, schema.$id])
)
);
// assign the .schema ourselves to the validation functions
+4 -4
View File
@@ -12,7 +12,7 @@ export interface PopupManagerContextData {
openPopup(popup: ComponentType<{ id: string; onClose: () => void }>): string;
openPopup<T>(
popup: ComponentType<{ id: string; onClose: () => void } & T>,
props: T,
props: T
): string;
closePopup(id: string): void;
@@ -34,7 +34,7 @@ export function PopupManager({ children }: { children: ReactNode }) {
const openPopup = useCallback<PopupManagerContextData["openPopup"]>(
(
Component: ComponentType<{ id: string; onClose: () => void }>,
props = {},
props = {}
) => {
const id = crypto.randomUUID();
Object.assign(props, {
@@ -45,14 +45,14 @@ export function PopupManager({ children }: { children: ReactNode }) {
setPopups((prev) => [...prev, { id, Component, props }]);
return id;
},
[],
[]
);
const closePopup = useCallback((id: string) => {
setPopups((prev) => prev.filter((x) => x.id !== id));
}, []);
const ctx = useMemo<PopupManagerContextData>(
() => ({ openPopup, closePopup }),
[],
[]
);
return (
+2 -2
View File
@@ -19,7 +19,7 @@ export function useDataResidentsByAbodeId(aid: string) {
const status = useLoad(loadResidentsByAbodeId, { aid });
const residents = useMemo(
() => Object.values(allResidents).filter((x) => x.aid === aid),
[allResidents, aid],
[allResidents, aid]
);
return { ...status, residents };
}
@@ -29,7 +29,7 @@ export function useDataResidentsByUserId(uid: string) {
const status = useLoad(loadResidentsByUserId, { uid });
const residents = useMemo(
() => Object.values(allResidents).filter((x) => x.uid === uid),
[allResidents, uid],
[allResidents, uid]
);
return { ...status, residents };
}
+2 -2
View File
@@ -5,7 +5,7 @@ import type { Store } from "../store/store.js";
import { useStore } from "../store/react.js";
export function useAction<P extends any[], R>(
action: (...params: [...P, { db: DbInterface; store: Store }]) => Promise<R>,
action: (...params: [...P, { db: DbInterface; store: Store }]) => Promise<R>
): (...args: P) => Promise<R> {
const db = use(DbContext);
const store = useStore();
@@ -15,6 +15,6 @@ export function useAction<P extends any[], R>(
if (!db) throw new Error("DB not present");
return action(...params, { db, store });
},
[action, db],
[action, db]
);
}
+1 -1
View File
@@ -32,7 +32,7 @@ export function useLoad<P>(loader: Loader<P>, params?: P): UseLoadResult {
const refresh = useCallback(
() => load({ loader, params: params!, store, db, refresh: true }),
[loader, params, db],
[loader, params, db]
);
return {
+3 -3
View File
@@ -8,7 +8,7 @@ import type { Store } from "../store.js";
export async function deleteAbodeById(
aid: string,
{ store, db }: { store: Store; db: DbInterface },
{ store, db }: { store: Store; db: DbInterface }
): Promise<void> {
await Promise.all([
waitForLoadIfLoading(store, "loadAllAbodes"),
@@ -22,7 +22,7 @@ export async function deleteAbodeById(
export async function updateAbode(
abode: UpdateAbode,
{ store, db }: { store: Store; db: DbInterface },
{ store, db }: { store: Store; db: DbInterface }
): Promise<void> {
await Promise.all([
waitForLoadIfLoading(store, "loadAllAbodes"),
@@ -38,7 +38,7 @@ export async function updateAbode(
export async function createAbode(
abode: CreateAbode,
{ store, db }: { store: Store; db: DbInterface },
{ store, db }: { store: Store; db: DbInterface }
): Promise<string> {
await waitForLoadIfLoading(store, "loadAllAbodes");
+3 -3
View File
@@ -7,7 +7,7 @@ import type { Store } from "../store.js";
export async function deleteUserById(
uid: string,
{ store, db }: { store: Store; db: DbInterface },
{ store, db }: { store: Store; db: DbInterface }
): Promise<void> {
await Promise.all([
waitForLoadIfLoading(store, "loadAllUsers"),
@@ -24,7 +24,7 @@ export async function deleteUserById(
export async function updateUser(
user: UpdateUser,
{ store, db }: { store: Store; db: DbInterface },
{ store, db }: { store: Store; db: DbInterface }
): Promise<void> {
await Promise.all([
waitForLoadIfLoading(store, "loadAllUsers"),
@@ -41,7 +41,7 @@ export async function updateUser(
export async function createUser(
user: CreateUser,
{ store, db }: { store: Store; db: DbInterface },
{ store, db }: { store: Store; db: DbInterface }
): Promise<string> {
await waitForLoadIfLoading(store, "loadAllUsers");
+4 -7
View File
@@ -48,7 +48,7 @@ async function loadImpl<P>({
setLoading([
id,
{ status: refresh ? "refreshing" : "loading", type, params },
]),
])
);
const controller = new AbortController();
try {
@@ -69,10 +69,7 @@ async function loadImpl<P>({
store.dispatch(setLoading([id, { status: "loaded", type, params }]));
} catch (e) {
store.dispatch(
setLoading([
id,
{ status: "error", type, params, error: objectError(e) },
]),
setLoading([id, { status: "error", type, params, error: objectError(e) }])
);
throw e;
}
@@ -117,7 +114,7 @@ export function loader<P>(loader: Loader<P>): Loader<P> {
export async function waitForLoadIfLoading(
store: Store,
id: string,
{ signal }: { signal?: AbortSignal } = {},
{ signal }: { signal?: AbortSignal } = {}
) {
if (!getLoadingStatus(store.getState(), id)) return;
return waitFor(
@@ -126,6 +123,6 @@ export async function waitForLoadIfLoading(
const status = getLoadingStatus(state, id);
return status === "loaded" || status === "error";
},
{ signal },
{ signal }
);
}
+1 -1
View File
@@ -16,7 +16,7 @@ export function Provider(
props: Omit<ProviderProps, "context" | "store" | "serverState"> & {
store: Store;
serverState?: State;
},
}
) {
return <RawProvider context={AbodeStoreContext} {...props} />;
}
+1 -1
View File
@@ -40,7 +40,7 @@ const loadingSlice = createSlice({
reducers: {
setLoading: (
state,
action: PayloadAction<[id: string, state: LoadingState]>,
action: PayloadAction<[id: string, state: LoadingState]>
) => {
state[action.payload[0]] = action.payload[1];
},
+1 -1
View File
@@ -19,7 +19,7 @@ const usersSlice = createSlice({
getUser: usersSelectors.selectById,
getUserByEmail: (state, email: string) =>
Object.values(state.entities).find(
(x): x is ClientUser => "email" in x && x.email === email,
(x): x is ClientUser => "email" in x && x.email === email
),
getUsers: usersSelectors.selectEntities,
getUserIds: usersSelectors.selectIds,
+2 -2
View File
@@ -3,7 +3,7 @@ import type { State, Store } from "./store.js";
export async function waitFor(
store: Store,
cond: (state: State) => boolean,
{ signal }: { signal?: AbortSignal } = {},
{ signal }: { signal?: AbortSignal } = {}
): Promise<void> {
return new Promise<void>((ok, ko) => {
signal?.throwIfAborted();
@@ -28,7 +28,7 @@ export async function waitFor(
() => {
controller.abort();
},
{ signal: controller.signal },
{ signal: controller.signal }
);
});
}
+19 -19
View File
@@ -1,23 +1,23 @@
export { default as user } from "./user/user.schema.json" with { type: "json" };
export { default as createuser } from "./user/createuser.schema.json" with { type: "json" };
export { default as updateuser } from "./user/updateuser.schema.json" with { type: "json" };
export { default as partialuser } from "./user/partialuser.schema.json" with { type: "json" };
export { default as clientuser } from "./user/clientuser.schema.json" with { type: "json" };
export { default as userflags } from "./user/userflags.schema.json" with { type: "json" };
export { default as loginuser } from "./user/loginuser.schema.json" with { type: "json" };
export { default as user } from "./user/user.schema.json" with {type: 'json'};
export { default as createuser } from "./user/createuser.schema.json" with {type: 'json'};
export { default as updateuser } from "./user/updateuser.schema.json" with {type: 'json'};
export { default as partialuser } from "./user/partialuser.schema.json" with {type: 'json'};
export { default as clientuser } from "./user/clientuser.schema.json" with {type: 'json'};
export { default as userflags } from "./user/userflags.schema.json" with {type: 'json'};
export { default as loginuser } from "./user/loginuser.schema.json" with {type: 'json'};
export { default as abode } from "./abode/abode.schema.json" with { type: "json" };
export { default as createabode } from "./abode/createabode.schema.json" with { type: "json" };
export { default as updateabode } from "./abode/updateabode.schema.json" with { type: "json" };
export { default as abode } from './abode/abode.schema.json' with {type: 'json'};
export { default as createabode } from './abode/createabode.schema.json' with {type: 'json'};
export { default as updateabode } from './abode/updateabode.schema.json' with {type: 'json'};
export { default as resident } from "./resident/resident.schema.json" with { type: "json" };
export { default as createresident } from "./resident/createresident.schema.json" with { type: "json" };
export { default as updateresident } from "./resident/updateresident.schema.json" with { type: "json" };
export { default as residentflags } from "./resident/residentflags.schema.json" with { type: "json" };
export { default as resident } from './resident/resident.schema.json' with {type: 'json'};
export { default as createresident } from './resident/createresident.schema.json' with {type: 'json'};
export { default as updateresident } from './resident/updateresident.schema.json' with {type: 'json'};
export { default as residentflags } from './resident/residentflags.schema.json' with {type: 'json'};
export { default as createapikey } from "./apikey/createapikey.schema.json" with { type: "json" };
export { default as apikeypermissions } from "./apikey/apikeypermissions.schema.json" with { type: "json" };
export { default as createapikey } from './apikey/createapikey.schema.json' with {type: 'json'};
export { default as apikeypermissions } from './apikey/apikeypermissions.schema.json' with {type: 'json'};
export { default as createnote } from "./note/createnote.schema.json" with { type: "json" };
export { default as updatenote } from "./note/updatenote.schema.json" with { type: "json" };
export { default as partialnoteproperties } from "./note/partialnoteproperties.schema.json" with { type: "json" };
export { default as createnote } from './note/createnote.schema.json' with {type: 'json'};
export { default as updatenote } from './note/updatenote.schema.json' with {type: 'json'};
export { default as partialnoteproperties } from './note/partialnoteproperties.schema.json' with {type: 'json'};
+1 -1
View File
@@ -39,7 +39,7 @@ function checkSchema(name: string, schema: AnySchema) {
if (!schema.$id) throw new Error(`Missing $id for schema ${name}`);
if (
!schema.$id.match(
/^https:\/\/abode\.codi\.moe\/schema\/[a-zA-Z0_9_-]+\.schema\.json$/,
/^https:\/\/abode\.codi\.moe\/schema\/[a-zA-Z0_9_-]+\.schema\.json$/
)
)
throw new Error(`Unexpected $id for schema ${name}`);
+1 -1
View File
@@ -18,7 +18,7 @@ const validators = Object.fromEntries(
Object.entries(schemas).map(([name, schema]) => [
name,
validator.compile(schema),
]),
])
) as unknown as {
[T in keyof Types]: {
(obj: unknown): obj is Types[T];
+1 -1
View File
@@ -69,7 +69,7 @@ function App() {
(input, key) => {
if (input === "q" || key.escape) app.exit();
},
{ isActive },
{ isActive }
);
const [activeCollection, setActiveCollection] =
+2 -2
View File
@@ -41,11 +41,11 @@ export function AbodesPanel() {
const onSelect = useCallback(
(abode: Abode) => openPopup(AbodePopup, { aid: abode.aid }),
[openPopup],
[openPopup]
);
const buttons = useMemo<ButtonListItem[]>(
() => [{ children: "New", onClick: () => openPopup(CreateAbodePopup) }],
[openPopup],
[openPopup]
);
return (
+2 -2
View File
@@ -59,11 +59,11 @@ export function UsersPanel() {
const onSelect = useCallback(
(user: ClientUser | PartialUser) => openPopup(UserPopup, { uid: user.uid }),
[openPopup],
[openPopup]
);
const buttons = useMemo<ButtonListItem[]>(
() => [{ children: "New", onClick: () => openPopup(CreateUserPopup) }],
[openPopup],
[openPopup]
);
return (
+2 -2
View File
@@ -18,7 +18,7 @@ export function Button({
(input, key) => {
if (input === " " || key.return) onClick();
},
{ isActive: isFocused },
{ isActive: isFocused }
);
return <Text inverse={isFocused}>[{children}]</Text>;
@@ -66,7 +66,7 @@ export function ButtonList({
setSelected((prev) => (prev - 1 + buttons.length) % buttons.length);
}
},
{ isActive: isFocused || forceFocus || false },
{ isActive: isFocused || forceFocus || false }
);
return (
+2 -2
View File
@@ -49,7 +49,7 @@ export function ListBox<T extends string>({
setSelected(items[(items.indexOf(selected) + 1) % items.length]);
} else if (key.upArrow) {
setSelected(
items[(items.indexOf(selected) - 1 + items.length) % items.length],
items[(items.indexOf(selected) - 1 + items.length) % items.length]
);
} else if (key.pageUp) {
setSelected(items[0]);
@@ -57,7 +57,7 @@ export function ListBox<T extends string>({
setSelected(items[items.length - 1]);
}
},
{ isActive: isFocused },
{ isActive: isFocused }
);
return (
+3 -3
View File
@@ -36,14 +36,14 @@ export function ListDisplay<T>({
onSelect?.(items[selected]);
}
},
{ isActive: isFocused },
{ isActive: isFocused }
);
useEffect(() => {
if (selected < start) setStart(selected);
else if (selected >= slice)
setStart(
Math.max(Math.min(selected - slice + start + 1, items.length - 1), 0),
Math.max(Math.min(selected - slice + start + 1, items.length - 1), 0)
);
}, [selected, start, slice, items.length]);
useEffect(() => {
@@ -56,7 +56,7 @@ export function ListDisplay<T>({
const indexed = useMemo(
() => items.map((item, index) => ({ item, index })),
[items],
[items]
);
return (
+1 -1
View File
@@ -16,7 +16,7 @@ export function Popup({
(_, key) => {
if (key.escape) onClose?.();
},
{ isActive: active && !!onClose },
{ isActive: active && !!onClose }
);
return (
+1 -1
View File
@@ -52,7 +52,7 @@ export function SearchPanel<T>({
refresh?.();
}
},
{ isActive: isFocused && !!refresh },
{ isActive: isFocused && !!refresh }
);
const topbar = !!match || !!buttons?.length;
+2 -2
View File
@@ -2,13 +2,13 @@ import { argon2id, argon2Verify } from "hash-wasm";
export async function validatePassword(
password: string,
hash: string,
hash: string
): Promise<boolean> {
return await argon2Verify({ password, hash });
}
export async function hashPassword(
password: string,
password: string
): Promise<`$${string}$${string}`> {
const salt = new Uint8Array(16);
crypto.getRandomValues(salt);
+14 -12
View File
@@ -7,11 +7,11 @@ const escapes = {
};
function parseAdd<T>(
rest: (Record<string, string> | string | ((writer: XmlWriter) => T))[],
rest: (Record<string, string> | string | ((writer: XmlWriter) => T))[]
): [
props?: Record<string, string>,
content?: string,
children?: (writer: XmlWriter) => T,
children?: (writer: XmlWriter) => T
] {
let props: Record<string, string> | undefined;
let children: ((writer: XmlWriter) => T) | undefined;
@@ -55,7 +55,7 @@ export class XmlWriter {
tag: string,
props?: Record<string, string>,
content?: string,
children?: NonNullable<unknown>,
children?: NonNullable<unknown>
) {
const top = this.#stack.at(-1);
if (top && !top.children) {
@@ -104,7 +104,7 @@ export class XmlWriter {
add(
tag: string,
props: Record<string, string>,
children: (writer: XmlWriter) => void,
children: (writer: XmlWriter) => void
): XmlWriter;
add(
tag: string,
@@ -125,21 +125,23 @@ export class XmlWriter {
addAsync(
tag: string,
props: Record<string, string>,
content: string,
content: string
): Promise<void>;
addAsync(
tag: string,
children: (writer: XmlWriter) => Promise<void>,
children: (writer: XmlWriter) => Promise<void>
): Promise<void>;
addAsync(
tag: string,
props: Record<string, string>,
children: (writer: XmlWriter) => Promise<void>,
children: (writer: XmlWriter) => Promise<void>
): Promise<void>;
async addAsync(
tag: string,
...rest: (
Record<string, string> | string | ((writer: XmlWriter) => Promise<void>)
| Record<string, string>
| string
| ((writer: XmlWriter) => Promise<void>)
)[]
): Promise<void> {
const [props, content, children] = parseAdd(rest);
@@ -162,7 +164,7 @@ export class XmlWriter {
| Parameters<InstanceType<typeof XmlWriter>["add"]>
| [
NonNullable<ConstructorParameters<typeof XmlWriter>[0]>,
...Parameters<InstanceType<typeof XmlWriter>["add"]>,
...Parameters<InstanceType<typeof XmlWriter>["add"]>
]
): string {
let options: ConstructorParameters<typeof XmlWriter>[0];
@@ -170,7 +172,7 @@ export class XmlWriter {
options = rest.shift()! as ConstructorParameters<typeof XmlWriter>[0];
}
return new XmlWriter(options).add(
...(rest as Parameters<InstanceType<typeof XmlWriter>["add"]>),
...(rest as Parameters<InstanceType<typeof XmlWriter>["add"]>)
).content;
}
@@ -186,7 +188,7 @@ export class XmlWriter {
| Parameters<InstanceType<typeof XmlWriter>["addAsync"]>
| [
NonNullable<ConstructorParameters<typeof XmlWriter>[0]>,
...Parameters<InstanceType<typeof XmlWriter>["addAsync"]>,
...Parameters<InstanceType<typeof XmlWriter>["addAsync"]>
]
): Promise<string> {
let options: ConstructorParameters<typeof XmlWriter>[0];
@@ -195,7 +197,7 @@ export class XmlWriter {
}
const writer = new XmlWriter(options);
await writer.addAsync(
...(rest as Parameters<InstanceType<typeof XmlWriter>["addAsync"]>),
...(rest as Parameters<InstanceType<typeof XmlWriter>["addAsync"]>)
);
return writer.content;
}
+14 -106
View File
@@ -11,43 +11,10 @@ import {
loginuser,
updateabode,
updateresident,
updatenote,
updateuser,
} from "../schema/validators.js";
import { authenticate } from "./middleware/authenticate.js";
import {
InvalidAbodeError,
NotAuthorizedAbodeError,
NotFoundAbodeError,
} from "../db/types/errors.js";
import { isExportable } from "../db/types/ExportImport.js";
import type { ExportFilter, ExportKind } from "../db/types/ExportImport.js";
import { intersectExportFilters, isExportKind } from "../db/export/filter.js";
import { computeForcedExportFilter } from "./exportScope.js";
import {
hasGlobalUserVisibility,
hideUserEmail,
userForCaller,
} from "./userVisibility.js";
function parseExportFilter(query: Record<string, unknown>): ExportFilter {
const list = (v: unknown): string[] | undefined => {
if (typeof v !== "string" || !v) return undefined;
return v.split(",").filter(Boolean);
};
const kinds = (v: unknown): ExportKind[] | undefined =>
list(v)?.filter(isExportKind);
const filter: ExportFilter = {};
const k = kinds(query.kinds);
if (k) filter.kinds = k;
const ek = kinds(query.excludeKinds);
if (ek) filter.excludeKinds = ek;
const abodes = list(query.abodes);
if (abodes) filter.abodes = abodes;
const users = list(query.users);
if (users) filter.users = users;
return filter;
}
import { InvalidAbodeError, NotFoundAbodeError } from "../db/types/errors.js";
export function apirouter(db: BackendDbInterface): KoaRouter {
const router = new KoaRouter();
@@ -61,8 +28,6 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
});
router.post("/auth/logout", authenticate(db), async (ctx) => {
if (ctx.session!.source !== "session") throw new InvalidAbodeError();
const token = ctx.cookies.get("abode_session");
if (token) await db.deleteSession(token as `as_${string}`);
ctx.cookies.set("abode_session", "", { expires: new Date("1970-01-01") });
ctx.status = 204;
});
@@ -74,66 +39,26 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
ctx.status = 204;
});
router.get("/export", authenticate(db), async (ctx) => {
// Everything that can throw a domain error runs *before* any byte is
// written, so `convertError` still applies. Once `ctx.body` is a stream,
// a mid-stream failure surfaces as a trailing `error` NDJSON line instead.
const forced = await computeForcedExportFilter(db, {
user: ctx.user!,
session: ctx.session!,
});
const effective = intersectExportFilters(
parseExportFilter(ctx.query),
forced,
);
if (!isExportable(db)) {
ctx.status = 501;
ctx.body = { ok: false, error: "export_unsupported" };
return;
}
const ac = new AbortController();
ctx.res.on("close", () => {
if (!ctx.res.writableEnded) ac.abort();
});
ctx.type = "application/x-ndjson";
ctx.body = db.export({ filter: effective, signal: ac.signal });
});
router.use("/users", authenticate(db));
router.get("/users", async (ctx) => {
const users = await db.listUsers();
ctx.body = users.map((user) =>
userForCaller(user, { user: ctx.user!, session: ctx.session! }),
);
ctx.body = await db.listUsers();
});
router.post("/users", jsonBody({ validate: createuser }), async (ctx) => {
ctx.body = await db.createUser(ctx.request.body);
});
router.get("/users/by-email", async (ctx) => {
if (typeof ctx.query.email !== "string") throw new InvalidAbodeError();
if (
!hasGlobalUserVisibility({
user: ctx.user!,
session: ctx.session!,
})
) {
throw new NotAuthorizedAbodeError();
}
ctx.body = await db.getUserByEmail(ctx.query.email);
});
router.get("/users/:uid", async (ctx) => {
const user = await db.getUserById(ctx.params.uid);
ctx.body = userForCaller(user, {
user: ctx.user!,
session: ctx.session!,
});
ctx.body = await db.getUserById(ctx.params.uid);
});
router.patch(
"/users/:uid",
jsonBody({ validate: updateuser, includeParams: ["uid"] }),
async (ctx) => {
ctx.body = await db.updateUser(ctx.request.body);
},
}
);
router.delete("/users/:uid", async (ctx) => {
await db.deleteUserById(ctx.params.uid);
@@ -154,7 +79,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
async (ctx) => {
const [apikey, token] = await db.createApikey(ctx.request.body);
ctx.body = { apikey, token };
},
}
);
router.get("/users/:uid/apikeys/:kid", async (ctx) => {
const apikey = await db.getApikeyById(ctx.params.kid);
@@ -167,11 +92,11 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
await db.deleteApikeyById(ctx.params.kid);
ctx.status = 204;
});
router.post("/users/:uid/auth/clear-sessions", async (ctx) => {
router.post("/user/:uid/auth/clear-sessions", async (ctx) => {
await db.deleteSessionsByUser(ctx.params.uid);
ctx.status = 204;
});
router.get("/users/:uid/notes", async (ctx) => {
router.get("/user/:uid/notes", async (ctx) => {
ctx.body = await db.listNotesByUserId(ctx.params.uid);
});
@@ -190,7 +115,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
jsonBody({ validate: updateabode, includeParams: ["aid"] }),
async (ctx) => {
ctx.body = await db.updateAbode(ctx.request.body, { uid: ctx.user!.uid });
},
}
);
router.delete("/abodes/:aid", async (ctx) => {
await db.deleteAbodeById(ctx.params.aid);
@@ -200,24 +125,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
ctx.body = await db.listResidentsByAbodeId(ctx.params.aid);
});
router.get("/abodes/:aid/users", async (ctx) => {
const users = await db.listUsersByAbodeId(ctx.params.aid);
const globalVisibility = hasGlobalUserVisibility({
user: ctx.user!,
session: ctx.session!,
});
const residents = globalVisibility
? []
: await db.listResidentsByAbodeId(ctx.params.aid);
const abodeAdmin = residents.some(
(resident) =>
resident.uid === ctx.user!.uid && resident.flags.admin === true,
);
ctx.body =
globalVisibility || abodeAdmin
? users
: users.map((user) =>
user.uid === ctx.user!.uid ? user : hideUserEmail(user),
);
ctx.body = await db.listUsersByAbodeId(ctx.params.aid);
});
router.get("/abodes/:aid/notes", async (ctx) => {
ctx.body = await db.listNotesByAbodeId(ctx.params.aid);
@@ -227,7 +135,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
jsonBody({ validate: createnote, includeParams: ["aid"] }),
async (ctx) => {
ctx.body = await db.createNote(ctx.request.body, { uid: ctx.user!.uid });
},
}
);
router.use("/residents", authenticate(db));
@@ -241,7 +149,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
ctx.body = await db.createResident(ctx.request.body, {
uid: ctx.user!.uid,
});
},
}
);
router.get("/residents/:uid/:aid", async (ctx) => {
ctx.body = await db.getResidentById(ctx.params.uid, ctx.params.aid);
@@ -253,7 +161,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
ctx.body = await db.updateResident(ctx.request.body, {
uid: ctx.user!.uid,
});
},
}
);
router.delete("/residents/:uid/:aid", async (ctx) => {
await db.deleteResidentById(ctx.params.uid, ctx.params.aid);
@@ -278,10 +186,10 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
});
router.patch(
"/notes/:nid",
jsonBody({ validate: updatenote, includeParams: ["nid"] }),
jsonBody({ includeParams: ["nid"] }),
async (ctx) => {
ctx.body = await db.updateNote(ctx.request.body, { uid: ctx.user!.uid });
},
}
);
router.delete("/notes/:nid", async (ctx) => {
await db.deleteNoteById(ctx.params.nid);
-64
View File
@@ -1,64 +0,0 @@
import type { Context } from "koa";
import type { BackendDbInterface } from "../db/types/DbInterface.js";
import type { ClientUser } from "../db/types/User.js";
import type { ExportFilter } from "../db/types/ExportImport.js";
/**
* Compute the export scope that must be *forced* on a caller, independent of
* anything they requested. Returns `null` when the caller is unrestricted (a
* global admin whose credential imposes no narrowing) their own filter, if
* any, is then honored verbatim as a voluntary narrowing.
*
* Otherwise returns `{ abodes, users, apikeys }`: the abodes the caller resides
* in, the users needed to keep that data referentially whole (the caller plus
* every co-resident of those abodes), and scoped tighter than `users`
* apikeys limited to the caller alone, so a non-admin never exports another
* user's apikey metadata even though that user's record is included. This is
* the maximum a non-admin may export; the route intersects it with any
* caller-supplied filter (never a union).
*/
export async function computeForcedExportFilter(
db: BackendDbInterface,
ctx: { user: ClientUser; session: NonNullable<Context["session"]> },
): Promise<ExportFilter | null> {
const { user, session } = ctx;
if (user.flags.admin) {
if (session.source !== "apikey") return null;
const p = session.key.permissions;
const unrestricted =
!!p.admin &&
!!p.all &&
!p.restrict_users?.length &&
!p.restrict_abodes?.length;
if (unrestricted) return null;
}
const residencies = await db.listResidentsByUserId(user.uid);
const abodeSet = new Set(residencies.map((r) => r.aid));
const userSet = new Set<string>([user.uid]);
for (const aid of abodeSet) {
for (const u of await db.listUsersByAbodeId(aid)) userSet.add(u.uid);
}
let abodes = [...abodeSet];
let users = [...userSet];
// apikeys are self-only for non-admins, regardless of co-residency.
let apikeys = [user.uid];
// An apikey can only narrow what its owning user could otherwise export.
if (session.source === "apikey") {
const p = session.key.permissions;
if (p.restrict_abodes?.length) {
const allow = new Set(p.restrict_abodes);
abodes = abodes.filter((a) => allow.has(a));
}
if (p.restrict_users?.length) {
const allow = new Set(p.restrict_users);
users = users.filter((u) => allow.has(u));
apikeys = apikeys.filter((u) => allow.has(u));
}
}
return { abodes, users, apikeys };
}
+1 -1
View File
@@ -16,7 +16,7 @@ export function schemarouter(): KoaRouter {
url: `${ctx.URL}/${name}.schema.json`,
},
];
}),
})
);
});
-36
View File
@@ -1,36 +0,0 @@
import type { Context } from "koa";
import type { ClientUser, PartialUser } from "../db/types/User.js";
type AuthContext = {
user: ClientUser;
session: NonNullable<Context["session"]>;
};
export function hasGlobalUserVisibility(ctx: AuthContext): boolean {
if (!ctx.user.flags.admin) return false;
if (ctx.session.source !== "apikey") return true;
const permissions = ctx.session.key.permissions;
return (
!!permissions.admin &&
!!permissions.all &&
!permissions.restrict_users?.length &&
!permissions.restrict_abodes?.length
);
}
export function hideUserEmail(user: PartialUser | ClientUser): PartialUser {
if (!("email" in user)) return user;
const { email: _email, ...partial } = user;
return partial;
}
export function userForCaller(
user: PartialUser | ClientUser,
ctx: AuthContext,
): PartialUser | ClientUser {
if (user.uid === ctx.user.uid || hasGlobalUserVisibility(ctx)) {
return user;
}
return hideUserEmail(user);
}
-219
View File
@@ -1,219 +0,0 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { ApiInterface } from "../../../src/db/api/ApiInterface.js";
import {
apiProtocols,
isApiUrl,
parseApiUrl,
} from "../../../src/db/api/url.js";
import {
NotFoundAbodeError,
NotAuthorizedAbodeError,
ReadonlyAbodeError,
InvalidAbodeError,
ConflictAbodeError,
} from "../../../src/db/types/errors.js";
describe("ApiInterface static properties", () => {
it("name is 'api'", () => {
const api = new ApiInterface("http://localhost:9999");
assert.equal(api.name, "api");
});
it("backend is false", () => {
const api = new ApiInterface("http://localhost:9999");
assert.equal(api.backend, false);
});
it("readonly defaults to false", () => {
const api = new ApiInterface("http://localhost:9999");
assert.equal(api.readonly, false);
});
it("readonly is set from options", () => {
const api = new ApiInterface("http://localhost:9999", { readonly: true });
assert.equal(api.readonly, true);
});
});
describe("apiProtocols and isApiUrl", () => {
it("apiProtocols includes expected protocols", () => {
assert.ok(apiProtocols.includes("https:"));
assert.ok(apiProtocols.includes("http:"));
assert.ok(apiProtocols.includes("abode+https:"));
assert.ok(apiProtocols.includes("abode+http:"));
});
it("isApiUrl returns true for http/https urls", () => {
assert.equal(isApiUrl("http://example.com"), true);
assert.equal(isApiUrl("https://example.com/api"), true);
assert.equal(isApiUrl("abode+http://example.com"), true);
assert.equal(isApiUrl("abode+https://example.com"), true);
});
it("isApiUrl returns false for non-http urls", () => {
assert.equal(isApiUrl("sqlite:///db.sqlite"), false);
assert.equal(isApiUrl("not-a-url"), false);
assert.equal(isApiUrl("ftp://example.com"), false);
});
});
describe("parseApiUrl", () => {
it("strips abode+ prefix from protocol", () => {
const [root] = parseApiUrl("abode+http://example.com");
assert.ok(root.startsWith("http://"), `expected http:// got ${root}`);
});
it("extracts Basic auth from URL credentials", () => {
const [, { headers }] = parseApiUrl("http://user:pass@example.com");
assert.ok(headers["Authorization"]?.startsWith("Basic "), "has Basic auth");
const decoded = atob(headers["Authorization"]!.slice("Basic ".length));
assert.equal(decoded, "user:pass");
});
it("strips credentials from root URL", () => {
const [root] = parseApiUrl("http://user:pass@example.com");
assert.ok(!root.includes("user"), "credentials stripped from root");
});
it("extracts readonly flag from query", () => {
const [, { readonly }] = parseApiUrl("http://example.com?readonly=1");
assert.equal(readonly, true);
});
it("defaults readonly to false", () => {
const [, { readonly }] = parseApiUrl("http://example.com");
assert.equal(readonly, false);
});
it("extra query params become headers", () => {
const [, { headers }] = parseApiUrl(
"http://example.com?X-Custom-Header=value",
);
assert.equal(headers["X-Custom-Header"], "value");
});
it("throws for non-api protocol", () => {
assert.throws(
() => parseApiUrl("sqlite:///db.sqlite"),
/Not an \{abode\+,\}http\{s,\}: protocol/,
);
});
});
describe("ApiInterface HTTP error mapping", () => {
let serverUrl: string;
let closeServer: () => Promise<void>;
let respondWith: (status: number) => void;
before(async () => {
let nextStatus = 500;
respondWith = (s) => {
nextStatus = s;
};
const server = createServer((req, res) => {
res.writeHead(nextStatus, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "test" }));
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve),
);
const { port } = server.address() as AddressInfo;
serverUrl = `http://127.0.0.1:${port}`;
closeServer = () =>
new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())),
);
});
after(() => closeServer());
it("404 response throws NotFoundAbodeError", async () => {
respondWith(404);
const api = new ApiInterface(serverUrl);
await assert.rejects(
() => api.listUsers(),
(err) => {
assert.ok(err instanceof NotFoundAbodeError);
return true;
},
);
});
it("401 response throws NotAuthorizedAbodeError", async () => {
respondWith(401);
const api = new ApiInterface(serverUrl);
await assert.rejects(
() => api.listUsers(),
(err) => {
assert.ok(err instanceof NotAuthorizedAbodeError);
return true;
},
);
});
it("403 response throws ReadonlyAbodeError", async () => {
respondWith(403);
const api = new ApiInterface(serverUrl);
await assert.rejects(
() => api.listUsers(),
(err) => {
assert.ok(err instanceof ReadonlyAbodeError);
return true;
},
);
});
it("400 response throws InvalidAbodeError", async () => {
respondWith(400);
const api = new ApiInterface(serverUrl);
await assert.rejects(
() => api.listUsers(),
(err) => {
assert.ok(err instanceof InvalidAbodeError);
return true;
},
);
});
it("409 response throws ConflictAbodeError", async () => {
respondWith(409);
const api = new ApiInterface(serverUrl);
await assert.rejects(
() => api.listUsers(),
(err) => {
assert.ok(err instanceof ConflictAbodeError);
return true;
},
);
});
});
describe("ApiInterface._ internal helpers", () => {
it("_.url builds correct URL for path params (no leftover query param)", () => {
const api = new ApiInterface("http://example.com");
const url = api._.url("/users/:uid", { uid: "abc-123" });
assert.equal(url, "http://example.com/users/abc-123");
});
it("_.url puts remaining params as query string", () => {
const api = new ApiInterface("http://example.com");
const url = api._.url("/users/by-email", { email: "a@b.com" });
assert.ok(url.includes("email="), `expected query param in ${url}`);
});
it("_.root matches the constructor argument", () => {
const api = new ApiInterface("http://example.com/api");
assert.equal(api._.root, "http://example.com/api");
});
it("_.headers includes Authorization when set", () => {
const api = new ApiInterface("http://example.com", {
headers: { Authorization: "Basic dGVzdA==" },
});
assert.equal(api._.headers["Authorization"], "Basic dGVzdA==");
});
});
-143
View File
@@ -1,143 +0,0 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { createTestDb } from "../../helpers/sqlite.js";
import { createTestServer, type TestServer } from "../../helpers/koa.js";
import { hashPassword } from "../../../src/util/hash.js";
import type { SqliteInterface } from "../../../src/db/sqlite/SqliteInterface.js";
const EMAIL = "auth-http@test.example";
const PASSWORD = "auth-http-password";
function getCookie(res: Response, name: string): string | undefined {
const raw = res.headers.getSetCookie?.() ?? [];
for (const entry of raw) {
const [pair] = entry.split(";");
const [key, value] = pair.split("=");
if (key === name) return value;
}
return undefined;
}
describe("api backend: auth over HTTP", async () => {
let db: SqliteInterface;
let closeDb: () => void;
let server: TestServer;
let uid: string;
before(async () => {
({ db, close: closeDb } = await createTestDb());
server = await createTestServer(db);
const pw = await hashPassword(PASSWORD);
const user = await db.createUser({
email: EMAIL,
name: "Auth HTTP User",
password: pw,
flags: {},
});
uid = user.uid;
});
after(async () => {
await server.close();
closeDb();
});
it("POST /auth/login sets a session cookie", async () => {
const res = await fetch(`${server.url}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
});
assert.equal(res.status, 200);
const cookie = getCookie(res, "abode_session");
assert.ok(cookie, "session cookie set");
});
it("session cookie authenticates GET /auth/self", async () => {
const login = await fetch(`${server.url}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
});
const cookie = getCookie(login, "abode_session");
const self = await fetch(`${server.url}/auth/self`, {
headers: { Cookie: `abode_session=${cookie}` },
});
assert.equal(self.status, 200);
const body = (await self.json()) as { uid: string };
assert.equal(body.uid, uid);
});
it("GET /auth/self without credentials returns 401", async () => {
const res = await fetch(`${server.url}/auth/self`);
assert.equal(res.status, 401);
});
it("POST /auth/logout clears the cookie and invalidates the session server-side", async () => {
const login = await fetch(`${server.url}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
});
const cookie = getCookie(login, "abode_session");
const logout = await fetch(`${server.url}/auth/logout`, {
method: "POST",
headers: { Cookie: `abode_session=${cookie}` },
});
assert.equal(logout.status, 204);
assert.equal(getCookie(logout, "abode_session"), "");
const self = await fetch(`${server.url}/auth/self`, {
headers: { Cookie: `abode_session=${cookie}` },
});
assert.equal(
self.status,
401,
"session was invalidated server-side, not just the cookie cleared",
);
});
it("POST /auth/clear-sessions invalidates outstanding session cookies", async () => {
const login = await fetch(`${server.url}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
});
const cookie = getCookie(login, "abode_session");
const clear = await fetch(`${server.url}/auth/clear-sessions`, {
method: "POST",
headers: { Cookie: `abode_session=${cookie}` },
});
assert.equal(clear.status, 204);
const self = await fetch(`${server.url}/auth/self`, {
headers: { Cookie: `abode_session=${cookie}` },
});
assert.equal(self.status, 401);
});
it("Bearer apikey token authenticates protected routes", async () => {
const [, token] = await db.createApikey({
uid,
name: "HTTP Test Key",
permissions: { all: true },
});
const res = await fetch(`${server.url}/users`, {
headers: { Authorization: `Bearer ${token}` },
});
assert.equal(res.status, 200);
});
it("invalid Bearer apikey token returns 401 invalid_apikey", async () => {
const res = await fetch(`${server.url}/users`, {
headers: { Authorization: `Bearer at_${"0".repeat(32)}` },
});
assert.equal(res.status, 401);
const body = (await res.json()) as { error: string };
assert.equal(body.error, "invalid_apikey");
});
});
-54
View File
@@ -1,54 +0,0 @@
import { createTestDb } from "../../helpers/sqlite.js";
import { createTestServer } from "../../helpers/koa.js";
import { ApiInterface } from "../../../src/db/api/ApiInterface.js";
import { hashPassword } from "../../../src/util/hash.js";
import { runUserTests } from "../../shared/users.js";
import { runAbodeTests } from "../../shared/abodes.js";
import { runResidentTests } from "../../shared/residents.js";
import { runApikeyTests } from "../../shared/apikeys.js";
const AUTH_EMAIL = "api-auth@test.example";
const AUTH_PASSWORD = "api-auth-password";
async function getApiDb() {
const { db: sqliteDb, close: closeSqlite } = await createTestDb();
const pw = await hashPassword(AUTH_PASSWORD);
await sqliteDb.createUser({
email: AUTH_EMAIL,
name: "API Auth User",
password: pw,
// The shared backend contract suite exercises unrestricted user lookup,
// which the HTTP API now reserves for global administrators.
flags: { admin: true },
});
const server = await createTestServer(sqliteDb);
const authHeader = "Basic " + btoa(`${AUTH_EMAIL}:${AUTH_PASSWORD}`);
const api = new ApiInterface(server.url, {
headers: { Authorization: authHeader },
});
return {
db: api,
close: async () => {
await server.close();
closeSqlite();
},
};
}
async function getReadonlyApiDb() {
const { db: sqliteDb, close: closeSqlite } = await createTestDb();
const server = await createTestServer(sqliteDb);
const api = new ApiInterface(server.url, { readonly: true });
return {
db: api,
close: async () => {
await server.close();
closeSqlite();
},
};
}
runUserTests("api", getApiDb, getReadonlyApiDb);
runAbodeTests("api", getApiDb);
runResidentTests("api", getApiDb);
runApikeyTests("api", getApiDb);
@@ -1,328 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { Readable } from "node:stream";
import { PostgresInterface } from "../../../src/db/postgres/PostgresInterface.js";
import type { WrappedPgClient } from "../../../src/db/postgres/pool.js";
import type { SqlCode } from "../../../src/db/postgres/sql.js";
import {
EXPORT_KIND_ORDER,
type ExportKind,
} from "../../../src/db/types/ExportImport.js";
// The postgres backend has no CI database, so these tests drive the real
// PostgresInterface.export/import code paths against an in-memory fake client.
// They verify control flow (NDJSON shape, meta.source, filtering, note
// skipping, counts, insert dispatch, abort -> rollback); the SQL-arg forms are
// the same {uuid}/{text}/{jsonb}/{date} patterns the pg backend's own CRUD
// already exercises against real Postgres.
interface Rows {
users?: Record<string, unknown>[];
abodes?: Record<string, unknown>[];
residents?: Record<string, unknown>[];
apikeys?: Record<string, unknown>[];
notes?: Record<string, unknown>[];
}
class FakePg implements WrappedPgClient {
readonly = false;
inserts: { table: string; vars: unknown[] }[] = [];
committed = false;
rolledBack = false;
#rows: Rows;
constructor(rows: Rows = {}) {
this.#rows = rows;
}
async destroy(): Promise<void> {}
async all<R>(stmt: SqlCode): Promise<R[]> {
const s = stmt._sql;
if (s.includes('FROM "users"')) return (this.#rows.users ?? []) as R[];
if (s.includes('FROM "abodes"')) return (this.#rows.abodes ?? []) as R[];
if (s.includes('FROM "residents"'))
return (this.#rows.residents ?? []) as R[];
if (s.includes('FROM "apikeys"')) return (this.#rows.apikeys ?? []) as R[];
if (s.includes('FROM "notes"')) return (this.#rows.notes ?? []) as R[];
return [] as R[];
}
async get<R>(stmt: SqlCode): Promise<R | null> {
const rows = await this.all<R>(stmt);
return rows[0] ?? null;
}
async run(stmt: SqlCode): Promise<{ changes: number }> {
const table = stmt._sql.match(/INSERT INTO "(\w+)"/)?.[1] ?? "?";
this.inserts.push({ table, vars: stmt._vars });
return { changes: 1 };
}
async multi<R>(fn: (tx: WrappedPgClient) => Promise<R>): Promise<R> {
try {
const r = await fn(this);
this.committed = true;
return r;
} catch (e) {
this.rolledBack = true;
throw e;
}
}
async rethrow<R>(fn: () => Promise<R>): Promise<R> {
return fn();
}
}
const iso = "2026-01-02T03:04:05.000Z";
function seededRows(): Rows {
return {
users: [
{
uid: "11111111-1111-1111-1111-111111111111",
email: "u1@test.example",
name: "User One",
flags: {},
created_at: new Date(iso),
updated_at: new Date(iso),
},
],
abodes: [
{
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1",
name: "Abode One",
created_at: new Date(iso),
created_by: "11111111-1111-1111-1111-111111111111",
updated_at: new Date(iso),
updated_by: null,
},
{
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2",
name: "Abode Two",
created_at: new Date(iso),
created_by: null,
updated_at: new Date(iso),
updated_by: null,
},
],
residents: [
{
uid: "11111111-1111-1111-1111-111111111111",
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1",
flags: {},
created_at: new Date(iso),
created_by: null,
updated_at: new Date(iso),
updated_by: null,
},
],
apikeys: [
{
uid: "11111111-1111-1111-1111-111111111111",
kid: "kkkkkkkk-kkkk-kkkk-kkkk-kkkkkkkkkkk1",
name: "key one",
permissions: {},
created_at: new Date(iso),
expires_at: null,
},
],
notes: [
{
nid: "nnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnn1",
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1",
name: "Note one",
content: "# Content",
properties: { type: "note" },
created_at: new Date(iso),
created_by: "11111111-1111-1111-1111-111111111111",
updated_at: new Date(iso),
updated_by: null,
},
],
};
}
async function streamToString(s: NodeJS.ReadableStream): Promise<string> {
let out = "";
for await (const chunk of s) out += chunk;
return out;
}
function parseLines(ndjson: string): { kind: string; data: any }[] {
return ndjson
.split("\n")
.filter(Boolean)
.map((l) => JSON.parse(l));
}
function line(kind: string, data: unknown): string {
return JSON.stringify({ kind, data }) + "\n";
}
describe("postgres export", () => {
it("streams meta and all supported kinds, including notes", async () => {
const db = new PostgresInterface(new FakePg(seededRows()));
const lines = parseLines(await streamToString(db.export()));
const meta = lines.find((l) => l.kind === "meta");
assert.ok(meta);
assert.equal(meta!.data.source, "postgres");
assert.equal(meta!.data.v, 1);
const kinds = new Set(lines.map((l) => l.kind));
assert.ok(kinds.has("user"));
assert.ok(kinds.has("abode"));
assert.ok(kinds.has("resident"));
assert.ok(kinds.has("apikey"));
assert.ok(kinds.has("note"));
});
it("emits record kinds grouped in FK-safe EXPORT_KIND_ORDER", async () => {
const db = new PostgresInterface(new FakePg(seededRows()));
const lines = parseLines(await streamToString(db.export()));
assert.equal(lines[0]?.kind, "meta", "first line is meta");
const rank = (k: string) => EXPORT_KIND_ORDER.indexOf(k as ExportKind);
let last = -1;
for (const { kind } of lines.slice(1)) {
const r = rank(kind);
assert.notEqual(r, -1, `unexpected kind ${kind}`);
assert.ok(r >= last, `kind ${kind} out of FK-safe order`);
last = r;
}
});
it("applies the kinds filter", async () => {
const db = new PostgresInterface(new FakePg(seededRows()));
const lines = parseLines(
await streamToString(db.export({ filter: { kinds: ["abode"] } })),
);
const kinds = new Set(
lines.filter((l) => l.kind !== "meta").map((l) => l.kind),
);
assert.deepEqual(kinds, new Set(["abode"]));
});
it("applies the abodes allowlist to abode/resident records", async () => {
const db = new PostgresInterface(new FakePg(seededRows()));
const lines = parseLines(
await streamToString(
db.export({
filter: { abodes: ["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"] },
}),
),
);
const abodeAids = lines
.filter((l) => l.kind === "abode")
.map((l) => l.data.aid);
assert.deepEqual(abodeAids, ["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"]);
});
});
describe("postgres import", () => {
it("dispatches inserts per kind, including note, and commits", async () => {
const fake = new FakePg();
const db = new PostgresInterface(fake);
const source = Readable.from([
line("meta", { v: 1 }),
line("user", {
uid: "11111111-1111-1111-1111-111111111111",
email: "u1@test.example",
name: "User One",
flags: {},
created_at: iso,
updated_at: iso,
}),
line("abode", {
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1",
name: "Abode One",
created_at: iso,
created_by: "11111111-1111-1111-1111-111111111111",
updated_at: iso,
updated_by: null,
}),
line("apikey", {
uid: "11111111-1111-1111-1111-111111111111",
kid: "kkkkkkkk-kkkk-kkkk-kkkk-kkkkkkkkkkk1",
name: "key one",
permissions: {},
created_at: iso,
expires_at: null,
}),
line("note", {
nid: "nnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnn1",
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1",
name: "Note",
content: "x",
properties: {},
created_at: iso,
created_by: null,
updated_at: iso,
updated_by: null,
}),
]);
const result = await db.import(source);
assert.deepEqual(result.counts, { user: 1, abode: 1, apikey: 1, note: 1 });
assert.ok(fake.committed);
assert.deepEqual(fake.inserts.map((i) => i.table).sort(), [
"abodes",
"apikeys",
"notes",
"users",
]);
// apikey gets a freshly-minted token (never exported)
const apikeyInsert = fake.inserts.find((i) => i.table === "apikeys")!;
assert.ok(
apikeyInsert.vars.some(
(v) => typeof v === "string" && v.startsWith("at_"),
),
"apikey insert carries a fresh token",
);
});
it("rejects and rolls back when the stream carries an error sentinel", async () => {
const fake = new FakePg();
const db = new PostgresInterface(fake);
const source = Readable.from([
line("meta", { v: 1 }),
line("user", {
uid: "11111111-1111-1111-1111-111111111111",
email: "u1@test.example",
name: "User One",
flags: {},
created_at: iso,
updated_at: iso,
}),
line("error", { message: "boom" }),
]);
await assert.rejects(() => db.import(source), /boom/);
assert.ok(fake.rolledBack);
assert.ok(!fake.committed);
});
it("rejects and rolls back on signal abort", async () => {
const fake = new FakePg();
const db = new PostgresInterface(fake);
const ac = new AbortController();
const source = Readable.from(
(async function* () {
yield line("meta", { v: 1 });
yield line("user", {
uid: "11111111-1111-1111-1111-111111111111",
email: "u1@test.example",
name: "User One",
flags: {},
created_at: iso,
updated_at: iso,
});
ac.abort();
await new Promise((r) => setTimeout(r, 1000));
})(),
);
await assert.rejects(() => db.import(source, { signal: ac.signal }));
assert.ok(fake.rolledBack);
assert.ok(!fake.committed);
});
});
-38
View File
@@ -1,38 +0,0 @@
import type { BackendDbInterface } from "../../../src/db/types/DbInterface.js";
import { SqliteInterface } from "../../../src/db/sqlite/SqliteInterface.js";
import { createTestDb, createReadonlyTestDb } from "../../helpers/sqlite.js";
import { runUserTests } from "../../shared/users.js";
import { runAbodeTests } from "../../shared/abodes.js";
import { runResidentTests } from "../../shared/residents.js";
import { runApikeyTests } from "../../shared/apikeys.js";
import { runSessionTests } from "../../shared/sessions.js";
import { runAuthTests } from "../../shared/auth.js";
async function createExpiredApikey(
db: BackendDbInterface,
uid: string,
): Promise<`at_${string}`> {
const si = db as SqliteInterface;
const token = `at_${"e".repeat(32)}` as `at_${string}`;
const kid = crypto.randomUUID();
const { sql } = si._;
si._.db.run(sql`
INSERT INTO "apikeys"("uid", "kid", "token", "name", "permissions", "expires_at")
VALUES(
${{ uuid: uid }},
${{ uuid: kid }},
${{ text: token }},
${{ text: "Expired Key" }},
${{ jsonb: {} }},
${{ date: new Date(Date.now() - 10000).toISOString() }}
)
`);
return token;
}
runUserTests("sqlite", createTestDb, createReadonlyTestDb);
runAbodeTests("sqlite", createTestDb);
runResidentTests("sqlite", createTestDb);
runApikeyTests("sqlite", createTestDb);
runSessionTests("sqlite", createTestDb);
runAuthTests("sqlite", createTestDb, createExpiredApikey);
-85
View File
@@ -1,85 +0,0 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { WrappedNodeSqliteDb } from "../../../src/db/sqlite/impl/node-sqlite.js";
import { SqliteMigrator } from "../../../src/db/sqlite/SqliteMigrator.js";
import { migrations } from "../../../src/db/sqlite/migrations/index.js";
describe("SqliteMigrator", () => {
it("listAvailableMigrations returns all known migrations", () => {
const db = new WrappedNodeSqliteDb(":memory:");
const migrator = new SqliteMigrator(db);
const available = migrator.listAvailableMigrations();
assert.ok(Array.isArray(available));
assert.ok(available.length >= 3, "at least 3 migrations");
const ids = available.map((m) => m.id);
assert.ok(ids.includes(1), "migration 1 present");
assert.ok(ids.includes(2), "migration 2 present");
assert.ok(ids.includes(3), "migration 3 present");
db.destroy();
});
it("listAppliedMigrations returns empty array on fresh db", async () => {
const db = new WrappedNodeSqliteDb(":memory:");
const migrator = new SqliteMigrator(db);
const applied = await migrator.listAppliedMigrations();
assert.deepEqual(applied, []);
db.destroy();
});
it("migrateTo(1) applies first migration", async () => {
const db = new WrappedNodeSqliteDb(":memory:");
const migrator = new SqliteMigrator(db);
await migrator.migrateTo(1);
const applied = await migrator.listAppliedMigrations();
assert.equal(applied.length, 1);
assert.equal(applied[0].id, 1);
assert.ok(applied[0].name, "migration has a name");
assert.ok(applied[0].applied_at, "migration has applied_at");
db.destroy();
});
it("migrateTo(3) applies all three migrations in order", async () => {
const db = new WrappedNodeSqliteDb(":memory:");
const migrator = new SqliteMigrator(db);
await migrator.migrateTo(3);
const applied = await migrator.listAppliedMigrations();
assert.equal(applied.length, 3);
assert.deepEqual(
applied.map((m) => m.id),
[1, 2, 3],
);
db.destroy();
});
it("migrateTo(3) twice is idempotent (nothing to do)", async () => {
const db = new WrappedNodeSqliteDb(":memory:");
const migrator = new SqliteMigrator(db);
await migrator.migrateTo(3);
await migrator.migrateTo(3);
const applied = await migrator.listAppliedMigrations();
assert.equal(applied.length, 3);
db.destroy();
});
it("migrateTo with unknown id throws", async () => {
const db = new WrappedNodeSqliteDb(":memory:");
const migrator = new SqliteMigrator(db);
await assert.rejects(
() => migrator.migrateTo(9999),
/No known migration with id 9999/,
);
db.destroy();
});
it("listAvailableMigrations names match migration objects", () => {
const db = new WrappedNodeSqliteDb(":memory:");
const migrator = new SqliteMigrator(db);
const available = migrator.listAvailableMigrations();
for (const { id, name } of available) {
const migration = migrations.find((m) => m.id === id);
assert.ok(migration, `migration ${id} exists`);
assert.equal(migration.name, name);
}
db.destroy();
});
});
-140
View File
@@ -1,140 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
sql,
catSql,
joinSql,
calcUpdates,
unsafeSql,
} from "../../../src/db/sqlite/sql.js";
describe("sql template tag", () => {
it("produces correct sql and empty vars for plain text", () => {
const result = sql`SELECT 1`;
assert.equal(result._sql, "SELECT 1");
assert.deepEqual(result._vars, []);
});
it("binds text args with ?", () => {
const result = sql`WHERE name = ${{ text: "alice" }}`;
assert.equal(result._sql, "WHERE name = ?");
assert.deepEqual(result._vars, ["alice"]);
});
it("binds uuid args with ? and converts to Buffer", () => {
const uuid = "12345678-1234-1234-1234-123456789abc";
const result = sql`WHERE uid = ${{ uuid }}`;
assert.equal(result._sql, "WHERE uid = ?");
assert.equal(result._vars.length, 1);
assert.ok(result._vars[0] instanceof Buffer, "uuid is stored as Buffer");
});
it("binds jsonb args with jsonb(?) wrapper", () => {
const result = sql`SET flags = ${{ jsonb: { admin: true } }}`;
assert.equal(result._sql, "SET flags = jsonb(?)");
assert.deepEqual(result._vars, [JSON.stringify({ admin: true })]);
});
it("binds date args with datetime(?, ...) wrapper", () => {
const date = "2024-01-01T00:00:00.000Z";
const result = sql`SET ts = ${{ date }}`;
assert.ok(result._sql.startsWith("SET ts = datetime("), result._sql);
assert.equal(result._vars.length, 1);
});
it("binds int args with ?", () => {
const result = sql`LIMIT ${{ int: 10 }}`;
assert.equal(result._sql, "LIMIT ?");
assert.deepEqual(result._vars, [10]);
});
it("throws for non-integer int value", () => {
assert.throws(() => sql`LIMIT ${{ int: 10.5 }}`, /Not an integer/);
});
it("emits NULL for null args", () => {
const result = sql`= ${{ null: true }}`;
assert.equal(result._sql, "= NULL");
assert.deepEqual(result._vars, []);
});
it("splices nested SqlCode", () => {
const inner = sql`AND x = ${{ text: "foo" }}`;
const outer = sql`WHERE 1=1 ${inner}`;
assert.equal(outer._sql, "WHERE 1=1 AND x = ?");
assert.deepEqual(outer._vars, ["foo"]);
});
it("handles multiple args", () => {
const result = sql`INSERT INTO t(a,b) VALUES(${{ text: "x" }}, ${{ int: 42 }})`;
assert.equal(result._sql, "INSERT INTO t(a,b) VALUES(?, ?)");
assert.deepEqual(result._vars, ["x", 42]);
});
});
describe("catSql", () => {
it("concatenates sql and vars", () => {
const a = sql`SELECT * FROM t`;
const b = sql` WHERE x = ${{ text: "y" }}`;
const result = catSql(a, b);
assert.equal(result._sql, "SELECT * FROM t WHERE x = ?");
assert.deepEqual(result._vars, ["y"]);
});
});
describe("joinSql", () => {
it("joins multiple parts with separator", () => {
const parts = [
sql`a = ${{ text: "1" }}`,
sql`b = ${{ text: "2" }}`,
sql`c = ${{ text: "3" }}`,
];
const result = joinSql(parts, sql`, `);
assert.equal(result._sql, "a = ?, b = ?, c = ?");
assert.deepEqual(result._vars, ["1", "2", "3"]);
});
it("returns single part unchanged (no separator)", () => {
const result = joinSql([sql`x = ${{ int: 1 }}`], sql`, `);
assert.equal(result._sql, "x = ?");
assert.deepEqual(result._vars, [1]);
});
});
describe("calcUpdates", () => {
it("returns only keys present in the object", () => {
const calc = calcUpdates({
name: (v: string) => sql`name = ${{ text: v }}`,
email: (v: string) => sql`email = ${{ text: v }}`,
});
const updates = calc({ name: "alice" });
assert.equal(updates.length, 1);
assert.equal(updates[0]._sql, "name = ?");
assert.deepEqual(updates[0]._vars, ["alice"]);
});
it("returns all keys when all are present", () => {
const calc = calcUpdates({
a: (v: string) => sql`a = ${{ text: v }}`,
b: (v: string) => sql`b = ${{ text: v }}`,
});
const updates = calc({ a: "x", b: "y" });
assert.equal(updates.length, 2);
});
it("returns empty array when no keys match", () => {
const calc = calcUpdates({
name: (v: string) => sql`name = ${{ text: v }}`,
});
const updates = calc({});
assert.equal(updates.length, 0);
});
});
describe("unsafeSql", () => {
it("wraps a raw sql string with no vars", () => {
const result = unsafeSql("CREATE TABLE t (id INTEGER)");
assert.equal(result._sql, "CREATE TABLE t (id INTEGER)");
assert.deepEqual(result._vars, []);
});
});
-123
View File
@@ -1,123 +0,0 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { WrappedNodeSqliteDb } from "../../../src/db/sqlite/impl/node-sqlite.js";
import { sql, unsafeSql } from "../../../src/db/sqlite/sql.js";
import type { WrappedDb } from "../../../src/db/sqlite/impl/types.js";
function runWrappedDbSuite(name: string, makeDb: () => WrappedDb) {
describe(`${name}: WrappedDb`, () => {
let db: WrappedDb;
before(() => {
db = makeDb();
db.run(
unsafeSql(
"CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY, val TEXT NOT NULL)",
),
);
});
after(() => db.destroy());
it("run INSERT returns changes count", () => {
const { changes } = db.run(
sql`INSERT INTO test(val) VALUES(${{ text: "hello" }})`,
);
assert.equal(changes, 1);
});
it("all SELECT returns all rows", () => {
db.run(unsafeSql("DELETE FROM test"));
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "a" }})`);
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "b" }})`);
const rows = db.all<{ val: string }>(
unsafeSql("SELECT val FROM test ORDER BY val"),
);
assert.equal(rows.length, 2);
assert.equal(rows[0].val, "a");
assert.equal(rows[1].val, "b");
});
it("get returns single row or null", () => {
db.run(unsafeSql("DELETE FROM test"));
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "one" }})`);
const row = db.get<{ val: string }>(unsafeSql("SELECT val FROM test"));
assert.ok(row !== null);
assert.equal(row.val, "one");
const none = db.get<{ val: string }>(
sql`SELECT val FROM test WHERE val = ${{ text: "none" }}`,
);
assert.equal(none, null);
});
it("get throws when multiple rows match", () => {
db.run(unsafeSql("DELETE FROM test"));
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "dup1" }})`);
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "dup2" }})`);
assert.throws(
() => db.get<{ val: string }>(unsafeSql("SELECT val FROM test")),
/Multiple results/,
);
});
it("multi commits on success", () => {
db.run(unsafeSql("DELETE FROM test"));
db.multi(() => {
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "in-tx" }})`);
});
const rows = db.all<{ val: string }>(unsafeSql("SELECT val FROM test"));
assert.equal(rows.length, 1);
assert.equal(rows[0].val, "in-tx");
});
it("multi rolls back on error", () => {
db.run(unsafeSql("DELETE FROM test"));
assert.throws(() =>
db.multi(() => {
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "rollback" }})`);
throw new Error("abort!");
}),
);
const rows = db.all<{ val: string }>(unsafeSql("SELECT val FROM test"));
assert.equal(rows.length, 0);
});
it("rethrow passes through return value", () => {
const result = db.rethrow(() => 42);
assert.equal(result, 42);
});
it("rethrow propagates non-SQLite errors unchanged", () => {
const err = new Error("custom error");
assert.throws(
() =>
db.rethrow(() => {
throw err;
}),
(e) => e === err,
);
});
});
}
runWrappedDbSuite("node-sqlite", () => new WrappedNodeSqliteDb(":memory:"));
describe("better-sqlite3 WrappedDb", async () => {
let bs3Ctor: (new (path: string) => WrappedDb) | null = null;
try {
const mod = await import("../../../src/db/sqlite/impl/better-sqlite3.js");
bs3Ctor = mod.WrappedBetterSqlite3Db;
} catch {
// better-sqlite3 not available, skip
}
if (bs3Ctor) {
runWrappedDbSuite("better-sqlite3", () => new bs3Ctor!(":memory:"));
} else {
it("better-sqlite3 is not available - skipped", (t) => {
t.skip("better-sqlite3 optional dependency not found");
});
}
});
-29
View File
@@ -1,29 +0,0 @@
import Koa from "koa";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { apirouter } from "../../src/webapi/apirouter.js";
import type { BackendDbInterface } from "../../src/db/types/DbInterface.js";
export interface TestServer {
url: string;
close(): Promise<void>;
}
export async function createTestServer(
db: BackendDbInterface,
): Promise<TestServer> {
const app = new Koa();
const router = apirouter(db);
app.use(router.routes());
app.use(router.allowedMethods());
const server = createServer(app.callback());
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as AddressInfo;
return {
url: `http://127.0.0.1:${port}`,
close: () =>
new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())),
),
};
}
-24
View File
@@ -1,24 +0,0 @@
import { getWrappedDb } from "../../src/db/sqlite/impl/index.js";
import { SqliteMigrator } from "../../src/db/sqlite/SqliteMigrator.js";
import { SqliteInterface } from "../../src/db/sqlite/SqliteInterface.js";
import type { WrappedDb } from "../../src/db/sqlite/impl/types.js";
export interface TestDb {
db: SqliteInterface;
wrapped: WrappedDb;
close(): void;
}
export async function createTestDb(): Promise<TestDb> {
const wrapped = getWrappedDb("node", ":memory:", {});
const migrator = new SqliteMigrator(wrapped);
await migrator.migrateTo(3);
const db = new SqliteInterface(wrapped);
return { db, wrapped, close: () => wrapped.destroy() };
}
export async function createReadonlyTestDb(): Promise<TestDb> {
const wrapped = getWrappedDb("node", ":memory:", { readonly: true });
const db = new SqliteInterface(wrapped);
return { db, wrapped, close: () => wrapped.destroy() };
}
-124
View File
@@ -1,124 +0,0 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import type { DbInterface } from "../../src/db/types/DbInterface.js";
import {
NotFoundAbodeError,
InvalidAbodeError,
} from "../../src/db/types/errors.js";
import { hashPassword } from "../../src/util/hash.js";
export function runAbodeTests(
name: string,
getDb: () => Promise<{ db: DbInterface; close(): void }>,
): void {
describe(`${name}: abodes`, async () => {
let db: DbInterface;
let close: () => void;
let ctxUid: string;
before(async () => {
({ db, close } = await getDb());
const pw = await hashPassword("abode-ctx");
const user = await db.createUser({
email: `abode-ctx-${Date.now()}@test.example`,
name: "Abode Ctx User",
password: pw,
flags: {},
});
ctxUid = user.uid;
});
after(() => close());
it("createAbode returns an Abode with expected fields", async () => {
const abode = await db.createAbode(
{ name: "Test Abode" },
{ uid: ctxUid },
);
assert.ok(abode.aid, "has aid");
assert.equal(abode.name, "Test Abode");
assert.ok(abode.created_at);
assert.ok(abode.updated_at);
});
it("getAbodeById returns the created abode", async () => {
const created = await db.createAbode(
{ name: "ById Abode" },
{ uid: ctxUid },
);
const found = await db.getAbodeById(created.aid);
assert.equal(found.aid, created.aid);
assert.equal(found.name, "ById Abode");
});
it("getAbodeById throws NotFoundAbodeError for unknown aid", async () => {
await assert.rejects(
() => db.getAbodeById("00000000-0000-0000-0000-000000000000"),
(err) => {
assert.ok(err instanceof NotFoundAbodeError);
return true;
},
);
});
it("listAbodes includes the created abode", async () => {
const created = await db.createAbode(
{ name: `Listed Abode ${Date.now()}` },
{ uid: ctxUid },
);
const abodes = await db.listAbodes();
assert.ok(Array.isArray(abodes));
const found = abodes.find((a) => a.aid === created.aid);
assert.ok(found, "created abode appears in listAbodes");
});
it("updateAbode updates the name", async () => {
const created = await db.createAbode({ name: "Before" }, { uid: ctxUid });
const updated = await db.updateAbode(
{ aid: created.aid, name: "After" },
{ uid: ctxUid },
);
assert.equal(updated.aid, created.aid);
assert.equal(updated.name, "After");
});
it("updateAbode with no fields throws InvalidAbodeError", async () => {
const created = await db.createAbode(
{ name: "No Update" },
{ uid: ctxUid },
);
await assert.rejects(
() => db.updateAbode({ aid: created.aid }, { uid: ctxUid }),
(err) => {
assert.ok(err instanceof InvalidAbodeError);
return true;
},
);
});
it("deleteAbodeById removes the abode", async () => {
const created = await db.createAbode(
{ name: "To Delete" },
{ uid: ctxUid },
);
await db.deleteAbodeById(created.aid);
await assert.rejects(
() => db.getAbodeById(created.aid),
(err) => {
assert.ok(err instanceof NotFoundAbodeError);
return true;
},
);
});
it("deleteAbodeById throws NotFoundAbodeError for unknown aid", async () => {
await assert.rejects(
() => db.deleteAbodeById("00000000-0000-0000-0000-000000000000"),
(err) => {
assert.ok(err instanceof NotFoundAbodeError);
return true;
},
);
});
});
}
-102
View File
@@ -1,102 +0,0 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import type { DbInterface } from "../../src/db/types/DbInterface.js";
import { NotFoundAbodeError } from "../../src/db/types/errors.js";
import { hashPassword } from "../../src/util/hash.js";
export function runApikeyTests(
name: string,
getDb: () => Promise<{ db: DbInterface; close(): void }>,
): void {
describe(`${name}: apikeys`, async () => {
let db: DbInterface;
let close: () => void;
let uid: string;
before(async () => {
({ db, close } = await getDb());
const pw = await hashPassword("apikey-password");
const user = await db.createUser({
email: `apikey-user-${Date.now()}@test.example`,
name: "Apikey User",
password: pw,
flags: {},
});
uid = user.uid;
});
after(() => close());
it("createApikey returns [ClientApikey, at_token]", async () => {
const [apikey, token] = await db.createApikey({
uid,
name: "Test Key",
permissions: {},
});
assert.ok(apikey.kid, "has kid");
assert.equal(apikey.uid, uid);
assert.equal(apikey.name, "Test Key");
assert.ok(!("token" in apikey), "ClientApikey has no token field");
assert.ok(token.startsWith("at_"), `token starts with at_: ${token}`);
});
it("listApikeysByUser returns created key", async () => {
const [created] = await db.createApikey({
uid,
name: "List Key",
permissions: {},
});
const keys = await db.listApikeysByUser(uid);
assert.ok(Array.isArray(keys));
const found = keys.find((k) => k.kid === created.kid);
assert.ok(found, "created key appears in listApikeysByUser");
});
it("getApikeyById returns the key", async () => {
const [created] = await db.createApikey({
uid,
name: "GetById Key",
permissions: {},
});
const found = await db.getApikeyById(created.kid);
assert.equal(found.kid, created.kid);
assert.equal(found.name, "GetById Key");
});
it("getApikeyById throws NotFoundAbodeError for unknown kid", async () => {
await assert.rejects(
() => db.getApikeyById("00000000-0000-0000-0000-000000000000"),
(err) => {
assert.ok(err instanceof NotFoundAbodeError);
return true;
},
);
});
it("deleteApikeyById removes the key", async () => {
const [created] = await db.createApikey({
uid,
name: "Delete Key",
permissions: {},
});
await db.deleteApikeyById(created.kid);
await assert.rejects(
() => db.getApikeyById(created.kid),
(err) => {
assert.ok(err instanceof NotFoundAbodeError);
return true;
},
);
});
it("deleteApikeyById throws NotFoundAbodeError for unknown kid", async () => {
await assert.rejects(
() => db.deleteApikeyById("00000000-0000-0000-0000-000000000000"),
(err) => {
assert.ok(err instanceof NotFoundAbodeError);
return true;
},
);
});
});
}
-144
View File
@@ -1,144 +0,0 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import type { BackendDbInterface } from "../../src/db/types/DbInterface.js";
import {
NotFoundAbodeError,
NotAuthorizedAbodeError,
ConflictAbodeError,
} from "../../src/db/types/errors.js";
import { hashPassword } from "../../src/util/hash.js";
export function runAuthTests(
name: string,
getDb: () => Promise<{ db: BackendDbInterface; close(): void }>,
createExpiredApikey?: (
db: BackendDbInterface,
uid: string,
) => Promise<`at_${string}`>,
): void {
describe(`${name}: auth`, async () => {
let db: BackendDbInterface;
let close: () => void;
let email: string;
const password = "auth-test-password";
before(async () => {
({ db, close } = await getDb());
email = `auth-user-${Date.now()}@test.example`;
const pw = await hashPassword(password);
await db.createUser({
email,
name: "Auth User",
password: pw,
flags: {},
});
});
after(() => close());
it("getUserByLogin returns user on correct credentials", async () => {
const user = await db.getUserByLogin({ email, password });
assert.equal(user.email, email);
assert.ok(!("password" in user));
});
it("getUserByLogin throws NotAuthorizedAbodeError for wrong password", async () => {
await assert.rejects(
() => db.getUserByLogin({ email, password: "wrong-password" }),
(err) => {
assert.ok(err instanceof NotAuthorizedAbodeError);
return true;
},
);
});
it("getUserByLogin throws NotFoundAbodeError for unknown email", async () => {
await assert.rejects(
() =>
db.getUserByLogin({
email: "nobody@nowhere.example",
password: "any",
}),
(err) => {
assert.ok(err instanceof NotFoundAbodeError);
return true;
},
);
});
it("getUserByLogin throws ConflictAbodeError for #unset password", async () => {
const unsetEmail = `unset-${Date.now()}@test.example`;
await db.createUser({
email: unsetEmail,
name: "Unset User",
password: "#unset",
flags: {},
});
await assert.rejects(
() => db.getUserByLogin({ email: unsetEmail, password: "any" }),
(err) => {
assert.ok(err instanceof ConflictAbodeError);
return true;
},
);
});
});
describe(`${name}: auth apikey`, async () => {
let db: BackendDbInterface;
let close: () => void;
let uid: string;
before(async () => {
({ db, close } = await getDb());
const pw = await hashPassword("apikey-auth-password");
const user = await db.createUser({
email: `apikey-auth-${Date.now()}@test.example`,
name: "Apikey Auth User",
password: pw,
flags: {},
});
uid = user.uid;
});
after(() => close());
it("getUserByApikey returns [user, apikey] for valid token", async () => {
const [, token] = await db.createApikey({
uid,
name: "Auth Key",
permissions: {},
});
const [user, apikey] = await db.getUserByApikey(token);
assert.equal(user.uid, uid);
assert.ok(apikey.kid);
assert.equal(apikey.uid, uid);
});
it("getUserByApikey throws NotAuthorizedAbodeError for expired key", async (t) => {
if (!createExpiredApikey) {
t.skip("createExpiredApikey helper not provided for this backend");
return;
}
const token = await createExpiredApikey(db, uid);
await assert.rejects(
() => db.getUserByApikey(token),
(err) => {
assert.ok(err instanceof NotAuthorizedAbodeError);
return true;
},
);
});
it("getUserByApikey throws NotFoundAbodeError for unknown token", async () => {
const fakeToken = `at_${"0".repeat(32)}` as `at_${string}`;
await assert.rejects(
() => db.getUserByApikey(fakeToken),
(err) => {
assert.ok(err instanceof NotFoundAbodeError);
return true;
},
);
});
});
}
-163
View File
@@ -1,163 +0,0 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import type { DbInterface } from "../../src/db/types/DbInterface.js";
import {
NotFoundAbodeError,
InvalidAbodeError,
} from "../../src/db/types/errors.js";
import { hashPassword } from "../../src/util/hash.js";
export function runResidentTests(
name: string,
getDb: () => Promise<{ db: DbInterface; close(): void }>,
): void {
describe(`${name}: residents`, async () => {
let db: DbInterface;
let close: () => void;
let uid: string;
let aid: string;
let ctxUid: string;
before(async () => {
({ db, close } = await getDb());
const pw = await hashPassword("resident-pw");
// ctx user (creator of abode)
const ctx = await db.createUser({
email: `res-ctx-${Date.now()}@test.example`,
name: "Resident Ctx",
password: pw,
flags: {},
});
ctxUid = ctx.uid;
// the resident user
const resUser = await db.createUser({
email: `resident-${Date.now()}@test.example`,
name: "Resident User",
password: pw,
flags: {},
});
uid = resUser.uid;
const abode = await db.createAbode(
{ name: `Resident Abode ${Date.now()}` },
{ uid: ctxUid },
);
aid = abode.aid;
await db.createResident({ uid, aid, flags: {} }, { uid: ctxUid });
});
after(() => close());
it("getResidentById returns the created resident", async () => {
const found = await db.getResidentById(uid, aid);
assert.equal(found.uid, uid);
assert.equal(found.aid, aid);
assert.ok(found.created_at);
});
it("getResidentById throws NotFoundAbodeError for unknown pair", async () => {
await assert.rejects(
() =>
db.getResidentById(
"00000000-0000-0000-0000-000000000000",
"00000000-0000-0000-0000-000000000001",
),
(err) => {
assert.ok(err instanceof NotFoundAbodeError);
return true;
},
);
});
it("listResidents includes the created resident", async () => {
const residents = await db.listResidents();
assert.ok(Array.isArray(residents));
const found = residents.find((r) => r.uid === uid && r.aid === aid);
assert.ok(found, "created resident appears in listResidents");
});
it("listResidentsByUserId filters by uid", async () => {
const results = await db.listResidentsByUserId(uid);
assert.ok(results.every((r) => r.uid === uid));
assert.ok(results.some((r) => r.aid === aid));
});
it("listResidentsByAbodeId filters by aid", async () => {
const results = await db.listResidentsByAbodeId(aid);
assert.ok(results.every((r) => r.aid === aid));
assert.ok(results.some((r) => r.uid === uid));
});
it("listUsersByAbodeId returns users in the abode", async () => {
const users = await db.listUsersByAbodeId(aid);
assert.ok(Array.isArray(users));
const found = users.find((u) => u.uid === uid);
assert.ok(found, "resident user appears in listUsersByAbodeId");
});
it("listAbodesByUserId returns abodes for user", async () => {
const abodes = await db.listAbodesByUserId(uid);
assert.ok(Array.isArray(abodes));
const found = abodes.find((a) => a.aid === aid);
assert.ok(found, "abode appears in listAbodesByUserId");
});
it("updateResident updates flags", async () => {
const updated = await db.updateResident(
{ uid, aid, flags: { admin: true } },
{ uid: ctxUid },
);
assert.equal(updated.uid, uid);
assert.deepEqual(updated.flags, { admin: true });
});
it("updateResident with no fields throws InvalidAbodeError", async () => {
await assert.rejects(
() => db.updateResident({ uid, aid }, { uid: ctxUid }),
(err) => {
assert.ok(err instanceof InvalidAbodeError);
return true;
},
);
});
it("deleteResidentById removes the resident then throws on re-fetch", async () => {
const pw = await hashPassword("del-res-pw");
const user2 = await db.createUser({
email: `del-res-${Date.now()}@test.example`,
name: "Del Res User",
password: pw,
flags: {},
});
const abode2 = await db.createAbode(
{ name: "Del Abode" },
{ uid: ctxUid },
);
await db.createResident(
{ uid: user2.uid, aid: abode2.aid, flags: {} },
{ uid: ctxUid },
);
await db.deleteResidentById(user2.uid, abode2.aid);
await assert.rejects(
() => db.getResidentById(user2.uid, abode2.aid),
(err) => {
assert.ok(err instanceof NotFoundAbodeError);
return true;
},
);
});
it("deleteResidentById throws NotFoundAbodeError for unknown pair", async () => {
await assert.rejects(
() =>
db.deleteResidentById(
"00000000-0000-0000-0000-000000000002",
"00000000-0000-0000-0000-000000000003",
),
(err) => {
assert.ok(err instanceof NotFoundAbodeError);
return true;
},
);
});
});
}
-65
View File
@@ -1,65 +0,0 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import type { BackendDbInterface } from "../../src/db/types/DbInterface.js";
import { NotFoundAbodeError } from "../../src/db/types/errors.js";
import { hashPassword } from "../../src/util/hash.js";
export function runSessionTests(
name: string,
getDb: () => Promise<{ db: BackendDbInterface; close(): void }>,
): void {
describe(`${name}: sessions`, async () => {
let db: BackendDbInterface;
let close: () => void;
let uid: string;
before(async () => {
({ db, close } = await getDb());
const pw = await hashPassword("session-password");
const user = await db.createUser({
email: `session-user-${Date.now()}@test.example`,
name: "Session User",
password: pw,
flags: {},
});
uid = user.uid;
});
after(() => close());
it("createSession returns an as_ token", async () => {
const token = await db.createSession(uid);
assert.ok(token.startsWith("as_"), `token starts with as_: ${token}`);
assert.equal(token.length, 35, "as_ + 32 hex chars");
});
it("getUserBySession returns the correct user", async () => {
const token = await db.createSession(uid);
const user = await db.getUserBySession(token);
assert.equal(user.uid, uid);
});
it("getUserBySession throws NotFoundAbodeError for unknown token", async () => {
const fakeToken = `as_${"0".repeat(32)}` as `as_${string}`;
await assert.rejects(
() => db.getUserBySession(fakeToken),
(err) => {
assert.ok(err instanceof NotFoundAbodeError);
return true;
},
);
});
it("deleteSessionsByUser invalidates all sessions for user", async () => {
const token = await db.createSession(uid);
await db.deleteSessionsByUser(uid);
await assert.rejects(
() => db.getUserBySession(token),
(err) => {
assert.ok(err instanceof NotFoundAbodeError);
return true;
},
);
});
});
}
-193
View File
@@ -1,193 +0,0 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import type { DbInterface } from "../../src/db/types/DbInterface.js";
import {
NotFoundAbodeError,
InvalidAbodeError,
ReadonlyAbodeError,
} from "../../src/db/types/errors.js";
import { hashPassword } from "../../src/util/hash.js";
export function runUserTests(
name: string,
getDb: () => Promise<{ db: DbInterface; close(): void }>,
getReadonlyDb?: () => Promise<{ db: DbInterface; close(): void }>,
): void {
describe(`${name}: users`, async () => {
let db: DbInterface;
let close: () => void;
let hashedPw: Awaited<ReturnType<typeof hashPassword>>;
before(async () => {
({ db, close } = await getDb());
hashedPw = await hashPassword("test-password");
});
after(() => close());
it("createUser returns a ClientUser without password", async () => {
const user = await db.createUser({
email: `user-create-${Date.now()}@test.example`,
name: "Test User",
password: hashedPw,
flags: {},
});
assert.ok(user.uid, "has uid");
assert.equal(user.name, "Test User");
assert.ok(!("password" in user), "no password field");
assert.ok(user.created_at);
assert.ok(user.updated_at);
});
it("getUserById returns the created user", async () => {
const created = await db.createUser({
email: `user-byid-${Date.now()}@test.example`,
name: "ById User",
password: hashedPw,
flags: {},
});
const found = await db.getUserById(created.uid);
assert.equal(found.uid, created.uid);
assert.equal(found.name, "ById User");
});
it("getUserById throws NotFoundAbodeError for unknown uid", async () => {
await assert.rejects(
() => db.getUserById("00000000-0000-0000-0000-000000000000"),
(err) => {
assert.ok(err instanceof NotFoundAbodeError);
return true;
},
);
});
it("getUserByEmail returns the created user", async () => {
const email = `user-byemail-${Date.now()}@test.example`;
await db.createUser({
email,
name: "ByEmail User",
password: hashedPw,
flags: {},
});
const found = await db.getUserByEmail(email);
assert.ok("email" in found, "result includes email");
assert.equal(found.email, email);
});
it("getUserByEmail throws NotFoundAbodeError for unknown email", async () => {
await assert.rejects(
() => db.getUserByEmail("nobody@nowhere.example"),
(err) => {
assert.ok(err instanceof NotFoundAbodeError);
return true;
},
);
});
it("listUsers includes the created user", async () => {
const email = `user-list-${Date.now()}@test.example`;
const created = await db.createUser({
email,
name: "Listed User",
password: hashedPw,
flags: {},
});
const users = await db.listUsers();
assert.ok(Array.isArray(users));
const found = users.find((u) => u.uid === created.uid);
assert.ok(found, "created user appears in listUsers");
});
it("updateUser updates the name", async () => {
const created = await db.createUser({
email: `user-update-${Date.now()}@test.example`,
name: "Before Update",
password: hashedPw,
flags: {},
});
const updated = await db.updateUser({
uid: created.uid,
name: "After Update",
});
assert.equal(updated.uid, created.uid);
assert.equal(updated.name, "After Update");
});
it("updateUser with no fields throws InvalidAbodeError", async () => {
const created = await db.createUser({
email: `user-noupdate-${Date.now()}@test.example`,
name: "No Update",
password: hashedPw,
flags: {},
});
await assert.rejects(
() => db.updateUser({ uid: created.uid }),
(err) => {
assert.ok(err instanceof InvalidAbodeError);
return true;
},
);
});
it("deleteUserById removes the user", async () => {
const created = await db.createUser({
email: `user-delete-${Date.now()}@test.example`,
name: "To Delete",
password: hashedPw,
flags: {},
});
await db.deleteUserById(created.uid);
await assert.rejects(
() => db.getUserById(created.uid),
(err) => {
assert.ok(err instanceof NotFoundAbodeError);
return true;
},
);
});
it("deleteUserById throws NotFoundAbodeError for unknown uid", async () => {
await assert.rejects(
() => db.deleteUserById("00000000-0000-0000-0000-000000000000"),
(err) => {
assert.ok(err instanceof NotFoundAbodeError);
return true;
},
);
});
});
describe(`${name}: users readonly`, async () => {
let db: DbInterface;
let close: () => void;
let hashedPw: Awaited<ReturnType<typeof hashPassword>>;
before(async () => {
if (getReadonlyDb) ({ db, close } = await getReadonlyDb());
hashedPw = await hashPassword("test-password");
});
after(() => close?.());
it("createUser on readonly db throws ReadonlyAbodeError", async (t) => {
if (!getReadonlyDb) {
t.skip("getReadonlyDb helper not provided for this backend");
return;
}
assert.ok(db.readonly, "test db is readonly");
await assert.rejects(
() =>
db.createUser({
email: "readonly@test.example",
name: "Readonly",
password: hashedPw,
flags: {},
}),
(err) => {
assert.ok(err instanceof ReadonlyAbodeError);
return true;
},
);
});
});
}
-355
View File
@@ -1,355 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { authenticate } from "../../src/webapi/middleware/authenticate.js";
import type { BackendDbInterface } from "../../src/db/types/DbInterface.js";
import type { ClientUser } from "../../src/db/types/User.js";
import type { ClientApikey } from "../../src/db/types/Apikey.js";
import {
ConflictAbodeError,
NotAuthorizedAbodeError,
NotFoundAbodeError,
} from "../../src/db/types/errors.js";
const MOCK_USER: ClientUser = {
uid: "11111111-1111-1111-1111-111111111111",
email: "test@example.com",
name: "Test User",
flags: {},
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
};
const MOCK_APIKEY: ClientApikey = {
kid: "22222222-2222-2222-2222-222222222222",
uid: MOCK_USER.uid,
name: "Test Key",
permissions: {},
created_at: "2024-01-01T00:00:00Z",
expires_at: null,
};
function makeMockDb(
overrides: Partial<BackendDbInterface> = {},
): BackendDbInterface {
return {
readonly: false,
backend: true,
name: "mock",
close: async () => {},
listUsers: async () => [],
getUserById: async () => {
throw new NotFoundAbodeError();
},
deleteUserById: async () => {},
createUser: async () => MOCK_USER,
updateUser: async () => MOCK_USER,
getUserByEmail: async () => {
throw new NotFoundAbodeError();
},
listAbodes: async () => [],
getAbodeById: async () => {
throw new NotFoundAbodeError();
},
deleteAbodeById: async () => {},
createAbode: async () => ({
aid: "a",
name: "A",
created_at: "",
created_by: null,
updated_at: "",
updated_by: null,
}),
updateAbode: async () => ({
aid: "a",
name: "A",
created_at: "",
created_by: null,
updated_at: "",
updated_by: null,
}),
listResidents: async () => [],
getResidentById: async () => {
throw new NotFoundAbodeError();
},
deleteResidentById: async () => {},
createResident: async () => ({
uid: "",
aid: "",
flags: {},
created_at: "",
created_by: null,
updated_at: "",
updated_by: null,
}),
updateResident: async () => ({
uid: "",
aid: "",
flags: {},
created_at: "",
created_by: null,
updated_at: "",
updated_by: null,
}),
listResidentsByUserId: async () => [],
listResidentsByAbodeId: async () => [],
listUsersByAbodeId: async () => [],
listAbodesByUserId: async () => [],
listNotes: async () => [],
getNoteById: async () => {
throw new NotFoundAbodeError();
},
deleteNoteById: async () => {},
createNote: async () => {
throw new Error("unimplemented");
},
updateNote: async () => {
throw new Error("unimplemented");
},
listNotesByAbodeId: async () => [],
listNotesByUserId: async () => [],
deleteSessionsByUser: async () => {},
listApikeysByUser: async () => [],
getApikeyById: async () => {
throw new NotFoundAbodeError();
},
createApikey: async () => [
MOCK_APIKEY,
("at_" + "0".repeat(32)) as `at_${string}`,
],
deleteApikeyById: async () => {},
getUserByLogin: async () => {
throw new NotFoundAbodeError();
},
getUserBySession: async () => {
throw new NotFoundAbodeError();
},
createSession: async () => `as_${"0".repeat(32)}`,
deleteSession: async () => {},
getUserByApikey: async () => {
throw new NotFoundAbodeError();
},
...overrides,
};
}
type MockCtx = {
headers: Record<string, string>;
cookieJar: Record<string, string>;
clearedCookies: Set<string>;
status: number;
body: unknown;
user?: ClientUser;
session?: unknown;
get(header: string): string;
cookies: {
get(name: string): string | undefined;
set(name: string, value: string, opts?: unknown): void;
};
};
function makeMockCtx(
headerOverrides: Record<string, string> = {},
cookieOverrides: Record<string, string> = {},
): MockCtx {
const clearedCookies = new Set<string>();
const ctx: MockCtx = {
headers: headerOverrides,
cookieJar: cookieOverrides,
clearedCookies,
status: 200,
body: null,
user: undefined,
session: undefined,
get(header: string) {
return this.headers[header] ?? this.headers[header.toLowerCase()] ?? "";
},
cookies: {
get(name: string) {
return cookieOverrides[name];
},
set(name: string, value: string, opts?: unknown) {
if (
value === "" ||
(opts && (opts as { expires?: Date }).expires?.getFullYear()! < 2000)
) {
clearedCookies.add(name);
}
},
},
};
return ctx;
}
async function runMiddleware(
db: BackendDbInterface,
ctx: MockCtx,
): Promise<boolean> {
let nextCalled = false;
const mw = authenticate(db);
await mw(ctx as any, async () => {
nextCalled = true;
});
return nextCalled;
}
describe("authenticate middleware", () => {
describe("Basic auth", () => {
it("valid credentials → sets user and session, calls next", async () => {
const db = makeMockDb({ getUserByLogin: async () => MOCK_USER });
const encoded = btoa("test@example.com:password");
const ctx = makeMockCtx({ Authorization: `Basic ${encoded}` });
const next = await runMiddleware(db, ctx);
assert.equal(next, true);
assert.deepEqual(ctx.user, MOCK_USER);
assert.deepEqual(ctx.session, { source: "basic" });
});
it("malformed base64 → 400", async () => {
const db = makeMockDb();
const ctx = makeMockCtx({ Authorization: "Basic !!not-base64!!" });
await runMiddleware(db, ctx);
assert.equal(ctx.status, 400);
});
it("missing colon in decoded value → 400", async () => {
const db = makeMockDb();
const ctx = makeMockCtx({ Authorization: "Basic " + btoa("nocolon") });
await runMiddleware(db, ctx);
assert.equal(ctx.status, 400);
});
it("wrong password (NotAuthorizedAbodeError) → 401 invalid_password", async () => {
const db = makeMockDb({
getUserByLogin: async () => {
throw new NotAuthorizedAbodeError();
},
});
const ctx = makeMockCtx({ Authorization: "Basic " + btoa("user:wrong") });
await runMiddleware(db, ctx);
assert.equal(ctx.status, 401);
assert.deepEqual((ctx.body as any)?.error, "invalid_password");
});
it("unknown user (NotFoundAbodeError) → 401 unknown_user", async () => {
const db = makeMockDb({
getUserByLogin: async () => {
throw new NotFoundAbodeError();
},
});
const ctx = makeMockCtx({
Authorization: "Basic " + btoa("nobody:pass"),
});
await runMiddleware(db, ctx);
assert.equal(ctx.status, 401);
assert.deepEqual((ctx.body as any)?.error, "unknown_user");
});
it("ConflictAbodeError (#unset password) → 401 user_not_loggable", async () => {
const db = makeMockDb({
getUserByLogin: async () => {
throw new ConflictAbodeError();
},
});
const ctx = makeMockCtx({ Authorization: "Basic " + btoa("user:pass") });
await runMiddleware(db, ctx);
assert.equal(ctx.status, 401);
assert.deepEqual((ctx.body as any)?.error, "user_not_loggable");
});
it("uses X-Abode-Authorization header when Authorization is absent", async () => {
const db = makeMockDb({ getUserByLogin: async () => MOCK_USER });
const encoded = btoa("test@example.com:password");
const ctx = makeMockCtx({ "X-Abode-Authorization": `Basic ${encoded}` });
const next = await runMiddleware(db, ctx);
assert.equal(next, true);
assert.ok(ctx.user);
});
});
describe("Bearer (API key)", () => {
const validToken = `at_${"a".repeat(32)}` as `at_${string}`;
it("valid at_ token → sets user and apikey session, calls next", async () => {
const db = makeMockDb({
getUserByApikey: async () => [MOCK_USER, MOCK_APIKEY],
});
const ctx = makeMockCtx({ Authorization: `Bearer ${validToken}` });
const next = await runMiddleware(db, ctx);
assert.equal(next, true);
assert.deepEqual(ctx.user, MOCK_USER);
assert.deepEqual(ctx.session, { source: "apikey", key: MOCK_APIKEY });
});
it("invalid/expired at_ token → 401 invalid_apikey", async () => {
const db = makeMockDb({
getUserByApikey: async () => {
throw new NotFoundAbodeError();
},
});
const ctx = makeMockCtx({ Authorization: `Bearer ${validToken}` });
await runMiddleware(db, ctx);
assert.equal(ctx.status, 401);
assert.deepEqual((ctx.body as any)?.error, "invalid_apikey");
});
it("bearer token without at_ prefix → 401 unrecognized_bearer", async () => {
const db = makeMockDb();
const ctx = makeMockCtx({ Authorization: "Bearer not-an-apikey-token" });
await runMiddleware(db, ctx);
assert.equal(ctx.status, 401);
assert.deepEqual((ctx.body as any)?.error, "unrecognized_bearer");
});
});
describe("session cookie", () => {
const validToken = `as_${"b".repeat(32)}`;
it("valid session cookie → sets user and session, calls next", async () => {
const db = makeMockDb({
getUserBySession: async () => MOCK_USER,
});
const ctx = makeMockCtx({}, { abode_session: validToken });
const next = await runMiddleware(db, ctx);
assert.equal(next, true);
assert.deepEqual(ctx.user, MOCK_USER);
assert.deepEqual(ctx.session, { source: "session" });
});
it("invalid session token format clears cookie and falls through to 401", async () => {
const db = makeMockDb();
const ctx = makeMockCtx({}, { abode_session: "not-a-session-token" });
await runMiddleware(db, ctx);
assert.ok(
ctx.clearedCookies.has("abode_session"),
"cookie should be cleared",
);
assert.equal(ctx.status, 401);
});
it("expired/unknown session token clears cookie and returns 401", async () => {
const db = makeMockDb({
getUserBySession: async () => {
throw new NotFoundAbodeError();
},
});
const ctx = makeMockCtx({}, { abode_session: validToken });
await runMiddleware(db, ctx);
assert.ok(
ctx.clearedCookies.has("abode_session"),
"cookie should be cleared",
);
assert.equal(ctx.status, 401);
});
});
describe("no auth", () => {
it("no credentials → 401 not_authenticated", async () => {
const db = makeMockDb();
const ctx = makeMockCtx();
const next = await runMiddleware(db, ctx);
assert.equal(next, false);
assert.equal(ctx.status, 401);
assert.deepEqual((ctx.body as any)?.error, "not_authenticated");
});
});
});
-76
View File
@@ -1,76 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { convertError } from "../../src/webapi/middleware/convertError.js";
import {
NotFoundAbodeError,
NotAuthorizedAbodeError,
ConflictAbodeError,
InvalidAbodeError,
ReadonlyAbodeError,
} from "../../src/db/types/errors.js";
function makeCtx() {
return { status: 200, body: null as unknown };
}
async function runWithError(err: unknown) {
const ctx = makeCtx();
await convertError(ctx as any, async () => {
throw err;
});
return ctx;
}
describe("convertError middleware", () => {
it("does not interfere when next succeeds", async () => {
const ctx = makeCtx();
let nextCalled = false;
await convertError(ctx as any, async () => {
nextCalled = true;
});
assert.equal(nextCalled, true);
assert.equal(ctx.status, 200);
});
it("NotFoundAbodeError → 404 not_found", async () => {
const ctx = await runWithError(new NotFoundAbodeError());
assert.equal(ctx.status, 404);
assert.deepEqual(ctx.body, { ok: false, error: "not_found" });
});
it("NotAuthorizedAbodeError → 401 not_authorized", async () => {
const ctx = await runWithError(new NotAuthorizedAbodeError());
assert.equal(ctx.status, 401);
assert.deepEqual(ctx.body, { ok: false, error: "not_authorized" });
});
it("ConflictAbodeError → 409 conflict", async () => {
const ctx = await runWithError(new ConflictAbodeError());
assert.equal(ctx.status, 409);
assert.deepEqual(ctx.body, { ok: false, error: "conflict" });
});
it("InvalidAbodeError → 400 invalid", async () => {
const ctx = await runWithError(new InvalidAbodeError());
assert.equal(ctx.status, 400);
assert.deepEqual(ctx.body, { ok: false, error: "invalid" });
});
it("ReadonlyAbodeError → 403 readonly", async () => {
const ctx = await runWithError(new ReadonlyAbodeError());
assert.equal(ctx.status, 403);
assert.deepEqual(ctx.body, { ok: false, error: "readonly" });
});
it("unknown Error → 500 unknown", async () => {
const ctx = await runWithError(new Error("something went wrong"));
assert.equal(ctx.status, 500);
assert.deepEqual(ctx.body, { ok: false, error: "unknown" });
});
it("non-Error thrown → 500 unknown", async () => {
const ctx = await runWithError("string error");
assert.equal(ctx.status, 500);
assert.deepEqual(ctx.body, { ok: false, error: "unknown" });
});
});
-794
View File
@@ -1,794 +0,0 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import readline from "node:readline";
import { Readable } from "node:stream";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import Koa from "koa";
import { createTestDb, type TestDb } from "../helpers/sqlite.js";
import { apirouter } from "../../src/webapi/apirouter.js";
import { computeForcedExportFilter } from "../../src/webapi/exportScope.js";
import {
intersectExportFilters,
recordAllowed,
kindAllowed,
} from "../../src/db/export/filter.js";
import { inspectExportStream } from "../../src/db/export/inspect.js";
import { hashPassword } from "../../src/util/hash.js";
import type { ClientUser } from "../../src/db/types/User.js";
import {
EXPORT_KIND_ORDER,
type ExportKind,
} from "../../src/db/types/ExportImport.js";
/**
* Assert the record lines of a parsed export are grouped in FK-safe
* EXPORT_KIND_ORDER: the leading line is `meta`, and every record kind's
* position in the order is non-decreasing down the stream.
*/
function assertFkSafeOrder(lines: { kind: string }[]): void {
assert.equal(lines[0]?.kind, "meta", "first line is meta");
const rank = (k: string) => EXPORT_KIND_ORDER.indexOf(k as ExportKind);
let last = -1;
for (const { kind } of lines.slice(1)) {
if (kind === "error") continue;
const r = rank(kind);
assert.notEqual(r, -1, `unexpected kind ${kind}`);
assert.ok(
r >= last,
`kind ${kind} (order ${r}) appears after a later kind (order ${last})`,
);
last = r;
}
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
async function streamToString(s: NodeJS.ReadableStream): Promise<string> {
let out = "";
for await (const chunk of s) out += chunk;
return out;
}
function parseLines(ndjson: string): { kind: string; data: any }[] {
return ndjson
.split("\n")
.filter(Boolean)
.map((l) => JSON.parse(l));
}
/** Normalise `*_at` fields to epoch ms so formatting never breaks equality. */
function norm(obj: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
out[k] = k.endsWith("_at")
? v == null
? null
: new Date(v as string).getTime()
: v;
}
return out;
}
function normSorted(
arr: Record<string, unknown>[],
key: (x: any) => string,
): Record<string, unknown>[] {
return arr.map(norm).sort((a, b) => key(a).localeCompare(key(b)));
}
interface Seed {
admin: ClientUser;
normal: ClientUser;
co: ClientUser;
aid1: string;
aid2: string;
nid1: string;
nid2: string;
}
async function seed(db: TestDb["db"]): Promise<Seed> {
const pw = await hashPassword("password");
const admin = await db.createUser({
email: "admin@test.example",
name: "Admin",
password: pw,
flags: { admin: true },
});
const normal = await db.createUser({
email: "normal@test.example",
name: "Normal",
password: pw,
flags: {},
});
const co = await db.createUser({
email: "co@test.example",
name: "Co Resident",
password: pw,
flags: {},
});
const abode1 = await db.createAbode(
{ name: "Abode One" },
{ uid: admin.uid },
);
const abode2 = await db.createAbode(
{ name: "Abode Two" },
{ uid: admin.uid },
);
await db.createResident(
{ uid: normal.uid, aid: abode1.aid, flags: {} },
{ uid: admin.uid },
);
await db.createResident(
{ uid: co.uid, aid: abode1.aid, flags: {} },
{ uid: admin.uid },
);
await db.createResident(
{ uid: admin.uid, aid: abode2.aid, flags: { admin: true } },
{ uid: admin.uid },
);
await db.createApikey({
uid: normal.uid,
name: "normal key",
permissions: {},
expires_at: null,
});
await db.createApikey({
uid: co.uid,
name: "co key",
permissions: {},
expires_at: null,
});
const note1 = await db.createNote(
{
aid: abode1.aid,
name: "Note One",
content: "hello",
properties: { type: "note" },
},
{ uid: normal.uid },
);
const note2 = await db.createNote(
{
aid: abode2.aid,
name: "Note Two",
content: "world",
properties: { type: "note" },
},
{ uid: admin.uid },
);
return {
admin,
normal,
co,
aid1: abode1.aid,
aid2: abode2.aid,
nid1: note1.nid,
nid2: note2.nid,
};
}
// ---------------------------------------------------------------------------
// 0. wire-format ordering invariant
// ---------------------------------------------------------------------------
describe("export ordering (FK-safe wire contract)", () => {
it("EXPORT_KIND_ORDER is the FK-safe dependency order", () => {
// Change-detector: reordering is a breaking change to the format and must
// keep every kind after the kinds it references (see ExportImport.ts).
assert.deepEqual(EXPORT_KIND_ORDER, [
"user",
"abode",
"resident",
"apikey",
"note",
]);
});
it("sqlite export emits record kinds grouped in EXPORT_KIND_ORDER", async () => {
const src = await createTestDb();
try {
await seed(src.db);
const lines = parseLines(await streamToString(src.db.export()));
// every kind must be present so the ordering is actually exercised
const kinds = new Set(lines.map((l) => l.kind));
for (const k of EXPORT_KIND_ORDER) {
assert.ok(kinds.has(k), `stream contains ${k}`);
}
assertFkSafeOrder(lines);
} finally {
src.close();
}
});
});
// ---------------------------------------------------------------------------
// 1. round-trip
// ---------------------------------------------------------------------------
describe("export/import: sqlite -> sqlite round-trip", () => {
it("reproduces users, abodes, residents, apikeys, notes", async () => {
const src = await createTestDb();
const dst = await createTestDb();
try {
const s = await seed(src.db);
const result = await dst.db.import(src.db.export());
assert.ok(result.counts.user && result.counts.user >= 3);
assert.ok(result.counts.note && result.counts.note >= 2);
assert.deepEqual(
normSorted(await dst.db.listUsers(), (u) => u.uid),
normSorted(await src.db.listUsers(), (u) => u.uid),
);
assert.deepEqual(
normSorted(await dst.db.listAbodes(), (a) => a.aid),
normSorted(await src.db.listAbodes(), (a) => a.aid),
);
assert.deepEqual(
normSorted(await dst.db.listResidents(), (r) => r.uid + r.aid),
normSorted(await src.db.listResidents(), (r) => r.uid + r.aid),
);
// apikeys: token is regenerated on import, so the ClientApikey view
// (which omits token) must still match exactly.
assert.deepEqual(
normSorted(await dst.db.listApikeysByUser(s.normal.uid), (k) => k.kid),
normSorted(await src.db.listApikeysByUser(s.normal.uid), (k) => k.kid),
);
// notes (full, with content)
const srcNote = await src.db.getNoteById(s.nid1);
const dstNote = await dst.db.getNoteById(s.nid1);
assert.deepEqual(norm(dstNote), norm(srcNote));
} finally {
src.close();
dst.close();
}
});
});
// ---------------------------------------------------------------------------
// 2. filter narrowing
// ---------------------------------------------------------------------------
describe("export/import: filter narrowing", () => {
it("scopes abode/resident/note to the requested abodes", async () => {
const src = await createTestDb();
try {
const s = await seed(src.db);
const ndjson = await streamToString(
src.db.export({ filter: { abodes: [s.aid1] } }),
);
const lines = parseLines(ndjson);
const abodeAids = lines
.filter((l) => l.kind === "abode")
.map((l) => l.data.aid);
assert.deepEqual(abodeAids, [s.aid1]);
const noteAids = new Set(
lines.filter((l) => l.kind === "note").map((l) => l.data.aid),
);
assert.ok(noteAids.has(s.aid1));
assert.ok(!noteAids.has(s.aid2));
const residentAids = new Set(
lines.filter((l) => l.kind === "resident").map((l) => l.data.aid),
);
assert.ok(!residentAids.has(s.aid2));
} finally {
src.close();
}
});
it("imports the narrowed dump into a fresh db with the same scope", async () => {
const src = await createTestDb();
const dst = await createTestDb();
try {
const s = await seed(src.db);
await dst.db.import(src.db.export({ filter: { abodes: [s.aid1] } }));
const abodes = await dst.db.listAbodes();
assert.deepEqual(
abodes.map((a) => a.aid),
[s.aid1],
);
const notes = await dst.db.listNotesByAbodeId(s.aid1);
assert.equal(notes.length, 1);
assert.equal((await dst.db.listNotesByAbodeId(s.aid2)).length, 0);
} finally {
src.close();
dst.close();
}
});
it("kinds filter selects only the requested kinds", async () => {
const src = await createTestDb();
try {
await seed(src.db);
const ndjson = await streamToString(
src.db.export({ filter: { kinds: ["abode"] } }),
);
const kinds = new Set(parseLines(ndjson).map((l) => l.kind));
assert.ok(kinds.has("abode"));
assert.ok(!kinds.has("user"));
assert.ok(!kinds.has("note"));
} finally {
src.close();
}
});
});
// ---------------------------------------------------------------------------
// 3. forced-filter enforcement
// ---------------------------------------------------------------------------
describe("exportScope: computeForcedExportFilter", () => {
it("returns null for a global admin on a basic/session credential", async () => {
const t = await createTestDb();
try {
const s = await seed(t.db);
const forced = await computeForcedExportFilter(t.db, {
user: s.admin,
session: { source: "basic" },
});
assert.equal(forced, null);
} finally {
t.close();
}
});
it("returns null for a global admin with an unrestricted apikey", async () => {
const t = await createTestDb();
try {
const s = await seed(t.db);
const forced = await computeForcedExportFilter(t.db, {
user: s.admin,
session: {
source: "apikey",
key: {
kid: "k",
uid: s.admin.uid,
name: "k",
permissions: { admin: true, all: true },
created_at: new Date().toISOString(),
expires_at: null,
},
},
});
assert.equal(forced, null);
} finally {
t.close();
}
});
it("forces a non-admin to their abodes + co-residents", async () => {
const t = await createTestDb();
try {
const s = await seed(t.db);
const forced = await computeForcedExportFilter(t.db, {
user: s.normal,
session: { source: "basic" },
});
assert.ok(forced);
assert.deepEqual(forced!.abodes, [s.aid1]);
assert.deepEqual(
new Set(forced!.users),
new Set([s.normal.uid, s.co.uid]),
);
assert.ok(!forced!.users!.includes(s.admin.uid));
// apikeys are self-only, even though co is a co-resident whose user
// record is exported for referential integrity.
assert.deepEqual(forced!.apikeys, [s.normal.uid]);
// A caller requesting a wider abode never gets it: intersection, not union.
const effective = intersectExportFilters(
{ abodes: [s.aid1, s.aid2] },
forced,
);
assert.deepEqual(effective.abodes, [s.aid1]);
assert.ok(!effective.abodes!.includes(s.aid2));
} finally {
t.close();
}
});
it("intersects a non-admin apikey with restrict_abodes", async () => {
const t = await createTestDb();
try {
const s = await seed(t.db);
const forced = await computeForcedExportFilter(t.db, {
user: s.normal,
session: {
source: "apikey",
key: {
kid: "k",
uid: s.normal.uid,
name: "k",
permissions: { restrict_abodes: [s.aid2] },
created_at: new Date().toISOString(),
expires_at: null,
},
},
});
// normal resides only in aid1; restrict to aid2 => empty intersection.
assert.deepEqual(forced!.abodes, []);
} finally {
t.close();
}
});
});
// ---------------------------------------------------------------------------
// 4. cancellation, export side
// ---------------------------------------------------------------------------
describe("export cancellation", () => {
it("stops querying tables after the destination aborts", async () => {
const t = await createTestDb();
try {
const pw = await hashPassword("password");
// Enough users that the first table can't be buffered in one go, so the
// generator backpressures mid-`user` and never reaches later tables.
for (let i = 0; i < 400; i++) {
await t.db.createUser({
email: `bulk-${i}@test.example`,
name: `Bulk ${i}`,
password: pw,
flags: {},
});
}
let allCalls = 0;
const orig = t.wrapped.all.bind(t.wrapped);
(t.wrapped as { all: unknown }).all = (stmt: never) => {
allCalls++;
return orig(stmt);
};
const ac = new AbortController();
const stream = t.db.export({ signal: ac.signal });
const rl = readline.createInterface({ input: stream });
let lines = 0;
let callsAtAbort = -1;
for await (const _ of rl) {
lines++;
if (lines === 3) {
ac.abort();
callsAtAbort = allCalls;
}
}
assert.ok(callsAtAbort >= 1, "at least the first table was queried");
assert.equal(allCalls, callsAtAbort, "no further queries after abort");
assert.ok(allCalls < 5, "did not materialise all five tables");
} finally {
t.close();
}
});
});
// ---------------------------------------------------------------------------
// 5. cancellation, import side
// ---------------------------------------------------------------------------
describe("import cancellation", () => {
it("rolls back and leaves no dangling transaction when the source errors", async () => {
const dst = await createTestDb();
try {
function line(kind: string, data: unknown): string {
return JSON.stringify({ kind, data }) + "\n";
}
const now = new Date().toISOString();
const mkUser = (i: number) => ({
uid: crypto.randomUUID(),
email: `imp-${i}@test.example`,
name: `Imp ${i}`,
flags: {},
created_at: now,
updated_at: now,
});
// Emits a couple of valid user records, then throws mid-stream.
const source = Readable.from(
(async function* () {
yield line("meta", { v: 1 });
yield line("user", mkUser(1));
yield line("user", mkUser(2));
throw new Error("source exploded");
})(),
);
await assert.rejects(() => dst.db.import(source), /source exploded/);
// ROLLBACK happened: nothing persisted.
assert.equal((await dst.db.listUsers()).length, 0);
// No lingering open transaction: a follow-up write succeeds immediately.
const pw = await hashPassword("password");
const created = await dst.db.createUser({
email: "after@test.example",
name: "After",
password: pw,
flags: {},
});
assert.ok(created.uid);
} finally {
dst.close();
}
});
it("aborts cleanly via signal and commits nothing", async () => {
const dst = await createTestDb();
try {
const ac = new AbortController();
const now = new Date().toISOString();
const source = Readable.from(
(async function* () {
yield JSON.stringify({ kind: "meta", data: { v: 1 } }) + "\n";
yield JSON.stringify({
kind: "user",
data: {
uid: crypto.randomUUID(),
email: "abort@test.example",
name: "Abort",
flags: {},
created_at: now,
updated_at: now,
},
}) + "\n";
ac.abort();
// Keep the stream alive so abort — not EOF — ends the import.
await new Promise((r) => setTimeout(r, 1000));
})(),
);
await assert.rejects(() => dst.db.import(source, { signal: ac.signal }));
assert.equal((await dst.db.listUsers()).length, 0);
} finally {
dst.close();
}
});
});
// ---------------------------------------------------------------------------
// 6. api export endpoint
// ---------------------------------------------------------------------------
describe("GET /export endpoint", () => {
let t: TestDb;
let s: Seed;
let url: string;
let close: () => Promise<void>;
const basic = (email: string) =>
"Basic " + Buffer.from(`${email}:password`).toString("base64");
before(async () => {
t = await createTestDb();
s = await seed(t.db);
const app = new Koa();
const router = apirouter(t.db);
app.use(router.routes());
app.use(router.allowedMethods());
const server = createServer(app.callback());
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
const { port } = server.address() as AddressInfo;
url = `http://127.0.0.1:${port}`;
close = () =>
new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())),
);
});
after(async () => {
await close();
t.close();
});
it("global admin exports everything", async () => {
const res = await fetch(`${url}/export`, {
headers: { Authorization: basic(s.admin.email) },
});
assert.equal(res.status, 200);
const lines = parseLines(await res.text());
const abodeAids = new Set(
lines.filter((l) => l.kind === "abode").map((l) => l.data.aid),
);
assert.ok(abodeAids.has(s.aid1));
assert.ok(abodeAids.has(s.aid2));
const userUids = new Set(
lines.filter((l) => l.kind === "user").map((l) => l.data.uid),
);
assert.ok(userUids.has(s.admin.uid));
assert.ok(userUids.has(s.normal.uid));
});
it("non-admin is force-scoped even when requesting wider abodes", async () => {
const res = await fetch(`${url}/export?abodes=${s.aid1},${s.aid2}`, {
headers: { Authorization: basic(s.normal.email) },
});
assert.equal(res.status, 200);
const lines = parseLines(await res.text());
const abodeAids = new Set(
lines.filter((l) => l.kind === "abode").map((l) => l.data.aid),
);
assert.ok(abodeAids.has(s.aid1));
assert.ok(!abodeAids.has(s.aid2), "aid2 forced out of scope");
const userUids = new Set(
lines.filter((l) => l.kind === "user").map((l) => l.data.uid),
);
assert.ok(userUids.has(s.normal.uid));
assert.ok(userUids.has(s.co.uid));
assert.ok(!userUids.has(s.admin.uid), "admin not a co-resident of aid1");
// apikeys are self-only: the caller's own key is exported, but a
// co-resident's key metadata is NOT, even though their user record is.
const apikeyUids = lines
.filter((l) => l.kind === "apikey")
.map((l) => l.data.uid);
assert.deepEqual(new Set(apikeyUids), new Set([s.normal.uid]));
assert.ok(
!apikeyUids.includes(s.co.uid),
"co-resident apikey metadata must not leak",
);
// The meta line records the *effective* (narrowed) filter.
const meta = lines.find((l) => l.kind === "meta");
assert.ok(meta);
assert.deepEqual(meta!.data.filter.abodes, [s.aid1]);
});
it("stops server-side querying shortly after the client aborts", async () => {
// Dedicated db/server. Rows are deliberately large (and inserted in bulk so
// the seed stays fast) so the users table alone can't fit in the socket
// buffer — the export generator backpressures mid-`user` and never reaches
// later tables while the client is still holding the connection open.
const big = await createTestDb();
let server: ReturnType<typeof createServer> | undefined;
try {
const pw = await hashPassword("password");
await big.db.createUser({
email: "a@test.example",
name: "A",
password: pw,
flags: { admin: true },
});
const { sql, db: raw } = big.db._;
// ~32MB of user rows — far more than any socket/kernel buffer can hold,
// so the generator is guaranteed to still be suspended mid-`user` (never
// having queried a later table) when the client aborts.
const bigName = "x".repeat(16000);
raw.multi(() => {
for (let i = 0; i < 2000; i++) {
raw.run(sql`
INSERT INTO "users"("uid", "email", "name", "flags")
VALUES(
${{ uuid: crypto.randomUUID() }},
${{ text: `b-${i}@test.example` }},
${{ text: bigName }},
${{ jsonb: {} }}
)
`);
}
});
let allCalls = 0;
const orig = big.wrapped.all.bind(big.wrapped);
(big.wrapped as { all: unknown }).all = (stmt: never) => {
allCalls++;
return orig(stmt);
};
const app = new Koa();
app.on("error", () => {}); // swallow the expected ECONNRESET on abort
const router = apirouter(big.db);
app.use(router.routes());
app.use(router.allowedMethods());
server = createServer(app.callback());
await new Promise<void>((r) => server!.listen(0, "127.0.0.1", r));
const { port } = server.address() as AddressInfo;
const ac = new AbortController();
const res = await fetch(`http://127.0.0.1:${port}/export`, {
headers: { Authorization: basic("a@test.example") },
signal: ac.signal,
});
const reader = res.body!.getReader();
await reader.read(); // first chunk — server has begun streaming users
const callsWhileStreaming = allCalls;
assert.ok(callsWhileStreaming >= 1, "server queried the first table");
ac.abort();
await reader.cancel().catch(() => {});
await new Promise((r) => setTimeout(r, 250));
const settled = allCalls;
await new Promise((r) => setTimeout(r, 250));
assert.equal(allCalls, settled, "no further queries after abort");
assert.ok(allCalls < 5, "did not materialise all five tables");
} finally {
if (server) await new Promise<void>((r) => server!.close(() => r()));
big.close();
}
});
});
// ---------------------------------------------------------------------------
// 7. inspect utility
// ---------------------------------------------------------------------------
describe("inspectExportStream", () => {
it("tallies counts and reads meta without a db", async () => {
const src = await createTestDb();
try {
await seed(src.db);
const ndjson = await streamToString(src.db.export());
const { counts, meta } = await inspectExportStream(
Readable.from([ndjson]),
);
assert.equal(meta?.v, 1);
assert.equal(meta?.source, "sqlite");
assert.ok((counts.user ?? 0) >= 3);
assert.ok((counts.abode ?? 0) >= 2);
assert.ok((counts.note ?? 0) >= 2);
} finally {
src.close();
}
});
it("stopAfterKinds short-circuits once every requested kind is seen", async () => {
const src = await createTestDb();
try {
await seed(src.db);
const ndjson = await streamToString(src.db.export());
const { counts } = await inspectExportStream(Readable.from([ndjson]), {
stopAfterKinds: ["user"],
});
assert.ok((counts.user ?? 0) >= 1);
// stopped as soon as the first user was seen, before later kinds.
assert.equal(counts.note ?? 0, 0);
} finally {
src.close();
}
});
});
// ---------------------------------------------------------------------------
// filter unit checks
// ---------------------------------------------------------------------------
describe("filter helpers", () => {
it("kindAllowed respects kinds/excludeKinds", () => {
assert.equal(kindAllowed({ kinds: ["abode"] }, "abode"), true);
assert.equal(kindAllowed({ kinds: ["abode"] }, "user"), false);
assert.equal(kindAllowed({ excludeKinds: ["note"] }, "note"), false);
assert.equal(kindAllowed(undefined, "note"), true);
});
it("recordAllowed scopes by aid/uid per kind", () => {
assert.equal(
recordAllowed({ abodes: ["a1"] }, "abode", { aid: "a1" }),
true,
);
assert.equal(
recordAllowed({ abodes: ["a1"] }, "abode", { aid: "a2" }),
false,
);
assert.equal(
recordAllowed({ users: ["u1"] }, "apikey", { uid: "u1" }),
true,
);
assert.equal(
recordAllowed({ users: ["u1"] }, "apikey", { uid: "u2" }),
false,
);
// abodes allowlist does not constrain user records
assert.equal(
recordAllowed({ abodes: ["a1"] }, "user", { uid: "u9" }),
true,
);
});
});
-39
View File
@@ -1,39 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { hashPassword, validatePassword } from "../../src/util/hash.js";
describe("hashPassword", () => {
it("returns a string in PHC/argon2 format", async () => {
const hash = await hashPassword("secret");
assert.ok(hash.startsWith("$argon2"), `expected argon2 hash, got: ${hash}`);
assert.ok(hash.includes("$"), "has delimiter");
});
it("two hashes of the same password differ (random salt)", async () => {
const [h1, h2] = await Promise.all([
hashPassword("same-password"),
hashPassword("same-password"),
]);
assert.notEqual(h1, h2, "hashes should differ due to random salt");
});
});
describe("validatePassword", () => {
it("returns true for matching password and hash", async () => {
const hash = await hashPassword("correct-password");
const result = await validatePassword("correct-password", hash);
assert.equal(result, true);
});
it("returns false for wrong password", async () => {
const hash = await hashPassword("correct-password");
const result = await validatePassword("wrong-password", hash);
assert.equal(result, false);
});
it("returns false for a completely different password", async () => {
const hash = await hashPassword("original-password");
const result = await validatePassword("different-password", hash);
assert.equal(result, false);
});
});
-146
View File
@@ -1,146 +0,0 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import Koa from "koa";
import KoaRouter from "@koa/router";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { jsonBody } from "../../src/webapi/middleware/jsonBody.js";
async function request(
url: string,
opts: { method?: string; body?: unknown; contentType?: string } = {},
): Promise<{ status: number; body: unknown }> {
const method = opts.method ?? "POST";
const bodyStr =
opts.body !== undefined ? JSON.stringify(opts.body) : undefined;
const headers: Record<string, string> = {};
if (bodyStr !== undefined) {
headers["Content-Type"] = opts.contentType ?? "application/json";
headers["Content-Length"] = String(bodyStr.length);
}
const res = await fetch(url, { method, headers, body: bodyStr });
const text = await res.text();
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
parsed = text;
}
return { status: res.status, body: parsed };
}
async function makeTestServer() {
const failValidator = Object.assign((_obj: unknown): _obj is never => false, {
errors: [{ message: "required" }] as unknown[],
schema: { $id: "test-schema", title: "Test", description: "" },
});
const app = new Koa();
const router = new KoaRouter();
router.post("/echo", jsonBody(), async (ctx) => {
ctx.status = 200;
ctx.body = { ok: true, received: ctx.request.body };
});
router.post(
"/fail-validate",
jsonBody({ validate: failValidator as any }),
async (ctx) => {
ctx.status = 200;
ctx.body = { ok: true };
},
);
router.post(
"/with-params/:uid",
jsonBody({ includeParams: ["uid"] }),
async (ctx) => {
ctx.status = 200;
ctx.body = { ok: true, body: ctx.request.body };
},
);
app.use(router.routes());
app.use(router.allowedMethods());
const server = createServer(app.callback());
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address() as AddressInfo;
const url = `http://127.0.0.1:${port}`;
const close = () =>
new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())),
);
return { url, close };
}
describe("jsonBody middleware", () => {
let url: string;
let closeServer: () => Promise<void>;
before(async () => {
({ url, close: closeServer } = await makeTestServer());
});
after(() => closeServer());
it("no Content-Type → 200 with empty body (bodyparser sets body to {})", async () => {
const res = await fetch(url + "/echo", { method: "POST" });
const json = await res.json();
assert.equal(res.status, 200);
assert.deepEqual(json.received, {});
});
it("invalid JSON with Content-Type: application/json → 400 from bodyparser", async () => {
const res = await fetch(url + "/echo", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "not-valid-json",
});
assert.equal(res.status, 400);
});
it("valid JSON body → 200 with echoed body", async () => {
const res = await request(url + "/echo", { body: { hello: "world" } });
assert.equal(res.status, 200);
assert.deepEqual((res.body as any).received, { hello: "world" });
});
it("failing validator → 400 jsonchema_validation_failed with schema and errors", async () => {
const res = await request(url + "/fail-validate", {
body: { any: "thing" },
});
assert.equal(res.status, 400);
assert.equal((res.body as any).error, "jsonchema_validation_failed");
assert.ok((res.body as any).schema, "response includes schema");
assert.ok(
Array.isArray((res.body as any).errors),
"response includes errors",
);
});
it("includeParams: param absent from body → merged in", async () => {
const res = await request(url + "/with-params/user-42", {
body: { other: "field" },
});
assert.equal(res.status, 200);
assert.equal((res.body as any).body?.uid, "user-42");
});
it("includeParams: param present with matching value → ok", async () => {
const res = await request(url + "/with-params/user-42", {
body: { uid: "user-42" },
});
assert.equal(res.status, 200);
});
it("includeParams: mismatching param value → 400 mismatch_params", async () => {
const res = await request(url + "/with-params/user-42", {
body: { uid: "different-uid" },
});
assert.equal(res.status, 400);
assert.equal((res.body as any).error, "mismatch_params");
assert.equal((res.body as any).param, "uid");
});
});
-86
View File
@@ -1,86 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
createSessionToken,
createApikeyToken,
isSessionToken,
isApikeyToken,
} from "../../src/util/token.js";
describe("createSessionToken", () => {
it("starts with 'as_'", () => {
const token = createSessionToken();
assert.ok(token.startsWith("as_"), `expected as_ prefix, got: ${token}`);
});
it("has 35 chars total (as_ + 32 hex)", () => {
const token = createSessionToken();
assert.equal(token.length, 35);
});
it("suffix is all hex characters", () => {
const token = createSessionToken();
const hex = token.slice(3);
assert.ok(/^[0-9a-f]{32}$/.test(hex), `not all hex: ${hex}`);
});
it("produces different tokens on each call", () => {
const tokens = new Set(Array.from({ length: 10 }, createSessionToken));
assert.equal(tokens.size, 10, "all tokens should be unique");
});
});
describe("createApikeyToken", () => {
it("starts with 'at_'", () => {
const token = createApikeyToken();
assert.ok(token.startsWith("at_"), `expected at_ prefix, got: ${token}`);
});
it("has 35 chars total (at_ + 32 hex)", () => {
const token = createApikeyToken();
assert.equal(token.length, 35);
});
it("suffix is all hex characters", () => {
const token = createApikeyToken();
const hex = token.slice(3);
assert.ok(/^[0-9a-f]{32}$/.test(hex), `not all hex: ${hex}`);
});
it("produces different tokens on each call", () => {
const tokens = new Set(Array.from({ length: 10 }, createApikeyToken));
assert.equal(tokens.size, 10, "all tokens should be unique");
});
});
describe("isSessionToken", () => {
it("returns true for as_ prefixed strings", () => {
assert.equal(isSessionToken("as_" + "a".repeat(32)), true);
});
it("returns false for at_ prefixed strings", () => {
assert.equal(isSessionToken("at_abc"), false);
});
it("returns false for empty string", () => {
assert.equal(isSessionToken(""), false);
});
it("returns false for plain strings", () => {
assert.equal(isSessionToken("hello"), false);
});
});
describe("isApikeyToken", () => {
it("returns true for at_ prefixed strings", () => {
assert.equal(isApikeyToken("at_" + "b".repeat(32)), true);
});
it("returns false for as_ prefixed strings", () => {
assert.equal(isApikeyToken("as_abc"), false);
});
it("returns false for empty string", () => {
assert.equal(isApikeyToken(""), false);
});
});
-148
View File
@@ -1,148 +0,0 @@
import { after, before, describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestDb, type TestDb } from "../helpers/sqlite.js";
import { createTestServer, type TestServer } from "../helpers/koa.js";
import { hashPassword } from "../../src/util/hash.js";
import type { ClientUser } from "../../src/db/types/User.js";
const PASSWORD = "user-visibility-password";
function basic(email: string): string {
return `Basic ${Buffer.from(`${email}:${PASSWORD}`).toString("base64")}`;
}
describe("API user email visibility", () => {
let testDb: TestDb;
let server: TestServer;
let admin: ClientUser;
let normal: ClientUser;
let coResident: ClientUser;
let abodeAdmin: ClientUser;
let aid: string;
before(async () => {
testDb = await createTestDb();
server = await createTestServer(testDb.db);
const password = await hashPassword(PASSWORD);
admin = await testDb.db.createUser({
email: "global-admin@test.example",
name: "Global Admin",
password,
flags: { admin: true },
});
normal = await testDb.db.createUser({
email: "normal@test.example",
name: "Normal",
password,
flags: {},
});
coResident = await testDb.db.createUser({
email: "co-resident@test.example",
name: "Co-resident",
password,
flags: {},
});
abodeAdmin = await testDb.db.createUser({
email: "abode-admin@test.example",
name: "Abode Admin",
password,
flags: {},
});
const abode = await testDb.db.createAbode(
{ name: "Shared abode" },
{ uid: admin.uid },
);
aid = abode.aid;
for (const user of [normal, coResident]) {
await testDb.db.createResident(
{ uid: user.uid, aid, flags: {} },
{ uid: admin.uid },
);
}
await testDb.db.createResident(
{ uid: abodeAdmin.uid, aid, flags: { admin: true } },
{ uid: admin.uid },
);
});
after(async () => {
await server.close();
testDb.close();
});
it("shows a normal caller only their own email in GET /users", async () => {
const response = await fetch(`${server.url}/users`, {
headers: { Authorization: basic(normal.email) },
});
assert.equal(response.status, 200);
const users = (await response.json()) as ClientUser[];
assert.equal(
users.find((user) => user.uid === normal.uid)?.email,
normal.email,
);
assert.ok(!("email" in users.find((user) => user.uid === coResident.uid)!));
});
it("hides another user's email in GET /users/:uid", async () => {
const response = await fetch(`${server.url}/users/${coResident.uid}`, {
headers: { Authorization: basic(normal.email) },
});
assert.equal(response.status, 200);
assert.ok(!("email" in ((await response.json()) as object)));
});
it("allows global admins to see user emails", async () => {
const response = await fetch(`${server.url}/users`, {
headers: { Authorization: basic(admin.email) },
});
assert.equal(response.status, 200);
const users = (await response.json()) as ClientUser[];
assert.equal(
users.find((user) => user.uid === coResident.uid)?.email,
coResident.email,
);
});
it("makes lookup by email global-admin-only", async () => {
const denied = await fetch(
`${server.url}/users/by-email?email=${encodeURIComponent(coResident.email)}`,
{ headers: { Authorization: basic(normal.email) } },
);
assert.equal(denied.status, 401);
const allowed = await fetch(
`${server.url}/users/by-email?email=${encodeURIComponent(coResident.email)}`,
{ headers: { Authorization: basic(admin.email) } },
);
assert.equal(allowed.status, 200);
assert.equal(
((await allowed.json()) as ClientUser).email,
coResident.email,
);
});
it("shows co-resident emails to an abode admin", async () => {
const response = await fetch(`${server.url}/abodes/${aid}/users`, {
headers: { Authorization: basic(abodeAdmin.email) },
});
assert.equal(response.status, 200);
const users = (await response.json()) as ClientUser[];
assert.equal(
users.find((user) => user.uid === coResident.uid)?.email,
coResident.email,
);
});
it("hides co-resident emails from a non-admin resident", async () => {
const response = await fetch(`${server.url}/abodes/${aid}/users`, {
headers: { Authorization: basic(normal.email) },
});
assert.equal(response.status, 200);
const users = (await response.json()) as ClientUser[];
assert.equal(
users.find((user) => user.uid === normal.uid)?.email,
normal.email,
);
assert.ok(!("email" in users.find((user) => user.uid === coResident.uid)!));
});
});
-181
View File
@@ -1,181 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
createuser,
updateuser,
clientuser,
loginuser,
createabode,
updateabode,
createresident,
createapikey,
} from "../../src/schema/validators.js";
describe("createuser validator", () => {
it("accepts a valid CreateUser object", () => {
const result = createuser({
email: "user@example.com",
name: "Alice",
password: "#unset",
flags: {},
});
assert.equal(result, true);
});
it("rejects missing name", () => {
const result = createuser({
email: "user@example.com",
password: "#unset",
flags: {},
});
assert.equal(result, false);
assert.ok(createuser.errors && createuser.errors.length > 0);
});
it("rejects missing email", () => {
const result = createuser({
name: "Alice",
password: "#unset",
flags: {},
});
assert.equal(result, false);
});
it("rejects missing password", () => {
const result = createuser({
email: "user@example.com",
name: "Alice",
flags: {},
});
assert.equal(result, false);
});
});
describe("updateuser validator", () => {
it("accepts update with just uid", () => {
const result = updateuser({ uid: "11111111-1111-1111-1111-111111111111" });
assert.equal(result, true);
});
it("accepts update with uid and name", () => {
const result = updateuser({
uid: "11111111-1111-1111-1111-111111111111",
name: "New Name",
});
assert.equal(result, true);
});
it("rejects missing uid", () => {
const result = updateuser({ name: "Alice" });
assert.equal(result, false);
});
});
describe("clientuser validator", () => {
it("accepts a valid ClientUser (no password)", () => {
const result = clientuser({
uid: "11111111-1111-1111-1111-111111111111",
email: "user@example.com",
name: "Alice",
flags: {},
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
});
assert.equal(result, true);
});
it("rejects object with password field", () => {
const result = clientuser({
uid: "11111111-1111-1111-1111-111111111111",
email: "user@example.com",
name: "Alice",
password: "#unset",
flags: {},
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
});
assert.equal(result, false);
});
});
describe("loginuser validator", () => {
it("accepts valid LoginUser", () => {
const result = loginuser({ email: "user@example.com", password: "secret" });
assert.equal(result, true);
});
it("rejects missing password", () => {
const result = loginuser({ email: "user@example.com" });
assert.equal(result, false);
});
it("rejects missing email", () => {
const result = loginuser({ password: "secret" });
assert.equal(result, false);
});
});
describe("createabode validator", () => {
it("accepts valid CreateAbode", () => {
const result = createabode({ name: "My Abode" });
assert.equal(result, true);
});
it("rejects missing name", () => {
const result = createabode({});
assert.equal(result, false);
});
});
describe("updateabode validator", () => {
it("accepts update with just aid", () => {
const result = updateabode({ aid: "22222222-2222-2222-2222-222222222222" });
assert.equal(result, true);
});
it("accepts update with aid and name", () => {
const result = updateabode({
aid: "22222222-2222-2222-2222-222222222222",
name: "New Name",
});
assert.equal(result, true);
});
});
describe("createresident validator", () => {
it("accepts valid CreateResident", () => {
const result = createresident({
uid: "11111111-1111-1111-1111-111111111111",
aid: "22222222-2222-2222-2222-222222222222",
flags: {},
});
assert.equal(result, true);
});
it("rejects missing uid", () => {
const result = createresident({
aid: "22222222-2222-2222-2222-222222222222",
flags: {},
});
assert.equal(result, false);
});
});
describe("createapikey validator", () => {
it("accepts valid CreateApikey", () => {
const result = createapikey({
uid: "11111111-1111-1111-1111-111111111111",
name: "My Key",
permissions: {},
});
assert.equal(result, true);
});
it("rejects missing name", () => {
const result = createapikey({
uid: "11111111-1111-1111-1111-111111111111",
permissions: {},
});
assert.equal(result, false);
});
});
+14 -14
View File
@@ -1,15 +1,15 @@
{
"compilerOptions": {
"rootDir": "src",
"strict": true,
"verbatimModuleSyntax": true,
"moduleResolution": "nodenext",
"module": "nodenext",
"target": "esnext",
"allowImportingTsExtensions": false,
"noEmit": true,
"sourceMap": true,
"jsx": "react-jsxdev"
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}
"compilerOptions": {
"rootDir": "src",
"strict": true,
"verbatimModuleSyntax": true,
"moduleResolution": "nodenext",
"module": "nodenext",
"target": "esnext",
"allowImportingTsExtensions": false,
"noEmit": true,
"sourceMap": true,
"jsx": "react-jsxdev"
},
"include": ["src/**/*.ts","src/**/*.tsx"]
}
-7
View File
@@ -1,7 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"]
}
+12 -12
View File
@@ -25,8 +25,8 @@ export default async (): Promise<Configuration[]> => {
process.env.DB_SOURCES === "dynamic"
? "dynamic"
: process.env.DB_SOURCES === "static"
? "static"
: "shared";
? "static"
: "shared";
let disableDbSqlite = process.env.DISABLE_DB_SQLITE === "1";
let disableBs3 = disableDbSqlite || process.env.DISABLE_BS3 === "1";
@@ -56,7 +56,7 @@ export default async (): Promise<Configuration[]> => {
(await readdir(file("./src/bin"))).map((bin) => [
bin.replace(/\..+$/, ""),
file(`./src/bin/${bin}`),
]),
])
);
for (const bin of Object.keys(binaries)) {
copies.push({
@@ -93,12 +93,12 @@ export default async (): Promise<Configuration[]> => {
} else if (dbSources === "static") {
console.log("Resolving db interfaces statically");
aliases[file("./src/db/dbSources.ts")] = file(
"./src/db/dbSources.static.ts",
"./src/db/dbSources.static.ts"
);
} else {
console.log("Resolving db interfaces shared");
aliases[file("./src/db/dbSources.ts")] = file(
"./src/db/dbSources.shared.ts",
"./src/db/dbSources.shared.ts"
);
}
@@ -111,21 +111,21 @@ export default async (): Promise<Configuration[]> => {
} else if (disableBs3) {
console.log("Disabling better-sqlite3 sqlite db backend");
aliases[file("./src/db/sqlite/impl/implementations.ts")] = file(
"./src/db/sqlite/impl/implementations.node.ts",
"./src/db/sqlite/impl/implementations.node.ts"
);
compiledSources.push("sqlite");
} else if (disableNodeSqlite) {
console.log("Disabling node:sqlite sqlite db backend");
aliases[file("./src/db/sqlite/impl/implementations.ts")] = file(
"./src/db/sqlite/impl/implementations.bs3.ts",
"./src/db/sqlite/impl/implementations.bs3.ts"
);
compiledSources.push("sqlite");
} else {
console.log(
"Enabling sqlite db interface with better-sqlite3 and node:sqlite backends",
"Enabling sqlite db interface with better-sqlite3 and node:sqlite backends"
);
aliases[file("./src/db/sqlite/impl/implementations.ts")] = file(
"./src/db/sqlite/impl/implementations.all.ts",
"./src/db/sqlite/impl/implementations.all.ts"
);
compiledSources.push("sqlite");
}
@@ -145,18 +145,18 @@ export default async (): Promise<Configuration[]> => {
defines.compiledSources = JSON.stringify(compiledSources);
for (const source of existingSources)
defines[`compiledSources.${source}`] = JSON.stringify(
compiledSources.includes(source),
compiledSources.includes(source)
);
if (!compiledSources.length) {
console.warn(
"No db interface enabled, the builds will be completely useless",
"No db interface enabled, the builds will be completely useless"
);
process.exit(1);
}
// log about the natives we have
console.log(
`Using ${Object.values(natives).filter(Boolean).length} natives:`,
`Using ${Object.values(natives).filter(Boolean).length} natives:`
);
for (const [key, path] of Object.entries(natives)) {
if (path) console.log(`- ${key}: ${path}`);