Compare commits

..
8 Commits
Author SHA1 Message Date
codingetandCodex 0355bd0b2e fix: restrict user email visibility
Co-Authored-By: gpt-5.6-sol <noreply@openai.com>
2026-07-23 23:18:59 +02:00
codingetandCodex 1e10391e20 style: format postgres notes implementation
CI / lint (pull_request) Successful in 26s
CI / format (pull_request) Successful in 27s
CI / install-and-build (pull_request) Successful in 50s
CI / typecheck-tests (pull_request) Successful in 33s
CI / typecheck-source (pull_request) Successful in 35s
CI / test (pull_request) Successful in 44s
Co-Authored-By: gpt-5.6-luna <noreply@openai.com>
2026-07-23 20:55:45 +00:00
codingetandCodex a0a436c4b8 feat(postgres): implement notes CRUD and export import
CI / lint (pull_request) Successful in 32s
CI / format (pull_request) Failing after 32s
CI / install-and-build (pull_request) Successful in 1m0s
CI / typecheck-tests (pull_request) Successful in 36s
CI / typecheck-source (pull_request) Successful in 37s
CI / test (pull_request) Successful in 41s
Co-Authored-By: gpt-5.6-luna <noreply@openai.com>
2026-07-23 18:49:23 +00:00
codingetandClaude d7e31dfce9 docs: codify the FK-safe stream ordering as a wire-format invariant
CI / lint (pull_request) Successful in 31s
CI / format (pull_request) Successful in 31s
CI / install-and-build (pull_request) Successful in 55s
CI / typecheck-tests (pull_request) Successful in 31s
CI / typecheck-source (pull_request) Successful in 31s
CI / test (pull_request) Successful in 42s
The postgres importer inserts records sequentially with FK enforcement live,
so it depends on records arriving in dependency order. That requirement was
implicit in each backend's export table list; make it explicit and enforced.

- Add EXPORT_KIND_ORDER (user, abode, resident, apikey, note) as a documented
  single source of truth, with the FK dependency chain spelled out on its doc
  comment, and note the ordering guarantee on the ExportEnvelope wire-format
  doc. Derive the isExportKind set from it.
- Both backends' export() now iterate EXPORT_KIND_ORDER via a loader map
  (postgres omits note by leaving it out of the map), so emission order is
  tied to the constant and can't drift.
- Tests: a change-detector on EXPORT_KIND_ORDER, plus assertions that both the
  sqlite and postgres (fake) exports emit record kinds grouped in FK-safe
  order (kind rank non-decreasing down the stream, after the leading meta).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:14:15 +00:00
codingetandClaude 3a56dcd9e5 feat: add export/import to the postgres backend; import auto-detects backend
CI / format (pull_request) Successful in 23s
CI / lint (pull_request) Successful in 23s
CI / install-and-build (pull_request) Successful in 45s
CI / typecheck-source (pull_request) Successful in 25s
CI / typecheck-tests (pull_request) Successful in 30s
CI / test (pull_request) Successful in 40s
The export/import plan predated the postgres backend. Bring it up to parity:

- PostgresInterface implements Exportable + Importable, mirroring the sqlite
  backend. Export is a signal-checked async generator (one query per table);
  import drives a transaction via `WrappedPool.multi`, which rolls back on any
  error/abort and commits only after the whole stream is consumed cleanly.
- The `note` kind is skipped on postgres (its note CRUD is still unimplemented,
  so a pg database holds none) — a full dump from sqlite imports its
  user/abode/resident/apikey records and drops notes.
- Unlike sqlite (PRAGMA foreign_keys=off), postgres keeps FK enforcement; the
  FK-safe insertion order keeps a full dump valid, and truly-dangling partial
  dumps will (correctly) fail.
- abode-import now resolves the backend via getDbInterface instead of
  constructing SqliteInterface directly; isImportable keeps it from ever
  running over the remote (api) interface, which has no import.
- Add isImportable(); make postgres selectClientApikeys' where optional.
- Tests: exercise the real PostgresInterface export/import paths against an
  in-memory fake WrappedPgClient (no pg service in CI) — NDJSON shape,
  meta.source, filtering, note-skip, insert dispatch, and error/abort rollback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 23:36:21 +00:00
codingetandClaude aadc950e24 fix: scope apikey export to the caller for non-admins
CI / format (pull_request) Successful in 23s
CI / lint (pull_request) Successful in 23s
CI / install-and-build (pull_request) Successful in 46s
CI / typecheck-tests (pull_request) Successful in 30s
CI / typecheck-source (pull_request) Successful in 31s
CI / test (pull_request) Successful in 40s
A non-admin's forced export filter includes co-resident *user* records so
abode/resident data isn't left with dangling references, but the same `users`
allowlist was also governing `apikey` records — leaking co-residents' apikey
metadata (name/permissions/expiry, though not the secret token).

