platform.ts219 lines · main
| 1 | /** |
| 2 | * Platform session helpers: OAuth user login, remote project list/create, |
| 3 | * and minting per-project CLI keys. Shared by `briven connect`, |
| 4 | * `briven projects`, and the interactive wizard. |
| 5 | */ |
| 6 | |
| 7 | import { apiCall, ApiCallError } from './api-client.js'; |
| 8 | import { |
| 9 | readUserCredential, |
| 10 | writeProjectCredential, |
| 11 | writeUserCredential, |
| 12 | type UserCredential, |
| 13 | } from './config.js'; |
| 14 | import { runOAuth } from './oauth.js'; |
| 15 | import { resolveOrigins, type Origins } from './origins.js'; |
| 16 | import { banner, blankLine, error as printError, step, success } from './output.js'; |
| 17 | |
| 18 | export interface MeUser { |
| 19 | id: string; |
| 20 | email: string; |
| 21 | } |
| 22 | |
| 23 | export interface RemoteProject { |
| 24 | id: string; |
| 25 | slug: string; |
| 26 | name?: string; |
| 27 | region: string; |
| 28 | tier: string; |
| 29 | orgName: string | null; |
| 30 | } |
| 31 | |
| 32 | /** Normalize /v1/me — API returns a flat profile; tolerate a nested { user } shape. */ |
| 33 | export function normalizeMe(body: unknown): MeUser { |
| 34 | if (!body || typeof body !== 'object') { |
| 35 | throw new Error('unexpected /v1/me response'); |
| 36 | } |
| 37 | const rec = body as Record<string, unknown>; |
| 38 | if (rec.user && typeof rec.user === 'object') { |
| 39 | const u = rec.user as Record<string, unknown>; |
| 40 | if (typeof u.id === 'string' && typeof u.email === 'string') { |
| 41 | return { id: u.id, email: u.email }; |
| 42 | } |
| 43 | } |
| 44 | if (typeof rec.id === 'string' && typeof rec.email === 'string') { |
| 45 | return { id: rec.id, email: rec.email }; |
| 46 | } |
| 47 | throw new Error('unexpected /v1/me response (missing id/email)'); |
| 48 | } |
| 49 | |
| 50 | export async function fetchMe(apiOrigin: string, token: string): Promise<MeUser> { |
| 51 | const body = await apiCall<unknown>('/v1/me', { |
| 52 | apiOrigin, |
| 53 | bearer: token, |
| 54 | }); |
| 55 | return normalizeMe(body); |
| 56 | } |
| 57 | |
| 58 | export async function listRemoteProjects( |
| 59 | apiOrigin: string, |
| 60 | token: string, |
| 61 | ): Promise<RemoteProject[]> { |
| 62 | const list = await apiCall<{ projects: RemoteProject[] }>('/v1/me/projects', { |
| 63 | apiOrigin, |
| 64 | bearer: token, |
| 65 | }); |
| 66 | return list.projects; |
| 67 | } |
| 68 | |
| 69 | /** One-time storage credentials returned by POST /v1/projects (standard setup). */ |
| 70 | export interface ProjectStorageBootstrap { |
| 71 | endpoint: string; |
| 72 | bucket: string; |
| 73 | accessKey: string; |
| 74 | secretKey: string; |
| 75 | } |
| 76 | |
| 77 | export async function createRemoteProject( |
| 78 | apiOrigin: string, |
| 79 | token: string, |
| 80 | input: { name: string; region?: string; slug?: string }, |
| 81 | ): Promise<{ id: string; slug: string; storage: ProjectStorageBootstrap | null }> { |
| 82 | const created = await apiCall<{ |
| 83 | project: { id: string; slug: string }; |
| 84 | storage?: { |
| 85 | endpoint: string; |
| 86 | bucket: string; |
| 87 | accessKey: string; |
| 88 | secretKey: string; |
| 89 | } | null; |
| 90 | }>('/v1/projects', { |
| 91 | apiOrigin, |
| 92 | bearer: token, |
| 93 | method: 'POST', |
| 94 | body: { |
| 95 | name: input.name, |
| 96 | ...(input.region ? { region: input.region } : {}), |
| 97 | ...(input.slug ? { slug: input.slug } : {}), |
| 98 | }, |
| 99 | }); |
| 100 | const s = created.storage; |
| 101 | const storage = |
| 102 | s && s.endpoint && s.bucket && s.accessKey && s.secretKey |
| 103 | ? { |
| 104 | endpoint: s.endpoint, |
| 105 | bucket: s.bucket, |
| 106 | accessKey: s.accessKey, |
| 107 | secretKey: s.secretKey, |
| 108 | } |
| 109 | : null; |
| 110 | return { id: created.project.id, slug: created.project.slug, storage }; |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * Mint a project-scoped admin key and persist it so `briven deploy` / `dev` |
| 115 | * work without pasting a dashboard key by hand. |
| 116 | */ |
| 117 | export async function mintAndStoreKey( |
| 118 | apiOrigin: string, |
| 119 | token: string, |
| 120 | projectId: string, |
| 121 | keyName = 'cli', |
| 122 | ): Promise<{ suffix: string }> { |
| 123 | const minted = await apiCall<{ |
| 124 | key: { id: string; suffix: string; createdAt: string }; |
| 125 | plaintext: string; |
| 126 | }>(`/v1/projects/${projectId}/api-keys`, { |
| 127 | apiOrigin, |
| 128 | bearer: token, |
| 129 | method: 'POST', |
| 130 | body: { name: keyName, role: 'admin' }, |
| 131 | }); |
| 132 | await writeProjectCredential({ |
| 133 | projectId, |
| 134 | apiKey: minted.plaintext, |
| 135 | apiOrigin, |
| 136 | suffix: minted.key.suffix, |
| 137 | createdAt: minted.key.createdAt, |
| 138 | }); |
| 139 | return { suffix: minted.key.suffix }; |
| 140 | } |
| 141 | |
| 142 | export interface EnsureSessionOptions { |
| 143 | /** Force a fresh browser OAuth even if a token is already stored. */ |
| 144 | force?: boolean; |
| 145 | /** Quiet: skip banners (for subcommands that already printed one). */ |
| 146 | quiet?: boolean; |
| 147 | origins?: Origins; |
| 148 | } |
| 149 | |
| 150 | /** |
| 151 | * Ensure this machine has a platform user session (OAuth token). |
| 152 | * Opens the browser when needed. Returns the stored user credential. |
| 153 | */ |
| 154 | export async function ensurePlatformSession( |
| 155 | opts: EnsureSessionOptions = {}, |
| 156 | ): Promise<UserCredential> { |
| 157 | const origins = opts.origins ?? resolveOrigins(); |
| 158 | const existing = await readUserCredential(); |
| 159 | |
| 160 | if (existing && !opts.force) { |
| 161 | try { |
| 162 | await fetchMe(existing.apiOrigin, existing.token); |
| 163 | return existing; |
| 164 | } catch (err) { |
| 165 | if (err instanceof ApiCallError && (err.status === 401 || err.status === 403)) { |
| 166 | if (!opts.quiet) { |
| 167 | step('saved session expired — re-authorizing…'); |
| 168 | } |
| 169 | } else { |
| 170 | throw err; |
| 171 | } |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | if (!opts.quiet) { |
| 176 | banner('connect'); |
| 177 | blankLine(); |
| 178 | step('opening browser to authorize the cli…'); |
| 179 | } |
| 180 | |
| 181 | const { token, apiOrigin } = await runOAuth({ |
| 182 | apiOrigin: origins.apiOrigin, |
| 183 | dashboardOrigin: origins.dashboardOrigin, |
| 184 | }); |
| 185 | |
| 186 | const me = await fetchMe(apiOrigin, token); |
| 187 | const user: UserCredential = { |
| 188 | token, |
| 189 | userId: me.id, |
| 190 | apiOrigin, |
| 191 | savedAt: new Date().toISOString(), |
| 192 | }; |
| 193 | await writeUserCredential(user); |
| 194 | if (!opts.quiet) { |
| 195 | success(`signed in as ${me.email}`); |
| 196 | blankLine(); |
| 197 | } |
| 198 | return user; |
| 199 | } |
| 200 | |
| 201 | /** Pretty-print a remote project list (for CLI stdout). */ |
| 202 | export function printRemoteProjects(projects: RemoteProject[]): void { |
| 203 | if (projects.length === 0) { |
| 204 | step('no projects on your account yet.'); |
| 205 | step('run: briven projects create --name my-app'); |
| 206 | return; |
| 207 | } |
| 208 | step(`${projects.length} project${projects.length === 1 ? '' : 's'} on your account:`); |
| 209 | for (const p of projects) { |
| 210 | const org = p.orgName ?? '—'; |
| 211 | const label = p.name && p.name !== p.slug ? `${p.slug} (${p.name})` : p.slug; |
| 212 | step(` ${p.id} · ${org}/${label} · ${p.region} · ${p.tier}`); |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | export function printSessionExpiredHint(): void { |
| 217 | printError('platform session missing or expired.'); |
| 218 | step('run: briven connect'); |
| 219 | } |