@alashi/cli 0.11.0 → 0.12.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,98 @@ 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
+ const totals = models[model] ??= emptyTotals();
423
+ totals.inputTokens += count(turn.inputTokens);
424
+ totals.outputTokens += count(turn.outputTokens);
425
+ totals.cacheReadInputTokens += count(turn.cacheReadInputTokens);
426
+ totals.cacheCreationInputTokens += count(turn.cacheCreationInputTokens);
427
+ totals.costUsd = round(totals.costUsd + count(turn.costUsd));
428
+ }
429
+ }
430
+ function mergeTotals(into, from) {
431
+ for (const [model, totals] of Object.entries(from ?? {})) {
432
+ const target = into[model] ??= emptyTotals();
433
+ target.inputTokens += count(totals.inputTokens);
434
+ target.outputTokens += count(totals.outputTokens);
435
+ target.cacheReadInputTokens += count(totals.cacheReadInputTokens);
436
+ target.cacheCreationInputTokens += count(totals.cacheCreationInputTokens);
437
+ target.costUsd = round(target.costUsd + count(totals.costUsd));
438
+ }
439
+ }
440
+ function sanitizeModels(models) {
441
+ if (models === void 0) return void 0;
442
+ const clean = {};
443
+ for (const [model, totals] of Object.entries(models)) clean[model] = {
444
+ inputTokens: count(totals?.inputTokens),
445
+ outputTokens: count(totals?.outputTokens),
446
+ cacheReadInputTokens: count(totals?.cacheReadInputTokens),
447
+ cacheCreationInputTokens: count(totals?.cacheCreationInputTokens),
448
+ costUsd: round(count(totals?.costUsd))
449
+ };
450
+ return Object.keys(clean).length > 0 ? clean : void 0;
451
+ }
31
452
  /**
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).
453
+ * Per-app AI spend and token usage, persisted as OS bookkeeping in the app
454
+ * container (`apps/<name>/usage.json`, next to jobs.json and chat.json):
455
+ * a per-UTC-day series trimmed to the retention window, plus per-month
456
+ * rollups that are kept forever.
34
457
  *
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.
458
+ * Cost and per-model tokens arrive from the `result` event, whose fields the
459
+ * SDK contract marks optional: engines that report neither simply contribute
460
+ * nothing. Writes are serialized per app so concurrent turns cannot lose an
461
+ * update, and each app has its own queue so a slow write for one app never
462
+ * delays another.
40
463
  */
41
464
  var UsageRecorder = class {
42
465
  store;
@@ -48,53 +471,136 @@ var UsageRecorder = class {
48
471
  this.retentionDays = retentionDays;
49
472
  }
50
473
  /**
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.
474
+ * Adds one turn's cost and token usage to the app's totals for the UTC day
475
+ * and month of `at`. A missing, non-finite, or negative `costUsd` is
476
+ * ignored; a turn that reports neither valid cost nor any model usage is
477
+ * not counted and nothing is written, so a provider that omits metering
478
+ * cannot corrupt the totals.
54
479
  */
55
- record(appName, costUsd, at = /* @__PURE__ */ new Date()) {
56
- if (typeof costUsd !== "number" || !Number.isFinite(costUsd) || costUsd < 0) return Promise.resolve();
480
+ record(appName, turn, at = /* @__PURE__ */ new Date()) {
481
+ const costValid = typeof turn.costUsd === "number" && Number.isFinite(turn.costUsd) && turn.costUsd >= 0;
482
+ const modelUsage = turn.modelUsage !== void 0 && Object.keys(turn.modelUsage).length > 0 ? turn.modelUsage : void 0;
483
+ if (!costValid && modelUsage === void 0) return Promise.resolve();
484
+ const costUsd = costValid ? turn.costUsd : 0;
57
485
  return this.enqueue(appName, async () => {
58
- const state = await this.store.readUsageState(appName);
486
+ const state = this.normalize(await this.store.readUsageState(appName));
59
487
  const date = utcDay(at);
60
488
  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
- });
489
+ if (day) this.apply(day, costUsd, modelUsage);
490
+ else {
491
+ const fresh = {
492
+ date,
493
+ costUsd: 0,
494
+ turns: 0
495
+ };
496
+ this.apply(fresh, costUsd, modelUsage);
497
+ state.days.push(fresh);
498
+ }
499
+ const monthKey = utcMonth(at);
500
+ const months = state.months ?? [];
501
+ const month = months.find((entry) => entry.month === monthKey);
502
+ if (month) this.apply(month, costUsd, modelUsage);
503
+ else {
504
+ const fresh = {
505
+ month: monthKey,
506
+ costUsd: 0,
507
+ turns: 0
508
+ };
509
+ this.apply(fresh, costUsd, modelUsage);
510
+ months.push(fresh);
511
+ }
512
+ state.months = months;
69
513
  await this.store.writeUsageState(appName, this.prune(state, at));
70
514
  });
71
515
  }
