import { pgTable, serial, integer, text, timestamp, index } from 'drizzle-orm/pg-core'; import { users } from 'officerdb/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)], );