Compare commits
6
Commits
ccb970f200
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0355bd0b2e | ||
|
|
1e10391e20 | ||
|
|
a0a436c4b8 | ||
|
|
d7e31dfce9 | ||
|
|
3a56dcd9e5 | ||
|
|
aadc950e24 |
+15
-10
@@ -1,9 +1,7 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { getWrappedDb } from "../db/sqlite/impl/index.js";
|
||||
import { SqliteInterface } from "../db/sqlite/SqliteInterface.js";
|
||||
import { parseSqliteUrl } from "../db/sqlite/url.js";
|
||||
import { getDbInterface } from "../db/index.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);
|
||||
|
||||
@@ -15,14 +13,15 @@ function printUsage(err: boolean | string = false): never {
|
||||
}
|
||||
log("Usage:");
|
||||
log("\tabode-import --help");
|
||||
log("\tabode-import <sqlite-database-url> <input-file|-> [--kinds=...] \\");
|
||||
log("\tabode-import <database-url> <input-file|-> [--kinds=...] \\");
|
||||
log(
|
||||
"\t [--exclude-kinds=...] [--abodes=aid,...] [--users=uid,...]",
|
||||
);
|
||||
log("");
|
||||
log(
|
||||
"The target database must already be migrated (run abode-migrate first).",
|
||||
"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);
|
||||
}
|
||||
|
||||
@@ -42,7 +41,7 @@ for (const arg of args) {
|
||||
|
||||
const url = positional[0];
|
||||
const input = positional[1];
|
||||
if (!url) printUsage("missing <sqlite-database-url>");
|
||||
if (!url) printUsage("missing <database-url>");
|
||||
if (!input) printUsage("missing <input-file|->");
|
||||
if (positional.length > 2) printUsage("too many arguments");
|
||||
|
||||
@@ -73,9 +72,15 @@ if (abodes) filter.abodes = abodes;
|
||||
const users = parseList(flags.get("users"));
|
||||
if (users) filter.users = users;
|
||||
|
||||
// Import is sqlite-only: construct the backend directly rather than resolving
|
||||
// generically, so it can never be pointed at a remote (api) target.
|
||||
const db = new SqliteInterface(getWrappedDb(...parseSqliteUrl(url)));
|
||||
// Resolve the backend generically. Import lives on the local backends (sqlite,
|
||||
// postgres); the remote (api) interface has no `import`, so `isImportable`
|
||||
// keeps it from ever running over HTTP.
|
||||
const db = await getDbInterface(url);
|
||||
if (!isImportable(db)) {
|
||||
console.error(`Error: backend '${db.name}' does not support import`);
|
||||
await db.close().catch(() => {});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const source = input === "-" ? process.stdin : createReadStream(input);
|
||||
|
||||
|
||||
+15
-12
@@ -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>([
|
||||
"user",
|
||||
"abode",
|
||||
"resident",
|
||||
"apikey",
|
||||
"note",
|
||||
]);
|
||||
const EXPORT_KINDS = new Set<ExportKind>(EXPORT_KIND_ORDER);
|
||||
|
||||
export function isExportKind(x: unknown): x is ExportKind {
|
||||
return typeof x === "string" && EXPORT_KINDS.has(x as ExportKind);
|
||||
@@ -24,9 +22,10 @@ export function kindAllowed(
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an individual record passes a filter's `abodes`/`users` allowlists.
|
||||
* `abodes` scopes abode/resident/note (by aid); `users` scopes user/apikey
|
||||
* (by uid). An absent allowlist means "unrestricted".
|
||||
* Whether an individual record passes a filter's `abodes`/`users`/`apikeys`
|
||||
* allowlists. `abodes` scopes abode/resident/note (by aid); `users` scopes user
|
||||
* (by uid); `apikey` records are scoped by `apikeys` when present, else by
|
||||
* `users`. An absent allowlist means "unrestricted".
|
||||
*/
|
||||
export function recordAllowed(
|
||||
filter: ExportFilter | undefined,
|
||||
@@ -36,8 +35,11 @@ export function recordAllowed(
|
||||
if (!filter) return true;
|
||||
switch (kind) {
|
||||
case "user":
|
||||
case "apikey":
|
||||
return !filter.users || filter.users.includes(record.uid as string);
|
||||
case "apikey": {
|
||||
const allow = filter.apikeys ?? filter.users;
|
||||
return !allow || allow.includes(record.uid as string);
|
||||
}
|
||||
case "abode":
|
||||
case "resident":
|
||||
case "note":
|
||||
@@ -75,5 +77,6 @@ export function intersectExportFilters(
|
||||
excludeKinds: unionList(a.excludeKinds, b.excludeKinds),
|
||||
abodes: intersectList(a.abodes, b.abodes),
|
||||
users: intersectList(a.users, b.users),
|
||||
apikeys: intersectList(a.apikeys, b.apikeys),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ import {
|
||||
selectClientUsers,
|
||||
selectResident,
|
||||
selectResidents,
|
||||
selectNote,
|
||||
selectNotes,
|
||||
selectPartialNotes,
|
||||
} from "./query.js";
|
||||
import type {
|
||||
CreateNote,
|
||||
@@ -43,8 +46,22 @@ import type {
|
||||
UpdateNote,
|
||||
} from "../types/Note.js";
|
||||
import type { WrappedPgClient } from "./pool.js";
|
||||
import { Readable } from "node:stream";
|
||||
import readline from "node:readline";
|
||||
import {
|
||||
EXPORT_KIND_ORDER,
|
||||
type Exportable,
|
||||
type ExportKind,
|
||||
type ExportOptions,
|
||||
type Importable,
|
||||
type ImportOptions,
|
||||
type ImportResult,
|
||||
} from "../types/ExportImport.js";
|
||||
import { isExportKind, kindAllowed, recordAllowed } from "../export/filter.js";
|
||||
|
||||
export class PostgresInterface implements BackendDbInterface {
|
||||
export class PostgresInterface
|
||||
implements BackendDbInterface, Exportable, Importable
|
||||
{
|
||||
#db: WrappedPgClient;
|
||||
|
||||
constructor(db: WrappedPgClient) {
|
||||
@@ -488,24 +505,271 @@ export class PostgresInterface implements BackendDbInterface {
|
||||
}
|
||||
|
||||
async listNotes(): Promise<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
return selectPartialNotes(this.#db);
|
||||
}
|
||||
async getNoteById(_nid: string): Promise<Note> {
|
||||
throw new Error("Unimplemented");
|
||||
async #getNoteById(nid: string, db: WrappedPgClient): Promise<Note> {
|
||||
const note = await selectNote(db, sql`n."nid" = ${{ uuid: nid }}`);
|
||||
if (!note) throw new NotFoundAbodeError();
|
||||
return note;
|
||||
}
|
||||
async deleteNoteById(_nid: string): Promise<void> {
|
||||
throw new Error("Unimplemented");
|
||||
async getNoteById(nid: string): Promise<Note> {
|
||||
return this.#getNoteById(nid, this.#db);
|
||||
}
|
||||
async createNote(_note: CreateNote, _ctx: { uid: string }): Promise<Note> {
|
||||
throw new Error("Unimplemented");
|
||||
async deleteNoteById(nid: string): Promise<void> {
|
||||
this.#checkReadonly();
|
||||
const { changes } = await this.#db.run(sql`
|
||||
DELETE FROM "notes" WHERE "nid" = ${{ uuid: nid }}
|
||||
`);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
}
|
||||
async updateNote(_note: UpdateNote, _ctx: { uid: string }): Promise<Note> {
|
||||
throw new Error("Unimplemented");
|
||||
async createNote(note: CreateNote, ctx: { uid: string }): Promise<Note> {
|
||||
this.#checkReadonly();
|
||||
const nid = crypto.randomUUID();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(async (tx) => {
|
||||
await tx.run(sql`
|
||||
INSERT INTO "notes"("nid", "aid", "name", "content", "properties", "created_by", "updated_by")
|
||||
VALUES(
|
||||
${{ uuid: nid }}, ${{ uuid: note.aid }}, ${{ text: note.name }},
|
||||
${{ text: note.content ?? "" }}, ${{ jsonb: note.properties }},
|
||||
${{ uuid: ctx.uid }}, ${{ uuid: ctx.uid }}
|
||||
)
|
||||
`);
|
||||
return this.#getNoteById(nid, tx);
|
||||
}),
|
||||
);
|
||||
}
|
||||
async listNotesByAbodeId(_aid: string): Promise<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
async updateNote(note: UpdateNote, ctx: { uid: string }): Promise<Note> {
|
||||
this.#checkReadonly();
|
||||
const updates = calcUpdates({
|
||||
name: (value: string) => sql`"name" = ${{ text: value }}`,
|
||||
content: (value: string) => sql`"content" = ${{ text: value }}`,
|
||||
properties: (value: object) => sql`"properties" = ${{ jsonb: value }}`,
|
||||
})(note);
|
||||
if (!updates.length) throw new InvalidAbodeError();
|
||||
return this.#db.rethrow(() =>
|
||||
this.#db.multi(async (tx) => {
|
||||
const { changes } = await tx.run(sql`
|
||||
UPDATE "notes"
|
||||
SET "updated_at" = NOW(), "updated_by" = ${{ uuid: ctx.uid }},
|
||||
${joinSql(updates, sql`, `)}
|
||||
WHERE "nid" = ${{ uuid: note.nid }}
|
||||
`);
|
||||
if (!changes) throw new NotFoundAbodeError();
|
||||
return this.#getNoteById(note.nid, tx);
|
||||
}),
|
||||
);
|
||||
}
|
||||
async listNotesByUserId(_uid: string): Promise<PartialNote[]> {
|
||||
throw new Error("Unimplemented");
|
||||
async listNotesByAbodeId(aid: string): Promise<PartialNote[]> {
|
||||
return selectPartialNotes(this.#db, sql`n."aid" = ${{ uuid: aid }}`);
|
||||
}
|
||||
async listNotesByUserId(uid: string): Promise<PartialNote[]> {
|
||||
return selectPartialNotes(this.#db, sql`n."created_by" = ${{ uuid: uid }}`);
|
||||
}
|
||||
|
||||
export(options: ExportOptions = {}): NodeJS.ReadableStream {
|
||||
const { filter, signal } = options;
|
||||
const db = this.#db;
|
||||
const source = this.name;
|
||||
|
||||
// Emission follows the FK-safe EXPORT_KIND_ORDER (part of the wire
|
||||
// contract; each `load()` is a single query, run lazily and skipped once
|
||||
// the destination aborts.
|
||||
const loaders: Partial<
|
||||
Record<ExportKind, () => Promise<{ uid?: string; aid?: string }[]>>
|
||||
> = {
|
||||
user: () => selectClientUsers(db),
|
||||
abode: () => selectAbodes(db),
|
||||
resident: () => selectResidents(db),
|
||||
apikey: () => selectClientApikeys(db),
|
||||
note: () => selectNotes(db),
|
||||
};
|
||||
|
||||
async function* generate(): AsyncGenerator<string> {
|
||||
if (signal?.aborted) return;
|
||||
yield JSON.stringify({
|
||||
kind: "meta",
|
||||
data: {
|
||||
v: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
source,
|
||||
filter: filter ?? {},
|
||||
},
|
||||
}) + "\n";
|
||||
try {
|
||||
for (const kind of EXPORT_KIND_ORDER) {
|
||||
if (signal?.aborted) return;
|
||||
const load = loaders[kind];
|
||||
if (!load) continue;
|
||||
if (!kindAllowed(filter, kind)) continue;
|
||||
for (const row of await load()) {
|
||||
if (signal?.aborted) return;
|
||||
if (recordAllowed(filter, kind, row)) {
|
||||
yield JSON.stringify({ kind, data: row }) + "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (signal?.aborted) return;
|
||||
yield JSON.stringify({
|
||||
kind: "error",
|
||||
data: {
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
code: e instanceof Error ? e.name : undefined,
|
||||
},
|
||||
}) + "\n";
|
||||
}
|
||||
}
|
||||
|
||||
return Readable.from(generate());
|
||||
}
|
||||
|
||||
async import(
|
||||
source: NodeJS.ReadableStream,
|
||||
options: ImportOptions = {},
|
||||
): Promise<ImportResult> {
|
||||
this.#checkReadonly();
|
||||
const { filter, signal } = options;
|
||||
const counts: Partial<Record<ExportKind, number>> = {};
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: source,
|
||||
crlfDelay: Infinity,
|
||||
signal,
|
||||
});
|
||||
|
||||
// Postgres holds FK enforcement (unlike sqlite, which we toggle off): the
|
||||
// FK-safe insertion order keeps a full dump valid.
|
||||
// The transaction commits only if the whole stream is consumed cleanly; an
|
||||
// error/abort rolls it back via `multi`.
|
||||
try {
|
||||
await this.#db.rethrow(() =>
|
||||
this.#db.multi(async (tx) => {
|
||||
for await (const raw of rl) {
|
||||
signal?.throwIfAborted();
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
let parsed: { kind?: unknown; data?: unknown };
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
throw new InvalidAbodeError();
|
||||
}
|
||||
if (parsed.kind === "meta") continue;
|
||||
if (parsed.kind === "error") {
|
||||
throw new Error(
|
||||
`export stream reported an error: ${
|
||||
(parsed.data as { message?: string })?.message ?? "unknown"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (!isExportKind(parsed.kind)) continue;
|
||||
if (!kindAllowed(filter, parsed.kind)) continue;
|
||||
const data = parsed.data as { uid?: string; aid?: string };
|
||||
if (!recordAllowed(filter, parsed.kind, data)) continue;
|
||||
await this.#importRecord(tx, parsed.kind, parsed.data);
|
||||
counts[parsed.kind] = (counts[parsed.kind] ?? 0) + 1;
|
||||
}
|
||||
// An abort while blocked on the source closes readline without
|
||||
// throwing, so re-check before the transaction commits.
|
||||
signal?.throwIfAborted();
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
|
||||
return { counts };
|
||||
}
|
||||
|
||||
async #importRecord(
|
||||
tx: WrappedPgClient,
|
||||
kind: ExportKind,
|
||||
data: unknown,
|
||||
): Promise<void> {
|
||||
switch (kind) {
|
||||
case "user": {
|
||||
const u = data as ClientUser;
|
||||
// `password` is never exported; imported users land on the schema
|
||||
// default ('#unset') and must reset before they can log in.
|
||||
await tx.run(sql`
|
||||
INSERT INTO "users"("uid", "email", "name", "flags", "created_at", "updated_at")
|
||||
VALUES(
|
||||
${{ uuid: u.uid }},
|
||||
${{ text: u.email }},
|
||||
${{ text: u.name }},
|
||||
${{ jsonb: u.flags }},
|
||||
${{ date: u.created_at }},
|
||||
${{ date: u.updated_at }}
|
||||
)
|
||||
`);
|
||||
break;
|
||||
}
|
||||
case "abode": {
|
||||
const a = data as Abode;
|
||||
await tx.run(sql`
|
||||
INSERT INTO "abodes"("aid", "name", "created_at", "created_by", "updated_at", "updated_by")
|
||||
VALUES(
|
||||
${{ uuid: a.aid }},
|
||||
${{ text: a.name }},
|
||||
${{ date: a.created_at }},
|
||||
${a.created_by ? { uuid: a.created_by } : { null: true }},
|
||||
${{ date: a.updated_at }},
|
||||
${a.updated_by ? { uuid: a.updated_by } : { null: true }}
|
||||
)
|
||||
`);
|
||||
break;
|
||||
}
|
||||
case "resident": {
|
||||
const r = data as Resident;
|
||||
await tx.run(sql`
|
||||
INSERT INTO "residents"("uid", "aid", "flags", "created_at", "created_by", "updated_at", "updated_by")
|
||||
VALUES(
|
||||
${{ uuid: r.uid }},
|
||||
${{ uuid: r.aid }},
|
||||
${{ jsonb: r.flags }},
|
||||
${{ date: r.created_at }},
|
||||
${r.created_by ? { uuid: r.created_by } : { null: true }},
|
||||
${{ date: r.updated_at }},
|
||||
${r.updated_by ? { uuid: r.updated_by } : { null: true }}
|
||||
)
|
||||
`);
|
||||
break;
|
||||
}
|
||||
case "apikey": {
|
||||
const k = data as ClientApikey;
|
||||
// `token` is never exported; mint a fresh unique one so the record's
|
||||
// metadata (kid/permissions/expiry) survives even though the original
|
||||
// secret cannot.
|
||||
await tx.run(sql`
|
||||
INSERT INTO "apikeys"("uid", "kid", "token", "name", "permissions", "created_at", "expires_at")
|
||||
VALUES(
|
||||
${{ uuid: k.uid }},
|
||||
${{ uuid: k.kid }},
|
||||
${{ text: createApikeyToken() }},
|
||||
${{ text: k.name }},
|
||||
${{ jsonb: k.permissions }},
|
||||
${{ date: k.created_at }},
|
||||
${k.expires_at ? { date: k.expires_at } : { null: true }}
|
||||
)
|
||||
`);
|
||||
break;
|
||||
}
|
||||
case "note": {
|
||||
const n = data as Note;
|
||||
await tx.run(sql`
|
||||
INSERT INTO "notes"("nid", "aid", "name", "content", "properties", "created_at", "created_by", "updated_at", "updated_by")
|
||||
VALUES(
|
||||
${{ uuid: n.nid }}, ${{ uuid: n.aid }}, ${{ text: n.name }},
|
||||
${{ text: n.content ?? "" }}, ${{ jsonb: n.properties }},
|
||||
${{ date: n.created_at }},
|
||||
${n.created_by ? { uuid: n.created_by } : { null: true }},
|
||||
${{ date: n.updated_at }},
|
||||
${n.updated_by ? { uuid: n.updated_by } : { null: true }}
|
||||
)
|
||||
`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { Abode } from "../types/Abode.js";
|
||||
import type { ApikeyPermissions, ClientApikey } from "../types/Apikey.js";
|
||||
import type { Resident, ResidentFlags } from "../types/Resident.js";
|
||||
import type {
|
||||
Note,
|
||||
NoteProperties,
|
||||
PartialNote,
|
||||
PartialNoteProperties,
|
||||
} from "../types/Note.js";
|
||||
import type { ClientUser, PartialUser, UserFlags } from "../types/User.js";
|
||||
|
||||
export function pgToDate(d: Date | string): string {
|
||||
@@ -135,3 +141,68 @@ export function pgToClientApikey(apikey: {
|
||||
expires_at: apikey.expires_at ? pgToDate(apikey.expires_at) : null,
|
||||
};
|
||||
}
|
||||
|
||||
const validNoteTypes = new Set(["note"]);
|
||||
|
||||
function pgToNoteProperties(properties: unknown): NoteProperties {
|
||||
if (
|
||||
typeof properties !== "object" ||
|
||||
!properties ||
|
||||
Array.isArray(properties)
|
||||
)
|
||||
return {};
|
||||
const value = properties as Record<string, unknown>;
|
||||
return validNoteTypes.has(value.type as string)
|
||||
? { type: value.type as "note" }
|
||||
: {};
|
||||
}
|
||||
|
||||
function pgToPartialNoteProperties(properties: unknown): PartialNoteProperties {
|
||||
return { type: pgToNoteProperties(properties).type ?? "note" };
|
||||
}
|
||||
|
||||
export function pgToNote(note: {
|
||||
nid: string;
|
||||
aid: string;
|
||||
name: string;
|
||||
content: string;
|
||||
properties: unknown;
|
||||
created_at: Date | string;
|
||||
created_by: string | null;
|
||||
updated_at: Date | string;
|
||||
updated_by: string | null;
|
||||
}): Note {
|
||||
return {
|
||||
nid: note.nid,
|
||||
aid: note.aid,
|
||||
name: note.name,
|
||||
content: note.content,
|
||||
properties: pgToNoteProperties(note.properties),
|
||||
created_at: pgToDate(note.created_at),
|
||||
created_by: note.created_by,
|
||||
updated_at: pgToDate(note.updated_at),
|
||||
updated_by: note.updated_by,
|
||||
};
|
||||
}
|
||||
|
||||
export function pgToPartialNote(note: {
|
||||
nid: string;
|
||||
aid: string;
|
||||
name: string;
|
||||
properties: unknown;
|
||||
created_at: Date | string;
|
||||
created_by: string | null;
|
||||
updated_at: Date | string;
|
||||
updated_by: string | null;
|
||||
}): PartialNote {
|
||||
return {
|
||||
nid: note.nid,
|
||||
aid: note.aid,
|
||||
name: note.name,
|
||||
properties: pgToPartialNoteProperties(note.properties),
|
||||
created_at: pgToDate(note.created_at),
|
||||
created_by: note.created_by,
|
||||
updated_at: pgToDate(note.updated_at),
|
||||
updated_by: note.updated_by,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@ import type { Abode } from "../types/Abode.js";
|
||||
import type { ClientApikey } from "../types/Apikey.js";
|
||||
import type { Resident } from "../types/Resident.js";
|
||||
import type { ClientUser } from "../types/User.js";
|
||||
import type { Note, PartialNote } from "../types/Note.js";
|
||||
import {
|
||||
pgToAbode,
|
||||
pgToClientApikey,
|
||||
pgToClientUser,
|
||||
pgToResident,
|
||||
pgToNote,
|
||||
pgToPartialNote,
|
||||
} from "./cast.js";
|
||||
import type { WrappedPgClient } from "./pool.js";
|
||||
import { sql, type SqlCode } from "./sql.js";
|
||||
@@ -130,10 +133,60 @@ export async function selectClientApikey(
|
||||
}
|
||||
export async function selectClientApikeys(
|
||||
db: WrappedPgClient,
|
||||
where: SqlCode,
|
||||
where?: SqlCode,
|
||||
): Promise<ClientApikey[]> {
|
||||
const rows = await db.all<RawClientApikey>(
|
||||
sql`${sqlClientApikey} WHERE ${where}`,
|
||||
where ? sql`${sqlClientApikey} WHERE ${where}` : sqlClientApikey,
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -48,13 +48,14 @@ import type {
|
||||
import type { WrappedDb } from "./impl/types.js";
|
||||
import { Readable } from "node:stream";
|
||||
import readline from "node:readline";
|
||||
import type {
|
||||
Exportable,
|
||||
ExportKind,
|
||||
ExportOptions,
|
||||
Importable,
|
||||
ImportOptions,
|
||||
ImportResult,
|
||||
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";
|
||||
|
||||
@@ -558,14 +559,16 @@ export class SqliteInterface
|
||||
// `load()` is exactly one `WrappedDb.all()`). Kept lazy so the first query
|
||||
// only fires once the destination starts pulling, and skipped entirely
|
||||
// once the signal is aborted — no further reads after the destination
|
||||
// goes away.
|
||||
const tables: [ExportKind, () => { uid?: string; aid?: string }[]][] = [
|
||||
["user", () => selectClientUsers(db)],
|
||||
["abode", () => selectAbodes(db)],
|
||||
["resident", () => selectResidents(db)],
|
||||
["apikey", () => selectClientApikeys(db)],
|
||||
["note", () => selectNotes(db)],
|
||||
];
|
||||
// goes away. Emission follows the FK-safe EXPORT_KIND_ORDER (part of the
|
||||
// wire contract; see ExportImport.ts).
|
||||
const loaders: Record<ExportKind, () => { uid?: string; aid?: string }[]> =
|
||||
{
|
||||
user: () => selectClientUsers(db),
|
||||
abode: () => selectAbodes(db),
|
||||
resident: () => selectResidents(db),
|
||||
apikey: () => selectClientApikeys(db),
|
||||
note: () => selectNotes(db),
|
||||
};
|
||||
|
||||
async function* generate(): AsyncGenerator<string> {
|
||||
if (signal?.aborted) return;
|
||||
@@ -579,10 +582,10 @@ export class SqliteInterface
|
||||
},
|
||||
}) + "\n";
|
||||
try {
|
||||
for (const [kind, load] of tables) {
|
||||
for (const kind of EXPORT_KIND_ORDER) {
|
||||
if (signal?.aborted) return;
|
||||
if (!kindAllowed(filter, kind)) continue;
|
||||
for (const row of load()) {
|
||||
for (const row of loaders[kind]()) {
|
||||
if (signal?.aborted) return;
|
||||
if (recordAllowed(filter, kind, row)) {
|
||||
yield JSON.stringify({ kind, data: row }) + "\n";
|
||||
|
||||
@@ -8,6 +8,34 @@ import type { DbInterface } from "./DbInterface.js";
|
||||
*/
|
||||
export type ExportKind = "user" | "abode" | "resident" | "apikey" | "note";
|
||||
|
||||
/**
|
||||
* Canonical order in which record kinds are emitted into an export stream, and
|
||||
* the order in which an importer may safely apply them.
|
||||
*
|
||||
* This ordering is **part of the wire contract**, not an implementation
|
||||
* detail. It is FK-safe: every foreign key points only at a kind that appears
|
||||
* earlier (or at the same kind, earlier in the stream), so an importer that
|
||||
* inserts records one-by-one with referential integrity enforced never
|
||||
* forward-references a row it hasn't inserted yet:
|
||||
*
|
||||
* - `abode.created_by`/`updated_by` → `user`
|
||||
* - `resident.uid` → `user`, `resident.aid` → `abode`
|
||||
* - `apikey.uid` → `user`
|
||||
* - `note.aid` → `abode`, `note.created_by`/`updated_by` → `user`
|
||||
*
|
||||
* The sqlite backend can afford to relax this (it suspends FK enforcement for
|
||||
* the load), but the postgres backend relies on it: it inserts sequentially
|
||||
* with constraints live. Every {@link Exportable} MUST emit in this order, and
|
||||
* reordering it is a breaking change to the format.
|
||||
*/
|
||||
export const EXPORT_KIND_ORDER = [
|
||||
"user",
|
||||
"abode",
|
||||
"resident",
|
||||
"apikey",
|
||||
"note",
|
||||
] as const satisfies readonly ExportKind[];
|
||||
|
||||
export type ExportFilter = {
|
||||
/** Include only these kinds; omit = all kinds. */
|
||||
kinds?: ExportKind[];
|
||||
@@ -15,8 +43,16 @@ export type ExportFilter = {
|
||||
excludeKinds?: ExportKind[];
|
||||
/** aid allowlist — scopes abode/resident/note. */
|
||||
abodes?: string[];
|
||||
/** uid allowlist — scopes user/apikey. */
|
||||
/** uid allowlist — scopes user (and apikey, unless `apikeys` is set). */
|
||||
users?: string[];
|
||||
/**
|
||||
* uid allowlist scoping apikey records specifically. When set it takes
|
||||
* precedence over `users` for the `apikey` kind — used to export a
|
||||
* non-admin's own keys while still exporting co-residents' *user* records
|
||||
* for referential integrity, without leaking their apikey metadata. Absent =
|
||||
* fall back to `users`.
|
||||
*/
|
||||
apikeys?: string[];
|
||||
};
|
||||
|
||||
export interface ExportOptions {
|
||||
@@ -47,8 +83,10 @@ export interface Importable {
|
||||
|
||||
/**
|
||||
* 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
|
||||
* after streaming had already begun.
|
||||
* `meta` record; the record lines that follow are grouped by kind in
|
||||
* {@link EXPORT_KIND_ORDER} (an FK-safe order importers may rely on); a
|
||||
* trailing `error` record may appear if the source failed after streaming had
|
||||
* already begun.
|
||||
*/
|
||||
export type ExportEnvelope =
|
||||
| { kind: "meta"; data: ExportMeta }
|
||||
@@ -66,3 +104,7 @@ export type ExportMeta = {
|
||||
export function isExportable(db: DbInterface): db is DbInterface & Exportable {
|
||||
return typeof (db as Partial<Exportable>).export === "function";
|
||||
}
|
||||
|
||||
export function isImportable(db: DbInterface): db is DbInterface & Importable {
|
||||
return typeof (db as Partial<Importable>).import === "function";
|
||||
}
|
||||
|
||||
+45
-4
@@ -15,11 +15,20 @@ import {
|
||||
updateuser,
|
||||
} from "../schema/validators.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 type { ExportFilter, ExportKind } from "../db/types/ExportImport.js";
|
||||
import { intersectExportFilters, isExportKind } from "../db/export/filter.js";
|
||||
import { computeForcedExportFilter } from "./exportScope.js";
|
||||
import {
|
||||
hasGlobalUserVisibility,
|
||||
hideUserEmail,
|
||||
userForCaller,
|
||||
} from "./userVisibility.js";
|
||||
|
||||
function parseExportFilter(query: Record<string, unknown>): ExportFilter {
|
||||
const list = (v: unknown): string[] | undefined => {
|
||||
@@ -92,17 +101,32 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
|
||||
|
||||
router.use("/users", authenticate(db));
|
||||
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) => {
|
||||
ctx.body = await db.createUser(ctx.request.body);
|
||||
});
|
||||
router.get("/users/by-email", async (ctx) => {
|
||||
if (typeof ctx.query.email !== "string") throw new InvalidAbodeError();
|
||||
if (
|
||||
!hasGlobalUserVisibility({
|
||||
user: ctx.user!,
|
||||
session: ctx.session!,
|
||||
})
|
||||
) {
|
||||
throw new NotAuthorizedAbodeError();
|
||||
}
|
||||
ctx.body = await db.getUserByEmail(ctx.query.email);
|
||||
});
|
||||
router.get("/users/:uid", async (ctx) => {
|
||||
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(
|
||||
"/users/:uid",
|
||||
@@ -176,7 +200,24 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
|
||||
ctx.body = await db.listResidentsByAbodeId(ctx.params.aid);
|
||||
});
|
||||
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) => {
|
||||
ctx.body = await db.listNotesByAbodeId(ctx.params.aid);
|
||||
|
||||
@@ -9,10 +9,13 @@ import type { ExportFilter } from "../db/types/ExportImport.js";
|
||||
* global admin whose credential imposes no narrowing) — their own filter, if
|
||||
* any, is then honored verbatim as a voluntary narrowing.
|
||||
*
|
||||
* Otherwise returns `{ abodes, users }`: the abodes the caller resides in, and
|
||||
* the users needed to keep that data referentially whole (the caller plus every
|
||||
* co-resident of those abodes). This is the maximum a non-admin may export; the
|
||||
* route intersects it with any caller-supplied filter (never a union).
|
||||
* Otherwise returns `{ abodes, users, apikeys }`: the abodes the caller resides
|
||||
* in, the users needed to keep that data referentially whole (the caller plus
|
||||
* every co-resident of those abodes), and — scoped tighter than `users` —
|
||||
* apikeys limited to the caller alone, so a non-admin never exports another
|
||||
* user's apikey metadata even though that user's record is included. This is
|
||||
* the maximum a non-admin may export; the route intersects it with any
|
||||
* caller-supplied filter (never a union).
|
||||
*/
|
||||
export async function computeForcedExportFilter(
|
||||
db: BackendDbInterface,
|
||||
@@ -40,6 +43,8 @@ export async function computeForcedExportFilter(
|
||||
|
||||
let abodes = [...abodeSet];
|
||||
let users = [...userSet];
|
||||
// apikeys are self-only for non-admins, regardless of co-residency.
|
||||
let apikeys = [user.uid];
|
||||
|
||||
// An apikey can only narrow what its owning user could otherwise export.
|
||||
if (session.source === "apikey") {
|
||||
@@ -51,8 +56,9 @@ export async function computeForcedExportFilter(
|
||||
if (p.restrict_users?.length) {
|
||||
const allow = new Set(p.restrict_users);
|
||||
users = users.filter((u) => allow.has(u));
|
||||
apikeys = apikeys.filter((u) => allow.has(u));
|
||||
}
|
||||
}
|
||||
|
||||
return { abodes, users };
|
||||
return { abodes, users, apikeys };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -17,7 +17,9 @@ async function getApiDb() {
|
||||
email: AUTH_EMAIL,
|
||||
name: "API Auth User",
|
||||
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 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);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,31 @@ import {
|
||||
import { inspectExportStream } from "../../src/db/export/inspect.js";
|
||||
import { hashPassword } from "../../src/util/hash.js";
|
||||
import type { ClientUser } from "../../src/db/types/User.js";
|
||||
import {
|
||||
EXPORT_KIND_ORDER,
|
||||
type ExportKind,
|
||||
} from "../../src/db/types/ExportImport.js";
|
||||
|
||||
/**
|
||||
* Assert the record lines of a parsed export are grouped in FK-safe
|
||||
* EXPORT_KIND_ORDER: the leading line is `meta`, and every record kind's
|
||||
* position in the order is non-decreasing down the stream.
|
||||
*/
|
||||
function assertFkSafeOrder(lines: { kind: string }[]): void {
|
||||
assert.equal(lines[0]?.kind, "meta", "first line is meta");
|
||||
const rank = (k: string) => EXPORT_KIND_ORDER.indexOf(k as ExportKind);
|
||||
let last = -1;
|
||||
for (const { kind } of lines.slice(1)) {
|
||||
if (kind === "error") continue;
|
||||
const r = rank(kind);
|
||||
assert.notEqual(r, -1, `unexpected kind ${kind}`);
|
||||
assert.ok(
|
||||
r >= last,
|
||||
`kind ${kind} (order ${r}) appears after a later kind (order ${last})`,
|
||||
);
|
||||
last = r;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
@@ -109,6 +134,12 @@ async function seed(db: TestDb["db"]): Promise<Seed> {
|
||||
permissions: {},
|
||||
expires_at: null,
|
||||
});
|
||||
await db.createApikey({
|
||||
uid: co.uid,
|
||||
name: "co key",
|
||||
permissions: {},
|
||||
expires_at: null,
|
||||
});
|
||||
const note1 = await db.createNote(
|
||||
{
|
||||
aid: abode1.aid,
|
||||
@@ -138,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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -312,6 +377,9 @@ describe("exportScope: computeForcedExportFilter", () => {
|
||||
new Set([s.normal.uid, s.co.uid]),
|
||||
);
|
||||
assert.ok(!forced!.users!.includes(s.admin.uid));
|
||||
// apikeys are self-only, even though co is a co-resident whose user
|
||||
// record is exported for referential integrity.
|
||||
assert.deepEqual(forced!.apikeys, [s.normal.uid]);
|
||||
|
||||
// A caller requesting a wider abode never gets it: intersection, not union.
|
||||
const effective = intersectExportFilters(
|
||||
@@ -557,6 +625,17 @@ describe("GET /export endpoint", () => {
|
||||
assert.ok(userUids.has(s.co.uid));
|
||||
assert.ok(!userUids.has(s.admin.uid), "admin not a co-resident of aid1");
|
||||
|
||||
// apikeys are self-only: the caller's own key is exported, but a
|
||||
// co-resident's key metadata is NOT, even though their user record is.
|
||||
const apikeyUids = lines
|
||||
.filter((l) => l.kind === "apikey")
|
||||
.map((l) => l.data.uid);
|
||||
assert.deepEqual(new Set(apikeyUids), new Set([s.normal.uid]));
|
||||
assert.ok(
|
||||
!apikeyUids.includes(s.co.uid),
|
||||
"co-resident apikey metadata must not leak",
|
||||
);
|
||||
|
||||
// The meta line records the *effective* (narrowed) filter.
|
||||
const meta = lines.find((l) => l.kind === "meta");
|
||||
assert.ok(meta);
|
||||
|
||||
@@ -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)!));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user