72
- /** The app's recorded days, oldest first, with expired days filtered out. */
516
+ /** The app's recorded usage, oldest first, with expired days filtered out. */
73
517
  async read(appName, at = /* @__PURE__ */ new Date()) {
74
- return this.prune(await this.store.readUsageState(appName), at);
518
+ return this.prune(this.normalize(await this.store.readUsageState(appName)), at);
75
519
  }
76
- /** Today's spend plus the daily series, for the dashboard and the usage endpoint. */
520
+ /** Today's spend plus the daily/monthly series, for the dashboard and the usage endpoints. */
77
521
  async summary(appName, at = /* @__PURE__ */ new Date()) {
78
- const { days } = await this.read(appName, at);
522
+ const { days, months } = await this.read(appName, at);
79
523
  const date = utcDay(at);
80
524
  const today = days.find((entry) => entry.date === date);
525
+ const models = {};
526
+ for (const day of days) mergeTotals(models, day.models);
81
527
  return {
82
528
  date,
83
529
  todayCostUsd: today?.costUsd ?? 0,
84
530
  todayTurns: today?.turns ?? 0,
531
+ todayModels: today?.models ?? {},
85
532
  totalCostUsd: round(days.reduce((sum, entry) => sum + entry.costUsd, 0)),
533
+ models,
86
534
  retentionDays: this.retentionDays,
87
- days
535
+ recorded: days.length > 0 || months.length > 0,
536
+ days,
537
+ months
88
538
  };
89
539
  }
90
- /** Drops days outside the retention window and normalizes ordering/values. */
540
+ apply(bucket, costUsd, modelUsage) {
541
+ bucket.costUsd = round(bucket.costUsd + costUsd);
542
+ bucket.turns += 1;
543
+ if (modelUsage !== void 0) {
544
+ bucket.models ??= {};
545
+ addModelUsage(bucket.models, modelUsage);
546
+ }
547
+ }
548
+ /**
549
+ * One-time migration for files written before monthly rollups existed:
550
+ * absent `months` is seeded from the surviving days, so history inside the
551
+ * retention window is not lost to the rollups. After the first write the
552
+ * field is always present (possibly empty) and this is a no-op.
553
+ */
554
+ normalize(state) {
555
+ if (state.months !== void 0) return {
556
+ days: state.days,
557
+ months: state.months
558
+ };
559
+ const byMonth = /* @__PURE__ */ new Map();
560
+ for (const day of state.days) {
561
+ if (typeof day?.date !== "string" || day.date.length < 7) continue;
562
+ const key = day.date.slice(0, 7);
563
+ const month = byMonth.get(key) ?? {
564
+ month: key,
565
+ costUsd: 0,
566
+ turns: 0
567
+ };
568
+ month.costUsd = round(month.costUsd + (Number.isFinite(day.costUsd) ? day.costUsd : 0));
569
+ month.turns += Number.isFinite(day.turns) ? day.turns : 0;
570
+ if (day.models !== void 0) {
571
+ month.models ??= {};
572
+ mergeTotals(month.models, day.models);
573
+ }
574
+ byMonth.set(key, month);
575
+ }
576
+ return {
577
+ days: state.days,
578
+ months: [...byMonth.values()]
579
+ };
580
+ }
581
+ /** Drops days outside the retention window and normalizes ordering/values. Months are kept forever. */
91
582
  prune(state, at) {
92
583
  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)) };
