@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.
Files changed (123) hide show
  1. package/dist/api/routes/activity.d.ts +9 -0
  2. package/dist/api/routes/activity.js +68 -0
  3. package/dist/api/routes/admin-audit-helper.d.ts +7 -0
  4. package/dist/api/routes/admin-audit-helper.js +13 -0
  5. package/dist/api/routes/admin-audit.d.ts +13 -0
  6. package/dist/api/routes/admin-audit.js +61 -0
  7. package/dist/api/routes/admin-backups.d.ts +19 -0
  8. package/dist/api/routes/admin-backups.js +116 -0
  9. package/dist/api/routes/admin-compliance.d.ts +9 -0
  10. package/dist/api/routes/admin-compliance.js +27 -0
  11. package/dist/api/routes/admin-credits.d.ts +9 -0
  12. package/dist/api/routes/admin-credits.js +255 -0
  13. package/dist/api/routes/admin-gpu.d.ts +46 -0
  14. package/dist/api/routes/admin-gpu.js +140 -0
  15. package/dist/api/routes/admin-inference.d.ts +16 -0
  16. package/dist/api/routes/admin-inference.js +98 -0
  17. package/dist/api/routes/admin-marketplace.d.ts +36 -0
  18. package/dist/api/routes/admin-marketplace.js +181 -0
  19. package/dist/api/routes/admin-migration.d.ts +10 -0
  20. package/dist/api/routes/admin-migration.js +46 -0
  21. package/dist/api/routes/admin-notes.d.ts +34 -0
  22. package/dist/api/routes/admin-notes.js +131 -0
  23. package/dist/api/routes/admin-onboarding.d.ts +7 -0
  24. package/dist/api/routes/admin-onboarding.js +49 -0
  25. package/dist/api/routes/admin-rates.d.ts +9 -0
  26. package/dist/api/routes/admin-rates.js +427 -0
  27. package/dist/api/routes/admin-recovery.d.ts +91 -0
  28. package/dist/api/routes/admin-recovery.js +246 -0
  29. package/dist/api/routes/admin-roles.d.ts +27 -0
  30. package/dist/api/routes/admin-roles.js +157 -0
  31. package/dist/api/routes/audit.d.ts +19 -0
  32. package/dist/api/routes/audit.js +95 -0
  33. package/dist/api/routes/auth.d.ts +19 -0
  34. package/dist/api/routes/auth.js +25 -0
  35. package/dist/api/routes/channel-validate.d.ts +11 -0
  36. package/dist/api/routes/channel-validate.js +148 -0
  37. package/dist/api/routes/fleet-events.d.ts +4 -0
  38. package/dist/api/routes/fleet-events.js +53 -0
  39. package/dist/api/routes/friends-proxy.d.ts +28 -0
  40. package/dist/api/routes/friends-proxy.js +63 -0
  41. package/dist/api/routes/friends-types.d.ts +34 -0
  42. package/dist/api/routes/friends-types.js +28 -0
  43. package/dist/api/routes/health.d.ts +14 -0
  44. package/dist/api/routes/health.js +32 -0
  45. package/dist/api/routes/health.test.d.ts +1 -0
  46. package/dist/api/routes/health.test.js +70 -0
  47. package/dist/api/routes/incident-response.d.ts +9 -0
  48. package/dist/api/routes/incident-response.js +148 -0
  49. package/dist/api/routes/internal-gpu.d.ts +12 -0
  50. package/dist/api/routes/internal-gpu.js +70 -0
  51. package/dist/api/routes/internal-nodes.d.ts +41 -0
  52. package/dist/api/routes/internal-nodes.js +105 -0
  53. package/dist/api/routes/login-history.d.ts +11 -0
  54. package/dist/api/routes/login-history.js +22 -0
  55. package/dist/api/routes/public-pricing.d.ts +9 -0
  56. package/dist/api/routes/public-pricing.js +32 -0
  57. package/dist/api/routes/quota.d.ts +8 -0
  58. package/dist/api/routes/quota.js +113 -0
  59. package/dist/api/routes/secret-audit.d.ts +12 -0
  60. package/dist/api/routes/secret-audit.js +41 -0
  61. package/dist/api/routes/secrets.d.ts +31 -0
  62. package/dist/api/routes/secrets.js +135 -0
  63. package/dist/api/routes/tenant-keys.d.ts +16 -0
  64. package/dist/api/routes/tenant-keys.js +142 -0
  65. package/dist/api/routes/verify-email.d.ts +19 -0
  66. package/dist/api/routes/verify-email.js +70 -0
  67. package/dist/api/routes/ws-auth.d.ts +21 -0
  68. package/dist/api/routes/ws-auth.js +24 -0
  69. package/dist/monetization/adapters/bootstrap.d.ts +2 -2
  70. package/dist/monetization/adapters/bootstrap.js +3 -2
  71. package/dist/monetization/adapters/bootstrap.test.js +11 -7
  72. package/dist/monetization/adapters/embeddings-factory.d.ts +10 -5
  73. package/dist/monetization/adapters/embeddings-factory.js +17 -4
  74. package/dist/monetization/adapters/embeddings-factory.test.js +85 -31
  75. package/dist/monetization/adapters/ollama-embeddings.d.ts +40 -0
  76. package/dist/monetization/adapters/ollama-embeddings.js +76 -0
  77. package/dist/monetization/adapters/ollama-embeddings.test.d.ts +1 -0
  78. package/dist/monetization/adapters/ollama-embeddings.test.js +178 -0
  79. package/dist/monetization/adapters/rate-table.js +9 -3
  80. package/dist/monetization/adapters/rate-table.test.js +22 -1
  81. package/package.json +35 -1
  82. package/src/api/routes/activity.ts +77 -0
  83. package/src/api/routes/admin-audit-helper.ts +18 -0
  84. package/src/api/routes/admin-audit.ts +67 -0
  85. package/src/api/routes/admin-backups.ts +134 -0
  86. package/src/api/routes/admin-compliance.ts +35 -0
  87. package/src/api/routes/admin-credits.ts +280 -0
  88. package/src/api/routes/admin-gpu.ts +202 -0
  89. package/src/api/routes/admin-inference.ts +109 -0
  90. package/src/api/routes/admin-marketplace.ts +233 -0
  91. package/src/api/routes/admin-migration.ts +61 -0
  92. package/src/api/routes/admin-notes.ts +145 -0
  93. package/src/api/routes/admin-onboarding.ts +62 -0
  94. package/src/api/routes/admin-rates.ts +462 -0
  95. package/src/api/routes/admin-recovery.ts +376 -0
  96. package/src/api/routes/admin-roles.ts +205 -0
  97. package/src/api/routes/audit.ts +106 -0
  98. package/src/api/routes/auth.ts +30 -0
  99. package/src/api/routes/channel-validate.ts +182 -0
  100. package/src/api/routes/fleet-events.ts +66 -0
  101. package/src/api/routes/friends-proxy.ts +94 -0
  102. package/src/api/routes/friends-types.ts +37 -0
  103. package/src/api/routes/health.test.ts +80 -0
  104. package/src/api/routes/health.ts +48 -0
  105. package/src/api/routes/incident-response.ts +159 -0
  106. package/src/api/routes/internal-gpu.ts +92 -0
  107. package/src/api/routes/internal-nodes.ts +157 -0
  108. package/src/api/routes/login-history.ts +28 -0
  109. package/src/api/routes/public-pricing.ts +36 -0
  110. package/src/api/routes/quota.ts +136 -0
  111. package/src/api/routes/secret-audit.ts +55 -0
  112. package/src/api/routes/secrets.ts +178 -0
  113. package/src/api/routes/tenant-keys.ts +178 -0
  114. package/src/api/routes/verify-email.ts +102 -0
  115. package/src/api/routes/ws-auth.ts +44 -0
  116. package/src/monetization/adapters/bootstrap.test.ts +11 -7
  117. package/src/monetization/adapters/bootstrap.ts +3 -2
  118. package/src/monetization/adapters/embeddings-factory.test.ts +102 -33
  119. package/src/monetization/adapters/embeddings-factory.ts +24 -7
  120. package/src/monetization/adapters/ollama-embeddings.test.ts +235 -0
  121. package/src/monetization/adapters/ollama-embeddings.ts +120 -0
  122. package/src/monetization/adapters/rate-table.test.ts +32 -1
  123. 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 + 1 embeddings + 2 image-gen = 11
29
- expect(result.adapters).toHaveLength(11);
30
- expect(result.summary.total).toBe(11);
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 + 1 embeddings + 2 image-gen = 11
124
- expect(result.adapters).toHaveLength(11);
125
- expect(result.summary.total).toBe(11);
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
- * self-hosted-embeddings (GPU, cheapest — not yet implemented)
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 an API key are instantiated. */
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 API key is present and non-empty.
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 API keys from:
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
- * self-hosted-embeddings (GPU, cheapest — not yet implemented)
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 API key is present and non-empty.
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 API keys from:
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
  });