service-badges.ts153 lines · main
1/**
2 * Project service badges — mint / list / revoke.
3 *
4 * GET /v1/projects/:id/service-badges?product=db|s3|auth
5 * POST /v1/projects/:id/service-badges
6 * DELETE /v1/projects/:id/service-badges/:badgeId
7 *
8 * Dashboard session only (admin) — same bar as api-keys / storage-keys mint.
9 * Secrets returned once on create.
10 */
11
12import { Hono } from 'hono';
13import { z } from 'zod';
14
15import { requireAuth } from '../middleware/session.js';
16import type { AppEnv } from '../types/app-env.js';
17import { audit, hashIp } from '../services/audit.js';
18import { assertProjectRole } from '../services/access.js';
19import {
20 createServiceBadge,
21 isMintableServiceBadgeProduct,
22 isServiceBadgeProduct,
23 isServiceBadgeRole,
24 listServiceBadges,
25 revokeServiceBadge,
26} from '../services/service-badges.js';
27
28const createSchema = z.object({
29 name: z.string().min(1).max(80),
30 product: z.enum(['db', 's3', 'auth', 'pay']),
31 role: z.enum(['viewer', 'developer', 'admin']).optional(),
32 expiresInDays: z.number().int().positive().max(365).optional(),
33});
34
35export const serviceBadgesRouter = new Hono<AppEnv>();
36
37serviceBadgesRouter.use('/v1/projects/:id/service-badges', requireAuth());
38serviceBadgesRouter.use('/v1/projects/:id/service-badges/*', requireAuth());
39
40serviceBadgesRouter.get('/v1/projects/:id/service-badges', async (c) => {
41 const user = c.get('user')!;
42 const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin');
43 const productQ = c.req.query('product');
44 const product =
45 productQ && isServiceBadgeProduct(productQ) ? productQ : undefined;
46 const badges = await listServiceBadges(project.id, product);
47 return c.json({
48 badges,
49 products: {
50 db: 'Doltgres database (tables, query, studio)',
51 s3: 'S3 / object storage for this project bucket',
52 auth: 'Auth machine clients (SuperTokens-style M2M)',
53 pay: 'Briven Pay (coming later)',
54 },
55 });
56});
57
58serviceBadgesRouter.post('/v1/projects/:id/service-badges', async (c) => {
59 const user = c.get('user')!;
60 const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin');
61 const body = await c.req.json().catch(() => null);
62 const parsed = createSchema.safeParse(body);
63 if (!parsed.success) {
64 return c.json(
65 {
66 code: 'validation_failed',
67 message: 'invalid request body',
68 issues: parsed.error.issues,
69 },
70 400,
71 );
72 }
73 if (!isMintableServiceBadgeProduct(parsed.data.product)) {
74 return c.json(
75 {
76 code: 'product_not_available',
77 message:
78 parsed.data.product === 'pay'
79 ? 'Briven Pay badges are not available yet'
80 : 'unknown product',
81 },
82 400,
83 );
84 }
85 if (parsed.data.role && !isServiceBadgeRole(parsed.data.role)) {
86 return c.json({ code: 'validation_failed', message: 'invalid role' }, 400);
87 }
88
89 const expiresAt = parsed.data.expiresInDays
90 ? new Date(Date.now() + parsed.data.expiresInDays * 24 * 60 * 60 * 1000)
91 : undefined;
92
93 try {
94 const created = await createServiceBadge({
95 projectId: project.id,
96 product: parsed.data.product,
97 name: parsed.data.name,
98 role: parsed.data.role,
99 createdBy: user.id,
100 expiresAt,
101 });
102
103 await audit({
104 actorId: user.id,
105 projectId: project.id,
106 action: 'service_badge.create',
107 ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')),
108 userAgent: c.req.header('user-agent') ?? null,
109 metadata: {
110 badgeId: created.badge.id,
111 product: created.badge.product,
112 name: created.badge.name,
113 role: created.badge.role,
114 },
115 });
116
117 return c.json(
118 {
119 badge: created.badge,
120 // One-time secrets — never stored as plaintext after this response.
121 plaintext: created.plaintext,
122 s3: created.s3 ?? null,
123 auth: created.auth ?? null,
124 },
125 201,
126 );
127 } catch (err) {
128 const message = err instanceof Error ? err.message : String(err);
129 if (message.includes('not configured') || message.includes('storage')) {
130 return c.json(
131 { code: 'storage_not_configured', message },
132 503,
133 );
134 }
135 throw err;
136 }
137});
138
139serviceBadgesRouter.delete('/v1/projects/:id/service-badges/:badgeId', async (c) => {
140 const user = c.get('user')!;
141 const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin');
142 const badgeId = c.req.param('badgeId');
143 await revokeServiceBadge(project.id, badgeId);
144 await audit({
145 actorId: user.id,
146 projectId: project.id,
147 action: 'service_badge.revoke',
148 ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')),
149 userAgent: c.req.header('user-agent') ?? null,
150 metadata: { badgeId },
151 });
152 return c.json({ ok: true, badgeId });
153});