@wopr-network/platform-core 1.13.0 → 1.13.2

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 (103) 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/package.json +35 -1
  70. package/src/api/routes/activity.ts +77 -0
  71. package/src/api/routes/admin-audit-helper.ts +18 -0
  72. package/src/api/routes/admin-audit.ts +67 -0
  73. package/src/api/routes/admin-backups.ts +134 -0
  74. package/src/api/routes/admin-compliance.ts +35 -0
  75. package/src/api/routes/admin-credits.ts +280 -0
  76. package/src/api/routes/admin-gpu.ts +202 -0
  77. package/src/api/routes/admin-inference.ts +109 -0
  78. package/src/api/routes/admin-marketplace.ts +233 -0
  79. package/src/api/routes/admin-migration.ts +61 -0
  80. package/src/api/routes/admin-notes.ts +145 -0
  81. package/src/api/routes/admin-onboarding.ts +62 -0
  82. package/src/api/routes/admin-rates.ts +462 -0
  83. package/src/api/routes/admin-recovery.ts +376 -0
  84. package/src/api/routes/admin-roles.ts +205 -0
  85. package/src/api/routes/audit.ts +106 -0
  86. package/src/api/routes/auth.ts +30 -0
  87. package/src/api/routes/channel-validate.ts +182 -0
  88. package/src/api/routes/fleet-events.ts +66 -0
  89. package/src/api/routes/friends-proxy.ts +94 -0
  90. package/src/api/routes/friends-types.ts +37 -0
  91. package/src/api/routes/health.test.ts +80 -0
  92. package/src/api/routes/health.ts +48 -0
  93. package/src/api/routes/incident-response.ts +159 -0
  94. package/src/api/routes/internal-gpu.ts +92 -0
  95. package/src/api/routes/internal-nodes.ts +157 -0
  96. package/src/api/routes/login-history.ts +28 -0
  97. package/src/api/routes/public-pricing.ts +36 -0
  98. package/src/api/routes/quota.ts +136 -0
  99. package/src/api/routes/secret-audit.ts +55 -0
  100. package/src/api/routes/secrets.ts +178 -0
  101. package/src/api/routes/tenant-keys.ts +178 -0
  102. package/src/api/routes/verify-email.ts +102 -0
  103. package/src/api/routes/ws-auth.ts +44 -0