584
+ return {
585
+ days: state.days.filter((entry) => typeof entry?.date === "string" && entry.date >= cutoff && Number.isFinite(entry.costUsd) && entry.costUsd >= 0).map((entry) => {
586
+ const models = sanitizeModels(entry.models);
587
+ return {
588
+ date: entry.date,
589
+ costUsd: round(entry.costUsd),
590
+ turns: Number.isFinite(entry.turns) ? entry.turns : 0,
591
+ ...models !== void 0 && { models }
592
+ };
593
+ }).sort((a, b) => a.date.localeCompare(b.date)),
594
+ months: state.months.filter((entry) => typeof entry?.month === "string" && Number.isFinite(entry.costUsd) && entry.costUsd >= 0).map((entry) => {
595
+ const models = sanitizeModels(entry.models);
596
+ return {
597
+ month: entry.month,
598
+ costUsd: round(entry.costUsd),
599
+ turns: Number.isFinite(entry.turns) ? entry.turns : 0,
600
+ ...models !== void 0 && { models }
601
+ };
602
+ }).sort((a, b) => a.month.localeCompare(b.month))
603
+ };
98
604
  }
99
605
  enqueue(appName, work) {
100
606
  const next = (this.queues.get(appName) ?? Promise.resolve()).then(work);
@@ -170,6 +676,7 @@ function budgetStatusFor(summary, dailyUsd) {
170
676
  return {
171
677
  date: summary.date,
172
678
  spentUsd: summary.todayCostUsd,
679
+ recorded: summary.recorded,
173
680
  ...dailyUsd !== void 0 && {
174
681
  dailyUsd,
175
682
  remainingUsd: Math.max(0, Number((dailyUsd - summary.todayCostUsd).toFixed(6)))
@@ -226,6 +733,9 @@ function checkSdkCompatibility(manifest) {
226
733
  }
227
734
  //#endregion
228
735
  //#region src/loader.ts
736
+ function isRecord(value) {
737
+ return typeof value === "object" && value !== null && !Array.isArray(value);
738
+ }
229
739
  function entryFromExports(exportsField) {
230
740
  if (typeof exportsField === "string") return exportsField;
231
741
  if (typeof exportsField !== "object" || exportsField === null) return void 0;
@@ -243,7 +753,7 @@ function entryFromExports(exportsField) {
243
753
  * The manifest is always re-validated here — the host never trusts
244
754
  * that an app used defineApp().
245
755
  */
246
- async function loadApp(repoDir) {
756
+ async function loadApp(repoDir, options = {}) {
247
757
  let pkg;
248
758
  try {
249
759
  pkg = JSON.parse(await readFile(join(repoDir, "package.json"), "utf8"));
@@ -262,15 +772,21 @@ async function loadApp(repoDir) {
262
772
  }
263
773
  const definition = mod.default;
264
774
  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;
775
+ const input = isRecord(definition.rawManifest) ? definition.rawManifest : definition.manifest;
776
+ const raw = input;
266
777
  if (typeof raw.sdkVersion === "string") checkSdkCompatibility({
267
778
  name: typeof raw.name === "string" ? raw.name : entry,
268
779
  sdkVersion: raw.sdkVersion
269
780
  });
781
+ const { manifest, unknownKeys } = parseManifestWithWarnings(input);
782
+ const warnings = unknownKeys.map((key) => `ignoring unknown manifest key "${key}" — this host uses SDK ${SDK_VERSION}`);
783
+ const warn = options.warn ?? ((message) => console.warn(`[${manifest.name}] ${message}`));
784
+ for (const warning of warnings) warn(warning);
270
785
  return {
271
786
  definition,
272
- manifest: parseManifest(definition.manifest),
273
- entryPath
787
+ manifest,
788
+ entryPath,
789
+ warnings
274
790
  };
275
791
  }
276
792
  //#endregion
@@ -481,7 +997,7 @@ async function installApp(store, source, options = {}) {
481
997
  inspectDir = stageDir;
482
998
  }
483
999
  try {
484
- const { manifest } = await loadApp(inspectDir);
1000
+ const { manifest, warnings } = await loadApp(inspectDir, { warn: () => {} });
485
1001
  if (expectName && manifest.name !== expectName) throw new AppSdkError(`App manifest name "${manifest.name}" does not match requested "${expectName}"`);
486
1002
  if (await store.readAppRecord(manifest.name)) throw new AppSdkError(`App "${manifest.name}" is already installed (use: alashi update ${manifest.name})`);
487
1003
  const repoDir = store.appRepoDir(manifest.name);
@@ -501,7 +1017,10 @@ async function installApp(store, source, options = {}) {
501
1017
  updatedAt: now
502
1018
  };
503
1019
  await store.writeAppRecord(record);
504
- return record;
1020
+ return {
1021
+ record,
1022
+ warnings
1023
+ };
505
1024
  } finally {
506
1025
  if (stageDir) await rm(stageDir, {
507
1026
  recursive: true,
@@ -515,7 +1034,7 @@ async function updateApp(store, name) {
515
1034
  const repoDir = store.appRepoDir(name);
516
1035
  await updateSource(record.source, repoDir);
517
1036
  await installDependencies(repoDir);
518
- const { manifest } = await loadApp(repoDir);
1037
+ const { manifest, warnings } = await loadApp(repoDir, { warn: () => {} });
519
1038
  if (manifest.name !== name) throw new AppSdkError(`App renamed itself to "${manifest.name}"; refusing update`);
520
1039
  const updated = {
521
1040
  ...record,
@@ -524,7 +1043,10 @@ async function updateApp(store, name) {
524
1043
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
525
1044
  };
526
1045
  await store.writeAppRecord(updated);
527
- return updated;
1046
+ return {
1047
+ record: updated,
1048
+ warnings
1049
+ };
528
1050
  }
529
1051
  async function removeApp(store, name, options = {}) {
530
1052
  if (!await store.readAppRecord(name)) throw new AppSdkError(`App "${name}" is not installed`);
@@ -832,7 +1354,7 @@ function createLogger(prefix) {
832
1354
  * chat turns, cron job handlers, and app routes alike — so spend is recorded
833
1355
  * no matter who opened the session.
834
1356
  */
835
- function meterSession(session, recordCost) {
1357
+ function meterSession(session, recordUsage) {
836
1358
  return {
837
1359
  get id() {
838
1360
  return session.id;
@@ -841,7 +1363,10 @@ function meterSession(session, recordCost) {
841
1363
  const events = session.send(input);
842
1364
  return (async function* metered() {
843
1365
  for await (const event of events) {
844
- if (event.type === "result") await recordCost(event.costUsd);
1366
+ if (event.type === "result") await recordUsage({
1367
+ costUsd: event.costUsd,
1368
+ modelUsage: event.modelUsage
1369
+ });
845
1370
  yield event;
846
1371
  }
847
1372
  })();
@@ -881,7 +1406,7 @@ function scopeProvider(provider, defaults) {
881
1406
  ...Object.keys(agents).length > 0 && { agents },
882
1407
  ...skillDirs.length > 0 && { skillDirs }
883
1408
  });
884
- return defaults.recordCost ? meterSession(session, defaults.recordCost) : session;
1409
+ return defaults.recordUsage ? meterSession(session, defaults.recordUsage) : session;
885
1410
  }
886
1411
  };
887
1412
  }
@@ -943,11 +1468,11 @@ async function buildAppContext(store, manifest, provider, usage) {
943
1468
  const mcpServers = interpolate(Object.fromEntries(Object.entries(manifest.mcpServers).filter(([server]) => !excluded.includes(server)).map(([server, { optional: _optional, ...decl }]) => [server, decl])));
944
1469
  const allowedTools = manifest.permissions.allowedTools.filter((tool) => !excluded.some((server) => tool.startsWith(`mcp__${server}__`)));
945
1470
  const logger = createLogger(name);
946
- const recordCost = usage ? async (costUsd) => {
1471
+ const recordUsage = usage ? async (turn) => {
947
1472
  try {
948
- await usage.record(name, costUsd);
1473
+ await usage.record(name, turn);
949
1474
  } catch (err) {
950
- logger.warn("Failed to record AI spend", { error: String(err) });
1475
+ logger.warn("Failed to record AI usage", { error: String(err) });
951
1476
  }
952
1477
  } : void 0;
953
1478
  return {
@@ -963,7 +1488,7 @@ async function buildAppContext(store, manifest, provider, usage) {
963
1488
  skillDirs: manifest.skills.map((dir) => resolve(repoDir, dir)),
964
1489
  ...instructions !== void 0 && { instructions },
965
1490
  cwd: dataDir,
966
- ...recordCost !== void 0 && { recordCost },
1491
+ ...recordUsage !== void 0 && { recordUsage },
967
1492
  ...usage !== void 0 && { checkBudget: () => assertWithinBudget(store, usage, name) },
968
1493
  ...!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
1494
  })
@@ -1140,6 +1665,7 @@ function settingsPage(name) {
1140
1665
  <main class="narrow">
1141
1666
  <div id="banner" class="banner" hidden></div>
1142
1667
  <div id="budget-card"></div>
1668
+ <div id="usage-card"></div>
1143
1669
  <div id="cards"></div>
1144
1670
  <button id="save" class="primary">Save changes</button><span id="status"></span>
1145
1671
  <div id="manifest-cards"></div>
@@ -1316,7 +1842,17 @@ function renderBudget(b, message) {
1316
1842
  },
1317
1843
  });
1318
1844
 
1319
- const spent = 'Spent today (' + b.date + ', UTC): ' + usd(b.spentUsd);
1845
+ const capNote = capped
1846
+ ? usd(b.remainingUsd) + ' left of ' + usd(b.dailyUsd)
1847
+ : 'no cap set, spend is unlimited';
1848
+ // No ledger at all is an absence, not $0.0000: the host meters only sessions
1849
+ // opened through ctx.ai, so an app that tracks its own costs elsewhere can
1850
+ // report real spend while the host has recorded nothing for it.
1851
+ const spent = b.recorded === false
1852
+ ? 'No AI spend recorded for this app yet (' + b.date + ', UTC) — ' + capNote
1853
+ + '. The host meters only sessions the app opens through ctx.ai, so the '
1854
+ + "app's own cost display may differ."
1855
+ : 'Spent today (' + b.date + ', UTC): ' + usd(b.spentUsd) + ' — ' + capNote;
1320
1856
  const body = [
1321
1857
  el('div', { class: 'desc', text:
1322
1858
  'Enforced by the host: once the spend for the UTC day reaches the cap, new AI sessions for '
@@ -1326,9 +1862,7 @@ function renderBudget(b, message) {
1326
1862
  el('label', { text: 'Daily spend cap (USD)' }),
1327
1863
  input,
1328
1864
  ]),
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' }),
1865
+ el('div', { class: 'desc', text: spent }),
1332
1866
  el('div', { style: 'margin-top:12px' }, [save, status]),
1333
1867
  ];
1334
1868
  if (b.overBudget) {
@@ -1344,6 +1878,70 @@ function renderBudget(b, message) {
1344
1878
  ]));
1345
1879
  }
1346
1880
 
1881
+ function tokens(n) {
1882
+ return (n ?? 0).toLocaleString('en-US');
1883
+ }
1884
+
1885
+ function usageTable(headers, rows) {
1886
+ return el('table', {}, [
1887
+ el('thead', {}, [el('tr', {}, headers.map((h, i) =>
1888
+ el('th', i === 0 ? { text: h } : { text: h, style: 'text-align:right' })))]),
1889
+ el('tbody', {}, rows.map((cells) => el('tr', {}, cells.map((c, i) =>
1890
+ el('td', i === 0 ? { text: c } : { text: c, style: 'text-align:right' }))))),
1891
+ ]);
1892
+ }
1893
+
1894
+ function monthTokens(models) {
1895
+ let input = 0, output = 0;
1896
+ for (const t of Object.values(models ?? {})) {
1897
+ input += t.inputTokens ?? 0;
1898
+ output += t.outputTokens ?? 0;
1899
+ }
1900
+ return { input, output };
1901
+ }
1902
+
1903
+ /** Read-only token/cost breakdown from GET /api/apps/:name/usage. */
1904
+ function renderUsage(u) {
1905
+ const root = document.getElementById('usage-card');
1906
+ root.replaceChildren();
1907
+ const body = [];
1908
+ if (!u.recorded) {
1909
+ body.push(el('div', { class: 'desc', text:
1910
+ 'No AI usage recorded for this app yet. The host meters only sessions the app opens '
1911
+ + 'through ctx.ai, so usage the app tracks on its own does not appear here.' }));
1912
+ } else {
1913
+ body.push(el('div', { class: 'desc', text:
1914
+ 'Today (' + u.date + ', UTC): ' + usd(u.todayCostUsd) + ' across ' + u.todayTurns
1915
+ + ' turn' + (u.todayTurns === 1 ? '' : 's') + '.' }));
1916
+ const models = Object.keys(u.models).sort();
1917
+ if (models.length > 0) {
1918
+ body.push(el('h3', { text: 'By model (last ' + u.retentionDays + ' days)' }));
1919
+ body.push(usageTable(
1920
+ ['Model', 'Input', 'Output', 'Cache read', 'Cache write', 'Cost'],
1921
+ models.map((m) => {
1922
+ const t = u.models[m];
1923
+ return [m, tokens(t.inputTokens), tokens(t.outputTokens),
1924
+ tokens(t.cacheReadInputTokens), tokens(t.cacheCreationInputTokens), usd(t.costUsd)];
1925
+ }),
1926
+ ));
1927
+ }
1928
+ if (u.months.length > 0) {
1929
+ body.push(el('h3', { text: 'By month' }));
1930
+ body.push(usageTable(
1931
+ ['Month', 'Turns', 'Input', 'Output', 'Cost'],
1932
+ [...u.months].reverse().map((m) => {
1933
+ const t = monthTokens(m.models);
1934
+ return [m.month, String(m.turns), tokens(t.input), tokens(t.output), usd(m.costUsd)];
1935
+ }),
1936
+ ));
1937
+ }
1938
+ }
1939
+ root.append(el('div', { class: 'card' }, [
1940
+ el('h2', { text: 'AI usage' }),
1941
+ el('div', { class: 'body' }, body),
1942
+ ]));
1943
+ }
1944
+
1347
1945
  function roCard(title, children) {
1348
1946
  return el('div', { class: 'card' }, [
1349
1947
  el('h2', { text: title }),
@@ -1417,13 +2015,15 @@ function renderManifest(m) {
1417
2015
  }
1418
2016
 
1419
2017
  async function load() {
1420
- const [res, manRes, budgetRes] = await Promise.all([
2018
+ const [res, manRes, budgetRes, usageRes] = await Promise.all([
1421
2019
  fetch('/api/apps/' + APP + '/config'),
1422
2020
  fetch('/api/apps/' + APP + '/manifest'),
1423
2021
  fetch('/api/apps/' + APP + '/budget'),
2022
+ fetch('/api/apps/' + APP + '/usage'),
1424
2023
  ]);
1425
- // Host bookkeeping: the cap renders even when the app itself fails to load.
2024
+ // Host bookkeeping: the cap and usage render even when the app itself fails to load.
1426
2025
  if (budgetRes.ok) renderBudget(await budgetRes.json());
2026
+ if (usageRes.ok) renderUsage(await usageRes.json());
1427
2027
  if (!res.ok) {
1428
2028
  document.getElementById('banner').hidden = false;
1429
2029
  document.getElementById('banner').className = 'banner warn';
@@ -1475,6 +2075,18 @@ const CONTENT_TYPES = {
1475
2075
  function escapeHtml(value) {
1476
2076
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
1477
2077
  }
2078
+ /**
2079
+ * What the dashboard's Today column actually measures. The host ledger
2080
+ * (`apps/<name>/usage.json`) only ever grows when a session opened through
2081
+ * `ctx.ai` reports `costUsd`, so an app that spends through its own bookkeeping
2082
+ * can legitimately show a different number in its own UI. Saying so in the
2083
+ * header keeps a smaller host figure from reading as a bug.
2084
+ */
2085
+ 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.";
2086
+ /** Tooltip for the em dash that stands in for "no host ledger at all". */
2087
+ const NO_SPEND_RECORDED_TITLE = "No AI spend recorded for this app yet — the host meters only sessions an app opens through ctx.ai";
2088
+ /** The same absence, said for the whole table rather than one app. */
2089
+ 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
2090
  /** Host-rendered failure page — apps never need to ship their own error UI. */
1479
2091
  function appErrorPage(name, err) {
1480
2092
  return `<!doctype html>
@@ -1499,14 +2111,27 @@ function appErrorPage(name, err) {
1499
2111
  `;
1500
2112
  }
1501
2113
  /**
1502
- * The host HTTP surface:
2114
+ * The host HTTP surface (all routes sit behind the auth policy middleware —
2115
+ * see auth.ts: public/user/owner levels, config-driven via config.auth):
2116
+ * GET /login sign-in form; ?token= redeems a printed login URL
2117
+ * POST /login { token } form -> session cookie
2118
+ * GET /logout destroy the session
2119
+ * GET /api/me the request's resolved identity, or null
1503
2120
  * GET / dashboard of installed apps
1504
2121
  * 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
2122
+ * GET /api/apps installed apps as JSON, each with todayCostUsd,
2123
+ * `recorded` (false when the host has never metered
2124
+ * the app, so todayCostUsd is an absence rather than
2125
+ * a measured zero), and its daily spend cap /
2126
+ * over-budget state
2127
+ * GET /api/usage host-wide usage: per-app summaries (cost, turns,
2128
+ * per-model tokens, daily series, monthly rollups)
2129
+ * plus combined totals across apps
1507
2130
  * 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
2131
+ * per-model token totals, monthly rollups,
2132
+ * `recorded`, and the budget status
2133
+ * GET /api/apps/:name/budget today's spend against the app's daily cap,
2134
+ * plus `recorded`
1510
2135
  * PUT /api/apps/:name/budget { dailyUsd: number | null } -> set or clear the cap
1511
2136
  * (a host setting; apps cannot set their own)
1512
2137
  * POST /api/apps/:name/chat { message, conversationId? } -> SSE stream of AgentEvents
@@ -1532,7 +2157,8 @@ function appErrorPage(name, err) {
1532
2157
  * otherwise the app's fetch handler (/app/:name stripped),
1533
2158
  * otherwise 404
1534
2159
  */
1535
- function createServer(store, provider) {
2160
+ function createServer(store, provider, options = {}) {
2161
+ const auth = options.auth ?? new AuthService(store);
1536
2162
  const apps = /* @__PURE__ */ new Map();
1537
2163
  const sessions = /* @__PURE__ */ new Map();
1538
2164
  const usage = new UsageRecorder(store);
@@ -1621,6 +2247,15 @@ function createServer(store, provider) {
1621
2247
  }
1622
2248
  const app = new Hono();
1623
2249
  app.onError((err, c) => c.json({ error: String(err) }, 500));
2250
+ const getRemoteAddress = options.getRemoteAddress ?? ((c) => {
2251
+ try {
2252
+ return getConnInfo(c).remote.address;
2253
+ } catch {
2254
+ return;
2255
+ }
2256
+ });
2257
+ app.use("*", createAuthMiddleware(store, auth, { getRemoteAddress }));
2258
+ registerAuthRoutes(app, auth);
1624
2259
  const assetsDir = fileURLToPath(new URL("../assets", import.meta.url));
1625
2260
  app.get("/assets/*", async (c) => {
1626
2261
  let rest;
@@ -1662,29 +2297,31 @@ function createServer(store, provider) {
1662
2297
  const status = await readiness(r.name);
1663
2298
  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
2299
  const budget = await budgetStatus(store, usage, r.name);
1665
- const todayCostUsd = budget.spentUsd;
1666
2300
  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
2301
  const cap = budget.dailyUsd === void 0 ? "" : ` <span class="muted" title="Daily spend cap">/ ${formatUsd(budget.dailyUsd)}</span>`;
2302
+ const today = budget.recorded ? formatUsd(budget.spentUsd) : `<span title="${escapeHtml(NO_SPEND_RECORDED_TITLE)}">—</span>`;
1668
2303
  return {
1669
2304
  row: `<tr>
1670
2305
  <td><a href="/app/${name}/">${name}</a></td>
1671
2306
  <td class="muted">v${escapeHtml(r.version)}</td>
1672
2307
  <td class="muted">${escapeHtml(r.source.type)}</td>
1673
2308
  <td>${badge}${overBadge}</td>
1674
- <td class="muted" style="text-align:right">${formatUsd(todayCostUsd)}${cap}</td>
2309
+ <td class="muted" style="text-align:right">${today}${cap}</td>
1675
2310
  <td style="text-align:right"><a href="/settings/${name}">settings</a></td>
1676
2311
  </tr>`,
1677
- todayCostUsd
2312
+ todayCostUsd: budget.spentUsd,
2313
+ recorded: budget.recorded
1678
2314
  };
1679
2315
  }));
1680
2316
  const rows = cells.map((cell) => cell.row).join("\n");
1681
- const totalToday = cells.reduce((sum, cell) => sum + cell.todayCostUsd, 0);
2317
+ const recorded = cells.filter((cell) => cell.recorded);
2318
+ const totalToday = recorded.length === 0 ? `<span title="${escapeHtml(NO_SPEND_RECORDED_ANY_TITLE)}">—</span>` : formatUsd(recorded.reduce((sum, cell) => sum + cell.todayCostUsd, 0));
1682
2319
  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>
2320
+ <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
2321
  <tbody>
1685
2322
  ${rows}
1686
2323
  </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>
2324
+ <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
2325
  </table>`;
1689
2326
  return c.html(`<!doctype html>
1690
2327
  <html lang="en">
@@ -1714,6 +2351,7 @@ ${body}
1714
2351
  return {
1715
2352
  ...record,
1716
2353
  todayCostUsd: budget.spentUsd,
2354
+ recorded: budget.recorded,
1717
2355
  overBudget: budget.overBudget,
1718
2356
  ...budget.dailyUsd !== void 0 && { dailyBudgetUsd: budget.dailyUsd }
1719
2357
  };
@@ -1724,6 +2362,19 @@ ${body}
1724
2362
  async function installedApp(name) {
1725
2363
  return APP_NAME_PATTERN.test(name) && await store.readAppRecord(name) !== void 0;
1726
2364
  }
2365
+ app.get("/api/usage", async (c) => {
2366
+ const records = await store.listInstalled();
2367
+ const summaries = await Promise.all(records.map(async (record) => ({
2368
+ name: record.name,
2369
+ ...await usage.summary(record.name)
2370
+ })));
2371
+ return c.json({
2372
+ date: utcDay(),
2373
+ retentionDays: 90,
2374
+ apps: summaries,
2375
+ totals: combineSummaries(summaries)
2376
+ });
2377
+ });
1727
2378
  app.get("/api/apps/:name/usage", async (c) => {
1728
2379
  const name = c.req.param("name");
1729
2380
  if (!await installedApp(name)) return c.json({ error: `App "${name}" is not installed` }, 404);
@@ -2008,6 +2659,7 @@ ${body}
2008
2659
  return {
2009
2660
  app,
2010
2661
  scheduler,
2662
+ auth,
2011
2663
  async close() {
2012
2664
  scheduler.stop();
2013
2665
  const open = [...sessions.values()];
@@ -2123,11 +2775,28 @@ var Store = class {
2123
2775
  await writeJson(this.appJobStatePath(name), state);
2124
2776
  }
2125
2777
  async readUsageState(name) {
2126
- return { days: (await readJson(this.appUsagePath(name)))?.days ?? [] };
2778
+ const state = await readJson(this.appUsagePath(name));
2779
+ return {
2780
+ days: state?.days ?? [],
2781
+ ...state?.months !== void 0 && { months: state.months }
2782
+ };
2127
2783
  }
2128
2784
  async writeUsageState(name, state) {
2129
2785
  await writeJson(this.appUsagePath(name), state);
2130
2786
  }
2787
+ get authStatePath() {
2788
+ return join(this.root, "auth.json");
2789
+ }
2790
+ async readAuthState() {
2791
+ const state = await readJson(this.authStatePath);
2792
+ return {
2793
+ tokens: state?.tokens ?? [],
2794
+ sessions: state?.sessions ?? []
2795
+ };
2796
+ }
2797
+ async writeAuthState(state) {
2798
+ await writeJson(this.authStatePath, state);
2799
+ }
2131
2800
  async listInstalled() {
2132
2801
  let entries;
2133
2802
  try {
@@ -2146,6 +2815,6 @@ var Store = class {
2146
2815
  }
2147
2816
  };
2148
2817
  //#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 };
2818
+ 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
2819
 
2151
- //# sourceMappingURL=store-VoJe_U1Q.mjs.map
2820
+ //# sourceMappingURL=store-BNZqhcwm.mjs.map