@alashi/cli 0.11.0 → 0.13.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.
@@ -1,16 +1,352 @@
1
- import { APP_NAME_PATTERN, AppSdkError, ConfigError, JsonFileStore, SDK_VERSION, evaluateAppConfig, interpolateConfig, manifestConfigDefaults, mergeConfig, parseAgentFile, parseManifest } from "@alashi/apps-sdk";
1
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import { deleteCookie, getCookie, setCookie } from "hono/cookie";
3
+ import { APP_NAME_PATTERN, AppSdkError, ConfigError, JsonFileStore, SDK_VERSION, evaluateAppConfig, interpolateConfig, manifestConfigDefaults, mergeConfig, parseAgentFile, parseManifestWithWarnings } from "@alashi/apps-sdk";
2
4
  import semver from "semver";
3
5
  import { execFile } from "node:child_process";
4
- import { randomUUID } from "node:crypto";
5
6
  import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
6
7
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
8
  import { promisify } from "node:util";
8
9
  import { fileURLToPath, pathToFileURL } from "node:url";
9
10
  import { z } from "zod";
11
+ import { getConnInfo } from "@hono/node-server/conninfo";
10
12
  import { Hono } from "hono";
11
13
  import { streamSSE } from "hono/streaming";
12
14
  import { Cron } from "croner";
13
15
  import { homedir } from "node:os";
