@azx-pbc/helix-cli 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +186 -7
- package/dist/helix.js +1553 -0
- package/package.json +32 -6
package/dist/helix.js
ADDED
|
@@ -0,0 +1,1553 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/args.ts
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
var options = {
|
|
6
|
+
slug: { type: "string" },
|
|
7
|
+
"portal-url": { type: "string" },
|
|
8
|
+
dir: { type: "string" },
|
|
9
|
+
bundle: { type: "string" },
|
|
10
|
+
token: { type: "string" },
|
|
11
|
+
promote: { type: "boolean" },
|
|
12
|
+
"display-name": { type: "string" },
|
|
13
|
+
visibility: { type: "string" },
|
|
14
|
+
help: { type: "boolean" }
|
|
15
|
+
};
|
|
16
|
+
function parseCliArgs(argv) {
|
|
17
|
+
const args = argv[0] === "--" ? argv.slice(1) : argv;
|
|
18
|
+
return parseArgs({ args, allowPositionals: true, options });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// src/client.ts
|
|
22
|
+
import { z as z19 } from "zod";
|
|
23
|
+
|
|
24
|
+
// ../shared/src/visibility.ts
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
var VisibilitySchema = z.discriminatedUnion("mode", [
|
|
27
|
+
z.object({ mode: z.literal("internal") }),
|
|
28
|
+
z.object({ mode: z.literal("group"), groupId: z.string().min(1) }),
|
|
29
|
+
z.object({ mode: z.literal("password") }),
|
|
30
|
+
z.object({ mode: z.literal("public") })
|
|
31
|
+
]);
|
|
32
|
+
var VISIBILITY_MODES = ["internal", "group", "password", "public"];
|
|
33
|
+
var VisibilityModeSchema = z.enum(VISIBILITY_MODES);
|
|
34
|
+
|
|
35
|
+
// ../shared/src/app.ts
|
|
36
|
+
import { z as z2 } from "zod";
|
|
37
|
+
var SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
38
|
+
var AppSchema = z2.object({
|
|
39
|
+
id: z2.uuid(),
|
|
40
|
+
slug: z2.string().min(1).max(63).regex(SLUG_PATTERN, "must be a lowercase DNS label (a-z, 0-9, hyphen)"),
|
|
41
|
+
displayName: z2.string().min(1).max(200),
|
|
42
|
+
visibility: VisibilitySchema,
|
|
43
|
+
/** The version currently served; null before the first deploy. */
|
|
44
|
+
currentVersionId: z2.uuid().nullable(),
|
|
45
|
+
/** When set, the app is archived: the edge serves 410 + Clear-Site-Data (§7). */
|
|
46
|
+
archivedAt: z2.iso.datetime().nullable(),
|
|
47
|
+
createdAt: z2.iso.datetime(),
|
|
48
|
+
updatedAt: z2.iso.datetime(),
|
|
49
|
+
/**
|
|
50
|
+
* Where this app is served, computed control-plane-side from the deployment's
|
|
51
|
+
* apps base (`APP_PUBLIC_BASE`) — so clients render a URL instead of
|
|
52
|
+
* templating `<slug>.<domain>` themselves and drifting per deployment. Optional
|
|
53
|
+
* on the wire: the CLI parses this schema, so requiring it would break a newer
|
|
54
|
+
* CLI against an older portal. Clients that lack it fall back to composing the
|
|
55
|
+
* slug onto `appPublicBase` from `GET /api/v1/config`.
|
|
56
|
+
*/
|
|
57
|
+
url: z2.url().optional()
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ../shared/src/version.ts
|
|
61
|
+
import { z as z3 } from "zod";
|
|
62
|
+
var VERSION_STATUSES = ["preview", "live", "archived"];
|
|
63
|
+
var VersionStatusSchema = z3.enum(VERSION_STATUSES);
|
|
64
|
+
var VersionSchema = z3.object({
|
|
65
|
+
id: z3.uuid(),
|
|
66
|
+
appId: z3.uuid(),
|
|
67
|
+
/** Monotonic per app, 1-based. */
|
|
68
|
+
number: z3.int().positive(),
|
|
69
|
+
/** Blob key prefix for this version's assets, e.g. `apps/<appId>/7/`. */
|
|
70
|
+
blobPrefix: z3.string().min(1),
|
|
71
|
+
status: VersionStatusSchema,
|
|
72
|
+
createdAt: z3.iso.datetime()
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ../shared/src/manifest.ts
|
|
76
|
+
import { z as z4 } from "zod";
|
|
77
|
+
var LlmCapabilitySchema = z4.object({
|
|
78
|
+
models: z4.array(z4.string()).default([]),
|
|
79
|
+
/**
|
|
80
|
+
* Per-app daily LLM spend cap in USD; unset ⇒ unbounded. Denominated in
|
|
81
|
+
* dollars (not tokens) so the cap means the same thing across models — the
|
|
82
|
+
* edge prices each call via `@azx-pbc/shared` pricing and enforces a daily +
|
|
83
|
+
* rolling-hour burst window off the frozen `costMicroUsd` ledger column.
|
|
84
|
+
*/
|
|
85
|
+
dollarsPerDay: z4.number().positive().optional()
|
|
86
|
+
});
|
|
87
|
+
var DataCapabilitySchema = z4.object({
|
|
88
|
+
user: z4.boolean().default(false),
|
|
89
|
+
collections: z4.array(z4.string().min(1)).default([]),
|
|
90
|
+
sharedRead: z4.array(z4.string().min(1)).default([]),
|
|
91
|
+
sharedWrite: z4.array(z4.string().min(1)).default([]),
|
|
92
|
+
writesPerDay: z4.int().positive().optional(),
|
|
93
|
+
bytesPerDay: z4.int().positive().optional()
|
|
94
|
+
});
|
|
95
|
+
var FetchConnectionSchema = z4.object({
|
|
96
|
+
origin: z4.url(),
|
|
97
|
+
connection: z4.string().min(1).optional()
|
|
98
|
+
});
|
|
99
|
+
var FetchCapabilitySchema = z4.object({
|
|
100
|
+
/** Opt-in transparent `fetch` shim injected at serve time (fetch-proxy §3.2). */
|
|
101
|
+
shim: z4.boolean().default(false),
|
|
102
|
+
/** Origins reached through the proxy (mode `proxy`); direct stays in `externalOrigins`. */
|
|
103
|
+
origins: z4.array(FetchConnectionSchema).default([]),
|
|
104
|
+
/** Per-app daily proxied-request budget; unset ⇒ unbounded (fetch-proxy §7). */
|
|
105
|
+
requestsPerDay: z4.int().positive().optional()
|
|
106
|
+
});
|
|
107
|
+
var SCOPE_SEGMENT = /^[A-Za-z0-9\-._~]+$/;
|
|
108
|
+
function isValidServiceWorkerScope(scope) {
|
|
109
|
+
if (!scope.startsWith("/") || !scope.endsWith("/")) return false;
|
|
110
|
+
const segments = scope.slice(1, -1).split("/");
|
|
111
|
+
const first = segments[0];
|
|
112
|
+
if (first === void 0) return false;
|
|
113
|
+
if (segments.some((s) => s === "" || s === "." || s === ".." || !SCOPE_SEGMENT.test(s))) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
return !first.startsWith("_");
|
|
117
|
+
}
|
|
118
|
+
var OfflineCapabilitySchema = z4.object({
|
|
119
|
+
/**
|
|
120
|
+
* URL path prefix the worker controls, e.g. `/app/`. Leading and trailing
|
|
121
|
+
* slash required; never root, never a `_`-prefixed platform namespace.
|
|
122
|
+
*/
|
|
123
|
+
scope: z4.string().refine(isValidServiceWorkerScope, {
|
|
124
|
+
message: "scope must be a non-root path prefix with a leading and trailing slash, and must not start with a reserved `_` segment (e.g. `/app/`)"
|
|
125
|
+
})
|
|
126
|
+
});
|
|
127
|
+
var CapabilitiesSchema = z4.object({
|
|
128
|
+
llm: LlmCapabilitySchema.optional(),
|
|
129
|
+
data: DataCapabilitySchema.optional(),
|
|
130
|
+
/** Platform-registered MCP servers this app may reach (§6.1, exposed as REST). */
|
|
131
|
+
mcp: z4.array(z4.string()).default([]),
|
|
132
|
+
/** Extra CSP `connect-src` origins for **direct** browser calls (§4.4). */
|
|
133
|
+
externalOrigins: z4.array(z4.url()).default([]),
|
|
134
|
+
/** Governed outbound HTTP via the fetch-proxy / egress plane (in build, M4.5). */
|
|
135
|
+
fetch: FetchCapabilitySchema.optional(),
|
|
136
|
+
/** Platform-owned, scope-confined service worker for offline cold boot (ADR-0035). */
|
|
137
|
+
offline: OfflineCapabilitySchema.optional()
|
|
138
|
+
});
|
|
139
|
+
var AppManifestSchema = z4.object({
|
|
140
|
+
/** App slug; matches `App.slug`. */
|
|
141
|
+
app: z4.string().min(1),
|
|
142
|
+
visibility: VisibilitySchema,
|
|
143
|
+
capabilities: CapabilitiesSchema.default({ mcp: [], externalOrigins: [] })
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ../shared/src/approval.ts
|
|
147
|
+
import { z as z5 } from "zod";
|
|
148
|
+
|
|
149
|
+
// ../shared/src/pricing.ts
|
|
150
|
+
var MODEL_PRICING = {
|
|
151
|
+
// Anthropic. NB `structuredOutputs` is deliberately absent on 4-7/4-6/sonnet-4-6:
|
|
152
|
+
// structured outputs are supported on Fable 5, Opus 4.8 and Haiku 4.5 but not on
|
|
153
|
+
// those three, so the flag is opt-in per model rather than per provider.
|
|
154
|
+
"claude-fable-5": {
|
|
155
|
+
inputPerMTok: 10,
|
|
156
|
+
outputPerMTok: 50,
|
|
157
|
+
provider: "anthropic",
|
|
158
|
+
structuredOutputs: true
|
|
159
|
+
},
|
|
160
|
+
"claude-opus-5": {
|
|
161
|
+
inputPerMTok: 5,
|
|
162
|
+
outputPerMTok: 25,
|
|
163
|
+
provider: "anthropic",
|
|
164
|
+
structuredOutputs: true
|
|
165
|
+
},
|
|
166
|
+
// NB list rates. Sonnet 5 has promotional $2/$10 pricing through 2026-08-31; this
|
|
167
|
+
// table drives the **cost gate**, so the list price is the safe number — it
|
|
168
|
+
// over-estimates spend during the promo rather than under-billing once it lapses.
|
|
169
|
+
"claude-sonnet-5": {
|
|
170
|
+
inputPerMTok: 3,
|
|
171
|
+
outputPerMTok: 15,
|
|
172
|
+
provider: "anthropic",
|
|
173
|
+
structuredOutputs: true
|
|
174
|
+
},
|
|
175
|
+
"claude-opus-4-8": {
|
|
176
|
+
inputPerMTok: 5,
|
|
177
|
+
outputPerMTok: 25,
|
|
178
|
+
provider: "anthropic",
|
|
179
|
+
structuredOutputs: true
|
|
180
|
+
},
|
|
181
|
+
"claude-opus-4-7": { inputPerMTok: 5, outputPerMTok: 25, provider: "anthropic" },
|
|
182
|
+
"claude-opus-4-6": { inputPerMTok: 5, outputPerMTok: 25, provider: "anthropic" },
|
|
183
|
+
"claude-sonnet-4-6": { inputPerMTok: 3, outputPerMTok: 15, provider: "anthropic" },
|
|
184
|
+
"claude-haiku-4-5": {
|
|
185
|
+
inputPerMTok: 1,
|
|
186
|
+
outputPerMTok: 5,
|
|
187
|
+
provider: "anthropic",
|
|
188
|
+
structuredOutputs: true
|
|
189
|
+
},
|
|
190
|
+
// OpenAI — VERIFY against current published rates before production billing.
|
|
191
|
+
// Every model here resolves to a snapshot new enough for `response_format`
|
|
192
|
+
// json_schema, so `structuredOutputs` is set across the board.
|
|
193
|
+
"gpt-4o": { inputPerMTok: 2.5, outputPerMTok: 10, provider: "openai", structuredOutputs: true },
|
|
194
|
+
"gpt-4o-mini": {
|
|
195
|
+
inputPerMTok: 0.15,
|
|
196
|
+
outputPerMTok: 0.6,
|
|
197
|
+
provider: "openai",
|
|
198
|
+
structuredOutputs: true
|
|
199
|
+
},
|
|
200
|
+
"gpt-4.1": { inputPerMTok: 2, outputPerMTok: 8, provider: "openai", structuredOutputs: true },
|
|
201
|
+
"gpt-4.1-mini": {
|
|
202
|
+
inputPerMTok: 0.4,
|
|
203
|
+
outputPerMTok: 1.6,
|
|
204
|
+
provider: "openai",
|
|
205
|
+
structuredOutputs: true
|
|
206
|
+
},
|
|
207
|
+
"gpt-4.1-nano": {
|
|
208
|
+
inputPerMTok: 0.1,
|
|
209
|
+
outputPerMTok: 0.4,
|
|
210
|
+
provider: "openai",
|
|
211
|
+
structuredOutputs: true
|
|
212
|
+
},
|
|
213
|
+
o3: {
|
|
214
|
+
inputPerMTok: 2,
|
|
215
|
+
outputPerMTok: 8,
|
|
216
|
+
provider: "openai",
|
|
217
|
+
reasoning: true,
|
|
218
|
+
minCompletionTokens: 25e3,
|
|
219
|
+
structuredOutputs: true
|
|
220
|
+
},
|
|
221
|
+
"o4-mini": {
|
|
222
|
+
inputPerMTok: 1.1,
|
|
223
|
+
outputPerMTok: 4.4,
|
|
224
|
+
provider: "openai",
|
|
225
|
+
reasoning: true,
|
|
226
|
+
minCompletionTokens: 25e3,
|
|
227
|
+
structuredOutputs: true
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// ../shared/src/approval.ts
|
|
232
|
+
var RISK_LEVELS = ["low", "med", "high"];
|
|
233
|
+
var RiskSchema = z5.enum(RISK_LEVELS);
|
|
234
|
+
var APPROVAL_STATUSES = [
|
|
235
|
+
"pending",
|
|
236
|
+
"approved",
|
|
237
|
+
"denied",
|
|
238
|
+
"withdrawn",
|
|
239
|
+
"needs_changes"
|
|
240
|
+
];
|
|
241
|
+
var ApprovalStatusSchema = z5.enum(APPROVAL_STATUSES);
|
|
242
|
+
var DeltaSchema = z5.object({
|
|
243
|
+
path: z5.string(),
|
|
244
|
+
from: z5.union([z5.string(), z5.number(), z5.boolean()]).optional(),
|
|
245
|
+
to: z5.union([z5.string(), z5.number(), z5.boolean()]).optional()
|
|
246
|
+
});
|
|
247
|
+
var ApprovalRequestSchema = z5.object({
|
|
248
|
+
id: z5.string(),
|
|
249
|
+
appId: z5.string(),
|
|
250
|
+
/** Joined for the admin queue / banner; not a column. */
|
|
251
|
+
appSlug: z5.string().optional(),
|
|
252
|
+
appDisplayName: z5.string().optional(),
|
|
253
|
+
status: ApprovalStatusSchema,
|
|
254
|
+
risk: RiskSchema,
|
|
255
|
+
deltas: z5.array(DeltaSchema),
|
|
256
|
+
baseSnapshot: z5.unknown(),
|
|
257
|
+
requestedBy: z5.string(),
|
|
258
|
+
reason: z5.string().nullable().optional(),
|
|
259
|
+
decidedBy: z5.string().nullable().optional(),
|
|
260
|
+
decisionNote: z5.string().nullable().optional(),
|
|
261
|
+
createdAt: z5.string(),
|
|
262
|
+
decidedAt: z5.string().nullable().optional()
|
|
263
|
+
});
|
|
264
|
+
var ManifestUpdateResultSchema = z5.object({
|
|
265
|
+
manifest: AppManifestSchema,
|
|
266
|
+
applied: z5.array(DeltaSchema),
|
|
267
|
+
pending: z5.string().nullable()
|
|
268
|
+
});
|
|
269
|
+
var VisibilityUpdateResultSchema = z5.object({
|
|
270
|
+
app: AppSchema,
|
|
271
|
+
applied: z5.array(DeltaSchema),
|
|
272
|
+
pending: z5.string().nullable()
|
|
273
|
+
});
|
|
274
|
+
var ApprovalDecisionRequestSchema = z5.object({
|
|
275
|
+
note: z5.string().max(2e3).optional()
|
|
276
|
+
});
|
|
277
|
+
var CURATED_LLM_MODELS = Object.keys(MODEL_PRICING);
|
|
278
|
+
|
|
279
|
+
// ../shared/src/health.ts
|
|
280
|
+
import { z as z6 } from "zod";
|
|
281
|
+
var HealthStateSchema = z6.enum(["ok", "degraded", "error"]);
|
|
282
|
+
var HealthCheckSchema = z6.object({
|
|
283
|
+
/** Stable identifier an alert rule can key on, e.g. `registry-projection`. */
|
|
284
|
+
name: z6.string(),
|
|
285
|
+
status: HealthStateSchema,
|
|
286
|
+
/** Operator-facing one-liner: what is wrong, and for how long. */
|
|
287
|
+
detail: z6.string().optional(),
|
|
288
|
+
/** Wall-clock instant this check last succeeded. Report-only — never the
|
|
289
|
+
* basis of a staleness decision (see the monotonic note in the edge's
|
|
290
|
+
* registry projection). */
|
|
291
|
+
lastSuccessAt: z6.iso.datetime().optional(),
|
|
292
|
+
/** Numeric facts a dashboard or a log-based metric can key on. */
|
|
293
|
+
metrics: z6.record(z6.string(), z6.number()).optional()
|
|
294
|
+
});
|
|
295
|
+
var HealthStatusSchema = z6.object({
|
|
296
|
+
/** The roll-up: the worst state across `checks` (see `worstHealthState`). */
|
|
297
|
+
status: HealthStateSchema,
|
|
298
|
+
service: z6.string(),
|
|
299
|
+
/** Process uptime in seconds. */
|
|
300
|
+
uptime: z6.number().nonnegative(),
|
|
301
|
+
/** Absent means the service reports liveness only (portal and egress today). */
|
|
302
|
+
checks: z6.array(HealthCheckSchema).optional()
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// ../shared/src/api.ts
|
|
306
|
+
import { z as z7 } from "zod";
|
|
307
|
+
var CreateAppRequestSchema = z7.object({
|
|
308
|
+
slug: AppSchema.shape.slug,
|
|
309
|
+
displayName: AppSchema.shape.displayName,
|
|
310
|
+
visibility: VisibilitySchema.default({ mode: "internal" }),
|
|
311
|
+
/** Optional per-app capability grant set at create time (architecture §6.3). */
|
|
312
|
+
capabilities: CapabilitiesSchema.optional()
|
|
313
|
+
});
|
|
314
|
+
var SetManifestRequestSchema = z7.object({
|
|
315
|
+
capabilities: CapabilitiesSchema,
|
|
316
|
+
/** Justification carried onto an approval request for any elevated deltas. */
|
|
317
|
+
reason: z7.string().max(2e3).optional()
|
|
318
|
+
});
|
|
319
|
+
var SetVisibilityRequestSchema = z7.object({
|
|
320
|
+
visibility: VisibilitySchema,
|
|
321
|
+
/** Justification carried onto the approval request when going public. */
|
|
322
|
+
reason: z7.string().max(2e3).optional()
|
|
323
|
+
});
|
|
324
|
+
var MIN_PASSWORD_LENGTH = 12;
|
|
325
|
+
var SetPasswordRequestSchema = z7.object({
|
|
326
|
+
password: z7.string().min(MIN_PASSWORD_LENGTH).optional()
|
|
327
|
+
});
|
|
328
|
+
var PasswordCredentialResponseSchema = z7.object({
|
|
329
|
+
/** The shared passphrase, in cleartext, for the owner to copy/share. */
|
|
330
|
+
password: z7.string().min(1),
|
|
331
|
+
/** The app's public URL, prebuilt so the UI can offer a one-click copy. */
|
|
332
|
+
url: z7.url(),
|
|
333
|
+
/** When the current password was set (ISO 8601). */
|
|
334
|
+
setAt: z7.string()
|
|
335
|
+
});
|
|
336
|
+
var CspWarningSchema = z7.object({
|
|
337
|
+
file: z7.string(),
|
|
338
|
+
origin: z7.string(),
|
|
339
|
+
hint: z7.string()
|
|
340
|
+
});
|
|
341
|
+
var OriginGrantRequestSchema = z7.object({
|
|
342
|
+
origin: z7.url(),
|
|
343
|
+
reason: z7.string().max(2e3).optional()
|
|
344
|
+
});
|
|
345
|
+
var CspViolationSchema = z7.object({
|
|
346
|
+
appId: z7.string(),
|
|
347
|
+
appSlug: z7.string().nullable(),
|
|
348
|
+
directive: z7.string(),
|
|
349
|
+
blockedUri: z7.string(),
|
|
350
|
+
count: z7.number().int(),
|
|
351
|
+
lastSeen: z7.string(),
|
|
352
|
+
/**
|
|
353
|
+
* The blocked origin is already permitted by the app's *current* manifest, so
|
|
354
|
+
* this historical report is no longer actionable. Derived at read time
|
|
355
|
+
* (directive-aware — only `connect-src`/`img-src` are widened by an
|
|
356
|
+
* `externalOrigins` grant); the row itself is never deleted.
|
|
357
|
+
*/
|
|
358
|
+
resolved: z7.boolean()
|
|
359
|
+
});
|
|
360
|
+
var CspViolationsPageSchema = z7.object({
|
|
361
|
+
violations: z7.array(CspViolationSchema)
|
|
362
|
+
});
|
|
363
|
+
var UploadVersionResponseSchema = z7.object({
|
|
364
|
+
version: VersionSchema,
|
|
365
|
+
warnings: z7.array(CspWarningSchema)
|
|
366
|
+
});
|
|
367
|
+
var RollbackRequestSchema = z7.object({
|
|
368
|
+
toNumber: z7.int().positive().optional()
|
|
369
|
+
});
|
|
370
|
+
var API_ERROR_CODES = [
|
|
371
|
+
"validation_failed",
|
|
372
|
+
"not_found",
|
|
373
|
+
"slug_taken",
|
|
374
|
+
"bundle_invalid",
|
|
375
|
+
"unauthorized",
|
|
376
|
+
/** Authenticated but not allowed — reserved for v1 RBAC. */
|
|
377
|
+
"forbidden",
|
|
378
|
+
"conflict",
|
|
379
|
+
/** Gateway: requested model is not in the app's manifest allowlist (§6.3). */
|
|
380
|
+
"model_not_allowed",
|
|
381
|
+
/** Gateway: the app's daily token budget is exhausted (§6.1). */
|
|
382
|
+
"quota_exceeded",
|
|
383
|
+
/**
|
|
384
|
+
* Gateway: the anonymous tier's per-IP request budget is exhausted on a
|
|
385
|
+
* `public` app (app-data design §7). Distinct from `quota_exceeded` (per-app
|
|
386
|
+
* daily budget) so the app can tell per-IP throttling apart from running out
|
|
387
|
+
* of its own budget. HTTP 429.
|
|
388
|
+
*/
|
|
389
|
+
"rate_limited",
|
|
390
|
+
/** Gateway: a configured capability is not available on this edge. */
|
|
391
|
+
"capability_unavailable",
|
|
392
|
+
"internal"
|
|
393
|
+
];
|
|
394
|
+
var ApiErrorCodeSchema = z7.enum(API_ERROR_CODES);
|
|
395
|
+
var ApiErrorSchema = z7.object({
|
|
396
|
+
error: z7.object({
|
|
397
|
+
code: ApiErrorCodeSchema,
|
|
398
|
+
message: z7.string(),
|
|
399
|
+
details: z7.unknown().optional()
|
|
400
|
+
})
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// ../shared/src/auth.ts
|
|
404
|
+
import { z as z8 } from "zod";
|
|
405
|
+
var MeResponseSchema = z8.object({
|
|
406
|
+
user: z8.object({
|
|
407
|
+
/** IdP subject (Entra object id) — stable, safe to key app data on. */
|
|
408
|
+
id: z8.string(),
|
|
409
|
+
displayName: z8.string()
|
|
410
|
+
})
|
|
411
|
+
});
|
|
412
|
+
var PortalMeResponseSchema = z8.object({
|
|
413
|
+
sub: z8.string(),
|
|
414
|
+
/** How the actor was established: `oidc` or `dev-token`. */
|
|
415
|
+
via: z8.string(),
|
|
416
|
+
name: z8.string().optional(),
|
|
417
|
+
email: z8.string().optional(),
|
|
418
|
+
/**
|
|
419
|
+
* Whether the actor holds the `platform-admin` role. Computed server-side from
|
|
420
|
+
* the actor's group/role claim — the raw ids never cross to the browser. Drives
|
|
421
|
+
* admin nav + route gating in the SPA (and `helix whoami`).
|
|
422
|
+
*/
|
|
423
|
+
isAdmin: z8.boolean()
|
|
424
|
+
});
|
|
425
|
+
var AuthConfigResponseSchema = z8.object({
|
|
426
|
+
issuer: z8.url(),
|
|
427
|
+
cliClientId: z8.string().min(1),
|
|
428
|
+
/** Public client the portal SPA uses for code+PKCE in the browser. */
|
|
429
|
+
webClientId: z8.string().min(1).optional(),
|
|
430
|
+
/** Expected token audience — part of what the CLI binds cached tokens to. */
|
|
431
|
+
audience: z8.string().min(1).optional(),
|
|
432
|
+
/**
|
|
433
|
+
* Whether this deployment permits `public` (anonymous) apps. Drives the SPA's
|
|
434
|
+
* visibility UI — it hides the public option when false. Absent = forbidden
|
|
435
|
+
* (older portal / dev-token-only where this endpoint 404s — open surfaces are
|
|
436
|
+
* opt-in). Server-side enforcement is independent of this hint (portal routes
|
|
437
|
+
* + edge serving).
|
|
438
|
+
*/
|
|
439
|
+
allowPublicApps: z8.boolean().optional(),
|
|
440
|
+
/** Whether this deployment permits `password` (shared-passphrase) apps. Same shape as {@link allowPublicApps}. */
|
|
441
|
+
allowPasswordApps: z8.boolean().optional()
|
|
442
|
+
});
|
|
443
|
+
function portalApiScope(audience) {
|
|
444
|
+
if (!audience || audience.startsWith("urn:")) return null;
|
|
445
|
+
const appIdUri = audience.startsWith("api://") ? audience : `api://${audience}`;
|
|
446
|
+
return `${appIdUri.replace(/\/+$/, "")}/access`;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ../shared/src/deployment.ts
|
|
450
|
+
import { z as z9 } from "zod";
|
|
451
|
+
var DeploymentConfigResponseSchema = z9.object({
|
|
452
|
+
/**
|
|
453
|
+
* Scheme + host + (non-default) port where apps are served, as reachable by
|
|
454
|
+
* this browser (architecture §4.1). The app slug is prepended as a subdomain:
|
|
455
|
+
* `https://<slug>.<host>`.
|
|
456
|
+
*/
|
|
457
|
+
appPublicBase: z9.url(),
|
|
458
|
+
/**
|
|
459
|
+
* Base of the opt-in dev gateway (dev-mode design §3), absent when it is not
|
|
460
|
+
* deployed. Unlike {@link appPublicBase} the slug goes in the *path*, not the
|
|
461
|
+
* host. Absent ⇒ dev mode is unavailable here, so the UI says so instead of
|
|
462
|
+
* printing an unreachable host.
|
|
463
|
+
*/
|
|
464
|
+
devApiBase: z9.url().optional(),
|
|
465
|
+
/**
|
|
466
|
+
* Month-to-date platform spend ceiling (USD) for the admin budget watch line.
|
|
467
|
+
* Display-only — the gateway is the choke point, so the rollup is exact, but
|
|
468
|
+
* nothing enforces this. Absent ⇒ no ceiling shown.
|
|
469
|
+
*/
|
|
470
|
+
platformMonthlyUsdCap: z9.number().positive().optional(),
|
|
471
|
+
/**
|
|
472
|
+
* Deploy bundle size caps in megabytes (`DEPLOY_MAX_FILE_MB` /
|
|
473
|
+
* `DEPLOY_MAX_BUNDLE_MB` on the portal) — `deployMaxFileMb` is per file,
|
|
474
|
+
* `deployMaxBundleMb` the whole-archive total. A current portal always sends
|
|
475
|
+
* both; optional only to tolerate one that predates the fields, same as `url`
|
|
476
|
+
* on {@link AppSchema}. Absent means "don't state a number" — a client must not
|
|
477
|
+
* substitute a default, because printing the wrong cap to an agent sends it
|
|
478
|
+
* chasing a rejection it can't see the cause of.
|
|
479
|
+
*/
|
|
480
|
+
deployMaxFileMb: z9.number().positive().optional(),
|
|
481
|
+
deployMaxBundleMb: z9.number().positive().optional()
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// ../shared/src/scrypt.ts
|
|
485
|
+
var SCRYPT_PARAMS = {
|
|
486
|
+
/** CPU/memory cost. OWASP floor for scrypt. */
|
|
487
|
+
N: 2 ** 17,
|
|
488
|
+
/** Block size. */
|
|
489
|
+
r: 8,
|
|
490
|
+
/** Parallelization. */
|
|
491
|
+
p: 1,
|
|
492
|
+
/** 192 MiB — headroom above the ~128 MiB working set at these params. */
|
|
493
|
+
maxmem: 192 * 1024 * 1024
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
// ../shared/src/llm.ts
|
|
497
|
+
import { z as z10 } from "zod";
|
|
498
|
+
var LlmMessageSchema = z10.object({
|
|
499
|
+
role: z10.enum(["user", "assistant"]),
|
|
500
|
+
content: z10.string().min(1)
|
|
501
|
+
});
|
|
502
|
+
var LlmUsageSchema = z10.object({
|
|
503
|
+
inputTokens: z10.int().nonnegative(),
|
|
504
|
+
outputTokens: z10.int().nonnegative(),
|
|
505
|
+
cacheReadInputTokens: z10.int().nonnegative().default(0),
|
|
506
|
+
cacheCreationInputTokens: z10.int().nonnegative().default(0)
|
|
507
|
+
});
|
|
508
|
+
var MAX_SCHEMA_CHARS = 32768;
|
|
509
|
+
var MAX_SCHEMA_DEPTH = 12;
|
|
510
|
+
function schemaDepth(value, depth = 1) {
|
|
511
|
+
if (value === null || typeof value !== "object") return depth;
|
|
512
|
+
let deepest = depth;
|
|
513
|
+
for (const child of Object.values(value)) {
|
|
514
|
+
const d = schemaDepth(child, depth + 1);
|
|
515
|
+
if (d > deepest) deepest = d;
|
|
516
|
+
if (deepest > MAX_SCHEMA_DEPTH) return deepest;
|
|
517
|
+
}
|
|
518
|
+
return deepest;
|
|
519
|
+
}
|
|
520
|
+
function withinSchemaBudget(schema) {
|
|
521
|
+
let serialized;
|
|
522
|
+
try {
|
|
523
|
+
serialized = JSON.stringify(schema);
|
|
524
|
+
} catch {
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
return serialized.length <= MAX_SCHEMA_CHARS && schemaDepth(schema) <= MAX_SCHEMA_DEPTH;
|
|
528
|
+
}
|
|
529
|
+
var LlmResponseFormatSchema = z10.object({
|
|
530
|
+
type: z10.literal("json_schema"),
|
|
531
|
+
/** Schema name. OpenAI requires one; defaulted at translation when omitted. */
|
|
532
|
+
name: z10.string().regex(/^[A-Za-z0-9_-]{1,64}$/, "must be 1-64 chars of [A-Za-z0-9_-]").optional(),
|
|
533
|
+
schema: z10.record(z10.string(), z10.unknown()).refine((s) => s.type === "object", 'schema root must be `{"type":"object"}`').refine(
|
|
534
|
+
withinSchemaBudget,
|
|
535
|
+
`schema must serialize to <= ${MAX_SCHEMA_CHARS} characters and nest <= ${MAX_SCHEMA_DEPTH} levels`
|
|
536
|
+
)
|
|
537
|
+
// NB there is deliberately no `strict` knob. Anthropic's `output_config.format`
|
|
538
|
+
// always enforces and has no best-effort mode, so a `strict:false` could only be
|
|
539
|
+
// honored on one vendor — the same request would then yield schema-violating JSON
|
|
540
|
+
// on `gpt-*` but not `claude-*`, which is exactly the provider leak this seam
|
|
541
|
+
// exists to prevent. The platform always enforces; see ADR-0034.
|
|
542
|
+
});
|
|
543
|
+
var LlmChatRequestSchema = z10.object({
|
|
544
|
+
model: z10.string().min(1),
|
|
545
|
+
messages: z10.array(LlmMessageSchema).min(1),
|
|
546
|
+
/** Optional system prompt; maps to the vendor's system channel. */
|
|
547
|
+
system: z10.string().optional(),
|
|
548
|
+
maxTokens: z10.int().positive().max(128e3).optional(),
|
|
549
|
+
/** Sampling temperature; forwarded as-is (the vendor validates its own range). */
|
|
550
|
+
temperature: z10.number().optional(),
|
|
551
|
+
/** Nucleus sampling; forwarded as-is. */
|
|
552
|
+
topP: z10.number().optional(),
|
|
553
|
+
/** Stop sequences; normalized to a list at the boundary. */
|
|
554
|
+
stop: z10.array(z10.string()).optional(),
|
|
555
|
+
/**
|
|
556
|
+
* Constrain the completion to a JSON schema (ADR-0034). Refused up front when
|
|
557
|
+
* the requested model can't enforce it (`ModelPrice.structuredOutputs`). The
|
|
558
|
+
* JSON still arrives as ordinary text, so `content` and the SSE `delta` frames
|
|
559
|
+
* are unchanged — callers `JSON.parse` the result.
|
|
560
|
+
*/
|
|
561
|
+
responseFormat: LlmResponseFormatSchema.optional(),
|
|
562
|
+
/** SSE streaming (default) vs a single JSON body. */
|
|
563
|
+
stream: z10.boolean().default(true)
|
|
564
|
+
});
|
|
565
|
+
var LlmChatResponseSchema = z10.object({
|
|
566
|
+
model: z10.string(),
|
|
567
|
+
content: z10.string(),
|
|
568
|
+
stopReason: z10.string(),
|
|
569
|
+
usage: LlmUsageSchema
|
|
570
|
+
});
|
|
571
|
+
var LlmStreamDeltaSchema = z10.object({ text: z10.string() });
|
|
572
|
+
var LlmStreamDoneSchema = z10.object({
|
|
573
|
+
stopReason: z10.string(),
|
|
574
|
+
usage: LlmUsageSchema
|
|
575
|
+
});
|
|
576
|
+
var LlmStreamErrorSchema = z10.object({
|
|
577
|
+
code: z10.string(),
|
|
578
|
+
message: z10.string()
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// ../shared/src/llmOpenai.ts
|
|
582
|
+
import { z as z11 } from "zod";
|
|
583
|
+
var OpenAiRoleSchema = z11.enum(["system", "developer", "user", "assistant", "tool"]);
|
|
584
|
+
var OpenAiMessageSchema = z11.object({
|
|
585
|
+
role: OpenAiRoleSchema,
|
|
586
|
+
content: z11.union([z11.string(), z11.array(z11.unknown())]).nullish(),
|
|
587
|
+
/** Present only on assistant tool-call turns — parsed to reject, never honored. */
|
|
588
|
+
tool_calls: z11.array(z11.unknown()).optional(),
|
|
589
|
+
tool_call_id: z11.string().optional(),
|
|
590
|
+
name: z11.string().optional()
|
|
591
|
+
});
|
|
592
|
+
var OpenAiStreamOptionsSchema = z11.object({
|
|
593
|
+
include_usage: z11.boolean().optional()
|
|
594
|
+
});
|
|
595
|
+
var OpenAiResponseFormatSchema = z11.discriminatedUnion("type", [
|
|
596
|
+
z11.object({
|
|
597
|
+
type: z11.literal("json_schema"),
|
|
598
|
+
json_schema: z11.object({
|
|
599
|
+
name: z11.string().optional(),
|
|
600
|
+
schema: z11.record(z11.string(), z11.unknown()),
|
|
601
|
+
strict: z11.boolean().nullish(),
|
|
602
|
+
/**
|
|
603
|
+
* Declared so the codec can reject it rather than zod silently stripping it.
|
|
604
|
+
* It is honorable on the OpenAI path but has no Anthropic equivalent, so
|
|
605
|
+
* forwarding it would make behaviour depend on the backing vendor — the same
|
|
606
|
+
* provider leak that got `json_object` rejected (ADR-0034).
|
|
607
|
+
*/
|
|
608
|
+
description: z11.string().optional()
|
|
609
|
+
})
|
|
610
|
+
}),
|
|
611
|
+
z11.object({ type: z11.literal("json_object") }),
|
|
612
|
+
z11.object({ type: z11.literal("text") })
|
|
613
|
+
]);
|
|
614
|
+
var OpenAiChatCompletionRequestSchema = z11.object({
|
|
615
|
+
model: z11.string().min(1),
|
|
616
|
+
messages: z11.array(OpenAiMessageSchema).min(1),
|
|
617
|
+
max_tokens: z11.int().positive().max(128e3).optional(),
|
|
618
|
+
max_completion_tokens: z11.int().positive().max(128e3).optional(),
|
|
619
|
+
temperature: z11.number().optional(),
|
|
620
|
+
top_p: z11.number().optional(),
|
|
621
|
+
stop: z11.union([z11.string(), z11.array(z11.string())]).optional(),
|
|
622
|
+
stream: z11.boolean().optional(),
|
|
623
|
+
stream_options: OpenAiStreamOptionsSchema.optional(),
|
|
624
|
+
/** Declared so the codec can reject tool use in v1 (not supported). */
|
|
625
|
+
tools: z11.array(z11.unknown()).optional(),
|
|
626
|
+
tool_choice: z11.unknown().optional(),
|
|
627
|
+
/**
|
|
628
|
+
* Structured output (ADR-0034). Parsed, not rejected — see the union above.
|
|
629
|
+
* `.nullish()` because clients and proxies that serialize every field send
|
|
630
|
+
* `"response_format": null` to mean "no structured output", which is a request
|
|
631
|
+
* this surface can serve; refusing it at the envelope would be gratuitous.
|
|
632
|
+
*/
|
|
633
|
+
response_format: OpenAiResponseFormatSchema.nullish(),
|
|
634
|
+
// Behaviour-changing params the platform does not honor in v1. Declared (not
|
|
635
|
+
// stripped) so the codec can reject them with a 400 rather than silently drop
|
|
636
|
+
// them — the same "reject, never silently drop" contract as `tools`.
|
|
637
|
+
n: z11.unknown().optional(),
|
|
638
|
+
seed: z11.unknown().optional(),
|
|
639
|
+
logit_bias: z11.unknown().optional(),
|
|
640
|
+
presence_penalty: z11.unknown().optional(),
|
|
641
|
+
frequency_penalty: z11.unknown().optional(),
|
|
642
|
+
logprobs: z11.unknown().optional(),
|
|
643
|
+
top_logprobs: z11.unknown().optional()
|
|
644
|
+
});
|
|
645
|
+
var OpenAiUsageSchema = z11.object({
|
|
646
|
+
prompt_tokens: z11.int().nonnegative(),
|
|
647
|
+
completion_tokens: z11.int().nonnegative(),
|
|
648
|
+
total_tokens: z11.int().nonnegative()
|
|
649
|
+
});
|
|
650
|
+
var OpenAiChatCompletionResponseSchema = z11.object({
|
|
651
|
+
id: z11.string(),
|
|
652
|
+
object: z11.literal("chat.completion"),
|
|
653
|
+
created: z11.int(),
|
|
654
|
+
model: z11.string(),
|
|
655
|
+
choices: z11.array(
|
|
656
|
+
z11.object({
|
|
657
|
+
index: z11.int(),
|
|
658
|
+
message: z11.object({
|
|
659
|
+
role: z11.literal("assistant"),
|
|
660
|
+
content: z11.string()
|
|
661
|
+
}),
|
|
662
|
+
finish_reason: z11.string()
|
|
663
|
+
})
|
|
664
|
+
),
|
|
665
|
+
usage: OpenAiUsageSchema
|
|
666
|
+
});
|
|
667
|
+
var OpenAiChatCompletionChunkSchema = z11.object({
|
|
668
|
+
id: z11.string(),
|
|
669
|
+
object: z11.literal("chat.completion.chunk"),
|
|
670
|
+
created: z11.int(),
|
|
671
|
+
model: z11.string(),
|
|
672
|
+
choices: z11.array(
|
|
673
|
+
z11.object({
|
|
674
|
+
index: z11.int(),
|
|
675
|
+
delta: z11.object({
|
|
676
|
+
role: z11.literal("assistant").optional(),
|
|
677
|
+
content: z11.string().optional()
|
|
678
|
+
}),
|
|
679
|
+
finish_reason: z11.string().nullable()
|
|
680
|
+
})
|
|
681
|
+
),
|
|
682
|
+
usage: OpenAiUsageSchema.nullish()
|
|
683
|
+
});
|
|
684
|
+
var OpenAiModelSchema = z11.object({
|
|
685
|
+
id: z11.string(),
|
|
686
|
+
object: z11.literal("model"),
|
|
687
|
+
created: z11.int(),
|
|
688
|
+
owned_by: z11.string()
|
|
689
|
+
});
|
|
690
|
+
var OpenAiModelListSchema = z11.object({
|
|
691
|
+
object: z11.literal("list"),
|
|
692
|
+
data: z11.array(OpenAiModelSchema)
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
// ../shared/src/usage.ts
|
|
696
|
+
import { z as z12 } from "zod";
|
|
697
|
+
var GATEWAY_OUTCOMES = ["ok", "error", "refusal", "quota_blocked"];
|
|
698
|
+
var GatewayOutcomeSchema = z12.enum(GATEWAY_OUTCOMES);
|
|
699
|
+
var USAGE_RANGES = ["24h", "7d", "30d"];
|
|
700
|
+
var UsageRangeSchema = z12.enum(USAGE_RANGES);
|
|
701
|
+
var PLATFORM_RANGES = ["7d", "30d", "90d"];
|
|
702
|
+
var PlatformRangeSchema = z12.enum(PLATFORM_RANGES);
|
|
703
|
+
var UsageSeriesPointSchema = z12.object({
|
|
704
|
+
bucket: z12.iso.datetime(),
|
|
705
|
+
costUsd: z12.number().nonnegative(),
|
|
706
|
+
tokens: z12.int().nonnegative(),
|
|
707
|
+
requests: z12.int().nonnegative()
|
|
708
|
+
});
|
|
709
|
+
var UsageSummarySchema = z12.object({
|
|
710
|
+
appId: z12.uuid(),
|
|
711
|
+
/** The rolling range these figures cover. */
|
|
712
|
+
range: UsageRangeSchema,
|
|
713
|
+
requests: z12.int().nonnegative(),
|
|
714
|
+
inputTokens: z12.int().nonnegative(),
|
|
715
|
+
outputTokens: z12.int().nonnegative(),
|
|
716
|
+
/** Cache-aware input token totals (0 until prompt caching is enabled). */
|
|
717
|
+
cacheReadInputTokens: z12.int().nonnegative(),
|
|
718
|
+
cacheCreationInputTokens: z12.int().nonnegative(),
|
|
719
|
+
/** Estimated spend in USD over the window at current rates (./pricing.ts). */
|
|
720
|
+
costUsd: z12.number().nonnegative(),
|
|
721
|
+
/** 95th-percentile upstream latency (ms) over the window; null when no timed calls. */
|
|
722
|
+
latencyP95Ms: z12.number().nonnegative().nullable(),
|
|
723
|
+
/** Fraction of calls in the window whose outcome was not `ok` (0..1). */
|
|
724
|
+
errorRate: z12.number().min(0).max(1),
|
|
725
|
+
/** Count of calls keyed by outcome (`ok` / `error` / `refusal` / `quota_blocked`). */
|
|
726
|
+
byOutcome: z12.record(z12.string(), z12.int().nonnegative()),
|
|
727
|
+
byModel: z12.array(
|
|
728
|
+
z12.object({
|
|
729
|
+
model: z12.string(),
|
|
730
|
+
tokens: z12.int().nonnegative(),
|
|
731
|
+
requests: z12.int().nonnegative(),
|
|
732
|
+
/** Estimated spend in USD for this model over the window. */
|
|
733
|
+
costUsd: z12.number().nonnegative()
|
|
734
|
+
})
|
|
735
|
+
),
|
|
736
|
+
/** Dense, zero-filled buckets across the range, oldest-first, for the trend chart. */
|
|
737
|
+
series: z12.array(UsageSeriesPointSchema),
|
|
738
|
+
/**
|
|
739
|
+
* Today-since-midnight totals, independent of `range` — backs the daily-cap
|
|
740
|
+
* gauge (the budget the edge enforces is per calendar day).
|
|
741
|
+
*/
|
|
742
|
+
today: z12.object({
|
|
743
|
+
tokens: z12.int().nonnegative(),
|
|
744
|
+
costUsd: z12.number().nonnegative()
|
|
745
|
+
})
|
|
746
|
+
});
|
|
747
|
+
var GatewayCallSchema = z12.object({
|
|
748
|
+
id: z12.uuid(),
|
|
749
|
+
appId: z12.uuid(),
|
|
750
|
+
/** App slug at read time; null when the app row no longer exists. */
|
|
751
|
+
slug: z12.string().nullable(),
|
|
752
|
+
userOid: z12.string(),
|
|
753
|
+
capability: z12.string(),
|
|
754
|
+
model: z12.string(),
|
|
755
|
+
inputTokens: z12.int().nonnegative(),
|
|
756
|
+
outputTokens: z12.int().nonnegative(),
|
|
757
|
+
cacheReadInputTokens: z12.int().nonnegative(),
|
|
758
|
+
cacheCreationInputTokens: z12.int().nonnegative(),
|
|
759
|
+
/** Estimated spend in USD for this single call at current rates. */
|
|
760
|
+
costUsd: z12.number().nonnegative(),
|
|
761
|
+
/** Upstream round-trip latency in ms (0 when not measured). */
|
|
762
|
+
durationMs: z12.int().nonnegative(),
|
|
763
|
+
/** Upstream/egress HTTP status — set for `fetch`; null for streamed `llm`. */
|
|
764
|
+
statusCode: z12.int().nullable(),
|
|
765
|
+
/** LLM stop reason; null for non-LLM calls. */
|
|
766
|
+
stopReason: z12.string().nullable(),
|
|
767
|
+
/** Short upstream error string; null on success. */
|
|
768
|
+
errorDetail: z12.string().nullable(),
|
|
769
|
+
outcome: GatewayOutcomeSchema,
|
|
770
|
+
createdAt: z12.iso.datetime()
|
|
771
|
+
});
|
|
772
|
+
var GatewayAuditPageSchema = z12.object({
|
|
773
|
+
rows: z12.array(GatewayCallSchema),
|
|
774
|
+
/** Pass as `?before=` to fetch the next (older) page; absent when exhausted. */
|
|
775
|
+
nextBefore: z12.iso.datetime().optional()
|
|
776
|
+
});
|
|
777
|
+
var PlatformUsageSchema = z12.object({
|
|
778
|
+
/** The rolling range the series + breakdowns cover. */
|
|
779
|
+
range: PlatformRangeSchema,
|
|
780
|
+
/** Dense, zero-filled daily buckets across the range, oldest-first. */
|
|
781
|
+
series: z12.array(UsageSeriesPointSchema),
|
|
782
|
+
/** Per-app rollup over the range, busiest-first. */
|
|
783
|
+
byApp: z12.array(
|
|
784
|
+
z12.object({
|
|
785
|
+
slug: z12.string().nullable(),
|
|
786
|
+
tokens: z12.int().nonnegative(),
|
|
787
|
+
requests: z12.int().nonnegative(),
|
|
788
|
+
/** Estimated spend in USD for this app over the range. */
|
|
789
|
+
costUsd: z12.number().nonnegative()
|
|
790
|
+
})
|
|
791
|
+
),
|
|
792
|
+
/** Month-to-date headline KPIs (independent of `range`). */
|
|
793
|
+
totals: z12.object({
|
|
794
|
+
tokensMTD: z12.int().nonnegative(),
|
|
795
|
+
requestsMTD: z12.int().nonnegative(),
|
|
796
|
+
/** Estimated month-to-date spend in USD across all apps. */
|
|
797
|
+
costMTD: z12.number().nonnegative(),
|
|
798
|
+
/** Distinct `userOid`s seen month-to-date. */
|
|
799
|
+
activeUsers: z12.int().nonnegative()
|
|
800
|
+
}),
|
|
801
|
+
/** Token + cost share by capability over the range (essentially all `llm` in M4). */
|
|
802
|
+
capabilityMix: z12.array(
|
|
803
|
+
z12.object({
|
|
804
|
+
capability: z12.string(),
|
|
805
|
+
tokens: z12.int().nonnegative(),
|
|
806
|
+
/** Estimated spend in USD for this capability over the range. */
|
|
807
|
+
costUsd: z12.number().nonnegative()
|
|
808
|
+
})
|
|
809
|
+
)
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
// ../shared/src/data.ts
|
|
813
|
+
import { z as z14 } from "zod";
|
|
814
|
+
|
|
815
|
+
// ../shared/src/env.ts
|
|
816
|
+
import { z as z13 } from "zod";
|
|
817
|
+
var ENVS = ["prod", "dev"];
|
|
818
|
+
var EnvSchema = z13.enum(ENVS);
|
|
819
|
+
|
|
820
|
+
// ../shared/src/data.ts
|
|
821
|
+
var CollectionItemSchema = z14.object({
|
|
822
|
+
id: z14.uuid(),
|
|
823
|
+
collection: z14.string(),
|
|
824
|
+
/**
|
|
825
|
+
* Which tier collected the row (dev-mode §5). The runtime roles are RLS-pinned
|
|
826
|
+
* to one tier each, but the portal reads across both, so the discriminator has
|
|
827
|
+
* to travel — otherwise a developer's dev-mode test submissions are
|
|
828
|
+
* indistinguishable from real prod leads in the drain.
|
|
829
|
+
*/
|
|
830
|
+
env: EnvSchema,
|
|
831
|
+
/** The submitting user, if authenticated; null for anonymous/public visitors. */
|
|
832
|
+
userOid: z14.string().nullable(),
|
|
833
|
+
item: z14.unknown(),
|
|
834
|
+
/** Hashed IP / truncated UA for abuse triage; null if not captured. */
|
|
835
|
+
meta: z14.unknown().nullable(),
|
|
836
|
+
createdAt: z14.iso.datetime()
|
|
837
|
+
});
|
|
838
|
+
var CollectionItemsPageSchema = z14.object({
|
|
839
|
+
rows: z14.array(CollectionItemSchema),
|
|
840
|
+
/** Pass as `?before=` to fetch the next (older) page; absent when exhausted. */
|
|
841
|
+
nextBefore: z14.iso.datetime().optional()
|
|
842
|
+
});
|
|
843
|
+
var CollectionSummarySchema = z14.object({
|
|
844
|
+
name: z14.string(),
|
|
845
|
+
env: EnvSchema,
|
|
846
|
+
count: z14.int().nonnegative(),
|
|
847
|
+
/** Newest row's timestamp; null only if the group is somehow empty. */
|
|
848
|
+
lastAt: z14.iso.datetime().nullable()
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
// ../shared/src/collectionTable.ts
|
|
852
|
+
var BOM = String.fromCharCode(65279);
|
|
853
|
+
|
|
854
|
+
// ../shared/src/secrets.ts
|
|
855
|
+
import { z as z15 } from "zod";
|
|
856
|
+
var FORBIDDEN_HEADER_NAMES = /* @__PURE__ */ new Set([
|
|
857
|
+
"host",
|
|
858
|
+
"content-length",
|
|
859
|
+
"transfer-encoding",
|
|
860
|
+
"connection",
|
|
861
|
+
"te",
|
|
862
|
+
"upgrade",
|
|
863
|
+
"expect",
|
|
864
|
+
"trailer"
|
|
865
|
+
]);
|
|
866
|
+
var HEADER_TOKEN = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
|
|
867
|
+
var HEADER_VALUE_SAFE = /^[\t\x20-\x7e]*$/;
|
|
868
|
+
var HEADER_VALUE_WIRE = /^[\t\x20-\x7e\x80-\xff]*$/;
|
|
869
|
+
var headerName = (from) => {
|
|
870
|
+
const base = z15.string().min(1);
|
|
871
|
+
return (from === "request" ? base.max(64).regex(HEADER_TOKEN, "must be an RFC 7230 token") : base).transform((n) => n.trim().toLowerCase()).refine((n) => !FORBIDDEN_HEADER_NAMES.has(n), "reserved header name").refine((n) => !n.startsWith("x-helix-"), "reserved header prefix");
|
|
872
|
+
};
|
|
873
|
+
var headerTemplate = (from) => from === "request" ? z15.string().max(512).regex(HEADER_VALUE_SAFE) : z15.string().regex(HEADER_VALUE_WIRE);
|
|
874
|
+
var injectionRecipe = (from) => z15.discriminatedUnion("kind", [
|
|
875
|
+
/** `Authorization: Bearer <secret>` — the common case. */
|
|
876
|
+
z15.object({ kind: z15.literal("header-bearer") }),
|
|
877
|
+
/** Arbitrary header; `{}` in `template` is replaced with the secret. */
|
|
878
|
+
z15.object({
|
|
879
|
+
kind: z15.literal("header"),
|
|
880
|
+
name: headerName(from),
|
|
881
|
+
template: headerTemplate(from).default("{}")
|
|
882
|
+
}),
|
|
883
|
+
/** Query parameter `?<param>=<secret>`. */
|
|
884
|
+
z15.object({ kind: z15.literal("query"), param: z15.string().min(1) }),
|
|
885
|
+
/**
|
|
886
|
+
* HMAC over a timestamp. The signed input is the timestamp string **alone** —
|
|
887
|
+
* not the method, path, query, or body — so injection is a pure function of
|
|
888
|
+
* (private key, now) and needs no request context.
|
|
889
|
+
*
|
|
890
|
+
* The canonical form lives in the KIND NAME, deliberately. A scheme that signs
|
|
891
|
+
* method+path+body is a *sibling kind* (`hmac-request`) — a code change with
|
|
892
|
+
* tests, reviewed — never an admin-editable canonical-string template.
|
|
893
|
+
* Canonicalization is where implementations of this family go wrong, and it
|
|
894
|
+
* does not belong in a text box.
|
|
895
|
+
*
|
|
896
|
+
* SHA-256, lowercase-hex, and ISO-8601-with-milliseconds are fixed rather than
|
|
897
|
+
* configurable. Each would carry a default, and `app_secrets.injection` is a
|
|
898
|
+
* schemaless JSON column, so adding a knob later is purely additive with no
|
|
899
|
+
* migration — while fixing them now removes the weak-algorithm and
|
|
900
|
+
* unencodable-digest failure classes outright.
|
|
901
|
+
*
|
|
902
|
+
* The stored value is a JSON blob carrying both halves of the key pair
|
|
903
|
+
* ({@link HmacCredentialSchema}): regenerating the pair changes both, and a
|
|
904
|
+
* blob rotates atomically through the existing rotate route.
|
|
905
|
+
*/
|
|
906
|
+
z15.object({
|
|
907
|
+
kind: z15.literal("hmac-timestamp"),
|
|
908
|
+
/** Header carrying the timestamp that is also the entire signed input. */
|
|
909
|
+
timestampHeader: headerName(from),
|
|
910
|
+
/** Header carrying the rendered credential + signature. */
|
|
911
|
+
authHeader: headerName(from).default("authorization"),
|
|
912
|
+
/**
|
|
913
|
+
* Value written to `authHeader`. `{credential}` and `{signature}` are
|
|
914
|
+
* substituted. Named rather than the `header` kind's bare `{}` because there
|
|
915
|
+
* are two substitutions: the convention is one value ⇒ `{}`, more than one ⇒
|
|
916
|
+
* named placeholders. Positional `{}` here would let a swapped template
|
|
917
|
+
* produce a well-formed header that silently fails to authenticate.
|
|
918
|
+
*
|
|
919
|
+
* Both placeholders are required. This recipe writes exactly two headers,
|
|
920
|
+
* one of them the timestamp, so there is no configuration where the public
|
|
921
|
+
* credential id travels elsewhere — omitting `{credential}` means the
|
|
922
|
+
* upstream can never identify the key and every call 401s.
|
|
923
|
+
*/
|
|
924
|
+
template: headerTemplate(from).refine((t) => t.includes("{signature}"), "template must contain {signature}").refine((t) => t.includes("{credential}"), "template must contain {credential}")
|
|
925
|
+
}).refine(
|
|
926
|
+
(r) => r.timestampHeader !== r.authHeader,
|
|
927
|
+
"timestampHeader and authHeader must differ \u2014 the second write overwrites the first"
|
|
928
|
+
)
|
|
929
|
+
]);
|
|
930
|
+
var InjectionRecipeSchema = injectionRecipe("request");
|
|
931
|
+
var StoredInjectionRecipeSchema = injectionRecipe("stored");
|
|
932
|
+
var HmacCredentialSchema = z15.object({
|
|
933
|
+
credential: z15.string().min(1),
|
|
934
|
+
key: z15.string().min(1)
|
|
935
|
+
});
|
|
936
|
+
var SECRET_SCOPES = ["app", "global", "platform"];
|
|
937
|
+
var SecretScopeSchema = z15.enum(SECRET_SCOPES);
|
|
938
|
+
var SecretMetadataSchema = z15.object({
|
|
939
|
+
id: z15.string(),
|
|
940
|
+
name: z15.string().min(1),
|
|
941
|
+
scope: SecretScopeSchema,
|
|
942
|
+
/** Partition tier (dev-mode §6): a dev fetch injects only `dev` connection secrets. */
|
|
943
|
+
env: EnvSchema,
|
|
944
|
+
/**
|
|
945
|
+
* The **stored** parser, not the strict one — this field describes a row, and
|
|
946
|
+
* the SPA re-parses this schema on every response. Using the strict parser here
|
|
947
|
+
* would let the portal return a name the browser then refuses, moving the read
|
|
948
|
+
* failure from the server to the client, where it presents as a dead page.
|
|
949
|
+
*
|
|
950
|
+
* `null` = the stored recipe is unreadable (it names a reserved header, or is
|
|
951
|
+
* not a recipe at all). The credential still exists and is still deletable; it
|
|
952
|
+
* cannot be rotated, and egress fails its hop closed. Recipes are immutable by
|
|
953
|
+
* design, so recovery is delete-and-recreate.
|
|
954
|
+
*/
|
|
955
|
+
injection: StoredInjectionRecipeSchema.nullable(),
|
|
956
|
+
createdBy: z15.string(),
|
|
957
|
+
createdAt: z15.string(),
|
|
958
|
+
rotatedAt: z15.string().nullable().optional(),
|
|
959
|
+
lastUsedAt: z15.string().nullable().optional(),
|
|
960
|
+
boundApps: z15.array(z15.string()).default([])
|
|
961
|
+
});
|
|
962
|
+
var SecretCreateRequestSchema = z15.object({
|
|
963
|
+
name: z15.string().min(1).max(64).regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase letters, digits, and hyphens"),
|
|
964
|
+
value: z15.string().min(1),
|
|
965
|
+
/** Target tier (dev-mode §6). Defaults `prod`; `dev` configures a dev-tier credential. */
|
|
966
|
+
env: EnvSchema.default("prod"),
|
|
967
|
+
injection: InjectionRecipeSchema.default({ kind: "header-bearer" })
|
|
968
|
+
});
|
|
969
|
+
var SecretRotateRequestSchema = z15.object({ value: z15.string().min(1) });
|
|
970
|
+
var SecretGrantRequestSchema = z15.object({ appSlug: z15.string().min(1) });
|
|
971
|
+
|
|
972
|
+
// ../shared/src/devTokens.ts
|
|
973
|
+
import { z as z16 } from "zod";
|
|
974
|
+
function isValidDevOrigin(input) {
|
|
975
|
+
if (input.includes("*")) return false;
|
|
976
|
+
let url;
|
|
977
|
+
try {
|
|
978
|
+
url = new URL(input);
|
|
979
|
+
} catch {
|
|
980
|
+
return false;
|
|
981
|
+
}
|
|
982
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
983
|
+
if (url.pathname !== "/" || url.search !== "" || url.hash !== "") return false;
|
|
984
|
+
if (url.username !== "" || url.password !== "") return false;
|
|
985
|
+
return input === url.origin || input === `${url.origin}/`;
|
|
986
|
+
}
|
|
987
|
+
var DevOriginSchema = z16.string().refine(
|
|
988
|
+
isValidDevOrigin,
|
|
989
|
+
"must be an exact origin (scheme://host[:port]) \u2014 no path, query, or wildcard"
|
|
990
|
+
).transform((o) => new URL(o).origin);
|
|
991
|
+
var TtlDaysSchema = z16.number().int().min(1).max(365).optional();
|
|
992
|
+
var DevTokenMintRequestSchema = z16.object({
|
|
993
|
+
origins: z16.array(DevOriginSchema).min(1).max(20),
|
|
994
|
+
ttlDays: TtlDaysSchema
|
|
995
|
+
});
|
|
996
|
+
var DevTokenRotateRequestSchema = z16.object({
|
|
997
|
+
ttlDays: TtlDaysSchema
|
|
998
|
+
});
|
|
999
|
+
var DevTokenMetadataSchema = z16.object({
|
|
1000
|
+
id: z16.string(),
|
|
1001
|
+
developerOid: z16.string(),
|
|
1002
|
+
origins: z16.array(z16.string()),
|
|
1003
|
+
expiresAt: z16.string(),
|
|
1004
|
+
revokedAt: z16.string().nullable().optional(),
|
|
1005
|
+
createdAt: z16.string()
|
|
1006
|
+
});
|
|
1007
|
+
var DevTokenMintResponseSchema = z16.object({
|
|
1008
|
+
token: z16.string(),
|
|
1009
|
+
metadata: DevTokenMetadataSchema
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
// ../shared/src/instruction.ts
|
|
1013
|
+
import { z as z17 } from "zod";
|
|
1014
|
+
var INSTRUCTION_CAPABILITIES = ["fetch", "llm"];
|
|
1015
|
+
var InstructionCapabilitySchema = z17.enum(INSTRUCTION_CAPABILITIES);
|
|
1016
|
+
var AttestedInstructionSchema = z17.object({
|
|
1017
|
+
/** App the call is attributed to (registry app id). */
|
|
1018
|
+
appId: z17.string().min(1),
|
|
1019
|
+
/** Authenticated user, or the anonymous sentinel on `public` apps. */
|
|
1020
|
+
userOid: z17.string().min(1),
|
|
1021
|
+
capability: InstructionCapabilitySchema,
|
|
1022
|
+
/** The allowlisted origin the edge authorized (scheme + host + port). */
|
|
1023
|
+
origin: z17.url(),
|
|
1024
|
+
/** Connection (secret) name to inject, if this is a secret-backed call. */
|
|
1025
|
+
connection: z17.string().min(1).optional(),
|
|
1026
|
+
/** Correlates the edge audit row with the egress call. */
|
|
1027
|
+
requestId: z17.string().min(1),
|
|
1028
|
+
/**
|
|
1029
|
+
* The HTTP method + URL pathname the edge authorized (ADR-0013 step 2, issue #6).
|
|
1030
|
+
* Egress refuses a mismatched verb/resource, so a captured instruction can't be
|
|
1031
|
+
* redirected to a different request on the same origin (origin is already bound
|
|
1032
|
+
* above; the `jti` burn already blocks replay — this closes the residual
|
|
1033
|
+
* same-origin-different-request gap). Bound to `pathname` only (not query): the
|
|
1034
|
+
* app controls the query, and origin + jti already constrain the call.
|
|
1035
|
+
*
|
|
1036
|
+
* Optional for rollout safety (like `env`): within one instance edge+egress
|
|
1037
|
+
* deploy together and instructions live 30 s, but a rolling restart may briefly
|
|
1038
|
+
* verify an old-edge token that lacks these. Only the edge can sign, so absence
|
|
1039
|
+
* means "old edge", not tampering — egress asserts ONLY when the claim is
|
|
1040
|
+
* present. Make required once a fleet is reliably past deploy.
|
|
1041
|
+
*/
|
|
1042
|
+
method: z17.string().min(1).optional(),
|
|
1043
|
+
path: z17.string().optional(),
|
|
1044
|
+
/**
|
|
1045
|
+
* Environment tier this call is scoped to (dev-mode design §6). Egress resolves
|
|
1046
|
+
* the connection secret within this tier — a `dev` instruction can never reach a
|
|
1047
|
+
* `prod` connection secret and vice-versa. Carried by the attested (signed)
|
|
1048
|
+
* claim, never an app/request parameter; defaults `prod` so any instruction
|
|
1049
|
+
* minted before this field existed verifies as production.
|
|
1050
|
+
*/
|
|
1051
|
+
env: EnvSchema.default("prod")
|
|
1052
|
+
});
|
|
1053
|
+
var INSTRUCTION_TTL_SECONDS = 30;
|
|
1054
|
+
var INSTRUCTION_BURN_RETENTION_SECONDS = INSTRUCTION_TTL_SECONDS + 15;
|
|
1055
|
+
|
|
1056
|
+
// ../shared/src/fetch.ts
|
|
1057
|
+
import { z as z18 } from "zod";
|
|
1058
|
+
var FETCH_ERROR_CODES = [
|
|
1059
|
+
"forbidden",
|
|
1060
|
+
"rate_limited",
|
|
1061
|
+
"bad_target",
|
|
1062
|
+
"blocked",
|
|
1063
|
+
"too_large",
|
|
1064
|
+
"replay",
|
|
1065
|
+
"upstream_error"
|
|
1066
|
+
];
|
|
1067
|
+
var FetchErrorCodeSchema = z18.enum(FETCH_ERROR_CODES);
|
|
1068
|
+
var FetchProxyErrorSchema = z18.object({
|
|
1069
|
+
code: FetchErrorCodeSchema,
|
|
1070
|
+
message: z18.string()
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
// src/client.ts
|
|
1074
|
+
var CliError = class extends Error {
|
|
1075
|
+
code;
|
|
1076
|
+
constructor(message, code) {
|
|
1077
|
+
super(message);
|
|
1078
|
+
this.name = "CliError";
|
|
1079
|
+
this.code = code;
|
|
1080
|
+
}
|
|
1081
|
+
};
|
|
1082
|
+
var VersionListSchema = z19.array(VersionSchema);
|
|
1083
|
+
var PortalClient = class {
|
|
1084
|
+
#baseUrl;
|
|
1085
|
+
#tokenProvider;
|
|
1086
|
+
/** `token` may be a static string (dev token) or an async provider (OIDC). */
|
|
1087
|
+
constructor(baseUrl, token) {
|
|
1088
|
+
this.#baseUrl = baseUrl.replace(/\/+$/, "");
|
|
1089
|
+
this.#tokenProvider = typeof token === "string" ? async () => token : token;
|
|
1090
|
+
}
|
|
1091
|
+
createApp(input) {
|
|
1092
|
+
return this.#json(AppSchema, "POST", "/api/v1/apps", { auth: true, body: input });
|
|
1093
|
+
}
|
|
1094
|
+
/** Public IdP discovery info — how `helix login` finds the issuer. */
|
|
1095
|
+
getAuthConfig() {
|
|
1096
|
+
return this.#json(AuthConfigResponseSchema, "GET", "/api/v1/auth/config", {});
|
|
1097
|
+
}
|
|
1098
|
+
/** The authenticated actor, per the portal — powers `helix whoami`. */
|
|
1099
|
+
me() {
|
|
1100
|
+
return this.#json(PortalMeResponseSchema, "GET", "/api/v1/me", { auth: true });
|
|
1101
|
+
}
|
|
1102
|
+
listVersions(slug) {
|
|
1103
|
+
return this.#json(VersionListSchema, "GET", `/api/v1/apps/${enc(slug)}/versions`, {
|
|
1104
|
+
auth: true
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
promote(slug, number) {
|
|
1108
|
+
return this.#json(AppSchema, "POST", `/api/v1/apps/${enc(slug)}/versions/${number}/promote`, {
|
|
1109
|
+
auth: true
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
rollback(slug, toNumber) {
|
|
1113
|
+
return this.#json(AppSchema, "POST", `/api/v1/apps/${enc(slug)}/rollback`, {
|
|
1114
|
+
auth: true,
|
|
1115
|
+
body: toNumber !== void 0 ? { toNumber } : {}
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
async uploadVersion(slug, zip, filename = "bundle.zip") {
|
|
1119
|
+
const form = new FormData();
|
|
1120
|
+
form.append("bundle", new Blob([new Uint8Array(zip)]), filename);
|
|
1121
|
+
const res = await fetch(this.#url(`/api/v1/apps/${enc(slug)}/versions`), {
|
|
1122
|
+
method: "POST",
|
|
1123
|
+
headers: await this.#authHeaders(true),
|
|
1124
|
+
body: form
|
|
1125
|
+
});
|
|
1126
|
+
return this.#parse(UploadVersionResponseSchema, res);
|
|
1127
|
+
}
|
|
1128
|
+
#url(path3) {
|
|
1129
|
+
return `${this.#baseUrl}${path3}`;
|
|
1130
|
+
}
|
|
1131
|
+
async #authHeaders(auth) {
|
|
1132
|
+
if (!auth) return {};
|
|
1133
|
+
const token = await this.#tokenProvider?.();
|
|
1134
|
+
if (!token) {
|
|
1135
|
+
throw new CliError("not signed in; run `helix login` (or set HELIX_TOKEN / pass --token)");
|
|
1136
|
+
}
|
|
1137
|
+
return { authorization: `Bearer ${token}` };
|
|
1138
|
+
}
|
|
1139
|
+
async #json(schema, method, path3, opts) {
|
|
1140
|
+
const headers = await this.#authHeaders(opts.auth ?? false);
|
|
1141
|
+
if (opts.body !== void 0) headers["content-type"] = "application/json";
|
|
1142
|
+
const res = await fetch(this.#url(path3), {
|
|
1143
|
+
method,
|
|
1144
|
+
headers,
|
|
1145
|
+
body: opts.body !== void 0 ? JSON.stringify(opts.body) : void 0
|
|
1146
|
+
});
|
|
1147
|
+
return this.#parse(schema, res);
|
|
1148
|
+
}
|
|
1149
|
+
async #parse(schema, res) {
|
|
1150
|
+
const text = await res.text();
|
|
1151
|
+
const data = text ? JSON.parse(text) : void 0;
|
|
1152
|
+
if (!res.ok) {
|
|
1153
|
+
const parsed = ApiErrorSchema.safeParse(data);
|
|
1154
|
+
if (parsed.success) throw new CliError(parsed.data.error.message, parsed.data.error.code);
|
|
1155
|
+
throw new CliError(`request failed (HTTP ${res.status})`);
|
|
1156
|
+
}
|
|
1157
|
+
return schema.parse(data);
|
|
1158
|
+
}
|
|
1159
|
+
};
|
|
1160
|
+
function enc(segment) {
|
|
1161
|
+
return encodeURIComponent(segment);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// src/commands.ts
|
|
1165
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
1166
|
+
|
|
1167
|
+
// src/zip.ts
|
|
1168
|
+
import { ZipArchive } from "archiver";
|
|
1169
|
+
function zipDirectory(dir) {
|
|
1170
|
+
return new Promise((resolve, reject) => {
|
|
1171
|
+
const chunks = [];
|
|
1172
|
+
const archive = new ZipArchive({ zlib: { level: 9 } });
|
|
1173
|
+
archive.on("data", (c) => chunks.push(c));
|
|
1174
|
+
archive.on("error", reject);
|
|
1175
|
+
archive.on("end", () => resolve(Buffer.concat(chunks)));
|
|
1176
|
+
archive.directory(dir, false);
|
|
1177
|
+
void archive.finalize();
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// src/auth/deviceFlow.ts
|
|
1182
|
+
import * as oidc from "openid-client";
|
|
1183
|
+
var DEVICE_SCOPES = "openid profile email offline_access";
|
|
1184
|
+
function deviceScopes(audience) {
|
|
1185
|
+
const apiScope = portalApiScope(audience);
|
|
1186
|
+
return apiScope ? `${DEVICE_SCOPES} ${apiScope}` : DEVICE_SCOPES;
|
|
1187
|
+
}
|
|
1188
|
+
async function discover(issuer, clientId) {
|
|
1189
|
+
const url = new URL(issuer);
|
|
1190
|
+
return oidc.discovery(
|
|
1191
|
+
url,
|
|
1192
|
+
clientId,
|
|
1193
|
+
void 0,
|
|
1194
|
+
void 0,
|
|
1195
|
+
// The local dev IdP is plain http on localhost; real issuers are https.
|
|
1196
|
+
url.protocol === "http:" ? { execute: [oidc.allowInsecureRequests] } : void 0
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
function toStoredTokens(tokens, clientId) {
|
|
1200
|
+
if (!tokens.access_token) throw new Error("token response carried no access token");
|
|
1201
|
+
return {
|
|
1202
|
+
accessToken: tokens.access_token,
|
|
1203
|
+
refreshToken: tokens.refresh_token,
|
|
1204
|
+
expiresAt: Date.now() + (tokens.expires_in ?? 300) * 1e3,
|
|
1205
|
+
clientId
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
async function runDeviceLogin(opts) {
|
|
1209
|
+
const config = await discover(opts.issuer, opts.clientId);
|
|
1210
|
+
const handle = await oidc.initiateDeviceAuthorization(config, {
|
|
1211
|
+
scope: deviceScopes(opts.audience)
|
|
1212
|
+
});
|
|
1213
|
+
opts.log("To sign in, open this URL in a browser:");
|
|
1214
|
+
opts.log(` ${handle.verification_uri_complete ?? handle.verification_uri}`);
|
|
1215
|
+
opts.log(`and confirm the code: ${handle.user_code}`);
|
|
1216
|
+
opts.log("Waiting for approval\u2026");
|
|
1217
|
+
const tokens = await oidc.pollDeviceAuthorizationGrant(config, handle);
|
|
1218
|
+
return toStoredTokens(tokens, opts.clientId);
|
|
1219
|
+
}
|
|
1220
|
+
async function refreshGrant(issuer, clientId, refreshToken) {
|
|
1221
|
+
const config = await discover(issuer, clientId);
|
|
1222
|
+
const tokens = await oidc.refreshTokenGrant(config, refreshToken);
|
|
1223
|
+
const stored = toStoredTokens(tokens, clientId);
|
|
1224
|
+
return { ...stored, refreshToken: stored.refreshToken ?? refreshToken };
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// src/auth/tokenStore.ts
|
|
1228
|
+
import { mkdir, readFile, rename, writeFile, chmod, rm } from "node:fs/promises";
|
|
1229
|
+
import { randomBytes } from "node:crypto";
|
|
1230
|
+
import os from "node:os";
|
|
1231
|
+
import path from "node:path";
|
|
1232
|
+
function portalOrigin(portalUrl) {
|
|
1233
|
+
return new URL(portalUrl).origin;
|
|
1234
|
+
}
|
|
1235
|
+
function defaultTokenPath(env = process.env) {
|
|
1236
|
+
const base = env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
1237
|
+
return path.join(base, "helix", "tokens.json");
|
|
1238
|
+
}
|
|
1239
|
+
async function readFileTolerant(file) {
|
|
1240
|
+
const empty = { version: 2, byPortal: {} };
|
|
1241
|
+
try {
|
|
1242
|
+
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
1243
|
+
if (parsed.version !== 2 || typeof parsed.byPortal !== "object" || !parsed.byPortal) {
|
|
1244
|
+
return empty;
|
|
1245
|
+
}
|
|
1246
|
+
return parsed;
|
|
1247
|
+
} catch {
|
|
1248
|
+
return empty;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
async function readTokens(key, file = defaultTokenPath()) {
|
|
1252
|
+
const data = await readFileTolerant(file);
|
|
1253
|
+
const entry = data.byPortal[portalOrigin(key.portalUrl)];
|
|
1254
|
+
if (!entry || entry.issuer !== key.issuer) return void 0;
|
|
1255
|
+
const tokens = entry.tokens;
|
|
1256
|
+
if (!tokens || typeof tokens.accessToken !== "string" || typeof tokens.expiresAt !== "number") {
|
|
1257
|
+
return void 0;
|
|
1258
|
+
}
|
|
1259
|
+
return tokens;
|
|
1260
|
+
}
|
|
1261
|
+
async function writeFile0600(file, content) {
|
|
1262
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 448 });
|
|
1263
|
+
const tmp = `${file}.${randomBytes(4).toString("hex")}.tmp`;
|
|
1264
|
+
await writeFile(tmp, content, { mode: 384 });
|
|
1265
|
+
await rename(tmp, file);
|
|
1266
|
+
await chmod(file, 384);
|
|
1267
|
+
}
|
|
1268
|
+
async function writeTokens(key, tokens, file = defaultTokenPath(), opts = {}) {
|
|
1269
|
+
const data = await readFileTolerant(file);
|
|
1270
|
+
data.byPortal[portalOrigin(key.portalUrl)] = {
|
|
1271
|
+
issuer: key.issuer,
|
|
1272
|
+
...opts.audience ? { audience: opts.audience } : {},
|
|
1273
|
+
tokens
|
|
1274
|
+
};
|
|
1275
|
+
await writeFile0600(file, JSON.stringify(data, null, 2));
|
|
1276
|
+
}
|
|
1277
|
+
async function deleteTokens(portalUrl, file = defaultTokenPath()) {
|
|
1278
|
+
const data = await readFileTolerant(file);
|
|
1279
|
+
const origin = portalOrigin(portalUrl);
|
|
1280
|
+
if (!(origin in data.byPortal)) return false;
|
|
1281
|
+
delete data.byPortal[origin];
|
|
1282
|
+
if (Object.keys(data.byPortal).length === 0) {
|
|
1283
|
+
await rm(file, { force: true });
|
|
1284
|
+
} else {
|
|
1285
|
+
await writeFile0600(file, JSON.stringify(data, null, 2));
|
|
1286
|
+
}
|
|
1287
|
+
return true;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// src/commands.ts
|
|
1291
|
+
function requireSlug(config) {
|
|
1292
|
+
if (!config.slug) {
|
|
1293
|
+
throw new CliError("no app slug; set it in helix.json or pass --slug");
|
|
1294
|
+
}
|
|
1295
|
+
return config.slug;
|
|
1296
|
+
}
|
|
1297
|
+
function parseVisibility(input) {
|
|
1298
|
+
if (!input) return void 0;
|
|
1299
|
+
if (input.startsWith("group:")) {
|
|
1300
|
+
const groupId = input.slice("group:".length);
|
|
1301
|
+
if (!groupId) throw new CliError("group visibility needs an id: group:<id>");
|
|
1302
|
+
return { mode: "group", groupId };
|
|
1303
|
+
}
|
|
1304
|
+
if (input === "internal" || input === "password" || input === "public") {
|
|
1305
|
+
return { mode: input };
|
|
1306
|
+
}
|
|
1307
|
+
if (input === "private") {
|
|
1308
|
+
throw new CliError(
|
|
1309
|
+
'visibility "private" was renamed to "internal" (the mode never checked which user signed in, only that someone had). Use --visibility internal; the name "private" is reserved for a future owner-only mode, so it is not accepted as an alias.'
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
throw new CliError(`invalid visibility "${input}" (internal | group:<id> | password | public)`);
|
|
1313
|
+
}
|
|
1314
|
+
function printVersion(v) {
|
|
1315
|
+
console.log(` version ${v.number} (${v.status}) \u2014 ${v.id}`);
|
|
1316
|
+
console.log(` assets: ${v.blobPrefix}`);
|
|
1317
|
+
}
|
|
1318
|
+
function printApp(app) {
|
|
1319
|
+
console.log(` app ${app.slug} \u2014 live version: ${app.currentVersionId ?? "(none)"}`);
|
|
1320
|
+
}
|
|
1321
|
+
async function createCommand(client, config, opts) {
|
|
1322
|
+
const slug = requireSlug(config);
|
|
1323
|
+
const app = await client.createApp({
|
|
1324
|
+
slug,
|
|
1325
|
+
displayName: opts.displayName ?? slug,
|
|
1326
|
+
visibility: parseVisibility(opts.visibility)
|
|
1327
|
+
});
|
|
1328
|
+
console.log(`Created app "${app.slug}".`);
|
|
1329
|
+
printApp(app);
|
|
1330
|
+
}
|
|
1331
|
+
async function deployCommand(client, config, opts) {
|
|
1332
|
+
const slug = requireSlug(config);
|
|
1333
|
+
const zip = config.bundle ? await readFile2(config.bundle) : await zipDirectory(config.dir);
|
|
1334
|
+
console.log(`Uploading bundle to "${slug}"\u2026`);
|
|
1335
|
+
const { version, warnings } = await client.uploadVersion(slug, zip);
|
|
1336
|
+
console.log(`Uploaded as preview:`);
|
|
1337
|
+
printVersion(version);
|
|
1338
|
+
if (warnings.length > 0) {
|
|
1339
|
+
console.log(`
|
|
1340
|
+
${warnings.length} CSP warning(s):`);
|
|
1341
|
+
for (const w of warnings) console.log(` - ${w.file}: ${w.hint}`);
|
|
1342
|
+
}
|
|
1343
|
+
if (opts.promote) {
|
|
1344
|
+
const app = await client.promote(slug, version.number);
|
|
1345
|
+
console.log(`
|
|
1346
|
+
Promoted version ${version.number} to live.`);
|
|
1347
|
+
printApp(app);
|
|
1348
|
+
} else {
|
|
1349
|
+
console.log(`
|
|
1350
|
+
Not live yet \u2014 promote with: helix promote ${version.number}`);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
async function versionsCommand(client, config) {
|
|
1354
|
+
const slug = requireSlug(config);
|
|
1355
|
+
const versions = await client.listVersions(slug);
|
|
1356
|
+
if (versions.length === 0) {
|
|
1357
|
+
console.log(`No versions for "${slug}".`);
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
console.log(`Versions for "${slug}" (newest first):`);
|
|
1361
|
+
for (const v of versions) console.log(` ${v.number} ${v.status} ${v.id}`);
|
|
1362
|
+
}
|
|
1363
|
+
async function promoteCommand(client, config, number) {
|
|
1364
|
+
const slug = requireSlug(config);
|
|
1365
|
+
const app = await client.promote(slug, number);
|
|
1366
|
+
console.log(`Promoted version ${number} to live.`);
|
|
1367
|
+
printApp(app);
|
|
1368
|
+
}
|
|
1369
|
+
async function rollbackCommand(client, config, toNumber) {
|
|
1370
|
+
const slug = requireSlug(config);
|
|
1371
|
+
const app = await client.rollback(slug, toNumber);
|
|
1372
|
+
console.log(
|
|
1373
|
+
toNumber ? `Rolled back to version ${toNumber}.` : `Rolled back to previous version.`
|
|
1374
|
+
);
|
|
1375
|
+
printApp(app);
|
|
1376
|
+
}
|
|
1377
|
+
async function loginCommand(client, config) {
|
|
1378
|
+
const { issuer, cliClientId, audience } = await client.getAuthConfig();
|
|
1379
|
+
const tokens = await runDeviceLogin({
|
|
1380
|
+
issuer,
|
|
1381
|
+
clientId: cliClientId,
|
|
1382
|
+
audience,
|
|
1383
|
+
log: console.log
|
|
1384
|
+
});
|
|
1385
|
+
await writeTokens({ portalUrl: config.portalUrl, issuer }, tokens, void 0, { audience });
|
|
1386
|
+
const authed = new PortalClient(config.portalUrl, tokens.accessToken);
|
|
1387
|
+
const me = await authed.me();
|
|
1388
|
+
console.log(`Logged in as ${me.name ?? me.sub} (${me.sub}).`);
|
|
1389
|
+
}
|
|
1390
|
+
async function logoutCommand(config) {
|
|
1391
|
+
const forgot = await deleteTokens(config.portalUrl);
|
|
1392
|
+
console.log(forgot ? "Logged out (local tokens forgotten)." : "Already logged out.");
|
|
1393
|
+
}
|
|
1394
|
+
async function whoamiCommand(client) {
|
|
1395
|
+
const me = await client.me();
|
|
1396
|
+
console.log(`${me.sub} (via ${me.via}${me.name ? `, ${me.name}` : ""})`);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// src/auth/session.ts
|
|
1400
|
+
var REFRESH_MARGIN_MS = 6e4;
|
|
1401
|
+
async function fetchAuthConfig(portalUrl) {
|
|
1402
|
+
const res = await fetch(`${portalUrl.replace(/\/+$/, "")}/api/v1/auth/config`);
|
|
1403
|
+
if (!res.ok) {
|
|
1404
|
+
throw new Error(
|
|
1405
|
+
`portal has no OIDC configured (GET /api/v1/auth/config \u2192 ${res.status}); use HELIX_TOKEN / --token instead`
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
return AuthConfigResponseSchema.parse(await res.json());
|
|
1409
|
+
}
|
|
1410
|
+
var defaultDeps = {
|
|
1411
|
+
getAuthConfig: fetchAuthConfig,
|
|
1412
|
+
refresh: refreshGrant,
|
|
1413
|
+
storePath: defaultTokenPath()
|
|
1414
|
+
};
|
|
1415
|
+
function makeTokenProvider(opts, deps = defaultDeps) {
|
|
1416
|
+
return async function getAccessToken() {
|
|
1417
|
+
if (opts.staticToken) return opts.staticToken;
|
|
1418
|
+
const { issuer, cliClientId } = await deps.getAuthConfig(opts.portalUrl);
|
|
1419
|
+
const key = { portalUrl: opts.portalUrl, issuer };
|
|
1420
|
+
const stored = await readTokens(key, deps.storePath);
|
|
1421
|
+
if (!stored) return void 0;
|
|
1422
|
+
if (stored.expiresAt - REFRESH_MARGIN_MS > Date.now()) {
|
|
1423
|
+
return stored.accessToken;
|
|
1424
|
+
}
|
|
1425
|
+
if (!stored.refreshToken) return void 0;
|
|
1426
|
+
try {
|
|
1427
|
+
const renewed = await deps.refresh(
|
|
1428
|
+
issuer,
|
|
1429
|
+
stored.clientId || cliClientId,
|
|
1430
|
+
stored.refreshToken
|
|
1431
|
+
);
|
|
1432
|
+
await writeTokens(key, renewed, deps.storePath);
|
|
1433
|
+
return renewed.accessToken;
|
|
1434
|
+
} catch {
|
|
1435
|
+
return void 0;
|
|
1436
|
+
}
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// src/config.ts
|
|
1441
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
1442
|
+
import path2 from "node:path";
|
|
1443
|
+
var DEFAULT_PORTAL_URL = "http://localhost:3001";
|
|
1444
|
+
var DEFAULT_DIR = "dist";
|
|
1445
|
+
var CONFIG_FILENAMES = ["helix.json", "azx.json"];
|
|
1446
|
+
async function readConfigFile(cwd) {
|
|
1447
|
+
for (const name of CONFIG_FILENAMES) {
|
|
1448
|
+
try {
|
|
1449
|
+
return JSON.parse(await readFile3(path2.join(cwd, name), "utf8"));
|
|
1450
|
+
} catch (err) {
|
|
1451
|
+
if (err.code === "ENOENT") continue;
|
|
1452
|
+
throw err;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
return {};
|
|
1456
|
+
}
|
|
1457
|
+
async function resolveConfig(flags, env = process.env, cwd = process.cwd()) {
|
|
1458
|
+
const file = await readConfigFile(cwd);
|
|
1459
|
+
return {
|
|
1460
|
+
portalUrl: flags.portalUrl ?? env.HELIX_PORTAL_URL ?? env.AZX_PORTAL_URL ?? file.portalUrl ?? DEFAULT_PORTAL_URL,
|
|
1461
|
+
token: flags.token ?? env.HELIX_TOKEN ?? env.AZX_TOKEN,
|
|
1462
|
+
slug: flags.slug ?? file.slug,
|
|
1463
|
+
dir: flags.dir ?? file.dir ?? DEFAULT_DIR,
|
|
1464
|
+
bundle: flags.bundle
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// src/bin.ts
|
|
1469
|
+
var USAGE = `helix \u2014 Helix deploy CLI
|
|
1470
|
+
|
|
1471
|
+
Usage:
|
|
1472
|
+
helix login # sign in via the browser (OIDC device flow)
|
|
1473
|
+
helix logout
|
|
1474
|
+
helix whoami
|
|
1475
|
+
helix deploy [--dir <dir>] [--bundle <zip>] [--promote]
|
|
1476
|
+
helix create [--display-name <name>] [--visibility <v>]
|
|
1477
|
+
helix versions
|
|
1478
|
+
helix promote <number>
|
|
1479
|
+
helix rollback [number]
|
|
1480
|
+
|
|
1481
|
+
Common flags: --slug <slug> --portal-url <url> --token <token>
|
|
1482
|
+
Env: HELIX_PORTAL_URL, HELIX_TOKEN (static token \u2014 skips login; CI/scripts).
|
|
1483
|
+
Config file: helix.json { slug, portalUrl, dir }
|
|
1484
|
+
Visibility: internal | group:<id> | password | public
|
|
1485
|
+
`;
|
|
1486
|
+
async function main() {
|
|
1487
|
+
const { values, positionals } = parseCliArgs(process.argv.slice(2));
|
|
1488
|
+
const command = positionals[0];
|
|
1489
|
+
if (!command || values.help) {
|
|
1490
|
+
console.log(USAGE);
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
const config = await resolveConfig({
|
|
1494
|
+
slug: values.slug,
|
|
1495
|
+
portalUrl: values["portal-url"],
|
|
1496
|
+
dir: values.dir,
|
|
1497
|
+
bundle: values.bundle,
|
|
1498
|
+
token: values.token
|
|
1499
|
+
});
|
|
1500
|
+
const client = new PortalClient(
|
|
1501
|
+
config.portalUrl,
|
|
1502
|
+
makeTokenProvider({ portalUrl: config.portalUrl, staticToken: config.token })
|
|
1503
|
+
);
|
|
1504
|
+
switch (command) {
|
|
1505
|
+
case "login":
|
|
1506
|
+
await loginCommand(client, config);
|
|
1507
|
+
break;
|
|
1508
|
+
case "logout":
|
|
1509
|
+
await logoutCommand(config);
|
|
1510
|
+
break;
|
|
1511
|
+
case "whoami":
|
|
1512
|
+
await whoamiCommand(client);
|
|
1513
|
+
break;
|
|
1514
|
+
case "deploy":
|
|
1515
|
+
await deployCommand(client, config, { promote: values.promote });
|
|
1516
|
+
break;
|
|
1517
|
+
case "create":
|
|
1518
|
+
await createCommand(client, config, {
|
|
1519
|
+
displayName: values["display-name"],
|
|
1520
|
+
visibility: values.visibility
|
|
1521
|
+
});
|
|
1522
|
+
break;
|
|
1523
|
+
case "versions":
|
|
1524
|
+
await versionsCommand(client, config);
|
|
1525
|
+
break;
|
|
1526
|
+
case "promote": {
|
|
1527
|
+
const number = Number(positionals[1]);
|
|
1528
|
+
if (!Number.isInteger(number)) throw new CliError("usage: helix promote <number>");
|
|
1529
|
+
await promoteCommand(client, config, number);
|
|
1530
|
+
break;
|
|
1531
|
+
}
|
|
1532
|
+
case "rollback": {
|
|
1533
|
+
const number = positionals[1] !== void 0 ? Number(positionals[1]) : void 0;
|
|
1534
|
+
if (number !== void 0 && !Number.isInteger(number)) {
|
|
1535
|
+
throw new CliError("usage: helix rollback [number]");
|
|
1536
|
+
}
|
|
1537
|
+
await rollbackCommand(client, config, number);
|
|
1538
|
+
break;
|
|
1539
|
+
}
|
|
1540
|
+
default:
|
|
1541
|
+
throw new CliError(`unknown command "${command}"
|
|
1542
|
+
|
|
1543
|
+
${USAGE}`);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
main().catch((err) => {
|
|
1547
|
+
if (err instanceof CliError) {
|
|
1548
|
+
console.error(`error: ${err.message}${err.code ? ` (${err.code})` : ""}`);
|
|
1549
|
+
} else {
|
|
1550
|
+
console.error(err);
|
|
1551
|
+
}
|
|
1552
|
+
process.exitCode = 1;
|
|
1553
|
+
});
|