Add a dedicated `apikeys` uid-allowlist to ExportFilter that scopes apikey
records specifically, falling back to `users` when absent (so existing
unfiltered/voluntary-narrowing behaviour and the round-trip are unchanged).
computeForcedExportFilter now sets it to the caller alone (intersected with an
apikey credential's restrict_users), so a non-admin can only ever export their
own keys. Global admins (forced filter null) are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 23:16:31 +00:00
codingetandClaude ccb970f200 feat: add export/import streaming to the pluggable backends
CI / format (pull_request) Successful in 28s
CI / lint (pull_request) Successful in 28s
CI / install-and-build (pull_request) Successful in 53s
CI / typecheck-tests (pull_request) Successful in 28s
CI / typecheck-source (pull_request) Successful in 32s
CI / test (pull_request) Successful in 43s
Add backend-agnostic data export/import over an NDJSON wire format, plus an
inspect utility, exposed via three new CLIs and an HTTP export endpoint.

- ExportImport types + filter helpers (kind/record scoping, hard-intersection
  of filters) in src/db/{types/ExportImport,export/filter}.ts
- SqliteInterface implements Exportable + Importable: signal-checked async
  generator export (one query per table, per-record yield, trailing error
  sentinel on mid-stream failure) and a manually-driven import transaction
  that rolls back on any error/abort and never commits partial data
- ApiInterface implements Exportable via its own fetch({signal})
- computeForcedExportFilter enforces non-global-admin scope (resided-in abodes
  + co-resident users, intersected with apikey restrict_*); GET /export
  intersects it with the caller's filter and wires an AbortController to the
  response socket
- inspectExportStream reports kinds/counts from any stream without a db
- abode-export / abode-import / abode-inspect CLIs (import is sqlite-only)
- Secrets are not exported: imported users default to '#unset' passwords and
  apikeys are re-minted a token (ClientApikey view round-trips exactly)
- test/tools/export-import.test.ts: round-trip, filter narrowing, forced-scope,
  export/import cancellation, in-process Koa endpoint, inspect

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 22:29:03 +00:00
codingetandCodex 31d4636dde ci: add pull request quality gates
CI / install-and-build (pull_request) Successful in 1m29s
CI / format (pull_request) Successful in 51s
CI / typecheck-source (pull_request) Successful in 41s
CI / typecheck-tests (pull_request) Successful in 39s
CI / test (pull_request) Successful in 51s
CI / lint (pull_request) Successful in 21s
Co-Authored-By: gpt-5.6-terra <noreply@openai.com>
2026-07-22 21:50:29 +00:00
92 changed files with 3282 additions and 551 deletions
+9
View File
@@ -0,0 +1,9 @@
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
@@ -0,0 +1,124 @@
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
@@ -0,0 +1 @@
22
+2
View File
@@ -0,0 +1,2 @@
dist
node_modules
+22
View File
@@ -0,0 +1,22 @@
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",
},
},
);
+1306 -20
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -31,7 +31,9 @@
"test:shared": "node --import tsx/esm --import ./src/meta/dev/register.ts --test $(find test/backends -name 'index.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)", "test:tools": "node --import tsx/esm --import ./src/meta/dev/register.ts --test $(find test/tools -name '*.test.ts' | sort)",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"typecheck:test": "tsc --noEmit -p tsconfig.test.json" "typecheck:test": "tsc --noEmit -p tsconfig.test.json",
"lint": "eslint .",
"format:check": "prettier --check ."
}, },
"dependencies": { "dependencies": {
"@koa/bodyparser": "^6.0.0", "@koa/bodyparser": "^6.0.0",
@@ -50,21 +52,25 @@
"react-redux": "^9.2.0" "react-redux": "^9.2.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.5",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/pg": "^8.20.0",
"@types/koa": "^3.0.0", "@types/koa": "^3.0.0",
"@types/koa__router": "^12.0.4", "@types/koa__router": "^12.0.4",
"@types/pg": "^8.20.0",
"@types/react": "^19.1.12", "@types/react": "^19.1.12",
"@types/webpack-bundle-analyzer": "^4.7.0", "@types/webpack-bundle-analyzer": "^4.7.0",
"copy-webpack-plugin": "^13.0.1", "copy-webpack-plugin": "^13.0.1",
"css-loader": "^7.1.2", "css-loader": "^7.1.2",
"dynohot": "^2.1.1", "dynohot": "^2.1.1",
"eslint": "^9.39.5",
"mini-css-extract-plugin": "^2.9.4", "mini-css-extract-plugin": "^2.9.4",
"prettier": "^3.6.2",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
"scss-loader": "^0.0.1", "scss-loader": "^0.0.1",
"ts-loader": "^9.5.4", "ts-loader": "^9.5.4",
"tsx": "^4.20.5", "tsx": "^4.20.5",
"typescript": "^5.9.2", "typescript": "^5.9.2",
"typescript-eslint": "^8.65.0",
"val-loader": "^6.0.0", "val-loader": "^6.0.0",
"webpack-bundle-analyzer": "^4.10.2", "webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^6.0.1" "webpack-cli": "^6.0.1"
+1 -1
View File
@@ -15,7 +15,7 @@ function printUsage(err: boolean | string = false): never {
log("Usage:"); log("Usage:");
log("\tabode-export --help"); log("\tabode-export --help");
log( log(
"\tabode-export <database-url> [--kinds=user,abode,...] [--exclude-kinds=...] \\" "\tabode-export <database-url> [--kinds=user,abode,...] [--exclude-kinds=...] \\",
); );
log("\t [--abodes=aid,...] [--users=uid,...] [--out=file|-]"); log("\t [--abodes=aid,...] [--users=uid,...] [--out=file|-]");
process.exit(err ? 1 : 0); process.exit(err ? 1 : 0);
+19 -13
View File
@@ -1,9 +1,7 @@
import { createReadStream } from "node:fs"; import { createReadStream } from "node:fs";
import { getWrappedDb } from "../db/sqlite/impl/index.js"; import { getDbInterface } from "../db/index.js";
import { SqliteInterface } from "../db/sqlite/SqliteInterface.js";
import { parseSqliteUrl } from "../db/sqlite/url.js";
import { isExportKind } from "../db/export/filter.js"; import { isExportKind } from "../db/export/filter.js";
import type { ExportFilter } from "../db/types/ExportImport.js"; import { isImportable, type ExportFilter } from "../db/types/ExportImport.js";
const args = process.argv.slice(2); const args = process.argv.slice(2);
@@ -15,12 +13,15 @@ function printUsage(err: boolean | string = false): never {
} }
log("Usage:"); log("Usage:");
log("\tabode-import --help"); log("\tabode-import --help");
log("\tabode-import <database-url> <input-file|-> [--kinds=...] \\");
log( log(
"\tabode-import <sqlite-database-url> <input-file|-> [--kinds=...] \\" "\t [--exclude-kinds=...] [--abodes=aid,...] [--users=uid,...]",
); );
log("\t [--exclude-kinds=...] [--abodes=aid,...] [--users=uid,...]");
log(""); log("");
log("The target database must already be migrated (run abode-migrate first)."); 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); process.exit(err ? 1 : 0);
} }
@@ -40,7 +41,7 @@ for (const arg of args) {
const url = positional[0]; const url = positional[0];
const input = positional[1]; const input = positional[1];
if (!url) printUsage("missing <sqlite-database-url>"); if (!url) printUsage("missing <database-url>");
if (!input) printUsage("missing <input-file|->"); if (!input) printUsage("missing <input-file|->");
if (positional.length > 2) printUsage("too many arguments"); if (positional.length > 2) printUsage("too many arguments");
@@ -71,12 +72,17 @@ if (abodes) filter.abodes = abodes;
const users = parseList(flags.get("users")); const users = parseList(flags.get("users"));
if (users) filter.users = users; if (users) filter.users = users;
// Import is sqlite-only: construct the backend directly rather than resolving // Resolve the backend generically. Import lives on the local backends (sqlite,
// generically, so it can never be pointed at a remote (api) target. // postgres); the remote (api) interface has no `import`, so `isImportable`
const db = new SqliteInterface(getWrappedDb(...parseSqliteUrl(url))); // 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 = const source = input === "-" ? process.stdin : createReadStream(input);
input === "-" ? process.stdin : createReadStream(input);
const ac = new AbortController(); const ac = new AbortController();
const onSignal = () => ac.abort(); const onSignal = () => ac.abort();
+3 -3
View File
@@ -40,7 +40,7 @@ switch (cmd) {
if (!current.length) console.log("(none)"); if (!current.length) console.log("(none)");
for (const migration of current) { for (const migration of current) {
console.log( console.log(
`- ${migration.id} (${migration.name}) applied at ${migration.applied_at}` `- ${migration.id} (${migration.name}) applied at ${migration.applied_at}`,
); );
} }
break; break;
@@ -73,7 +73,7 @@ switch (cmd) {
password: await hashPassword("changeme"), password: await hashPassword("changeme"),
}); });
console.log( 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(); users = await db.listUsers();
@@ -88,7 +88,7 @@ switch (cmd) {
expires_at: null, expires_at: null,
}); });
console.log( 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( console.log(
`Compiled with ${compiledSources.length} sources:`, `Compiled with ${compiledSources.length} sources:`,
compiledSources.join(", ") compiledSources.join(", "),
); );
const url = args[0] ?? "abode://"; const url = args[0] ?? "abode://";
@@ -31,7 +31,7 @@ for (const source of sources) {
console.log(`- ${source.name}`); console.log(`- ${source.name}`);
console.log( console.log(
" - protocols:", " - protocols:",
source.protocols.map((x) => `'${x}'`).join(" ") source.protocols.map((x) => `'${x}'`).join(" "),
); );
const match = source.checkUrl(url); const match = source.checkUrl(url);
console.log(` - matches url: ${match}`); console.log(` - matches url: ${match}`);
@@ -41,7 +41,7 @@ for (const source of sources) {
console.log( console.log(
` - generates an interface named ${db.name} ${ ` - generates an interface named ${db.name} ${
db.backend ? "with" : "without" db.backend ? "with" : "without"
} backend` } backend`,
); );
await db.close().catch(console.error); await db.close().catch(console.error);
} catch (e) { } catch (e) {
@@ -53,7 +53,7 @@ for (const source of sources) {
console.log( console.log(
` - generates a migrator knowing ${ ` - generates a migrator knowing ${
db.listAvailableMigrations().length db.listAvailableMigrations().length
} migrations` } migrations`,
); );
} catch (e) { } catch (e) {
console.error(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) => { process.stdin.once("data", (chunk) => {
const result = chunk.toString("utf8"); const result = chunk.toString("utf8");
const match = result.match( 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"); if (!match) return ko("Didn't recognize terminal bg color");
const [r, g, b] = match const [r, g, b] = match
@@ -78,7 +78,7 @@ if (import.meta.hot) {
import.meta.hot.accept("../tui/App.js", (mod) => { import.meta.hot.accept("../tui/App.js", (mod) => {
fullscreenApp.instance.rerender( fullscreenApp.instance.rerender(
(mod.app as typeof app)({ db, bgColor, store }) (mod.app as typeof app)({ db, bgColor, store }),
); );
}); });
} }
+6 -8
View File
@@ -27,10 +27,7 @@ import type {
} from "../types/User.js"; } from "../types/User.js";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import type { ReadableStream as WebReadableStream } from "node:stream/web"; import type { ReadableStream as WebReadableStream } from "node:stream/web";
import type { import type { Exportable, ExportOptions } from "../types/ExportImport.js";
Exportable,
ExportOptions,
} from "../types/ExportImport.js";
export class ApiInterface implements DbInterface, Exportable { export class ApiInterface implements DbInterface, Exportable {
#root: string; #root: string;
@@ -45,7 +42,7 @@ export class ApiInterface implements DbInterface, Exportable {
}: { }: {
headers?: Record<string, string>; headers?: Record<string, string>;
readonly?: boolean; readonly?: boolean;
} = {} } = {},
) { ) {
if (root.endsWith("/")) root = root.slice(0, -1); if (root.endsWith("/")) root = root.slice(0, -1);
this.#root = root; this.#root = root;
@@ -88,7 +85,7 @@ export class ApiInterface implements DbInterface, Exportable {
params?: Record<string, string>; params?: Record<string, string>;
body?: unknown; body?: unknown;
headers?: Record<string, string>; headers?: Record<string, string>;
} = {} } = {},
): Promise<T> { ): Promise<T> {
const resolvedHeaders = { ...this.#headers, ...headers }; const resolvedHeaders = { ...this.#headers, ...headers };
if (body !== undefined) { if (body !== undefined) {
@@ -253,7 +250,7 @@ export class ApiInterface implements DbInterface, Exportable {
}); });
} }
async createApikey( async createApikey(
apikey: CreateApikey apikey: CreateApikey,
): Promise<[ClientApikey, `at_${string}`]> { ): Promise<[ClientApikey, `at_${string}`]> {
this.#checkReadonly(); this.#checkReadonly();
const { apikey: key, token } = await this.#call<{ const { apikey: key, token } = await this.#call<{
@@ -310,7 +307,8 @@ export class ApiInterface implements DbInterface, Exportable {
const { filter, signal } = options; const { filter, signal } = options;
const sp = new URLSearchParams(); const sp = new URLSearchParams();
if (filter?.kinds) sp.set("kinds", filter.kinds.join(",")); if (filter?.kinds) sp.set("kinds", filter.kinds.join(","));
if (filter?.excludeKinds) sp.set("excludeKinds", filter.excludeKinds.join(",")); if (filter?.excludeKinds)
sp.set("excludeKinds", filter.excludeKinds.join(","));
if (filter?.abodes) sp.set("abodes", filter.abodes.join(",")); if (filter?.abodes) sp.set("abodes", filter.abodes.join(","));
if (filter?.users) sp.set("users", filter.users.join(",")); if (filter?.users) sp.set("users", filter.users.join(","));
const query = sp.toString(); const query = sp.toString();
+1 -1
View File
@@ -3,7 +3,7 @@ import { apiProtocols } from "./url.js";
const getApi = () => const getApi = () =>
import(/* webpackChunkName: 'dbsource-api' */ "./getdb.static.js").then( import(/* webpackChunkName: 'dbsource-api' */ "./getdb.static.js").then(
(x) => x.default (x) => x.default,
); );
const getApiDynamic: GetDbDynamic = { const getApiDynamic: GetDbDynamic = {
+4 -2
View File
@@ -17,7 +17,9 @@ export function parseApiUrl(url: string) {
urlObj.href = urlObj.href.replace(/^abode\+/, ""); urlObj.href = urlObj.href.replace(/^abode\+/, "");
const readonly = (urlObj.searchParams.get("readonly") ?? "0") !== "0"; const readonly = (urlObj.searchParams.get("readonly") ?? "0") !== "0";
const headers = Object.fromEntries( const headers = Object.fromEntries(
[...urlObj.searchParams.entries()].filter(([param]) => param !== "readonly") [...urlObj.searchParams.entries()].filter(
([param]) => param !== "readonly",
),
); );
if (urlObj.username) { if (urlObj.username) {
headers["Authorization"] = headers["Authorization"] =
@@ -26,7 +28,7 @@ export function parseApiUrl(url: string) {
[ [
decodeURIComponent(urlObj.username), decodeURIComponent(urlObj.username),
decodeURIComponent(urlObj.password), decodeURIComponent(urlObj.password),
].join(":") ].join(":"),
); );
urlObj.username = ""; urlObj.username = "";
urlObj.password = ""; urlObj.password = "";
+1 -1
View File
@@ -15,7 +15,7 @@ export async function getDbSources(url: string): Promise<GetDbStatic[]> {
.catch(() => null) .catch(() => null)
.then((dbSource) => { .then((dbSource) => {
if (dbSource) dbSources.push(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; let rawGetDbSources: typeof getDbSources | undefined = undefined;
const getGetDbSources = () => const getGetDbSources = () =>
import(/* webpackChunkName: 'dbsources' */ "./dbSources.static.js").then( import(/* webpackChunkName: 'dbsources' */ "./dbSources.static.js").then(
(x) => x.getDbSources (x) => x.getDbSources,
); );
export async function getDbSources(url: string): Promise<GetDbStatic[]> { export async function getDbSources(url: string): Promise<GetDbStatic[]> {
+18 -15
View File
@@ -1,12 +1,10 @@
import type { ExportFilter, ExportKind } from "../types/ExportImport.js"; import {
EXPORT_KIND_ORDER,
type ExportFilter,
type ExportKind,
} from "../types/ExportImport.js";
const EXPORT_KINDS = new Set<ExportKind>([ const EXPORT_KINDS = new Set<ExportKind>(EXPORT_KIND_ORDER);
"user",
"abode",
"resident",
"apikey",
"note",
]);
export function isExportKind(x: unknown): x is ExportKind { export function isExportKind(x: unknown): x is ExportKind {
return typeof x === "string" && EXPORT_KINDS.has(x as ExportKind); return typeof x === "string" && EXPORT_KINDS.has(x as ExportKind);
@@ -15,7 +13,7 @@ export function isExportKind(x: unknown): x is ExportKind {
/** Whether a `kind` survives a filter's `kinds`/`excludeKinds` rules. */ /** Whether a `kind` survives a filter's `kinds`/`excludeKinds` rules. */
export function kindAllowed( export function kindAllowed(
filter: ExportFilter | undefined, filter: ExportFilter | undefined,
kind: ExportKind kind: ExportKind,
): boolean { ): boolean {
if (!filter) return true; if (!filter) return true;
if (filter.kinds && !filter.kinds.includes(kind)) return false; if (filter.kinds && !filter.kinds.includes(kind)) return false;
@@ -24,20 +22,24 @@ export function kindAllowed(
} }
/** /**
* Whether an individual record passes a filter's `abodes`/`users` allowlists. * Whether an individual record passes a filter's `abodes`/`users`/`apikeys`
* `abodes` scopes abode/resident/note (by aid); `users` scopes user/apikey * allowlists. `abodes` scopes abode/resident/note (by aid); `users` scopes user
* (by uid). An absent allowlist means "unrestricted". * (by uid); `apikey` records are scoped by `apikeys` when present, else by
* `users`. An absent allowlist means "unrestricted".
*/ */
export function recordAllowed( export function recordAllowed(
filter: ExportFilter | undefined, filter: ExportFilter | undefined,
kind: ExportKind, kind: ExportKind,
record: { uid?: string; aid?: string } record: { uid?: string; aid?: string },
): boolean { ): boolean {
if (!filter) return true; if (!filter) return true;
switch (kind) { switch (kind) {
case "user": case "user":
case "apikey":
return !filter.users || filter.users.includes(record.uid as string); 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 "abode":
case "resident": case "resident":
case "note": case "note":
@@ -66,7 +68,7 @@ function unionList<T extends string>(a?: T[], b?: T[]): T[] | undefined {
*/ */
export function intersectExportFilters( export function intersectExportFilters(
a: ExportFilter | null | undefined, a: ExportFilter | null | undefined,
b: ExportFilter | null | undefined b: ExportFilter | null | undefined,
): ExportFilter { ): ExportFilter {
if (!a) return b ?? {}; if (!a) return b ?? {};
if (!b) return a; if (!b) return a;
@@ -75,5 +77,6 @@ export function intersectExportFilters(
excludeKinds: unionList(a.excludeKinds, b.excludeKinds), excludeKinds: unionList(a.excludeKinds, b.excludeKinds),
abodes: intersectList(a.abodes, b.abodes), abodes: intersectList(a.abodes, b.abodes),
users: intersectList(a.users, b.users), users: intersectList(a.users, b.users),
apikeys: intersectList(a.apikeys, b.apikeys),
}; };
} }
+1 -1
View File
@@ -22,7 +22,7 @@ export type InspectResult = {
*/ */
export async function inspectExportStream( export async function inspectExportStream(
source: NodeJS.ReadableStream, source: NodeJS.ReadableStream,
options: { signal?: AbortSignal; stopAfterKinds?: ExportKind[] } = {} options: { signal?: AbortSignal; stopAfterKinds?: ExportKind[] } = {},
): Promise<InspectResult> { ): Promise<InspectResult> {
const { signal, stopAfterKinds } = options; const { signal, stopAfterKinds } = options;
const counts: Partial<Record<ExportKind, number>> = {}; const counts: Partial<Record<ExportKind, number>> = {};
+309 -44
View File
@@ -35,6 +35,9 @@ import {
selectClientUsers, selectClientUsers,
selectResident, selectResident,
selectResidents, selectResidents,
selectNote,
selectNotes,
selectPartialNotes,
} from "./query.js"; } from "./query.js";
import type { import type {
CreateNote, CreateNote,
@@ -43,8 +46,22 @@ import type {
UpdateNote, UpdateNote,
} from "../types/Note.js"; } from "../types/Note.js";
import type { WrappedPgClient } from "./pool.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 { export class PostgresInterface
implements BackendDbInterface, Exportable, Importable
{
#db: WrappedPgClient; #db: WrappedPgClient;
constructor(db: WrappedPgClient) { constructor(db: WrappedPgClient) {
@@ -92,7 +109,7 @@ export class PostgresInterface implements BackendDbInterface {
async getUserByEmail(email: string): Promise<ClientUser> { async getUserByEmail(email: string): Promise<ClientUser> {
const user = await selectClientUser( const user = await selectClientUser(
this.#db, this.#db,
sql`u."email" = ${{ text: email }}` sql`u."email" = ${{ text: email }}`,
); );
if (!user) throw new NotFoundAbodeError(); if (!user) throw new NotFoundAbodeError();
return user; return user;
@@ -111,7 +128,7 @@ export class PostgresInterface implements BackendDbInterface {
SELECT "uid", "email", "name", "flags", "created_at", "updated_at", "password" SELECT "uid", "email", "name", "flags", "created_at", "updated_at", "password"
FROM "users" FROM "users"
WHERE "email" = ${{ text: email }} WHERE "email" = ${{ text: email }}
` `,
); );
if (!rawUser) throw new NotFoundAbodeError(); if (!rawUser) throw new NotFoundAbodeError();
if (rawUser.password.startsWith("#")) throw new ConflictAbodeError(); if (rawUser.password.startsWith("#")) throw new ConflictAbodeError();
@@ -125,7 +142,7 @@ export class PostgresInterface implements BackendDbInterface {
sql` sql`
DELETE FROM "users" DELETE FROM "users"
WHERE "uid" = ${{ uuid: id }} WHERE "uid" = ${{ uuid: id }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
} }
@@ -145,10 +162,10 @@ export class PostgresInterface implements BackendDbInterface {
${{ text: user.password }}, ${{ text: user.password }},
${{ jsonb: user.flags }} ${{ jsonb: user.flags }}
) )
` `,
); );
return this.#getUserById(uid, tx); return this.#getUserById(uid, tx);
}) }),
); );
} }
async updateUser(user: UpdateUser): Promise<ClientUser> { async updateUser(user: UpdateUser): Promise<ClientUser> {
@@ -161,7 +178,8 @@ export class PostgresInterface implements BackendDbInterface {
if ("name" in user && user.name !== undefined) if ("name" in user && user.name !== undefined)
updates.push(sql`"name" = ${{ text: user.name }}`); updates.push(sql`"name" = ${{ text: user.name }}`);
if ("password" in user && user.password !== undefined) { 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("#")) { if (user.password.startsWith("#")) {
await tx.run(sql` await tx.run(sql`
DELETE FROM "apikeys" DELETE FROM "apikeys"
@@ -185,11 +203,11 @@ export class PostgresInterface implements BackendDbInterface {
"updated_at" = NOW(), "updated_at" = NOW(),
${joinSql(updates, sql`, `)} ${joinSql(updates, sql`, `)}
WHERE "uid" = ${{ uuid: user.uid }} WHERE "uid" = ${{ uuid: user.uid }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
return this.#getUserById(user.uid, tx); return this.#getUserById(user.uid, tx);
}) }),
); );
} }
@@ -210,7 +228,7 @@ export class PostgresInterface implements BackendDbInterface {
sql` sql`
DELETE FROM "abodes" DELETE FROM "abodes"
WHERE "aid" = ${{ uuid: id }} WHERE "aid" = ${{ uuid: id }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
} }
@@ -228,10 +246,10 @@ export class PostgresInterface implements BackendDbInterface {
${{ uuid: ctx.uid }}, ${{ uuid: ctx.uid }},
${{ uuid: ctx.uid }} ${{ uuid: ctx.uid }}
) )
` `,
); );
return this.#getAbodeById(aid, tx); return this.#getAbodeById(aid, tx);
}) }),
); );
} }
async updateAbode(abode: UpdateAbode, ctx: { uid: string }): Promise<Abode> { async updateAbode(abode: UpdateAbode, ctx: { uid: string }): Promise<Abode> {
@@ -250,11 +268,11 @@ export class PostgresInterface implements BackendDbInterface {
"updated_by" = ${{ uuid: ctx.uid }}, "updated_by" = ${{ uuid: ctx.uid }},
${joinSql(updates, sql`, `)} ${joinSql(updates, sql`, `)}
WHERE "aid" = ${{ uuid: abode.aid }} WHERE "aid" = ${{ uuid: abode.aid }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
return this.#getAbodeById(abode.aid, tx); return this.#getAbodeById(abode.aid, tx);
}) }),
); );
} }
@@ -270,11 +288,11 @@ export class PostgresInterface implements BackendDbInterface {
async #getResidentById( async #getResidentById(
uid: string, uid: string,
aid: string, aid: string,
db: WrappedPgClient db: WrappedPgClient,
): Promise<Resident> { ): Promise<Resident> {
const resident = await selectResident( const resident = await selectResident(
db, db,
sql`"uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}` sql`"uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}`,
); );
if (!resident) throw new NotFoundAbodeError(); if (!resident) throw new NotFoundAbodeError();
return resident; return resident;
@@ -288,13 +306,13 @@ export class PostgresInterface implements BackendDbInterface {
sql` sql`
DELETE FROM "residents" DELETE FROM "residents"
WHERE "uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }} WHERE "uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
} }
async createResident( async createResident(
resident: CreateResident, resident: CreateResident,
ctx: { uid: string } ctx: { uid: string },
): Promise<Resident> { ): Promise<Resident> {
this.#checkReadonly(); this.#checkReadonly();
return this.#db.rethrow(() => return this.#db.rethrow(() =>
@@ -309,15 +327,15 @@ export class PostgresInterface implements BackendDbInterface {
${{ uuid: ctx.uid }}, ${{ uuid: ctx.uid }},
${{ uuid: ctx.uid }} ${{ uuid: ctx.uid }}
) )
` `,
); );
return this.#getResidentById(resident.uid, resident.aid, tx); return this.#getResidentById(resident.uid, resident.aid, tx);
}) }),
); );
} }
async updateResident( async updateResident(
resident: updateResident, resident: updateResident,
ctx: { uid: string } ctx: { uid: string },
): Promise<Resident> { ): Promise<Resident> {
this.#checkReadonly(); this.#checkReadonly();
const updates = calcUpdates({ const updates = calcUpdates({
@@ -336,11 +354,11 @@ export class PostgresInterface implements BackendDbInterface {
WHERE WHERE
"uid" = ${{ uuid: resident.uid }} "uid" = ${{ uuid: resident.uid }}
AND "aid" = ${{ uuid: resident.aid }} AND "aid" = ${{ uuid: resident.aid }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
return this.#getResidentById(resident.uid, resident.aid, tx); return this.#getResidentById(resident.uid, resident.aid, tx);
}) }),
); );
} }
@@ -350,7 +368,7 @@ export class PostgresInterface implements BackendDbInterface {
sql` sql`
JOIN "residents" r ON u."uid" = r."uid" JOIN "residents" r ON u."uid" = r."uid"
WHERE r."aid" = ${{ uuid: id }} WHERE r."aid" = ${{ uuid: id }}
` `,
); );
} }
async listAbodesByUserId(id: string): Promise<Abode[]> { async listAbodesByUserId(id: string): Promise<Abode[]> {
@@ -359,7 +377,7 @@ export class PostgresInterface implements BackendDbInterface {
sql` sql`
JOIN "residents" r ON a."aid" = r."aid" JOIN "residents" r ON a."aid" = r."aid"
WHERE r."uid" = ${{ uuid: id }} WHERE r."uid" = ${{ uuid: id }}
` `,
); );
} }
@@ -418,17 +436,17 @@ export class PostgresInterface implements BackendDbInterface {
async #getApikeyByToken( async #getApikeyByToken(
token: `at_${string}`, token: `at_${string}`,
db: WrappedPgClient db: WrappedPgClient,
): Promise<ClientApikey> { ): Promise<ClientApikey> {
const apikey = await selectClientApikey( const apikey = await selectClientApikey(
db, db,
sql`k."token" = ${{ text: token }}` sql`k."token" = ${{ text: token }}`,
); );
if (!apikey) throw new NotFoundAbodeError(); if (!apikey) throw new NotFoundAbodeError();
return apikey; return apikey;
} }
async getUserByApikey( async getUserByApikey(
token: `at_${string}` token: `at_${string}`,
): Promise<[ClientUser, ClientApikey]> { ): Promise<[ClientUser, ClientApikey]> {
const apikey = await this.#getApikeyByToken(token, this.#db); const apikey = await this.#getApikeyByToken(token, this.#db);
if ( if (
@@ -445,13 +463,13 @@ export class PostgresInterface implements BackendDbInterface {
async getApikeyById(kid: string): Promise<ClientApikey> { async getApikeyById(kid: string): Promise<ClientApikey> {
const apikey = await selectClientApikey( const apikey = await selectClientApikey(
this.#db, this.#db,
sql`k."kid" = ${{ uuid: kid }}` sql`k."kid" = ${{ uuid: kid }}`,
); );
if (!apikey) throw new NotFoundAbodeError(); if (!apikey) throw new NotFoundAbodeError();
return apikey; return apikey;
} }
async createApikey( async createApikey(
apikey: CreateApikey apikey: CreateApikey,
): Promise<[ClientApikey, `at_${string}`]> { ): Promise<[ClientApikey, `at_${string}`]> {
this.#checkReadonly(); this.#checkReadonly();
const token = createApikeyToken(); const token = createApikeyToken();
@@ -459,7 +477,7 @@ export class PostgresInterface implements BackendDbInterface {
let expires = apikey.expires_at; let expires = apikey.expires_at;
if (expires === undefined) if (expires === undefined)
expires = new Date( expires = new Date(
new Date().getTime() + 1000 * 60 * 60 * 24 * 365 new Date().getTime() + 1000 * 60 * 60 * 24 * 365,
).toISOString(); ).toISOString();
if (expires && new Date(expires).getTime() < Date.now()) if (expires && new Date(expires).getTime() < Date.now())
throw new InvalidAbodeError(); throw new InvalidAbodeError();
@@ -487,24 +505,271 @@ export class PostgresInterface implements BackendDbInterface {
} }
async listNotes(): Promise<PartialNote[]> { async listNotes(): Promise<PartialNote[]> {
throw new Error("Unimplemented"); return selectPartialNotes(this.#db);
} }
async getNoteById(_nid: string): Promise<Note> { async #getNoteById(nid: string, db: WrappedPgClient): Promise<Note> {
throw new Error("Unimplemented"); const note = await selectNote(db, sql`n."nid" = ${{ uuid: nid }}`);
if (!note) throw new NotFoundAbodeError();
return note;
} }
async deleteNoteById(_nid: string): Promise<void> { async getNoteById(nid: string): Promise<Note> {
throw new Error("Unimplemented"); return this.#getNoteById(nid, this.#db);
} }
async createNote(_note: CreateNote, _ctx: { uid: string }): Promise<Note> { async deleteNoteById(nid: string): Promise<void> {
throw new Error("Unimplemented"); this.#checkReadonly();
const { changes } = await this.#db.run(sql`
DELETE FROM "notes" WHERE "nid" = ${{ uuid: nid }}
`);
if (!changes) throw new NotFoundAbodeError();
} }
async updateNote(_note: UpdateNote, _ctx: { uid: string }): Promise<Note> { async createNote(note: CreateNote, ctx: { uid: string }): Promise<Note> {
throw new Error("Unimplemented"); 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 listNotesByAbodeId(_aid: string): Promise<PartialNote[]> { async updateNote(note: UpdateNote, ctx: { uid: string }): Promise<Note> {
throw new Error("Unimplemented"); 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 listNotesByUserId(_uid: string): Promise<PartialNote[]> { async listNotesByAbodeId(aid: string): Promise<PartialNote[]> {
throw new Error("Unimplemented"); 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;
}
}
} }
} }
+5 -6
View File
@@ -24,7 +24,7 @@ export class PostgresMigrator implements Migrator {
`SELECT EXISTS ( `SELECT EXISTS (
SELECT 1 FROM information_schema.tables SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = '_migrations' WHERE table_schema = 'public' AND table_name = '_migrations'
) AS "exists"` ) AS "exists"`,
); );
if (!existsResult.rows[0]?.exists) return null; if (!existsResult.rows[0]?.exists) return null;
@@ -33,7 +33,7 @@ export class PostgresMigrator implements Migrator {
name: string; name: string;
applied_at: Date; 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) => ({ return result.rows.map((x) => ({
...x, ...x,
@@ -73,17 +73,16 @@ export class PostgresMigrator implements Migrator {
throw new Error(`Applied migration ${id} (${name}) not known`); throw new Error(`Applied migration ${id} (${name}) not known`);
if (migration.name !== name) if (migration.name !== name)
throw new Error( 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 = const start = migrations.findIndex((x) => x.id === current.at(-1)?.id) + 1;
migrations.findIndex((x) => x.id === current.at(-1)?.id) + 1;
const end = migrations.indexOf(target) + 1; const end = migrations.indexOf(target) + 1;
if (end < start) { if (end < start) {
throw new Error( 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}`,
); );
} }
+71
View File
@@ -1,6 +1,12 @@
import type { Abode } from "../types/Abode.js"; import type { Abode } from "../types/Abode.js";
import type { ApikeyPermissions, ClientApikey } from "../types/Apikey.js"; import type { ApikeyPermissions, ClientApikey } from "../types/Apikey.js";
import type { Resident, ResidentFlags } from "../types/Resident.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"; import type { ClientUser, PartialUser, UserFlags } from "../types/User.js";
export function pgToDate(d: Date | string): string { export function pgToDate(d: Date | string): string {
@@ -135,3 +141,68 @@ export function pgToClientApikey(apikey: {
expires_at: apikey.expires_at ? pgToDate(apikey.expires_at) : null, 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,
};
}
+3 -5
View File
@@ -31,9 +31,7 @@ async function rethrow<R>(fn: () => Promise<R>): Promise<R> {
// Rollback on a broken connection can itself throw; the original error is // Rollback on a broken connection can itself throw; the original error is
// the one worth surfacing. // the one worth surfacing.
export async function rollbackQuietly( export async function rollbackQuietly(client: pg.PoolClient): Promise<void> {
client: pg.PoolClient
): Promise<void> {
try { try {
await client.query("ROLLBACK"); await client.query("ROLLBACK");
} catch {} } catch {}
@@ -58,7 +56,7 @@ abstract class WrappedPgBase implements WrappedPgClient {
async all<R>(stmt: SqlCode): Promise<R[]> { async all<R>(stmt: SqlCode): Promise<R[]> {
const result = await this.#queryable.query( const result = await this.#queryable.query(
toPositional(stmt._sql), toPositional(stmt._sql),
stmt._vars stmt._vars,
); );
return result.rows as R[]; return result.rows as R[];
} }
@@ -72,7 +70,7 @@ abstract class WrappedPgBase implements WrappedPgClient {
async run(stmt: SqlCode): Promise<{ changes: number }> { async run(stmt: SqlCode): Promise<{ changes: number }> {
const result = await this.#queryable.query( const result = await this.#queryable.query(
toPositional(stmt._sql), toPositional(stmt._sql),
stmt._vars stmt._vars,
); );
return { changes: result.rowCount ?? 0 }; return { changes: result.rowCount ?? 0 };
} }
+67 -16
View File
@@ -2,11 +2,14 @@ import type { Abode } from "../types/Abode.js";
import type { ClientApikey } from "../types/Apikey.js"; import type { ClientApikey } from "../types/Apikey.js";
import type { Resident } from "../types/Resident.js"; import type { Resident } from "../types/Resident.js";
import type { ClientUser } from "../types/User.js"; import type { ClientUser } from "../types/User.js";
import type { Note, PartialNote } from "../types/Note.js";
import { import {
pgToAbode, pgToAbode,
pgToClientApikey, pgToClientApikey,
pgToClientUser, pgToClientUser,
pgToResident, pgToResident,
pgToNote,
pgToPartialNote,
} from "./cast.js"; } from "./cast.js";
import type { WrappedPgClient } from "./pool.js"; import type { WrappedPgClient } from "./pool.js";
import { sql, type SqlCode } from "./sql.js"; import { sql, type SqlCode } from "./sql.js";
@@ -26,20 +29,18 @@ const sqlClientUser = sql`
export async function selectClientUser( export async function selectClientUser(
db: WrappedPgClient, db: WrappedPgClient,
where: SqlCode where: SqlCode,
): Promise<ClientUser | null> { ): Promise<ClientUser | null> {
const raw = await db.get<RawClientUser>( const raw = await db.get<RawClientUser>(sql`${sqlClientUser} WHERE ${where}`);
sql`${sqlClientUser} WHERE ${where}`
);
if (raw) return pgToClientUser(raw); if (raw) return pgToClientUser(raw);
return null; return null;
} }
export async function selectClientUsers( export async function selectClientUsers(
db: WrappedPgClient, db: WrappedPgClient,
rest?: SqlCode rest?: SqlCode,
): Promise<ClientUser[]> { ): Promise<ClientUser[]> {
const rows = await db.all<RawClientUser>( const rows = await db.all<RawClientUser>(
rest ? sql`${sqlClientUser} ${rest}` : sqlClientUser rest ? sql`${sqlClientUser} ${rest}` : sqlClientUser,
); );
return rows.map(pgToClientUser); return rows.map(pgToClientUser);
} }
@@ -59,7 +60,7 @@ const sqlAbode = sql`
export async function selectAbode( export async function selectAbode(
db: WrappedPgClient, db: WrappedPgClient,
where: SqlCode where: SqlCode,
): Promise<Abode | null> { ): Promise<Abode | null> {
const raw = await db.get<RawAbode>(sql`${sqlAbode} WHERE ${where}`); const raw = await db.get<RawAbode>(sql`${sqlAbode} WHERE ${where}`);
if (raw) return pgToAbode(raw); if (raw) return pgToAbode(raw);
@@ -67,10 +68,10 @@ export async function selectAbode(
} }
export async function selectAbodes( export async function selectAbodes(
db: WrappedPgClient, db: WrappedPgClient,
rest?: SqlCode rest?: SqlCode,
): Promise<Abode[]> { ): Promise<Abode[]> {
const rows = await db.all<RawAbode>( const rows = await db.all<RawAbode>(
rest ? sql`${sqlAbode} ${rest}` : sqlAbode rest ? sql`${sqlAbode} ${rest}` : sqlAbode,
); );
return rows.map(pgToAbode); return rows.map(pgToAbode);
} }
@@ -91,7 +92,7 @@ const sqlResident = sql`
export async function selectResident( export async function selectResident(
db: WrappedPgClient, db: WrappedPgClient,
where: SqlCode where: SqlCode,
): Promise<Resident | null> { ): Promise<Resident | null> {
const raw = await db.get<RawResident>(sql`${sqlResident} WHERE ${where}`); const raw = await db.get<RawResident>(sql`${sqlResident} WHERE ${where}`);
if (raw) return pgToResident(raw); if (raw) return pgToResident(raw);
@@ -99,10 +100,10 @@ export async function selectResident(
} }
export async function selectResidents( export async function selectResidents(
db: WrappedPgClient, db: WrappedPgClient,
where?: SqlCode where?: SqlCode,
): Promise<Resident[]> { ): Promise<Resident[]> {
const rows = await db.all<RawResident>( const rows = await db.all<RawResident>(
where ? sql`${sqlResident} WHERE ${where}` : sqlResident where ? sql`${sqlResident} WHERE ${where}` : sqlResident,
); );
return rows.map(pgToResident); return rows.map(pgToResident);
} }
@@ -122,20 +123,70 @@ const sqlClientApikey = sql`
export async function selectClientApikey( export async function selectClientApikey(
db: WrappedPgClient, db: WrappedPgClient,
where: SqlCode where: SqlCode,
): Promise<ClientApikey | null> { ): Promise<ClientApikey | null> {
const raw = await db.get<RawClientApikey>( const raw = await db.get<RawClientApikey>(
sql`${sqlClientApikey} WHERE ${where}` sql`${sqlClientApikey} WHERE ${where}`,
); );
if (raw) return pgToClientApikey(raw); if (raw) return pgToClientApikey(raw);
return null; return null;
} }
export async function selectClientApikeys( export async function selectClientApikeys(
db: WrappedPgClient, db: WrappedPgClient,
where: SqlCode where?: SqlCode,
): Promise<ClientApikey[]> { ): Promise<ClientApikey[]> {
const rows = await db.all<RawClientApikey>( const rows = await db.all<RawClientApikey>(
sql`${sqlClientApikey} WHERE ${where}` where ? sql`${sqlClientApikey} WHERE ${where}` : sqlClientApikey,
); );
return rows.map(pgToClientApikey); 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 -1
View File
@@ -65,7 +65,7 @@ export function calcUpdates<T extends object>(updater: {
for (const [prop, update] of Object.entries(updater)) { for (const [prop, update] of Object.entries(updater)) {
if (prop in obj) { if (prop in obj) {
updates.push( updates.push(
(update as (value: unknown) => SqlCode)(obj[prop as keyof T]!) (update as (value: unknown) => SqlCode)(obj[prop as keyof T]!),
); );
} }
} }
+4 -1
View File
@@ -9,7 +9,10 @@ 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"); if (!isPgUrl(url)) throw new Error("Not a postgres: URL");
const urlObj = new URL(url); const urlObj = new URL(url);
const readonly = (urlObj.searchParams.get("readonly") ?? "0") !== "0"; const readonly = (urlObj.searchParams.get("readonly") ?? "0") !== "0";
+66 -67
View File
@@ -48,13 +48,14 @@ import type {
import type { WrappedDb } from "./impl/types.js"; import type { WrappedDb } from "./impl/types.js";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import readline from "node:readline"; import readline from "node:readline";
import type { import {
Exportable, EXPORT_KIND_ORDER,
ExportKind, type Exportable,
ExportOptions, type ExportKind,
Importable, type ExportOptions,
ImportOptions, type Importable,
ImportResult, type ImportOptions,
type ImportResult,
} from "../types/ExportImport.js"; } from "../types/ExportImport.js";
import { isExportKind, kindAllowed, recordAllowed } from "../export/filter.js"; import { isExportKind, kindAllowed, recordAllowed } from "../export/filter.js";
@@ -124,7 +125,7 @@ export class SqliteInterface
SELECT "uid", "email", "name", json("flags") AS "flags", "created_at", "updated_at", "password" SELECT "uid", "email", "name", json("flags") AS "flags", "created_at", "updated_at", "password"
FROM "users" FROM "users"
WHERE "email" = ${{ text: email }} WHERE "email" = ${{ text: email }}
` `,
); );
if (!rawUser) throw new NotFoundAbodeError(); if (!rawUser) throw new NotFoundAbodeError();
if (rawUser.password.startsWith("#")) throw new ConflictAbodeError(); if (rawUser.password.startsWith("#")) throw new ConflictAbodeError();
@@ -138,7 +139,7 @@ export class SqliteInterface
sql` sql`
DELETE FROM "users" DELETE FROM "users"
WHERE "uid" = ${{ uuid: id }} WHERE "uid" = ${{ uuid: id }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
} }
@@ -156,10 +157,10 @@ export class SqliteInterface
${{ text: user.email }}, ${{ text: user.email }},
${{ text: user.name }}, ${{ text: user.name }},
${{ text: user.password }},${{ jsonb: user.flags }}) ${{ text: user.password }},${{ jsonb: user.flags }})
` `,
); );
return this.#getUserById(uid); return this.#getUserById(uid);
}) }),
); );
} }
async updateUser(user: UpdateUser): Promise<ClientUser> { async updateUser(user: UpdateUser): Promise<ClientUser> {
@@ -193,11 +194,11 @@ export class SqliteInterface
"updated_at" = datetime('now', 'localtime', 'subsec'), "updated_at" = datetime('now', 'localtime', 'subsec'),
${joinSql(updates, sql`, `)} ${joinSql(updates, sql`, `)}
WHERE "uid" = ${{ uuid: user.uid }} WHERE "uid" = ${{ uuid: user.uid }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
return this.#getUserById(user.uid); return this.#getUserById(user.uid);
}) }),
); );
} }
@@ -218,7 +219,7 @@ export class SqliteInterface
sql` sql`
DELETE FROM "abodes" DELETE FROM "abodes"
WHERE "aid" = ${{ uuid: id }} WHERE "aid" = ${{ uuid: id }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
} }
@@ -236,10 +237,10 @@ export class SqliteInterface
${{ uuid: ctx.uid }}, ${{ uuid: ctx.uid }},
${{ uuid: ctx.uid }} ${{ uuid: ctx.uid }}
) )
` `,
); );
return this.#getAbodeById(aid); return this.#getAbodeById(aid);
}) }),
); );
} }
async updateAbode(abode: UpdateAbode, ctx: { uid: string }): Promise<Abode> { async updateAbode(abode: UpdateAbode, ctx: { uid: string }): Promise<Abode> {
@@ -258,11 +259,11 @@ export class SqliteInterface
"updated_by" = ${{ uuid: ctx.uid }}, "updated_by" = ${{ uuid: ctx.uid }},
${joinSql(updates, sql`, `)} ${joinSql(updates, sql`, `)}
WHERE "aid" = ${{ uuid: abode.aid }} WHERE "aid" = ${{ uuid: abode.aid }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
return this.#getAbodeById(abode.aid); return this.#getAbodeById(abode.aid);
}) }),
); );
} }
@@ -278,7 +279,7 @@ export class SqliteInterface
#getResidentById(uid: string, aid: string): Resident { #getResidentById(uid: string, aid: string): Resident {
const resident = selectResident( const resident = selectResident(
this.#db, this.#db,
sql`"uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}` sql`"uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}`,
); );
if (!resident) throw new NotFoundAbodeError(); if (!resident) throw new NotFoundAbodeError();
return resident; return resident;
@@ -292,13 +293,13 @@ export class SqliteInterface
sql` sql`
DELETE FROM "residents" DELETE FROM "residents"
WHERE "uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }} WHERE "uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
} }
async createResident( async createResident(
resident: CreateResident, resident: CreateResident,
ctx: { uid: string } ctx: { uid: string },
): Promise<Resident> { ): Promise<Resident> {
this.#checkReadonly(); this.#checkReadonly();
return this.#db.rethrow(() => return this.#db.rethrow(() =>
@@ -313,15 +314,15 @@ export class SqliteInterface
${{ uuid: ctx.uid }}, ${{ uuid: ctx.uid }},
${{ uuid: ctx.uid }} ${{ uuid: ctx.uid }}
) )
` `,
); );
return this.#getResidentById(resident.uid, resident.aid); return this.#getResidentById(resident.uid, resident.aid);
}) }),
); );
} }
async updateResident( async updateResident(
resident: updateResident, resident: updateResident,
ctx: { uid: string } ctx: { uid: string },
): Promise<Resident> { ): Promise<Resident> {
this.#checkReadonly(); this.#checkReadonly();
const updates = calcUpdates({ const updates = calcUpdates({
@@ -340,11 +341,11 @@ export class SqliteInterface
WHERE WHERE
"uid" = ${{ uuid: resident.uid }} "uid" = ${{ uuid: resident.uid }}
AND "aid" = ${{ uuid: resident.aid }} AND "aid" = ${{ uuid: resident.aid }}
` `,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
return this.#getResidentById(resident.uid, resident.aid); return this.#getResidentById(resident.uid, resident.aid);
}) }),
); );
} }
@@ -354,7 +355,7 @@ export class SqliteInterface
sql` sql`
JOIN "residents" r ON u."uid" = r."uid" JOIN "residents" r ON u."uid" = r."uid"
WHERE r."aid" = ${{ uuid: id }} WHERE r."aid" = ${{ uuid: id }}
` `,
); );
} }
async listAbodesByUserId(id: string): Promise<Abode[]> { async listAbodesByUserId(id: string): Promise<Abode[]> {
@@ -363,7 +364,7 @@ export class SqliteInterface
sql` sql`
JOIN "residents" r ON a."aid" = r."aid" JOIN "residents" r ON a."aid" = r."aid"
WHERE r."uid" = ${{ uuid: id }} WHERE r."uid" = ${{ uuid: id }}
` `,
); );
} }
@@ -419,13 +420,13 @@ export class SqliteInterface
#getApikeyByToken(token: `at_${string}`): ClientApikey { #getApikeyByToken(token: `at_${string}`): ClientApikey {
const apikey = selectClientApikey( const apikey = selectClientApikey(
this.#db, this.#db,
sql`"token" = ${{ text: token }}` sql`"token" = ${{ text: token }}`,
); );
if (!apikey) throw new NotFoundAbodeError(); if (!apikey) throw new NotFoundAbodeError();
return apikey; return apikey;
} }
async getUserByApikey( async getUserByApikey(
token: `at_${string}` token: `at_${string}`,
): Promise<[ClientUser, ClientApikey]> { ): Promise<[ClientUser, ClientApikey]> {
const apikey = this.#getApikeyByToken(token); const apikey = this.#getApikeyByToken(token);
if ( if (
@@ -445,7 +446,7 @@ export class SqliteInterface
return apikey; return apikey;
} }
async createApikey( async createApikey(
apikey: CreateApikey apikey: CreateApikey,
): Promise<[ClientApikey, `at_${string}`]> { ): Promise<[ClientApikey, `at_${string}`]> {
this.#checkReadonly(); this.#checkReadonly();
const token = createApikeyToken(); const token = createApikeyToken();
@@ -453,7 +454,7 @@ export class SqliteInterface
let expires = apikey.expires_at; let expires = apikey.expires_at;
if (expires === undefined) if (expires === undefined)
expires = new Date( expires = new Date(
new Date().getTime() + 1000 * 60 * 60 * 24 * 365 new Date().getTime() + 1000 * 60 * 60 * 24 * 365,
).toISOString(); ).toISOString();
if (expires && new Date(expires).getTime() < Date.now()) if (expires && new Date(expires).getTime() < Date.now())
throw new InvalidAbodeError(); throw new InvalidAbodeError();
@@ -494,7 +495,7 @@ export class SqliteInterface
async deleteNoteById(nid: string): Promise<void> { async deleteNoteById(nid: string): Promise<void> {
this.#checkReadonly(); this.#checkReadonly();
const { changes } = this.#db.run( const { changes } = this.#db.run(
sql`DELETE FROM "notes" WHERE "nid" = ${{ uuid: nid }}` sql`DELETE FROM "notes" WHERE "nid" = ${{ uuid: nid }}`,
); );
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
} }
@@ -516,7 +517,7 @@ export class SqliteInterface
) )
`); `);
return this.#getNoteById(nid); return this.#getNoteById(nid);
}) }),
); );
} }
async updateNote(note: UpdateNote, ctx: { uid: string }): Promise<Note> { async updateNote(note: UpdateNote, ctx: { uid: string }): Promise<Note> {
@@ -539,7 +540,7 @@ export class SqliteInterface
`); `);
if (!changes) throw new NotFoundAbodeError(); if (!changes) throw new NotFoundAbodeError();
return this.#getNoteById(note.nid); return this.#getNoteById(note.nid);
}) }),
); );
} }
async listNotesByAbodeId(aid: string): Promise<PartialNote[]> { async listNotesByAbodeId(aid: string): Promise<PartialNote[]> {
@@ -558,33 +559,33 @@ export class SqliteInterface
// `load()` is exactly one `WrappedDb.all()`). Kept lazy so the first query // `load()` is exactly one `WrappedDb.all()`). Kept lazy so the first query
// only fires once the destination starts pulling, and skipped entirely // only fires once the destination starts pulling, and skipped entirely
// once the signal is aborted — no further reads after the destination // once the signal is aborted — no further reads after the destination
// goes away. // goes away. Emission follows the FK-safe EXPORT_KIND_ORDER (part of the
const tables: [ExportKind, () => { uid?: string; aid?: string }[]][] = [ // wire contract; see ExportImport.ts).
["user", () => selectClientUsers(db)], const loaders: Record<ExportKind, () => { uid?: string; aid?: string }[]> =
["abode", () => selectAbodes(db)], {
["resident", () => selectResidents(db)], user: () => selectClientUsers(db),
["apikey", () => selectClientApikeys(db)], abode: () => selectAbodes(db),
["note", () => selectNotes(db)], resident: () => selectResidents(db),
]; apikey: () => selectClientApikeys(db),
note: () => selectNotes(db),
};
async function* generate(): AsyncGenerator<string> { async function* generate(): AsyncGenerator<string> {
if (signal?.aborted) return; if (signal?.aborted) return;
yield ( yield JSON.stringify({
JSON.stringify({ kind: "meta",
kind: "meta", data: {
data: { v: 1,
v: 1, exportedAt: new Date().toISOString(),
exportedAt: new Date().toISOString(), source,
source, filter: filter ?? {},
filter: filter ?? {}, },
}, }) + "\n";
}) + "\n"
);
try { try {
for (const [kind, load] of tables) { for (const kind of EXPORT_KIND_ORDER) {
if (signal?.aborted) return; if (signal?.aborted) return;
if (!kindAllowed(filter, kind)) continue; if (!kindAllowed(filter, kind)) continue;
for (const row of load()) { for (const row of loaders[kind]()) {
if (signal?.aborted) return; if (signal?.aborted) return;
if (recordAllowed(filter, kind, row)) { if (recordAllowed(filter, kind, row)) {
yield JSON.stringify({ kind, data: row }) + "\n"; yield JSON.stringify({ kind, data: row }) + "\n";
@@ -596,15 +597,13 @@ export class SqliteInterface
// failure is surfaced as a trailing sentinel line (HTTP 200 headers // failure is surfaced as a trailing sentinel line (HTTP 200 headers
// are already flushed, so `convertError` can no longer apply). // are already flushed, so `convertError` can no longer apply).
if (signal?.aborted) return; if (signal?.aborted) return;
yield ( yield JSON.stringify({
JSON.stringify({ kind: "error",
kind: "error", data: {
data: { message: e instanceof Error ? e.message : String(e),
message: e instanceof Error ? e.message : String(e), code: e instanceof Error ? e.name : undefined,
code: e instanceof Error ? e.name : undefined, },
}, }) + "\n";
}) + "\n"
);
} }
} }
@@ -613,7 +612,7 @@ export class SqliteInterface
async import( async import(
source: NodeJS.ReadableStream, source: NodeJS.ReadableStream,
options: ImportOptions = {} options: ImportOptions = {},
): Promise<ImportResult> { ): Promise<ImportResult> {
this.#checkReadonly(); this.#checkReadonly();
const { filter, signal } = options; const { filter, signal } = options;
@@ -648,7 +647,7 @@ export class SqliteInterface
throw new Error( throw new Error(
`export stream reported an error: ${ `export stream reported an error: ${
(parsed.data as { message?: string })?.message ?? "unknown" (parsed.data as { message?: string })?.message ?? "unknown"
}` }`,
); );
} }
if (!isExportKind(parsed.kind)) continue; if (!isExportKind(parsed.kind)) continue;
+5 -6
View File
@@ -16,12 +16,11 @@ export class SqliteMigrator implements Migrator {
} }
#listAppliedMigrations(): #listAppliedMigrations():
| { id: number; name: string; applied_at: string }[] { id: number; name: string; applied_at: string }[] | null {
| null {
try { try {
return this.#db return this.#db
.all<{ id: number; name: string; applied_at: string }>( .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) })); .map((x) => ({ ...x, applied_at: sqliteToDate(x.applied_at) }));
} catch (e) { } catch (e) {
@@ -59,7 +58,7 @@ export class SqliteMigrator implements Migrator {
throw new Error(`Applied migration ${id} (${name}) not known`); throw new Error(`Applied migration ${id} (${name}) not known`);
if (migration.name !== name) if (migration.name !== name)
throw new Error( 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})`,
); );
} }
@@ -70,7 +69,7 @@ export class SqliteMigrator implements Migrator {
throw new Error( throw new Error(
`Cannot migrate backward, at ${current.at(-1)?.id ?? 0}, going to ${ `Cannot migrate backward, at ${current.at(-1)?.id ?? 0}, going to ${
target.id target.id
}` }`,
); );
} }
@@ -94,7 +93,7 @@ export class SqliteMigrator implements Migrator {
sql` sql`
INSERT INTO "_migrations"("id", "name") INSERT INTO "_migrations"("id", "name")
VALUES (${{ int: migration.id }}, ${{ text: migration.name }}) VALUES (${{ int: migration.id }}, ${{ text: migration.name }})
` `,
); );
this.#db.run(sql`COMMIT`); this.#db.run(sql`COMMIT`);
} catch (e) { } catch (e) {
+3 -3
View File
@@ -22,7 +22,7 @@ export function uuidToSqlite(uuid: string) {
} }
export function sqliteToUuid(uuid: Buffer | Uint8Array) { export function sqliteToUuid(uuid: Buffer | Uint8Array) {
const hex = (uuid instanceof Buffer ? uuid : Buffer.from(uuid)).toString( const hex = (uuid instanceof Buffer ? uuid : Buffer.from(uuid)).toString(
"hex" "hex",
); );
return [ return [
hex.slice(0, 8), hex.slice(0, 8),
@@ -126,7 +126,7 @@ export function sqliteToResident(resident: {
const defaultApikeyPermissions: ApikeyPermissions = {}; const defaultApikeyPermissions: ApikeyPermissions = {};
export function sqliteToApikeyPermissions( export function sqliteToApikeyPermissions(
permissions: string permissions: string,
): ApikeyPermissions { ): ApikeyPermissions {
const parsed = JSON.parse(permissions); const parsed = JSON.parse(permissions);
const out = { ...defaultApikeyPermissions }; const out = { ...defaultApikeyPermissions };
@@ -184,7 +184,7 @@ export function sqliteToNoteProperties(props: string): NoteProperties {
} }
export function sqliteToPartialNoteProperties( export function sqliteToPartialNoteProperties(
props: string props: string,
): PartialNoteProperties { ): PartialNoteProperties {
const base = sqliteToNoteProperties(props); const base = sqliteToNoteProperties(props);
return { type: base.type ?? "note" }; return { type: base.type ?? "note" };
+1 -1
View File
@@ -3,7 +3,7 @@ import { sqliteProtocols } from "./url.js";
const getSqlite = () => const getSqlite = () =>
import(/* webpackChunkName: 'dbsource-sqlite' */ "./getdb.static.js").then( import(/* webpackChunkName: 'dbsource-sqlite' */ "./getdb.static.js").then(
(x) => x.default (x) => x.default,
); );
const getSqliteDynamic: GetDbDynamic = { const getSqliteDynamic: GetDbDynamic = {
+1 -1
View File
@@ -6,7 +6,7 @@ import type { WrappedDb, WrappedDbOptions } from "./types.js";
function getDatabase( function getDatabase(
path: string, path: string,
options?: Omit<sqlite.Options, "nativeBinding"> options?: Omit<sqlite.Options, "nativeBinding">,
): sqlite.Database { ): sqlite.Database {
if (!natives.sqlite) throw new Error("No natives found for better-sqlite3"); if (!natives.sqlite) throw new Error("No natives found for better-sqlite3");
options = { ...options }; options = { ...options };
+1 -1
View File
@@ -4,7 +4,7 @@ import { node, bs3 } from "./implementations.js";
export function getWrappedDb( export function getWrappedDb(
kind: "any" | "node" | "bs3", kind: "any" | "node" | "bs3",
path: string, path: string,
options: WrappedDbOptions options: WrappedDbOptions,
): WrappedDb { ): WrappedDb {
if (kind === "node") { if (kind === "node") {
if (!node) throw new Error("Requesting unavailable node backend"); if (!node) throw new Error("Requesting unavailable node backend");
+13 -11
View File
@@ -29,7 +29,7 @@ const sqlClientUser = sql`
export function selectClientUser( export function selectClientUser(
db: WrappedDb, db: WrappedDb,
where: SqlCode where: SqlCode,
): ClientUser | null { ): ClientUser | null {
const rawUser = db.get<RawClientUser>(sql`${sqlClientUser} WHERE ${where}`); const rawUser = db.get<RawClientUser>(sql`${sqlClientUser} WHERE ${where}`);
if (rawUser) return sqliteToClientUser(rawUser); if (rawUser) return sqliteToClientUser(rawUser);
@@ -37,7 +37,7 @@ export function selectClientUser(
} }
export function selectClientUsers(db: WrappedDb, rest?: SqlCode): ClientUser[] { export function selectClientUsers(db: WrappedDb, rest?: SqlCode): ClientUser[] {
const rawUsers = db.all<RawClientUser>( const rawUsers = db.all<RawClientUser>(
rest ? sql`${sqlClientUser} ${rest}` : sqlClientUser rest ? sql`${sqlClientUser} ${rest}` : sqlClientUser,
); );
return rawUsers.map(sqliteToClientUser); return rawUsers.map(sqliteToClientUser);
} }
@@ -62,7 +62,7 @@ export function selectAbode(db: WrappedDb, where: SqlCode): Abode | null {
} }
export function selectAbodes(db: WrappedDb, rest?: SqlCode): Abode[] { export function selectAbodes(db: WrappedDb, rest?: SqlCode): Abode[] {
const rawAbodes = db.all<RawAbode>( const rawAbodes = db.all<RawAbode>(
rest ? sql`${sqlAbode} ${rest}` : sqlAbode rest ? sql`${sqlAbode} ${rest}` : sqlAbode,
); );
return rawAbodes.map(sqliteToAbode); return rawAbodes.map(sqliteToAbode);
} }
@@ -88,7 +88,7 @@ export function selectResident(db: WrappedDb, where: SqlCode): Resident | null {
} }
export function selectResidents(db: WrappedDb, where?: SqlCode): Resident[] { export function selectResidents(db: WrappedDb, where?: SqlCode): Resident[] {
const rawResidents = db.all<RawResident>( const rawResidents = db.all<RawResident>(
where ? sql`${sqlResident} WHERE ${where}` : sqlResident where ? sql`${sqlResident} WHERE ${where}` : sqlResident,
); );
return rawResidents.map(sqliteToResident); return rawResidents.map(sqliteToResident);
} }
@@ -108,20 +108,20 @@ const sqlClientApikey = sql`
export function selectClientApikey( export function selectClientApikey(
db: WrappedDb, db: WrappedDb,
where: SqlCode where: SqlCode,
): ClientApikey | null { ): ClientApikey | null {
const rawApikey = db.get<RawClientApikey>( const rawApikey = db.get<RawClientApikey>(
sql`${sqlClientApikey} WHERE ${where}` sql`${sqlClientApikey} WHERE ${where}`,
); );
if (rawApikey) return sqliteToClientApikey(rawApikey); if (rawApikey) return sqliteToClientApikey(rawApikey);
return null; return null;
} }
export function selectClientApikeys( export function selectClientApikeys(
db: WrappedDb, db: WrappedDb,
where?: SqlCode where?: SqlCode,
): ClientApikey[] { ): ClientApikey[] {
const rawApikeys = db.all<RawClientApikey>( const rawApikeys = db.all<RawClientApikey>(
where ? sql`${sqlClientApikey} WHERE ${where}` : sqlClientApikey where ? sql`${sqlClientApikey} WHERE ${where}` : sqlClientApikey,
); );
return rawApikeys.map(sqliteToClientApikey); return rawApikeys.map(sqliteToClientApikey);
} }
@@ -156,15 +156,17 @@ export function selectNote(db: WrappedDb, where: SqlCode): Note | null {
return null; return null;
} }
export function selectNotes(db: WrappedDb, where?: SqlCode): Note[] { export function selectNotes(db: WrappedDb, where?: SqlCode): Note[] {
const raws = db.all<RawNote>(where ? sql`${sqlNote} WHERE ${where}` : sqlNote); const raws = db.all<RawNote>(
where ? sql`${sqlNote} WHERE ${where}` : sqlNote,
);
return raws.map(sqliteToNote); return raws.map(sqliteToNote);
} }
export function selectPartialNotes( export function selectPartialNotes(
db: WrappedDb, db: WrappedDb,
where?: SqlCode where?: SqlCode,
): PartialNote[] { ): PartialNote[] {
const raws = db.all<RawPartialNote>( const raws = db.all<RawPartialNote>(
where ? sql`${sqlPartialNote} WHERE ${where}` : sqlPartialNote where ? sql`${sqlPartialNote} WHERE ${where}` : sqlPartialNote,
); );
return raws.map(sqliteToPartialNote); 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)) { for (const [prop, update] of Object.entries(updater)) {
if (prop in obj) { if (prop in obj) {
updates.push( 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( export function parseSqliteUrl(
url: string url: string,
): ["any" | "node" | "bs3", string, WrappedDbOptions] { ): ["any" | "node" | "bs3", string, WrappedDbOptions] {
if (!isSqliteUrl(url)) throw new Error("Not sqlite: protocol"); if (!isSqliteUrl(url)) throw new Error("Not sqlite: protocol");
const urlObj = new URL(url); const urlObj = new URL(url);
@@ -39,7 +39,7 @@ export function parseSqliteUrl(
urlObj.protocol === "node+sqlite:" urlObj.protocol === "node+sqlite:"
? "node" ? "node"
: urlObj.protocol === "bs3+sqlite:" : urlObj.protocol === "bs3+sqlite:"
? "bs3" ? "bs3"
: "any"; : "any";
return [kind, urlObj.pathname, options]; return [kind, urlObj.pathname, options];
} }
+3 -3
View File
@@ -40,11 +40,11 @@ export interface DbInterface {
deleteResidentById(uid: string, aid: string): Promise<void>; deleteResidentById(uid: string, aid: string): Promise<void>;
createResident( createResident(
resident: CreateResident, resident: CreateResident,
ctx: { uid: string } ctx: { uid: string },
): Promise<Resident>; ): Promise<Resident>;
updateResident( updateResident(
resident: updateResident, resident: updateResident,
ctx: { uid: string } ctx: { uid: string },
): Promise<Resident>; ): Promise<Resident>;
// list residents by member // list residents by member
@@ -104,7 +104,7 @@ export function isBackendInterface(db: DbInterface): db is BackendDbInterface {
] as const ] as const
).every( ).every(
(x) => (x) =>
x in db && typeof (db as Partial<BackendDbInterface>)[x] === "function" x in db && typeof (db as Partial<BackendDbInterface>)[x] === "function",
) )
); );
} }
+46 -4
View File
@@ -8,6 +8,34 @@ import type { DbInterface } from "./DbInterface.js";
*/ */
export type ExportKind = "user" | "abode" | "resident" | "apikey" | "note"; 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 = { export type ExportFilter = {
/** Include only these kinds; omit = all kinds. */ /** Include only these kinds; omit = all kinds. */
kinds?: ExportKind[]; kinds?: ExportKind[];
@@ -15,8 +43,16 @@ export type ExportFilter = {
excludeKinds?: ExportKind[]; excludeKinds?: ExportKind[];
/** aid allowlist — scopes abode/resident/note. */ /** aid allowlist — scopes abode/resident/note. */
abodes?: string[]; abodes?: string[];
/** uid allowlist — scopes user/apikey. */ /** uid allowlist — scopes user (and apikey, unless `apikeys` is set). */
users?: string[]; 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 { export interface ExportOptions {
@@ -41,14 +77,16 @@ export interface ImportResult {
export interface Importable { export interface Importable {
import( import(
source: NodeJS.ReadableStream, source: NodeJS.ReadableStream,
options?: ImportOptions options?: ImportOptions,
): Promise<ImportResult>; ): Promise<ImportResult>;
} }
/** /**
* The NDJSON envelope written/read for every line. The leading line is a * The NDJSON envelope written/read for every line. The leading line is a
* `meta` record; a trailing `error` record may appear if the source failed * `meta` record; the record lines that follow are grouped by kind in
* after streaming had already begun. * {@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 = export type ExportEnvelope =
| { kind: "meta"; data: ExportMeta } | { kind: "meta"; data: ExportMeta }
@@ -66,3 +104,7 @@ export type ExportMeta = {
export function isExportable(db: DbInterface): db is DbInterface & Exportable { export function isExportable(db: DbInterface): db is DbInterface & Exportable {
return typeof (db as Partial<Exportable>).export === "function"; 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( export function isValidUserPassword(
password: string password: string,
): password is User["password"] { ): password is User["password"] {
if (password.startsWith("#")) { if (password.startsWith("#")) {
return ["unset"].includes(password.slice(1)); return ["unset"].includes(password.slice(1));
+1 -1
View File
@@ -2,7 +2,7 @@ if (import.meta.hot) {
import.meta.hot.on("message", (msg) => { import.meta.hot.on("message", (msg) => {
if ( if (
msg.includes( 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"); 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( export async function findNative(
module: string, module: string,
native: string native: string,
): Promise<string> { ): Promise<string> {
const path = await getPackageJsonDir( 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}`); if (!path) throw new Error(`Cannot find module directory for ${module}`);
const file = await find(path, native); const file = await find(path, native);
if (!file) if (!file)
throw new Error( throw new Error(
`Cannot find native ${native} of package ${module} in ${path}` `Cannot find native ${native} of package ${module} in ${path}`,
); );
return file; return file;
} }
export async function tryFindNative( export async function tryFindNative(
module: string, module: string,
native: string native: string,
): Promise<string | null> { ): Promise<string | null> {
try { try {
return await findNative(module, native); return await findNative(module, native);
+2 -2
View File
@@ -22,8 +22,8 @@ export async function webpack(): Promise<{ code: string }> {
let code: string = standaloneCode( let code: string = standaloneCode(
validator, validator,
Object.fromEntries( 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 // 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(popup: ComponentType<{ id: string; onClose: () => void }>): string;
openPopup<T>( openPopup<T>(
popup: ComponentType<{ id: string; onClose: () => void } & T>, popup: ComponentType<{ id: string; onClose: () => void } & T>,
props: T props: T,
): string; ): string;
closePopup(id: string): void; closePopup(id: string): void;
@@ -34,7 +34,7 @@ export function PopupManager({ children }: { children: ReactNode }) {
const openPopup = useCallback<PopupManagerContextData["openPopup"]>( const openPopup = useCallback<PopupManagerContextData["openPopup"]>(
( (
Component: ComponentType<{ id: string; onClose: () => void }>, Component: ComponentType<{ id: string; onClose: () => void }>,
props = {} props = {},
) => { ) => {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
Object.assign(props, { Object.assign(props, {
@@ -45,14 +45,14 @@ export function PopupManager({ children }: { children: ReactNode }) {
setPopups((prev) => [...prev, { id, Component, props }]); setPopups((prev) => [...prev, { id, Component, props }]);
return id; return id;
}, },
[] [],
); );
const closePopup = useCallback((id: string) => { const closePopup = useCallback((id: string) => {
setPopups((prev) => prev.filter((x) => x.id !== id)); setPopups((prev) => prev.filter((x) => x.id !== id));
}, []); }, []);
const ctx = useMemo<PopupManagerContextData>( const ctx = useMemo<PopupManagerContextData>(
() => ({ openPopup, closePopup }), () => ({ openPopup, closePopup }),
[] [],
); );
return ( return (
+2 -2
View File
@@ -19,7 +19,7 @@ export function useDataResidentsByAbodeId(aid: string) {
const status = useLoad(loadResidentsByAbodeId, { aid }); const status = useLoad(loadResidentsByAbodeId, { aid });
const residents = useMemo( const residents = useMemo(
() => Object.values(allResidents).filter((x) => x.aid === aid), () => Object.values(allResidents).filter((x) => x.aid === aid),
[allResidents, aid] [allResidents, aid],
); );
return { ...status, residents }; return { ...status, residents };
} }
@@ -29,7 +29,7 @@ export function useDataResidentsByUserId(uid: string) {
const status = useLoad(loadResidentsByUserId, { uid }); const status = useLoad(loadResidentsByUserId, { uid });
const residents = useMemo( const residents = useMemo(
() => Object.values(allResidents).filter((x) => x.uid === uid), () => Object.values(allResidents).filter((x) => x.uid === uid),
[allResidents, uid] [allResidents, uid],
); );
return { ...status, residents }; return { ...status, residents };
} }
+2 -2
View File
@@ -5,7 +5,7 @@ import type { Store } from "../store/store.js";
import { useStore } from "../store/react.js"; import { useStore } from "../store/react.js";
export function useAction<P extends any[], R>( 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> { ): (...args: P) => Promise<R> {
const db = use(DbContext); const db = use(DbContext);
const store = useStore(); const store = useStore();
@@ -15,6 +15,6 @@ export function useAction<P extends any[], R>(
if (!db) throw new Error("DB not present"); if (!db) throw new Error("DB not present");
return action(...params, { db, store }); 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( const refresh = useCallback(
() => load({ loader, params: params!, store, db, refresh: true }), () => load({ loader, params: params!, store, db, refresh: true }),
[loader, params, db] [loader, params, db],
); );
return { return {
+3 -3
View File
@@ -8,7 +8,7 @@ import type { Store } from "../store.js";
export async function deleteAbodeById( export async function deleteAbodeById(
aid: string, aid: string,
{ store, db }: { store: Store; db: DbInterface } { store, db }: { store: Store; db: DbInterface },
): Promise<void> { ): Promise<void> {
await Promise.all([ await Promise.all([
waitForLoadIfLoading(store, "loadAllAbodes"), waitForLoadIfLoading(store, "loadAllAbodes"),
@@ -22,7 +22,7 @@ export async function deleteAbodeById(
export async function updateAbode( export async function updateAbode(
abode: UpdateAbode, abode: UpdateAbode,
{ store, db }: { store: Store; db: DbInterface } { store, db }: { store: Store; db: DbInterface },
): Promise<void> { ): Promise<void> {
await Promise.all([ await Promise.all([
waitForLoadIfLoading(store, "loadAllAbodes"), waitForLoadIfLoading(store, "loadAllAbodes"),
@@ -38,7 +38,7 @@ export async function updateAbode(
export async function createAbode( export async function createAbode(
abode: CreateAbode, abode: CreateAbode,
{ store, db }: { store: Store; db: DbInterface } { store, db }: { store: Store; db: DbInterface },
): Promise<string> { ): Promise<string> {
await waitForLoadIfLoading(store, "loadAllAbodes"); await waitForLoadIfLoading(store, "loadAllAbodes");
+3 -3
View File
@@ -7,7 +7,7 @@ import type { Store } from "../store.js";
export async function deleteUserById( export async function deleteUserById(
uid: string, uid: string,
{ store, db }: { store: Store; db: DbInterface } { store, db }: { store: Store; db: DbInterface },
): Promise<void> { ): Promise<void> {
await Promise.all([ await Promise.all([
waitForLoadIfLoading(store, "loadAllUsers"), waitForLoadIfLoading(store, "loadAllUsers"),
@@ -24,7 +24,7 @@ export async function deleteUserById(
export async function updateUser( export async function updateUser(
user: UpdateUser, user: UpdateUser,
{ store, db }: { store: Store; db: DbInterface } { store, db }: { store: Store; db: DbInterface },
): Promise<void> { ): Promise<void> {
await Promise.all([ await Promise.all([
waitForLoadIfLoading(store, "loadAllUsers"), waitForLoadIfLoading(store, "loadAllUsers"),
@@ -41,7 +41,7 @@ export async function updateUser(
export async function createUser( export async function createUser(
user: CreateUser, user: CreateUser,
{ store, db }: { store: Store; db: DbInterface } { store, db }: { store: Store; db: DbInterface },
): Promise<string> { ): Promise<string> {
await waitForLoadIfLoading(store, "loadAllUsers"); await waitForLoadIfLoading(store, "loadAllUsers");
+7 -4
View File
@@ -48,7 +48,7 @@ async function loadImpl<P>({
setLoading([ setLoading([
id, id,
{ status: refresh ? "refreshing" : "loading", type, params }, { status: refresh ? "refreshing" : "loading", type, params },
]) ]),
); );
const controller = new AbortController(); const controller = new AbortController();
try { try {
@@ -69,7 +69,10 @@ async function loadImpl<P>({
store.dispatch(setLoading([id, { status: "loaded", type, params }])); store.dispatch(setLoading([id, { status: "loaded", type, params }]));
} catch (e) { } catch (e) {
store.dispatch( store.dispatch(
setLoading([id, { status: "error", type, params, error: objectError(e) }]) setLoading([
id,
{ status: "error", type, params, error: objectError(e) },
]),
); );
throw e; throw e;
} }
@@ -114,7 +117,7 @@ export function loader<P>(loader: Loader<P>): Loader<P> {
export async function waitForLoadIfLoading( export async function waitForLoadIfLoading(
store: Store, store: Store,
id: string, id: string,
{ signal }: { signal?: AbortSignal } = {} { signal }: { signal?: AbortSignal } = {},
) { ) {
if (!getLoadingStatus(store.getState(), id)) return; if (!getLoadingStatus(store.getState(), id)) return;
return waitFor( return waitFor(
@@ -123,6 +126,6 @@ export async function waitForLoadIfLoading(
const status = getLoadingStatus(state, id); const status = getLoadingStatus(state, id);
return status === "loaded" || status === "error"; return status === "loaded" || status === "error";
}, },
{ signal } { signal },
); );
} }
+1 -1
View File
@@ -16,7 +16,7 @@ export function Provider(
props: Omit<ProviderProps, "context" | "store" | "serverState"> & { props: Omit<ProviderProps, "context" | "store" | "serverState"> & {
store: Store; store: Store;
serverState?: State; serverState?: State;
} },
) { ) {
return <RawProvider context={AbodeStoreContext} {...props} />; return <RawProvider context={AbodeStoreContext} {...props} />;
} }
+1 -1
View File
@@ -40,7 +40,7 @@ const loadingSlice = createSlice({
reducers: { reducers: {
setLoading: ( setLoading: (
state, state,
action: PayloadAction<[id: string, state: LoadingState]> action: PayloadAction<[id: string, state: LoadingState]>,
) => { ) => {
state[action.payload[0]] = action.payload[1]; state[action.payload[0]] = action.payload[1];
}, },
+1 -1
View File
@@ -19,7 +19,7 @@ const usersSlice = createSlice({
getUser: usersSelectors.selectById, getUser: usersSelectors.selectById,
getUserByEmail: (state, email: string) => getUserByEmail: (state, email: string) =>
Object.values(state.entities).find( 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, getUsers: usersSelectors.selectEntities,
getUserIds: usersSelectors.selectIds, getUserIds: usersSelectors.selectIds,
+2 -2
View File
@@ -3,7 +3,7 @@ import type { State, Store } from "./store.js";
export async function waitFor( export async function waitFor(
store: Store, store: Store,
cond: (state: State) => boolean, cond: (state: State) => boolean,
{ signal }: { signal?: AbortSignal } = {} { signal }: { signal?: AbortSignal } = {},
): Promise<void> { ): Promise<void> {
return new Promise<void>((ok, ko) => { return new Promise<void>((ok, ko) => {
signal?.throwIfAborted(); signal?.throwIfAborted();
@@ -28,7 +28,7 @@ export async function waitFor(
() => { () => {
controller.abort(); 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 user } from "./user/user.schema.json" with { type: "json" };
export { default as createuser } from "./user/createuser.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 updateuser } from "./user/updateuser.schema.json" with { type: "json" };
export { default as partialuser } from "./user/partialuser.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 clientuser } from "./user/clientuser.schema.json" with { type: "json" };
export { default as userflags } from "./user/userflags.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 loginuser } from "./user/loginuser.schema.json" with { type: "json" };
export { default as abode } from './abode/abode.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 createabode } from "./abode/createabode.schema.json" with { type: "json" };
export { default as updateabode } from './abode/updateabode.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 resident } from "./resident/resident.schema.json" with { type: "json" };
export { default as createresident } from './resident/createresident.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 updateresident } from "./resident/updateresident.schema.json" with { type: "json" };
export { default as residentflags } from './resident/residentflags.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 createapikey } from "./apikey/createapikey.schema.json" with { type: "json" };
export { default as apikeypermissions } from './apikey/apikeypermissions.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 createnote } from "./note/createnote.schema.json" with { type: "json" };
export { default as updatenote } from './note/updatenote.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 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) throw new Error(`Missing $id for schema ${name}`);
if ( if (
!schema.$id.match( !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}`); 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]) => [ Object.entries(schemas).map(([name, schema]) => [
name, name,
validator.compile(schema), validator.compile(schema),
]) ]),
) as unknown as { ) as unknown as {
[T in keyof Types]: { [T in keyof Types]: {
(obj: unknown): obj is Types[T]; (obj: unknown): obj is Types[T];
+1 -1
View File
@@ -69,7 +69,7 @@ function App() {
(input, key) => { (input, key) => {
if (input === "q" || key.escape) app.exit(); if (input === "q" || key.escape) app.exit();
}, },
{ isActive } { isActive },
); );
const [activeCollection, setActiveCollection] = const [activeCollection, setActiveCollection] =
+2 -2
View File
@@ -41,11 +41,11 @@ export function AbodesPanel() {
const onSelect = useCallback( const onSelect = useCallback(
(abode: Abode) => openPopup(AbodePopup, { aid: abode.aid }), (abode: Abode) => openPopup(AbodePopup, { aid: abode.aid }),
[openPopup] [openPopup],
); );
const buttons = useMemo<ButtonListItem[]>( const buttons = useMemo<ButtonListItem[]>(
() => [{ children: "New", onClick: () => openPopup(CreateAbodePopup) }], () => [{ children: "New", onClick: () => openPopup(CreateAbodePopup) }],
[openPopup] [openPopup],
); );
return ( return (
+2 -2
View File
@@ -59,11 +59,11 @@ export function UsersPanel() {
const onSelect = useCallback( const onSelect = useCallback(
(user: ClientUser | PartialUser) => openPopup(UserPopup, { uid: user.uid }), (user: ClientUser | PartialUser) => openPopup(UserPopup, { uid: user.uid }),
[openPopup] [openPopup],
); );
const buttons = useMemo<ButtonListItem[]>( const buttons = useMemo<ButtonListItem[]>(
() => [{ children: "New", onClick: () => openPopup(CreateUserPopup) }], () => [{ children: "New", onClick: () => openPopup(CreateUserPopup) }],
[openPopup] [openPopup],
); );
return ( return (
+2 -2
View File
@@ -18,7 +18,7 @@ export function Button({
(input, key) => { (input, key) => {
if (input === " " || key.return) onClick(); if (input === " " || key.return) onClick();
}, },
{ isActive: isFocused } { isActive: isFocused },
); );
return <Text inverse={isFocused}>[{children}]</Text>; return <Text inverse={isFocused}>[{children}]</Text>;
@@ -66,7 +66,7 @@ export function ButtonList({
setSelected((prev) => (prev - 1 + buttons.length) % buttons.length); setSelected((prev) => (prev - 1 + buttons.length) % buttons.length);
} }
}, },
{ isActive: isFocused || forceFocus || false } { isActive: isFocused || forceFocus || false },
); );
return ( return (
+2 -2
View File
@@ -49,7 +49,7 @@ export function ListBox<T extends string>({
setSelected(items[(items.indexOf(selected) + 1) % items.length]); setSelected(items[(items.indexOf(selected) + 1) % items.length]);
} else if (key.upArrow) { } else if (key.upArrow) {
setSelected( setSelected(
items[(items.indexOf(selected) - 1 + items.length) % items.length] items[(items.indexOf(selected) - 1 + items.length) % items.length],
); );
} else if (key.pageUp) { } else if (key.pageUp) {
setSelected(items[0]); setSelected(items[0]);
@@ -57,7 +57,7 @@ export function ListBox<T extends string>({
setSelected(items[items.length - 1]); setSelected(items[items.length - 1]);
} }
}, },
{ isActive: isFocused } { isActive: isFocused },
); );
return ( return (
+3 -3
View File
@@ -36,14 +36,14 @@ export function ListDisplay<T>({
onSelect?.(items[selected]); onSelect?.(items[selected]);
} }
}, },
{ isActive: isFocused } { isActive: isFocused },
); );
useEffect(() => { useEffect(() => {
if (selected < start) setStart(selected); if (selected < start) setStart(selected);
else if (selected >= slice) else if (selected >= slice)
setStart( 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]); }, [selected, start, slice, items.length]);
useEffect(() => { useEffect(() => {
@@ -56,7 +56,7 @@ export function ListDisplay<T>({
const indexed = useMemo( const indexed = useMemo(
() => items.map((item, index) => ({ item, index })), () => items.map((item, index) => ({ item, index })),
[items] [items],
); );
return ( return (
+1 -1
View File
@@ -16,7 +16,7 @@ export function Popup({
(_, key) => { (_, key) => {
if (key.escape) onClose?.(); if (key.escape) onClose?.();
}, },
{ isActive: active && !!onClose } { isActive: active && !!onClose },
); );
return ( return (
+1 -1
View File
@@ -52,7 +52,7 @@ export function SearchPanel<T>({
refresh?.(); refresh?.();
} }
}, },
{ isActive: isFocused && !!refresh } { isActive: isFocused && !!refresh },
); );
const topbar = !!match || !!buttons?.length; const topbar = !!match || !!buttons?.length;
+2 -2
View File
@@ -2,13 +2,13 @@ import { argon2id, argon2Verify } from "hash-wasm";
export async function validatePassword( export async function validatePassword(
password: string, password: string,
hash: string hash: string,
): Promise<boolean> { ): Promise<boolean> {
return await argon2Verify({ password, hash }); return await argon2Verify({ password, hash });
} }
export async function hashPassword( export async function hashPassword(
password: string password: string,
): Promise<`$${string}$${string}`> { ): Promise<`$${string}$${string}`> {
const salt = new Uint8Array(16); const salt = new Uint8Array(16);
crypto.getRandomValues(salt); crypto.getRandomValues(salt);
+12 -14
View File
@@ -7,11 +7,11 @@ const escapes = {
}; };
function parseAdd<T>( function parseAdd<T>(
rest: (Record<string, string> | string | ((writer: XmlWriter) => T))[] rest: (Record<string, string> | string | ((writer: XmlWriter) => T))[],
): [ ): [
props?: Record<string, string>, props?: Record<string, string>,
content?: string, content?: string,
children?: (writer: XmlWriter) => T children?: (writer: XmlWriter) => T,
] { ] {
let props: Record<string, string> | undefined; let props: Record<string, string> | undefined;
let children: ((writer: XmlWriter) => T) | undefined; let children: ((writer: XmlWriter) => T) | undefined;
@@ -55,7 +55,7 @@ export class XmlWriter {
tag: string, tag: string,
props?: Record<string, string>, props?: Record<string, string>,
content?: string, content?: string,
children?: NonNullable<unknown> children?: NonNullable<unknown>,
) { ) {
const top = this.#stack.at(-1); const top = this.#stack.at(-1);
if (top && !top.children) { if (top && !top.children) {
@@ -104,7 +104,7 @@ export class XmlWriter {
add( add(
tag: string, tag: string,
props: Record<string, string>, props: Record<string, string>,
children: (writer: XmlWriter) => void children: (writer: XmlWriter) => void,
): XmlWriter; ): XmlWriter;
add( add(
tag: string, tag: string,
@@ -125,23 +125,21 @@ export class XmlWriter {
addAsync( addAsync(
tag: string, tag: string,
props: Record<string, string>, props: Record<string, string>,
content: string content: string,
): Promise<void>; ): Promise<void>;
addAsync( addAsync(
tag: string, tag: string,
children: (writer: XmlWriter) => Promise<void> children: (writer: XmlWriter) => Promise<void>,
): Promise<void>; ): Promise<void>;
addAsync( addAsync(
tag: string, tag: string,
props: Record<string, string>, props: Record<string, string>,
children: (writer: XmlWriter) => Promise<void> children: (writer: XmlWriter) => Promise<void>,
): Promise<void>; ): Promise<void>;
async addAsync( async addAsync(
tag: string, tag: string,
...rest: ( ...rest: (
| Record<string, string> Record<string, string> | string | ((writer: XmlWriter) => Promise<void>)
| string
| ((writer: XmlWriter) => Promise<void>)
)[] )[]
): Promise<void> { ): Promise<void> {
const [props, content, children] = parseAdd(rest); const [props, content, children] = parseAdd(rest);
@@ -164,7 +162,7 @@ export class XmlWriter {
| Parameters<InstanceType<typeof XmlWriter>["add"]> | Parameters<InstanceType<typeof XmlWriter>["add"]>
| [ | [
NonNullable<ConstructorParameters<typeof XmlWriter>[0]>, NonNullable<ConstructorParameters<typeof XmlWriter>[0]>,
...Parameters<InstanceType<typeof XmlWriter>["add"]> ...Parameters<InstanceType<typeof XmlWriter>["add"]>,
] ]
): string { ): string {
let options: ConstructorParameters<typeof XmlWriter>[0]; let options: ConstructorParameters<typeof XmlWriter>[0];
@@ -172,7 +170,7 @@ export class XmlWriter {
options = rest.shift()! as ConstructorParameters<typeof XmlWriter>[0]; options = rest.shift()! as ConstructorParameters<typeof XmlWriter>[0];
} }
return new XmlWriter(options).add( return new XmlWriter(options).add(
...(rest as Parameters<InstanceType<typeof XmlWriter>["add"]>) ...(rest as Parameters<InstanceType<typeof XmlWriter>["add"]>),
).content; ).content;
} }
@@ -188,7 +186,7 @@ export class XmlWriter {
| Parameters<InstanceType<typeof XmlWriter>["addAsync"]> | Parameters<InstanceType<typeof XmlWriter>["addAsync"]>
| [ | [
NonNullable<ConstructorParameters<typeof XmlWriter>[0]>, NonNullable<ConstructorParameters<typeof XmlWriter>[0]>,
...Parameters<InstanceType<typeof XmlWriter>["addAsync"]> ...Parameters<InstanceType<typeof XmlWriter>["addAsync"]>,
] ]
): Promise<string> { ): Promise<string> {
let options: ConstructorParameters<typeof XmlWriter>[0]; let options: ConstructorParameters<typeof XmlWriter>[0];
@@ -197,7 +195,7 @@ export class XmlWriter {
} }
const writer = new XmlWriter(options); const writer = new XmlWriter(options);
await writer.addAsync( await writer.addAsync(
...(rest as Parameters<InstanceType<typeof XmlWriter>["addAsync"]>) ...(rest as Parameters<InstanceType<typeof XmlWriter>["addAsync"]>),
); );
return writer.content; return writer.content;
} }
+53 -12
View File
@@ -15,11 +15,20 @@ import {
updateuser, updateuser,
} from "../schema/validators.js"; } from "../schema/validators.js";
import { authenticate } from "./middleware/authenticate.js"; import { authenticate } from "./middleware/authenticate.js";
import { InvalidAbodeError, NotFoundAbodeError } from "../db/types/errors.js"; import {
InvalidAbodeError,
NotAuthorizedAbodeError,
NotFoundAbodeError,
} from "../db/types/errors.js";
import { isExportable } from "../db/types/ExportImport.js"; import { isExportable } from "../db/types/ExportImport.js";
import type { ExportFilter, ExportKind } from "../db/types/ExportImport.js"; import type { ExportFilter, ExportKind } from "../db/types/ExportImport.js";
import { intersectExportFilters, isExportKind } from "../db/export/filter.js"; import { intersectExportFilters, isExportKind } from "../db/export/filter.js";
import { computeForcedExportFilter } from "./exportScope.js"; import { computeForcedExportFilter } from "./exportScope.js";
import {
hasGlobalUserVisibility,
hideUserEmail,
userForCaller,
} from "./userVisibility.js";
function parseExportFilter(query: Record<string, unknown>): ExportFilter { function parseExportFilter(query: Record<string, unknown>): ExportFilter {
const list = (v: unknown): string[] | undefined => { const list = (v: unknown): string[] | undefined => {
@@ -75,7 +84,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
}); });
const effective = intersectExportFilters( const effective = intersectExportFilters(
parseExportFilter(ctx.query), parseExportFilter(ctx.query),
forced forced,
); );
if (!isExportable(db)) { if (!isExportable(db)) {
ctx.status = 501; ctx.status = 501;
@@ -92,24 +101,39 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
router.use("/users", authenticate(db)); router.use("/users", authenticate(db));
router.get("/users", async (ctx) => { router.get("/users", async (ctx) => {
ctx.body = await db.listUsers(); const users = await db.listUsers();
ctx.body = users.map((user) =>
userForCaller(user, { user: ctx.user!, session: ctx.session! }),
);
}); });
router.post("/users", jsonBody({ validate: createuser }), async (ctx) => { router.post("/users", jsonBody({ validate: createuser }), async (ctx) => {
ctx.body = await db.createUser(ctx.request.body); ctx.body = await db.createUser(ctx.request.body);
}); });
router.get("/users/by-email", async (ctx) => { router.get("/users/by-email", async (ctx) => {
if (typeof ctx.query.email !== "string") throw new InvalidAbodeError(); 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); ctx.body = await db.getUserByEmail(ctx.query.email);
}); });
router.get("/users/:uid", async (ctx) => { router.get("/users/:uid", async (ctx) => {
ctx.body = await db.getUserById(ctx.params.uid); const user = await db.getUserById(ctx.params.uid);
ctx.body = userForCaller(user, {
user: ctx.user!,
session: ctx.session!,
});
}); });
router.patch( router.patch(
"/users/:uid", "/users/:uid",
jsonBody({ validate: updateuser, includeParams: ["uid"] }), jsonBody({ validate: updateuser, includeParams: ["uid"] }),
async (ctx) => { async (ctx) => {
ctx.body = await db.updateUser(ctx.request.body); ctx.body = await db.updateUser(ctx.request.body);
} },
); );
router.delete("/users/:uid", async (ctx) => { router.delete("/users/:uid", async (ctx) => {
await db.deleteUserById(ctx.params.uid); await db.deleteUserById(ctx.params.uid);
@@ -130,7 +154,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
async (ctx) => { async (ctx) => {
const [apikey, token] = await db.createApikey(ctx.request.body); const [apikey, token] = await db.createApikey(ctx.request.body);
ctx.body = { apikey, token }; ctx.body = { apikey, token };
} },
); );
router.get("/users/:uid/apikeys/:kid", async (ctx) => { router.get("/users/:uid/apikeys/:kid", async (ctx) => {
const apikey = await db.getApikeyById(ctx.params.kid); const apikey = await db.getApikeyById(ctx.params.kid);
@@ -166,7 +190,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
jsonBody({ validate: updateabode, includeParams: ["aid"] }), jsonBody({ validate: updateabode, includeParams: ["aid"] }),
async (ctx) => { async (ctx) => {
ctx.body = await db.updateAbode(ctx.request.body, { uid: ctx.user!.uid }); ctx.body = await db.updateAbode(ctx.request.body, { uid: ctx.user!.uid });
} },
); );
router.delete("/abodes/:aid", async (ctx) => { router.delete("/abodes/:aid", async (ctx) => {
await db.deleteAbodeById(ctx.params.aid); await db.deleteAbodeById(ctx.params.aid);
@@ -176,7 +200,24 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
ctx.body = await db.listResidentsByAbodeId(ctx.params.aid); ctx.body = await db.listResidentsByAbodeId(ctx.params.aid);
}); });
router.get("/abodes/:aid/users", async (ctx) => { router.get("/abodes/:aid/users", async (ctx) => {
ctx.body = await db.listUsersByAbodeId(ctx.params.aid); 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),
);
}); });
router.get("/abodes/:aid/notes", async (ctx) => { router.get("/abodes/:aid/notes", async (ctx) => {
ctx.body = await db.listNotesByAbodeId(ctx.params.aid); ctx.body = await db.listNotesByAbodeId(ctx.params.aid);
@@ -186,7 +227,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
jsonBody({ validate: createnote, includeParams: ["aid"] }), jsonBody({ validate: createnote, includeParams: ["aid"] }),
async (ctx) => { async (ctx) => {
ctx.body = await db.createNote(ctx.request.body, { uid: ctx.user!.uid }); ctx.body = await db.createNote(ctx.request.body, { uid: ctx.user!.uid });
} },
); );
router.use("/residents", authenticate(db)); router.use("/residents", authenticate(db));
@@ -200,7 +241,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
ctx.body = await db.createResident(ctx.request.body, { ctx.body = await db.createResident(ctx.request.body, {
uid: ctx.user!.uid, uid: ctx.user!.uid,
}); });
} },
); );
router.get("/residents/:uid/:aid", async (ctx) => { router.get("/residents/:uid/:aid", async (ctx) => {
ctx.body = await db.getResidentById(ctx.params.uid, ctx.params.aid); ctx.body = await db.getResidentById(ctx.params.uid, ctx.params.aid);
@@ -212,7 +253,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
ctx.body = await db.updateResident(ctx.request.body, { ctx.body = await db.updateResident(ctx.request.body, {
uid: ctx.user!.uid, uid: ctx.user!.uid,
}); });
} },
); );
router.delete("/residents/:uid/:aid", async (ctx) => { router.delete("/residents/:uid/:aid", async (ctx) => {
await db.deleteResidentById(ctx.params.uid, ctx.params.aid); await db.deleteResidentById(ctx.params.uid, ctx.params.aid);
@@ -240,7 +281,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
jsonBody({ validate: updatenote, includeParams: ["nid"] }), jsonBody({ validate: updatenote, includeParams: ["nid"] }),
async (ctx) => { async (ctx) => {
ctx.body = await db.updateNote(ctx.request.body, { uid: ctx.user!.uid }); ctx.body = await db.updateNote(ctx.request.body, { uid: ctx.user!.uid });
} },
); );
router.delete("/notes/:nid", async (ctx) => { router.delete("/notes/:nid", async (ctx) => {
await db.deleteNoteById(ctx.params.nid); await db.deleteNoteById(ctx.params.nid);
+12 -6
View File
@@ -9,14 +9,17 @@ import type { ExportFilter } from "../db/types/ExportImport.js";
* global admin whose credential imposes no narrowing) — their own filter, if * global admin whose credential imposes no narrowing) — their own filter, if
* any, is then honored verbatim as a voluntary narrowing. * any, is then honored verbatim as a voluntary narrowing.
* *
* Otherwise returns `{ abodes, users }`: the abodes the caller resides in, and * Otherwise returns `{ abodes, users, apikeys }`: the abodes the caller resides
* the users needed to keep that data referentially whole (the caller plus every * in, the users needed to keep that data referentially whole (the caller plus
* co-resident of those abodes). This is the maximum a non-admin may export; the * every co-resident of those abodes), and — scoped tighter than `users` —
* route intersects it with any caller-supplied filter (never a union). * 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( export async function computeForcedExportFilter(
db: BackendDbInterface, db: BackendDbInterface,
ctx: { user: ClientUser; session: NonNullable<Context["session"]> } ctx: { user: ClientUser; session: NonNullable<Context["session"]> },
): Promise<ExportFilter | null> { ): Promise<ExportFilter | null> {
const { user, session } = ctx; const { user, session } = ctx;
@@ -40,6 +43,8 @@ export async function computeForcedExportFilter(
let abodes = [...abodeSet]; let abodes = [...abodeSet];
let users = [...userSet]; 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. // An apikey can only narrow what its owning user could otherwise export.
if (session.source === "apikey") { if (session.source === "apikey") {
@@ -51,8 +56,9 @@ export async function computeForcedExportFilter(
if (p.restrict_users?.length) { if (p.restrict_users?.length) {
const allow = new Set(p.restrict_users); const allow = new Set(p.restrict_users);
users = users.filter((u) => allow.has(u)); users = users.filter((u) => allow.has(u));
apikeys = apikeys.filter((u) => allow.has(u));
} }
} }
return { abodes, users }; return { abodes, users, apikeys };
} }
+1 -1
View File
@@ -16,7 +16,7 @@ export function schemarouter(): KoaRouter {
url: `${ctx.URL}/${name}.schema.json`, url: `${ctx.URL}/${name}.schema.json`,
}, },
]; ];
}) }),
); );
}); });
+36
View File
@@ -0,0 +1,36 @@
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);
}
+19 -11
View File
@@ -3,7 +3,11 @@ import assert from "node:assert/strict";
import { createServer } from "node:http"; import { createServer } from "node:http";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import { ApiInterface } from "../../../src/db/api/ApiInterface.js"; import { ApiInterface } from "../../../src/db/api/ApiInterface.js";
import { apiProtocols, isApiUrl, parseApiUrl } from "../../../src/db/api/url.js"; import {
apiProtocols,
isApiUrl,
parseApiUrl,
} from "../../../src/db/api/url.js";
import { import {
NotFoundAbodeError, NotFoundAbodeError,
NotAuthorizedAbodeError, NotAuthorizedAbodeError,
@@ -86,7 +90,7 @@ describe("parseApiUrl", () => {
it("extra query params become headers", () => { it("extra query params become headers", () => {
const [, { headers }] = parseApiUrl( const [, { headers }] = parseApiUrl(
"http://example.com?X-Custom-Header=value" "http://example.com?X-Custom-Header=value",
); );
assert.equal(headers["X-Custom-Header"], "value"); assert.equal(headers["X-Custom-Header"], "value");
}); });
@@ -94,7 +98,7 @@ describe("parseApiUrl", () => {
it("throws for non-api protocol", () => { it("throws for non-api protocol", () => {
assert.throws( assert.throws(
() => parseApiUrl("sqlite:///db.sqlite"), () => parseApiUrl("sqlite:///db.sqlite"),
/Not an \{abode\+,\}http\{s,\}: protocol/ /Not an \{abode\+,\}http\{s,\}: protocol/,
); );
}); });
}); });
@@ -106,18 +110,22 @@ describe("ApiInterface HTTP error mapping", () => {
before(async () => { before(async () => {
let nextStatus = 500; let nextStatus = 500;
respondWith = (s) => { nextStatus = s; }; respondWith = (s) => {
nextStatus = s;
};
const server = createServer((req, res) => { const server = createServer((req, res) => {
res.writeHead(nextStatus, { "Content-Type": "application/json" }); res.writeHead(nextStatus, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: false, error: "test" })); res.end(JSON.stringify({ ok: false, error: "test" }));
}); });
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve),
);
const { port } = server.address() as AddressInfo; const { port } = server.address() as AddressInfo;
serverUrl = `http://127.0.0.1:${port}`; serverUrl = `http://127.0.0.1:${port}`;
closeServer = () => closeServer = () =>
new Promise<void>((resolve, reject) => new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())) server.close((err) => (err ? reject(err) : resolve())),
); );
}); });
@@ -131,7 +139,7 @@ describe("ApiInterface HTTP error mapping", () => {
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
@@ -143,7 +151,7 @@ describe("ApiInterface HTTP error mapping", () => {
(err) => { (err) => {
assert.ok(err instanceof NotAuthorizedAbodeError); assert.ok(err instanceof NotAuthorizedAbodeError);
return true; return true;
} },
); );
}); });
@@ -155,7 +163,7 @@ describe("ApiInterface HTTP error mapping", () => {
(err) => { (err) => {
assert.ok(err instanceof ReadonlyAbodeError); assert.ok(err instanceof ReadonlyAbodeError);
return true; return true;
} },
); );
}); });
@@ -167,7 +175,7 @@ describe("ApiInterface HTTP error mapping", () => {
(err) => { (err) => {
assert.ok(err instanceof InvalidAbodeError); assert.ok(err instanceof InvalidAbodeError);
return true; return true;
} },
); );
}); });
@@ -179,7 +187,7 @@ describe("ApiInterface HTTP error mapping", () => {
(err) => { (err) => {
assert.ok(err instanceof ConflictAbodeError); assert.ok(err instanceof ConflictAbodeError);
return true; return true;
} },
); );
}); });
}); });
+5 -1
View File
@@ -92,7 +92,11 @@ describe("api backend: auth over HTTP", async () => {
const self = await fetch(`${server.url}/auth/self`, { const self = await fetch(`${server.url}/auth/self`, {
headers: { Cookie: `abode_session=${cookie}` }, headers: { Cookie: `abode_session=${cookie}` },
}); });
assert.equal(self.status, 401, "session was invalidated server-side, not just the cookie cleared"); 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 () => { it("POST /auth/clear-sessions invalidates outstanding session cookies", async () => {
+3 -1
View File
@@ -17,7 +17,9 @@ async function getApiDb() {
email: AUTH_EMAIL, email: AUTH_EMAIL,
name: "API Auth User", name: "API Auth User",
password: pw, password: pw,
flags: {}, // 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 server = await createTestServer(sqliteDb);
const authHeader = "Basic " + btoa(`${AUTH_EMAIL}:${AUTH_PASSWORD}`); const authHeader = "Basic " + btoa(`${AUTH_EMAIL}:${AUTH_PASSWORD}`);
@@ -0,0 +1,328 @@
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);
});
});
+2 -2
View File
@@ -10,10 +10,10 @@ import { runAuthTests } from "../../shared/auth.js";
async function createExpiredApikey( async function createExpiredApikey(
db: BackendDbInterface, db: BackendDbInterface,
uid: string uid: string,
): Promise<`at_${string}`> { ): Promise<`at_${string}`> {
const si = db as SqliteInterface; const si = db as SqliteInterface;
const token = (`at_${"e".repeat(32)}`) as `at_${string}`; const token = `at_${"e".repeat(32)}` as `at_${string}`;
const kid = crypto.randomUUID(); const kid = crypto.randomUUID();
const { sql } = si._; const { sql } = si._;
si._.db.run(sql` si._.db.run(sql`
+2 -2
View File
@@ -46,7 +46,7 @@ describe("SqliteMigrator", () => {
assert.equal(applied.length, 3); assert.equal(applied.length, 3);
assert.deepEqual( assert.deepEqual(
applied.map((m) => m.id), applied.map((m) => m.id),
[1, 2, 3] [1, 2, 3],
); );
db.destroy(); db.destroy();
}); });
@@ -66,7 +66,7 @@ describe("SqliteMigrator", () => {
const migrator = new SqliteMigrator(db); const migrator = new SqliteMigrator(db);
await assert.rejects( await assert.rejects(
() => migrator.migrateTo(9999), () => migrator.migrateTo(9999),
/No known migration with id 9999/ /No known migration with id 9999/,
); );
db.destroy(); db.destroy();
}); });
+7 -1
View File
@@ -1,6 +1,12 @@
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { sql, catSql, joinSql, calcUpdates, unsafeSql } from "../../../src/db/sqlite/sql.js"; import {
sql,
catSql,
joinSql,
calcUpdates,
unsafeSql,
} from "../../../src/db/sqlite/sql.js";
describe("sql template tag", () => { describe("sql template tag", () => {
it("produces correct sql and empty vars for plain text", () => { it("produces correct sql and empty vars for plain text", () => {
+22 -10
View File
@@ -10,13 +10,19 @@ function runWrappedDbSuite(name: string, makeDb: () => WrappedDb) {
before(() => { before(() => {
db = makeDb(); db = makeDb();
db.run(unsafeSql("CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY, val TEXT NOT NULL)")); db.run(
unsafeSql(
"CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY, val TEXT NOT NULL)",
),
);
}); });
after(() => db.destroy()); after(() => db.destroy());
it("run INSERT returns changes count", () => { it("run INSERT returns changes count", () => {
const { changes } = db.run(sql`INSERT INTO test(val) VALUES(${{ text: "hello" }})`); const { changes } = db.run(
sql`INSERT INTO test(val) VALUES(${{ text: "hello" }})`,
);
assert.equal(changes, 1); assert.equal(changes, 1);
}); });
@@ -24,7 +30,9 @@ function runWrappedDbSuite(name: string, makeDb: () => WrappedDb) {
db.run(unsafeSql("DELETE FROM test")); 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: "a" }})`);
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "b" }})`); db.run(sql`INSERT INTO test(val) VALUES(${{ text: "b" }})`);
const rows = db.all<{ val: string }>(unsafeSql("SELECT val FROM test ORDER BY val")); const rows = db.all<{ val: string }>(
unsafeSql("SELECT val FROM test ORDER BY val"),
);
assert.equal(rows.length, 2); assert.equal(rows.length, 2);
assert.equal(rows[0].val, "a"); assert.equal(rows[0].val, "a");
assert.equal(rows[1].val, "b"); assert.equal(rows[1].val, "b");
@@ -38,7 +46,7 @@ function runWrappedDbSuite(name: string, makeDb: () => WrappedDb) {
assert.equal(row.val, "one"); assert.equal(row.val, "one");
const none = db.get<{ val: string }>( const none = db.get<{ val: string }>(
sql`SELECT val FROM test WHERE val = ${{ text: "none" }}` sql`SELECT val FROM test WHERE val = ${{ text: "none" }}`,
); );
assert.equal(none, null); assert.equal(none, null);
}); });
@@ -49,7 +57,7 @@ function runWrappedDbSuite(name: string, makeDb: () => WrappedDb) {
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "dup2" }})`); db.run(sql`INSERT INTO test(val) VALUES(${{ text: "dup2" }})`);
assert.throws( assert.throws(
() => db.get<{ val: string }>(unsafeSql("SELECT val FROM test")), () => db.get<{ val: string }>(unsafeSql("SELECT val FROM test")),
/Multiple results/ /Multiple results/,
); );
}); });
@@ -69,7 +77,7 @@ function runWrappedDbSuite(name: string, makeDb: () => WrappedDb) {
db.multi(() => { db.multi(() => {
db.run(sql`INSERT INTO test(val) VALUES(${{ text: "rollback" }})`); db.run(sql`INSERT INTO test(val) VALUES(${{ text: "rollback" }})`);
throw new Error("abort!"); throw new Error("abort!");
}) }),
); );
const rows = db.all<{ val: string }>(unsafeSql("SELECT val FROM test")); const rows = db.all<{ val: string }>(unsafeSql("SELECT val FROM test"));
assert.equal(rows.length, 0); assert.equal(rows.length, 0);
@@ -82,7 +90,13 @@ function runWrappedDbSuite(name: string, makeDb: () => WrappedDb) {
it("rethrow propagates non-SQLite errors unchanged", () => { it("rethrow propagates non-SQLite errors unchanged", () => {
const err = new Error("custom error"); const err = new Error("custom error");
assert.throws(() => db.rethrow(() => { throw err; }), (e) => e === err); assert.throws(
() =>
db.rethrow(() => {
throw err;
}),
(e) => e === err,
);
}); });
}); });
} }
@@ -93,9 +107,7 @@ describe("better-sqlite3 WrappedDb", async () => {
let bs3Ctor: (new (path: string) => WrappedDb) | null = null; let bs3Ctor: (new (path: string) => WrappedDb) | null = null;
try { try {
const mod = await import( const mod = await import("../../../src/db/sqlite/impl/better-sqlite3.js");
"../../../src/db/sqlite/impl/better-sqlite3.js"
);
bs3Ctor = mod.WrappedBetterSqlite3Db; bs3Ctor = mod.WrappedBetterSqlite3Db;
} catch { } catch {
// better-sqlite3 not available, skip // better-sqlite3 not available, skip
+2 -2
View File
@@ -10,7 +10,7 @@ export interface TestServer {
} }
export async function createTestServer( export async function createTestServer(
db: BackendDbInterface db: BackendDbInterface,
): Promise<TestServer> { ): Promise<TestServer> {
const app = new Koa(); const app = new Koa();
const router = apirouter(db); const router = apirouter(db);
@@ -23,7 +23,7 @@ export async function createTestServer(
url: `http://127.0.0.1:${port}`, url: `http://127.0.0.1:${port}`,
close: () => close: () =>
new Promise<void>((resolve, reject) => new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())) server.close((err) => (err ? reject(err) : resolve())),
), ),
}; };
} }
+27 -12
View File
@@ -1,12 +1,15 @@
import { describe, it, before, after } from "node:test"; import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import type { DbInterface } from "../../src/db/types/DbInterface.js"; import type { DbInterface } from "../../src/db/types/DbInterface.js";
import { NotFoundAbodeError, InvalidAbodeError } from "../../src/db/types/errors.js"; import {
NotFoundAbodeError,
InvalidAbodeError,
} from "../../src/db/types/errors.js";
import { hashPassword } from "../../src/util/hash.js"; import { hashPassword } from "../../src/util/hash.js";
export function runAbodeTests( export function runAbodeTests(
name: string, name: string,
getDb: () => Promise<{ db: DbInterface; close(): void }> getDb: () => Promise<{ db: DbInterface; close(): void }>,
): void { ): void {
describe(`${name}: abodes`, async () => { describe(`${name}: abodes`, async () => {
let db: DbInterface; let db: DbInterface;
@@ -28,7 +31,10 @@ export function runAbodeTests(
after(() => close()); after(() => close());
it("createAbode returns an Abode with expected fields", async () => { it("createAbode returns an Abode with expected fields", async () => {
const abode = await db.createAbode({ name: "Test Abode" }, { uid: ctxUid }); const abode = await db.createAbode(
{ name: "Test Abode" },
{ uid: ctxUid },
);
assert.ok(abode.aid, "has aid"); assert.ok(abode.aid, "has aid");
assert.equal(abode.name, "Test Abode"); assert.equal(abode.name, "Test Abode");
assert.ok(abode.created_at); assert.ok(abode.created_at);
@@ -36,7 +42,10 @@ export function runAbodeTests(
}); });
it("getAbodeById returns the created abode", async () => { it("getAbodeById returns the created abode", async () => {
const created = await db.createAbode({ name: "ById Abode" }, { uid: ctxUid }); const created = await db.createAbode(
{ name: "ById Abode" },
{ uid: ctxUid },
);
const found = await db.getAbodeById(created.aid); const found = await db.getAbodeById(created.aid);
assert.equal(found.aid, created.aid); assert.equal(found.aid, created.aid);
assert.equal(found.name, "ById Abode"); assert.equal(found.name, "ById Abode");
@@ -48,14 +57,14 @@ export function runAbodeTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
it("listAbodes includes the created abode", async () => { it("listAbodes includes the created abode", async () => {
const created = await db.createAbode( const created = await db.createAbode(
{ name: `Listed Abode ${Date.now()}` }, { name: `Listed Abode ${Date.now()}` },
{ uid: ctxUid } { uid: ctxUid },
); );
const abodes = await db.listAbodes(); const abodes = await db.listAbodes();
assert.ok(Array.isArray(abodes)); assert.ok(Array.isArray(abodes));
@@ -67,32 +76,38 @@ export function runAbodeTests(
const created = await db.createAbode({ name: "Before" }, { uid: ctxUid }); const created = await db.createAbode({ name: "Before" }, { uid: ctxUid });
const updated = await db.updateAbode( const updated = await db.updateAbode(
{ aid: created.aid, name: "After" }, { aid: created.aid, name: "After" },
{ uid: ctxUid } { uid: ctxUid },
); );
assert.equal(updated.aid, created.aid); assert.equal(updated.aid, created.aid);
assert.equal(updated.name, "After"); assert.equal(updated.name, "After");
}); });
it("updateAbode with no fields throws InvalidAbodeError", async () => { it("updateAbode with no fields throws InvalidAbodeError", async () => {
const created = await db.createAbode({ name: "No Update" }, { uid: ctxUid }); const created = await db.createAbode(
{ name: "No Update" },
{ uid: ctxUid },
);
await assert.rejects( await assert.rejects(
() => db.updateAbode({ aid: created.aid }, { uid: ctxUid }), () => db.updateAbode({ aid: created.aid }, { uid: ctxUid }),
(err) => { (err) => {
assert.ok(err instanceof InvalidAbodeError); assert.ok(err instanceof InvalidAbodeError);
return true; return true;
} },
); );
}); });
it("deleteAbodeById removes the abode", async () => { it("deleteAbodeById removes the abode", async () => {
const created = await db.createAbode({ name: "To Delete" }, { uid: ctxUid }); const created = await db.createAbode(
{ name: "To Delete" },
{ uid: ctxUid },
);
await db.deleteAbodeById(created.aid); await db.deleteAbodeById(created.aid);
await assert.rejects( await assert.rejects(
() => db.getAbodeById(created.aid), () => db.getAbodeById(created.aid),
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
@@ -102,7 +117,7 @@ export function runAbodeTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
}); });
+4 -4
View File
@@ -6,7 +6,7 @@ import { hashPassword } from "../../src/util/hash.js";
export function runApikeyTests( export function runApikeyTests(
name: string, name: string,
getDb: () => Promise<{ db: DbInterface; close(): void }> getDb: () => Promise<{ db: DbInterface; close(): void }>,
): void { ): void {
describe(`${name}: apikeys`, async () => { describe(`${name}: apikeys`, async () => {
let db: DbInterface; let db: DbInterface;
@@ -69,7 +69,7 @@ export function runApikeyTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
@@ -85,7 +85,7 @@ export function runApikeyTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
@@ -95,7 +95,7 @@ export function runApikeyTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
}); });
+15 -7
View File
@@ -11,7 +11,10 @@ import { hashPassword } from "../../src/util/hash.js";
export function runAuthTests( export function runAuthTests(
name: string, name: string,
getDb: () => Promise<{ db: BackendDbInterface; close(): void }>, getDb: () => Promise<{ db: BackendDbInterface; close(): void }>,
createExpiredApikey?: (db: BackendDbInterface, uid: string) => Promise<`at_${string}`> createExpiredApikey?: (
db: BackendDbInterface,
uid: string,
) => Promise<`at_${string}`>,
): void { ): void {
describe(`${name}: auth`, async () => { describe(`${name}: auth`, async () => {
let db: BackendDbInterface; let db: BackendDbInterface;
@@ -23,7 +26,12 @@ export function runAuthTests(
({ db, close } = await getDb()); ({ db, close } = await getDb());
email = `auth-user-${Date.now()}@test.example`; email = `auth-user-${Date.now()}@test.example`;
const pw = await hashPassword(password); const pw = await hashPassword(password);
await db.createUser({ email, name: "Auth User", password: pw, flags: {} }); await db.createUser({
email,
name: "Auth User",
password: pw,
flags: {},
});
}); });
after(() => close()); after(() => close());
@@ -40,7 +48,7 @@ export function runAuthTests(
(err) => { (err) => {
assert.ok(err instanceof NotAuthorizedAbodeError); assert.ok(err instanceof NotAuthorizedAbodeError);
return true; return true;
} },
); );
}); });
@@ -54,7 +62,7 @@ export function runAuthTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
@@ -71,7 +79,7 @@ export function runAuthTests(
(err) => { (err) => {
assert.ok(err instanceof ConflictAbodeError); assert.ok(err instanceof ConflictAbodeError);
return true; return true;
} },
); );
}); });
}); });
@@ -118,7 +126,7 @@ export function runAuthTests(
(err) => { (err) => {
assert.ok(err instanceof NotAuthorizedAbodeError); assert.ok(err instanceof NotAuthorizedAbodeError);
return true; return true;
} },
); );
}); });
@@ -129,7 +137,7 @@ export function runAuthTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
}); });
+21 -12
View File
@@ -1,12 +1,15 @@
import { describe, it, before, after } from "node:test"; import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import type { DbInterface } from "../../src/db/types/DbInterface.js"; import type { DbInterface } from "../../src/db/types/DbInterface.js";
import { NotFoundAbodeError, InvalidAbodeError } from "../../src/db/types/errors.js"; import {
NotFoundAbodeError,
InvalidAbodeError,
} from "../../src/db/types/errors.js";
import { hashPassword } from "../../src/util/hash.js"; import { hashPassword } from "../../src/util/hash.js";
export function runResidentTests( export function runResidentTests(
name: string, name: string,
getDb: () => Promise<{ db: DbInterface; close(): void }> getDb: () => Promise<{ db: DbInterface; close(): void }>,
): void { ): void {
describe(`${name}: residents`, async () => { describe(`${name}: residents`, async () => {
let db: DbInterface; let db: DbInterface;
@@ -36,7 +39,7 @@ export function runResidentTests(
uid = resUser.uid; uid = resUser.uid;
const abode = await db.createAbode( const abode = await db.createAbode(
{ name: `Resident Abode ${Date.now()}` }, { name: `Resident Abode ${Date.now()}` },
{ uid: ctxUid } { uid: ctxUid },
); );
aid = abode.aid; aid = abode.aid;
await db.createResident({ uid, aid, flags: {} }, { uid: ctxUid }); await db.createResident({ uid, aid, flags: {} }, { uid: ctxUid });
@@ -56,12 +59,12 @@ export function runResidentTests(
() => () =>
db.getResidentById( db.getResidentById(
"00000000-0000-0000-0000-000000000000", "00000000-0000-0000-0000-000000000000",
"00000000-0000-0000-0000-000000000001" "00000000-0000-0000-0000-000000000001",
), ),
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
@@ -101,7 +104,7 @@ export function runResidentTests(
it("updateResident updates flags", async () => { it("updateResident updates flags", async () => {
const updated = await db.updateResident( const updated = await db.updateResident(
{ uid, aid, flags: { admin: true } }, { uid, aid, flags: { admin: true } },
{ uid: ctxUid } { uid: ctxUid },
); );
assert.equal(updated.uid, uid); assert.equal(updated.uid, uid);
assert.deepEqual(updated.flags, { admin: true }); assert.deepEqual(updated.flags, { admin: true });
@@ -113,7 +116,7 @@ export function runResidentTests(
(err) => { (err) => {
assert.ok(err instanceof InvalidAbodeError); assert.ok(err instanceof InvalidAbodeError);
return true; return true;
} },
); );
}); });
@@ -125,15 +128,21 @@ export function runResidentTests(
password: pw, password: pw,
flags: {}, flags: {},
}); });
const abode2 = await db.createAbode({ name: "Del Abode" }, { uid: ctxUid }); const abode2 = await db.createAbode(
await db.createResident({ uid: user2.uid, aid: abode2.aid, flags: {} }, { uid: ctxUid }); { 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 db.deleteResidentById(user2.uid, abode2.aid);
await assert.rejects( await assert.rejects(
() => db.getResidentById(user2.uid, abode2.aid), () => db.getResidentById(user2.uid, abode2.aid),
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
@@ -142,12 +151,12 @@ export function runResidentTests(
() => () =>
db.deleteResidentById( db.deleteResidentById(
"00000000-0000-0000-0000-000000000002", "00000000-0000-0000-0000-000000000002",
"00000000-0000-0000-0000-000000000003" "00000000-0000-0000-0000-000000000003",
), ),
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
}); });
+3 -3
View File
@@ -6,7 +6,7 @@ import { hashPassword } from "../../src/util/hash.js";
export function runSessionTests( export function runSessionTests(
name: string, name: string,
getDb: () => Promise<{ db: BackendDbInterface; close(): void }> getDb: () => Promise<{ db: BackendDbInterface; close(): void }>,
): void { ): void {
describe(`${name}: sessions`, async () => { describe(`${name}: sessions`, async () => {
let db: BackendDbInterface; let db: BackendDbInterface;
@@ -46,7 +46,7 @@ export function runSessionTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
@@ -58,7 +58,7 @@ export function runSessionTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
}); });
+17 -9
View File
@@ -11,7 +11,7 @@ import { hashPassword } from "../../src/util/hash.js";
export function runUserTests( export function runUserTests(
name: string, name: string,
getDb: () => Promise<{ db: DbInterface; close(): void }>, getDb: () => Promise<{ db: DbInterface; close(): void }>,
getReadonlyDb?: () => Promise<{ db: DbInterface; close(): void }> getReadonlyDb?: () => Promise<{ db: DbInterface; close(): void }>,
): void { ): void {
describe(`${name}: users`, async () => { describe(`${name}: users`, async () => {
let db: DbInterface; let db: DbInterface;
@@ -57,13 +57,18 @@ export function runUserTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
it("getUserByEmail returns the created user", async () => { it("getUserByEmail returns the created user", async () => {
const email = `user-byemail-${Date.now()}@test.example`; const email = `user-byemail-${Date.now()}@test.example`;
await db.createUser({ email, name: "ByEmail User", password: hashedPw, flags: {} }); await db.createUser({
email,
name: "ByEmail User",
password: hashedPw,
flags: {},
});
const found = await db.getUserByEmail(email); const found = await db.getUserByEmail(email);
assert.ok("email" in found, "result includes email"); assert.ok("email" in found, "result includes email");
assert.equal(found.email, email); assert.equal(found.email, email);
@@ -75,7 +80,7 @@ export function runUserTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
@@ -100,7 +105,10 @@ export function runUserTests(
password: hashedPw, password: hashedPw,
flags: {}, flags: {},
}); });
const updated = await db.updateUser({ uid: created.uid, name: "After Update" }); const updated = await db.updateUser({
uid: created.uid,
name: "After Update",
});
assert.equal(updated.uid, created.uid); assert.equal(updated.uid, created.uid);
assert.equal(updated.name, "After Update"); assert.equal(updated.name, "After Update");
}); });
@@ -117,7 +125,7 @@ export function runUserTests(
(err) => { (err) => {
assert.ok(err instanceof InvalidAbodeError); assert.ok(err instanceof InvalidAbodeError);
return true; return true;
} },
); );
}); });
@@ -134,7 +142,7 @@ export function runUserTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
@@ -144,7 +152,7 @@ export function runUserTests(
(err) => { (err) => {
assert.ok(err instanceof NotFoundAbodeError); assert.ok(err instanceof NotFoundAbodeError);
return true; return true;
} },
); );
}); });
}); });
@@ -178,7 +186,7 @@ export function runUserTests(
(err) => { (err) => {
assert.ok(err instanceof ReadonlyAbodeError); assert.ok(err instanceof ReadonlyAbodeError);
return true; return true;
} },
); );
}); });
}); });
+110 -29
View File
@@ -29,7 +29,7 @@ const MOCK_APIKEY: ClientApikey = {
}; };
function makeMockDb( function makeMockDb(
overrides: Partial<BackendDbInterface> = {} overrides: Partial<BackendDbInterface> = {},
): BackendDbInterface { ): BackendDbInterface {
return { return {
readonly: false, readonly: false,
@@ -37,42 +37,97 @@ function makeMockDb(
name: "mock", name: "mock",
close: async () => {}, close: async () => {},
listUsers: async () => [], listUsers: async () => [],
getUserById: async () => { throw new NotFoundAbodeError(); }, getUserById: async () => {
throw new NotFoundAbodeError();
},
deleteUserById: async () => {}, deleteUserById: async () => {},
createUser: async () => MOCK_USER, createUser: async () => MOCK_USER,
updateUser: async () => MOCK_USER, updateUser: async () => MOCK_USER,
getUserByEmail: async () => { throw new NotFoundAbodeError(); }, getUserByEmail: async () => {
throw new NotFoundAbodeError();
},
listAbodes: async () => [], listAbodes: async () => [],
getAbodeById: async () => { throw new NotFoundAbodeError(); }, getAbodeById: async () => {
throw new NotFoundAbodeError();
},
deleteAbodeById: async () => {}, deleteAbodeById: async () => {},
createAbode: async () => ({ aid: "a", name: "A", created_at: "", created_by: null, updated_at: "", updated_by: null }), createAbode: async () => ({
updateAbode: async () => ({ aid: "a", name: "A", created_at: "", created_by: null, updated_at: "", updated_by: null }), 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 () => [], listResidents: async () => [],
getResidentById: async () => { throw new NotFoundAbodeError(); }, getResidentById: async () => {
throw new NotFoundAbodeError();
},
deleteResidentById: async () => {}, deleteResidentById: async () => {},
createResident: async () => ({ uid: "", aid: "", flags: {}, created_at: "", created_by: null, updated_at: "", updated_by: null }), createResident: async () => ({
updateResident: async () => ({ uid: "", aid: "", flags: {}, created_at: "", created_by: null, updated_at: "", updated_by: null }), 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 () => [], listResidentsByUserId: async () => [],
listResidentsByAbodeId: async () => [], listResidentsByAbodeId: async () => [],
listUsersByAbodeId: async () => [], listUsersByAbodeId: async () => [],
listAbodesByUserId: async () => [], listAbodesByUserId: async () => [],
listNotes: async () => [], listNotes: async () => [],
getNoteById: async () => { throw new NotFoundAbodeError(); }, getNoteById: async () => {
throw new NotFoundAbodeError();
},
deleteNoteById: async () => {}, deleteNoteById: async () => {},
createNote: async () => { throw new Error("unimplemented"); }, createNote: async () => {
updateNote: async () => { throw new Error("unimplemented"); }, throw new Error("unimplemented");
},
updateNote: async () => {
throw new Error("unimplemented");
},
listNotesByAbodeId: async () => [], listNotesByAbodeId: async () => [],
listNotesByUserId: async () => [], listNotesByUserId: async () => [],
deleteSessionsByUser: async () => {}, deleteSessionsByUser: async () => {},
listApikeysByUser: async () => [], listApikeysByUser: async () => [],
getApikeyById: async () => { throw new NotFoundAbodeError(); }, getApikeyById: async () => {
createApikey: async () => [MOCK_APIKEY, "at_" + "0".repeat(32) as `at_${string}`], throw new NotFoundAbodeError();
},
createApikey: async () => [
MOCK_APIKEY,
("at_" + "0".repeat(32)) as `at_${string}`,
],
deleteApikeyById: async () => {}, deleteApikeyById: async () => {},
getUserByLogin: async () => { throw new NotFoundAbodeError(); }, getUserByLogin: async () => {
getUserBySession: async () => { throw new NotFoundAbodeError(); }, throw new NotFoundAbodeError();
},
getUserBySession: async () => {
throw new NotFoundAbodeError();
},
createSession: async () => `as_${"0".repeat(32)}`, createSession: async () => `as_${"0".repeat(32)}`,
deleteSession: async () => {}, deleteSession: async () => {},
getUserByApikey: async () => { throw new NotFoundAbodeError(); }, getUserByApikey: async () => {
throw new NotFoundAbodeError();
},
...overrides, ...overrides,
}; };
} }
@@ -92,7 +147,10 @@ type MockCtx = {
}; };
}; };
function makeMockCtx(headerOverrides: Record<string, string> = {}, cookieOverrides: Record<string, string> = {}): MockCtx { function makeMockCtx(
headerOverrides: Record<string, string> = {},
cookieOverrides: Record<string, string> = {},
): MockCtx {
const clearedCookies = new Set<string>(); const clearedCookies = new Set<string>();
const ctx: MockCtx = { const ctx: MockCtx = {
headers: headerOverrides, headers: headerOverrides,
@@ -110,7 +168,10 @@ function makeMockCtx(headerOverrides: Record<string, string> = {}, cookieOverrid
return cookieOverrides[name]; return cookieOverrides[name];
}, },
set(name: string, value: string, opts?: unknown) { set(name: string, value: string, opts?: unknown) {
if (value === "" || (opts && (opts as { expires?: Date }).expires?.getFullYear()! < 2000)) { if (
value === "" ||
(opts && (opts as { expires?: Date }).expires?.getFullYear()! < 2000)
) {
clearedCookies.add(name); clearedCookies.add(name);
} }
}, },
@@ -121,11 +182,13 @@ function makeMockCtx(headerOverrides: Record<string, string> = {}, cookieOverrid
async function runMiddleware( async function runMiddleware(
db: BackendDbInterface, db: BackendDbInterface,
ctx: MockCtx ctx: MockCtx,
): Promise<boolean> { ): Promise<boolean> {
let nextCalled = false; let nextCalled = false;
const mw = authenticate(db); const mw = authenticate(db);
await mw(ctx as any, async () => { nextCalled = true; }); await mw(ctx as any, async () => {
nextCalled = true;
});
return nextCalled; return nextCalled;
} }
@@ -157,7 +220,9 @@ describe("authenticate middleware", () => {
it("wrong password (NotAuthorizedAbodeError) → 401 invalid_password", async () => { it("wrong password (NotAuthorizedAbodeError) → 401 invalid_password", async () => {
const db = makeMockDb({ const db = makeMockDb({
getUserByLogin: async () => { throw new NotAuthorizedAbodeError(); }, getUserByLogin: async () => {
throw new NotAuthorizedAbodeError();
},
}); });
const ctx = makeMockCtx({ Authorization: "Basic " + btoa("user:wrong") }); const ctx = makeMockCtx({ Authorization: "Basic " + btoa("user:wrong") });
await runMiddleware(db, ctx); await runMiddleware(db, ctx);
@@ -167,9 +232,13 @@ describe("authenticate middleware", () => {
it("unknown user (NotFoundAbodeError) → 401 unknown_user", async () => { it("unknown user (NotFoundAbodeError) → 401 unknown_user", async () => {
const db = makeMockDb({ const db = makeMockDb({
getUserByLogin: async () => { throw new NotFoundAbodeError(); }, getUserByLogin: async () => {
throw new NotFoundAbodeError();
},
});
const ctx = makeMockCtx({
Authorization: "Basic " + btoa("nobody:pass"),
}); });
const ctx = makeMockCtx({ Authorization: "Basic " + btoa("nobody:pass") });
await runMiddleware(db, ctx); await runMiddleware(db, ctx);
assert.equal(ctx.status, 401); assert.equal(ctx.status, 401);
assert.deepEqual((ctx.body as any)?.error, "unknown_user"); assert.deepEqual((ctx.body as any)?.error, "unknown_user");
@@ -177,7 +246,9 @@ describe("authenticate middleware", () => {
it("ConflictAbodeError (#unset password) → 401 user_not_loggable", async () => { it("ConflictAbodeError (#unset password) → 401 user_not_loggable", async () => {
const db = makeMockDb({ const db = makeMockDb({
getUserByLogin: async () => { throw new ConflictAbodeError(); }, getUserByLogin: async () => {
throw new ConflictAbodeError();
},
}); });
const ctx = makeMockCtx({ Authorization: "Basic " + btoa("user:pass") }); const ctx = makeMockCtx({ Authorization: "Basic " + btoa("user:pass") });
await runMiddleware(db, ctx); await runMiddleware(db, ctx);
@@ -211,7 +282,9 @@ describe("authenticate middleware", () => {
it("invalid/expired at_ token → 401 invalid_apikey", async () => { it("invalid/expired at_ token → 401 invalid_apikey", async () => {
const db = makeMockDb({ const db = makeMockDb({
getUserByApikey: async () => { throw new NotFoundAbodeError(); }, getUserByApikey: async () => {
throw new NotFoundAbodeError();
},
}); });
const ctx = makeMockCtx({ Authorization: `Bearer ${validToken}` }); const ctx = makeMockCtx({ Authorization: `Bearer ${validToken}` });
await runMiddleware(db, ctx); await runMiddleware(db, ctx);
@@ -246,17 +319,25 @@ describe("authenticate middleware", () => {
const db = makeMockDb(); const db = makeMockDb();
const ctx = makeMockCtx({}, { abode_session: "not-a-session-token" }); const ctx = makeMockCtx({}, { abode_session: "not-a-session-token" });
await runMiddleware(db, ctx); await runMiddleware(db, ctx);
assert.ok(ctx.clearedCookies.has("abode_session"), "cookie should be cleared"); assert.ok(
ctx.clearedCookies.has("abode_session"),
"cookie should be cleared",
);
assert.equal(ctx.status, 401); assert.equal(ctx.status, 401);
}); });
it("expired/unknown session token clears cookie and returns 401", async () => { it("expired/unknown session token clears cookie and returns 401", async () => {
const db = makeMockDb({ const db = makeMockDb({
getUserBySession: async () => { throw new NotFoundAbodeError(); }, getUserBySession: async () => {
throw new NotFoundAbodeError();
},
}); });
const ctx = makeMockCtx({}, { abode_session: validToken }); const ctx = makeMockCtx({}, { abode_session: validToken });
await runMiddleware(db, ctx); await runMiddleware(db, ctx);
assert.ok(ctx.clearedCookies.has("abode_session"), "cookie should be cleared"); assert.ok(
ctx.clearedCookies.has("abode_session"),
"cookie should be cleared",
);
assert.equal(ctx.status, 401); assert.equal(ctx.status, 401);
}); });
}); });
+3 -1
View File
@@ -25,7 +25,9 @@ describe("convertError middleware", () => {
it("does not interfere when next succeeds", async () => { it("does not interfere when next succeeds", async () => {
const ctx = makeCtx(); const ctx = makeCtx();
let nextCalled = false; let nextCalled = false;
await convertError(ctx as any, async () => { nextCalled = true; }); await convertError(ctx as any, async () => {
nextCalled = true;
});
assert.equal(nextCalled, true); assert.equal(nextCalled, true);
assert.equal(ctx.status, 200); assert.equal(ctx.status, 200);
}); });
+171 -49
View File
@@ -16,6 +16,31 @@ import {
import { inspectExportStream } from "../../src/db/export/inspect.js"; import { inspectExportStream } from "../../src/db/export/inspect.js";
import { hashPassword } from "../../src/util/hash.js"; import { hashPassword } from "../../src/util/hash.js";
import type { ClientUser } from "../../src/db/types/User.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 // helpers
@@ -38,13 +63,17 @@ function parseLines(ndjson: string): { kind: string; data: any }[] {
function norm(obj: Record<string, unknown>): Record<string, unknown> { function norm(obj: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {}; const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) { for (const [k, v] of Object.entries(obj)) {
out[k] = k.endsWith("_at") ? (v == null ? null : new Date(v as string).getTime()) : v; out[k] = k.endsWith("_at")
? v == null
? null
: new Date(v as string).getTime()
: v;
} }
return out; return out;
} }
function normSorted( function normSorted(
arr: Record<string, unknown>[], arr: Record<string, unknown>[],
key: (x: any) => string key: (x: any) => string,
): Record<string, unknown>[] { ): Record<string, unknown>[] {
return arr.map(norm).sort((a, b) => key(a).localeCompare(key(b))); return arr.map(norm).sort((a, b) => key(a).localeCompare(key(b)));
} }
@@ -79,19 +108,25 @@ async function seed(db: TestDb["db"]): Promise<Seed> {
password: pw, password: pw,
flags: {}, flags: {},
}); });
const abode1 = await db.createAbode({ name: "Abode One" }, { uid: admin.uid }); const abode1 = await db.createAbode(
const abode2 = await db.createAbode({ name: "Abode Two" }, { uid: admin.uid }); { name: "Abode One" },
{ uid: admin.uid },
);
const abode2 = await db.createAbode(
{ name: "Abode Two" },
{ uid: admin.uid },
);
await db.createResident( await db.createResident(
{ uid: normal.uid, aid: abode1.aid, flags: {} }, { uid: normal.uid, aid: abode1.aid, flags: {} },
{ uid: admin.uid } { uid: admin.uid },
); );
await db.createResident( await db.createResident(
{ uid: co.uid, aid: abode1.aid, flags: {} }, { uid: co.uid, aid: abode1.aid, flags: {} },
{ uid: admin.uid } { uid: admin.uid },
); );
await db.createResident( await db.createResident(
{ uid: admin.uid, aid: abode2.aid, flags: { admin: true } }, { uid: admin.uid, aid: abode2.aid, flags: { admin: true } },
{ uid: admin.uid } { uid: admin.uid },
); );
await db.createApikey({ await db.createApikey({
uid: normal.uid, uid: normal.uid,
@@ -99,13 +134,29 @@ async function seed(db: TestDb["db"]): Promise<Seed> {
permissions: {}, permissions: {},
expires_at: null, expires_at: null,
}); });
await db.createApikey({
uid: co.uid,
name: "co key",
permissions: {},
expires_at: null,
});
const note1 = await db.createNote( const note1 = await db.createNote(
{ aid: abode1.aid, name: "Note One", content: "hello", properties: { type: "note" } }, {
{ uid: normal.uid } aid: abode1.aid,
name: "Note One",
content: "hello",
properties: { type: "note" },
},
{ uid: normal.uid },
); );
const note2 = await db.createNote( const note2 = await db.createNote(
{ aid: abode2.aid, name: "Note Two", content: "world", properties: { type: "note" } }, {
{ uid: admin.uid } aid: abode2.aid,
name: "Note Two",
content: "world",
properties: { type: "note" },
},
{ uid: admin.uid },
); );
return { return {
admin, admin,
@@ -118,6 +169,40 @@ async function seed(db: TestDb["db"]): Promise<Seed> {
}; };
} }
// ---------------------------------------------------------------------------
// 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 // 1. round-trip
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -135,21 +220,21 @@ describe("export/import: sqlite -> sqlite round-trip", () => {
assert.deepEqual( assert.deepEqual(
normSorted(await dst.db.listUsers(), (u) => u.uid), normSorted(await dst.db.listUsers(), (u) => u.uid),
normSorted(await src.db.listUsers(), (u) => u.uid) normSorted(await src.db.listUsers(), (u) => u.uid),
); );
assert.deepEqual( assert.deepEqual(
normSorted(await dst.db.listAbodes(), (a) => a.aid), normSorted(await dst.db.listAbodes(), (a) => a.aid),
normSorted(await src.db.listAbodes(), (a) => a.aid) normSorted(await src.db.listAbodes(), (a) => a.aid),
); );
assert.deepEqual( assert.deepEqual(
normSorted(await dst.db.listResidents(), (r) => r.uid + r.aid), normSorted(await dst.db.listResidents(), (r) => r.uid + r.aid),
normSorted(await src.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 // apikeys: token is regenerated on import, so the ClientApikey view
// (which omits token) must still match exactly. // (which omits token) must still match exactly.
assert.deepEqual( assert.deepEqual(
normSorted(await dst.db.listApikeysByUser(s.normal.uid), (k) => k.kid), normSorted(await dst.db.listApikeysByUser(s.normal.uid), (k) => k.kid),
normSorted(await src.db.listApikeysByUser(s.normal.uid), (k) => k.kid) normSorted(await src.db.listApikeysByUser(s.normal.uid), (k) => k.kid),
); );
// notes (full, with content) // notes (full, with content)
const srcNote = await src.db.getNoteById(s.nid1); const srcNote = await src.db.getNoteById(s.nid1);
@@ -172,21 +257,23 @@ describe("export/import: filter narrowing", () => {
try { try {
const s = await seed(src.db); const s = await seed(src.db);
const ndjson = await streamToString( const ndjson = await streamToString(
src.db.export({ filter: { abodes: [s.aid1] } }) src.db.export({ filter: { abodes: [s.aid1] } }),
); );
const lines = parseLines(ndjson); const lines = parseLines(ndjson);
const abodeAids = lines.filter((l) => l.kind === "abode").map((l) => l.data.aid); const abodeAids = lines
.filter((l) => l.kind === "abode")
.map((l) => l.data.aid);
assert.deepEqual(abodeAids, [s.aid1]); assert.deepEqual(abodeAids, [s.aid1]);
const noteAids = new Set( const noteAids = new Set(
lines.filter((l) => l.kind === "note").map((l) => l.data.aid) lines.filter((l) => l.kind === "note").map((l) => l.data.aid),
); );
assert.ok(noteAids.has(s.aid1)); assert.ok(noteAids.has(s.aid1));
assert.ok(!noteAids.has(s.aid2)); assert.ok(!noteAids.has(s.aid2));
const residentAids = new Set( const residentAids = new Set(
lines.filter((l) => l.kind === "resident").map((l) => l.data.aid) lines.filter((l) => l.kind === "resident").map((l) => l.data.aid),
); );
assert.ok(!residentAids.has(s.aid2)); assert.ok(!residentAids.has(s.aid2));
} finally { } finally {
@@ -204,7 +291,7 @@ describe("export/import: filter narrowing", () => {
const abodes = await dst.db.listAbodes(); const abodes = await dst.db.listAbodes();
assert.deepEqual( assert.deepEqual(
abodes.map((a) => a.aid), abodes.map((a) => a.aid),
[s.aid1] [s.aid1],
); );
const notes = await dst.db.listNotesByAbodeId(s.aid1); const notes = await dst.db.listNotesByAbodeId(s.aid1);
assert.equal(notes.length, 1); assert.equal(notes.length, 1);
@@ -220,7 +307,7 @@ describe("export/import: filter narrowing", () => {
try { try {
await seed(src.db); await seed(src.db);
const ndjson = await streamToString( const ndjson = await streamToString(
src.db.export({ filter: { kinds: ["abode"] } }) src.db.export({ filter: { kinds: ["abode"] } }),
); );
const kinds = new Set(parseLines(ndjson).map((l) => l.kind)); const kinds = new Set(parseLines(ndjson).map((l) => l.kind));
assert.ok(kinds.has("abode")); assert.ok(kinds.has("abode"));
@@ -285,11 +372,20 @@ describe("exportScope: computeForcedExportFilter", () => {
}); });
assert.ok(forced); assert.ok(forced);
assert.deepEqual(forced!.abodes, [s.aid1]); assert.deepEqual(forced!.abodes, [s.aid1]);
assert.deepEqual(new Set(forced!.users), new Set([s.normal.uid, s.co.uid])); assert.deepEqual(
new Set(forced!.users),
new Set([s.normal.uid, s.co.uid]),
);
assert.ok(!forced!.users!.includes(s.admin.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. // A caller requesting a wider abode never gets it: intersection, not union.
const effective = intersectExportFilters({ abodes: [s.aid1, s.aid2] }, forced); const effective = intersectExportFilters(
{ abodes: [s.aid1, s.aid2] },
forced,
);
assert.deepEqual(effective.abodes, [s.aid1]); assert.deepEqual(effective.abodes, [s.aid1]);
assert.ok(!effective.abodes!.includes(s.aid2)); assert.ok(!effective.abodes!.includes(s.aid2));
} finally { } finally {
@@ -401,7 +497,7 @@ describe("import cancellation", () => {
yield line("user", mkUser(1)); yield line("user", mkUser(1));
yield line("user", mkUser(2)); yield line("user", mkUser(2));
throw new Error("source exploded"); throw new Error("source exploded");
})() })(),
); );
await assert.rejects(() => dst.db.import(source), /source exploded/); await assert.rejects(() => dst.db.import(source), /source exploded/);
@@ -431,23 +527,21 @@ describe("import cancellation", () => {
const source = Readable.from( const source = Readable.from(
(async function* () { (async function* () {
yield JSON.stringify({ kind: "meta", data: { v: 1 } }) + "\n"; yield JSON.stringify({ kind: "meta", data: { v: 1 } }) + "\n";
yield ( yield JSON.stringify({
JSON.stringify({ kind: "user",
kind: "user", data: {
data: { uid: crypto.randomUUID(),
uid: crypto.randomUUID(), email: "abort@test.example",
email: "abort@test.example", name: "Abort",
name: "Abort", flags: {},
flags: {}, created_at: now,
created_at: now, updated_at: now,
updated_at: now, },
}, }) + "\n";
}) + "\n"
);
ac.abort(); ac.abort();
// Keep the stream alive so abort — not EOF — ends the import. // Keep the stream alive so abort — not EOF — ends the import.
await new Promise((r) => setTimeout(r, 1000)); await new Promise((r) => setTimeout(r, 1000));
})() })(),
); );
await assert.rejects(() => dst.db.import(source, { signal: ac.signal })); await assert.rejects(() => dst.db.import(source, { signal: ac.signal }));
@@ -484,7 +578,7 @@ describe("GET /export endpoint", () => {
url = `http://127.0.0.1:${port}`; url = `http://127.0.0.1:${port}`;
close = () => close = () =>
new Promise<void>((resolve, reject) => new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())) server.close((err) => (err ? reject(err) : resolve())),
); );
}); });
@@ -500,12 +594,12 @@ describe("GET /export endpoint", () => {
assert.equal(res.status, 200); assert.equal(res.status, 200);
const lines = parseLines(await res.text()); const lines = parseLines(await res.text());
const abodeAids = new Set( const abodeAids = new Set(
lines.filter((l) => l.kind === "abode").map((l) => l.data.aid) lines.filter((l) => l.kind === "abode").map((l) => l.data.aid),
); );
assert.ok(abodeAids.has(s.aid1)); assert.ok(abodeAids.has(s.aid1));
assert.ok(abodeAids.has(s.aid2)); assert.ok(abodeAids.has(s.aid2));
const userUids = new Set( const userUids = new Set(
lines.filter((l) => l.kind === "user").map((l) => l.data.uid) lines.filter((l) => l.kind === "user").map((l) => l.data.uid),
); );
assert.ok(userUids.has(s.admin.uid)); assert.ok(userUids.has(s.admin.uid));
assert.ok(userUids.has(s.normal.uid)); assert.ok(userUids.has(s.normal.uid));
@@ -519,18 +613,29 @@ describe("GET /export endpoint", () => {
const lines = parseLines(await res.text()); const lines = parseLines(await res.text());
const abodeAids = new Set( const abodeAids = new Set(
lines.filter((l) => l.kind === "abode").map((l) => l.data.aid) lines.filter((l) => l.kind === "abode").map((l) => l.data.aid),
); );
assert.ok(abodeAids.has(s.aid1)); assert.ok(abodeAids.has(s.aid1));
assert.ok(!abodeAids.has(s.aid2), "aid2 forced out of scope"); assert.ok(!abodeAids.has(s.aid2), "aid2 forced out of scope");
const userUids = new Set( const userUids = new Set(
lines.filter((l) => l.kind === "user").map((l) => l.data.uid) lines.filter((l) => l.kind === "user").map((l) => l.data.uid),
); );
assert.ok(userUids.has(s.normal.uid)); assert.ok(userUids.has(s.normal.uid));
assert.ok(userUids.has(s.co.uid)); assert.ok(userUids.has(s.co.uid));
assert.ok(!userUids.has(s.admin.uid), "admin not a co-resident of aid1"); 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. // The meta line records the *effective* (narrowed) filter.
const meta = lines.find((l) => l.kind === "meta"); const meta = lines.find((l) => l.kind === "meta");
assert.ok(meta); assert.ok(meta);
@@ -621,7 +726,9 @@ describe("inspectExportStream", () => {
try { try {
await seed(src.db); await seed(src.db);
const ndjson = await streamToString(src.db.export()); const ndjson = await streamToString(src.db.export());
const { counts, meta } = await inspectExportStream(Readable.from([ndjson])); const { counts, meta } = await inspectExportStream(
Readable.from([ndjson]),
);
assert.equal(meta?.v, 1); assert.equal(meta?.v, 1);
assert.equal(meta?.source, "sqlite"); assert.equal(meta?.source, "sqlite");
assert.ok((counts.user ?? 0) >= 3); assert.ok((counts.user ?? 0) >= 3);
@@ -662,11 +769,26 @@ describe("filter helpers", () => {
}); });
it("recordAllowed scopes by aid/uid per kind", () => { it("recordAllowed scopes by aid/uid per kind", () => {
assert.equal(recordAllowed({ abodes: ["a1"] }, "abode", { aid: "a1" }), true); assert.equal(
assert.equal(recordAllowed({ abodes: ["a1"] }, "abode", { aid: "a2" }), false); recordAllowed({ abodes: ["a1"] }, "abode", { aid: "a1" }),
assert.equal(recordAllowed({ users: ["u1"] }, "apikey", { uid: "u1" }), true); true,
assert.equal(recordAllowed({ users: ["u1"] }, "apikey", { uid: "u2" }), false); );
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 // abodes allowlist does not constrain user records
assert.equal(recordAllowed({ abodes: ["a1"] }, "user", { uid: "u9" }), true); assert.equal(
recordAllowed({ abodes: ["a1"] }, "user", { uid: "u9" }),
true,
);
}); });
}); });
+20 -14
View File
@@ -8,7 +8,7 @@ import { jsonBody } from "../../src/webapi/middleware/jsonBody.js";
async function request( async function request(
url: string, url: string,
opts: { method?: string; body?: unknown; contentType?: string } = {} opts: { method?: string; body?: unknown; contentType?: string } = {},
): Promise<{ status: number; body: unknown }> { ): Promise<{ status: number; body: unknown }> {
const method = opts.method ?? "POST"; const method = opts.method ?? "POST";
const bodyStr = const bodyStr =
@@ -21,18 +21,19 @@ async function request(
const res = await fetch(url, { method, headers, body: bodyStr }); const res = await fetch(url, { method, headers, body: bodyStr });
const text = await res.text(); const text = await res.text();
let parsed: unknown; let parsed: unknown;
try { parsed = JSON.parse(text); } catch { parsed = text; } try {
parsed = JSON.parse(text);
} catch {
parsed = text;
}
return { status: res.status, body: parsed }; return { status: res.status, body: parsed };
} }
async function makeTestServer() { async function makeTestServer() {
const failValidator = Object.assign( const failValidator = Object.assign((_obj: unknown): _obj is never => false, {
(_obj: unknown): _obj is never => false, errors: [{ message: "required" }] as unknown[],
{ schema: { $id: "test-schema", title: "Test", description: "" },
errors: [{ message: "required" }] as unknown[], });
schema: { $id: "test-schema", title: "Test", description: "" },
}
);
const app = new Koa(); const app = new Koa();
const router = new KoaRouter(); const router = new KoaRouter();
@@ -48,7 +49,7 @@ async function makeTestServer() {
async (ctx) => { async (ctx) => {
ctx.status = 200; ctx.status = 200;
ctx.body = { ok: true }; ctx.body = { ok: true };
} },
); );
router.post( router.post(
@@ -57,7 +58,7 @@ async function makeTestServer() {
async (ctx) => { async (ctx) => {
ctx.status = 200; ctx.status = 200;
ctx.body = { ok: true, body: ctx.request.body }; ctx.body = { ok: true, body: ctx.request.body };
} },
); );
app.use(router.routes()); app.use(router.routes());
@@ -69,7 +70,7 @@ async function makeTestServer() {
const url = `http://127.0.0.1:${port}`; const url = `http://127.0.0.1:${port}`;
const close = () => const close = () =>
new Promise<void>((resolve, reject) => new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())) server.close((err) => (err ? reject(err) : resolve())),
); );
return { url, close }; return { url, close };
} }
@@ -107,11 +108,16 @@ describe("jsonBody middleware", () => {
}); });
it("failing validator → 400 jsonchema_validation_failed with schema and errors", async () => { it("failing validator → 400 jsonchema_validation_failed with schema and errors", async () => {
const res = await request(url + "/fail-validate", { body: { any: "thing" } }); const res = await request(url + "/fail-validate", {
body: { any: "thing" },
});
assert.equal(res.status, 400); assert.equal(res.status, 400);
assert.equal((res.body as any).error, "jsonchema_validation_failed"); assert.equal((res.body as any).error, "jsonchema_validation_failed");
assert.ok((res.body as any).schema, "response includes schema"); assert.ok((res.body as any).schema, "response includes schema");
assert.ok(Array.isArray((res.body as any).errors), "response includes errors"); assert.ok(
Array.isArray((res.body as any).errors),
"response includes errors",
);
}); });
it("includeParams: param absent from body → merged in", async () => { it("includeParams: param absent from body → merged in", async () => {
+148
View File
@@ -0,0 +1,148 @@
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)!));
});
});
+14 -14
View File
@@ -1,15 +1,15 @@
{ {
"compilerOptions": { "compilerOptions": {
"rootDir": "src", "rootDir": "src",
"strict": true, "strict": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"moduleResolution": "nodenext", "moduleResolution": "nodenext",
"module": "nodenext", "module": "nodenext",
"target": "esnext", "target": "esnext",
"allowImportingTsExtensions": false, "allowImportingTsExtensions": false,
"noEmit": true, "noEmit": true,
"sourceMap": true, "sourceMap": true,
"jsx": "react-jsxdev" "jsx": "react-jsxdev"
}, },
"include": ["src/**/*.ts","src/**/*.tsx"] "include": ["src/**/*.ts", "src/**/*.tsx"]
} }
+5 -5
View File
@@ -1,7 +1,7 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"rootDir": "." "rootDir": "."
}, },
"include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"] "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" process.env.DB_SOURCES === "dynamic"
? "dynamic" ? "dynamic"
: process.env.DB_SOURCES === "static" : process.env.DB_SOURCES === "static"
? "static" ? "static"
: "shared"; : "shared";
let disableDbSqlite = process.env.DISABLE_DB_SQLITE === "1"; let disableDbSqlite = process.env.DISABLE_DB_SQLITE === "1";
let disableBs3 = disableDbSqlite || process.env.DISABLE_BS3 === "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) => [ (await readdir(file("./src/bin"))).map((bin) => [
bin.replace(/\..+$/, ""), bin.replace(/\..+$/, ""),
file(`./src/bin/${bin}`), file(`./src/bin/${bin}`),
]) ]),
); );
for (const bin of Object.keys(binaries)) { for (const bin of Object.keys(binaries)) {
copies.push({ copies.push({
@@ -93,12 +93,12 @@ export default async (): Promise<Configuration[]> => {
} else if (dbSources === "static") { } else if (dbSources === "static") {
console.log("Resolving db interfaces statically"); console.log("Resolving db interfaces statically");
aliases[file("./src/db/dbSources.ts")] = file( aliases[file("./src/db/dbSources.ts")] = file(
"./src/db/dbSources.static.ts" "./src/db/dbSources.static.ts",
); );
} else { } else {
console.log("Resolving db interfaces shared"); console.log("Resolving db interfaces shared");
aliases[file("./src/db/dbSources.ts")] = file( 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) { } else if (disableBs3) {
console.log("Disabling better-sqlite3 sqlite db backend"); console.log("Disabling better-sqlite3 sqlite db backend");
aliases[file("./src/db/sqlite/impl/implementations.ts")] = file( 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"); compiledSources.push("sqlite");
} else if (disableNodeSqlite) { } else if (disableNodeSqlite) {
console.log("Disabling node:sqlite sqlite db backend"); console.log("Disabling node:sqlite sqlite db backend");
aliases[file("./src/db/sqlite/impl/implementations.ts")] = file( 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"); compiledSources.push("sqlite");
} else { } else {
console.log( 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( 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"); compiledSources.push("sqlite");
} }
@@ -145,18 +145,18 @@ export default async (): Promise<Configuration[]> => {
defines.compiledSources = JSON.stringify(compiledSources); defines.compiledSources = JSON.stringify(compiledSources);
for (const source of existingSources) for (const source of existingSources)
defines[`compiledSources.${source}`] = JSON.stringify( defines[`compiledSources.${source}`] = JSON.stringify(
compiledSources.includes(source) compiledSources.includes(source),
); );
if (!compiledSources.length) { if (!compiledSources.length) {
console.warn( 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); process.exit(1);
} }
// log about the natives we have // log about the natives we have
console.log( 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)) { for (const [key, path] of Object.entries(natives)) {
if (path) console.log(`- ${key}: ${path}`); if (path) console.log(`- ${key}: ${path}`);