@synoi/gateway-lite 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.
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ /**
3
+ * shadow-mode.ts
4
+ *
5
+ * SYNOI_MODE controls whether governance blocks are enforced or only observed.
6
+ *
7
+ * observe (default) — decisions evaluated, receipts generated, nothing blocked.
8
+ * Dashboard shows "would have blocked X actions this week."
9
+ * enforce — Class C (high-risk) actions hard-block without a human-approved
10
+ * receipt. Class B (medium-risk) soft-blocks with a 429-style
11
+ * "approval required" response. Class A (low) passes through.
12
+ *
13
+ * Shadow mode is the default. Enforcement requires explicit opt-in via SYNOI_MODE=enforce.
14
+ *
15
+ * Class C DOCTRINE: the machine never auto-approves. It waits. It does not time out to allow.
16
+ *
17
+ * State-drift enforce gate (#119):
18
+ * The state-drift subsystem has a two-key enforce gate:
19
+ * 1. Global: SYNOI_MODE=enforce (isEnforce)
20
+ * 2. Per-tenant: state_drift.mode setting = { mode: 'enforce' }
21
+ * Both keys must be set before a divergent target can trigger a hard block.
22
+ * This prevents state-drift from blocking live traffic while the operator
23
+ * believes the gateway is in shadow mode. Default is observe on both axes.
24
+ * Use stateDriftEnforceActive(tenantId) as the single predicate.
25
+ */
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.isEnforce = exports.isObserve = exports.MODE = void 0;
28
+ exports.shouldBlock = shouldBlock;
29
+ exports.requiresApprovedReceipt = requiresApprovedReceipt;
30
+ exports.stateDriftMode = stateDriftMode;
31
+ exports.stateDriftEnforceActive = stateDriftEnforceActive;
32
+ const tenant_store_1 = require("./tenant-store");
33
+ exports.MODE = (process.env.SYNOI_MODE ?? 'observe') === 'enforce' ? 'enforce' : 'observe';
34
+ exports.isObserve = exports.MODE === 'observe';
35
+ exports.isEnforce = exports.MODE === 'enforce';
36
+ /**
37
+ * Returns true when the request should be hard-blocked (403).
38
+ * In observe mode: never blocks.
39
+ * In enforce mode: blocks high (Class C) and medium (Class B) risk.
40
+ * Low (Class A) always passes through even in enforce mode.
41
+ */
42
+ function shouldBlock(riskLevel) {
43
+ if (exports.isObserve)
44
+ return false;
45
+ return riskLevel === 'high' || riskLevel === 'medium';
46
+ }
47
+ /**
48
+ * Returns true when the risk level requires a human-approved receipt to proceed.
49
+ * Only Class C (high) has this requirement — the machine waits for explicit approval.
50
+ * Class B (medium) is blocked but does not require a pre-approved receipt.
51
+ */
52
+ function requiresApprovedReceipt(riskLevel) {
53
+ return exports.isEnforce && riskLevel === 'high';
54
+ }
55
+ /**
56
+ * Per-tenant state-drift mode. Reads the 'state_drift.mode' tenant setting.
57
+ * Default is 'observe' when absent or unreadable.
58
+ *
59
+ * Write via: settingsPut(tenantId, 'state_drift.mode', { mode: 'enforce' })
60
+ */
61
+ function stateDriftMode(tenantId) {
62
+ try {
63
+ const s = (0, tenant_store_1.settingsGet)(tenantId, 'state_drift.mode');
64
+ if (s?.mode === 'enforce')
65
+ return 'enforce';
66
+ }
67
+ catch {
68
+ // Storage error: default observe (fail-safe).
69
+ }
70
+ return 'observe';
71
+ }
72
+ /**
73
+ * Two-key enforce gate for state-drift (#119).
74
+ *
75
+ * Returns true ONLY when:
76
+ * - The global gateway mode is enforce (SYNOI_MODE=enforce), AND
77
+ * - The per-tenant state_drift.mode setting is 'enforce'.
78
+ *
79
+ * Either key being 'observe' keeps the state-drift path in observe mode.
80
+ * This prevents state-drift from hard-blocking while the operator believes
81
+ * the gateway is globally in shadow.
82
+ */
83
+ function stateDriftEnforceActive(tenantId) {
84
+ return exports.isEnforce && stateDriftMode(tenantId) === 'enforce';
85
+ }
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // SPDX-FileCopyrightText: 2026 SynOI Inc.
4
+ Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.isQuantizedValue = isQuantizedValue;
6
+ exports.quantizeFloat = quantizeFloat;
7
+ exports.dequantize = dequantize;
8
+ exports.quantizeObservationValue = quantizeObservationValue;
9
+ exports.quantizeObservationBody = quantizeObservationBody;
10
+ /** True iff `x` is a QuantizedValue shape. */
11
+ function isQuantizedValue(x) {
12
+ if (typeof x !== 'object' || x === null)
13
+ return false;
14
+ const q = x;
15
+ return typeof q['value_int'] === 'number' && Number.isInteger(q['value_int']) &&
16
+ typeof q['scale'] === 'number' && Number.isInteger(q['scale']) && q['scale'] >= 1 &&
17
+ typeof q['unit'] === 'string';
18
+ }
19
+ /**
20
+ * Well-known field -> (scale, unit). The temp_f entry is the ADR worked example.
21
+ * Extend as real sensors land; an unknown field uses DEFAULT_SCALE below.
22
+ */
23
+ const FIELD_QUANTIZATION = {
24
+ temp_f: { scale: 1000, unit: 'millidegf' },
25
+ temp_c: { scale: 1000, unit: 'millidegc' },
26
+ lat: { scale: 1_000_000, unit: 'microdeg' },
27
+ lon: { scale: 1_000_000, unit: 'microdeg' },
28
+ ratio: { scale: 10_000, unit: 'basis_points' },
29
+ // Inference result payloads (broker resultOid preimage). Model outputs are
30
+ // float32; a fixed 1e6 scale is finer than fp32 mantissa precision at the
31
+ // [0,1)/[-1,1] magnitudes these fields occupy, so the quantized integer
32
+ // preimage is a lossless-to-fp32 content address across languages.
33
+ embedding: { scale: 1_000_000, unit: 'fp32_micro' },
34
+ logit: { scale: 1_000_000, unit: 'fp32_micro' },
35
+ tensor: { scale: 1_000_000, unit: 'fp32_micro' },
36
+ score: { scale: 1_000_000, unit: 'fp32_micro' },
37
+ confidence: { scale: 1_000_000, unit: 'fp32_micro' },
38
+ };
39
+ /** Fallback scale/unit for a float field with no table entry (milli precision). */
40
+ const DEFAULT_QUANT = { scale: 1000, unit: 'milli' };
41
+ /** Round half away from zero (deterministic, cross-language reproducible). */
42
+ function roundHalfAwayFromZero(x) {
43
+ return x < 0 ? -Math.round(-x) : Math.round(x);
44
+ }
45
+ /**
46
+ * Quantize one float to a QuantizedValue at an explicit (scale, unit). Throws on
47
+ * a non-finite input (NaN/Infinity have no integer representation). An already-
48
+ * integer input is quantized identically (value_int = v*scale), so the shape is
49
+ * uniform regardless of whether the source happened to be integral.
50
+ */
51
+ function quantizeFloat(v, scale, unit) {
52
+ if (!Number.isFinite(v)) {
53
+ throw new TypeError(`quantizeFloat: cannot quantize non-finite value ${String(v)}`);
54
+ }
55
+ if (!Number.isInteger(scale) || scale < 1) {
56
+ throw new TypeError(`quantizeFloat: scale must be an integer >= 1, got ${String(scale)}`);
57
+ }
58
+ return { value_int: roundHalfAwayFromZero(v * scale), scale, unit };
59
+ }
60
+ /** Recover the (lossy-to-scale) original number from a QuantizedValue. */
61
+ function dequantize(q) {
62
+ return q.value_int / q.scale;
63
+ }
64
+ /**
65
+ * Recursively quantize every non-integer number in an observation/sensor body so
66
+ * it is safe to canonicalize/sign. An integer number is left AS-IS (integers are
67
+ * legal). A float number is replaced with a QuantizedValue using the FIELD_
68
+ * QUANTIZATION scale for the KEY it sits under, or DEFAULT_QUANT otherwise. A
69
+ * value that is already a QuantizedValue (idempotency) is left unchanged. Arrays,
70
+ * nested objects, strings, booleans, null are walked / preserved. Returns a NEW
71
+ * value; the input is not mutated.
72
+ *
73
+ * `keyHint` is the object key the value sits under (used to pick the scale/unit).
74
+ * Top-level scalar callers can pass undefined -> DEFAULT_QUANT.
75
+ */
76
+ function quantizeObservationValue(value, keyHint) {
77
+ if (typeof value === 'number') {
78
+ if (Number.isInteger(value))
79
+ return value; // integers are already legal
80
+ const q = keyHint !== undefined && FIELD_QUANTIZATION[keyHint]
81
+ ? FIELD_QUANTIZATION[keyHint]
82
+ : DEFAULT_QUANT;
83
+ return quantizeFloat(value, q.scale, q.unit);
84
+ }
85
+ if (Array.isArray(value)) {
86
+ return value.map(el => quantizeObservationValue(el, keyHint));
87
+ }
88
+ if (typeof value === 'object' && value !== null) {
89
+ // Already-quantized subtree: leave it (idempotent).
90
+ if (isQuantizedValue(value))
91
+ return value;
92
+ const out = {};
93
+ for (const [k, v] of Object.entries(value)) {
94
+ out[k] = quantizeObservationValue(v, k);
95
+ }
96
+ return out;
97
+ }
98
+ return value; // string / boolean / null / undefined
99
+ }
100
+ /**
101
+ * Quantize a full observation body (the JSON object a sensor reports). Thin
102
+ * wrapper over quantizeObservationValue that guarantees an object in / object
103
+ * out, so an ingest path can call it on `state` before building the CDRO.
104
+ */
105
+ function quantizeObservationBody(body) {
106
+ return quantizeObservationValue(body);
107
+ }
@@ -0,0 +1,366 @@
1
+ "use strict";
2
+ /**
3
+ * tenant-store.ts — synoi-gateway#16 Phase 1
4
+ *
5
+ * Multi-tenant foundation: tenant records + per-tenant K/V settings.
6
+ *
7
+ * Phase 1 (this file): tables, CRUD, resolver, default `founder` tenant.
8
+ * Phase 2 (follow-up): wire each Phase 0 feature (kb-local, house-style,
9
+ * conversation-prune, embedder, arbitrage-decision) to read from settingsGet()
10
+ * with the existing env vars as fallback. Phase 1 doesn't change runtime
11
+ * behavior — it only adds the infrastructure for tenant-scoped config.
12
+ *
13
+ * Storage co-locates in `~/.synoi/local-cache.db` (same SQLite file as
14
+ * anthropic-cache). Splits to its own DB / Postgres only when scale demands.
15
+ *
16
+ * Distinct from `src/tenant.ts` which manages per-tenant CDRO stores + HNSW
17
+ * indexes (data isolation, not config storage).
18
+ */
19
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ var desc = Object.getOwnPropertyDescriptor(m, k);
22
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
23
+ desc = { enumerable: true, get: function() { return m[k]; } };
24
+ }
25
+ Object.defineProperty(o, k2, desc);
26
+ }) : (function(o, m, k, k2) {
27
+ if (k2 === undefined) k2 = k;
28
+ o[k2] = m[k];
29
+ }));
30
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
31
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
32
+ }) : function(o, v) {
33
+ o["default"] = v;
34
+ });
35
+ var __importStar = (this && this.__importStar) || (function () {
36
+ var ownKeys = function(o) {
37
+ ownKeys = Object.getOwnPropertyNames || function (o) {
38
+ var ar = [];
39
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
40
+ return ar;
41
+ };
42
+ return ownKeys(o);
43
+ };
44
+ return function (mod) {
45
+ if (mod && mod.__esModule) return mod;
46
+ var result = {};
47
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
48
+ __setModuleDefault(result, mod);
49
+ return result;
50
+ };
51
+ })();
52
+ var __importDefault = (this && this.__importDefault) || function (mod) {
53
+ return (mod && mod.__esModule) ? mod : { "default": mod };
54
+ };
55
+ Object.defineProperty(exports, "__esModule", { value: true });
56
+ exports.tenantGet = tenantGet;
57
+ exports.tenantList = tenantList;
58
+ exports.tenantPut = tenantPut;
59
+ exports.tenantDelete = tenantDelete;
60
+ exports.settingsGet = settingsGet;
61
+ exports.settingsPut = settingsPut;
62
+ exports.settingsDelete = settingsDelete;
63
+ exports.settingsList = settingsList;
64
+ exports.apiKeyIssue = apiKeyIssue;
65
+ exports.apiKeyList = apiKeyList;
66
+ exports.apiKeyRevoke = apiKeyRevoke;
67
+ exports.resolveTenant = resolveTenant;
68
+ exports.resolveTenantStrict = resolveTenantStrict;
69
+ exports.resolveAuthContext = resolveAuthContext;
70
+ exports.tenantStoreStats = tenantStoreStats;
71
+ const os = __importStar(require("os"));
72
+ const path = __importStar(require("path"));
73
+ const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
74
+ const crypto_1 = require("crypto");
75
+ const keys_1 = require("./keys");
76
+ const SYNOI_DIR = process.env.SYNOI_DATA_DIR ?? path.join(os.homedir(), '.synoi');
77
+ const DB_PATH = path.join(SYNOI_DIR, 'local-cache.db');
78
+ const db = new better_sqlite3_1.default(DB_PATH);
79
+ db.pragma('journal_mode = WAL');
80
+ db.exec(`
81
+ CREATE TABLE IF NOT EXISTS tenants (
82
+ tenant_id TEXT PRIMARY KEY,
83
+ name TEXT NOT NULL,
84
+ plan TEXT NOT NULL DEFAULT 'free',
85
+ status TEXT NOT NULL DEFAULT 'active',
86
+ created_at INTEGER NOT NULL,
87
+ updated_at INTEGER NOT NULL
88
+ );
89
+ CREATE INDEX IF NOT EXISTS idx_tenants_status ON tenants(status);
90
+
91
+ CREATE TABLE IF NOT EXISTS tenant_settings (
92
+ tenant_id TEXT NOT NULL,
93
+ key TEXT NOT NULL,
94
+ value_json TEXT NOT NULL,
95
+ updated_at INTEGER NOT NULL,
96
+ PRIMARY KEY (tenant_id, key)
97
+ );
98
+ CREATE INDEX IF NOT EXISTS idx_tenant_settings_key ON tenant_settings(key);
99
+
100
+ -- API-key → tenant resolution (Phase 2 of #16)
101
+ -- Stored as SHA-256 hash of the raw key + a short prefix for human display.
102
+ -- Raw key returned ONCE at creation time; never recoverable after that.
103
+ CREATE TABLE IF NOT EXISTS tenant_api_keys (
104
+ key_hash TEXT PRIMARY KEY,
105
+ tenant_id TEXT NOT NULL,
106
+ key_prefix TEXT NOT NULL,
107
+ label TEXT NOT NULL DEFAULT '',
108
+ created_at INTEGER NOT NULL,
109
+ last_used_at INTEGER,
110
+ revoked_at INTEGER
111
+ );
112
+ CREATE INDEX IF NOT EXISTS idx_tenant_api_keys_tenant ON tenant_api_keys(tenant_id);
113
+ CREATE INDEX IF NOT EXISTS idx_tenant_api_keys_prefix ON tenant_api_keys(key_prefix);
114
+ `);
115
+ // Default founder tenant — exists so the gateway has a place to resolve to
116
+ // in dev / single-user mode where no auth header carries a tenant id.
117
+ db.prepare(`
118
+ INSERT INTO tenants (tenant_id, name, plan, status, created_at, updated_at)
119
+ VALUES ('founder', 'Founder (default)', 'founder', 'active', ?, ?)
120
+ ON CONFLICT(tenant_id) DO NOTHING
121
+ `).run(Date.now(), Date.now());
122
+ // ─── Tenant CRUD ──────────────────────────────────────────────────────────────
123
+ const STMT_GET = db.prepare(`SELECT * FROM tenants WHERE tenant_id = ?`);
124
+ const STMT_LIST = db.prepare(`SELECT * FROM tenants ORDER BY created_at ASC`);
125
+ const STMT_PUT = db.prepare(`
126
+ INSERT INTO tenants (tenant_id, name, plan, status, created_at, updated_at)
127
+ VALUES (?, ?, ?, ?, ?, ?)
128
+ ON CONFLICT(tenant_id) DO UPDATE SET
129
+ name = excluded.name,
130
+ plan = excluded.plan,
131
+ status = excluded.status,
132
+ updated_at = excluded.updated_at
133
+ `);
134
+ const STMT_DELETE = db.prepare(`DELETE FROM tenants WHERE tenant_id = ?`);
135
+ function tenantGet(tenantId) {
136
+ const row = STMT_GET.get(tenantId);
137
+ return row ?? null;
138
+ }
139
+ function tenantList() {
140
+ return STMT_LIST.all();
141
+ }
142
+ function tenantPut(input) {
143
+ const existing = tenantGet(input.tenant_id);
144
+ const now = Date.now();
145
+ const created_at = existing?.created_at ?? now;
146
+ const name = input.name ?? existing?.name ?? input.tenant_id;
147
+ const plan = input.plan ?? existing?.plan ?? 'free';
148
+ const status = input.status ?? existing?.status ?? 'active';
149
+ STMT_PUT.run(input.tenant_id, name, plan, status, created_at, now);
150
+ return tenantGet(input.tenant_id);
151
+ }
152
+ function tenantDelete(tenantId) {
153
+ if (tenantId === 'founder')
154
+ return false; // protect default tenant
155
+ const info = STMT_DELETE.run(tenantId);
156
+ // Cascading settings cleanup
157
+ db.prepare(`DELETE FROM tenant_settings WHERE tenant_id = ?`).run(tenantId);
158
+ return info.changes > 0;
159
+ }
160
+ // ─── Settings CRUD ────────────────────────────────────────────────────────────
161
+ const STMT_SET_GET = db.prepare(`SELECT value_json FROM tenant_settings WHERE tenant_id = ? AND key = ?`);
162
+ const STMT_SET_LIST = db.prepare(`SELECT key, value_json FROM tenant_settings WHERE tenant_id = ? ORDER BY key`);
163
+ const STMT_SET_PUT = db.prepare(`
164
+ INSERT INTO tenant_settings (tenant_id, key, value_json, updated_at)
165
+ VALUES (?, ?, ?, ?)
166
+ ON CONFLICT(tenant_id, key) DO UPDATE SET
167
+ value_json = excluded.value_json,
168
+ updated_at = excluded.updated_at
169
+ `);
170
+ const STMT_SET_DELETE = db.prepare(`DELETE FROM tenant_settings WHERE tenant_id = ? AND key = ?`);
171
+ /**
172
+ * settingsGet — fetch a per-tenant setting. Returns null if absent so callers
173
+ * can fall back to env vars or built-in defaults. Type parameter is for
174
+ * caller convenience; the underlying JSON.parse always returns `unknown`.
175
+ */
176
+ function settingsGet(tenantId, key) {
177
+ const row = STMT_SET_GET.get(tenantId, key);
178
+ if (!row)
179
+ return null;
180
+ try {
181
+ return JSON.parse(row.value_json);
182
+ }
183
+ catch {
184
+ return null;
185
+ }
186
+ }
187
+ function settingsPut(tenantId, key, value) {
188
+ STMT_SET_PUT.run(tenantId, key, JSON.stringify(value), Date.now());
189
+ }
190
+ function settingsDelete(tenantId, key) {
191
+ return STMT_SET_DELETE.run(tenantId, key).changes > 0;
192
+ }
193
+ function settingsList(tenantId) {
194
+ const rows = STMT_SET_LIST.all(tenantId);
195
+ const out = {};
196
+ for (const r of rows) {
197
+ try {
198
+ out[r.key] = JSON.parse(r.value_json);
199
+ }
200
+ catch {
201
+ out[r.key] = r.value_json;
202
+ }
203
+ }
204
+ return out;
205
+ }
206
+ // ─── API keys ────────────────────────────────────────────────────────────────
207
+ const API_KEY_PREFIX = 'synoi-sk-';
208
+ function hashApiKey(raw) {
209
+ return (0, crypto_1.createHash)('sha256').update(raw, 'utf8').digest('hex');
210
+ }
211
+ /**
212
+ * Issue a fresh API key for a tenant. Returns the raw key ONCE — caller is
213
+ * responsible for showing it to the operator. Only the hash is persisted.
214
+ */
215
+ function apiKeyIssue(tenantId, label = '') {
216
+ if (!tenantGet(tenantId))
217
+ throw new Error(`tenant not found: ${tenantId}`);
218
+ const rand = (0, crypto_1.randomBytes)(24).toString('hex'); // 48 hex chars
219
+ const raw = `${API_KEY_PREFIX}${rand}`;
220
+ const hash = hashApiKey(raw);
221
+ const prefix = raw.slice(0, 12);
222
+ const now = Date.now();
223
+ db.prepare(`
224
+ INSERT INTO tenant_api_keys (key_hash, tenant_id, key_prefix, label, created_at)
225
+ VALUES (?, ?, ?, ?, ?)
226
+ `).run(hash, tenantId, prefix, label, now);
227
+ return {
228
+ raw_key: raw,
229
+ record: { key_hash: hash, tenant_id: tenantId, key_prefix: prefix, label, created_at: now, last_used_at: null, revoked_at: null },
230
+ };
231
+ }
232
+ function apiKeyList(tenantId) {
233
+ return db.prepare(`SELECT * FROM tenant_api_keys WHERE tenant_id = ? ORDER BY created_at DESC`).all(tenantId);
234
+ }
235
+ function apiKeyRevoke(keyHash) {
236
+ const r = db.prepare(`UPDATE tenant_api_keys SET revoked_at = ? WHERE key_hash = ? AND revoked_at IS NULL`).run(Date.now(), keyHash);
237
+ return r.changes > 0;
238
+ }
239
+ const STMT_API_LOOKUP = db.prepare(`
240
+ SELECT tenant_id, revoked_at FROM tenant_api_keys WHERE key_hash = ? LIMIT 1
241
+ `);
242
+ const STMT_API_TOUCH = db.prepare(`
243
+ UPDATE tenant_api_keys SET last_used_at = ? WHERE key_hash = ?
244
+ `);
245
+ /** Resolve a raw API key to a tenant_id, or null if unknown / revoked. */
246
+ function lookupApiKey(rawKey) {
247
+ const hash = hashApiKey(rawKey);
248
+ const row = STMT_API_LOOKUP.get(hash);
249
+ if (!row || row.revoked_at !== null)
250
+ return null;
251
+ STMT_API_TOUCH.run(Date.now(), hash);
252
+ return row.tenant_id;
253
+ }
254
+ // ─── Resolver ─────────────────────────────────────────────────────────────────
255
+ /**
256
+ * resolveTenant — derive a tenant_id from an inbound request.
257
+ *
258
+ * Phase 1 strategy (simple, defensive):
259
+ * 1. Explicit `X-SynOI-Tenant: <id>` header (admin / test override)
260
+ * 2. `x-api-key` Anthropic header — if it maps to a tenant in our table
261
+ * via a stored API-key alias (NOT YET — table for this is Phase 2)
262
+ * 3. `Authorization: Bearer synoi-sk-<key>` — synoi key store
263
+ * (also Phase 2 wire-in)
264
+ * 4. Default: 'founder'
265
+ *
266
+ * For Phase 1 only #1 and #4 are functional. Other paths return 'founder'
267
+ * with a debug log so we can see what fallbacks fired without changing
268
+ * behavior.
269
+ */
270
+ function resolveTenant(req) {
271
+ // 1. Explicit X-SynOI-Tenant header (admin override; falls through if invalid)
272
+ const explicit = req.header('x-synoi-tenant');
273
+ if (explicit) {
274
+ const t = tenantGet(explicit);
275
+ if (t && t.status === 'active')
276
+ return t.tenant_id;
277
+ }
278
+ // 2. `Authorization: Bearer synoi-sk-...` — gateway-issued tenant API key
279
+ const auth = req.header('authorization');
280
+ if (auth?.startsWith('Bearer ')) {
281
+ const token = auth.slice(7).trim();
282
+ if (token.startsWith(API_KEY_PREFIX)) {
283
+ const tid = lookupApiKey(token);
284
+ if (tid) {
285
+ const t = tenantGet(tid);
286
+ if (t && t.status === 'active')
287
+ return t.tenant_id;
288
+ }
289
+ }
290
+ }
291
+ // 3. `x-api-key: synoi-sk-...` — same tenant key, alternate header
292
+ // (Anthropic clients pass their key here; if it happens to be a synoi-sk-*
293
+ // key we resolve from it. Otherwise it's an upstream-Anthropic key and we
294
+ // fall through to founder.)
295
+ const apiKey = req.header('x-api-key');
296
+ if (apiKey?.startsWith(API_KEY_PREFIX)) {
297
+ const tid = lookupApiKey(apiKey);
298
+ if (tid) {
299
+ const t = tenantGet(tid);
300
+ if (t && t.status === 'active')
301
+ return t.tenant_id;
302
+ }
303
+ }
304
+ // 4. Default for dev / unauthenticated requests
305
+ return 'founder';
306
+ }
307
+ /**
308
+ * CR-2: strict tenant resolution for routes that must REJECT anonymous callers
309
+ * (e.g. /v1/risk, /mcp — which mint signed receipts and proxy outbound). Unlike
310
+ * resolveTenant, this requires a real `synoi-sk-*` key (Authorization Bearer or
311
+ * x-api-key) and returns null otherwise — it does NOT honor a bare X-SynOI-Tenant
312
+ * header (which is spoofable) and never defaults to 'founder'.
313
+ */
314
+ function resolveTenantStrict(req) {
315
+ const fromKey = (token) => {
316
+ if (!token || !token.startsWith(API_KEY_PREFIX))
317
+ return null;
318
+ const tid = lookupApiKey(token); // a valid synoi-sk key IS the credential
319
+ if (!tid)
320
+ return null;
321
+ const t = tenantGet(tid);
322
+ if (t && t.status !== 'active')
323
+ return null; // only reject an explicitly-suspended tenant
324
+ return tid; // (a /keys-minted key may have no separate tenant record)
325
+ };
326
+ const auth = req.header('authorization');
327
+ const bearer = auth?.startsWith('Bearer ') ? auth.slice(7).trim() : undefined;
328
+ return fromKey(bearer) ?? fromKey(req.header('x-api-key'));
329
+ }
330
+ /**
331
+ * PR 4 — like resolveTenant, but also returns the license's profile binding
332
+ * when the inbound bearer is a `synoi-sk-...` key recognised by keyStore.
333
+ * Used by anthropic-inbound (which doesn't go through authMiddleware) so
334
+ * per-license profile enforcement applies equally to the Anthropic-native
335
+ * inbound path.
336
+ */
337
+ function resolveAuthContext(req) {
338
+ // Reuse resolveTenant's lookup paths for tenant_id, then add a parallel
339
+ // KeyStore lookup to pull profile_id if the bearer is a synoi-sk-* token.
340
+ const tenant_id = resolveTenant(req);
341
+ let profile_id = null;
342
+ const tokens = [];
343
+ const auth = req.header('authorization');
344
+ if (auth?.startsWith('Bearer '))
345
+ tokens.push(auth.slice(7).trim());
346
+ const apiKey = req.header('x-api-key');
347
+ if (apiKey)
348
+ tokens.push(apiKey);
349
+ for (const t of tokens) {
350
+ if (!t.startsWith('synoi-sk-'))
351
+ continue;
352
+ const v = keys_1.keyStore.validate(t);
353
+ if (v?.profile_id) {
354
+ profile_id = v.profile_id;
355
+ break;
356
+ }
357
+ }
358
+ return { tenant_id, profile_id };
359
+ }
360
+ // ─── Stats ────────────────────────────────────────────────────────────────────
361
+ function tenantStoreStats() {
362
+ const tenants = db.prepare(`SELECT COUNT(*) AS n FROM tenants`).get().n;
363
+ const active_tenants = db.prepare(`SELECT COUNT(*) AS n FROM tenants WHERE status = 'active'`).get().n;
364
+ const total_settings = db.prepare(`SELECT COUNT(*) AS n FROM tenant_settings`).get().n;
365
+ return { tenants, active_tenants, total_settings, default_tenant: tenantGet('founder') };
366
+ }