@wopr-network/platform-core 1.12.2 → 1.13.1
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/dist/api/routes/activity.d.ts +9 -0
- package/dist/api/routes/activity.js +68 -0
- package/dist/api/routes/admin-audit-helper.d.ts +7 -0
- package/dist/api/routes/admin-audit-helper.js +13 -0
- package/dist/api/routes/admin-audit.d.ts +13 -0
- package/dist/api/routes/admin-audit.js +61 -0
- package/dist/api/routes/admin-backups.d.ts +19 -0
- package/dist/api/routes/admin-backups.js +116 -0
- package/dist/api/routes/admin-compliance.d.ts +9 -0
- package/dist/api/routes/admin-compliance.js +27 -0
- package/dist/api/routes/admin-credits.d.ts +9 -0
- package/dist/api/routes/admin-credits.js +255 -0
- package/dist/api/routes/admin-gpu.d.ts +46 -0
- package/dist/api/routes/admin-gpu.js +140 -0
- package/dist/api/routes/admin-inference.d.ts +16 -0
- package/dist/api/routes/admin-inference.js +98 -0
- package/dist/api/routes/admin-marketplace.d.ts +36 -0
- package/dist/api/routes/admin-marketplace.js +181 -0
- package/dist/api/routes/admin-migration.d.ts +10 -0
- package/dist/api/routes/admin-migration.js +46 -0
- package/dist/api/routes/admin-notes.d.ts +34 -0
- package/dist/api/routes/admin-notes.js +131 -0
- package/dist/api/routes/admin-onboarding.d.ts +7 -0
- package/dist/api/routes/admin-onboarding.js +49 -0
- package/dist/api/routes/admin-rates.d.ts +9 -0
- package/dist/api/routes/admin-rates.js +427 -0
- package/dist/api/routes/admin-recovery.d.ts +91 -0
- package/dist/api/routes/admin-recovery.js +246 -0
- package/dist/api/routes/admin-roles.d.ts +27 -0
- package/dist/api/routes/admin-roles.js +157 -0
- package/dist/api/routes/audit.d.ts +19 -0
- package/dist/api/routes/audit.js +95 -0
- package/dist/api/routes/auth.d.ts +19 -0
- package/dist/api/routes/auth.js +25 -0
- package/dist/api/routes/channel-validate.d.ts +11 -0
- package/dist/api/routes/channel-validate.js +148 -0
- package/dist/api/routes/fleet-events.d.ts +4 -0
- package/dist/api/routes/fleet-events.js +53 -0
- package/dist/api/routes/friends-proxy.d.ts +28 -0
- package/dist/api/routes/friends-proxy.js +63 -0
- package/dist/api/routes/friends-types.d.ts +34 -0
- package/dist/api/routes/friends-types.js +28 -0
- package/dist/api/routes/health.d.ts +14 -0
- package/dist/api/routes/health.js +32 -0
- package/dist/api/routes/health.test.d.ts +1 -0
- package/dist/api/routes/health.test.js +70 -0
- package/dist/api/routes/incident-response.d.ts +9 -0
- package/dist/api/routes/incident-response.js +148 -0
- package/dist/api/routes/internal-gpu.d.ts +12 -0
- package/dist/api/routes/internal-gpu.js +70 -0
- package/dist/api/routes/internal-nodes.d.ts +41 -0
- package/dist/api/routes/internal-nodes.js +105 -0
- package/dist/api/routes/login-history.d.ts +11 -0
- package/dist/api/routes/login-history.js +22 -0
- package/dist/api/routes/public-pricing.d.ts +9 -0
- package/dist/api/routes/public-pricing.js +32 -0
- package/dist/api/routes/quota.d.ts +8 -0
- package/dist/api/routes/quota.js +113 -0
- package/dist/api/routes/secret-audit.d.ts +12 -0
- package/dist/api/routes/secret-audit.js +41 -0
- package/dist/api/routes/secrets.d.ts +31 -0
- package/dist/api/routes/secrets.js +135 -0
- package/dist/api/routes/tenant-keys.d.ts +16 -0
- package/dist/api/routes/tenant-keys.js +142 -0
- package/dist/api/routes/verify-email.d.ts +19 -0
- package/dist/api/routes/verify-email.js +70 -0
- package/dist/api/routes/ws-auth.d.ts +21 -0
- package/dist/api/routes/ws-auth.js +24 -0
- package/dist/monetization/adapters/bootstrap.d.ts +2 -2
- package/dist/monetization/adapters/bootstrap.js +3 -2
- package/dist/monetization/adapters/bootstrap.test.js +11 -7
- package/dist/monetization/adapters/embeddings-factory.d.ts +10 -5
- package/dist/monetization/adapters/embeddings-factory.js +17 -4
- package/dist/monetization/adapters/embeddings-factory.test.js +85 -31
- package/dist/monetization/adapters/ollama-embeddings.d.ts +40 -0
- package/dist/monetization/adapters/ollama-embeddings.js +76 -0
- package/dist/monetization/adapters/ollama-embeddings.test.d.ts +1 -0
- package/dist/monetization/adapters/ollama-embeddings.test.js +178 -0
- package/dist/monetization/adapters/rate-table.js +9 -3
- package/dist/monetization/adapters/rate-table.test.js +22 -1
- package/package.json +35 -1
- package/src/api/routes/activity.ts +77 -0
- package/src/api/routes/admin-audit-helper.ts +18 -0
- package/src/api/routes/admin-audit.ts +67 -0
- package/src/api/routes/admin-backups.ts +134 -0
- package/src/api/routes/admin-compliance.ts +35 -0
- package/src/api/routes/admin-credits.ts +280 -0
- package/src/api/routes/admin-gpu.ts +202 -0
- package/src/api/routes/admin-inference.ts +109 -0
- package/src/api/routes/admin-marketplace.ts +233 -0
- package/src/api/routes/admin-migration.ts +61 -0
- package/src/api/routes/admin-notes.ts +145 -0
- package/src/api/routes/admin-onboarding.ts +62 -0
- package/src/api/routes/admin-rates.ts +462 -0
- package/src/api/routes/admin-recovery.ts +376 -0
- package/src/api/routes/admin-roles.ts +205 -0
- package/src/api/routes/audit.ts +106 -0
- package/src/api/routes/auth.ts +30 -0
- package/src/api/routes/channel-validate.ts +182 -0
- package/src/api/routes/fleet-events.ts +66 -0
- package/src/api/routes/friends-proxy.ts +94 -0
- package/src/api/routes/friends-types.ts +37 -0
- package/src/api/routes/health.test.ts +80 -0
- package/src/api/routes/health.ts +48 -0
- package/src/api/routes/incident-response.ts +159 -0
- package/src/api/routes/internal-gpu.ts +92 -0
- package/src/api/routes/internal-nodes.ts +157 -0
- package/src/api/routes/login-history.ts +28 -0
- package/src/api/routes/public-pricing.ts +36 -0
- package/src/api/routes/quota.ts +136 -0
- package/src/api/routes/secret-audit.ts +55 -0
- package/src/api/routes/secrets.ts +178 -0
- package/src/api/routes/tenant-keys.ts +178 -0
- package/src/api/routes/verify-email.ts +102 -0
- package/src/api/routes/ws-auth.ts +44 -0
- package/src/monetization/adapters/bootstrap.test.ts +11 -7
- package/src/monetization/adapters/bootstrap.ts +3 -2
- package/src/monetization/adapters/embeddings-factory.test.ts +102 -33
- package/src/monetization/adapters/embeddings-factory.ts +24 -7
- package/src/monetization/adapters/ollama-embeddings.test.ts +235 -0
- package/src/monetization/adapters/ollama-embeddings.ts +120 -0
- package/src/monetization/adapters/rate-table.test.ts +32 -1
- package/src/monetization/adapters/rate-table.ts +9 -3
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { validateTenantOwnership } from "../../auth/index.js";
|
|
3
|
+
import { decrypt as defaultDecrypt, deriveInstanceKey as defaultDeriveInstanceKey } from "../../security/encryption.js";
|
|
4
|
+
import { forwardSecretsToInstance as defaultForwardSecrets, writeEncryptedSeed as defaultWriteSeed, } from "../../security/key-injection.js";
|
|
5
|
+
import { validateProviderKey as defaultValidateKey } from "../../security/key-validation.js";
|
|
6
|
+
import { validateKeyRequestSchema, writeSecretsRequestSchema } from "../../security/types.js";
|
|
7
|
+
/** Allowlist: only alphanumeric, hyphens, and underscores. */
|
|
8
|
+
const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
|
9
|
+
function isValidInstanceId(id) {
|
|
10
|
+
return INSTANCE_ID_RE.test(id);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Create secrets management routes.
|
|
14
|
+
*
|
|
15
|
+
* @param deps - Dependencies for secret operations
|
|
16
|
+
*/
|
|
17
|
+
export function createSecretsRoutes(deps) {
|
|
18
|
+
const routes = new Hono();
|
|
19
|
+
const instanceDataDir = deps.instanceDataDir ?? "/data/instances";
|
|
20
|
+
// Resolve security functions with defaults
|
|
21
|
+
const decryptFn = deps.security?.decrypt ?? defaultDecrypt;
|
|
22
|
+
const deriveInstanceKeyFn = deps.security?.deriveInstanceKey ?? defaultDeriveInstanceKey;
|
|
23
|
+
const writeEncryptedSeedFn = deps.security?.writeEncryptedSeed ?? defaultWriteSeed;
|
|
24
|
+
const forwardSecretsFn = deps.security?.forwardSecretsToInstance ?? defaultForwardSecrets;
|
|
25
|
+
const validateKeyFn = deps.security?.validateProviderKey ?? defaultValidateKey;
|
|
26
|
+
/**
|
|
27
|
+
* PUT /instances/:id/config/secrets
|
|
28
|
+
*
|
|
29
|
+
* Writes secrets to a running instance by forwarding the body opaquely,
|
|
30
|
+
* or writes an encrypted seed file if the instance is not running.
|
|
31
|
+
*/
|
|
32
|
+
routes.put("/instances/:id/config/secrets", async (c) => {
|
|
33
|
+
const instanceId = c.req.param("id");
|
|
34
|
+
if (!isValidInstanceId(instanceId)) {
|
|
35
|
+
return c.json({ error: "Invalid instance ID" }, 400);
|
|
36
|
+
}
|
|
37
|
+
// Validate tenant ownership of the instance
|
|
38
|
+
const tenantId = await deps.profileLookup.getInstanceTenantId(instanceId);
|
|
39
|
+
const ownershipError = validateTenantOwnership(c, instanceId, tenantId);
|
|
40
|
+
if (ownershipError) {
|
|
41
|
+
return ownershipError;
|
|
42
|
+
}
|
|
43
|
+
const mode = c.req.query("mode") || "proxy";
|
|
44
|
+
if (mode === "seed") {
|
|
45
|
+
// Pre-boot: parse body to encrypt, then discard plaintext
|
|
46
|
+
let body;
|
|
47
|
+
try {
|
|
48
|
+
body = await c.req.json();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
52
|
+
}
|
|
53
|
+
const parsed = writeSecretsRequestSchema.safeParse(body);
|
|
54
|
+
if (!parsed.success) {
|
|
55
|
+
return c.json({ error: "Validation failed", details: parsed.error.flatten() }, 400);
|
|
56
|
+
}
|
|
57
|
+
if (!deps.platformSecret) {
|
|
58
|
+
return c.json({ error: "Platform secret not configured" }, 500);
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const instanceKey = deriveInstanceKeyFn(instanceId, deps.platformSecret);
|
|
62
|
+
const woprHome = `${instanceDataDir}/${instanceId}`;
|
|
63
|
+
await writeEncryptedSeedFn(woprHome, parsed.data, instanceKey);
|
|
64
|
+
return c.json({ ok: true, mode: "seed" });
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
const message = err instanceof Error ? err.message : "Failed to write seed";
|
|
68
|
+
deps.logger?.error("Failed to write encrypted seed", { instanceId, error: message });
|
|
69
|
+
return c.json({ error: "Failed to write encrypted seed" }, 500);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Default: proxy mode — forward body opaquely to the instance container
|
|
73
|
+
const rawBody = await c.req.text();
|
|
74
|
+
const instanceUrl = `http://wopr-${instanceId}:3000`;
|
|
75
|
+
const authHeader = c.req.header("Authorization") || "";
|
|
76
|
+
const sessionToken = authHeader.replace(/^Bearer\s+/i, "");
|
|
77
|
+
const result = await forwardSecretsFn(instanceUrl, sessionToken, rawBody);
|
|
78
|
+
if (result.ok) {
|
|
79
|
+
return c.json({ ok: true, mode: "proxy" });
|
|
80
|
+
}
|
|
81
|
+
const status = result.status === 502 ? 502 : result.status === 503 ? 503 : result.status === 404 ? 404 : 500;
|
|
82
|
+
return c.json({ error: result.error || "Proxy failed" }, status);
|
|
83
|
+
});
|
|
84
|
+
/**
|
|
85
|
+
* POST /validate-key
|
|
86
|
+
*
|
|
87
|
+
* Validates a provider API key without logging or persisting it.
|
|
88
|
+
*/
|
|
89
|
+
routes.post("/validate-key", async (c) => {
|
|
90
|
+
let body;
|
|
91
|
+
try {
|
|
92
|
+
body = await c.req.json();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
96
|
+
}
|
|
97
|
+
const parsed = validateKeyRequestSchema.safeParse(body);
|
|
98
|
+
if (!parsed.success) {
|
|
99
|
+
return c.json({ error: "Validation failed", details: parsed.error.flatten() }, 400);
|
|
100
|
+
}
|
|
101
|
+
const { provider, encryptedKey } = parsed.data;
|
|
102
|
+
// Decrypt the key in memory
|
|
103
|
+
let plaintextKey;
|
|
104
|
+
try {
|
|
105
|
+
const encryptedPayload = JSON.parse(encryptedKey);
|
|
106
|
+
const instanceId = c.req.query("instanceId");
|
|
107
|
+
if (!instanceId) {
|
|
108
|
+
return c.json({ error: "instanceId query parameter required" }, 400);
|
|
109
|
+
}
|
|
110
|
+
if (!isValidInstanceId(instanceId)) {
|
|
111
|
+
return c.json({ error: "Invalid instance ID" }, 400);
|
|
112
|
+
}
|
|
113
|
+
// Validate tenant ownership of the instance
|
|
114
|
+
const tenantId = await deps.profileLookup.getInstanceTenantId(instanceId);
|
|
115
|
+
const ownershipError = validateTenantOwnership(c, instanceId, tenantId);
|
|
116
|
+
if (ownershipError) {
|
|
117
|
+
return ownershipError;
|
|
118
|
+
}
|
|
119
|
+
if (!deps.platformSecret) {
|
|
120
|
+
return c.json({ error: "Platform secret not configured" }, 500);
|
|
121
|
+
}
|
|
122
|
+
const instanceKey = deriveInstanceKeyFn(instanceId, deps.platformSecret);
|
|
123
|
+
plaintextKey = decryptFn(encryptedPayload, instanceKey);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return c.json({ error: "Failed to decrypt key payload" }, 400);
|
|
127
|
+
}
|
|
128
|
+
// Validate against the provider API
|
|
129
|
+
const result = await validateKeyFn(provider, plaintextKey);
|
|
130
|
+
// Explicitly discard the key reference
|
|
131
|
+
plaintextKey = "";
|
|
132
|
+
return c.json({ valid: result.valid, error: result.error });
|
|
133
|
+
});
|
|
134
|
+
return routes;
|
|
135
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { ITenantKeyRepository } from "../../security/tenant-keys/tenant-key-repository.js";
|
|
3
|
+
export interface TenantKeyDeps {
|
|
4
|
+
repo: () => ITenantKeyRepository;
|
|
5
|
+
/** Platform secret for key derivation. If not provided, encrypt operations will fail with 500. */
|
|
6
|
+
platformSecret?: string;
|
|
7
|
+
logger?: {
|
|
8
|
+
warn(msg: string): void;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Create tenant key management routes.
|
|
13
|
+
*
|
|
14
|
+
* @param deps - Dependencies for tenant key operations
|
|
15
|
+
*/
|
|
16
|
+
export declare function createTenantKeyRoutes(deps: TenantKeyDeps): Hono;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { encrypt } from "../../security/encryption.js";
|
|
5
|
+
import { providerSchema } from "../../security/types.js";
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Request schemas
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
const storeKeySchema = z.object({
|
|
10
|
+
provider: providerSchema,
|
|
11
|
+
apiKey: z.string().min(1, "API key is required"),
|
|
12
|
+
label: z.string().max(100).optional(),
|
|
13
|
+
});
|
|
14
|
+
/** Derive a per-tenant encryption key from tenant ID and platform secret. */
|
|
15
|
+
function deriveTenantKey(tenantId, platformSecret) {
|
|
16
|
+
return createHmac("sha256", platformSecret).update(`tenant:${tenantId}`).digest();
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Create tenant key management routes.
|
|
20
|
+
*
|
|
21
|
+
* @param deps - Dependencies for tenant key operations
|
|
22
|
+
*/
|
|
23
|
+
export function createTenantKeyRoutes(deps) {
|
|
24
|
+
const routes = new Hono();
|
|
25
|
+
/**
|
|
26
|
+
* GET /
|
|
27
|
+
*
|
|
28
|
+
* List all API keys for the authenticated tenant.
|
|
29
|
+
* Returns metadata only (never the encrypted key material).
|
|
30
|
+
*/
|
|
31
|
+
routes.get("/", async (c) => {
|
|
32
|
+
const tenantId = getTenantId(c);
|
|
33
|
+
if (!tenantId) {
|
|
34
|
+
return c.json({ error: "Tenant context required" }, 400);
|
|
35
|
+
}
|
|
36
|
+
const keys = await deps.repo().listForTenant(tenantId);
|
|
37
|
+
return c.json({ keys });
|
|
38
|
+
});
|
|
39
|
+
/**
|
|
40
|
+
* GET /:provider
|
|
41
|
+
*
|
|
42
|
+
* Check whether the tenant has a stored key for a specific provider.
|
|
43
|
+
* Returns metadata only.
|
|
44
|
+
*/
|
|
45
|
+
routes.get("/:provider", async (c) => {
|
|
46
|
+
const tenantId = getTenantId(c);
|
|
47
|
+
if (!tenantId) {
|
|
48
|
+
return c.json({ error: "Tenant context required" }, 400);
|
|
49
|
+
}
|
|
50
|
+
const provider = c.req.param("provider");
|
|
51
|
+
const parsed = providerSchema.safeParse(provider);
|
|
52
|
+
if (!parsed.success) {
|
|
53
|
+
return c.json({ error: "Invalid provider" }, 400);
|
|
54
|
+
}
|
|
55
|
+
const record = await deps.repo().get(tenantId, parsed.data);
|
|
56
|
+
if (!record) {
|
|
57
|
+
return c.json({ error: "No key stored for this provider" }, 404);
|
|
58
|
+
}
|
|
59
|
+
// Return metadata only, never the encrypted key
|
|
60
|
+
return c.json({
|
|
61
|
+
id: record.id,
|
|
62
|
+
tenant_id: record.tenant_id,
|
|
63
|
+
provider: record.provider,
|
|
64
|
+
label: record.label,
|
|
65
|
+
created_at: record.created_at,
|
|
66
|
+
updated_at: record.updated_at,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
/**
|
|
70
|
+
* PUT /:provider
|
|
71
|
+
*
|
|
72
|
+
* Store or replace a tenant's API key for a provider.
|
|
73
|
+
* The key is encrypted at rest using AES-256-GCM with a tenant-derived key.
|
|
74
|
+
*/
|
|
75
|
+
routes.put("/:provider", async (c) => {
|
|
76
|
+
const tenantId = getTenantId(c);
|
|
77
|
+
if (!tenantId) {
|
|
78
|
+
return c.json({ error: "Tenant context required" }, 400);
|
|
79
|
+
}
|
|
80
|
+
const provider = c.req.param("provider");
|
|
81
|
+
const providerParsed = providerSchema.safeParse(provider);
|
|
82
|
+
if (!providerParsed.success) {
|
|
83
|
+
return c.json({ error: "Invalid provider" }, 400);
|
|
84
|
+
}
|
|
85
|
+
let body;
|
|
86
|
+
try {
|
|
87
|
+
body = await c.req.json();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
91
|
+
}
|
|
92
|
+
const parsed = storeKeySchema.safeParse(body);
|
|
93
|
+
if (!parsed.success) {
|
|
94
|
+
return c.json({ error: "Validation failed", details: parsed.error.flatten() }, 400);
|
|
95
|
+
}
|
|
96
|
+
if (parsed.data.provider !== providerParsed.data) {
|
|
97
|
+
return c.json({ error: "Provider in body must match URL parameter" }, 400);
|
|
98
|
+
}
|
|
99
|
+
if (!deps.platformSecret) {
|
|
100
|
+
return c.json({ error: "Platform secret not configured" }, 500);
|
|
101
|
+
}
|
|
102
|
+
// Encrypt the key in memory, then discard the plaintext
|
|
103
|
+
const tenantKey = deriveTenantKey(tenantId, deps.platformSecret);
|
|
104
|
+
const encryptedPayload = encrypt(parsed.data.apiKey, tenantKey);
|
|
105
|
+
const id = await deps.repo().upsert(tenantId, providerParsed.data, encryptedPayload, parsed.data.label ?? "");
|
|
106
|
+
return c.json({ ok: true, id, provider: providerParsed.data });
|
|
107
|
+
});
|
|
108
|
+
/**
|
|
109
|
+
* DELETE /:provider
|
|
110
|
+
*
|
|
111
|
+
* Delete a tenant's stored API key for a provider.
|
|
112
|
+
*/
|
|
113
|
+
routes.delete("/:provider", async (c) => {
|
|
114
|
+
const tenantId = getTenantId(c);
|
|
115
|
+
if (!tenantId) {
|
|
116
|
+
return c.json({ error: "Tenant context required" }, 400);
|
|
117
|
+
}
|
|
118
|
+
const provider = c.req.param("provider");
|
|
119
|
+
const parsed = providerSchema.safeParse(provider);
|
|
120
|
+
if (!parsed.success) {
|
|
121
|
+
return c.json({ error: "Invalid provider" }, 400);
|
|
122
|
+
}
|
|
123
|
+
const deleted = await deps.repo().delete(tenantId, parsed.data);
|
|
124
|
+
if (!deleted) {
|
|
125
|
+
return c.json({ error: "No key stored for this provider" }, 404);
|
|
126
|
+
}
|
|
127
|
+
return c.json({ ok: true, provider: parsed.data });
|
|
128
|
+
});
|
|
129
|
+
return routes;
|
|
130
|
+
}
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Helpers
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
/** Extract the tenant ID from the auth context. */
|
|
135
|
+
function getTenantId(c) {
|
|
136
|
+
try {
|
|
137
|
+
return c.get("tokenTenantId");
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
import type { ICreditLedger } from "../../credits/index.js";
|
|
4
|
+
export interface VerifyEmailRouteDeps {
|
|
5
|
+
pool: Pool;
|
|
6
|
+
creditLedger: ICreditLedger;
|
|
7
|
+
}
|
|
8
|
+
export interface VerifyEmailRouteConfig {
|
|
9
|
+
/** UI origin for redirect URLs (default: http://localhost:3001) */
|
|
10
|
+
uiOrigin?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Create verify-email routes with explicit dependencies (for testing).
|
|
14
|
+
*/
|
|
15
|
+
export declare function createVerifyEmailRoutes(deps: VerifyEmailRouteDeps, config?: VerifyEmailRouteConfig): Hono;
|
|
16
|
+
/**
|
|
17
|
+
* Create verify-email routes with factory functions (for lazy init).
|
|
18
|
+
*/
|
|
19
|
+
export declare function createVerifyEmailRoutesLazy(poolFactory: () => Pool, creditLedgerFactory: () => ICreditLedger, config?: VerifyEmailRouteConfig): Hono;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { logger } from "../../config/logger.js";
|
|
3
|
+
import { grantSignupCredits } from "../../credits/index.js";
|
|
4
|
+
import { getEmailClient } from "../../email/client.js";
|
|
5
|
+
import { verifyToken, welcomeTemplate } from "../../email/index.js";
|
|
6
|
+
/**
|
|
7
|
+
* Create verify-email routes with explicit dependencies (for testing).
|
|
8
|
+
*/
|
|
9
|
+
export function createVerifyEmailRoutes(deps, config) {
|
|
10
|
+
return buildRoutes(() => deps.pool, () => deps.creditLedger, config);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Create verify-email routes with factory functions (for lazy init).
|
|
14
|
+
*/
|
|
15
|
+
export function createVerifyEmailRoutesLazy(poolFactory, creditLedgerFactory, config) {
|
|
16
|
+
return buildRoutes(poolFactory, creditLedgerFactory, config);
|
|
17
|
+
}
|
|
18
|
+
function buildRoutes(poolFactory, creditLedgerFactory, config) {
|
|
19
|
+
const uiOrigin = config?.uiOrigin ?? process.env.UI_ORIGIN ?? "http://localhost:3001";
|
|
20
|
+
const routes = new Hono();
|
|
21
|
+
routes.get("/verify", async (c) => {
|
|
22
|
+
const token = c.req.query("token");
|
|
23
|
+
if (!token) {
|
|
24
|
+
return c.redirect(`${uiOrigin}/auth/verify?status=error&reason=missing_token`);
|
|
25
|
+
}
|
|
26
|
+
const pool = poolFactory();
|
|
27
|
+
const result = await verifyToken(pool, token);
|
|
28
|
+
if (!result) {
|
|
29
|
+
return c.redirect(`${uiOrigin}/auth/verify?status=error&reason=invalid_or_expired`);
|
|
30
|
+
}
|
|
31
|
+
// Grant $5 signup credit (idempotent — safe on link re-click)
|
|
32
|
+
try {
|
|
33
|
+
const ledger = creditLedgerFactory();
|
|
34
|
+
const granted = await grantSignupCredits(ledger, result.userId);
|
|
35
|
+
if (granted) {
|
|
36
|
+
logger.info("Signup credit granted", { userId: result.userId });
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
logger.info("Signup credit already granted, skipping", { userId: result.userId });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
logger.error("Failed to grant signup credit", {
|
|
44
|
+
userId: result.userId,
|
|
45
|
+
error: err instanceof Error ? err.message : String(err),
|
|
46
|
+
});
|
|
47
|
+
// Don't block verification if credit grant fails
|
|
48
|
+
}
|
|
49
|
+
// Send welcome email
|
|
50
|
+
try {
|
|
51
|
+
const emailClient = getEmailClient();
|
|
52
|
+
const template = welcomeTemplate(result.email);
|
|
53
|
+
await emailClient.send({
|
|
54
|
+
to: result.email,
|
|
55
|
+
...template,
|
|
56
|
+
userId: result.userId,
|
|
57
|
+
templateName: "welcome",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
logger.error("Failed to send welcome email", {
|
|
62
|
+
userId: result.userId,
|
|
63
|
+
error: err instanceof Error ? err.message : String(err),
|
|
64
|
+
});
|
|
65
|
+
// Don't block verification if welcome email fails
|
|
66
|
+
}
|
|
67
|
+
return c.redirect(`${uiOrigin}/auth/verify?status=success`);
|
|
68
|
+
});
|
|
69
|
+
return routes;
|
|
70
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface WsAuthRequest {
|
|
2
|
+
nodeId: string;
|
|
3
|
+
authHeader: string | undefined;
|
|
4
|
+
}
|
|
5
|
+
export interface WsAuthResult {
|
|
6
|
+
authenticated: boolean;
|
|
7
|
+
nodeId?: string;
|
|
8
|
+
reason?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface NodeSecretVerifier {
|
|
11
|
+
verifyNodeSecret(nodeId: string, secret: string): Promise<boolean | null>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Authenticate a WebSocket upgrade request for a node agent connection.
|
|
15
|
+
*
|
|
16
|
+
* The bearer token resolves to a specific node via per-node persistent secret;
|
|
17
|
+
* the resolved nodeId must match the URL nodeId.
|
|
18
|
+
*
|
|
19
|
+
* @param verifier - object with verifyNodeSecret method (typically a node repository)
|
|
20
|
+
*/
|
|
21
|
+
export declare function authenticateWebSocketUpgrade(req: WsAuthRequest, verifier: NodeSecretVerifier): Promise<WsAuthResult>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticate a WebSocket upgrade request for a node agent connection.
|
|
3
|
+
*
|
|
4
|
+
* The bearer token resolves to a specific node via per-node persistent secret;
|
|
5
|
+
* the resolved nodeId must match the URL nodeId.
|
|
6
|
+
*
|
|
7
|
+
* @param verifier - object with verifyNodeSecret method (typically a node repository)
|
|
8
|
+
*/
|
|
9
|
+
export async function authenticateWebSocketUpgrade(req, verifier) {
|
|
10
|
+
const { nodeId, authHeader } = req;
|
|
11
|
+
const bearer = authHeader?.replace(/^Bearer\s+/i, "");
|
|
12
|
+
// Per-node persistent secret — timing-safe comparison via verifyNodeSecret
|
|
13
|
+
if (bearer) {
|
|
14
|
+
const verified = await verifier.verifyNodeSecret(nodeId, bearer);
|
|
15
|
+
if (verified === true) {
|
|
16
|
+
return { authenticated: true, nodeId };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// No valid auth
|
|
20
|
+
if (!bearer) {
|
|
21
|
+
return { authenticated: false, reason: "unauthorized" };
|
|
22
|
+
}
|
|
23
|
+
return { authenticated: false, reason: "unauthorized" };
|
|
24
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* - text-generation (DeepSeek, Gemini, MiniMax, Kimi, OpenRouter)
|
|
12
12
|
* - tts (Chatterbox GPU, ElevenLabs)
|
|
13
13
|
* - transcription (Deepgram)
|
|
14
|
-
* - embeddings (OpenRouter)
|
|
14
|
+
* - embeddings (Ollama GPU, OpenRouter)
|
|
15
15
|
* - image-generation (Replicate, Nano Banana)
|
|
16
16
|
*/
|
|
17
17
|
import { type EmbeddingsFactoryConfig } from "./embeddings-factory.js";
|
|
@@ -74,7 +74,7 @@ export declare function bootstrapAdapters(config: BootstrapConfig): BootstrapRes
|
|
|
74
74
|
* - DEEPSEEK_API_KEY, GEMINI_API_KEY, MINIMAX_API_KEY, KIMI_API_KEY, OPENROUTER_API_KEY (text-gen)
|
|
75
75
|
* - CHATTERBOX_BASE_URL, ELEVENLABS_API_KEY (TTS)
|
|
76
76
|
* - DEEPGRAM_API_KEY (transcription)
|
|
77
|
-
* - OPENROUTER_API_KEY (embeddings)
|
|
77
|
+
* - OLLAMA_BASE_URL, OPENROUTER_API_KEY (embeddings)
|
|
78
78
|
* - REPLICATE_API_TOKEN, NANO_BANANA_API_KEY (image-gen)
|
|
79
79
|
*
|
|
80
80
|
* Accepts optional per-capability config overrides.
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* - text-generation (DeepSeek, Gemini, MiniMax, Kimi, OpenRouter)
|
|
12
12
|
* - tts (Chatterbox GPU, ElevenLabs)
|
|
13
13
|
* - transcription (Deepgram)
|
|
14
|
-
* - embeddings (OpenRouter)
|
|
14
|
+
* - embeddings (Ollama GPU, OpenRouter)
|
|
15
15
|
* - image-generation (Replicate, Nano Banana)
|
|
16
16
|
*/
|
|
17
17
|
import { createEmbeddingsAdapters } from "./embeddings-factory.js";
|
|
@@ -76,7 +76,7 @@ export function bootstrapAdapters(config) {
|
|
|
76
76
|
* - DEEPSEEK_API_KEY, GEMINI_API_KEY, MINIMAX_API_KEY, KIMI_API_KEY, OPENROUTER_API_KEY (text-gen)
|
|
77
77
|
* - CHATTERBOX_BASE_URL, ELEVENLABS_API_KEY (TTS)
|
|
78
78
|
* - DEEPGRAM_API_KEY (transcription)
|
|
79
|
-
* - OPENROUTER_API_KEY (embeddings)
|
|
79
|
+
* - OLLAMA_BASE_URL, OPENROUTER_API_KEY (embeddings)
|
|
80
80
|
* - REPLICATE_API_TOKEN, NANO_BANANA_API_KEY (image-gen)
|
|
81
81
|
*
|
|
82
82
|
* Accepts optional per-capability config overrides.
|
|
@@ -101,6 +101,7 @@ export function bootstrapAdaptersFromEnv(overrides) {
|
|
|
101
101
|
...overrides?.transcription,
|
|
102
102
|
},
|
|
103
103
|
embeddings: {
|
|
104
|
+
ollamaBaseUrl: process.env.OLLAMA_BASE_URL,
|
|
104
105
|
openrouterApiKey: process.env.OPENROUTER_API_KEY,
|
|
105
106
|
...overrides?.embeddings,
|
|
106
107
|
},
|
|
@@ -18,6 +18,7 @@ describe("bootstrapAdapters", () => {
|
|
|
18
18
|
deepgramApiKey: "sk-dg",
|
|
19
19
|
},
|
|
20
20
|
embeddings: {
|
|
21
|
+
ollamaBaseUrl: "http://ollama:11434",
|
|
21
22
|
openrouterApiKey: "sk-or",
|
|
22
23
|
},
|
|
23
24
|
imageGen: {
|
|
@@ -25,9 +26,9 @@ describe("bootstrapAdapters", () => {
|
|
|
25
26
|
geminiApiKey: "sk-gem",
|
|
26
27
|
},
|
|
27
28
|
});
|
|
28
|
-
// 5 text-gen + 2 TTS + 1 transcription +
|
|
29
|
-
expect(result.adapters).toHaveLength(
|
|
30
|
-
expect(result.summary.total).toBe(
|
|
29
|
+
// 5 text-gen + 2 TTS + 1 transcription + 2 embeddings + 2 image-gen = 12
|
|
30
|
+
expect(result.adapters).toHaveLength(12);
|
|
31
|
+
expect(result.summary.total).toBe(12);
|
|
31
32
|
expect(result.summary.skipped).toBe(0);
|
|
32
33
|
});
|
|
33
34
|
it("allows duplicate provider names across capabilities", () => {
|
|
@@ -64,7 +65,7 @@ describe("bootstrapAdapters", () => {
|
|
|
64
65
|
});
|
|
65
66
|
expect(result.skipped.tts).toEqual(["chatterbox-tts", "elevenlabs"]);
|
|
66
67
|
expect(result.skipped.transcription).toEqual(["deepgram"]);
|
|
67
|
-
expect(result.skipped.embeddings).toEqual(["openrouter"]);
|
|
68
|
+
expect(result.skipped.embeddings).toEqual(["ollama-embeddings", "openrouter"]);
|
|
68
69
|
expect(result.skipped["text-generation"]).toEqual(["gemini", "minimax", "kimi", "openrouter"]);
|
|
69
70
|
expect(result.skipped["image-generation"]).toEqual(["replicate", "nano-banana"]);
|
|
70
71
|
});
|
|
@@ -117,12 +118,13 @@ describe("bootstrapAdaptersFromEnv", () => {
|
|
|
117
118
|
vi.stubEnv("CHATTERBOX_BASE_URL", "http://chatterbox:8000");
|
|
118
119
|
vi.stubEnv("ELEVENLABS_API_KEY", "env-el");
|
|
119
120
|
vi.stubEnv("DEEPGRAM_API_KEY", "env-dg");
|
|
121
|
+
vi.stubEnv("OLLAMA_BASE_URL", "http://ollama:11434");
|
|
120
122
|
vi.stubEnv("REPLICATE_API_TOKEN", "r8-rep");
|
|
121
123
|
vi.stubEnv("NANO_BANANA_API_KEY", "env-nb");
|
|
122
124
|
const result = bootstrapAdaptersFromEnv();
|
|
123
|
-
// 5 text-gen + 2 TTS + 1 transcription +
|
|
124
|
-
expect(result.adapters).toHaveLength(
|
|
125
|
-
expect(result.summary.total).toBe(
|
|
125
|
+
// 5 text-gen + 2 TTS + 1 transcription + 2 embeddings + 2 image-gen = 12
|
|
126
|
+
expect(result.adapters).toHaveLength(12);
|
|
127
|
+
expect(result.summary.total).toBe(12);
|
|
126
128
|
});
|
|
127
129
|
it("returns empty when no env vars set", () => {
|
|
128
130
|
vi.stubEnv("DEEPSEEK_API_KEY", "");
|
|
@@ -133,6 +135,7 @@ describe("bootstrapAdaptersFromEnv", () => {
|
|
|
133
135
|
vi.stubEnv("CHATTERBOX_BASE_URL", "");
|
|
134
136
|
vi.stubEnv("ELEVENLABS_API_KEY", "");
|
|
135
137
|
vi.stubEnv("DEEPGRAM_API_KEY", "");
|
|
138
|
+
vi.stubEnv("OLLAMA_BASE_URL", "");
|
|
136
139
|
vi.stubEnv("REPLICATE_API_TOKEN", "");
|
|
137
140
|
vi.stubEnv("NANO_BANANA_API_KEY", "");
|
|
138
141
|
const result = bootstrapAdaptersFromEnv();
|
|
@@ -148,6 +151,7 @@ describe("bootstrapAdaptersFromEnv", () => {
|
|
|
148
151
|
vi.stubEnv("CHATTERBOX_BASE_URL", "");
|
|
149
152
|
vi.stubEnv("ELEVENLABS_API_KEY", "");
|
|
150
153
|
vi.stubEnv("DEEPGRAM_API_KEY", "");
|
|
154
|
+
vi.stubEnv("OLLAMA_BASE_URL", "");
|
|
151
155
|
vi.stubEnv("REPLICATE_API_TOKEN", "");
|
|
152
156
|
vi.stubEnv("NANO_BANANA_API_KEY", "");
|
|
153
157
|
const result = bootstrapAdaptersFromEnv({
|
|
@@ -7,16 +7,20 @@
|
|
|
7
7
|
* registers with an ArbitrageRouter or AdapterSocket.
|
|
8
8
|
*
|
|
9
9
|
* Priority order (cheapest first, when all adapters available):
|
|
10
|
-
*
|
|
10
|
+
* Ollama (GPU, cheapest — $0.005/1M tokens amortized)
|
|
11
11
|
* → OpenRouter ($0.02/1M tokens via text-embedding-3-small)
|
|
12
12
|
*/
|
|
13
|
+
import { type OllamaEmbeddingsAdapterConfig } from "./ollama-embeddings.js";
|
|
13
14
|
import { type OpenRouterAdapterConfig } from "./openrouter.js";
|
|
14
15
|
import type { ProviderAdapter } from "./types.js";
|
|
15
|
-
/** Top-level factory config. Only providers with
|
|
16
|
+
/** Top-level factory config. Only providers with a key/URL are instantiated. */
|
|
16
17
|
export interface EmbeddingsFactoryConfig {
|
|
18
|
+
/** Ollama base URL (e.g., "http://ollama:11434"). Omit or empty string to skip. */
|
|
19
|
+
ollamaBaseUrl?: string;
|
|
17
20
|
/** OpenRouter API key. Omit or empty string to skip. */
|
|
18
21
|
openrouterApiKey?: string;
|
|
19
22
|
/** Per-adapter config overrides */
|
|
23
|
+
ollama?: Omit<Partial<OllamaEmbeddingsAdapterConfig>, "baseUrl">;
|
|
20
24
|
openrouter?: Omit<Partial<OpenRouterAdapterConfig>, "apiKey">;
|
|
21
25
|
}
|
|
22
26
|
/** Result of the factory — adapters + metadata for observability. */
|
|
@@ -31,16 +35,17 @@ export interface EmbeddingsFactoryResult {
|
|
|
31
35
|
/**
|
|
32
36
|
* Create embeddings adapters from the provided config.
|
|
33
37
|
*
|
|
34
|
-
* Returns only adapters whose
|
|
38
|
+
* Returns only adapters whose key/URL is present and non-empty.
|
|
35
39
|
* Order matches arbitrage priority: cheapest first.
|
|
36
40
|
*/
|
|
37
41
|
export declare function createEmbeddingsAdapters(config: EmbeddingsFactoryConfig): EmbeddingsFactoryResult;
|
|
38
42
|
/**
|
|
39
43
|
* Create embeddings adapters from environment variables.
|
|
40
44
|
*
|
|
41
|
-
* Reads
|
|
45
|
+
* Reads config from:
|
|
46
|
+
* - OLLAMA_BASE_URL (for self-hosted Ollama embeddings)
|
|
42
47
|
* - OPENROUTER_API_KEY
|
|
43
48
|
*
|
|
44
49
|
* Accepts optional per-adapter overrides.
|
|
45
50
|
*/
|
|
46
|
-
export declare function createEmbeddingsAdaptersFromEnv(overrides?: Omit<EmbeddingsFactoryConfig, "openrouterApiKey">): EmbeddingsFactoryResult;
|
|
51
|
+
export declare function createEmbeddingsAdaptersFromEnv(overrides?: Omit<EmbeddingsFactoryConfig, "ollamaBaseUrl" | "openrouterApiKey">): EmbeddingsFactoryResult;
|
|
@@ -7,19 +7,31 @@
|
|
|
7
7
|
* registers with an ArbitrageRouter or AdapterSocket.
|
|
8
8
|
*
|
|
9
9
|
* Priority order (cheapest first, when all adapters available):
|
|
10
|
-
*
|
|
10
|
+
* Ollama (GPU, cheapest — $0.005/1M tokens amortized)
|
|
11
11
|
* → OpenRouter ($0.02/1M tokens via text-embedding-3-small)
|
|
12
12
|
*/
|
|
13
|
+
import { createOllamaEmbeddingsAdapter } from "./ollama-embeddings.js";
|
|
13
14
|
import { createOpenRouterAdapter } from "./openrouter.js";
|
|
14
15
|
/**
|
|
15
16
|
* Create embeddings adapters from the provided config.
|
|
16
17
|
*
|
|
17
|
-
* Returns only adapters whose
|
|
18
|
+
* Returns only adapters whose key/URL is present and non-empty.
|
|
18
19
|
* Order matches arbitrage priority: cheapest first.
|
|
19
20
|
*/
|
|
20
21
|
export function createEmbeddingsAdapters(config) {
|
|
21
22
|
const adapters = [];
|
|
22
23
|
const skipped = [];
|
|
24
|
+
// Ollama — $0.005/1M tokens (self-hosted GPU, cheapest)
|
|
25
|
+
if (config.ollamaBaseUrl) {
|
|
26
|
+
adapters.push(createOllamaEmbeddingsAdapter({
|
|
27
|
+
baseUrl: config.ollamaBaseUrl,
|
|
28
|
+
costPerUnit: 0.000000005,
|
|
29
|
+
...config.ollama,
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
skipped.push("ollama-embeddings");
|
|
34
|
+
}
|
|
23
35
|
// OpenRouter — $0.02/1M tokens (text-embedding-3-small via OpenAI)
|
|
24
36
|
if (config.openrouterApiKey) {
|
|
25
37
|
adapters.push(createOpenRouterAdapter({ ...config.openrouter, apiKey: config.openrouterApiKey }));
|
|
@@ -27,7 +39,6 @@ export function createEmbeddingsAdapters(config) {
|
|
|
27
39
|
else {
|
|
28
40
|
skipped.push("openrouter");
|
|
29
41
|
}
|
|
30
|
-
// Future: self-hosted-embeddings will go BEFORE openrouter (GPU tier, cheapest)
|
|
31
42
|
const adapterMap = new Map();
|
|
32
43
|
for (const adapter of adapters) {
|
|
33
44
|
adapterMap.set(adapter.name, adapter);
|
|
@@ -37,13 +48,15 @@ export function createEmbeddingsAdapters(config) {
|
|
|
37
48
|
/**
|
|
38
49
|
* Create embeddings adapters from environment variables.
|
|
39
50
|
*
|
|
40
|
-
* Reads
|
|
51
|
+
* Reads config from:
|
|
52
|
+
* - OLLAMA_BASE_URL (for self-hosted Ollama embeddings)
|
|
41
53
|
* - OPENROUTER_API_KEY
|
|
42
54
|
*
|
|
43
55
|
* Accepts optional per-adapter overrides.
|
|
44
56
|
*/
|
|
45
57
|
export function createEmbeddingsAdaptersFromEnv(overrides) {
|
|
46
58
|
return createEmbeddingsAdapters({
|
|
59
|
+
ollamaBaseUrl: process.env.OLLAMA_BASE_URL,
|
|
47
60
|
openrouterApiKey: process.env.OPENROUTER_API_KEY,
|
|
48
61
|
...overrides,
|
|
49
62
|
});
|