feat(broker): device-code auth with PostgreSQL persistence
Some checks failed
CI / Typecheck (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

- Drizzle schema: device_code + cli_session tables in mesh pgSchema
- Broker endpoints: POST /cli/device-code, GET /cli/device-code/:code,
  POST /cli/device-code/:code/approve, GET /cli/sessions
- Web app API routes now proxy to broker (no in-memory state)
- Tracks devices per user: hostname, platform, arch, last_seen, token_hash
- JWT signed with CLI_SYNC_SECRET, 30-day expiry
- Session revocation support via revokedAt column

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-13 08:22:13 +01:00
parent ac709dbe92
commit ca441dae45
5 changed files with 377 additions and 141 deletions

View File

@@ -1017,3 +1017,95 @@ export const selectTelegramBridgeSchema = createSelectSchema(telegramBridge);
export const insertTelegramBridgeSchema = createInsertSchema(telegramBridge);
export type SelectTelegramBridge = typeof telegramBridge.$inferSelect;
export type InsertTelegramBridge = typeof telegramBridge.$inferInsert;
// ---------------------------------------------------------------------------
// CLI device-code authentication
// ---------------------------------------------------------------------------
export const deviceCodeStatusEnum = meshSchema.enum("device_code_status", [
"pending",
"approved",
"consumed",
"expired",
]);
/**
* Device codes for CLI → browser → CLI OAuth flow.
* CLI creates a code, browser approves it, CLI polls until approved.
*/
export const deviceCode = meshSchema.table("device_code", {
id: text().primaryKey().notNull().$defaultFn(generateId),
/** Random 16-char code used by CLI to poll. */
deviceCode: text().notNull().unique(),
/** Human-readable code shown in browser (ABCD-EFGH). */
userCode: text().notNull(),
status: deviceCodeStatusEnum().notNull().default("pending"),
/** Filled on approve — the authenticated user. */
userId: text().references(() => user.id, { onDelete: "cascade" }),
/** Device info from CLI request. */
hostname: text(),
platform: text(),
arch: text(),
ipAddress: text(),
/** Signed JWT session token — filled on approve. */
sessionToken: text(),
createdAt: timestamp().defaultNow().notNull(),
approvedAt: timestamp(),
expiresAt: timestamp().notNull(),
}, (table) => [
index("device_code_status_idx").on(table.status),
index("device_code_user_code_idx").on(table.userCode),
]);
export const deviceCodeRelations = relations(deviceCode, ({ one }) => ({
user: one(user, {
fields: [deviceCode.userId],
references: [user.id],
}),
}));
export const selectDeviceCodeSchema = createSelectSchema(deviceCode);
export const insertDeviceCodeSchema = createInsertSchema(deviceCode);
export type SelectDeviceCode = typeof deviceCode.$inferSelect;
export type InsertDeviceCode = typeof deviceCode.$inferInsert;
/**
* Persistent CLI session records — one per authenticated device.
* Enables dashboard "Signed in on N devices" view and per-device revocation.
*/
export const cliSession = meshSchema.table("cli_session", {
id: text().primaryKey().notNull().$defaultFn(generateId),
userId: text()
.references(() => user.id, { onDelete: "cascade" })
.notNull(),
/** Which device-code auth created this session. */
deviceCodeId: text().references(() => deviceCode.id),
hostname: text(),
platform: text(),
arch: text(),
/** SHA-256 hash of the JWT for revocation lookup. */
tokenHash: text().notNull(),
lastSeenAt: timestamp().defaultNow(),
createdAt: timestamp().defaultNow().notNull(),
/** NULL until user revokes from dashboard. */
revokedAt: timestamp(),
}, (table) => [
index("cli_session_user_idx").on(table.userId),
index("cli_session_token_hash_idx").on(table.tokenHash),
]);
export const cliSessionRelations = relations(cliSession, ({ one }) => ({
user: one(user, {
fields: [cliSession.userId],
references: [user.id],
}),
deviceCodeEntry: one(deviceCode, {
fields: [cliSession.deviceCodeId],
references: [deviceCode.id],
}),
}));
export const selectCliSessionSchema = createSelectSchema(cliSession);
export const insertCliSessionSchema = createInsertSchema(cliSession);
export type SelectCliSession = typeof cliSession.$inferSelect;
export type InsertCliSession = typeof cliSession.$inferInsert;