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.
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/dist/cli/claude.js +326 -0
- package/dist/cli/claude.js.map +1 -0
- package/dist/cli/config.js +59 -0
- package/dist/cli/config.js.map +1 -0
- package/dist/cli/login.js +130 -0
- package/dist/cli/login.js.map +1 -0
- package/dist/cli/logout.js +20 -0
- package/dist/cli/logout.js.map +1 -0
- package/dist/cli/whoami.js +34 -0
- package/dist/cli/whoami.js.map +1 -0
- package/dist/server/adapters/anthropic.js +74 -0
- package/dist/server/adapters/anthropic.js.map +1 -0
- package/dist/server/adapters/fireworks.js +54 -0
- package/dist/server/adapters/fireworks.js.map +1 -0
- package/dist/server/adapters/index.js +16 -0
- package/dist/server/adapters/index.js.map +1 -0
- package/dist/server/adapters/registry.js +38 -0
- package/dist/server/adapters/registry.js.map +1 -0
- package/dist/server/adapters/rewrite.js +152 -0
- package/dist/server/adapters/rewrite.js.map +1 -0
- package/dist/server/adapters/url.js +24 -0
- package/dist/server/adapters/url.js.map +1 -0
- package/dist/server/api/auth.js +213 -0
- package/dist/server/api/auth.js.map +1 -0
- package/dist/server/api/live.js +108 -0
- package/dist/server/api/live.js.map +1 -0
- package/dist/server/api/models.js +129 -0
- package/dist/server/api/models.js.map +1 -0
- package/dist/server/api/oauth.js +300 -0
- package/dist/server/api/oauth.js.map +1 -0
- package/dist/server/api/routes.js +164 -0
- package/dist/server/api/routes.js.map +1 -0
- package/dist/server/api/routing-view.js +47 -0
- package/dist/server/api/routing-view.js.map +1 -0
- package/dist/server/auth/accounts.js +111 -0
- package/dist/server/auth/accounts.js.map +1 -0
- package/dist/server/auth/gateway-401.js +85 -0
- package/dist/server/auth/gateway-401.js.map +1 -0
- package/dist/server/auth/guard.js +74 -0
- package/dist/server/auth/guard.js.map +1 -0
- package/dist/server/auth/oauth.js +43 -0
- package/dist/server/auth/oauth.js.map +1 -0
- package/dist/server/auth/password.js +104 -0
- package/dist/server/auth/password.js.map +1 -0
- package/dist/server/auth/posture.js +145 -0
- package/dist/server/auth/posture.js.map +1 -0
- package/dist/server/auth/session.js +161 -0
- package/dist/server/auth/session.js.map +1 -0
- package/dist/server/bin/fest.js +589 -0
- package/dist/server/bin/fest.js.map +1 -0
- package/dist/server/config.js +109 -0
- package/dist/server/config.js.map +1 -0
- package/dist/server/credentials/provider.js +70 -0
- package/dist/server/credentials/provider.js.map +1 -0
- package/dist/server/credentials/resolve.js +89 -0
- package/dist/server/credentials/resolve.js.map +1 -0
- package/dist/server/http/body.js +66 -0
- package/dist/server/http/body.js.map +1 -0
- package/dist/server/http/errors.js +144 -0
- package/dist/server/http/errors.js.map +1 -0
- package/dist/server/http/headers.js +189 -0
- package/dist/server/http/headers.js.map +1 -0
- package/dist/server/http/pipe.js +97 -0
- package/dist/server/http/pipe.js.map +1 -0
- package/dist/server/http/server.js +226 -0
- package/dist/server/http/server.js.map +1 -0
- package/dist/server/http/sse.js +167 -0
- package/dist/server/http/sse.js.map +1 -0
- package/dist/server/http/static.js +138 -0
- package/dist/server/http/static.js.map +1 -0
- package/dist/server/ingest/live-bus.js +67 -0
- package/dist/server/ingest/live-bus.js.map +1 -0
- package/dist/server/ingest/sink.js +141 -0
- package/dist/server/ingest/sink.js.map +1 -0
- package/dist/server/log.js +31 -0
- package/dist/server/log.js.map +1 -0
- package/dist/server/pipeline/count-tokens.js +48 -0
- package/dist/server/pipeline/count-tokens.js.map +1 -0
- package/dist/server/pipeline/dispatch.js +138 -0
- package/dist/server/pipeline/dispatch.js.map +1 -0
- package/dist/server/pipeline/passthrough.js +392 -0
- package/dist/server/pipeline/passthrough.js.map +1 -0
- package/dist/server/pipeline/preflight.js +66 -0
- package/dist/server/pipeline/preflight.js.map +1 -0
- package/dist/server/pipeline/record.js +49 -0
- package/dist/server/pipeline/record.js.map +1 -0
- package/dist/server/pipeline/substitute.js +287 -0
- package/dist/server/pipeline/substitute.js.map +1 -0
- package/dist/server/routes/resolve.js +176 -0
- package/dist/server/routes/resolve.js.map +1 -0
- package/dist/server/routes/table.js +247 -0
- package/dist/server/routes/table.js.map +1 -0
- package/dist/server/routes/watch.js +101 -0
- package/dist/server/routes/watch.js.map +1 -0
- package/dist/server/secret/fingerprint.js +107 -0
- package/dist/server/secret/fingerprint.js.map +1 -0
- package/dist/server/secret/non-persistable.js +69 -0
- package/dist/server/secret/non-persistable.js.map +1 -0
- package/dist/server/store/audit.js +53 -0
- package/dist/server/store/audit.js.map +1 -0
- package/dist/server/store/bootstrap.js +53 -0
- package/dist/server/store/bootstrap.js.map +1 -0
- package/dist/server/store/db.js +157 -0
- package/dist/server/store/db.js.map +1 -0
- package/dist/server/store/ids.js +14 -0
- package/dist/server/store/ids.js.map +1 -0
- package/dist/server/store/migrations/001-init.sql +213 -0
- package/dist/server/store/migrations/002-feed-and-error-indexes.sql +26 -0
- package/dist/server/store/migrations/003-routing.sql +42 -0
- package/dist/server/store/migrations/004-admin-auth.sql +72 -0
- package/dist/server/store/migrations/005-notional-cost.sql +28 -0
- package/dist/server/store/queries.js +904 -0
- package/dist/server/store/queries.js.map +1 -0
- package/dist/server/store/retention.js +79 -0
- package/dist/server/store/retention.js.map +1 -0
- package/dist/server/store/seed.js +203 -0
- package/dist/server/store/seed.js.map +1 -0
- package/dist/server/store/tokens.js +133 -0
- package/dist/server/store/tokens.js.map +1 -0
- package/dist/server/store/write.js +330 -0
- package/dist/server/store/write.js.map +1 -0
- package/dist/server/usage/accumulator.js +209 -0
- package/dist/server/usage/accumulator.js.map +1 -0
- package/dist/server/usage/cost.js +57 -0
- package/dist/server/usage/cost.js.map +1 -0
- package/dist/server/usage/prices/catalog.js +136 -0
- package/dist/server/usage/prices/catalog.js.map +1 -0
- package/dist/server/usage/prices/refresh.js +125 -0
- package/dist/server/usage/prices/refresh.js.map +1 -0
- package/dist/server/usage/prices/snapshot.json.gz +0 -0
- package/dist/server/usage/prices/table.js +102 -0
- package/dist/server/usage/prices/table.js.map +1 -0
- package/dist/server/usage/pricing.js +103 -0
- package/dist/server/usage/pricing.js.map +1 -0
- package/dist/shared/api.js +19 -0
- package/dist/shared/api.js.map +1 -0
- package/dist/shared/base-models.js +28 -0
- package/dist/shared/base-models.js.map +1 -0
- package/dist/shared/demotion-vars.js +17 -0
- package/dist/shared/demotion-vars.js.map +1 -0
- package/dist/shared/series.js +50 -0
- package/dist/shared/series.js.map +1 -0
- package/dist/shared/types.js +40 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/web/dist/assets/index-8HlKsJqt.css +2 -0
- package/dist/web/dist/assets/index-DQ7fyPVm.js +56 -0
- package/dist/web/dist/assets/index-DQ7fyPVm.js.map +1 -0
- package/dist/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/dist/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/dist/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/dist/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/dist/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/dist/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/dist/web/dist/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
- package/dist/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/dist/web/dist/index.html +44 -0
- package/dist/web/dist/nearform-icon.svg +4 -0
- package/package.json +82 -0
- 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;
|