service-badges.ts436 lines · main
1/**
2 * Service badges — project-scoped agent/machine passes.
3 *
4 * One badge opens exactly one product wall inside one project:
5 * db → Doltgres (studio / query / tables) via bearer `sb_db_…`
6 * s3 → this project's MinIO/S3 bucket (storage key under the hood)
7 * auth → SuperTokens-style M2M client_credentials (briven-engine)
8 * pay → reserved (not mintable yet)
9 *
10 * Secrets are returned once at create. Revoke is idempotent.
11 */
12
13import { createHash, randomBytes } from 'node:crypto';
14
15import { newId, NotFoundError, ValidationError } from '@briven/shared';
16import { and, desc, eq, isNull, sql } from 'drizzle-orm';
17
18import { env } from '../env.js';
19import { getDb } from '../db/client.js';
20import {
21 serviceBadgeProduct,
22 serviceBadgeRole,
23 serviceBadges,
24 type ServiceBadgeProduct,
25 type ServiceBadgeRole,
26} from '../db/schema.js';
27import { createM2mClient, revokeM2mClient } from './auth-core/m2m.js';
28import { createStorageKey, revokeStorageKey } from './storage-keys.js';
29
30const KEY_ENTROPY_BYTES = 32;
31const NAME_MIN = 1;
32const NAME_MAX = 80;
33
34/** Plaintext prefix per product — greppable if leaked. */
35export const SERVICE_BADGE_PREFIX: Record<ServiceBadgeProduct, string> = {
36 db: 'sb_db_',
37 s3: 'sb_s3_',
38 auth: 'sb_auth_',
39 pay: 'sb_pay_',
40};
41
42const MINTABLE: readonly ServiceBadgeProduct[] = ['db', 's3', 'auth'];
43
44export function isServiceBadgeProduct(v: string): v is ServiceBadgeProduct {
45 return (serviceBadgeProduct as readonly string[]).includes(v);
46}
47
48export function isMintableServiceBadgeProduct(v: string): v is 'db' | 's3' | 'auth' {
49 return (MINTABLE as readonly string[]).includes(v);
50}
51
52export function isServiceBadgeRole(v: string): v is ServiceBadgeRole {
53 return (serviceBadgeRole as readonly string[]).includes(v);
54}
55
56/** True when a bearer looks like any service-badge secret. */
57export function looksLikeServiceBadge(token: string): boolean {
58 return (
59 token.startsWith(SERVICE_BADGE_PREFIX.db) ||
60 token.startsWith(SERVICE_BADGE_PREFIX.s3) ||
61 token.startsWith(SERVICE_BADGE_PREFIX.auth) ||
62 token.startsWith(SERVICE_BADGE_PREFIX.pay)
63 );
64}
65
66function hashBearer(plaintext: string): string {
67 return createHash('sha256').update(plaintext).digest('hex');
68}
69
70let tableReady = false;
71async function ensureTable(): Promise<void> {
72 if (tableReady) return;
73 await getDb().execute(
74 sql.raw(`
75 CREATE TABLE IF NOT EXISTS "service_badges" (
76 "id" text PRIMARY KEY NOT NULL,
77 "project_id" text NOT NULL,
78 "product" text NOT NULL,
79 "name" text NOT NULL,
80 "role" text DEFAULT 'developer' NOT NULL,
81 "prefix" text NOT NULL,
82 "suffix" varchar(4) NOT NULL,
83 "hash" text,
84 "storage_key_id" text,
85 "m2m_client_id" text,
86 "created_by" text,
87 "last_used_at" timestamp with time zone,
88 "expires_at" timestamp with time zone,
89 "created_at" timestamp with time zone DEFAULT now() NOT NULL,
90 "revoked_at" timestamp with time zone
91 )`),
92 );
93 await getDb().execute(
94 sql.raw(
95 `CREATE UNIQUE INDEX IF NOT EXISTS "service_badges_hash_idx" ON "service_badges" ("hash")`,
96 ),
97 );
98 await getDb().execute(
99 sql.raw(
100 `CREATE INDEX IF NOT EXISTS "service_badges_project_product_idx" ON "service_badges" ("project_id","product")`,
101 ),
102 );
103 tableReady = true;
104}
105
106export interface MaskedServiceBadge {
107 id: string;
108 product: ServiceBadgeProduct;
109 name: string;
110 role: ServiceBadgeRole;
111 prefix: string;
112 suffix: string;
113 /** product=auth: M2M client id (public half of the machine pair). */
114 m2mClientId: string | null;
115 /** product=s3: MinIO access key id. */
116 storageAccessKeyId: string | null;
117 createdAt: string;
118 lastUsedAt: string | null;
119 expiresAt: string | null;
120 revokedAt: string | null;
121}
122
123export interface CreatedServiceBadge {
124 badge: MaskedServiceBadge;
125 /**
126 * product=db: full bearer secret (sb_db_…).
127 * product=s3 / auth: may be null; product-specific secrets below.
128 */
129 plaintext: string | null;
130 /** product=s3 only — MinIO credentials for this project's bucket. */
131 s3?: {
132 endpoint: string;
133 bucket: string;
134 accessKey: string;
135 secretKey: string;
136 };
137 /** product=auth only — SuperTokens-style M2M client credentials. */
138 auth?: {
139 clientId: string;
140 clientSecret: string;
141 tokenUrl: string;
142 };
143}
144
145function iso(d: Date | null | undefined): string | null {
146 if (!d) return null;
147 return (d instanceof Date ? d : new Date(d)).toISOString();
148}
149
150function toMasked(row: {
151 id: string;
152 product: ServiceBadgeProduct;
153 name: string;
154 role: ServiceBadgeRole;
155 prefix: string;
156 suffix: string;
157 m2mClientId: string | null;
158 storageKeyId: string | null;
159 createdAt: Date;
160 lastUsedAt: Date | null;
161 expiresAt: Date | null;
162 revokedAt: Date | null;
163 storageAccessKeyId?: string | null;
164}): MaskedServiceBadge {
165 return {
166 id: row.id,
167 product: row.product,
168 name: row.name,
169 role: row.role,
170 prefix: row.prefix,
171 suffix: row.suffix,
172 m2mClientId: row.m2mClientId,
173 storageAccessKeyId: row.storageAccessKeyId ?? null,
174 createdAt: iso(row.createdAt) ?? new Date().toISOString(),
175 lastUsedAt: iso(row.lastUsedAt),
176 expiresAt: iso(row.expiresAt),
177 revokedAt: iso(row.revokedAt),
178 };
179}
180
181export async function listServiceBadges(
182 projectId: string,
183 product?: ServiceBadgeProduct,
184): Promise<MaskedServiceBadge[]> {
185 await ensureTable();
186 const db = getDb();
187 const rows = await db
188 .select()
189 .from(serviceBadges)
190 .where(
191 product
192 ? and(eq(serviceBadges.projectId, projectId), eq(serviceBadges.product, product))
193 : eq(serviceBadges.projectId, projectId),
194 )
195 .orderBy(desc(serviceBadges.createdAt));
196
197 return rows.map((r) =>
198 toMasked({
199 ...r,
200 storageAccessKeyId: null,
201 }),
202 );
203}
204
205export async function createServiceBadge(input: {
206 projectId: string;
207 product: ServiceBadgeProduct;
208 name: string;
209 role?: ServiceBadgeRole;
210 createdBy: string | null;
211 expiresAt?: Date;
212}): Promise<CreatedServiceBadge> {
213 await ensureTable();
214 const name = input.name.trim();
215 if (name.length < NAME_MIN || name.length > NAME_MAX) {
216 throw new ValidationError(`name must be ${NAME_MIN}-${NAME_MAX} chars`, { name });
217 }
218 if (!isMintableServiceBadgeProduct(input.product)) {
219 throw new ValidationError(
220 input.product === 'pay'
221 ? 'Briven Pay badges are not available yet'
222 : `product must be one of ${MINTABLE.join(' | ')}`,
223 { product: input.product },
224 );
225 }
226 const role: ServiceBadgeRole =
227 input.role && isServiceBadgeRole(input.role) ? input.role : 'developer';
228 if (!isServiceBadgeRole(role)) {
229 throw new ValidationError(`role must be one of ${serviceBadgeRole.join(' | ')}`, { role });
230 }
231
232 const prefix = SERVICE_BADGE_PREFIX[input.product];
233 const id = newId('sb');
234
235 if (input.product === 'db') {
236 const raw = randomBytes(KEY_ENTROPY_BYTES).toString('base64url');
237 const plaintext = `${prefix}${raw}`;
238 const hash = hashBearer(plaintext);
239 const suffix = plaintext.slice(-4);
240
241 const [record] = await getDb()
242 .insert(serviceBadges)
243 .values({
244 id,
245 projectId: input.projectId,
246 product: 'db',
247 name,
248 role,
249 prefix,
250 suffix,
251 hash,
252 createdBy: input.createdBy,
253 expiresAt: input.expiresAt ?? null,
254 })
255 .returning();
256 if (!record) throw new Error('service_badges insert returned no row');
257
258 return {
259 badge: toMasked({ ...record, storageAccessKeyId: null }),
260 plaintext,
261 };
262 }
263
264 if (input.product === 's3') {
265 const publicEndpoint =
266 env.BRIVEN_MINIO_PUBLIC_ENDPOINT ?? env.BRIVEN_MINIO_ENDPOINT ?? '';
267 const created = await createStorageKey({
268 projectId: input.projectId,
269 name,
270 createdBy: input.createdBy,
271 publicEndpoint,
272 });
273 // Registry row links to the storage key; secret is MinIO's, not a sb_s3_ bearer.
274 const [record] = await getDb()
275 .insert(serviceBadges)
276 .values({
277 id,
278 projectId: input.projectId,
279 product: 's3',
280 name,
281 role,
282 prefix,
283 suffix: created.record.suffix,
284 hash: null,
285 storageKeyId: created.record.id,
286 createdBy: input.createdBy,
287 expiresAt: input.expiresAt ?? null,
288 })
289 .returning();
290 if (!record) throw new Error('service_badges insert returned no row');
291
292 return {
293 badge: toMasked({
294 ...record,
295 storageAccessKeyId: created.accessKey,
296 }),
297 plaintext: null,
298 s3: {
299 endpoint: created.endpoint,
300 bucket: created.bucket,
301 accessKey: created.accessKey,
302 secretKey: created.secretKey,
303 },
304 };
305 }
306
307 // product === 'auth' — SuperTokens-style M2M under the hood
308 const m2m = await createM2mClient({
309 projectId: input.projectId,
310 name,
311 role,
312 createdBy: input.createdBy,
313 });
314 const [record] = await getDb()
315 .insert(serviceBadges)
316 .values({
317 id,
318 projectId: input.projectId,
319 product: 'auth',
320 name,
321 role,
322 prefix,
323 suffix: m2m.client.secretSuffix,
324 hash: null,
325 m2mClientId: m2m.client.clientId,
326 createdBy: input.createdBy,
327 expiresAt: input.expiresAt ?? null,
328 })
329 .returning();
330 if (!record) throw new Error('service_badges insert returned no row');
331
332 const apiBase = (env.BRIVEN_API_ORIGIN ?? '').replace(/\/$/, '');
333 const tokenUrl = apiBase
334 ? `${apiBase}/v1/auth-core/oauth/token`
335 : '/v1/auth-core/oauth/token';
336
337 return {
338 badge: toMasked({ ...record, storageAccessKeyId: null }),
339 plaintext: null,
340 auth: {
341 clientId: m2m.client.clientId,
342 clientSecret: m2m.clientSecret,
343 tokenUrl,
344 },
345 };
346}
347
348export async function revokeServiceBadge(
349 projectId: string,
350 badgeId: string,
351): Promise<void> {
352 await ensureTable();
353 const db = getDb();
354 const [row] = await db
355 .select()
356 .from(serviceBadges)
357 .where(and(eq(serviceBadges.id, badgeId), eq(serviceBadges.projectId, projectId)))
358 .limit(1);
359 if (!row) throw new NotFoundError('service_badge', badgeId);
360 if (row.revokedAt) return; // idempotent
361
362 // Tear down the product credential first, then stamp the registry.
363 if (row.product === 's3' && row.storageKeyId) {
364 try {
365 await revokeStorageKey(projectId, row.storageKeyId);
366 } catch {
367 // storage key may already be gone — still revoke the badge row
368 }
369 }
370 if (row.product === 'auth' && row.m2mClientId) {
371 try {
372 await revokeM2mClient(projectId, row.m2mClientId);
373 } catch {
374 // m2m client may already be gone
375 }
376 }
377
378 await db
379 .update(serviceBadges)
380 .set({ revokedAt: new Date() })
381 .where(eq(serviceBadges.id, badgeId));
382}
383
384/**
385 * Resolve a Doltgres (product=db) bearer secret.
386 * Returns null if invalid, wrong product, revoked, or expired.
387 */
388export async function resolveDbServiceBadge(plaintext: string): Promise<{
389 badgeId: string;
390 projectId: string;
391 role: ServiceBadgeRole;
392 product: 'db';
393} | null> {
394 if (!plaintext.startsWith(SERVICE_BADGE_PREFIX.db)) return null;
395 await ensureTable();
396 const hash = hashBearer(plaintext);
397 const db = getDb();
398 const [row] = await db
399 .select()
400 .from(serviceBadges)
401 .where(
402 and(
403 eq(serviceBadges.hash, hash),
404 eq(serviceBadges.product, 'db'),
405 isNull(serviceBadges.revokedAt),
406 ),
407 )
408 .limit(1);
409 if (!row) return null;
410 if (row.expiresAt && row.expiresAt.getTime() < Date.now()) return null;
411
412 await db
413 .update(serviceBadges)
414 .set({ lastUsedAt: new Date() })
415 .where(eq(serviceBadges.id, row.id));
416
417 return {
418 badgeId: row.id,
419 projectId: row.projectId,
420 role: row.role,
421 product: 'db',
422 };
423}
424
425/**
426 * Product wall check: a service-badge actor may only call routes for its product.
427 * Session / brk_ / CLI / M2M JWT actors have no product lock (full project tools).
428 */
429export function serviceBadgeAllowedOnRoute(
430 badgeProduct: ServiceBadgeProduct | null | undefined,
431 routeProduct: ServiceBadgeProduct | 'any',
432): boolean {
433 if (!badgeProduct) return true; // not a service-badge actor
434 if (routeProduct === 'any') return false; // badge never opens "everything"
435 return badgeProduct === routeProduct;
436}