feat: personal API tokens
Pull request / title (pull_request) Successful in 0s
CI / test (pull_request) Successful in 1m9s
CI / image (apps/api/Dockerfile, t5de-hub-api) (pull_request) Skipped
CI / image (apps/web/Dockerfile, t5de-hub-web) (pull_request) Skipped

Tokens for scripts, made, listed and revoked on the account page. Each grants some of the person's own permissions
at chosen places (a product of theirs, a catalog whose team they're on, or the hub for moderators); every check is
the intersection with what they hold now. Tokens work only on routes that check a permission (session-only routes
answer token_not_allowed), are shown once and stored as hashes, note their last use, and mark the audit entries of
what they do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
dhkatzandClaude Opus 5.5 committed 2026-11-05 14:58:58 -08:00
1 parent 58a3e70a63
commit 2b88a52bcb
17 files changed
+4358 -98

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
import * as schema from "./schema";
export { schema };
export type { AuditTags, DecorationItem, FileEntry, InfoChange, ParentInfo, RoomModerator, SceneItem } from "./schema";
export type { AuditTags, DecorationItem, FileEntry, InfoChange, ParentInfo, RoomModerator, SceneItem, TokenGrant } from "./schema";
export * from "drizzle-orm";
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
+22
View File
@@ -667,3 +667,25 @@ export const notification = pgTable(
},
(t) => [index("notification_user").on(t.userId, t.id)],
);
/** What an API token may do: a permission at a scope (`{type: "hub"}`, or a catalog or product by ID). */
export type TokenGrant = { permission: string; scope: { type: "hub" } | { type: "catalog" | "product"; id: string } };
// Personal API tokens for scripts (docs/console.md): a subset of the person's permissions; only the hash is kept.
export const apiToken = pgTable(
"api_token",
{
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
name: text("name").notNull(),
hash: text("hash").notNull(), // sha256 of the token, hex
grants: jsonb("grants").$type<TokenGrant[]>().notNull(),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
createdAt: createdAt(),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
},
(t) => [uniqueIndex("api_token_hash").on(t.hash), index("api_token_user").on(t.userId)],
);