Files
platform/src/databases/officer_db/src/dav/queries.ts
T
pastilhasandClaude Opus 5 68f2c55ecf one directory per feature: schema.ts and queries.ts together
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>
2026-08-13 01:34:04 +00:00

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;
}