[AI] Add sync-server API token persistence

This commit is contained in:
Julian Dominguez-Schatz
2026-04-28 00:24:00 -04:00
parent c2987af64f
commit 1b107c3b5e
2 changed files with 348 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
import { getAccountDb } from '../src/account-db';
export const up = async function () {
const accountDb = getAccountDb();
accountDb.transaction(() => {
accountDb.exec(
`
CREATE TABLE api_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
token_hash TEXT NOT NULL,
token_prefix TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER,
expires_at INTEGER NOT NULL,
enabled INTEGER DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE api_token_budgets (
token_id TEXT NOT NULL,
file_id TEXT NOT NULL,
PRIMARY KEY (token_id, file_id),
FOREIGN KEY (token_id) REFERENCES api_tokens(id) ON DELETE CASCADE,
FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE
);
CREATE INDEX idx_api_tokens_user_id ON api_tokens(user_id);
CREATE INDEX idx_api_tokens_prefix ON api_tokens(token_prefix);
CREATE INDEX idx_api_token_budgets_token_id ON api_token_budgets(token_id);
`,
);
});
};
export const down = async function () {
const accountDb = getAccountDb();
accountDb.transaction(() => {
accountDb.exec(
`
DROP INDEX IF EXISTS idx_api_token_budgets_token_id;
DROP INDEX IF EXISTS idx_api_tokens_prefix;
DROP INDEX IF EXISTS idx_api_tokens_user_id;
DROP TABLE IF EXISTS api_token_budgets;
DROP TABLE IF EXISTS api_tokens;
`,
);
});
};

View File