16
+ //#region src/auth.ts
17
+ /** Session cookie lifetime. Fixed, not sliding: re-login once a month. */
18
+ const SESSION_TTL_MS = 2592e6;
19
+ const SESSION_COOKIE = "alashi_session";
20
+ /** Recognizable in `Authorization: Bearer ...` and in shell history. */
21
+ const TOKEN_PREFIX = "alashi_tok_";
22
+ function sha256(value) {
23
+ return createHash("sha256").update(value).digest("hex");
24
+ }
25
+ /** Constant-time compare of two hex digests of equal length. */
26
+ function hashEquals(a, b) {
27
+ const bufA = Buffer.from(a, "hex");
28
+ const bufB = Buffer.from(b, "hex");
29
+ return bufA.length === bufB.length && timingSafeEqual(bufA, bufB);
30
+ }
31
+ const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
32
+ "localhost",
33
+ "127.0.0.1",
34
+ "::1"
35
+ ]);
36
+ function isLoopbackAddress(addr) {
37
+ const bare = addr.startsWith("::ffff:") ? addr.slice(7) : addr;
38
+ return bare === "::1" || bare.startsWith("127.");
39
+ }
40
+ const FORWARDING_HEADERS = [
41
+ "x-forwarded-for",
42
+ "x-forwarded-host",
43
+ "x-forwarded-proto",
44
+ "x-real-ip",
45
+ "forwarded"
46
+ ];
47
+ /** True when the request carries any reverse-proxy forwarding header. */
48
+ function hasForwardingHeaders(headers) {
49
+ return FORWARDING_HEADERS.some((name) => headers.get(name) !== null);
50
+ }
51
+ /**
52
+ * The console heuristic: is this request verifiably from the local machine?
53
+ *
54
+ * Requires all three of: a loopback peer address, a local request hostname,
55
+ * and no forwarding headers — because tunnels (ngrok, cloudflared, `tailscale
56
+ * serve`) connect from loopback too, and what gives them away is the forwarded
57
+ * hostname or the forwarding headers they add. A proxy configured to strip
58
+ * both is indistinguishable from a local request; `mode: "required"` exists
59
+ * for anyone deliberately exposing the host.
60
+ *
61
+ * `hostname` is the request URL's hostname (which the server derives from the
62
+ * Host header) rather than the raw header, because in-process requests carry
63
+ * no Host header at all. `remoteAddress === undefined` means an in-process
64
+ * request (tests, the host calling itself) — there is no socket, so it is by
65
+ * definition local.
66
+ */
67
+ function isConsoleRequest(hostname, headers, remoteAddress) {
68
+ if (remoteAddress !== void 0 && !isLoopbackAddress(remoteAddress)) return false;
69
+ if (hasForwardingHeaders(headers)) return false;
70
+ return LOOPBACK_HOSTS.has(hostname.toLowerCase().replace(/^\[|\]$/g, ""));
71
+ }
72
+ /**
73
+ * The access level a path demands. Deny-by-default shape: everything is at
74
+ * least `user` unless it is explicitly public, and the routes that change
75
+ * host state or spend-relevant settings (config, budgets, manual job runs)
76
+ * are `owner`.
77
+ */
78
+ function requiredLevel(path, publicApps) {
79
+ if (path === "/login" || path === "/logout" || path === "/api/me" || path === "/favicon.ico") return "public";
80
+ if (path === "/assets" || path.startsWith("/assets/")) return "public";
81
+ if (path.startsWith("/app/")) {
82
+ const name = path.slice(5).split("/", 1)[0] ?? "";
83
+ return publicApps.includes(name) ? "public" : "user";
84
+ }
85
+ if (path.startsWith("/settings/")) return "owner";
86
+ if (/^\/api\/apps\/[^/]+\/(config|budget)$/.test(path)) return "owner";
87
+ if (/^\/api\/apps\/[^/]+\/jobs\/[^/]+\/run$/.test(path)) return "owner";
88
+ return "user";
89
+ }
90
+ function meetsLevel(identity, required) {
91
+ return required !== "owner" || identity.level === "owner";
92
+ }
93
+ /**
94
+ * Host authentication: bearer tokens and browser sessions persisted in
95
+ * `auth.json` (hashes only), plus an in-memory one-time boot login token.
96
+ * Writes are serialized so concurrent logins cannot lose each other's update.
97
+ */
98
+ var AuthService = class {
99
+ store;
100
+ queue = Promise.resolve();
101
+ bootHash;
102
+ constructor(store) {
103
+ this.store = store;
104
+ }
105
+ /**
106
+ * Mints the one-time login secret printed at serve startup. In-memory only
107
+ * and consumed by the first successful login; restart the host (or mint a
108
+ * real token) to get another.
109
+ */
110
+ issueBootToken() {
111
+ const secret = `${TOKEN_PREFIX}${randomBytes(24).toString("hex")}`;
112
+ this.bootHash = sha256(secret);
113
+ return secret;
114
+ }
115
+ /** Creates a persistent owner bearer token; the secret is only returned here. */
116
+ async mintToken(label) {
117
+ const secret = `${TOKEN_PREFIX}${randomBytes(24).toString("hex")}`;
118
+ const record = {
119
+ id: randomBytes(6).toString("hex"),
120
+ ...label !== void 0 && label !== "" && { label },
121
+ hash: sha256(secret),
122
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
123
+ };
124
+ await this.mutate((state) => {
125
+ state.tokens.push(record);
126
+ });
127
+ return {
128
+ id: record.id,
129
+ secret
130
+ };
131
+ }
132
+ async listTokens() {
133
+ const { tokens } = await this.store.readAuthState();
134
+ return tokens.map(({ hash: _hash, ...info }) => info);
135
+ }
136
+ /** Revokes a token and every session it created is left alone: sessions expire on their own. */
137
+ async revokeToken(id) {
138
+ let found = false;
139
+ await this.mutate((state) => {
140
+ const before = state.tokens.length;
141
+ state.tokens = state.tokens.filter((token) => token.id !== id);
142
+ found = state.tokens.length !== before;
143
+ });
144
+ return found;
145
+ }
146
+ /**
147
+ * Exchanges a login secret (boot token or minted token) for an identity.
148
+ * A boot token is consumed by success; minted tokens are reusable.
149
+ */
150
+ async redeemLoginSecret(secret) {
151
+ const hash = sha256(secret);
152
+ if (this.bootHash !== void 0 && hashEquals(hash, this.bootHash)) {
153
+ this.bootHash = void 0;
154
+ return {
155
+ level: "owner",
156
+ via: "boot",
157
+ name: "owner"
158
+ };
159
+ }
160
+ return this.tokenIdentity(hash);
161
+ }
162
+ /** Resolves `Authorization: Bearer <secret>`; undefined when absent or invalid. */
163
+ async bearerIdentity(authorization) {
164
+ if (authorization === void 0 || !authorization.toLowerCase().startsWith("bearer ")) return;
165
+ return this.tokenIdentity(sha256(authorization.slice(7).trim()));
166
+ }
167
+ async tokenIdentity(hash) {
168
+ const { tokens } = await this.store.readAuthState();
169
+ const token = tokens.find((entry) => hashEquals(entry.hash, hash));
170
+ if (!token) return void 0;
171
+ this.mutate((state) => {
172
+ const entry = state.tokens.find((candidate) => candidate.id === token.id);
173
+ if (entry) entry.lastUsedAt = (/* @__PURE__ */ new Date()).toISOString();
174
+ }).catch(() => void 0);
175
+ return {
176
+ level: "owner",
177
+ via: "token",
178
+ name: token.label ?? `token ${token.id}`
179
+ };
180
+ }
181
+ /** Creates a session for an already-verified identity; returns the cookie value. */
182
+ async createSession(identity, at = /* @__PURE__ */ new Date()) {
183
+ const value = randomBytes(24).toString("hex");
184
+ const record = {
185
+ hash: sha256(value),
186
+ name: identity.name,
187
+ level: identity.level,
188
+ createdAt: at.toISOString(),
189
+ expiresAt: new Date(at.getTime() + SESSION_TTL_MS).toISOString()
190
+ };
191
+ await this.mutate((state) => {
192
+ state.sessions.push(record);
193
+ });
194
+ return value;
195
+ }
196
+ /** Resolves a session cookie value; undefined when unknown or expired. */
197
+ async sessionIdentity(value, at = /* @__PURE__ */ new Date()) {
198
+ if (value === void 0 || value === "") return void 0;
199
+ const hash = sha256(value);
200
+ const { sessions } = await this.store.readAuthState();
201
+ const session = sessions.find((entry) => hashEquals(entry.hash, hash));
202
+ if (!session || session.expiresAt <= at.toISOString()) return void 0;
203
+ return {
204
+ level: session.level,
205
+ via: "session",
206
+ name: session.name
207
+ };
208
+ }
209
+ /** Deletes the session behind a cookie value (logout). */
210
+ async destroySession(value) {
211
+ const hash = sha256(value);
212
+ await this.mutate((state) => {
213
+ state.sessions = state.sessions.filter((entry) => !hashEquals(entry.hash, hash));
214
+ });
215
+ }
216
+ /** Read-modify-write of auth.json, serialized, with expired sessions pruned. */
217
+ mutate(change) {
218
+ const next = this.queue.then(async () => {
219
+ const state = await this.store.readAuthState();
220
+ const now = (/* @__PURE__ */ new Date()).toISOString();
221
+ state.sessions = state.sessions.filter((session) => session.expiresAt > now);
222
+ change(state);
223
+ await this.store.writeAuthState(state);
224
+ });
225
+ this.queue = next.catch(() => void 0);
226
+ return next;
227
+ }
228
+ };
229
+ function wantsHtml(c) {
230
+ return c.req.method === "GET" && (c.req.header("accept") ?? "").includes("text/html");
231
+ }
232
+ /** Tailscale identity, when the config says the headers can be believed. */
233
+ function tailscaleIdentity(c, trusted) {
234
+ if (!trusted) return void 0;
235
+ const login = c.req.header("tailscale-user-login");
236
+ if (login === void 0 || login === "") return void 0;
237
+ return {
238
+ level: "user",
239
+ via: "tailscale",
240
+ name: c.req.header("tailscale-user-name") ?? login
241
+ };
242
+ }
243
+ /**
244
+ * The host's front door. Resolves an identity for every request (bearer token,
245
+ * session cookie, tailscale headers, console heuristic — in that order) and
246
+ * enforces the path's required level. Auth config is read per request so
247
+ * settings changes apply without a restart.
248
+ */
249
+ function createAuthMiddleware(store, auth, options = {}) {
250
+ let warnedProxyWhileOff = false;
251
+ return async (c, next) => {
252
+ const config = (await store.readGlobalConfig()).auth ?? {};
253
+ const mode = config.mode ?? "auto";
254
+ const required = requiredLevel(c.req.path, config.public ?? []);
255
+ if (mode === "off") {
256
+ if (!warnedProxyWhileOff && hasForwardingHeaders(c.req.raw.headers)) {
257
+ warnedProxyWhileOff = true;
258
+ console.warn("alashi: a request arrived through a proxy while auth.mode is \"off\" — this host appears to be exposed; set auth.mode to \"required\" in config.json");
259
+ }
260
+ c.set("identity", {
261
+ level: "owner",
262
+ via: "off",
263
+ name: "owner"
264
+ });
265
+ return next();
266
+ }
267
+ const identity = await auth.bearerIdentity(c.req.header("authorization")) ?? await auth.sessionIdentity(getCookie(c, "alashi_session")) ?? tailscaleIdentity(c, config.trustTailscaleHeaders === true) ?? (mode === "auto" && isConsoleRequest(new URL(c.req.url).hostname, c.req.raw.headers, options.getRemoteAddress?.(c)) ? {
268
+ level: "owner",
269
+ via: "console",
270
+ name: "owner"
271
+ } : void 0);
272
+ if (identity) c.set("identity", identity);
273
+ if (required === "public") return next();
274
+ if (identity && meetsLevel(identity, required)) return next();
275
+ if (wantsHtml(c)) return c.redirect("/login");
276
+ return c.json({ error: identity ? "This action requires the owner" : "Authentication required" }, identity ? 403 : 401);
277
+ };
278
+ }
279
+ function loginPage(error) {
280
+ return `<!doctype html>
281
+ <html lang="en">
282
+ <head>
283
+ <meta charset="utf-8">
284
+ <meta name="viewport" content="width=device-width, initial-scale=1">
285
+ <title>alashi — sign in</title>
286
+ <link rel="stylesheet" href="/assets/v1/system.css">
287
+ </head>
288
+ <body>
289
+ <header class="topbar"><strong>alashi</strong></header>
290
+ <main class="narrow">
291
+ <h1>Sign in</h1>
292
+ <div class="panel">
293
+ ${error ? "<p class=\"badge err\">Invalid or expired token</p>" : ""}
294
+ <p class="muted">Paste a login token — the one printed by <code>alashi serve</code>, or one minted with <code>alashi token create</code>.</p>
295
+ <form method="post" action="/login">
296
+ <p><input type="password" name="token" autocomplete="off" autofocus style="width:100%"></p>
297
+ <p><button type="submit">Sign in</button></p>
298
+ </form>
299
+ </div>
300
+ </main>
301
+ </body>
302
+ </html>
303
+ `;
304
+ }
305
+ function grantSession(c, value) {
306
+ setCookie(c, SESSION_COOKIE, value, {
307
+ path: "/",
308
+ httpOnly: true,
309
+ sameSite: "Lax",
310
+ maxAge: Math.floor(SESSION_TTL_MS / 1e3)
311
+ });
312
+ }
313
+ /**
314
+ * The auth surface itself (all public in the policy map):
315
+ * GET /login login form; `?token=` redeems the printed one-time URL
316
+ * POST /login { token } (form-encoded) -> session cookie + redirect /
317
+ * GET /logout destroys the session, back to /login
318
+ * GET /api/me the resolved identity, or { identity: null }
319
+ */
320
+ function registerAuthRoutes(app, auth) {
321
+ app.get("/login", async (c) => {
322
+ const token = c.req.query("token");
323
+ if (token === void 0) {
324
+ const existing = c.get("identity");
325
+ if (existing && existing.via !== "console") return c.redirect("/");
326
+ return c.html(loginPage());
327
+ }
328
+ const identity = await auth.redeemLoginSecret(token);
329
+ if (!identity) return c.html(loginPage("invalid"), 401);
330
+ grantSession(c, await auth.createSession(identity));
331
+ return c.redirect("/");
332
+ });
333
+ app.post("/login", async (c) => {
334
+ const body = await c.req.parseBody();
335
+ const token = typeof body.token === "string" ? body.token.trim() : "";
336
+ const identity = token === "" ? void 0 : await auth.redeemLoginSecret(token);
337
+ if (!identity) return c.html(loginPage("invalid"), 401);
338
+ grantSession(c, await auth.createSession(identity));
339
+ return c.redirect("/");
340
+ });
341
+ app.get("/logout", async (c) => {
342
+ const value = getCookie(c, SESSION_COOKIE);
343
+ if (value !== void 0) await auth.destroySession(value);
344
+ deleteCookie(c, SESSION_COOKIE, { path: "/" });
345
+ return c.redirect("/login");
346
+ });
347
+ app.get("/api/me", (c) => c.json({ identity: c.get("identity") ?? null }));
348
+ }
349
+ //#endregion
14
350
  //#region src/usage.ts
