-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.ts
More file actions
2091 lines (1952 loc) · 82.2 KB
/
index.ts
File metadata and controls
2091 lines (1952 loc) · 82.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Real API surface — talks to api.instanode.dev (via Vite proxy in dev,
// same-origin in prod).
//
// §10.21 complete (2026-05-12): every FIXTURE_* fallback that previously
// masked backend outages is gone. Surfaces with no live endpoint return
// honest empty/null results; surfaces with a partial backend (billing 503,
// invoices 503) now propagate errors so the consuming page renders a
// real error banner instead of lying with mock data.
import type {
Resource, DashboardStack, StackStatus,
DashboardDeployment, DeploymentStatus,
DashboardTeam, BillingDetails, Invoice,
TeamMember, TeamInvitation, AuthMeResponse, VaultEntry, ActivityItem,
AdminCustomerListResponse, AdminCustomerDetailResponse,
AdminIssuePromoInput, AdminIssuePromoResponse,
AdminSetTierInput, AdminSetTierResponse,
} from './types'
export * from './types'
// ─── API base URL resolution ─────────────────────────────────────────────
// Resolves the origin used for every fetch in this module.
// - Dev (Vite dev server): '' so the Vite proxy in vite.config.ts handles
// /api, /auth, /claim, ... — keeps requests same-origin and avoids
// CORS preflights during local development.
// - Prod (GitHub Pages at https://instanode.dev): full origin
// 'https://api.instanode.dev' so the browser issues cross-origin
// requests directly to the API on DOKS.
// - Tests can pin the origin via window.__INSTANODE_API_URL__.
// - CI builds can override via VITE_API_URL at build time (this beats
// both of the above).
export function getAPIBaseURL(): string {
// Override for tests / debugging
if (typeof window !== 'undefined') {
const o = (window as any).__INSTANODE_API_URL__ as string | undefined
if (o) return o
}
// Build-time env (set in CI)
const env = (import.meta as any).env?.VITE_API_URL
if (env !== undefined && env !== null) return env
// Default differs by mode:
// - dev: '' so Vite's proxy handles it (no CORS preflight needed)
// - prod: full URL — cross-origin direct
if ((import.meta as any).env?.DEV) return ''
return 'https://api.instanode.dev'
}
// ─── Auth + token storage ────────────────────────────────────────────────
const TOKEN_KEY = 'instanode.token'
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(t: string): void {
localStorage.setItem(TOKEN_KEY, t)
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY)
}
// ─── Low-level fetch ─────────────────────────────────────────────────────
export class APIError extends Error {
status: number
code: string
constructor(status: number, code: string, message: string) {
super(message)
this.status = status
this.code = code
}
}
// Paths where a 401 SHOULD auto-redirect to /login. Only the gated `/app/*`
// subtree warrants kicking the user out — every other route (marketing `/`,
// `/pricing`, `/docs`, `/blog`, `/use-cases`, `/status`, `/incidents`,
// `/login`, `/claim`, …) must render even when the api rejects a stray
// call. The previous SKIP-list approach was inverted: any public page that
// happened to fire a 401-producing api call (stale NR ping, deferred
// fetch, a re-mounted hook from cached navigation) would bounce the
// visitor to /login. That was the root cause of "homepage automatically
// redirects to /login" — fixed by gating the redirect to the path we
// actually want to protect.
const AUTH_REDIRECT_REQUIRED_PREFIXES = ['/app']
const RETURN_TO_KEY = 'instanode.return_to'
/**
* Central fetch wrapper. On a 401 response from the API the helper:
* 1. clears the stored token,
* 2. saves the current pathname+search under `instanode.return_to` so the
* user can be sent back after re-login,
* 3. redirects to `/login` via `window.location.replace` ONLY if the
* caller is already inside the gated `/app/*` subtree,
* 4. still throws the `APIError` so callers see a rejected promise.
*
* The redirect is intentionally limited to `/app/*`. On any public page
* (marketing, pricing, docs, login, claim) we clear the token and surface
* the error to the caller without navigation — the page stays renderable
* for an anonymous visitor. The function is also a no-op outside a
* browser environment (SSR / unit tests).
*/
async function call<T>(
path: string,
init: RequestInit = {},
): Promise<T> {
const headers = new Headers(init.headers ?? {})
if (!headers.has('Content-Type') && init.body) headers.set('Content-Type', 'application/json')
const tok = getToken()
if (tok) headers.set('Authorization', `Bearer ${tok}`)
// Resolve to an absolute URL so cross-origin builds (GitHub Pages → DOKS)
// hit the API directly. When base is '' (dev — Vite proxy) we still need
// an absolute origin for the URL constructor; fall back to the current
// window origin (or a localhost stub for non-browser tests).
const base = getAPIBaseURL()
const url = new URL(
path,
base || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost'),
)
// Bearer auth is in the Authorization header — no cookies in flight, so
// we deliberately do NOT set credentials: 'include' (avoids unnecessary
// CORS preflights and keeps the API allowlist simple).
const res = await fetch(url.toString(), { ...init, headers })
const ct = res.headers.get('content-type') ?? ''
const body: any = ct.includes('application/json') ? await res.json().catch(() => null) : await res.text()
if (!res.ok) {
if (res.status === 401) {
let inGatedAppArea = false
try {
const p = location.pathname
inGatedAppArea = AUTH_REDIRECT_REQUIRED_PREFIXES.some(
(prefix) => p === prefix || p.startsWith(prefix + '/'),
)
} catch {
/* non-browser env (jsdom-less tests) — treat as not in /app */
}
// Always clear the stale token so subsequent calls don't re-attach
// it. Only navigate when we're already inside the gated area.
clearToken()
if (inGatedAppArea) {
try {
localStorage.setItem(RETURN_TO_KEY, location.pathname + location.search)
} catch {
/* localStorage / location unavailable — best-effort only */
}
if (typeof window !== 'undefined') {
window.location.replace('/login')
}
}
}
const code = (body && body.error) || `http_${res.status}`
const msg = (body && (body.message || body.error_description)) || res.statusText
throw new APIError(res.status, code, msg)
}
return body as T
}
// ─── Auth / me ───────────────────────────────────────────────────────────
// GET /auth/me on the agent API returns:
// { ok, user_id, team_id, email, tier, experiments }
// The dashboard expected { user, team } — we adapt here so the rest of
// the dashboard still consumes the richer fixture shape.
//
// Historical note: this response used to include a `trial_ends_at` field;
// removed on 2026-05-14 per policy memory project_no_trial_pay_day_one.md.
// The platform has no trial period — hobby/pro/team are paid from day one.
export async function fetchMe(): Promise<AuthMeResponse> {
type AgentMe = {
ok: boolean
user_id: string
team_id: string
email: string
tier: string
/** A/B-test bucket per registered experiment, e.g.
* `{ upgrade_button: "urgent" }`. Older API builds omit this
* field entirely — callers must treat undefined as "no
* experiment, render control variant". */
experiments?: Record<string, string>
/** Track A — server-authoritative platform-admin flag. The
* dashboard surfaces the `/app/admin/customers` console only when
* this is `true`. Absent on older API builds → treat as `false`. */
is_platform_admin?: boolean
/** Unguessable URL prefix for the admin customer-management surface.
* Sent by the API only when (a) the caller is on the ADMIN_EMAILS
* allowlist AND (b) the deploy has ADMIN_PATH_PREFIX configured.
* Absent for every other caller / configuration. Treat as "no admin
* surface available" when undefined or empty. */
admin_path_prefix?: string
}
// No try/catch — errors propagate. The previous fixture fallback masked
// backend outages by serving the `aanya@acme.dev` mock identity, which
// led to chrome lying ("acme-corp", "aanya@acme.dev") instead of
// surfacing the failure. (§10.21.1.) Callers handle errors:
// - 401 → AuthGate redirects to /login
// - other → useDashboardCtx records meErr; chrome shows a fallback
// `workspace` placeholder and the page can render a banner.
const me = await call<AgentMe>('/auth/me')
// Derive a stable team slug from the email's local part — the only
// human-readable identity we have until a real team table exposes a slug.
const localPart = me.email?.split('@')[0] ?? ''
const slug = localPart.toLowerCase().replace(/[^a-z0-9-]/g, '-') || me.team_id.slice(0, 8)
// Stash the admin path prefix in a module-local var so the admin URL
// builders below can mint `/api/v1/${prefix}/customers/...` requests
// without forcing every caller to plumb it through manually. The prefix
// is a secret — see setAdminPathPrefix() — never log, never echo to UI.
setAdminPathPrefix(me.admin_path_prefix ?? '')
return {
user: {
id: me.user_id,
email: me.email,
team_id: me.team_id,
tier: me.tier as any,
created_at: '',
},
team: {
id: me.team_id,
name: localPart || 'workspace',
slug,
owner_id: me.user_id,
member_count: 1,
tier: me.tier as any,
created_at: '',
},
experiments: me.experiments,
is_platform_admin: me.is_platform_admin === true,
admin_path_prefix: me.admin_path_prefix,
}
}
// ─── A/B-experiment conversion ───────────────────────────────────────────
// reportExperimentConverted — fires POST /api/v1/experiments/converted to
// record that the user took the conversion action on a server-bucketed
// experiment (e.g. clicked the Upgrade button). Best-effort:
// - swallows every error (network down, 400 from a stale variant, etc.)
// - never blocks navigation; callers race it against a short timeout
// and proceed regardless.
//
// The matching server-side endpoint writes an audit_log row with
// kind="experiment.conversion" and metadata={experiment, variant,
// action_taken}. See api/internal/handlers/experiments.go.
export async function reportExperimentConverted(input: {
experiment: string
variant: string
action: string
}): Promise<void> {
try {
await call<{ ok: boolean }>('/api/v1/experiments/converted', {
method: 'POST',
body: JSON.stringify(input),
})
} catch {
/* analytics tail must not wag the conversion dog */
}
}
// ─── Status — public, real backend (W11) ──────────────────────────────────
// GET /api/v1/status returns the worker-aggregated uptime feed. Replaces
// the previous client-side probe loop on /status (which had the fatal
// "instanode edge down → probe also down" failure mode caught by P3).
//
// Public — no auth. Errors fall through to an empty payload so the page
// renders a skeleton instead of a crash.
export interface StatusComponent {
slug: string
name: string
category: string
description?: string
current_status: 'operational' | 'degraded' | 'down'
uptime_7d_pct: number
uptime_30d_pct: number
/** 96 booleans, one per 15-minute slot, oldest → newest. */
last_24h_samples: boolean[]
}
export interface StatusIncident {
id: string
title: string
severity: string
status: string
started_at: string
resolved_at?: string
summary?: string
url?: string
}
export interface StatusPayload {
ok: boolean
freshness_seconds: number
as_of: string
components: StatusComponent[]
current_incidents: StatusIncident[]
}
/**
* fetchStatus — public GET /api/v1/status. Best-effort: on any failure
* returns an honest empty payload (ok=false, components=[]) so the page
* can render a degraded-but-functional skeleton instead of a 500 or a
* console error. The page logic distinguishes ok=false from
* components=[] (the latter is also a valid "fresh install, no probes
* yet" state).
*/
export async function fetchStatus(): Promise<StatusPayload> {
const base = getAPIBaseURL()
const origin =
base || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost')
let url: string
try {
url = new URL('/api/v1/status', origin).toString()
} catch {
return emptyStatus()
}
try {
const res = await fetch(url, { method: 'GET' })
if (!res.ok) return emptyStatus()
const body = (await res.json().catch(() => null)) as StatusPayload | null
if (!body || !Array.isArray(body.components)) return emptyStatus()
// Defensive: server should always send current_incidents, but coerce
// missing/null to [] so the consumer can safely .map().
if (!Array.isArray(body.current_incidents)) {
body.current_incidents = []
}
return body
} catch {
return emptyStatus()
}
}
function emptyStatus(): StatusPayload {
return {
ok: false,
freshness_seconds: 60,
as_of: new Date().toISOString(),
components: [],
current_incidents: [],
}
}
export async function logout(): Promise<{ ok: true }> {
clearToken()
// Drop the admin URL prefix on logout. A stale prefix in module-local
// state would survive across a re-login by a different user (admin →
// non-admin same tab), and the non-admin's first /auth/me would race
// with their first admin-page render. Belt-and-braces: also clears it
// in tests that mock fetchMe but exercise logout afterwards.
setAdminPathPrefix('')
return { ok: true }
}
// ─── Team — honest empty/error states (no fixtures, §10.21.1) ────────────
export async function fetchTeam(): Promise<{ ok: true; team: DashboardTeam }> {
// GET /api/v1/team isn't implemented yet — derive from /auth/me.
const me = await fetchMe()
return { ok: true, team: me.team }
}
export async function updateTeam(_patch: { name?: string; display_name?: string }): Promise<{ ok: true; team: DashboardTeam }> {
// PATCH /api/v1/team isn't implemented. Return current team unchanged
// and let the caller surface "this isn't editable yet" to the user.
const me = await fetchMe()
return { ok: true, team: me.team }
}
export async function listMembers(): Promise<{ ok: true; members: TeamMember[]; member_limit: number }> {
// LIVE — `GET /api/v1/team/members`. On failure (other than 401),
// fall back to a single-owner row derived from /auth/me — that's
// honest minimum data the user is guaranteed to own.
type Resp = { ok: boolean; members: any[]; member_limit: number }
try {
const r = await call<Resp>('/api/v1/team/members')
const members: TeamMember[] = (r.members ?? []).map((m) => ({
user_id: m.user_id,
email: m.email,
role: m.role,
joined_at: m.joined_at,
created_at: m.joined_at,
display_name: m.display_name ?? m.email,
id: m.user_id,
}) as unknown as TeamMember)
return { ok: true, members, member_limit: r.member_limit ?? -1 }
} catch (e: any) {
if (e?.status === 401) throw e
// Honest fallback: a single owner row built from /auth/me. Not a fixture.
const me = await fetchMe()
return {
ok: true,
members: [{
id: me.user.id,
user_id: me.user.id,
email: me.user.email,
role: 'owner',
joined_at: me.user.created_at,
created_at: me.user.created_at,
display_name: me.user.email,
} as unknown as TeamMember],
member_limit: -1,
}
}
}
export async function listInvitations(): Promise<{ ok: true; invitations: TeamInvitation[] }> {
// GET /api/v1/teams/:id/invitations exists on the agent API but the
// dashboard adapter isn't wired yet. Return empty until then — better
// than fabricating pending invites that don't exist.
return { ok: true, invitations: [] }
}
export async function inviteMember(_body: { email: string; role: string }): Promise<{ ok: true }> {
// The team-invite flow is agent-driven in this product (see TeamPage
// PromptCard). The dashboard never POSTs invitations directly. Return
// ok so any legacy callers don't break; the actual invite is sent by
// the agent running the user's prompt.
return { ok: true }
}
// ─── Resources (LIVE) ───────────────────────────────────────────────────
type ResourceListResp = { ok: boolean; items: any[]; total: number }
type ResourceGetResp = { ok: boolean; item: any }
function adaptResource(r: any): Resource {
return {
id: r.id,
token: r.token,
resource_type: r.resource_type,
tier: r.tier,
status: r.status,
name: r.name ?? null,
env: r.env ?? 'production',
storage_bytes: r.storage_bytes ?? 0,
storage_limit_bytes: r.storage_limit_bytes ?? 0,
storage_exceeded: r.storage_exceeded ?? false,
connections_in_use: r.connections_in_use,
connections_limit: r.connections_limit,
cloud_vendor: r.cloud_vendor,
country_code: r.country_code,
expires_at: r.expires_at ?? null,
created_at: r.created_at,
connection_url: r.connection_url,
}
}
export async function listResources(env?: string): Promise<{ ok: true; items: Resource[]; total: number }> {
// ?env= filters server-side. Omit for all envs (the legacy behavior).
const path = env ? `/api/v1/resources?env=${encodeURIComponent(env)}` : '/api/v1/resources'
const r = await call<ResourceListResp>(path)
const items = (r.items ?? []).map(adaptResource)
return { ok: true, items, total: r.total ?? items.length }
}
export async function getResource(id: string): Promise<{ ok: true; resource: Resource }> {
const r = await call<ResourceGetResp>(`/api/v1/resources/${id}`)
// The agent API splits credentials into a separate endpoint.
let connection_url: string | undefined
try {
const c = await call<{ connection_url: string }>(`/api/v1/resources/${id}/credentials`)
connection_url = c.connection_url
} catch {
/* credentials may be hidden for some resource types */
}
return { ok: true, resource: adaptResource({ ...r.item, connection_url }) }
}
export async function deleteResource(id: string): Promise<void> {
await call(`/api/v1/resources/${id}`, { method: 'DELETE' })
}
// ─── Pause / resume (Pro+ feature) ──────────────────────────────────────
//
// Pause stops the resource counting against quota while preserving every
// byte of data. Resume flips it back to 'active' so it counts again and is
// reachable. Both are idempotent on the server: pausing an already-paused
// resource returns the current row unchanged; same for resume.
//
// Tier gate: the agent API returns 402 with `agent_action` on
// anonymous/free/hobby. The callers (PauseResumeButton) trap that status
// and render the upgrade CTA inline instead of throwing. Other errors
// (5xx, network) propagate so the UI can surface a real banner.
//
// Status semantics:
// POST /api/v1/resources/:id/pause → { ok, item: <resource with status='paused'> }
// POST /api/v1/resources/:id/resume → { ok, item: <resource with status='active'> }
//
// We re-use the existing GetResp adapter so the returned Resource mirrors
// what GET /:id would have produced — callers can replace state directly
// without a second fetch.
export async function pauseResource(id: string): Promise<{ ok: true; resource: Resource }> {
const r = await call<ResourceGetResp>(`/api/v1/resources/${id}/pause`, { method: 'POST' })
return { ok: true, resource: adaptResource(r.item) }
}
export async function resumeResource(id: string): Promise<{ ok: true; resource: Resource }> {
const r = await call<ResourceGetResp>(`/api/v1/resources/${id}/resume`, { method: 'POST' })
return { ok: true, resource: adaptResource(r.item) }
}
export async function rotateResource(id: string): Promise<{ ok: true; connection_url: string; resource: Resource }> {
const r = await call<{ ok: boolean; connection_url: string }>(
`/api/v1/resources/${id}/rotate-credentials`,
{ method: 'POST' },
)
const detail = await call<ResourceGetResp>(`/api/v1/resources/${id}`)
return {
ok: true,
connection_url: r.connection_url,
resource: adaptResource({ ...detail.item, connection_url: r.connection_url }),
}
}
// ─── Resource metrics ───
//
// GET /api/v1/resources/:id/metrics — per-resource time-series metrics
// (p50/p95/p99 latency, active connections, storage_bytes, error_rate_pct)
// over a tier-capped window. The dashboard polls this every 60s on the
// Metrics tab; per CLAUDE.md feedback ("aggregations need caching +
// consistency reasoning") the freshness model here is poll-on-demand, not
// long-cached — these tiles are observability surfaces where seeing the
// latest sample matters more than minimising server load.
//
// The response's `data_source` field is "stub" until the W5-A prober's
// per-probe row writer lands. The dashboard renders a yellow "metrics will
// populate" banner only when data_source === "stub" so the layout doesn't
// shift when real data arrives.
export type MetricsDataSource = 'stub' | 'newrelic' | 'resource_metrics'
export interface ResourceMetricsResponse {
ok: true
resource_id: string
resource_type: string
window_seconds: number
samples_count: number
sample_interval_seconds: number
metrics: {
latency_p50_ms: number[]
latency_p95_ms: number[]
latency_p99_ms: number[]
connections_active: number[]
storage_bytes: number[]
error_rate_pct: number[]
}
data_source: MetricsDataSource
}
export async function getResourceMetrics(
id: string,
windowParam: string = '1h',
): Promise<ResourceMetricsResponse> {
const r = await call<ResourceMetricsResponse>(
`/api/v1/resources/${id}/metrics?window=${encodeURIComponent(windowParam)}`,
)
return r
}
// ─── Resource audit ─────────────────────────────────────────────────────
//
// W7-C/W11: the team-level audit log (GET /api/v1/audit) returns rows
// scoped to either `team_id = caller_team` OR `metadata.resource_id`
// pointing at a resource the caller owns. There is no per-resource
// endpoint yet — instead we fetch the team window and filter client-side
// for rows whose metadata.resource_id matches the resource we're looking
// at. The endpoint enforces a tier-derived hard lookback floor (Hobby
// 30d, Pro 90d, Team unlimited; anonymous/free 402s), so this surface
// renders an "upgrade required" state on 402 rather than throwing.
//
// Wire shape (from auditEventToMap):
// { id, kind, created_at, metadata, actor_user_id, actor_email_masked }
//
// `metadata` is unmarshalled JSON or null. We surface metadata.resource_id
// and metadata.summary when present; everything else lands in the raw
// metadata JSON column on the table for transparency.
export interface ResourceAuditEvent {
id: string
kind: string
created_at: string
actor_user_id: string | null
actor_email_masked: string | null
metadata: Record<string, unknown> | null
}
export interface ResourceAuditResponse {
ok: true
items: ResourceAuditEvent[]
total_returned: number
next_cursor: string | null
lookback_days: number
tier: string
}
/**
* Fetch audit rows scoped to a single resource over the last `sinceHours`
* hours. Implementation: call GET /api/v1/audit?since=<iso>&limit=200 and
* filter client-side for rows whose metadata.resource_id matches. The
* team-level endpoint already enforces ownership (rows are returned only
* if `team_id = caller_team` OR the metadata.resource_id points at a
* resource the caller owns), so the client-side filter is a precision
* cut, not a security boundary.
*
* On 402 (anonymous/free tier) the call propagates so the caller can
* render an upgrade prompt instead of an error banner.
*/
export async function fetchResourceAudit(
resourceId: string,
sinceHours: number = 24,
limit: number = 200,
): Promise<ResourceAuditResponse> {
const sinceIso = new Date(Date.now() - sinceHours * 60 * 60 * 1000).toISOString()
const path = `/api/v1/audit?since=${encodeURIComponent(sinceIso)}&limit=${limit}`
const r = await call<ResourceAuditResponse>(path)
const items = (r.items ?? []).filter((ev) => {
if (!ev.metadata || typeof ev.metadata !== 'object') return false
const ridRaw = (ev.metadata as Record<string, unknown>).resource_id
if (typeof ridRaw !== 'string') return false
return ridRaw === resourceId
})
return {
ok: true,
items,
total_returned: items.length,
next_cursor: r.next_cursor ?? null,
lookback_days: r.lookback_days ?? 0,
tier: r.tier ?? '',
}
}
// ─── Stacks / deployments ───
// GET /api/v1/stacks returns one row per stack including the real env
// (production / staging / dev / ...) and parent_stack_id linkage. We adapt
// the shape into DashboardStack and leave still-missing fields (url,
// last_deploy_at, build_duration_s) undefined — the UI handles missing
// fields gracefully. Until POST /deploy/new ships in Phase 1, expect this
// to return an empty list for most teams.
type StacksListResp = {
ok: boolean
items?: Array<{
stack_id?: string
name?: string
status?: string
tier?: string
namespace?: string
env?: string
parent_stack_id?: string
created_at?: string
}>
total?: number
}
export async function listStacks(): Promise<{ ok: true; items: DashboardStack[]; total: number }> {
try {
const r = await call<StacksListResp>('/api/v1/stacks')
const items: DashboardStack[] = (r.items ?? []).map((s) => ({
id: s.stack_id ?? '',
slug: s.stack_id ?? '',
name: s.name ?? '',
status: (s.status as DashboardStack['status']) ?? 'building',
url: null,
created_at: s.created_at ?? '',
team_id: '',
// env defaults to 'production' for legacy stacks pre-dating migration
// 015. The API never returns null for env (the column has NOT NULL
// DEFAULT 'production'), so the ?? branch is only exercised when the
// backend predates env-aware deployments entirely.
env: (s.env as DashboardStack['env']) ?? 'production',
tier: (s.tier as DashboardStack['tier']) ?? 'free',
}))
return { ok: true, items, total: r.total ?? items.length }
} catch {
// Auth missing, endpoint unavailable, or other transient — show honest
// empty state rather than fixture data.
return { ok: true, items: [], total: 0 }
}
}
// ─── Deployments (LIVE — POST /deploy/new single-container apps) ────────
//
// `listDeployments()` hits GET /api/v1/deployments on the agent API and
// returns the typed dashboard shape. The server response keys collide
// with DashboardStack vocabulary in one place: it returns `env` for the
// env-vars map and `environment` for the scope name. We swap them here
// so the rest of the dashboard can treat env (scope) and env_vars (map)
// the same way it does for stacks.
//
// Status mapping: the server emits 'healthy' for a live deploy, which the
// dashboard's shared StatusPill renders as 'running' (matching stacks).
// We normalise here so consumer code doesn't need to special-case it.
type DeploymentRespItem = {
id?: string
token?: string
app_id?: string
url?: string
port?: number
tier?: string
status?: string
// Server returns env as a map of env_vars (legacy alias). New callers
// should also accept env_vars for forward compat with the spec.
env?: Record<string, string> | string
env_vars?: Record<string, string>
// Env scope (production / staging / dev / ...).
environment?: string
created_at?: string
updated_at?: string
last_deploy_at?: string
build_duration_s?: number
resource_id?: string
name?: string
// Track B (private deploys): server flags + IP allow-list. Older API
// builds omit these fields entirely — the adapter treats them as
// `private=false` and `allowed_ips=[]` so the dashboard never lies about
// privacy state when the backend hasn't shipped yet.
private?: boolean
allowed_ips?: string[]
// Wave FIX-J TTL fields (migration 045 + handlers/deploy_ttl.go).
ttl_policy?: string
expires_at?: string
reminders_sent?: number
make_permanent_url?: string
extend_ttl_url?: string
}
type DeploymentsListResp = {
ok: boolean
items?: DeploymentRespItem[]
total?: number
}
type DeploymentGetResp = {
ok: boolean
item?: DeploymentRespItem
}
function normaliseDeploymentStatus(s: string | undefined): DeploymentStatus {
switch (s) {
case 'healthy':
return 'running' // dashboard's StatusPill speaks 'running'
case 'building':
case 'deploying':
case 'failed':
case 'stopped':
case 'running':
return s
default:
return 'building'
}
}
function adaptDeployment(d: DeploymentRespItem): DashboardDeployment {
// The server's `env` field is the env_vars map (legacy alias); the env
// scope name lives under `environment`. New callers may also send a
// dedicated `env_vars` field — accept either.
const envVarsRaw =
d.env_vars ??
(typeof d.env === 'object' && d.env !== null ? d.env : undefined)
const envScope = d.environment ?? (typeof d.env === 'string' ? d.env : undefined)
const id = d.id ?? d.app_id ?? d.token ?? ''
const appID = d.app_id ?? d.token ?? id
return {
id,
app_id: appID,
// Surface the server's display name verbatim, or `null` when absent.
// The UI (DeploymentsPage / DeployDetailPage) renders `(unnamed deploy)`
// for a null name and keeps app_id as muted secondary text — we no
// longer promote the hash into the primary `name` slot.
name: d.name?.trim() ? d.name : null,
url: d.url ?? null,
status: normaliseDeploymentStatus(d.status),
env: (envScope ?? 'production') as DashboardDeployment['env'],
port: d.port ?? 0,
tier: (d.tier ?? 'free') as DashboardDeployment['tier'],
env_vars: envVarsRaw ?? {},
created_at: d.created_at ?? '',
last_deploy_at: d.last_deploy_at ?? d.updated_at,
build_duration_s: d.build_duration_s,
resource_id: d.resource_id,
// Private-deploy fields (Track B). Older API builds omit them; we
// surface false / [] so the UI never silently inherits "private"
// state from a stale payload.
private: d.private ?? false,
allowed_ips: d.allowed_ips ?? [],
// Wave FIX-J TTL fields. The server returns them on every deploy
// payload; older builds omit them and the dashboard renders as
// ttl_policy='auto_24h' (the documented default) so the UI never
// shows a stale "no TTL" state.
ttl_policy: d.ttl_policy as DashboardDeployment['ttl_policy'],
expires_at: d.expires_at,
reminders_sent: typeof d.reminders_sent === 'number' ? d.reminders_sent : undefined,
make_permanent_url: d.make_permanent_url,
extend_ttl_url: d.extend_ttl_url,
}
}
export async function listDeployments(env?: string): Promise<{ ok: true; items: DashboardDeployment[]; total: number }> {
// No try/catch fallback to empty — errors propagate so DeploymentsPage
// can render a real error state instead of silently lying. The list
// endpoint requires auth; 401 still triggers the AuthGate redirect.
// ?env= filters server-side; omitting returns all envs (legacy behavior).
const path = env ? `/api/v1/deployments?env=${encodeURIComponent(env)}` : '/api/v1/deployments'
const r = await call<DeploymentsListResp>(path)
const items = (r.items ?? []).map(adaptDeployment)
return { ok: true, items, total: r.total ?? items.length }
}
/**
* Fetch a single deployment by ID. Returns `null` when the API returns
* 404 so the caller (DeployDetailPage) can fall back to the stack lookup
* without a noisy console error. Other errors still propagate.
*/
export async function getDeployment(
id: string,
): Promise<{ ok: true; deployment: DashboardDeployment | null }> {
try {
const r = await call<DeploymentGetResp>(`/api/v1/deployments/${encodeURIComponent(id)}`)
if (!r.item) return { ok: true, deployment: null }
return { ok: true, deployment: adaptDeployment(r.item) }
} catch (e: any) {
if (e?.status === 404) return { ok: true, deployment: null }
throw e
}
}
// ─── createDeploy — POST /deploy/new (Track B private-deploy fields) ─────
//
// The dashboard doesn't usually drive deploys itself (the read-only model
// favours agent-driven mutations via PromptCard), but the createDeploy
// helper exists so:
// 1. The "Configure private access" panel on DeploymentsPage can build a
// precise agent prompt that mirrors the request shape the helper
// would send (single source of truth for the field names).
// 2. Future agentic flows in the dashboard (e.g. one-click redeploy with
// a privacy patch) can call this without re-implementing the contract.
//
// Body shape — accepted by the Track A backend:
// - tarball is uploaded multipart; the dashboard doesn't have file-upload
// UI yet, so this helper takes the metadata and trusts the caller to
// attach a tarball via FormData if needed (omit for prompts-only flow).
// - env_vars is sent as `env` (server's legacy alias) for symmetry with
// the response adapter above.
// - `private` (bool) + `allowed_ips` (string[]) are the Track B fields
// this Track B PR introduces. Backend returns 402 with agent_action on
// hobby/free/anonymous, 400 with `validation_error` on empty
// allowed_ips when private=true, or invalid IPs/CIDRs.
//
// Errors propagate (APIError with status + code); the caller decides
// whether to surface them inline or fall back to the prompt-only path.
export interface CreateDeployInput {
name?: string
port?: number
env?: string // Env scope name (production / staging / dev).
env_vars?: Record<string, string>
resource_id?: string
/** Track B: gate the deploy by an IP allow-list. Requires Pro+. */
private?: boolean
/** Track B: IPv4 addresses or CIDR blocks (max 32) permitted when
* `private` is true. Backend returns 400 on empty list when private=true
* or on invalid IP/CIDR strings. */
allowed_ips?: string[]
}
export async function createDeploy(
input: CreateDeployInput,
): Promise<{ ok: true; deployment: DashboardDeployment }> {
// Wire shape matches the Track A `POST /deploy/new` contract: env_vars
// is sent as `env` (legacy alias) for symmetry with the list-response
// adapter above. The dedicated `environment` field carries the env
// scope. `private` + `allowed_ips` ride alongside as top-level fields.
const body: Record<string, unknown> = {}
if (input.name) body.name = input.name
if (input.port !== undefined) body.port = input.port
if (input.env) body.environment = input.env
if (input.env_vars) body.env = input.env_vars
if (input.resource_id) body.resource_id = input.resource_id
if (input.private !== undefined) body.private = input.private
if (input.allowed_ips !== undefined) body.allowed_ips = input.allowed_ips
const r = await call<DeploymentGetResp>('/deploy/new', {
method: 'POST',
body: JSON.stringify(body),
})
if (!r.item) {
throw new APIError(500, 'invalid_response', 'POST /deploy/new returned no item')
}
return { ok: true, deployment: adaptDeployment(r.item) }
}
// ─── updateDeploymentAccess — PATCH /api/v1/deployments/:id (Track A) ────
//
// Pro+ feature: toggle a deployment's privacy state and edit its
// allowed_ips after creation. The PATCH endpoint is Track A's
// responsibility — until it ships, this helper still issues the request
// and surfaces a 404 to the caller so the DeployDetailPage can render a
// read-only "edits pending backend" hint instead of pretending the change
// landed.
//
// Errors:
// - 402 with agent_action — tier gate (hobby / free / anonymous)
// - 400 with validation_error — empty allowed_ips when private=true,
// invalid IPs/CIDRs, > 32 entries
// - 404 — endpoint not yet shipped (Track A pending)
// - 5xx — server error; bubble up so the page shows a real banner
export async function updateDeploymentAccess(
id: string,
privateFlag: boolean,
allowedIps: string[],
): Promise<{ ok: true; deployment: DashboardDeployment }> {
const r = await call<DeploymentGetResp>(
`/api/v1/deployments/${encodeURIComponent(id)}`,
{
method: 'PATCH',
body: JSON.stringify({ private: privateFlag, allowed_ips: allowedIps }),
},
)
if (!r.item) {
throw new APIError(500, 'invalid_response', 'PATCH /deployments/:id returned no item')
}
return { ok: true, deployment: adaptDeployment(r.item) }
}
// ─── Deletion confirmation (Wave FIX-I) ──────────────────────────────────
//
// Two-step email-confirmed deletion. The api returns 202 with
// deletion_status='pending_confirmation' on the first DELETE for any
// paid-tier deploy or stack; this module wraps the follow-up endpoints.
//
// All three helpers share the same envelope shape — surfaced via the
// DeletionConfirmResponse type so the page can render a consistent
// success/failure banner.
export type DeletionPendingResponse = {
ok: true
id: string
deletion_status: 'pending_confirmation'
confirmation_sent_to: string
confirmation_expires_at: string
agent_action: string
cancellation_note: string
}
export type DeletionResolvedResponse = {
ok: true
id: string
resource_type: 'deploy' | 'stack'
deletion_status: 'confirmed' | 'cancelled'
freed_at?: string
agent_action: string
note: string
}
/** Issue the initial DELETE — may return either an immediate 200 (free
* / anonymous / header-bypass) or a 202 with the pending envelope.
* The caller branches on `deletion_status`. */
export async function deleteDeployment(
id: string,
opts?: { skipEmailConfirmation?: boolean },
): Promise<DeletionPendingResponse | { ok: true; message: string }> {
const headers: Record<string, string> = {}
if (opts?.skipEmailConfirmation) {
headers['X-Skip-Email-Confirmation'] = 'yes'
}
return call(`/api/v1/deployments/${encodeURIComponent(id)}`, {
method: 'DELETE',
headers,
})
}
/** Step 2 — confirm the pending deletion with the plaintext token
* carried in the email link. Returns 200 on the winning CAS; 410 on
* expired/already-used (surfaces as APIError with code
* 'deletion_token_invalid'). */
export async function confirmDeploymentDeletion(
id: string,
token: string,
): Promise<DeletionResolvedResponse> {
return call(
`/api/v1/deployments/${encodeURIComponent(id)}/confirm-deletion?token=${encodeURIComponent(token)}`,
{ method: 'POST' },
)
}
/** Step 2 (alternate) — cancel a pending deletion. The resource stays
* active and the slot stays consumed. Idempotent: a cancel on an
* already-resolved row returns 410. */
export async function cancelDeploymentDeletion(
id: string,
): Promise<DeletionResolvedResponse> {
return call(
`/api/v1/deployments/${encodeURIComponent(id)}/confirm-deletion`,
{ method: 'DELETE' },
)
}
// Stack-side counterparts. Same contract, different path prefix.
export async function deleteStack(
slug: string,
opts?: { skipEmailConfirmation?: boolean },
): Promise<DeletionPendingResponse | { ok: true; message: string }> {
const headers: Record<string, string> = {}
if (opts?.skipEmailConfirmation) {
headers['X-Skip-Email-Confirmation'] = 'yes'
}