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
7import { apiCall, ApiCallError } from './api-client.js';
8import {
9 readUserCredential,
10 writeProjectCredential,
11 writeUserCredential,
12 type UserCredential,
13} from './config.js';
14import { runOAuth } from './oauth.js';
15import { resolveOrigins, type Origins } from './origins.js';
16import { banner, blankLine, error as printError, step, success } from './output.js';
17
18export interface MeUser {
19 id: string;
20 email: string;
21}
22
23export 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. */
33export 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
50export 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
58export 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). */
70export interface ProjectStorageBootstrap {
71 endpoint: string;
72 bucket: string;
73 accessKey: string;
74 secretKey: string;
75}
76
77export 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 */
117export 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
142export 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 */
154export 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). */
202export 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
216export function printSessionExpiredHint(): void {
217 printError('platform session missing or expired.');
218 step('run: briven connect');
219}