caldav leaves the platform

calendar and contacts are a plugin now — plugins/caldav, its own repository like
every other one. a fresh machine runs five processes instead of seven and gets a
calendar by installing one plugin, which is the whole point: the platform stops
carrying a python calendar server for people who may not want one.

what left: the radicale sidecar, four routers, the panel apps, the
dav_app_passwords table, the two screens, the two dock tiles, the permission
registry entry, the settings section, and radicale from every package list.

three platform things had to change first, each committed on its own:
6b5c87da a plugin can be more than one screen; e0df28ff a first-party plugin can
claim a reserved root path. this commit adds the third, `personal` on a plugin
permission — a member granted READ must still mint and revoke their own app
password, because that password is how their own phone reaches their own
calendar. those rows are theirs and nobody else's.

two fixes the work surfaced rather than caused. the dock pinned mountPrefix, so
a multi-screen plugin pinned a tile pointing at /caldav — a route no plugin
serves. and the OPTIONS carve-out was a literal list of four DAV paths; it asks
the claim registry now, so it follows whoever owns the route.

/calendar stays in DEFAULT_DOCK_PATHS. it is installed by default and a fresh
dock that silently dropped it would read as a downgrade.

docs:check earned its keep twice here: it caught spotlight still offering
/settings/integrations/dav-app-passwords after the section was deleted, and the
permission count going 22 -> 21. neither is greppable and both were already wrong
when it ran.

verified against alpha, which is still on the pre-extraction code: /.well-known/
caldav 301s, /dav/ answers 401 with the same WWW-Authenticate realm byte for
byte, /api/caldav/passwords 401s, and OPTIONS reaches the DAV router rather than
being answered as a CORS preflight. installed here end to end — schema applied,
sidecar started, two tiles with their own artwork behind one permission.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-27 04:54:42 +00:00
co-authored by Claude Opus 5
parent e0df28ff5d
commit 52f209c69f
45 changed files with 108 additions and 2277 deletions
@@ -1,9 +0,0 @@
export {
listDavAppPasswords,
createDavAppPassword,
revokeDavAppPassword,
deleteDavAppPassword,
verifyDavAppPassword,
} from './queries';
export type { DavAppPassword, DavAppPasswordView } from './queries';
-113
View File
@@ -1,113 +0,0 @@
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;
}
@@ -1,46 +0,0 @@
import { pgTable, serial, integer, text, timestamp, index } from 'drizzle-orm/pg-core';
import { users } from '../auth/schema';
// Per-device credentials for CalDAV / CardDAV clients — DAVx5, iOS, macOS, Thunderbird.
//
// A new credential type is unavoidable and NextCloud solved it the same way for the same reason: a DAV
// client cannot log into Officer. It speaks HTTP Basic on every request and has nowhere to put a 30-day
// JWT, no way to refresh one, and no way to answer a passkey challenge. Without this table the only
// credential a phone could carry is the account password itself — which then lives in that phone's
// account manager in recoverable form, and in whatever backs the phone up.
//
// So: one password per device, revocable per device, hashed with argon2 like the account password and
// SHOWN EXACTLY ONCE at creation. Nothing reads the plaintext back, because nothing stores it.
//
// SCOPE IS LOAD-BEARING. These are accepted ONLY by the /dav mount. `userMiddleware` must never look at
// this table — an app password is not a session, and a device that syncs a calendar has no business
// reaching the wallet or the vault.
//
// `revokedAt` rather than a delete, so a revoked credential stays visible in the UI ("this phone had
// access until Tuesday") instead of silently vanishing. `lastUsedAt` is what makes a stale device
// noticeable at all.
export const davAppPasswords = pgTable(
'dav_app_passwords',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** What the owner calls the device — "Pixel 9", "iPad". The only way to tell two rows apart in the UI. */
label: text('label').notNull(),
/** argon2 hash. Deliberately not unique: two devices could in principle collide and it would not matter. */
passwordHash: text('password_hash').notNull(),
/**
* First 8 chars of the generated secret, stored in the clear on purpose. It is not enough to
* authenticate with, and it is the only way for the owner to match a row in this list against the
* password saved on a device they are looking at.
*/
hint: text('hint').notNull(),
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
// Every DAV request authenticates, and a phone polls often. This index is the difference between that
// being free and it being a sequential scan on every PROPFIND.
(t) => [index('idx_dav_app_passwords_user').on(t.userId)],
);
-1
View File
@@ -52,7 +52,6 @@ export * from './user-data';
// ── Plugins — exported only so tsgo stays clean; nothing mounts them ──────────────────────────────
export * from './dav';
export * from './notify';
// `operations` is absent because it no longer exists: it held task_logs, and the Task Logs feature was
+5 -3
View File
@@ -51,9 +51,11 @@ export * from './service-connections/schema'; // service_connections
export * from './notify/schema'; // push_devices — core since 2026-08-19, see docs/push-notifications.md
export * from './nudges/schema'; // nudges — the Nudger schedule
// ── Plugins — uncomment when the plugin is installed ─────────────────────────────────────────────
export * from './dav/schema'; // dav_app_passwords officer-caldav
// ── Plugins — nothing here ───────────────────────────────────────────────────────────────────────
//
// `dav_app_passwords` was the last entry and it left with caldav on 2026-08-27. A plugin's tables are
// generated into `drizzle-schema.ts` from the plugin's own `db/schema.ts` when it is installed, which is
// what this hand-maintained line was standing in for while caldav was core.
// ── Plugin tables are NOT here ───────────────────────────────────────────────────────────────────
//