@@ -0,0 +1,178 @@
1
+ import { Hono } from "hono";
2
+ import type { AuthEnv } from "../../auth/index.js";
3
+ import { validateTenantOwnership } from "../../auth/index.js";
4
+ import { decrypt as defaultDecrypt, deriveInstanceKey as defaultDeriveInstanceKey } from "../../security/encryption.js";
5
+ import {
6
+ forwardSecretsToInstance as defaultForwardSecrets,
7
+ writeEncryptedSeed as defaultWriteSeed,
8
+ } from "../../security/key-injection.js";
9
+ import { validateProviderKey as defaultValidateKey } from "../../security/key-validation.js";
10
+ import { validateKeyRequestSchema, writeSecretsRequestSchema } from "../../security/types.js";
11
+
12
+ /** Allowlist: only alphanumeric, hyphens, and underscores. */
13
+ const INSTANCE_ID_RE = /^[a-zA-Z0-9_-]+$/;
14
+
15
+ function isValidInstanceId(id: string): boolean {
16
+ return INSTANCE_ID_RE.test(id);
17
+ }
18
+
19
+ export interface IProfileLookup {
20
+ getInstanceTenantId(instanceId: string): Promise<string | undefined>;
21
+ }
22
+
23
+ export interface SecretsSecurityFns {
24
+ decrypt: typeof defaultDecrypt;
25
+ deriveInstanceKey: typeof defaultDeriveInstanceKey;
26
+ writeEncryptedSeed: typeof defaultWriteSeed;
27
+ forwardSecretsToInstance: typeof defaultForwardSecrets;
28
+ validateProviderKey: typeof defaultValidateKey;
29
+ }
30
+
31
+ export interface SecretsDeps {
32
+ profileLookup: IProfileLookup;
33
+ platformSecret?: string;
34
+ instanceDataDir?: string;
35
+ logger?: { error(msg: string, meta?: Record<string, unknown>): void };
36
+ /** Override security functions (useful for testing). Falls back to platform-core defaults. */
37
+ security?: Partial<SecretsSecurityFns>;
38
+ }
39
+
40
+ /**
41
+ * Create secrets management routes.
42
+ *
43
+ * @param deps - Dependencies for secret operations
44
+ */
45
+ export function createSecretsRoutes(deps: SecretsDeps): Hono<AuthEnv> {
46
+ const routes = new Hono<AuthEnv>();
47
+ const instanceDataDir = deps.instanceDataDir ?? "/data/instances";
48
+
49
+ // Resolve security functions with defaults
50
+ const decryptFn = deps.security?.decrypt ?? defaultDecrypt;
51
+ const deriveInstanceKeyFn = deps.security?.deriveInstanceKey ?? defaultDeriveInstanceKey;
52
+ const writeEncryptedSeedFn = deps.security?.writeEncryptedSeed ?? defaultWriteSeed;
53
+ const forwardSecretsFn = deps.security?.forwardSecretsToInstance ?? defaultForwardSecrets;
54
+ const validateKeyFn = deps.security?.validateProviderKey ?? defaultValidateKey;
55
+
56
+ /**
57
+ * PUT /instances/:id/config/secrets
58
+ *
59
+ * Writes secrets to a running instance by forwarding the body opaquely,
60
+ * or writes an encrypted seed file if the instance is not running.
61
+ */
62
+ routes.put("/instances/:id/config/secrets", async (c) => {
63
+ const instanceId = c.req.param("id");
64
+ if (!isValidInstanceId(instanceId)) {
65
+ return c.json({ error: "Invalid instance ID" }, 400);
66
+ }
67
+
68
+ // Validate tenant ownership of the instance
69
+ const tenantId = await deps.profileLookup.getInstanceTenantId(instanceId);
70
+ const ownershipError = validateTenantOwnership(c, instanceId, tenantId);
71
+ if (ownershipError) {
72
+ return ownershipError;
73
+ }
74
+
75
+ const mode = c.req.query("mode") || "proxy";
76
+
77
+ if (mode === "seed") {
78
+ // Pre-boot: parse body to encrypt, then discard plaintext
79
+ let body: unknown;
80
+ try {
81
+ body = await c.req.json();
82
+ } catch {
83
+ return c.json({ error: "Invalid JSON body" }, 400);
84
+ }
85
+
86
+ const parsed = writeSecretsRequestSchema.safeParse(body);
87
+ if (!parsed.success) {
88
+ return c.json({ error: "Validation failed", details: parsed.error.flatten() }, 400);
89
+ }
90
+
91
+ if (!deps.platformSecret) {
92
+ return c.json({ error: "Platform secret not configured" }, 500);
93
+ }
94
+
95
+ try {
96
+ const instanceKey = deriveInstanceKeyFn(instanceId, deps.platformSecret);
97
+ const woprHome = `${instanceDataDir}/${instanceId}`;
98
+ await writeEncryptedSeedFn(woprHome, parsed.data, instanceKey);
99
+ return c.json({ ok: true, mode: "seed" });
100
+ } catch (err) {
101
+ const message = err instanceof Error ? err.message : "Failed to write seed";
102
+ deps.logger?.error("Failed to write encrypted seed", { instanceId, error: message });
103
+ return c.json({ error: "Failed to write encrypted seed" }, 500);
104
+ }
105
+ }
106
+
107
+ // Default: proxy mode — forward body opaquely to the instance container
108
+ const rawBody = await c.req.text();
109
+ const instanceUrl = `http://wopr-${instanceId}:3000`;
110
+ const authHeader = c.req.header("Authorization") || "";
111
+ const sessionToken = authHeader.replace(/^Bearer\s+/i, "");
112
+
113
+ const result = await forwardSecretsFn(instanceUrl, sessionToken, rawBody);
114
+ if (result.ok) {
115
+ return c.json({ ok: true, mode: "proxy" });
116
+ }
117
+ const status = result.status === 502 ? 502 : result.status === 503 ? 503 : result.status === 404 ? 404 : 500;
118
+ return c.json({ error: result.error || "Proxy failed" }, status);
119
+ });
120
+
121
+ /**
122
+ * POST /validate-key
123
+ *
124
+ * Validates a provider API key without logging or persisting it.
125
+ */
126
+ routes.post("/validate-key", async (c) => {
127
+ let body: unknown;
128
+ try {
129
+ body = await c.req.json();
130
+ } catch {
131
+ return c.json({ error: "Invalid JSON body" }, 400);
132
+ }
133
+
134
+ const parsed = validateKeyRequestSchema.safeParse(body);
135
+ if (!parsed.success) {
136
+ return c.json({ error: "Validation failed", details: parsed.error.flatten() }, 400);
137
+ }
138
+
139
+ const { provider, encryptedKey } = parsed.data;
140
+
141
+ // Decrypt the key in memory
142
+ let plaintextKey: string;
143
+ try {
144
+ const encryptedPayload = JSON.parse(encryptedKey);
145
+ const instanceId = c.req.query("instanceId");
146
+ if (!instanceId) {
147
+ return c.json({ error: "instanceId query parameter required" }, 400);
148
+ }
149
+ if (!isValidInstanceId(instanceId)) {
150
+ return c.json({ error: "Invalid instance ID" }, 400);
151
+ }
152
+
153
+ // Validate tenant ownership of the instance
154
+ const tenantId = await deps.profileLookup.getInstanceTenantId(instanceId);
155
+ const ownershipError = validateTenantOwnership(c, instanceId, tenantId);
156
+ if (ownershipError) {
157
+ return ownershipError;
158
+ }
159
+ if (!deps.platformSecret) {
160
+ return c.json({ error: "Platform secret not configured" }, 500);
161
+ }
162
+ const instanceKey = deriveInstanceKeyFn(instanceId, deps.platformSecret);
163
+ plaintextKey = decryptFn(encryptedPayload, instanceKey);
164
+ } catch {
165
+ return c.json({ error: "Failed to decrypt key payload" }, 400);
166
+ }
167
+
168
+ // Validate against the provider API
169
+ const result = await validateKeyFn(provider, plaintextKey);
170
+
171
+ // Explicitly discard the key reference
172
+ plaintextKey = "";
173
+
174
+ return c.json({ valid: result.valid, error: result.error });
175
+ });
176
+
177
+ return routes;
178
+ }
@@ -0,0 +1,178 @@
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 type { ITenantKeyRepository } from "../../security/tenant-keys/tenant-key-repository.js";
6
+ import { providerSchema } from "../../security/types.js";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Request schemas
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const storeKeySchema = z.object({
13
+ provider: providerSchema,
14
+ apiKey: z.string().min(1, "API key is required"),
15
+ label: z.string().max(100).optional(),
16
+ });
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Deps
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export interface TenantKeyDeps {
23
+ repo: () => ITenantKeyRepository;
24
+ /** Platform secret for key derivation. If not provided, encrypt operations will fail with 500. */
25
+ platformSecret?: string;
26
+ logger?: { warn(msg: string): void };
27
+ }
28
+
29
+ /** Derive a per-tenant encryption key from tenant ID and platform secret. */
30
+ function deriveTenantKey(tenantId: string, platformSecret: string): Buffer {
31
+ return createHmac("sha256", platformSecret).update(`tenant:${tenantId}`).digest();
32
+ }
33
+
34
+ /**
35
+ * Create tenant key management routes.
36
+ *
37
+ * @param deps - Dependencies for tenant key operations
38
+ */
39
+ export function createTenantKeyRoutes(deps: TenantKeyDeps): Hono {
40
+ const routes = new Hono();
41
+
42
+ /**
43
+ * GET /
44
+ *
45
+ * List all API keys for the authenticated tenant.
46
+ * Returns metadata only (never the encrypted key material).
47
+ */
48
+ routes.get("/", async (c) => {
49
+ const tenantId = getTenantId(c);
50
+ if (!tenantId) {
51
+ return c.json({ error: "Tenant context required" }, 400);
52
+ }
53
+
54
+ const keys = await deps.repo().listForTenant(tenantId);
55
+ return c.json({ keys });
56
+ });
57
+
58
+ /**
59
+ * GET /:provider
60
+ *
61
+ * Check whether the tenant has a stored key for a specific provider.
62
+ * Returns metadata only.
63
+ */
64
+ routes.get("/:provider", async (c) => {
65
+ const tenantId = getTenantId(c);
66
+ if (!tenantId) {
67
+ return c.json({ error: "Tenant context required" }, 400);
68
+ }
69
+
70
+ const provider = c.req.param("provider");
71
+ const parsed = providerSchema.safeParse(provider);
72
+ if (!parsed.success) {
73
+ return c.json({ error: "Invalid provider" }, 400);
74
+ }
75
+
76
+ const record = await deps.repo().get(tenantId, parsed.data);
77
+ if (!record) {
78
+ return c.json({ error: "No key stored for this provider" }, 404);
79
+ }
80
+
81
+ // Return metadata only, never the encrypted key
82
+ return c.json({
83
+ id: record.id,
84
+ tenant_id: record.tenant_id,
85
+ provider: record.provider,
86
+ label: record.label,
87
+ created_at: record.created_at,
88
+ updated_at: record.updated_at,
89
+ });
90
+ });
91
+
92
+ /**
93
+ * PUT /:provider
94
+ *
95
+ * Store or replace a tenant's API key for a provider.
96
+ * The key is encrypted at rest using AES-256-GCM with a tenant-derived key.
97
+ */
98
+ routes.put("/:provider", async (c) => {
99
+ const tenantId = getTenantId(c);
100
+ if (!tenantId) {
101
+ return c.json({ error: "Tenant context required" }, 400);
102
+ }
103
+
104
+ const provider = c.req.param("provider");
105
+ const providerParsed = providerSchema.safeParse(provider);
106
+ if (!providerParsed.success) {
107
+ return c.json({ error: "Invalid provider" }, 400);
108
+ }
109
+
110
+ let body: unknown;
111
+ try {
112
+ body = await c.req.json();
113
+ } catch {
114
+ return c.json({ error: "Invalid JSON body" }, 400);
115
+ }
116
+
117
+ const parsed = storeKeySchema.safeParse(body);
118
+ if (!parsed.success) {
119
+ return c.json({ error: "Validation failed", details: parsed.error.flatten() }, 400);
120
+ }
121
+
122
+ if (parsed.data.provider !== providerParsed.data) {
123
+ return c.json({ error: "Provider in body must match URL parameter" }, 400);
124
+ }
125
+
126
+ if (!deps.platformSecret) {
127
+ return c.json({ error: "Platform secret not configured" }, 500);
128
+ }
129
+
130
+ // Encrypt the key in memory, then discard the plaintext
131
+ const tenantKey = deriveTenantKey(tenantId, deps.platformSecret);
132
+ const encryptedPayload = encrypt(parsed.data.apiKey, tenantKey);
133
+
134
+ const id = await deps.repo().upsert(tenantId, providerParsed.data, encryptedPayload, parsed.data.label ?? "");
135
+
136
+ return c.json({ ok: true, id, provider: providerParsed.data });
137
+ });
138
+
139
+ /**
140
+ * DELETE /:provider
141
+ *
142
+ * Delete a tenant's stored API key for a provider.
143
+ */
144
+ routes.delete("/:provider", async (c) => {
145
+ const tenantId = getTenantId(c);
146
+ if (!tenantId) {
147
+ return c.json({ error: "Tenant context required" }, 400);
148
+ }
149
+
150
+ const provider = c.req.param("provider");
151
+ const parsed = providerSchema.safeParse(provider);
152
+ if (!parsed.success) {
153
+ return c.json({ error: "Invalid provider" }, 400);
154
+ }
155
+
156
+ const deleted = await deps.repo().delete(tenantId, parsed.data);
157
+ if (!deleted) {
158
+ return c.json({ error: "No key stored for this provider" }, 404);
159
+ }
160
+
161
+ return c.json({ ok: true, provider: parsed.data });
162
+ });
163
+
164
+ return routes;
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Helpers
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /** Extract the tenant ID from the auth context. */
172
+ function getTenantId(c: { get: (key: string) => unknown }): string | undefined {
173
+ try {
174
+ return c.get("tokenTenantId") as string | undefined;
175
+ } catch {
176
+ return undefined;
177
+ }
178
+ }
@@ -0,0 +1,102 @@
1
+ import { Hono } from "hono";
2
+ import type { Pool } from "pg";
3
+ import { logger } from "../../config/logger.js";
4
+ import type { ICreditLedger } from "../../credits/index.js";
5
+ import { grantSignupCredits } from "../../credits/index.js";
6
+ import { getEmailClient } from "../../email/client.js";
7
+ import { verifyToken, welcomeTemplate } from "../../email/index.js";
8
+
9
+ export interface VerifyEmailRouteDeps {
10
+ pool: Pool;
11
+ creditLedger: ICreditLedger;
12
+ }
13
+
14
+ export interface VerifyEmailRouteConfig {
15
+ /** UI origin for redirect URLs (default: http://localhost:3001) */
16
+ uiOrigin?: string;
17
+ }
18
+
19
+ /**
20
+ * Create verify-email routes with explicit dependencies (for testing).
21
+ */
22
+ export function createVerifyEmailRoutes(deps: VerifyEmailRouteDeps, config?: VerifyEmailRouteConfig): Hono {
23
+ return buildRoutes(
24
+ () => deps.pool,
25
+ () => deps.creditLedger,
26
+ config,
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Create verify-email routes with factory functions (for lazy init).
32
+ */
33
+ export function createVerifyEmailRoutesLazy(
34
+ poolFactory: () => Pool,
35
+ creditLedgerFactory: () => ICreditLedger,
36
+ config?: VerifyEmailRouteConfig,
37
+ ): Hono {
38
+ return buildRoutes(poolFactory, creditLedgerFactory, config);
39
+ }
40
+
41
+ function buildRoutes(
42
+ poolFactory: () => Pool,
43
+ creditLedgerFactory: () => ICreditLedger,
44
+ config?: VerifyEmailRouteConfig,
45
+ ): Hono {
46
+ const uiOrigin = config?.uiOrigin ?? process.env.UI_ORIGIN ?? "http://localhost:3001";
47
+ const routes = new Hono();
48
+
49
+ routes.get("/verify", async (c) => {
50
+ const token = c.req.query("token");
51
+
52
+ if (!token) {
53
+ return c.redirect(`${uiOrigin}/auth/verify?status=error&reason=missing_token`);
54
+ }
55
+
56
+ const pool = poolFactory();
57
+ const result = await verifyToken(pool, token);
58
+
59
+ if (!result) {
60
+ return c.redirect(`${uiOrigin}/auth/verify?status=error&reason=invalid_or_expired`);
61
+ }
62
+
63
+ // Grant $5 signup credit (idempotent — safe on link re-click)
64
+ try {
65
+ const ledger = creditLedgerFactory();
66
+ const granted = await grantSignupCredits(ledger, result.userId);
67
+ if (granted) {
68
+ logger.info("Signup credit granted", { userId: result.userId });
69
+ } else {
70
+ logger.info("Signup credit already granted, skipping", { userId: result.userId });
71
+ }
72
+ } catch (err) {
73
+ logger.error("Failed to grant signup credit", {
74
+ userId: result.userId,
75
+ error: err instanceof Error ? err.message : String(err),
76
+ });
77
+ // Don't block verification if credit grant fails
78
+ }
79
+
80
+ // Send welcome email
81
+ try {
82
+ const emailClient = getEmailClient();
83
+ const template = welcomeTemplate(result.email);
84
+ await emailClient.send({
85
+ to: result.email,
86
+ ...template,
87
+ userId: result.userId,
88
+ templateName: "welcome",
89
+ });
90
+ } catch (err) {
91
+ logger.error("Failed to send welcome email", {
92
+ userId: result.userId,
93
+ error: err instanceof Error ? err.message : String(err),
94
+ });
95
+ // Don't block verification if welcome email fails
96
+ }
97
+
98
+ return c.redirect(`${uiOrigin}/auth/verify?status=success`);
99
+ });
100
+
101
+ return routes;
102
+ }
@@ -0,0 +1,44 @@
1
+ export interface WsAuthRequest {
2
+ nodeId: string;
3
+ authHeader: string | undefined;
4
+ }
5
+
6
+ export interface WsAuthResult {
7
+ authenticated: boolean;
8
+ nodeId?: string;
9
+ reason?: string;
10
+ }
11
+
12
+ export interface NodeSecretVerifier {
13
+ verifyNodeSecret(nodeId: string, secret: string): Promise<boolean | null>;
14
+ }
15
+
16
+ /**
17
+ * Authenticate a WebSocket upgrade request for a node agent connection.
18
+ *
19
+ * The bearer token resolves to a specific node via per-node persistent secret;
20
+ * the resolved nodeId must match the URL nodeId.
21
+ *
22
+ * @param verifier - object with verifyNodeSecret method (typically a node repository)
23
+ */
24
+ export async function authenticateWebSocketUpgrade(
25
+ req: WsAuthRequest,
26
+ verifier: NodeSecretVerifier,
27
+ ): Promise<WsAuthResult> {
28
+ const { nodeId, authHeader } = req;
29
+ const bearer = authHeader?.replace(/^Bearer\s+/i, "");
30
+
31
+ // Per-node persistent secret — timing-safe comparison via verifyNodeSecret
32
+ if (bearer) {
33
+ const verified = await verifier.verifyNodeSecret(nodeId, bearer);
34
+ if (verified === true) {
35
+ return { authenticated: true, nodeId };
36
+ }
37
+ }
38
+
39
+ // No valid auth
40
+ if (!bearer) {
41
+ return { authenticated: false, reason: "unauthorized" };
42
+ }
43
+ return { authenticated: false, reason: "unauthorized" };
44
+ }