src/databases/officer_db/src/<feature>/{schema.ts,queries.ts}, replacing the
parallel schema/ and queries/ trees. 24 feature directories, 46 files moved with
git mv so history follows.
The parallel trees had drifted, which is what the restructure is really fixing:
four features were named differently on each side — app-store/sidecar-installs,
email/email-accounts, server/server-config
operations had a schema and NO query file: its task_logs is reached directly
from src/servers/api/task-logger.ts, bypassing this package's own boundary
integrations had queries and NO schema, because it spans two features'
tables — server_integrations and user_integrations
Both lopsided cases survive as directories holding one file, which states the
problem instead of hiding it across two trees.
Nothing outside the package changed how it imports. `officerdb`, `officerdb/types`
and `officerdb/db` resolve exactly as before; index.ts absorbed the path changes.
Added `"./*": "./src/*"` so the new layout is reachable — `officerdb/soulseek/schema`
— which one script needed, because soulseek is a plugin and therefore commented
out of the aggregator.
schema/index.ts became src/schema.ts, keeping the core/plugin split from earlier
tonight. drizzle.config.ts and the package's "./schema" export follow it.
Verified rather than assumed: all 52 files in the package parse, every relative
import resolves against the new layout (checked by walking each specifier to a
real file, since parsing does not check paths), and everything in the tree
importing officerdb still parses. Not typechecked — empty node_modules, frozen
installs.
One rewrite bug worth recording: the rule mapping a query module's sibling import
also matched the './schema' this pass had just written, turning it into
'../schema/queries' in 22 files. Caught by the resolver check, not by parsing —
both spellings parse fine.
Also corrects every path reference the move invalidated: src/databases/CLAUDE.md's
layout diagram, the root CLAUDE.md data section, three docs, and seven sidecar
comments naming queries/<x>.ts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
114 lines
4.0 KiB
TypeScript
114 lines
4.0 KiB
TypeScript
import { and, desc, eq, isNull } from 'drizzle-orm';
|
|
import argon2 from 'argon2';
|
|
import { randomBytes } from 'node:crypto';
|
|
import { db } from '../db';
|
|
import { davAppPasswords } from './schema';
|
|
|
|
export type DavAppPassword = typeof davAppPasswords.$inferSelect;
|
|
|
|
/** What the UI is allowed to see: everything except the hash. */
|
|
export type DavAppPasswordView = Omit<DavAppPassword, 'passwordHash'>;
|
|
|
|
const view = (row: DavAppPassword): DavAppPasswordView => {
|
|
const { passwordHash: _hash, ...rest } = row;
|
|
return rest;
|
|
};
|
|
|
|
// Base32-ish over an unambiguous alphabet: no 0/O/1/I/l, because this gets read off a screen and typed
|
|
// into a phone by hand. Grouped into blocks of four for the same reason.
|
|
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
|
|
|
|
function generateSecret(): string {
|
|
const bytes = randomBytes(20);
|
|
// 20 bytes over a 31-char alphabet ≈ 99 bits. Well past anything Basic auth over TLS needs, and it
|
|
// still fits in five readable blocks.
|
|
const chars = Array.from(bytes, (b) => ALPHABET[b % ALPHABET.length]).join('');
|
|
return (chars.match(/.{1,4}/g) ?? [chars]).join('-');
|
|
}
|
|
|
|
export async function listDavAppPasswords(userId: number): Promise<DavAppPasswordView[]> {
|
|
const rows = await db
|
|
.select()
|
|
.from(davAppPasswords)
|
|
.where(eq(davAppPasswords.userId, userId))
|
|
.orderBy(desc(davAppPasswords.createdAt));
|
|
return rows.map(view);
|
|
}
|
|
|
|
/**
|
|
* Mint a credential. The plaintext is returned HERE AND NOWHERE ELSE — it is not stored, so this return
|
|
* value is the only chance the owner gets to see it.
|
|
*/
|
|
export async function createDavAppPassword(
|
|
userId: number,
|
|
label: string,
|
|
): Promise<{ entry: DavAppPasswordView; password: string }> {
|
|
const password = generateSecret();
|
|
const [row] = await db
|
|
.insert(davAppPasswords)
|
|
.values({
|
|
userId,
|
|
label,
|
|
passwordHash: await argon2.hash(password),
|
|
hint: password.slice(0, 8),
|
|
})
|
|
.returning();
|
|
if (!row) throw new Error('failed to create dav app password');
|
|
return { entry: view(row), password };
|
|
}
|
|
|
|
export async function revokeDavAppPassword(userId: number, id: number): Promise<boolean> {
|
|
const [row] = await db
|
|
.update(davAppPasswords)
|
|
.set({ revokedAt: new Date() })
|
|
.where(and(eq(davAppPasswords.id, id), eq(davAppPasswords.userId, userId), isNull(davAppPasswords.revokedAt)))
|
|
.returning();
|
|
return !!row;
|
|
}
|
|
|
|
export async function deleteDavAppPassword(userId: number, id: number): Promise<boolean> {
|
|
const [row] = await db
|
|
.delete(davAppPasswords)
|
|
.where(and(eq(davAppPasswords.id, id), eq(davAppPasswords.userId, userId)))
|
|
.returning();
|
|
return !!row;
|
|
}
|
|
|
|
/**
|
|
* Verify a Basic-auth credential, returning the owning user id.
|
|
*
|
|
* Every live credential is tried, because the username a DAV client sends is the account email, not a
|
|
* row id — there is nothing in the request that says WHICH device is calling. That means the cost is
|
|
* one argon2 verify per stored credential, which is why revoked rows are filtered in SQL and why the
|
|
* UI should encourage deleting devices that are gone rather than accumulating them.
|
|
*
|
|
* `lastUsedAt` is written on success, at most once a minute: a syncing phone hits this constantly and
|
|
* the column exists to answer "is this device still around", not to be an access log.
|
|
*/
|
|
export async function verifyDavAppPassword(userId: number, password: string): Promise<number | null> {
|
|
const rows = await db
|
|
.select()
|
|
.from(davAppPasswords)
|
|
.where(and(eq(davAppPasswords.userId, userId), isNull(davAppPasswords.revokedAt)));
|
|
|
|
for (const row of rows) {
|
|
let ok = false;
|
|
try {
|
|
ok = await argon2.verify(row.passwordHash, password);
|
|
} catch {
|
|
ok = false; // a corrupt hash must not take the whole login path down
|
|
}
|
|
if (!ok) continue;
|
|
|
|
const now = Date.now();
|
|
if (!row.lastUsedAt || now - row.lastUsedAt.getTime() > 60_000) {
|
|
await db
|
|
.update(davAppPasswords)
|
|
.set({ lastUsedAt: new Date(now) })
|
|
.where(eq(davAppPasswords.id, row.id));
|
|
}
|
|
return row.userId;
|
|
}
|
|
return null;
|
|
}
|