@@ -0,0 +1,296 @@
import crypto from 'crypto';
import * as bcrypt from 'bcrypt';
import { getAccountDb } from '#account-db';
import { TOKEN_EXPIRATION_NEVER } from '#util/validate-user';
// ============================================
// Database Row Types (internal use)
// ============================================
/** Raw row from api_tokens table */
type ApiTokenRow = {
id: string;
user_id: string;
name: string;
token_hash: string;
token_prefix: string;
created_at: number;
last_used_at: number | null;
expires_at: number;
enabled: number; // SQLite stores booleans as 0/1
};
/** Raw row from api_token_budgets table */
type ApiTokenBudgetRow = {
token_id: string;
file_id: string;
};
/** Database wrapper type */
type WrappedDatabase = {
all<T = unknown>(sql: string, params?: unknown[]): T[];
first<T = unknown>(sql: string, params?: unknown[]): T | null;
mutate(
sql: string,
params?: unknown[],
): { changes: number; insertId: number | bigint };
transaction<T>(fn: () => T): T;
};
// ============================================
// Public API Types (exported)
// ============================================
/** Result returned when creating a new token */
export type CreateTokenResult = {
id: string;
token: string; // Only exposed at creation time
prefix: string;
name: string;
budgetIds: string[];
createdAt: number;
expiresAt: number;
};
/** Token information returned when listing tokens */
export type TokenListItem = {
id: string;
name: string;
prefix: string;
createdAt: number;
lastUsedAt: number | null;
expiresAt: number;
enabled: boolean;
budgetIds: string[];
};
/** API Token Service interface */
export type ApiTokenService = {
createToken(
userId: string,
name: string,
budgetIds?: string[],
expiresAt?: number | null,
): Promise<CreateTokenResult>;
listTokens(userId: string): TokenListItem[];
revokeToken(tokenId: string, userId: string): boolean;
setTokenEnabled(tokenId: string, userId: string, enabled: boolean): boolean;
getTokenBudgets(tokenId: string): string[];
};
// ============================================
// Constants
// ============================================
const TOKEN_PREFIX = 'act_' as const;
const TOKEN_LENGTH = 32 as const;
// base64 produces 4 chars per 3 bytes, then ~3% are filtered out as non-alphanumeric
// 32 chars * (3 bytes / 4 chars) * 1.34 safety margin = 32 bytes → 43 base64 chars → <1e-8 failure rate
const TOKEN_RANDOM_BYTES = Math.ceil(((TOKEN_LENGTH * 3) / 4) * 1.34);
const BCRYPT_ROUNDS = 12 as const;
// ============================================
// Helper Functions
// ============================================
/**
* Generate a secure random API token
* Format: act_<32 random chars>
*/
function generateToken(): string {
let randomPart = '';
// Loop until we have enough alphanumeric characters (extremely rare to need multiple iterations)
while (randomPart.length < TOKEN_LENGTH) {
const bytes = crypto.randomBytes(TOKEN_RANDOM_BYTES);
randomPart += bytes.toString('base64url').replace(/[^a-zA-Z0-9]/g, '');
}
return `${TOKEN_PREFIX}${randomPart.slice(0, TOKEN_LENGTH)}`;
}
/**
* Extract the prefix portion of a token for lookup
* @param token - The full token
* @returns The prefix (first 12 chars: "act_" + 8 chars)
*/
function extractPrefix(token: string): string {
return token.slice(0, 12);
}
/**
* Hash a token using bcrypt
* @param token - The token to hash
* @returns The bcrypt hash
*/
async function hashToken(token: string): Promise<string> {
return bcrypt.hash(token, BCRYPT_ROUNDS);
}
// ============================================
// Service Implementation
// ============================================
export const apiTokenService: ApiTokenService = {
/**
* Create a new API token for a user
* @param userId - The user ID
* @param name - A friendly name for the token
* @param budgetIds - Array of budget/file IDs to scope the token to (empty = all)
* @param expiresAt - Unix timestamp for expiration, or null for never
*/
async createToken(
userId: string,
name: string,
budgetIds: string[] = [],
expiresAt: number | null = null,
): Promise<CreateTokenResult> {
const accountDb = getAccountDb() as WrappedDatabase;
const token = generateToken();
const tokenHash = await hashToken(token);
const tokenPrefix = extractPrefix(token);
const tokenId = crypto.randomUUID();
const createdAt = Math.floor(Date.now() / 1000);
const expiration = expiresAt ?? TOKEN_EXPIRATION_NEVER;
accountDb.transaction(() => {
accountDb.mutate(
`INSERT INTO api_tokens (id, user_id, name, token_hash, token_prefix, created_at, expires_at, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)`,
[tokenId, userId, name, tokenHash, tokenPrefix, createdAt, expiration],
);
// Add budget scopes if specified
for (const budgetId of budgetIds) {
accountDb.mutate(
`INSERT INTO api_token_budgets (token_id, file_id) VALUES (?, ?)`,
[tokenId, budgetId],
);
}
});
return {
id: tokenId,
token, // Only returned on creation
prefix: tokenPrefix,
name,
budgetIds,
createdAt,
expiresAt: expiration,
};
},
/**
* List all API tokens for a user (without revealing token values)
* @param userId - The user ID
*/
listTokens(userId: string): TokenListItem[] {
const accountDb = getAccountDb() as WrappedDatabase;
const tokens = accountDb.all<ApiTokenRow>(
`SELECT id, name, token_prefix, created_at, last_used_at, expires_at, enabled
FROM api_tokens
WHERE user_id = ?
ORDER BY created_at DESC`,
[userId],
);
// Early return if no tokens
if (tokens.length === 0) {
return [];
}
// Fetch all budgets for all tokens in a single query (avoids N+1)
const tokenIds = tokens.map(t => t.id);
const placeholders = tokenIds.map(() => '?').join(',');
const allBudgets = accountDb.all<ApiTokenBudgetRow>(
`SELECT token_id, file_id FROM api_token_budgets WHERE token_id IN (${placeholders})`,
tokenIds,
);
// Build a lookup map: token_id -> array of file_ids
const budgetsByToken = new Map<string, string[]>();
for (const budget of allBudgets) {
const existing = budgetsByToken.get(budget.token_id) || [];
existing.push(budget.file_id);
budgetsByToken.set(budget.token_id, existing);
}
return tokens.map(token => ({
id: token.id,
name: token.name,
prefix: token.token_prefix,
createdAt: token.created_at,
lastUsedAt: token.last_used_at,
expiresAt: token.expires_at,
enabled: Boolean(token.enabled),
budgetIds: budgetsByToken.get(token.id) || [],
}));
},
/**
* Revoke (delete) an API token
* @param tokenId - The token ID
* @param userId - The user ID (for authorization)
* @returns True if the token was deleted
*/
revokeToken(tokenId: string, userId: string): boolean {
const accountDb = getAccountDb() as WrappedDatabase;
// Verify ownership
const token = accountDb.first<{ id: string }>(
`SELECT id FROM api_tokens WHERE id = ? AND user_id = ?`,
[tokenId, userId],
);
if (!token) {
return false;
}
accountDb.transaction(() => {
// Delete budget scopes first (cascade should handle this, but be explicit)
accountDb.mutate(`DELETE FROM api_token_budgets WHERE token_id = ?`, [
tokenId,
]);
accountDb.mutate(`DELETE FROM api_tokens WHERE id = ?`, [tokenId]);
});
return true;
},
/**
* Enable or disable an API token
* @param tokenId - The token ID
* @param userId - The user ID (for authorization)
* @param enabled - Whether to enable or disable
* @returns True if the token was updated
*/
setTokenEnabled(tokenId: string, userId: string, enabled: boolean): boolean {
const accountDb = getAccountDb() as WrappedDatabase;
const result = accountDb.mutate(
`UPDATE api_tokens SET enabled = ? WHERE id = ? AND user_id = ?`,
[enabled ? 1 : 0, tokenId, userId],
);
return result.changes > 0;
},
/**
* Get the budget IDs that a token has access to
* @param tokenId - The token ID
* @returns Array of budget/file IDs (empty means all user's budgets)
*/
getTokenBudgets(tokenId: string): string[] {
const accountDb = getAccountDb() as WrappedDatabase;
const budgetRows = accountDb.all<ApiTokenBudgetRow>(
`SELECT file_id FROM api_token_budgets WHERE token_id = ?`,
[tokenId],
);
return budgetRows.map(row => row.file_id);
},
};