fest-gateway 0.1.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.
Files changed (161) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -0
  3. package/dist/cli/claude.js +326 -0
  4. package/dist/cli/claude.js.map +1 -0
  5. package/dist/cli/config.js +59 -0
  6. package/dist/cli/config.js.map +1 -0
  7. package/dist/cli/login.js +130 -0
  8. package/dist/cli/login.js.map +1 -0
  9. package/dist/cli/logout.js +20 -0
  10. package/dist/cli/logout.js.map +1 -0
  11. package/dist/cli/whoami.js +34 -0
  12. package/dist/cli/whoami.js.map +1 -0
  13. package/dist/server/adapters/anthropic.js +74 -0
  14. package/dist/server/adapters/anthropic.js.map +1 -0
  15. package/dist/server/adapters/fireworks.js +54 -0
  16. package/dist/server/adapters/fireworks.js.map +1 -0
  17. package/dist/server/adapters/index.js +16 -0
  18. package/dist/server/adapters/index.js.map +1 -0
  19. package/dist/server/adapters/registry.js +38 -0
  20. package/dist/server/adapters/registry.js.map +1 -0
  21. package/dist/server/adapters/rewrite.js +152 -0
  22. package/dist/server/adapters/rewrite.js.map +1 -0
  23. package/dist/server/adapters/url.js +24 -0
  24. package/dist/server/adapters/url.js.map +1 -0
  25. package/dist/server/api/auth.js +213 -0
  26. package/dist/server/api/auth.js.map +1 -0
  27. package/dist/server/api/live.js +108 -0
  28. package/dist/server/api/live.js.map +1 -0
  29. package/dist/server/api/models.js +129 -0
  30. package/dist/server/api/models.js.map +1 -0
  31. package/dist/server/api/oauth.js +300 -0
  32. package/dist/server/api/oauth.js.map +1 -0
  33. package/dist/server/api/routes.js +164 -0
  34. package/dist/server/api/routes.js.map +1 -0
  35. package/dist/server/api/routing-view.js +47 -0
  36. package/dist/server/api/routing-view.js.map +1 -0
  37. package/dist/server/auth/accounts.js +111 -0
  38. package/dist/server/auth/accounts.js.map +1 -0
  39. package/dist/server/auth/gateway-401.js +85 -0
  40. package/dist/server/auth/gateway-401.js.map +1 -0
  41. package/dist/server/auth/guard.js +74 -0
  42. package/dist/server/auth/guard.js.map +1 -0
  43. package/dist/server/auth/oauth.js +43 -0
  44. package/dist/server/auth/oauth.js.map +1 -0
  45. package/dist/server/auth/password.js +104 -0
  46. package/dist/server/auth/password.js.map +1 -0
  47. package/dist/server/auth/posture.js +145 -0
  48. package/dist/server/auth/posture.js.map +1 -0
  49. package/dist/server/auth/session.js +161 -0
  50. package/dist/server/auth/session.js.map +1 -0
  51. package/dist/server/bin/fest.js +589 -0
  52. package/dist/server/bin/fest.js.map +1 -0
  53. package/dist/server/config.js +109 -0
  54. package/dist/server/config.js.map +1 -0
  55. package/dist/server/credentials/provider.js +70 -0
  56. package/dist/server/credentials/provider.js.map +1 -0
  57. package/dist/server/credentials/resolve.js +89 -0
  58. package/dist/server/credentials/resolve.js.map +1 -0
  59. package/dist/server/http/body.js +66 -0
  60. package/dist/server/http/body.js.map +1 -0
  61. package/dist/server/http/errors.js +144 -0
  62. package/dist/server/http/errors.js.map +1 -0
  63. package/dist/server/http/headers.js +189 -0
  64. package/dist/server/http/headers.js.map +1 -0
  65. package/dist/server/http/pipe.js +97 -0
  66. package/dist/server/http/pipe.js.map +1 -0
  67. package/dist/server/http/server.js +226 -0
  68. package/dist/server/http/server.js.map +1 -0
  69. package/dist/server/http/sse.js +167 -0
  70. package/dist/server/http/sse.js.map +1 -0
  71. package/dist/server/http/static.js +138 -0
  72. package/dist/server/http/static.js.map +1 -0
  73. package/dist/server/ingest/live-bus.js +67 -0
  74. package/dist/server/ingest/live-bus.js.map +1 -0
  75. package/dist/server/ingest/sink.js +141 -0
  76. package/dist/server/ingest/sink.js.map +1 -0
  77. package/dist/server/log.js +31 -0
  78. package/dist/server/log.js.map +1 -0
  79. package/dist/server/pipeline/count-tokens.js +48 -0
  80. package/dist/server/pipeline/count-tokens.js.map +1 -0
  81. package/dist/server/pipeline/dispatch.js +138 -0
  82. package/dist/server/pipeline/dispatch.js.map +1 -0
  83. package/dist/server/pipeline/passthrough.js +392 -0
  84. package/dist/server/pipeline/passthrough.js.map +1 -0
  85. package/dist/server/pipeline/preflight.js +66 -0
  86. package/dist/server/pipeline/preflight.js.map +1 -0
  87. package/dist/server/pipeline/record.js +49 -0
  88. package/dist/server/pipeline/record.js.map +1 -0
  89. package/dist/server/pipeline/substitute.js +287 -0
  90. package/dist/server/pipeline/substitute.js.map +1 -0
  91. package/dist/server/routes/resolve.js +176 -0
  92. package/dist/server/routes/resolve.js.map +1 -0
  93. package/dist/server/routes/table.js +247 -0
  94. package/dist/server/routes/table.js.map +1 -0
  95. package/dist/server/routes/watch.js +101 -0
  96. package/dist/server/routes/watch.js.map +1 -0
  97. package/dist/server/secret/fingerprint.js +107 -0
  98. package/dist/server/secret/fingerprint.js.map +1 -0
  99. package/dist/server/secret/non-persistable.js +69 -0
  100. package/dist/server/secret/non-persistable.js.map +1 -0
  101. package/dist/server/store/audit.js +53 -0
  102. package/dist/server/store/audit.js.map +1 -0
  103. package/dist/server/store/bootstrap.js +53 -0
  104. package/dist/server/store/bootstrap.js.map +1 -0
  105. package/dist/server/store/db.js +157 -0
  106. package/dist/server/store/db.js.map +1 -0
  107. package/dist/server/store/ids.js +14 -0
  108. package/dist/server/store/ids.js.map +1 -0
  109. package/dist/server/store/migrations/001-init.sql +213 -0
  110. package/dist/server/store/migrations/002-feed-and-error-indexes.sql +26 -0
  111. package/dist/server/store/migrations/003-routing.sql +42 -0
  112. package/dist/server/store/migrations/004-admin-auth.sql +72 -0
  113. package/dist/server/store/migrations/005-notional-cost.sql +28 -0
  114. package/dist/server/store/queries.js +904 -0
  115. package/dist/server/store/queries.js.map +1 -0
  116. package/dist/server/store/retention.js +79 -0
  117. package/dist/server/store/retention.js.map +1 -0
  118. package/dist/server/store/seed.js +203 -0
  119. package/dist/server/store/seed.js.map +1 -0
  120. package/dist/server/store/tokens.js +133 -0
  121. package/dist/server/store/tokens.js.map +1 -0
  122. package/dist/server/store/write.js +330 -0
  123. package/dist/server/store/write.js.map +1 -0
  124. package/dist/server/usage/accumulator.js +209 -0
  125. package/dist/server/usage/accumulator.js.map +1 -0
  126. package/dist/server/usage/cost.js +57 -0
  127. package/dist/server/usage/cost.js.map +1 -0
  128. package/dist/server/usage/prices/catalog.js +136 -0
  129. package/dist/server/usage/prices/catalog.js.map +1 -0
  130. package/dist/server/usage/prices/refresh.js +125 -0
  131. package/dist/server/usage/prices/refresh.js.map +1 -0
  132. package/dist/server/usage/prices/snapshot.json.gz +0 -0
  133. package/dist/server/usage/prices/table.js +102 -0
  134. package/dist/server/usage/prices/table.js.map +1 -0
  135. package/dist/server/usage/pricing.js +103 -0
  136. package/dist/server/usage/pricing.js.map +1 -0
  137. package/dist/shared/api.js +19 -0
  138. package/dist/shared/api.js.map +1 -0
  139. package/dist/shared/base-models.js +28 -0
  140. package/dist/shared/base-models.js.map +1 -0
  141. package/dist/shared/demotion-vars.js +17 -0
  142. package/dist/shared/demotion-vars.js.map +1 -0
  143. package/dist/shared/series.js +50 -0
  144. package/dist/shared/series.js.map +1 -0
  145. package/dist/shared/types.js +40 -0
  146. package/dist/shared/types.js.map +1 -0
  147. package/dist/web/dist/assets/index-8HlKsJqt.css +2 -0
  148. package/dist/web/dist/assets/index-DQ7fyPVm.js +56 -0
  149. package/dist/web/dist/assets/index-DQ7fyPVm.js.map +1 -0
  150. package/dist/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  151. package/dist/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  152. package/dist/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  153. package/dist/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  154. package/dist/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  155. package/dist/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  156. package/dist/web/dist/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
  157. package/dist/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  158. package/dist/web/dist/index.html +44 -0
  159. package/dist/web/dist/nearform-icon.svg +4 -0
  160. package/package.json +82 -0
  161. package/routes.example.json +22 -0
