Add browser-based board CLI auth flow

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-23 07:48:03 -05:00
parent 1376fc8f44
commit 37c2c4acc4
31 changed files with 13299 additions and 19 deletions

View file

@ -0,0 +1,39 @@
CREATE TABLE "board_api_keys" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"name" text NOT NULL,
"key_hash" text NOT NULL,
"last_used_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"expires_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cli_auth_challenges" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"secret_hash" text NOT NULL,
"command" text NOT NULL,
"client_name" text,
"requested_access" text DEFAULT 'board' NOT NULL,
"requested_company_id" uuid,
"pending_key_hash" text NOT NULL,
"pending_key_name" text NOT NULL,
"approved_by_user_id" text,
"board_api_key_id" uuid,
"approved_at" timestamp with time zone,
"cancelled_at" timestamp with time zone,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "instance_settings" ADD COLUMN "general" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "board_api_keys" ADD CONSTRAINT "board_api_keys_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cli_auth_challenges" ADD CONSTRAINT "cli_auth_challenges_requested_company_id_companies_id_fk" FOREIGN KEY ("requested_company_id") REFERENCES "public"."companies"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cli_auth_challenges" ADD CONSTRAINT "cli_auth_challenges_approved_by_user_id_user_id_fk" FOREIGN KEY ("approved_by_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cli_auth_challenges" ADD CONSTRAINT "cli_auth_challenges_board_api_key_id_board_api_keys_id_fk" FOREIGN KEY ("board_api_key_id") REFERENCES "public"."board_api_keys"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "board_api_keys_key_hash_idx" ON "board_api_keys" USING btree ("key_hash");--> statement-breakpoint
CREATE INDEX "board_api_keys_user_idx" ON "board_api_keys" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "cli_auth_challenges_secret_hash_idx" ON "cli_auth_challenges" USING btree ("secret_hash");--> statement-breakpoint
CREATE INDEX "cli_auth_challenges_approved_by_idx" ON "cli_auth_challenges" USING btree ("approved_by_user_id");--> statement-breakpoint
CREATE INDEX "cli_auth_challenges_requested_company_idx" ON "cli_auth_challenges" USING btree ("requested_company_id");

File diff suppressed because it is too large Load diff

View file

@ -309,6 +309,13 @@
"when": 1774008910991,
"tag": "0043_reflective_captain_universe",
"breakpoints": true
},
{
"idx": 44,
"version": "7",
"when": 1774269579794,
"tag": "0044_illegal_toad",
"breakpoints": true
}
]
}
}

View file

@ -0,0 +1,20 @@
import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core";
import { authUsers } from "./auth.js";
export const boardApiKeys = pgTable(
"board_api_keys",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: text("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }),
name: text("name").notNull(),
keyHash: text("key_hash").notNull(),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
expiresAt: timestamp("expires_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
keyHashIdx: index("board_api_keys_key_hash_idx").on(table.keyHash),
userIdx: index("board_api_keys_user_idx").on(table.userId),
}),
);

View file

@ -0,0 +1,30 @@
import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core";
import { authUsers } from "./auth.js";
import { companies } from "./companies.js";
import { boardApiKeys } from "./board_api_keys.js";
export const cliAuthChallenges = pgTable(
"cli_auth_challenges",
{
id: uuid("id").primaryKey().defaultRandom(),
secretHash: text("secret_hash").notNull(),
command: text("command").notNull(),
clientName: text("client_name"),
requestedAccess: text("requested_access").notNull().default("board"),
requestedCompanyId: uuid("requested_company_id").references(() => companies.id, { onDelete: "set null" }),
pendingKeyHash: text("pending_key_hash").notNull(),
pendingKeyName: text("pending_key_name").notNull(),
approvedByUserId: text("approved_by_user_id").references(() => authUsers.id, { onDelete: "set null" }),
boardApiKeyId: uuid("board_api_key_id").references(() => boardApiKeys.id, { onDelete: "set null" }),
approvedAt: timestamp("approved_at", { withTimezone: true }),
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
secretHashIdx: index("cli_auth_challenges_secret_hash_idx").on(table.secretHash),
approvedByIdx: index("cli_auth_challenges_approved_by_idx").on(table.approvedByUserId),
requestedCompanyIdx: index("cli_auth_challenges_requested_company_idx").on(table.requestedCompanyId),
}),
);

View file

@ -4,6 +4,8 @@ export { authUsers, authSessions, authAccounts, authVerifications } from "./auth
export { instanceSettings } from "./instance_settings.js";
export { instanceUserRoles } from "./instance_user_roles.js";
export { agents } from "./agents.js";
export { boardApiKeys } from "./board_api_keys.js";
export { cliAuthChallenges } from "./cli_auth_challenges.js";
export { companyMemberships } from "./company_memberships.js";
export { principalPermissionGrants } from "./principal_permission_grants.js";
export { invites } from "./invites.js";

View file

@ -444,6 +444,9 @@ export {
acceptInviteSchema,
listJoinRequestsQuerySchema,
claimJoinRequestApiKeySchema,
boardCliAuthAccessLevelSchema,
createCliAuthChallengeSchema,
resolveCliAuthChallengeSchema,
updateMemberPermissionsSchema,
updateUserCompanyAccessSchema,
type CreateCostEvent,
@ -455,6 +458,9 @@ export {
type AcceptInvite,
type ListJoinRequestsQuery,
type ClaimJoinRequestApiKey,
type BoardCliAuthAccessLevel,
type CreateCliAuthChallenge,
type ResolveCliAuthChallenge,
type UpdateMemberPermissions,
type UpdateUserCompanyAccess,
companySkillSourceTypeSchema,

View file

@ -52,6 +52,28 @@ export const claimJoinRequestApiKeySchema = z.object({
export type ClaimJoinRequestApiKey = z.infer<typeof claimJoinRequestApiKeySchema>;
export const boardCliAuthAccessLevelSchema = z.enum([
"board",
"instance_admin_required",
]);
export type BoardCliAuthAccessLevel = z.infer<typeof boardCliAuthAccessLevelSchema>;
export const createCliAuthChallengeSchema = z.object({
command: z.string().min(1).max(240),
clientName: z.string().max(120).optional().nullable(),
requestedAccess: boardCliAuthAccessLevelSchema.default("board"),
requestedCompanyId: z.string().uuid().optional().nullable(),
});
export type CreateCliAuthChallenge = z.infer<typeof createCliAuthChallengeSchema>;
export const resolveCliAuthChallengeSchema = z.object({
token: z.string().min(16).max(256),
});
export type ResolveCliAuthChallenge = z.infer<typeof resolveCliAuthChallengeSchema>;
export const updateMemberPermissionsSchema = z.object({
grants: z.array(
z.object({

View file

@ -226,6 +226,9 @@ export {
acceptInviteSchema,
listJoinRequestsQuerySchema,
claimJoinRequestApiKeySchema,
boardCliAuthAccessLevelSchema,
createCliAuthChallengeSchema,
resolveCliAuthChallengeSchema,
updateMemberPermissionsSchema,
updateUserCompanyAccessSchema,
type CreateCompanyInvite,
@ -233,6 +236,9 @@ export {
type AcceptInvite,
type ListJoinRequestsQuery,
type ClaimJoinRequestApiKey,
type BoardCliAuthAccessLevel,
type CreateCliAuthChallenge,
type ResolveCliAuthChallenge,
type UpdateMemberPermissions,
type UpdateUserCompanyAccess,
} from "./access.js";