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 | |
| 12 | import { Hono } from 'hono'; |
| 13 | import { z } from 'zod'; |
| 14 | |
| 15 | import { requireAuth } from '../middleware/session.js'; |
| 16 | import type { AppEnv } from '../types/app-env.js'; |
| 17 | import { audit, hashIp } from '../services/audit.js'; |
| 18 | import { assertProjectRole } from '../services/access.js'; |
| 19 | import { |
| 20 | createServiceBadge, |
| 21 | isMintableServiceBadgeProduct, |
| 22 | isServiceBadgeProduct, |
| 23 | isServiceBadgeRole, |
| 24 | listServiceBadges, |
| 25 | revokeServiceBadge, |
| 26 | } from '../services/service-badges.js'; |
| 27 | |
| 28 | const 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 | |
| 35 | export const serviceBadgesRouter = new Hono<AppEnv>(); |
| 36 | |
| 37 | serviceBadgesRouter.use('/v1/projects/:id/service-badges', requireAuth()); |
| 38 | serviceBadgesRouter.use('/v1/projects/:id/service-badges/*', requireAuth()); |
| 39 | |
| 40 | serviceBadgesRouter.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 | |
| 58 | serviceBadgesRouter.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 | |
| 139 | serviceBadgesRouter.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 | }); |