@@ -0,0 +1,53 @@
1
+ /**
2
+ * First-run bootstrap.
3
+ *
4
+ * Fest ships single-org: one row, seeded on first boot. Every table already
5
+ * carries `org_id` so that stays an implementation detail rather than a
6
+ * migration later.
7
+ */
8
+ import { newId } from "./ids.js";
9
+ export const DEFAULT_ORG_SLUG = "default";
10
+ export function ensureOrg(store, slug = DEFAULT_ORG_SLUG, name = "Default") {
11
+ const existing = store.db
12
+ .prepare(`SELECT id, slug, name FROM orgs WHERE slug = ?`)
13
+ .get(slug);
14
+ if (existing !== undefined)
15
+ return existing;
16
+ const org = { id: newId("org"), slug, name };
17
+ store.db
18
+ .prepare(`INSERT INTO orgs (id, slug, name, created_at) VALUES (?, ?, ?, ?)`)
19
+ .run(org.id, org.slug, org.name, Date.now());
20
+ return org;
21
+ }
22
+ /**
23
+ * Idempotent by (org, email) so `seed` can be re-run safely — which matters
24
+ * because migrations and seeding both run on boot in a container.
25
+ */
26
+ export function ensureUser(store, args) {
27
+ const existing = store.db
28
+ .prepare(`SELECT id, org_id, email, role FROM users WHERE org_id = ? AND email = ?`)
29
+ .get(args.orgId, args.email);
30
+ if (existing !== undefined) {
31
+ return { id: existing.id, orgId: existing.org_id, email: existing.email, role: existing.role };
32
+ }
33
+ const user = {
34
+ id: newId("usr"),
35
+ orgId: args.orgId,
36
+ email: args.email,
37
+ role: args.role ?? "member",
38
+ };
39
+ store.db
40
+ .prepare(`INSERT INTO users (id, org_id, email, display_name, role, created_at)
41
+ VALUES (?, ?, ?, ?, ?, ?)`)
42
+ .run(user.id, user.orgId, user.email, args.displayName ?? "", user.role, Date.now());
43
+ return user;
44
+ }
45
+ export function findUserByEmail(store, orgId, email) {
46
+ const row = store.db
47
+ .prepare(`SELECT id, org_id, email, role FROM users WHERE org_id = ? AND email = ?`)
48
+ .get(orgId, email);
49
+ if (row === undefined)
50
+ return null;
51
+ return { id: row.id, orgId: row.org_id, email: row.email, role: row.role };
52
+ }
53
+ //# sourceMappingURL=bootstrap.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bootstrap.js","sourceRoot":"","sources":["../../../server/store/bootstrap.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAEjC,MAAM,CAAC,MAAM,gBAAgB,GAAG,SAAS,CAAC;AAe1C,MAAM,UAAU,SAAS,CAAC,KAAY,EAAE,IAAI,GAAG,gBAAgB,EAAE,IAAI,GAAG,SAAS;IAC/E,MAAM,QAAQ,GAAG,KAAK,CAAC,EAAE;SACtB,OAAO,CAAC,gDAAgD,CAAC;SACzD,GAAG,CAAC,IAAI,CAA2D,CAAC;IACvE,IAAI,QAAQ,KAAK,SAAS;QAAE,OAAO,QAAQ,CAAC;IAE5C,MAAM,GAAG,GAAQ,EAAE,EAAE,EAAE,KAAK,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC;IAClD,KAAK,CAAC,EAAE;SACL,OAAO,CAAC,mEAAmE,CAAC;SAC5E,GAAG,CAAC,GAAG,CAAC,EAAE,EAAE,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IAC/C,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,UAAU,CACxB,KAAY,EACZ,IAAiG;IAEjG,MAAM,QAAQ,GAAG,KAAK,CAAC,EAAE;SACtB,OAAO,CAAC,0EAA0E,CAAC;SACnF,GAAG,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,KAAK,CAEhB,CAAC;IACd,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;QAC3B,OAAO,EAAE,EAAE,EAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,EAAE,QAAQ,CAAC,MAAM,EAAE,KAAK,EAAE,QAAQ,CAAC,KAAK,EAAE,IAAI,EAAE,QAAQ,CAAC,IAAI,EAAE,CAAC;IACjG,CAAC;IAED,MAAM,IAAI,GAAS;QACjB,EAAE,EAAE,KAAK,CAAC,KAAK,CAAC;QAChB,KAAK,EAAE,IAAI,CAAC,KAAK;QACjB,KAAK,EAAE,IAAI,CAAC,KAAK;QACjB,IAAI,EAAE,IAAI,CAAC,IAAI,IAAI,QAAQ;KAC5B,CAAC;IACF,KAAK,CAAC,EAAE;SACL,OAAO,CACN;iCAC2B,CAC5B;SACA,GAAG,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,WAAW,IAAI,EAAE,EAAE,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IACvF,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,KAAY,EAAE,KAAa,EAAE,KAAa;IACxE,MAAM,GAAG,GAAG,KAAK,CAAC,EAAE;SACjB,OAAO,CAAC,0EAA0E,CAAC;SACnF,GAAG,CAAC,KAAK,EAAE,KAAK,CAEN,CAAC;IACd,IAAI,GAAG,KAAK,SAAS;QAAE,OAAO,IAAI,CAAC;IACnC,OAAO,EAAE,EAAE,EAAE,GAAG,CAAC,EAAE,EAAE,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,KAAK,EAAE,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,CAAC;AAC7E,CAAC"}
@@ -0,0 +1,157 @@
1
+ /**
2
+ * SQLite handle, pragmas, and forward-only migrations.
3
+ *
4
+ * ── The synchronous-API constraint, which is load-bearing ────────────────────
5
+ *
6
+ * `node:sqlite`'s `DatabaseSync` is exactly what it says: EVERY statement runs
7
+ * on the main thread and blocks the event loop for its full duration. In a
8
+ * process whose day job is relaying long-lived streaming HTTP responses, that
9
+ * is normally disqualifying — a 40ms dashboard aggregate would stall every
10
+ * in-flight proxied stream by 40ms, and Claude Code users would see it as
11
+ * stutter in their terminal.
12
+ *
13
+ * It is acceptable here only because of a structural rule: THE REQUEST HOT PATH
14
+ * NEVER TOUCHES THIS DATABASE. Identity lookups are served from an in-memory
15
+ * cache, and usage is handed to a queue that batches into one transaction well
16
+ * off the request path (see server/ingest/sink.ts). The only synchronous
17
+ * queries are the metering writer's batch and the dashboard's own requests.
18
+ *
19
+ * So: if you are about to add a `store.db.prepare(...)` call inside a proxied
20
+ * request's lifecycle, don't. Move it behind the queue, or move the database to
21
+ * a worker thread first. This comment is the guardrail.
22
+ *
23
+ * ── Pragmas ─────────────────────────────────────────────────────────────────
24
+ *
25
+ * WAL is the one that matters. Under the default rollback journal a writer
26
+ * excludes all readers, so a dashboard query could block the metering writer
27
+ * (and vice versa) — and because the API is synchronous, "blocked" means the
28
+ * whole process is parked, streams included. WAL lets readers and the single
29
+ * writer proceed concurrently, which is precisely the shape of this workload:
30
+ * one batching writer, several bursty readers.
31
+ *
32
+ * Note: depending on Node version, importing `node:sqlite` may print an
33
+ * ExperimentalWarning. It is expected; we deliberately do not suppress it
34
+ * process-wide, because doing so would also hide unrelated warnings.
35
+ */
36
+ import { DatabaseSync } from "node:sqlite";
37
+ import { readdirSync, readFileSync } from "node:fs";
38
+ import { join } from "node:path";
39
+ import { log } from "../log.js";
40
+ const MIGRATIONS_DIR = join(import.meta.dirname, "migrations");
41
+ export function openStore(path) {
42
+ const db = new DatabaseSync(path);
43
+ // Order matters: WAL first so the journal switch happens before anything
44
+ // else writes, and busy_timeout last so it covers the rest of the session.
45
+ db.exec("PRAGMA journal_mode = WAL");
46
+ // NORMAL, not FULL: we fsync at checkpoints rather than every commit. The
47
+ // exposure is losing the last few hundred milliseconds of metering on an OS
48
+ // crash, which is an acceptable trade for not fsyncing per batch. Metering is
49
+ // observability, not ledger.
50
+ db.exec("PRAGMA synchronous = NORMAL");
51
+ db.exec("PRAGMA foreign_keys = ON");
52
+ // Another process (a backup, a CLI) can hold the write lock briefly; wait
53
+ // rather than throwing SQLITE_BUSY straight into the caller.
54
+ db.exec("PRAGMA busy_timeout = 5000");
55
+ /**
56
+ * NOT RE-ENTRANT. SQLite has no nested transactions without SAVEPOINT, and
57
+ * silently flattening a nested call would mean an inner "rollback" that
58
+ * actually discards the outer caller's work. We detect and throw instead:
59
+ * a loud error at development time beats a partially-applied batch in
60
+ * production.
61
+ */
62
+ let depth = 0;
63
+ const store = {
64
+ db,
65
+ transaction(fn) {
66
+ if (depth > 0) {
67
+ throw new Error("Store.transaction is not re-entrant; flatten the call or use SAVEPOINT");
68
+ }
69
+ depth = 1;
70
+ // BEGIN IMMEDIATE, not BEGIN: a deferred transaction takes the write
71
+ // lock at the first write, so contention surfaces halfway through a
72
+ // batch where we would have to unwind. IMMEDIATE takes it up front and
73
+ // lets busy_timeout do the waiting before we have done any work.
74
+ db.exec("BEGIN IMMEDIATE");
75
+ try {
76
+ const result = fn();
77
+ db.exec("COMMIT");
78
+ return result;
79
+ }
80
+ catch (err) {
81
+ try {
82
+ db.exec("ROLLBACK");
83
+ }
84
+ catch (rollbackErr) {
85
+ // A failed rollback must not mask the original error.
86
+ log.warn("store rollback failed", { err: String(rollbackErr) });
87
+ }
88
+ throw err;
89
+ }
90
+ finally {
91
+ depth = 0;
92
+ }
93
+ },
94
+ close() {
95
+ db.close();
96
+ },
97
+ };
98
+ return store;
99
+ }
100
+ function userVersion(db) {
101
+ const row = db.prepare("PRAGMA user_version").get();
102
+ return Number(row?.user_version ?? 0);
103
+ }
104
+ /** Numeric prefix of a migration filename, e.g. "007-add-x.sql" → 7. */
105
+ function migrationVersion(file) {
106
+ const match = /^(\d+)/.exec(file);
107
+ return match ? Number(match[1]) : Number.NaN;
108
+ }
109
+ /**
110
+ * Apply every migration newer than `PRAGMA user_version`.
111
+ *
112
+ * Forward-only, by design and with precedent: there are no down migrations
113
+ * anywhere in Fest. Reversing a schema change correctly requires the old data
114
+ * to still exist, which a destructive migration by definition destroyed, so a
115
+ * "down" script gives false confidence. The rollback story is restoring a
116
+ * volume snapshot, which is also the only story that survives a bad deploy that
117
+ * corrupted rows rather than columns.
118
+ *
119
+ * Idempotent: a second call sees user_version already at the highest prefix and
120
+ * applies nothing.
121
+ */
122
+ export function migrate(store) {
123
+ const current = userVersion(store.db);
124
+ const files = readdirSync(MIGRATIONS_DIR)
125
+ .filter((f) => f.endsWith(".sql"))
126
+ // Sort by filename, not by parsed number: the numeric prefixes are
127
+ // zero-padded, so lexical order is numeric order, and a filename that
128
+ // sorts oddly is a naming bug we want to see rather than paper over.
129
+ .sort();
130
+ let version = current;
131
+ let applied = 0;
132
+ for (const file of files) {
133
+ const fileVersion = migrationVersion(file);
134
+ if (!Number.isInteger(fileVersion)) {
135
+ throw new Error(`migration ${file} has no numeric prefix`);
136
+ }
137
+ if (fileVersion <= current)
138
+ continue;
139
+ const sql = readFileSync(join(MIGRATIONS_DIR, file), "utf8");
140
+ // Each file in its own transaction: SQLite applies DDL transactionally, so
141
+ // a syntax error in migration 3 leaves 1 and 2 committed and the version
142
+ // at 2 — a retry after fixing the file resumes from exactly there.
143
+ store.transaction(() => {
144
+ store.db.exec(sql);
145
+ });
146
+ applied += 1;
147
+ version = fileVersion;
148
+ }
149
+ if (version !== current) {
150
+ // user_version cannot be parameterised, hence the interpolation; the value
151
+ // came from a filename we just proved is an integer.
152
+ store.db.exec(`PRAGMA user_version = ${version}`);
153
+ log.info("store migrated", { from: current, to: version, applied });
154
+ }
155
+ return { applied, version };
156
+ }
157
+ //# sourceMappingURL=db.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"db.js","sourceRoot":"","sources":["../../../server/store/db.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Entity ids: `<kind>_<hex>`.
3
+ *
4
+ * Kind-prefixed so an id is self-describing in a log line or a URL, and so a
5
+ * user id pasted where a token id belongs fails obviously instead of silently
6
+ * matching nothing.
7
+ */
8
+ import { randomUUID } from "node:crypto";
9
+ export function newId(prefix) {
10
+ // Dashes stripped: the id ends up in URLs and CLI args, and 32 hex chars
11
+ // avoid any question about what is a separator.
12
+ return `${prefix}_${randomUUID().replaceAll("-", "")}`;
13
+ }
14
+ //# sourceMappingURL=ids.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ids.js","sourceRoot":"","sources":["../../../server/store/ids.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAIzC,MAAM,UAAU,KAAK,CAAC,MAAgB;IACpC,yEAAyE;IACzE,gDAAgD;IAChD,OAAO,GAAG,MAAM,IAAI,UAAU,EAAE,CAAC,UAAU,CAAC,GAAG,EAAE,EAAE,CAAC,EAAE,CAAC;AACzD,CAAC"}
@@ -0,0 +1,213 @@
1
+ -- Fest schema, migration 001.
2
+ --
3
+ -- Conventions:
4
+ -- * Timestamps are INTEGER epoch milliseconds. No timezone class of bug.
5
+ -- * Booleans are INTEGER 0/1.
6
+ -- * Entity ids are TEXT with a kind prefix (org_, usr_, tok_) so they are
7
+ -- safe in URLs and self-describing in logs.
8
+ -- * `requests` uses the integer rowid as its primary key: monotonic, cheap,
9
+ -- and it gives keyset pagination for free. `id` is the external uuid.
10
+ -- * org_id is on every tenant table from day one even though the spike seeds
11
+ -- a single org. The column costs nothing now; retrofitting it across a
12
+ -- request table and every query later costs a week and a data migration.
13
+
14
+ PRAGMA foreign_keys = ON;
15
+
16
+ CREATE TABLE orgs (
17
+ id TEXT PRIMARY KEY,
18
+ slug TEXT NOT NULL UNIQUE,
19
+ name TEXT NOT NULL,
20
+ created_at INTEGER NOT NULL,
21
+ -- retention_days, member_visibility, etc. Kept as JSON so adding a setting
22
+ -- is not a migration.
23
+ settings_json TEXT NOT NULL DEFAULT '{}'
24
+ );
25
+
26
+ CREATE TABLE users (
27
+ id TEXT PRIMARY KEY,
28
+ org_id TEXT NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
29
+ email TEXT NOT NULL,
30
+ display_name TEXT NOT NULL DEFAULT '',
31
+ role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
32
+ disabled_at INTEGER,
33
+ created_at INTEGER NOT NULL,
34
+ UNIQUE (org_id, email)
35
+ );
36
+
37
+ -- What a developer's Claude Code presents to Fest so usage can be attributed.
38
+ --
39
+ -- This is NOT an upstream provider credential. It is never forwarded to
40
+ -- Anthropic, and it deliberately cannot live in ANTHROPIC_API_KEY or
41
+ -- ANTHROPIC_AUTH_TOKEN, because setting either makes Claude Code abandon
42
+ -- subscription auth. It travels in the URL path or an X-Fest-Token header.
43
+ CREATE TABLE identity_tokens (
44
+ id TEXT PRIMARY KEY,
45
+ org_id TEXT NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
46
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
47
+ name TEXT NOT NULL DEFAULT '',
48
+ -- sha256(raw), hex. The raw token is shown exactly once, at creation.
49
+ --
50
+ -- sha256 and not scrypt/bcrypt on purpose: these are 32 bytes of
51
+ -- randomBytes, so there is no dictionary to attack and nothing for a slow
52
+ -- KDF to buy — while a slow KDF would add its cost to EVERY proxied request.
53
+ -- User passwords, when they arrive, do use scrypt. Do not "fix" this.
54
+ token_hash TEXT NOT NULL UNIQUE,
55
+ -- Display-only, e.g. "fest_7Fq2…". Lets the UI and support identify a token
56
+ -- without ever holding it.
57
+ token_prefix TEXT NOT NULL,
58
+ created_at INTEGER NOT NULL,
59
+ expires_at INTEGER,
60
+ revoked_at INTEGER,
61
+ -- Updated coarsely (batched), never per request: it would double the write
62
+ -- volume for information nobody needs to the second.
63
+ last_used_at INTEGER
64
+ );
65
+
66
+ CREATE INDEX identity_tokens_live ON identity_tokens (token_hash) WHERE revoked_at IS NULL;
67
+ CREATE INDEX identity_tokens_user ON identity_tokens (org_id, user_id);
68
+
69
+ -- One row per completed upstream call. The hot table.
70
+ CREATE TABLE requests (
71
+ seq INTEGER PRIMARY KEY,
72
+ id TEXT NOT NULL UNIQUE,
73
+ -- Deliberately NOT a foreign key, unlike users/identity_tokens: this is the
74
+ -- hot insert path and an FK check per row buys nothing here, since org_id is
75
+ -- supplied by the server rather than a client. Please do not "fix" the
76
+ -- asymmetry by adding REFERENCES.
77
+ org_id TEXT NOT NULL,
78
+ -- Nullable: a request with no identity token is recorded as unattributed
79
+ -- rather than dropped. We never guess who it was.
80
+ user_id TEXT,
81
+ token_id TEXT,
82
+
83
+ started_at INTEGER NOT NULL,
84
+ ended_at INTEGER NOT NULL,
85
+
86
+ -- Credential dimension. This is the compliance story, so it is first-class:
87
+ -- an admin must be able to see at a glance which requests ran on a
88
+ -- developer's own subscription versus a server-held key.
89
+ posture TEXT NOT NULL,
90
+ identity_carrier TEXT NOT NULL,
91
+ caller_fingerprint TEXT,
92
+ -- Fingerprint only. A bearer is never stored; there is no column for one.
93
+ credential_fingerprint TEXT,
94
+ credential_origin TEXT NOT NULL,
95
+
96
+ -- Claude Code supplies this itself, so session grouping is free.
97
+ session_id TEXT,
98
+
99
+ requested_model TEXT,
100
+ served_model TEXT,
101
+ upstream TEXT NOT NULL,
102
+
103
+ stream INTEGER NOT NULL DEFAULT 0,
104
+ status TEXT NOT NULL,
105
+ http_status INTEGER,
106
+ error_type TEXT,
107
+ error_message TEXT,
108
+ partial INTEGER NOT NULL DEFAULT 0,
109
+
110
+ -- DISJOINT billing buckets. input_tokens excludes both cache reads and cache
111
+ -- writes; context size is the sum of all four. Folding cache reads into
112
+ -- input is an order-of-magnitude error on a cache-heavy agent workload,
113
+ -- which Claude Code is.
114
+ input_tokens INTEGER NOT NULL DEFAULT 0,
115
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
116
+ cache_write_5m_tokens INTEGER NOT NULL DEFAULT 0,
117
+ cache_write_1h_tokens INTEGER NOT NULL DEFAULT 0,
118
+ output_tokens INTEGER NOT NULL DEFAULT 0,
119
+ web_searches INTEGER NOT NULL DEFAULT 0,
120
+ service_tier TEXT,
121
+
122
+ -- NULL means no dollar figure applies or is available. It does NOT mean zero.
123
+ -- cost_basis says which: 'subscription' (the developer's own plan absorbed
124
+ -- it, so there is no org spend), 'list' (priced), or 'none' (unknown model).
125
+ -- A SUM() over this column silently skips NULLs, which is why the rollup
126
+ -- carries unpriced_requests alongside the priced sum.
127
+ cost_usd REAL,
128
+ cost_basis TEXT NOT NULL DEFAULT 'none',
129
+
130
+ ttfb_ms INTEGER,
131
+ duration_ms INTEGER NOT NULL DEFAULT 0,
132
+ bytes_in INTEGER NOT NULL DEFAULT 0,
133
+ bytes_out INTEGER NOT NULL DEFAULT 0,
134
+
135
+ upstream_request_id TEXT,
136
+
137
+ -- Anthropic's unified quota headers, on every response. For a subscription
138
+ -- developer THIS is the scarce resource, not dollars, so it is stored as
139
+ -- queryable columns rather than opaque JSON.
140
+ rl_status TEXT,
141
+ rl_5h_utilization REAL,
142
+ rl_5h_status TEXT,
143
+ rl_5h_reset_at INTEGER,
144
+ rl_7d_utilization REAL,
145
+ rl_7d_status TEXT,
146
+ rl_7d_reset_at INTEGER,
147
+ rl_claim TEXT,
148
+ rl_overage_status TEXT,
149
+ rl_overage_reason TEXT,
150
+
151
+ client_version TEXT
152
+ );
153
+
154
+ -- One index per dashboard query shape. None speculative.
155
+ CREATE INDEX requests_feed ON requests (org_id, started_at DESC);
156
+ CREATE INDEX requests_user ON requests (org_id, user_id, started_at DESC);
157
+ CREATE INDEX requests_model ON requests (org_id, served_model, started_at DESC);
158
+ CREATE INDEX requests_credential ON requests (org_id, credential_origin, started_at DESC);
159
+ CREATE INDEX requests_session ON requests (org_id, session_id, started_at DESC);
160
+ -- Partial: the errors view then scans ~1% of rows instead of all of them.
161
+ CREATE INDEX requests_errors ON requests (org_id, started_at DESC) WHERE error_type IS NOT NULL;
162
+
163
+ -- Hourly rollups. Survive raw-row deletion and serve every chart beyond the
164
+ -- live window, so long-range trends keep working after the PII-bearing rows
165
+ -- have aged out.
166
+ CREATE TABLE usage_hourly (
167
+ org_id TEXT NOT NULL,
168
+ hour_start INTEGER NOT NULL,
169
+ -- '' is the "all" bucket for each dimension, so one table serves both
170
+ -- per-user and org-wide queries.
171
+ user_id TEXT NOT NULL DEFAULT '',
172
+ served_model TEXT NOT NULL DEFAULT '',
173
+ credential_origin TEXT NOT NULL DEFAULT '',
174
+ cost_basis TEXT NOT NULL DEFAULT '',
175
+
176
+ requests INTEGER NOT NULL DEFAULT 0,
177
+ errors INTEGER NOT NULL DEFAULT 0,
178
+
179
+ input_tokens INTEGER NOT NULL DEFAULT 0,
180
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
181
+ cache_write_5m_tokens INTEGER NOT NULL DEFAULT 0,
182
+ cache_write_1h_tokens INTEGER NOT NULL DEFAULT 0,
183
+ output_tokens INTEGER NOT NULL DEFAULT 0,
184
+ web_searches INTEGER NOT NULL DEFAULT 0,
185
+
186
+ -- Sum of PRICED rows only, plus the count of rows we could not price, so a
187
+ -- total can be rendered honestly as "$12.3456 (+3 n/a)" rather than being
188
+ -- quietly short.
189
+ cost_usd REAL NOT NULL DEFAULT 0,
190
+ unpriced_requests INTEGER NOT NULL DEFAULT 0,
191
+ -- Requests absorbed by a developer's own subscription: real usage, no org
192
+ -- spend. Counted separately so it is never added to a dollar total.
193
+ subscription_requests INTEGER NOT NULL DEFAULT 0,
194
+
195
+ duration_ms_sum INTEGER NOT NULL DEFAULT 0,
196
+ duration_ms_max INTEGER NOT NULL DEFAULT 0,
197
+ ttfb_ms_sum INTEGER NOT NULL DEFAULT 0,
198
+ ttfb_count INTEGER NOT NULL DEFAULT 0,
199
+
200
+ -- Fixed latency histogram buckets rather than percentiles: percentiles do
201
+ -- not merge across rollup rows, but bucket counts add. Boundaries are
202
+ -- <1s, <3s, <10s, <30s, <60s, >=60s.
203
+ lat_b0 INTEGER NOT NULL DEFAULT 0,
204
+ lat_b1 INTEGER NOT NULL DEFAULT 0,
205
+ lat_b2 INTEGER NOT NULL DEFAULT 0,
206
+ lat_b3 INTEGER NOT NULL DEFAULT 0,
207
+ lat_b4 INTEGER NOT NULL DEFAULT 0,
208
+ lat_b5 INTEGER NOT NULL DEFAULT 0,
209
+
210
+ PRIMARY KEY (org_id, hour_start, user_id, served_model, credential_origin, cost_basis)
211
+ ) WITHOUT ROWID;
212
+
213
+ CREATE INDEX usage_hourly_time ON usage_hourly (org_id, hour_start DESC);
@@ -0,0 +1,26 @@
1
+ -- Migration 002: fix two indexes that did not match how the code actually
2
+ -- queries. Both were found by building the read layer against 001.
3
+
4
+ -- 1. The keyset feed had no index.
5
+ --
6
+ -- The feed pages with `WHERE org_id = ? AND seq < ? ORDER BY seq DESC`, but
7
+ -- `requests_feed` is (org_id, started_at DESC) and cannot order by seq. SQLite
8
+ -- fell back to a descending rowid search: technically a SEARCH rather than a
9
+ -- SCAN, so it looked fine in a query plan, but it walks other orgs' rows to
10
+ -- find yours. Harmless at one org, wrong at ten.
11
+ CREATE INDEX requests_feed_seq ON requests (org_id, seq DESC);
12
+
13
+ -- 2. The errors partial index keyed off the wrong predicate.
14
+ --
15
+ -- 001 defined it as `error_type IS NOT NULL`, but "is an error" everywhere in
16
+ -- the code is `status <> 'ok'` — which is what the writer counts and what the
17
+ -- errors view filters on. The two disagree for exactly the rows that matter
18
+ -- most: `identity_denied` and `client_abort` are non-ok and carry no
19
+ -- error_type, so they were invisible to the index and the errors view could
20
+ -- not use it at all.
21
+ --
22
+ -- Aligning the index to the code rather than the reverse, because `status` is
23
+ -- the authoritative field: it is NOT NULL and always set, while error_type is
24
+ -- incidental detail.
25
+ DROP INDEX IF EXISTS requests_errors;
26
+ CREATE INDEX requests_errors ON requests (org_id, started_at DESC) WHERE status <> 'ok';
@@ -0,0 +1,42 @@
1
+ -- Phase 4: routing and credential visibility.
2
+ --
3
+ -- `pipeline` and `route_id` record WHICH decision served a request;
4
+ -- `credentials_considered` records WHY. The last is stored as JSON rather than
5
+ -- normalised into its own table on purpose: it is an append-only audit note
6
+ -- read whole and never queried across, and a join table would invite exactly
7
+ -- the partial read ("show me the credential" — which one?) that this field
8
+ -- exists to prevent.
9
+ --
10
+ -- The CHECK is the structural half of the never-persist-a-credential rule.
11
+ -- There is no column here a secret belongs in, and this makes that enforceable
12
+ -- rather than customary: anything secret-shaped fails the INSERT instead of
13
+ -- being written and noticed later, if ever.
14
+ --
15
+ -- ── Deliberately NOT added to usage_hourly ──────────────────────────────────
16
+ --
17
+ -- `pipeline` is not a rollup dimension. It would have to join the cube's
18
+ -- primary key, which means rebuilding the cube and revisiting the writer's key
19
+ -- de-duplication — and a mistake there multiplies every token count in the org
20
+ -- totals, which is precisely the bug that shipped and was caught in Phase 2.
21
+ --
22
+ -- The aggregate question is already answered without it: a substituted request
23
+ -- is recorded as `credential_origin = 'fallback_server'`, which IS a rollup
24
+ -- dimension. So "how much of our traffic ran on a server-held key" survives
25
+ -- retention, while per-ROUTE breakdowns read raw rows and therefore go blank
26
+ -- once those age out. That is the same honest limitation the error breakdown
27
+ -- already carries, and the right trade against destabilising every number on
28
+ -- the dashboard.
29
+
30
+ ALTER TABLE requests ADD COLUMN pipeline TEXT NOT NULL DEFAULT 'passthrough';
31
+ ALTER TABLE requests ADD COLUMN route_id TEXT;
32
+ ALTER TABLE requests ADD COLUMN credentials_considered TEXT NOT NULL DEFAULT '[]'
33
+ CHECK (
34
+ credentials_considered NOT LIKE '%sk-ant-%'
35
+ AND credentials_considered NOT LIKE '%sk-or-%'
36
+ AND credentials_considered NOT LIKE '%fw_%'
37
+ AND credentials_considered NOT LIKE '%Bearer %'
38
+ );
39
+
40
+ -- "Which requests did not run on the caller's own credential" is the compliance
41
+ -- question this phase exists to answer, so it gets an index rather than a scan.
42
+ CREATE INDEX requests_pipeline ON requests (org_id, pipeline, started_at DESC);
@@ -0,0 +1,72 @@
1
+ -- Fest schema, migration 004: dashboard accounts, sessions, audit log.
2
+ --
3
+ -- Two different authentication systems live in this database and must not be
4
+ -- confused:
5
+ --
6
+ -- identity_tokens — a developer's Claude Code proving who it is on the
7
+ -- PROXY path. Hot path. sha256, no KDF (see 001).
8
+ -- users.password_hash + sessions
9
+ -- — a human signing in to the DASHBOARD. Cold path, human
10
+ -- chosen secret, so scrypt with a per-password salt.
11
+ --
12
+ -- The proxy path never reads either of the tables below. That is what keeps a
13
+ -- slow KDF and a synchronous session lookup off a request that is relaying a
14
+ -- developer's stream.
15
+
16
+ -- NULL password_hash means "this user exists as an attribution target but
17
+ -- cannot sign in" — which is every user created by `fest token create`. A
18
+ -- dashboard account is granted deliberately, never as a side effect of a
19
+ -- developer being metered.
20
+ ALTER TABLE users ADD COLUMN password_hash TEXT;
21
+ ALTER TABLE users ADD COLUMN password_set_at INTEGER;
22
+
23
+ CREATE TABLE sessions (
24
+ id TEXT PRIMARY KEY,
25
+ org_id TEXT NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
26
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
27
+ -- sha256(raw). The raw value exists only in the operator's cookie, so a
28
+ -- database read — a backup, a support session, a leaked file — cannot be
29
+ -- turned into a live session.
30
+ token_hash TEXT NOT NULL UNIQUE,
31
+ created_at INTEGER NOT NULL,
32
+ -- Absolute cap. A session dies at this instant no matter how active it is,
33
+ -- so a stolen cookie has a bounded life even under constant use.
34
+ expires_at INTEGER NOT NULL,
35
+ -- Idle timeout, rolled forward as the session is used.
36
+ last_seen_at INTEGER NOT NULL,
37
+ revoked_at INTEGER,
38
+ -- Recorded for the audit trail, never for authorisation: both are trivially
39
+ -- forged, so binding a session to them would only lock out real users behind
40
+ -- a changing IP.
41
+ user_agent TEXT NOT NULL DEFAULT '',
42
+ ip TEXT NOT NULL DEFAULT ''
43
+ );
44
+
45
+ CREATE INDEX sessions_user ON sessions (org_id, user_id, created_at DESC);
46
+ CREATE INDEX sessions_expiry ON sessions (expires_at);
47
+
48
+ -- Who did what, as an administrative record rather than a debug log.
49
+ --
50
+ -- Deliberately a table and not the application log: logs rotate, are filtered
51
+ -- by level, and are the first thing lost when a container restarts. "When was
52
+ -- this token minted, and by whom" is a question asked months later.
53
+ CREATE TABLE audit_log (
54
+ seq INTEGER PRIMARY KEY,
55
+ org_id TEXT NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
56
+ at INTEGER NOT NULL,
57
+ -- NULL for an actor who never authenticated — a failed login has no user.
58
+ actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
59
+ -- What the actor called themselves: the email typed at a failed login, or
60
+ -- "cli" for a command run on the host. Survives the user row being deleted,
61
+ -- which is exactly when the record matters most.
62
+ actor_label TEXT NOT NULL DEFAULT '',
63
+ action TEXT NOT NULL,
64
+ target TEXT NOT NULL DEFAULT '',
65
+ outcome TEXT NOT NULL CHECK (outcome IN ('ok', 'denied', 'error')),
66
+ -- Never a credential, never a prompt. Enforced by test.
67
+ detail_json TEXT NOT NULL DEFAULT '{}',
68
+ ip TEXT NOT NULL DEFAULT ''
69
+ );
70
+
71
+ CREATE INDEX audit_log_recent ON audit_log (org_id, at DESC);
72
+ CREATE INDEX audit_log_actor ON audit_log (org_id, actor_user_id, at DESC);
@@ -0,0 +1,28 @@
1
+ -- Notional cost: what usage WOULD have cost at published API rates.
2
+ --
3
+ -- The motivating gap: subscription requests are the bulk of Fest's traffic and
4
+ -- carry no dollar figure at all, because `cost_usd` is org spend and a
5
+ -- developer's own plan absorbed them. That is correct for an invoice and
6
+ -- useless for a team lead asking what their Max seats deliver.
7
+ --
8
+ -- So this is a SECOND, PARALLEL figure, never a widening of the first:
9
+ --
10
+ -- * `cost_usd` remains org spend. Subscription rows stay NULL. Every existing
11
+ -- aggregate, guard and UI label is unchanged.
12
+ -- * `notional_cost_usd` is VALUE, populated on every path including
13
+ -- subscription. It is never added to `cost_usd` anywhere — the two live in
14
+ -- separate columns, separate SQL aggregates and separate dashboard stats
15
+ -- precisely so that summing them is never the easy thing to do.
16
+ --
17
+ -- Existing rows keep NULL / 0. They are genuinely unknown: back-filling them at
18
+ -- today's rates would invent a history that never happened, and rates drift.
19
+
20
+ ALTER TABLE requests ADD COLUMN notional_cost_usd REAL;
21
+
22
+ ALTER TABLE usage_hourly ADD COLUMN notional_cost_usd REAL NOT NULL DEFAULT 0;
23
+
24
+ -- The same honesty device as `unpriced_requests`, and needed for the same
25
+ -- reason: SUM() skips NULLs, so without a count the notional total looks
26
+ -- complete while silently omitting every model with no published rate.
27
+ -- Non-zero means `notional_cost_usd` is a lower bound.
28
+ ALTER TABLE usage_hourly ADD COLUMN notional_unpriced_requests INTEGER NOT NULL DEFAULT 0;