15
351
  /** Days of history kept in usage.json; older days are dropped on write. */
16
352
  const USAGE_RETENTION_DAYS = 90;
@@ -21,6 +357,10 @@ const DAY_MS = 864e5;
21
357
  function utcDay(at = /* @__PURE__ */ new Date()) {
22
358
  return at.toISOString().slice(0, 10);
23
359
  }
360
+ /** The UTC calendar month (`YYYY-MM`) a timestamp falls in. */
361
+ function utcMonth(at = /* @__PURE__ */ new Date()) {
362
+ return at.toISOString().slice(0, 7);
363
+ }
24
364
  function round(value) {
25
365
  return Number(value.toFixed(COST_PRECISION));
26
366
  }
@@ -28,15 +368,100 @@ function round(value) {
28
368
  function formatUsd(value) {
29
369
  return `$${value.toFixed(value >= 1 ? 2 : 4)}`;
30
370
  }
371
+ /** Merges per-app summaries into the host-wide view. */
372
+ function combineSummaries(summaries) {
373
+ const combined = {
374
+ todayCostUsd: 0,
375
+ todayTurns: 0,
376
+ totalCostUsd: 0,
377
+ todayModels: {},
378
+ models: {},
379
+ months: [],
380
+ recorded: false
381
+ };
382
+ const byMonth = /* @__PURE__ */ new Map();
383
+ for (const summary of summaries) {
384
+ combined.todayCostUsd = round(combined.todayCostUsd + summary.todayCostUsd);
385
+ combined.todayTurns += summary.todayTurns;
386
+ combined.totalCostUsd = round(combined.totalCostUsd + summary.totalCostUsd);
387
+ combined.recorded ||= summary.recorded;
388
+ mergeTotals(combined.todayModels, summary.todayModels);
389
+ mergeTotals(combined.models, summary.models);
390
+ for (const month of summary.months) {
391
+ const target = byMonth.get(month.month) ?? {
392
+ month: month.month,
393
+ costUsd: 0,
394
+ turns: 0
395
+ };
396
+ target.costUsd = round(target.costUsd + month.costUsd);
397
+ target.turns += month.turns;
398
+ if (month.models !== void 0) {
399
+ target.models ??= {};
400
+ mergeTotals(target.models, month.models);
401
+ }
402
+ byMonth.set(month.month, target);
403
+ }
404
+ }
405
+ combined.months = [...byMonth.values()].sort((a, b) => a.month.localeCompare(b.month));
406
+ return combined;
407
+ }
408
+ function emptyTotals() {
409
+ return {
410
+ inputTokens: 0,
411
+ outputTokens: 0,
412
+ cacheReadInputTokens: 0,
413
+ cacheCreationInputTokens: 0,
414
+ costUsd: 0
415
+ };
416
+ }
417
+ function count(value) {
418
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
419
+ }
420
+ function addModelUsage(models, usage) {
421
+ for (const [model, turn] of Object.entries(usage)) {
422
+ models[model] ??= emptyTotals();
423
+ const totals = models[model];
424
+ totals.inputTokens += count(turn.inputTokens);
425
+ totals.outputTokens += count(turn.outputTokens);
426
+ totals.cacheReadInputTokens += count(turn.cacheReadInputTokens);
427
+ totals.cacheCreationInputTokens += count(turn.cacheCreationInputTokens);
428
+ totals.costUsd = round(totals.costUsd + count(turn.costUsd));
429
+ }
430
+ }
431
+ function mergeTotals(into, from) {
432
+ for (const [model, totals] of Object.entries(from ?? {})) {
433
+ into[model] ??= emptyTotals();
434
+ const target = into[model];
435
+ target.inputTokens += count(totals.inputTokens);
436
+ target.outputTokens += count(totals.outputTokens);
437
+ target.cacheReadInputTokens += count(totals.cacheReadInputTokens);
438
+ target.cacheCreationInputTokens += count(totals.cacheCreationInputTokens);
439
+ target.costUsd = round(target.costUsd + count(totals.costUsd));
440
+ }
441
+ }
442
+ function sanitizeModels(models) {
443
+ if (models === void 0) return void 0;
444
+ const clean = {};
445
+ for (const [model, totals] of Object.entries(models)) clean[model] = {
446
+ inputTokens: count(totals?.inputTokens),
447
+ outputTokens: count(totals?.outputTokens),
448
+ cacheReadInputTokens: count(totals?.cacheReadInputTokens),
449
+ cacheCreationInputTokens: count(totals?.cacheCreationInputTokens),
450
+ costUsd: round(count(totals?.costUsd))
451
+ };
452
+ return Object.keys(clean).length > 0 ? clean : void 0;
453
+ }
31
454
  /**
32
- * Per-app, per-UTC-day AI spend, persisted as OS bookkeeping in the app
33
- * container (`apps/<name>/usage.json`, next to jobs.json and chat.json).
455
+ * Per-app AI spend and token usage, persisted as OS bookkeeping in the app
456
+ * container (`apps/<name>/usage.json`, next to jobs.json and chat.json):
457
+ * a per-UTC-day series trimmed to the retention window, plus per-month
458
+ * rollups that are kept forever.
34
459
  *
35
- * Cost arrives from the `result` event's `costUsd`, which the SDK contract
36
- * marks optional: engines that do not report cost simply contribute nothing.
37
- * Writes are serialized per app so concurrent turns cannot lose an update,
38
- * and each app has its own queue so a slow write for one app never delays
39
- * another.
460
+ * Cost and per-model tokens arrive from the `result` event, whose fields the
461
+ * SDK contract marks optional: engines that report neither simply contribute
462
+ * nothing. Writes are serialized per app so concurrent turns cannot lose an
463
+ * update, and each app has its own queue so a slow write for one app never
464
+ * delays another.
40
465
  */
41
466
  var UsageRecorder = class {
42
467
  store;
@@ -48,53 +473,136 @@ var UsageRecorder = class {
48
473
  this.retentionDays = retentionDays;
49
474
  }
50
475
  /**
51
- * Adds one turn's cost to the app's total for the UTC day of `at`.
52
- * A missing, non-finite, or negative `costUsd` is ignored (no turn counted,
53
- * no file written) so a provider that omits cost cannot corrupt the totals.
476
+ * Adds one turn's cost and token usage to the app's totals for the UTC day
477
+ * and month of `at`. A missing, non-finite, or negative `costUsd` is
478
+ * ignored; a turn that reports neither valid cost nor any model usage is
479
+ * not counted and nothing is written, so a provider that omits metering
480
+ * cannot corrupt the totals.
54
481
  */
55
- record(appName, costUsd, at = /* @__PURE__ */ new Date()) {
56
- if (typeof costUsd !== "number" || !Number.isFinite(costUsd) || costUsd < 0) return Promise.resolve();
482
+ record(appName, turn, at = /* @__PURE__ */ new Date()) {
483
+ const costValid = typeof turn.costUsd === "number" && Number.isFinite(turn.costUsd) && turn.costUsd >= 0;
484
+ const modelUsage = turn.modelUsage !== void 0 && Object.keys(turn.modelUsage).length > 0 ? turn.modelUsage : void 0;
485
+ if (!costValid && modelUsage === void 0) return Promise.resolve();
486
+ const costUsd = costValid ? turn.costUsd : 0;
57
487
  return this.enqueue(appName, async () => {
58
- const state = await this.store.readUsageState(appName);
488
+ const state = this.normalize(await this.store.readUsageState(appName));
59
489
  const date = utcDay(at);
60
490
  const day = state.days.find((entry) => entry.date === date);
61
- if (day) {
62
- day.costUsd = round(day.costUsd + costUsd);
63
- day.turns += 1;
64
- } else state.days.push({
65
- date,
66
- costUsd: round(costUsd),
67
- turns: 1
68
- });
491
+ if (day) this.apply(day, costUsd, modelUsage);
492
+ else {
493
+ const fresh = {
494
+ date,
495
+ costUsd: 0,
496
+ turns: 0
497
+ };
498
+ this.apply(fresh, costUsd, modelUsage);
499
+ state.days.push(fresh);
500
+ }
501
+ const monthKey = utcMonth(at);
502
+ const months = state.months ?? [];
503
+ const month = months.find((entry) => entry.month === monthKey);
504
+ if (month) this.apply(month, costUsd, modelUsage);
505
+ else {
506
+ const fresh = {
507
+ month: monthKey,
508
+ costUsd: 0,
509
+ turns: 0
510
+ };
511
+ this.apply(fresh, costUsd, modelUsage);
512
+ months.push(fresh);
513
+ }
514
+ state.months = months;
69
515
  await this.store.writeUsageState(appName, this.prune(state, at));
70
516
  });
71
517
  }
72
- /** The app's recorded days, oldest first, with expired days filtered out. */
518
+ /** The app's recorded usage, oldest first, with expired days filtered out. */
73
519
  async read(appName, at = /* @__PURE__ */ new Date()) {
74
- return this.prune(await this.store.readUsageState(appName), at);
520
+ return this.prune(this.normalize(await this.store.readUsageState(appName)), at);
75
521
  }
76
- /** Today's spend plus the daily series, for the dashboard and the usage endpoint. */
522
+ /** Today's spend plus the daily/monthly series, for the dashboard and the usage endpoints. */
77
523
  async summary(appName, at = /* @__PURE__ */ new Date()) {
78
- const { days } = await this.read(appName, at);
524
+ const { days, months } = await this.read(appName, at);
79
525
  const date = utcDay(at);
80
526
  const today = days.find((entry) => entry.date === date);
527
+ const models = {};
528
+ for (const day of days) mergeTotals(models, day.models);
81
529
  return {
82
530
  date,
83
531
  todayCostUsd: today?.costUsd ?? 0,
84
532
  todayTurns: today?.turns ?? 0,
533
+ todayModels: today?.models ?? {},
85
534
  totalCostUsd: round(days.reduce((sum, entry) => sum + entry.costUsd, 0)),
535
+ models,
86
536
  retentionDays: this.retentionDays,
87
- days
537
+ recorded: days.length > 0 || months.length > 0,
538
+ days,
539
+ months
88
540
  };
89
541
  }
90
- /** Drops days outside the retention window and normalizes ordering/values. */
542
+ apply(bucket, costUsd, modelUsage) {
543
+ bucket.costUsd = round(bucket.costUsd + costUsd);
544
+ bucket.turns += 1;
545
+ if (modelUsage !== void 0) {
546
+ bucket.models ??= {};
547
+ addModelUsage(bucket.models, modelUsage);
548
+ }
549
+ }
550
+ /**
551
+ * One-time migration for files written before monthly rollups existed:
552
+ * absent `months` is seeded from the surviving days, so history inside the
553
+ * retention window is not lost to the rollups. After the first write the
554
+ * field is always present (possibly empty) and this is a no-op.
555
+ */
556
+ normalize(state) {
557
+ if (state.months !== void 0) return {
558
+ days: state.days,
559
+ months: state.months
560
+ };
561
+ const byMonth = /* @__PURE__ */ new Map();
562
+ for (const day of state.days) {
563
+ if (typeof day?.date !== "string" || day.date.length < 7) continue;
564
+ const key = day.date.slice(0, 7);
565
+ const month = byMonth.get(key) ?? {
566
+ month: key,
567
+ costUsd: 0,
568
+ turns: 0
569
+ };
570
+ month.costUsd = round(month.costUsd + (Number.isFinite(day.costUsd) ? day.costUsd : 0));
571
+ month.turns += Number.isFinite(day.turns) ? day.turns : 0;
572
+ if (day.models !== void 0) {
573
+ month.models ??= {};
574
+ mergeTotals(month.models, day.models);
575
+ }
576
+ byMonth.set(key, month);
577
+ }
578
+ return {
579
+ days: state.days,
580
+ months: [...byMonth.values()]
581
+ };
582
+ }
583
+ /** Drops days outside the retention window and normalizes ordering/values. Months are kept forever. */
91
584
  prune(state, at) {
92
585
  const cutoff = utcDay(/* @__PURE__ */ new Date(at.getTime() - (this.retentionDays - 1) * DAY_MS));
93
- return { days: state.days.filter((entry) => typeof entry?.date === "string" && entry.date >= cutoff && Number.isFinite(entry.costUsd) && entry.costUsd >= 0).map((entry) => ({
94
- date: entry.date,
95
- costUsd: round(entry.costUsd),
96
- turns: Number.isFinite(entry.turns) ? entry.turns : 0
97
- })).sort((a, b) => a.date.localeCompare(b.date)) };
586
+ return {
587
+ days: state.days.filter((entry) => typeof entry?.date === "string" && entry.date >= cutoff && Number.isFinite(entry.costUsd) && entry.costUsd >= 0).map((entry) => {
588
+ const models = sanitizeModels(entry.models);
589
+ return {
590
+ date: entry.date,
591
+ costUsd: round(entry.costUsd),
592
+ turns: Number.isFinite(entry.turns) ? entry.turns : 0,
593
+ ...models !== void 0 && { models }
594
+ };
595
+ }).sort((a, b) => a.date.localeCompare(b.date)),
596
+ months: state.months.filter((entry) => typeof entry?.month === "string" && Number.isFinite(entry.costUsd) && entry.costUsd >= 0).map((entry) => {
597
+ const models = sanitizeModels(entry.models);
598
+ return {
599
+ month: entry.month,
600
+ costUsd: round(entry.costUsd),
601
+ turns: Number.isFinite(entry.turns) ? entry.turns : 0,
602
+ ...models !== void 0 && { models }
603
+ };
604
+ }).sort((a, b) => a.month.localeCompare(b.month))
605
+ };
98
606
  }
99
607
  enqueue(appName, work) {
100
608
  const next = (this.queues.get(appName) ?? Promise.resolve()).then(work);
@@ -170,6 +678,7 @@ function budgetStatusFor(summary, dailyUsd) {
170
678
  return {
171
679
  date: summary.date,
172
680
  spentUsd: summary.todayCostUsd,
681
+ recorded: summary.recorded,
173
682
  ...dailyUsd !== void 0 && {
174
683
  dailyUsd,
175
684
  remainingUsd: Math.max(0, Number((dailyUsd - summary.todayCostUsd).toFixed(6)))
@@ -226,6 +735,9 @@ function checkSdkCompatibility(manifest) {
226
735
  }
227
736
  //#endregion
228
737
  //#region src/loader.ts
738
+ function isRecord(value) {
739
+ return typeof value === "object" && value !== null && !Array.isArray(value);
740
+ }
229
741
  function entryFromExports(exportsField) {
230
742
  if (typeof exportsField === "string") return exportsField;
231
743
  if (typeof exportsField !== "object" || exportsField === null) return void 0;
@@ -243,7 +755,7 @@ function entryFromExports(exportsField) {
243
755
  * The manifest is always re-validated here — the host never trusts
244
756
  * that an app used defineApp().
245
757
  */
246
- async function loadApp(repoDir) {
758
+ async function loadApp(repoDir, options = {}) {
247
759
  let pkg;
248
760
  try {
249
761
  pkg = JSON.parse(await readFile(join(repoDir, "package.json"), "utf8"));
@@ -262,15 +774,21 @@ async function loadApp(repoDir) {
262
774
  }
263
775
  const definition = mod.default;
264
776
  if (typeof definition !== "object" || definition === null || !("manifest" in definition)) throw new AppSdkError("App entry must default-export an app definition (use defineApp() from @alashi/apps-sdk)");
265
- const raw = definition.manifest;
777
+ const input = isRecord(definition.rawManifest) ? definition.rawManifest : definition.manifest;
778
+ const raw = input;
266
779
  if (typeof raw.sdkVersion === "string") checkSdkCompatibility({
267
780
  name: typeof raw.name === "string" ? raw.name : entry,
268
781
  sdkVersion: raw.sdkVersion
269
782
  });
783
+ const { manifest, unknownKeys } = parseManifestWithWarnings(input);
784
+ const warnings = unknownKeys.map((key) => `ignoring unknown manifest key "${key}" — this host uses SDK ${SDK_VERSION}`);
785
+ const warn = options.warn ?? ((message) => console.warn(`[${manifest.name}] ${message}`));
786
+ for (const warning of warnings) warn(warning);
270
787
  return {
271
788
  definition,
272
- manifest: parseManifest(definition.manifest),
273
- entryPath
789
+ manifest,
790
+ entryPath,
791
+ warnings
274
792
  };
275
793
  }
276
794
  //#endregion
@@ -481,7 +999,7 @@ async function installApp(store, source, options = {}) {
481
999
  inspectDir = stageDir;
482
1000
  }
483
1001
  try {
484
- const { manifest } = await loadApp(inspectDir);
1002
+ const { manifest, warnings } = await loadApp(inspectDir, { warn: () => {} });
485
1003
  if (expectName && manifest.name !== expectName) throw new AppSdkError(`App manifest name "${manifest.name}" does not match requested "${expectName}"`);
486
1004
  if (await store.readAppRecord(manifest.name)) throw new AppSdkError(`App "${manifest.name}" is already installed (use: alashi update ${manifest.name})`);
487
1005
  const repoDir = store.appRepoDir(manifest.name);
@@ -501,7 +1019,10 @@ async function installApp(store, source, options = {}) {
501
1019
  updatedAt: now
502
1020
  };
503
1021
  await store.writeAppRecord(record);
504
- return record;
1022
+ return {
1023
+ record,
1024
+ warnings
1025
+ };
505
1026
  } finally {
506
1027
  if (stageDir) await rm(stageDir, {
507
1028
  recursive: true,
@@ -515,7 +1036,7 @@ async function updateApp(store, name) {
515
1036
  const repoDir = store.appRepoDir(name);
516
1037
  await updateSource(record.source, repoDir);
517
1038
  await installDependencies(repoDir);
518
- const { manifest } = await loadApp(repoDir);
1039
+ const { manifest, warnings } = await loadApp(repoDir, { warn: () => {} });
519
1040
  if (manifest.name !== name) throw new AppSdkError(`App renamed itself to "${manifest.name}"; refusing update`);
520
1041
  const updated = {
521
1042
  ...record,
@@ -524,7 +1045,10 @@ async function updateApp(store, name) {
524
1045
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
525
1046
  };
526
1047
  await store.writeAppRecord(updated);
527
- return updated;
1048
+ return {
1049
+ record: updated,
1050
+ warnings
1051
+ };
528
1052
  }
529
1053
  async function removeApp(store, name, options = {}) {
530
1054
  if (!await store.readAppRecord(name)) throw new AppSdkError(`App "${name}" is not installed`);
@@ -832,7 +1356,7 @@ function createLogger(prefix) {
832
1356
  * chat turns, cron job handlers, and app routes alike — so spend is recorded
833
1357
  * no matter who opened the session.
834
1358
  */
835
- function meterSession(session, recordCost) {
1359
+ function meterSession(session, recordUsage) {
836
1360
  return {
837
1361
  get id() {
838
1362
  return session.id;
@@ -841,7 +1365,10 @@ function meterSession(session, recordCost) {
841
1365
  const events = session.send(input);
842
1366
  return (async function* metered() {
843
1367
  for await (const event of events) {
844
- if (event.type === "result") await recordCost(event.costUsd);
1368
+ if (event.type === "result") await recordUsage({
1369
+ costUsd: event.costUsd,
1370
+ modelUsage: event.modelUsage
1371
+ });
845
1372
  yield event;
846
1373
  }
847
1374
  })();
@@ -881,7 +1408,7 @@ function scopeProvider(provider, defaults) {
881
1408
  ...Object.keys(agents).length > 0 && { agents },
882
1409
  ...skillDirs.length > 0 && { skillDirs }
883
1410
  });
884
- return defaults.recordCost ? meterSession(session, defaults.recordCost) : session;
1411
+ return defaults.recordUsage ? meterSession(session, defaults.recordUsage) : session;
885
1412
  }
886
1413
  };
887
1414
  }
@@ -943,11 +1470,11 @@ async function buildAppContext(store, manifest, provider, usage) {
943
1470
  const mcpServers = interpolate(Object.fromEntries(Object.entries(manifest.mcpServers).filter(([server]) => !excluded.includes(server)).map(([server, { optional: _optional, ...decl }]) => [server, decl])));
944
1471
  const allowedTools = manifest.permissions.allowedTools.filter((tool) => !excluded.some((server) => tool.startsWith(`mcp__${server}__`)));
945
1472
  const logger = createLogger(name);
946
- const recordCost = usage ? async (costUsd) => {
1473
+ const recordUsage = usage ? async (turn) => {
947
1474
  try {
948
- await usage.record(name, costUsd);
1475
+ await usage.record(name, turn);
949
1476
  } catch (err) {
950
- logger.warn("Failed to record AI spend", { error: String(err) });
1477
+ logger.warn("Failed to record AI usage", { error: String(err) });
951
1478
  }
952
1479
  } : void 0;
953
1480
  return {
@@ -963,7 +1490,7 @@ async function buildAppContext(store, manifest, provider, usage) {
963
1490
  skillDirs: manifest.skills.map((dir) => resolve(repoDir, dir)),
964
1491
  ...instructions !== void 0 && { instructions },
965
1492
  cwd: dataDir,
966
- ...recordCost !== void 0 && { recordCost },
1493
+ ...recordUsage !== void 0 && { recordUsage },
967
1494
  ...usage !== void 0 && { checkBudget: () => assertWithinBudget(store, usage, name) },
968
1495
  ...!status.ready && { unreadyError: `App "${name}" is not configured: missing ${status.missing.join(", ")}. Set the missing keys in the dashboard settings or ${store.appConfigPath(name)}.` }
969
1496
  })
@@ -1034,6 +1561,10 @@ var Scheduler = class {
1034
1561
  this.crons = [];
1035
1562
  this.started = false;
1036
1563
  }
1564
+ /** How many jobs are currently scheduled (0 until start()). */
1565
+ get jobCount() {
1566
+ return this.crons.length;
1567
+ }
1037
1568
  /**
1038
1569
  * Runs one job immediately. Returns the recorded run, 'skipped' when that
1039
1570
  * job is already mid-run, or undefined when the app/job doesn't exist.
@@ -1140,6 +1671,7 @@ function settingsPage(name) {
1140
1671
  <main class="narrow">
1141
1672
  <div id="banner" class="banner" hidden></div>
1142
1673
  <div id="budget-card"></div>
1674
+ <div id="usage-card"></div>
1143
1675
  <div id="cards"></div>
1144
1676
  <button id="save" class="primary">Save changes</button><span id="status"></span>
1145
1677
  <div id="manifest-cards"></div>
@@ -1316,7 +1848,17 @@ function renderBudget(b, message) {
1316
1848
  },
1317
1849
  });
1318
1850
 
1319
- const spent = 'Spent today (' + b.date + ', UTC): ' + usd(b.spentUsd);
1851
+ const capNote = capped
1852
+ ? usd(b.remainingUsd) + ' left of ' + usd(b.dailyUsd)
1853
+ : 'no cap set, spend is unlimited';
1854
+ // No ledger at all is an absence, not $0.0000: the host meters only sessions
1855
+ // opened through ctx.ai, so an app that tracks its own costs elsewhere can
1856
+ // report real spend while the host has recorded nothing for it.
1857
+ const spent = b.recorded === false
1858
+ ? 'No AI spend recorded for this app yet (' + b.date + ', UTC) — ' + capNote
1859
+ + '. The host meters only sessions the app opens through ctx.ai, so the '
1860
+ + "app's own cost display may differ."
1861
+ : 'Spent today (' + b.date + ', UTC): ' + usd(b.spentUsd) + ' — ' + capNote;
1320
1862
  const body = [
1321
1863
  el('div', { class: 'desc', text:
1322
1864
  'Enforced by the host: once the spend for the UTC day reaches the cap, new AI sessions for '
@@ -1326,9 +1868,7 @@ function renderBudget(b, message) {
1326
1868
  el('label', { text: 'Daily spend cap (USD)' }),
1327
1869
  input,
1328
1870
  ]),
1329
- el('div', { class: 'desc', text: capped
1330
- ? spent + ' — ' + usd(b.remainingUsd) + ' left of ' + usd(b.dailyUsd)
1331
- : spent + ' — no cap set, spend is unlimited' }),
1871
+ el('div', { class: 'desc', text: spent }),
1332
1872
  el('div', { style: 'margin-top:12px' }, [save, status]),
1333
1873
  ];
1334
1874
  if (b.overBudget) {
@@ -1344,6 +1884,70 @@ function renderBudget(b, message) {
1344
1884
  ]));
1345
1885
  }
1346
1886
 
1887
+ function tokens(n) {
1888
+ return (n ?? 0).toLocaleString('en-US');
1889
+ }
1890
+
1891
+ function usageTable(headers, rows) {
1892
+ return el('table', {}, [
1893
+ el('thead', {}, [el('tr', {}, headers.map((h, i) =>
1894
+ el('th', i === 0 ? { text: h } : { text: h, style: 'text-align:right' })))]),
1895
+ el('tbody', {}, rows.map((cells) => el('tr', {}, cells.map((c, i) =>
1896
+ el('td', i === 0 ? { text: c } : { text: c, style: 'text-align:right' }))))),
1897
+ ]);
1898
+ }
1899
+
1900
+ function monthTokens(models) {
1901
+ let input = 0, output = 0;
1902
+ for (const t of Object.values(models ?? {})) {
1903
+ input += t.inputTokens ?? 0;
1904
+ output += t.outputTokens ?? 0;
1905
+ }
1906
+ return { input, output };
1907
+ }
1908
+
1909
+ /** Read-only token/cost breakdown from GET /api/apps/:name/usage. */
1910
+ function renderUsage(u) {
1911
+ const root = document.getElementById('usage-card');
1912
+ root.replaceChildren();
1913
+ const body = [];
1914
+ if (!u.recorded) {
1915
+ body.push(el('div', { class: 'desc', text:
1916
+ 'No AI usage recorded for this app yet. The host meters only sessions the app opens '
1917
+ + 'through ctx.ai, so usage the app tracks on its own does not appear here.' }));
1918
+ } else {
1919
+ body.push(el('div', { class: 'desc', text:
1920
+ 'Today (' + u.date + ', UTC): ' + usd(u.todayCostUsd) + ' across ' + u.todayTurns
1921
+ + ' turn' + (u.todayTurns === 1 ? '' : 's') + '.' }));
1922
+ const models = Object.keys(u.models).sort();
1923
+ if (models.length > 0) {
1924
+ body.push(el('h3', { text: 'By model (last ' + u.retentionDays + ' days)' }));
1925
+ body.push(usageTable(
1926
+ ['Model', 'Input', 'Output', 'Cache read', 'Cache write', 'Cost'],
1927
+ models.map((m) => {
1928
+ const t = u.models[m];
1929
+ return [m, tokens(t.inputTokens), tokens(t.outputTokens),
1930
+ tokens(t.cacheReadInputTokens), tokens(t.cacheCreationInputTokens), usd(t.costUsd)];
1931
+ }),
1932
+ ));
1933
+ }
1934
+ if (u.months.length > 0) {
1935
+ body.push(el('h3', { text: 'By month' }));
1936
+ body.push(usageTable(
1937
+ ['Month', 'Turns', 'Input', 'Output', 'Cost'],
1938
+ [...u.months].reverse().map((m) => {
1939
+ const t = monthTokens(m.models);
1940
+ return [m.month, String(m.turns), tokens(t.input), tokens(t.output), usd(m.costUsd)];
1941
+ }),
1942
+ ));
1943
+ }
1944
+ }
1945
+ root.append(el('div', { class: 'card' }, [
1946
+ el('h2', { text: 'AI usage' }),
1947
+ el('div', { class: 'body' }, body),
1948
+ ]));
1949
+ }
1950
+
1347
1951
  function roCard(title, children) {
1348
1952
  return el('div', { class: 'card' }, [
1349
1953
  el('h2', { text: title }),
@@ -1417,13 +2021,15 @@ function renderManifest(m) {
1417
2021
  }
1418
2022
 
1419
2023
  async function load() {
1420
- const [res, manRes, budgetRes] = await Promise.all([
2024
+ const [res, manRes, budgetRes, usageRes] = await Promise.all([
1421
2025
  fetch('/api/apps/' + APP + '/config'),
1422
2026
  fetch('/api/apps/' + APP + '/manifest'),
1423
2027
  fetch('/api/apps/' + APP + '/budget'),
2028
+ fetch('/api/apps/' + APP + '/usage'),
1424
2029
  ]);
1425
- // Host bookkeeping: the cap renders even when the app itself fails to load.
2030
+ // Host bookkeeping: the cap and usage render even when the app itself fails to load.
1426
2031
  if (budgetRes.ok) renderBudget(await budgetRes.json());
2032
+ if (usageRes.ok) renderUsage(await usageRes.json());
1427
2033
  if (!res.ok) {
1428
2034
  document.getElementById('banner').hidden = false;
1429
2035
  document.getElementById('banner').className = 'banner warn';
@@ -1475,6 +2081,18 @@ const CONTENT_TYPES = {
1475
2081
  function escapeHtml(value) {
1476
2082
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
1477
2083
  }
2084
+ /**
2085
+ * What the dashboard's Today column actually measures. The host ledger
2086
+ * (`apps/<name>/usage.json`) only ever grows when a session opened through
2087
+ * `ctx.ai` reports `costUsd`, so an app that spends through its own bookkeeping
2088
+ * can legitimately show a different number in its own UI. Saying so in the
2089
+ * header keeps a smaller host figure from reading as a bug.
2090
+ */
2091
+ const TODAY_COLUMN_TITLE = "AI spend today (UTC) as metered by the host: only sessions opened through an app's ctx.ai are counted, so an app's own in-app cost display may differ.";
2092
+ /** Tooltip for the em dash that stands in for "no host ledger at all". */
2093
+ const NO_SPEND_RECORDED_TITLE = "No AI spend recorded for this app yet — the host meters only sessions an app opens through ctx.ai";
2094
+ /** The same absence, said for the whole table rather than one app. */
2095
+ const NO_SPEND_RECORDED_ANY_TITLE = "No AI spend recorded for any installed app yet — the host meters only sessions an app opens through ctx.ai";
1478
2096
  /** Host-rendered failure page — apps never need to ship their own error UI. */
1479
2097
  function appErrorPage(name, err) {
1480
2098
  return `<!doctype html>
@@ -1499,14 +2117,27 @@ function appErrorPage(name, err) {
1499
2117
  `;
1500
2118
  }
1501
2119
  /**
1502
- * The host HTTP surface:
2120
+ * The host HTTP surface (all routes sit behind the auth policy middleware —
2121
+ * see auth.ts: public/user/owner levels, config-driven via config.auth):
2122
+ * GET /login sign-in form; ?token= redeems a printed login URL
2123
+ * POST /login { token } form -> session cookie
2124
+ * GET /logout destroy the session
2125
+ * GET /api/me the request's resolved identity, or null
1503
2126
  * GET / dashboard of installed apps
1504
2127
  * GET /settings/:name per-app settings page (config form)
1505
- * GET /api/apps installed apps as JSON, each with todayCostUsd
1506
- * and its daily spend cap / over-budget state
2128
+ * GET /api/apps installed apps as JSON, each with todayCostUsd,
2129
+ * `recorded` (false when the host has never metered
2130
+ * the app, so todayCostUsd is an absence rather than
2131
+ * a measured zero), and its daily spend cap /
2132
+ * over-budget state
2133
+ * GET /api/usage host-wide usage: per-app summaries (cost, turns,
2134
+ * per-model tokens, daily series, monthly rollups)
2135
+ * plus combined totals across apps
1507
2136
  * GET /api/apps/:name/usage recorded AI spend: today's total, a daily series,
1508
- * and the budget status
1509
- * GET /api/apps/:name/budget today's spend against the app's daily cap
2137
+ * per-model token totals, monthly rollups,
2138
+ * `recorded`, and the budget status
2139
+ * GET /api/apps/:name/budget today's spend against the app's daily cap,
2140
+ * plus `recorded`
1510
2141
  * PUT /api/apps/:name/budget { dailyUsd: number | null } -> set or clear the cap
1511
2142
  * (a host setting; apps cannot set their own)
1512
2143
  * POST /api/apps/:name/chat { message, conversationId? } -> SSE stream of AgentEvents
@@ -1532,7 +2163,8 @@ function appErrorPage(name, err) {
1532
2163
  * otherwise the app's fetch handler (/app/:name stripped),
1533
2164
  * otherwise 404
1534
2165
  */
1535
- function createServer(store, provider) {
2166
+ function createServer(store, provider, options = {}) {
2167
+ const auth = options.auth ?? new AuthService(store);
1536
2168
  const apps = /* @__PURE__ */ new Map();
1537
2169
  const sessions = /* @__PURE__ */ new Map();
1538
2170
  const usage = new UsageRecorder(store);
@@ -1621,6 +2253,15 @@ function createServer(store, provider) {
1621
2253
  }
1622
2254
  const app = new Hono();
1623
2255
  app.onError((err, c) => c.json({ error: String(err) }, 500));
2256
+ const getRemoteAddress = options.getRemoteAddress ?? ((c) => {
2257
+ try {
2258
+ return getConnInfo(c).remote.address;
2259
+ } catch {
2260
+ return;
2261
+ }
2262
+ });
2263
+ app.use("*", createAuthMiddleware(store, auth, { getRemoteAddress }));
2264
+ registerAuthRoutes(app, auth);
1624
2265
  const assetsDir = fileURLToPath(new URL("../assets", import.meta.url));
1625
2266
  app.get("/assets/*", async (c) => {
1626
2267
  let rest;
@@ -1662,29 +2303,31 @@ function createServer(store, provider) {
1662
2303
  const status = await readiness(r.name);
1663
2304
  const badge = !status ? "<span class=\"badge err\">failed to load</span>" : "loadError" in status ? `<span class="badge err" title="${escapeHtml(status.loadError)}">failed to load</span>` : status.ready ? "<span class=\"badge ok\">ready</span>" : `<a href="/settings/${name}"><span class="badge err">needs setup</span></a>`;
1664
2305
  const budget = await budgetStatus(store, usage, r.name);
1665
- const todayCostUsd = budget.spentUsd;
1666
2306
  const overBadge = budget.overBudget ? ` <a href="/settings/${name}" title="Daily AI spend cap reached — new sessions are refused until 00:00 UTC"><span class="badge err">over budget</span></a>` : "";
1667
2307
  const cap = budget.dailyUsd === void 0 ? "" : ` <span class="muted" title="Daily spend cap">/ ${formatUsd(budget.dailyUsd)}</span>`;
2308
+ const today = budget.recorded ? formatUsd(budget.spentUsd) : `<span title="${escapeHtml(NO_SPEND_RECORDED_TITLE)}">—</span>`;
1668
2309
  return {
1669
2310
  row: `<tr>
1670
2311
  <td><a href="/app/${name}/">${name}</a></td>
1671
2312
  <td class="muted">v${escapeHtml(r.version)}</td>
1672
2313
  <td class="muted">${escapeHtml(r.source.type)}</td>
1673
2314
  <td>${badge}${overBadge}</td>
1674
- <td class="muted" style="text-align:right">${formatUsd(todayCostUsd)}${cap}</td>
2315
+ <td class="muted" style="text-align:right">${today}${cap}</td>
1675
2316
  <td style="text-align:right"><a href="/settings/${name}">settings</a></td>
1676
2317
  </tr>`,
1677
- todayCostUsd
2318
+ todayCostUsd: budget.spentUsd,
2319
+ recorded: budget.recorded
1678
2320
  };
1679
2321
  }));
1680
2322
  const rows = cells.map((cell) => cell.row).join("\n");
1681
- const totalToday = cells.reduce((sum, cell) => sum + cell.todayCostUsd, 0);
2323
+ const recorded = cells.filter((cell) => cell.recorded);
2324
+ const totalToday = recorded.length === 0 ? `<span title="${escapeHtml(NO_SPEND_RECORDED_ANY_TITLE)}">—</span>` : formatUsd(recorded.reduce((sum, cell) => sum + cell.todayCostUsd, 0));
1682
2325
  const body = records.length === 0 ? "<div class=\"empty\">No apps installed. Run <code>alashi install &lt;name&gt;</code>.</div>" : `<table>
1683
- <thead><tr><th>App</th><th>Version</th><th>Source</th><th>Status</th><th style="text-align:right" title="AI spend today (UTC)">Today</th><th></th></tr></thead>
2326
+ <thead><tr><th>App</th><th>Version</th><th>Source</th><th>Status</th><th style="text-align:right" title="${escapeHtml(TODAY_COLUMN_TITLE)}">Today</th><th></th></tr></thead>
1684
2327
  <tbody>
1685
2328
  ${rows}
1686
2329
  </tbody>
1687
- <tfoot><tr><td colspan="4" class="muted">AI spend today (UTC)</td><td class="muted" style="text-align:right">${formatUsd(totalToday)}</td><td></td></tr></tfoot>
2330
+ <tfoot><tr><td colspan="4" class="muted" title="${escapeHtml(TODAY_COLUMN_TITLE)}">AI spend today (UTC)</td><td class="muted" style="text-align:right">${totalToday}</td><td></td></tr></tfoot>
1688
2331
  </table>`;
1689
2332
  return c.html(`<!doctype html>
1690
2333
  <html lang="en">
@@ -1714,6 +2357,7 @@ ${body}
1714
2357
  return {
1715
2358
  ...record,
1716
2359
  todayCostUsd: budget.spentUsd,
2360
+ recorded: budget.recorded,
1717
2361
  overBudget: budget.overBudget,
1718
2362
  ...budget.dailyUsd !== void 0 && { dailyBudgetUsd: budget.dailyUsd }
1719
2363
  };
@@ -1724,6 +2368,19 @@ ${body}
1724
2368
  async function installedApp(name) {
1725
2369
  return APP_NAME_PATTERN.test(name) && await store.readAppRecord(name) !== void 0;
1726
2370
  }
2371
+ app.get("/api/usage", async (c) => {
2372
+ const records = await store.listInstalled();
2373
+ const summaries = await Promise.all(records.map(async (record) => ({
2374
+ name: record.name,
2375
+ ...await usage.summary(record.name)
2376
+ })));
2377
+ return c.json({
2378
+ date: utcDay(),
2379
+ retentionDays: 90,
2380
+ apps: summaries,
2381
+ totals: combineSummaries(summaries)
2382
+ });
2383
+ });
1727
2384
  app.get("/api/apps/:name/usage", async (c) => {
1728
2385
  const name = c.req.param("name");
1729
2386
  if (!await installedApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
@@ -2008,6 +2665,7 @@ ${body}
2008
2665
  return {
2009
2666
  app,
2010
2667
  scheduler,
2668
+ auth,
2011
2669
  async close() {
2012
2670
  scheduler.stop();
2013
2671
  const open = [...sessions.values()];
@@ -2123,11 +2781,28 @@ var Store = class {
2123
2781
  await writeJson(this.appJobStatePath(name), state);
2124
2782
  }
2125
2783
  async readUsageState(name) {
2126
- return { days: (await readJson(this.appUsagePath(name)))?.days ?? [] };
2784
+ const state = await readJson(this.appUsagePath(name));
2785
+ return {
2786
+ days: state?.days ?? [],
2787
+ ...state?.months !== void 0 && { months: state.months }
2788
+ };
2127
2789
  }
2128
2790
  async writeUsageState(name, state) {
2129
2791
  await writeJson(this.appUsagePath(name), state);
2130
2792
  }
2793
+ get authStatePath() {
2794
+ return join(this.root, "auth.json");
2795
+ }
2796
+ async readAuthState() {
2797
+ const state = await readJson(this.authStatePath);
2798
+ return {
2799
+ tokens: state?.tokens ?? [],
2800
+ sessions: state?.sessions ?? []
2801
+ };
2802
+ }
2803
+ async writeAuthState(state) {
2804
+ await writeJson(this.authStatePath, state);
2805
+ }
2131
2806
  async listInstalled() {
2132
2807
  let entries;
2133
2808
  try {
@@ -2146,6 +2821,6 @@ var Store = class {
2146
2821
  }
2147
2822
  };
2148
2823
  //#endregion
2149
- export { UsageRecorder as A, BudgetExceededError as C, budgetStatusFor as D, budgetStatus as E, utcDay as M, setAppBudget as O, BUDGET_EXCEEDED_CODE as S, assertWithinBudget as T, fetchSource as _, EXPORT_FORMAT_VERSION as a, loadApp as b, installApp as c, updateApp as d, addRegistry as f, resolveApp as g, removeRegistry as h, resolveAppConfig as i, formatUsd as j, USAGE_RETENTION_DAYS as k, installDependencies as l, refreshRegistry as m, createServer as n, exportApp as o, readCachedIndex as p, buildAppContext as r, importApp as s, Store as t, removeApp as u, parseSourceArg as v, appBudget as w, checkSdkCompatibility as x, updateSource as y };
2824
+ export { UsageRecorder as A, BudgetExceededError as C, budgetStatusFor as D, budgetStatus as E, createAuthMiddleware as F, isConsoleRequest as I, registerAuthRoutes as L, utcDay as M, AuthService as N, setAppBudget as O, SESSION_COOKIE as P, requiredLevel as R, BUDGET_EXCEEDED_CODE as S, assertWithinBudget as T, fetchSource as _, EXPORT_FORMAT_VERSION as a, loadApp as b, installApp as c, updateApp as d, addRegistry as f, resolveApp as g, removeRegistry as h, resolveAppConfig as i, formatUsd as j, USAGE_RETENTION_DAYS as k, installDependencies as l, refreshRegistry as m, createServer as n, exportApp as o, readCachedIndex as p, buildAppContext as r, importApp as s, Store as t, removeApp as u, parseSourceArg as v, appBudget as w, checkSdkCompatibility as x, updateSource as y };
2150
2825
 
2151
- //# sourceMappingURL=store-VoJe_U1Q.mjs.map
2826
+ //# sourceMappingURL=store-DoAGOynU.mjs.map