diff --git a/apps/sdp-api/src/app.ts b/apps/sdp-api/src/app.ts index 7b5818c5ab..608a13275e 100644 --- a/apps/sdp-api/src/app.ts +++ b/apps/sdp-api/src/app.ts @@ -46,7 +46,6 @@ import health from "@/routes/health"; import heliusRings from "@/routes/helius-rings"; import internalCustody from "@/routes/internal-custody"; import internalHeliusRings from "@/routes/internal-helius-rings"; -import internalRpc from "@/routes/internal-rpc"; import issuance from "@/routes/issuance"; import llms from "@/routes/llms"; import members from "@/routes/members"; @@ -424,7 +423,6 @@ export function createApp(deps: AppDeps): Hono<{ Bindings: Env }> { // public OpenAPI and AI discovery surfaces. app.route("/internal/playground", playgroundInternal); app.route("/internal/dashboard/custody", internalCustody); - app.route("/internal/dashboard/rpc", internalRpc); app.route("/internal/dashboard/helius-rings", internalHeliusRings); // Admin routes (internal) diff --git a/apps/sdp-api/src/db/migrations/rpc-connection-ownership.test.ts b/apps/sdp-api/src/db/migrations/rpc-connection-ownership.test.ts deleted file mode 100644 index 87a814e042..0000000000 --- a/apps/sdp-api/src/db/migrations/rpc-connection-ownership.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { getDb } from "@/db"; -import { env } from "@/test/helpers/env"; -import { seedDefaultProjects } from "@/test/helpers/projects"; -import { seedTestDatabase } from "@/test/mocks/db"; - -/** - * The database, not the service layer, is the boundary that has to hold: these - * assert the constraints in 0060_rpc_connections.sql directly, so a future - * service refactor cannot quietly become the only thing preventing a - * cross-tenant credential reference or a second live default. - */ -const ORGANIZATION_ID = "org_rpc_connection_constraints"; -const OTHER_ORGANIZATION_ID = "org_rpc_connection_constraints_other"; -const PROJECT_ID = "prj_rpc_connection_constraints"; -const OTHER_PROJECT_ID = "prj_rpc_connection_constraints_other"; -const USER_ID = "usr_rpc_connection_constraints"; -const CHECKED_AT = "2026-08-16T12:00:00.000Z"; - -async function seedScope(): Promise { - const db = getDb(env); - await db.batch([ - db - .prepare( - `INSERT INTO organizations (id, name, slug, tier, status) - VALUES (?, 'RPC connection constraints', ?, 'individual', 'active')` - ) - .bind(ORGANIZATION_ID, "rpc-connection-constraints"), - db - .prepare( - `INSERT INTO organizations (id, name, slug, tier, status) - VALUES (?, 'RPC connection constraints other', ?, 'individual', 'active')` - ) - .bind(OTHER_ORGANIZATION_ID, "rpc-connection-constraints-other"), - db - .prepare( - `INSERT INTO users (id, email, email_verified, status) - VALUES (?, 'rpc-connection-constraints@example.com', 1, 'active')` - ) - .bind(USER_ID), - ]); - await seedDefaultProjects(db, { - organizationId: ORGANIZATION_ID, - createdBy: USER_ID, - members: [], - ids: { sandbox: PROJECT_ID, production: OTHER_PROJECT_ID }, - }); -} - -async function insertCredential( - id: string, - options: { organizationId?: string; projectId?: string | null; provider?: string } = {} -): Promise { - const organizationId = options.organizationId ?? ORGANIZATION_ID; - const projectId = options.projectId === undefined ? null : options.projectId; - await getDb(env) - .prepare( - `INSERT INTO provider_credentials ( - id, organization_id, project_id, provider, label, scope, source, - storage_backend, encrypted_secret_payload, status, created_by - ) VALUES (?, ?, ?, ?, 'Tenant RPC', ?, 'stored', 'encrypted_db', 'secret', 'active', ?)` - ) - .bind( - id, - organizationId, - projectId, - options.provider ?? "helius", - projectId ? "project" : "organization", - USER_ID - ) - .run(); -} - -async function insertConnection( - id: string, - credentialId: string, - options: { - organizationId?: string; - projectId?: string | null; - credentialScopeKey?: string; - provider?: string; - network?: string; - status?: string; - isDefault?: boolean; - } = {} -): Promise { - const organizationId = options.organizationId ?? ORGANIZATION_ID; - const projectId = options.projectId === undefined ? null : options.projectId; - const status = options.status ?? "active"; - await getDb(env) - .prepare( - `INSERT INTO rpc_connections ( - id, organization_id, project_id, provider, scope, - provider_credential_id, provider_credential_scope_key, - network, status, is_default, activated_at, created_by - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - ) - .bind( - id, - organizationId, - projectId, - options.provider ?? "helius", - projectId ? "project" : "organization", - credentialId, - options.credentialScopeKey ?? projectId ?? "__organization__", - options.network ?? "devnet", - status, - options.isDefault ?? false, - status === "active" ? CHECKED_AT : null, - USER_ID - ) - .run(); -} - -describe("rpc_connections constraints", () => { - beforeEach(async () => { - await seedTestDatabase(env); - await seedScope(); - }); - - it("refuses a credential belonging to another organization", async () => { - await insertCredential("pcred_rpc_foreign", { organizationId: OTHER_ORGANIZATION_ID }); - - // The composite foreign key has no parent row for (id, this org, ...), so - // the reference cannot resolve regardless of what the service believed. - // Asserting the constraint by name: a bare toThrow() would also pass on a - // typo in the insert. - await expect(insertConnection("rconn_foreign", "pcred_rpc_foreign")).rejects.toThrow( - /foreign key constraint/i - ); - }); - - it("refuses a credential belonging to a different provider", async () => { - await insertCredential("pcred_rpc_alchemy", { provider: "alchemy" }); - - await expect( - insertConnection("rconn_provider_mismatch", "pcred_rpc_alchemy", { provider: "helius" }) - ).rejects.toThrow(); - }); - - it("refuses an organization connection reaching into a project credential", async () => { - await insertCredential("pcred_rpc_project", { projectId: PROJECT_ID }); - - await expect( - insertConnection("rconn_org_borrowing_project", "pcred_rpc_project", { - projectId: null, - credentialScopeKey: PROJECT_ID, - }) - ).rejects.toThrow(/rpc_connections_credential_scope_check|foreign key constraint/i); - }); - - it("lets a project connection borrow an organization credential", async () => { - await insertCredential("pcred_rpc_org_shared"); - - await insertConnection("rconn_project_borrowing_org", "pcred_rpc_org_shared", { - projectId: PROJECT_ID, - credentialScopeKey: "__organization__", - }); - - const row = await getDb(env) - .prepare(`SELECT scope, scope_key FROM rpc_connections WHERE id = ?`) - .bind("rconn_project_borrowing_org") - .first<{ scope: string; scope_key: string }>(); - expect(row).toEqual({ scope: "project", scope_key: PROJECT_ID }); - }); - - it("permits only one active default per scope and network", async () => { - await insertCredential("pcred_rpc_default_one"); - await insertCredential("pcred_rpc_default_two"); - - await insertConnection("rconn_default_one", "pcred_rpc_default_one", { isDefault: true }); - - await expect( - insertConnection("rconn_default_two", "pcred_rpc_default_two", { isDefault: true }) - ).rejects.toThrow(/rpc_connections_one_default_per_scope_network/); - }); - - it("keeps defaults independent across networks", async () => { - await insertCredential("pcred_rpc_devnet"); - await insertCredential("pcred_rpc_mainnet"); - - await insertConnection("rconn_devnet_default", "pcred_rpc_devnet", { - isDefault: true, - network: "devnet", - }); - await insertConnection("rconn_mainnet_default", "pcred_rpc_mainnet", { - isDefault: true, - network: "mainnet-beta", - }); - - const rows = await getDb(env) - .prepare(`SELECT COUNT(*) AS total FROM rpc_connections WHERE is_default = TRUE`) - .bind() - .first<{ total: number | string }>(); - expect(Number(rows?.total)).toBe(2); - }); - - it("keeps defaults independent across scopes", async () => { - await insertCredential("pcred_rpc_scope_org"); - await insertCredential("pcred_rpc_scope_project", { projectId: PROJECT_ID }); - - await insertConnection("rconn_org_default", "pcred_rpc_scope_org", { isDefault: true }); - await insertConnection("rconn_project_default", "pcred_rpc_scope_project", { - projectId: PROJECT_ID, - credentialScopeKey: PROJECT_ID, - isDefault: true, - }); - - const rows = await getDb(env) - .prepare(`SELECT COUNT(*) AS total FROM rpc_connections WHERE is_default = TRUE`) - .bind() - .first<{ total: number | string }>(); - expect(Number(rows?.total)).toBe(2); - }); - - it("refuses a default that is not live", async () => { - await insertCredential("pcred_rpc_pending_default"); - - // A pending connection the relay would never pick must not be able to - // occupy the default slot and block the one that should hold it. - await expect( - insertConnection("rconn_pending_default", "pcred_rpc_pending_default", { - status: "pending", - isDefault: true, - }) - ).rejects.toThrow(/rpc_connections_default_requires_active/); - }); - - it("lets a connection that was live record a later failed check", async () => { - await insertCredential("pcred_rpc_failed_after_active"); - await insertConnection("rconn_failed_after_active", "pcred_rpc_failed_after_active", { - status: "active", - isDefault: true, - }); - - // Re-checking a live connection whose provider has started rejecting the - // key is the ordinary case, and the lifecycle flip must be recordable. - // While the lifecycle check excluded 'failed', this update raised a - // constraint violation, the 409 turned into a 500, and the row kept - // reading active — the relay would go on trusting a connection that was - // down. The check result itself is no longer stored (HOO-1228); only the - // status it puts the connection into. - await expect( - getDb(env) - .prepare( - `UPDATE rpc_connections - SET status = 'failed', - is_default = FALSE - WHERE id = ?` - ) - .bind("rconn_failed_after_active") - .run() - ).resolves.toBeDefined(); - - const row = await getDb(env) - .prepare("SELECT status, activated_at FROM rpc_connections WHERE id = ?") - .bind("rconn_failed_after_active") - .first<{ status: string; activated_at: string | null }>(); - - // activated_at survives: it is history, not a claim about current health. - expect(row?.status).toBe("failed"); - expect(row?.activated_at).toBe(CHECKED_AT); - }); - - it("refuses an unknown network", async () => { - await insertCredential("pcred_rpc_bad_network"); - - await expect( - insertConnection("rconn_bad_network", "pcred_rpc_bad_network", { network: "testnet" }) - ).rejects.toThrow(/rpc_connections_network_check/); - }); -}); diff --git a/apps/sdp-api/src/lib/errors.ts b/apps/sdp-api/src/lib/errors.ts index fca66fdb41..43efb790db 100644 --- a/apps/sdp-api/src/lib/errors.ts +++ b/apps/sdp-api/src/lib/errors.ts @@ -43,7 +43,6 @@ export type ErrorCode = | "MAX_SUPPLY_EXCEEDED" | "SOLANA_RPC_ERROR" | "SOLANA_RPC_TIMEOUT" - | "UPSTREAM_RESPONSE_TOO_LARGE" | "CUSTODY_ERROR" // Transaction errors | "TRANSACTION_FAILED" @@ -104,7 +103,6 @@ const ERROR_STATUS_CODES: Record = { MAX_SUPPLY_EXCEEDED: 400, SOLANA_RPC_ERROR: 502, SOLANA_RPC_TIMEOUT: 504, - UPSTREAM_RESPONSE_TOO_LARGE: 502, CUSTODY_ERROR: 502, // Transaction errors TRANSACTION_FAILED: 400, @@ -161,8 +159,6 @@ const DEFAULT_ERROR_MESSAGES: Record = { MAX_SUPPLY_EXCEEDED: "Operation would exceed maximum supply", SOLANA_RPC_ERROR: "Error communicating with Solana RPC", SOLANA_RPC_TIMEOUT: "The RPC upstream did not answer in time; the request's outcome is unknown", - UPSTREAM_RESPONSE_TOO_LARGE: - "The RPC upstream answered with a body larger than the relay returns", CUSTODY_ERROR: "Custody provider error", // Transaction errors TRANSACTION_FAILED: "Transaction failed", diff --git a/apps/sdp-api/src/middleware/credential-admin-auth.ts b/apps/sdp-api/src/middleware/credential-admin-auth.ts index b6ace0b7e9..1f392222dd 100644 --- a/apps/sdp-api/src/middleware/credential-admin-auth.ts +++ b/apps/sdp-api/src/middleware/credential-admin-auth.ts @@ -19,18 +19,21 @@ export function credentialAdminAuthMiddleware() { } /** - * RPC connections hold organization-wide egress credentials rather than - * signing material, so they gate on `org:admin` (HOO-1092) instead of - * `custody:admin`. API keys are refused for the same reason as custody: a - * credential administration surface must be tied to a person. + * Gate for surfaces that hold organization-wide egress endpoints rather than + * signing material (Helius Rings connections), so they require `org:admin` + * (HOO-1092) instead of `custody:admin`. API keys are refused for the same + * reason as custody: a credential administration surface must be tied to a + * person. + * + * @returns Hono middleware that authenticates the caller and requires an organization administrator. */ -export function rpcAdminAuthMiddleware() { +export function organizationCredentialAdminAuthMiddleware() { const authenticate = unifiedAuthMiddleware(); return async (c: Context<{ Bindings: Env }>, next: Next) => { await authenticate(c, async () => { if (!canManageOrganizationCredentials(getAuth(c))) { - throw forbidden("RPC connection administration requires an organization administrator"); + throw forbidden("Connection administration requires an organization administrator"); } await next(); }); diff --git a/apps/sdp-api/src/openapi/paths/onboarding.ts b/apps/sdp-api/src/openapi/paths/onboarding.ts index 0706830cf8..cd6819262b 100644 --- a/apps/sdp-api/src/openapi/paths/onboarding.ts +++ b/apps/sdp-api/src/openapi/paths/onboarding.ts @@ -29,7 +29,7 @@ export function registerOnboardingPaths(registry: OpenAPIRegistry) { summary: "Complete organization onboarding", operationId: "completeOrganizationOnboarding", description: - "Marks organization onboarding complete after verifying an RPC selection and the selected default custody wallet for the default sandbox project.", + "Marks organization onboarding complete after verifying the selected default custody wallet for the default sandbox project.", security: [{ apiKeyAuth: [] }], request: { body: { diff --git a/apps/sdp-api/src/openapi/paths/responses.ts b/apps/sdp-api/src/openapi/paths/responses.ts index 3e071dd431..30e8e8d27a 100644 --- a/apps/sdp-api/src/openapi/paths/responses.ts +++ b/apps/sdp-api/src/openapi/paths/responses.ts @@ -81,7 +81,6 @@ import { projectResponseSchema, revokeApiKeyResponseSchema, rotateApiKeyResponseSchema, - rpcProvidersResponseSchema, rpcRelayResponseSchema, signerCheckResponseSchema, successResponseSchema, @@ -167,7 +166,6 @@ export const listProjectsResponse = successResponseSchema(listProjectsResponseSc export const listProjectMembersResponse = successResponseSchema(listProjectMembersResponseSchema); export const projectMemberResponse = successResponseSchema(projectMemberResponseSchema); export const listProjectApiKeysResponse = successResponseSchema(listProjectApiKeysResponseSchema); -export const rpcProvidersResponse = successResponseSchema(rpcProvidersResponseSchema); export const rpcRelayResponse = successResponseSchema(rpcRelayResponseSchema); export const tokenResponse = successResponseSchema(tokenResponseSchema); diff --git a/apps/sdp-api/src/openapi/paths/rpc.ts b/apps/sdp-api/src/openapi/paths/rpc.ts index 5fea1af2a9..0577f795d0 100644 --- a/apps/sdp-api/src/openapi/paths/rpc.ts +++ b/apps/sdp-api/src/openapi/paths/rpc.ts @@ -2,30 +2,9 @@ import type { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; import { errorResponseSchema, rpcRelayRequestSchema } from "../schemas"; import { errorResponses, jsonContent, projectScopeHeaders } from "./helpers"; -import { rpcProvidersResponse, rpcRelayResponse } from "./responses"; +import { rpcRelayResponse } from "./responses"; export function registerRpcPaths(registry: OpenAPIRegistry) { - registry.registerPath({ - method: "get", - path: "/v1/rpc/providers", - tags: ["RPC"], - summary: "List relay providers and stats", - operationId: "listRpcProviders", - description: - "Lists managed RPC providers, aggregated telemetry, and the currently selected provider for the caller/project context.", - security: [{ apiKeyAuth: [] }], - request: { - headers: projectScopeHeaders, - }, - responses: { - 200: { - description: "RPC provider list", - content: jsonContent(rpcProvidersResponse), - }, - ...errorResponses(errorResponseSchema, [400, 401, 403, 404, 500]), - }, - }); - registry.registerPath({ method: "post", path: "/v1/rpc/proxy", @@ -33,7 +12,7 @@ export function registerRpcPaths(registry: OpenAPIRegistry) { summary: "Proxy a JSON-RPC request", operationId: "proxyRpcRequest", description: - "Proxies a JSON-RPC request to the resolved provider and records telemetry. Provider selection is controlled via organization/project settings.", + "Proxies a JSON-RPC request to SDP's managed RPC pool, which picks the upstream provider round-robin.", security: [{ apiKeyAuth: [] }], request: { headers: projectScopeHeaders, diff --git a/apps/sdp-api/src/openapi/schemas/onboarding.ts b/apps/sdp-api/src/openapi/schemas/onboarding.ts index 39fce8c133..42e7c043d7 100644 --- a/apps/sdp-api/src/openapi/schemas/onboarding.ts +++ b/apps/sdp-api/src/openapi/schemas/onboarding.ts @@ -3,8 +3,7 @@ import { organizationSchema } from "./organizations"; export const organizationOnboardingSetupSchema = z.object({ status: z.enum(["not_started", "in_progress", "complete"]), - currentStep: z.enum(["rpc", "custody", "complete"]), - rpcProvider: z.string().nullable(), + currentStep: z.enum(["custody", "complete"]), custodyProvider: z.string().nullable(), completedAt: z.string().nullable(), version: z.number().int().positive(), diff --git a/apps/sdp-api/src/openapi/schemas/organizations.ts b/apps/sdp-api/src/openapi/schemas/organizations.ts index fb593f8382..2a73549296 100644 --- a/apps/sdp-api/src/openapi/schemas/organizations.ts +++ b/apps/sdp-api/src/openapi/schemas/organizations.ts @@ -1,4 +1,4 @@ -import { ORGANIZATION_RPC_PROVIDERS, ORGANIZATION_STATUSES, ORGANIZATION_TIERS } from "@sdp/types"; +import { ORGANIZATION_STATUSES, ORGANIZATION_TIERS } from "@sdp/types"; import { acceptSchema as acceptSchemaBase, inviteSchema as inviteSchemaBase, @@ -16,10 +16,6 @@ import { export const organizationSettingsSchema = z .object({ - rpcProvider: z.enum(ORGANIZATION_RPC_PROVIDERS).optional().openapi({ - description: "Organization-wide preferred RPC provider. `default` uses SDP round-robin.", - example: "default", - }), defaultEnvironment: z.enum(["sandbox", "production"]).optional().openapi({ description: "Default environment for new resources.", example: "production", @@ -219,7 +215,6 @@ export const updateOrganizationRequestSchema = updateOrgSchemaBase settings: withOpenApi(updateOrgSchemaBase.shape.settings, { description: "Organization settings to update.", example: { - rpcProvider: "default", defaultEnvironment: "production", allowedIpAddresses: ["203.0.113.0/24"], }, diff --git a/apps/sdp-api/src/openapi/schemas/private-channels.ts b/apps/sdp-api/src/openapi/schemas/private-channels.ts index 557674f631..06d81566b3 100644 --- a/apps/sdp-api/src/openapi/schemas/private-channels.ts +++ b/apps/sdp-api/src/openapi/schemas/private-channels.ts @@ -15,7 +15,7 @@ export const privateChannelInstanceSchema = z gatewayUrl: z.string().openapi({ example: "http://34.71.147.163:8899" }), chainRpcUrl: z.string().openapi({ description: - "Deprecated compatibility field. Private Channels execution uses the project's RPC integration.", + "Deprecated compatibility field. Private Channels execution uses SDP's managed RPC.", example: "https://devnet.helius-rpc.com/?api-key=…", }), escrowProgramId: solanaAddressSchema, @@ -33,8 +33,7 @@ export const privateChannelInstanceInputSchema = z .object({ gatewayUrl: z.string(), chainRpcUrl: z.string().optional().openapi({ - description: - "Deprecated and ignored for execution. Configure RPC on the SDP project instead.", + description: "Deprecated and ignored for execution. Private Channels uses SDP's managed RPC.", }), escrowProgramId: solanaAddressSchema, withdrawProgramId: solanaAddressSchema, @@ -122,7 +121,7 @@ export const privateChannelProbeBodySchema = z .meta(privateChannelProbeDeploymentConstraint) .openapi({ description: - "Probe request body. When deployment addresses are supplied, the selected project's configured RPC verifies them automatically.", + "Probe request body. When deployment addresses are supplied, SDP's managed RPC verifies them automatically.", }); export const privateChannelOverviewSchema = z diff --git a/apps/sdp-api/src/openapi/schemas/projects.ts b/apps/sdp-api/src/openapi/schemas/projects.ts index 47cb604da9..5f207ae0a5 100644 --- a/apps/sdp-api/src/openapi/schemas/projects.ts +++ b/apps/sdp-api/src/openapi/schemas/projects.ts @@ -1,7 +1,5 @@ -import { PROJECT_RPC_PROVIDERS } from "@sdp/types"; import { addMemberSchema as addMemberSchemaBase, - projectRpcEndpointSchema, updateMemberSchema as updateMemberSchemaBase, updateProjectSchema as updateProjectSchemaBase, } from "../../routes/projects/schemas"; @@ -19,16 +17,6 @@ import { userSchema } from "./organizations"; export const projectSettingsSchema = z .object({ - rpcProvider: z.enum(PROJECT_RPC_PROVIDERS).optional().openapi({ - description: - "Preferred RPC provider for this project. Defaults to `default` (round-robin managed providers). Use `custom` with `rpcEndpoint` for a dedicated endpoint.", - example: "default", - }), - rpcEndpoint: projectRpcEndpointSchema.optional().openapi({ - description: - "Custom Solana RPC endpoint for the project (used when rpcProvider=custom). Must be https, without embedded credentials, and must not point at a private or reserved address.", - example: "https://rpc.example.com", - }), webhookUrl: z.string().url().optional().openapi({ description: "Webhook URL for event notifications.", example: "https://example.com/webhook", @@ -42,10 +30,7 @@ export const projectSettingsSchema = z }), }) .strict() - .openapi({ - description: - "Project settings. `rpcProvider` defaults to `default` (round-robin) when omitted.", - }); + .openapi({ description: "Project settings." }); export const projectSchema = z .object({ @@ -60,8 +45,8 @@ export const projectSchema = z environment: z .enum(["sandbox", "beta", "production"]) .openapi({ description: "Project environment.", example: "sandbox" }), - settings: projectSettingsSchema.openapi({ - description: "Project settings with normalized defaults.", + settings: projectSettingsSchema.nullable().openapi({ + description: "Project settings, or null when none have been set.", }), status: z.enum(["active", "archived"]).openapi({ description: "Project status.", @@ -156,8 +141,7 @@ export const updateProjectRequestSchema = updateProjectSchemaBase settings: withOpenApi(updateProjectSchemaBase.shape.settings, { description: "Updated project settings. Use null to clear.", example: { - rpcProvider: "custom", - rpcEndpoint: "https://rpc.example.com", + webhookUrl: "https://example.com/webhook", }, }), }) diff --git a/apps/sdp-api/src/openapi/schemas/rpc.ts b/apps/sdp-api/src/openapi/schemas/rpc.ts index 21420f05e4..2d3e70ff47 100644 --- a/apps/sdp-api/src/openapi/schemas/rpc.ts +++ b/apps/sdp-api/src/openapi/schemas/rpc.ts @@ -1,4 +1,4 @@ -import { ORGANIZATION_RPC_PROVIDERS, PROJECT_RPC_PROVIDERS } from "@sdp/types"; +import { ORGANIZATION_RPC_PROVIDERS } from "@sdp/types"; import { RPC_RELAY_MAX_BATCH, rpcRelayPayloadSchema as relayPayloadSchemaBase, @@ -10,80 +10,9 @@ const managedRpcProviderIdSchema = z.enum(ORGANIZATION_RPC_PROVIDERS).openapi({ example: "default", }); -const selectedRpcProviderIdSchema = z.enum(PROJECT_RPC_PROVIDERS).openapi({ - description: "Resolved RPC provider identifier. Includes `custom` for project-level endpoints.", - example: "default", -}); - -const rpcSelectionModeSchema = z - .enum([ - // Tenant-owned connections outrank platform selection, so they lead the - // union here the same way they do in the resolver. - "project_connection", - "organization_connection", - "project_provider", - "project_custom_provider", - "organization_provider", - "round_robin_default", - ]) - .openapi({ - description: "How the relay selected the provider endpoint.", - example: "round_robin_default", - }); - -const rpcProviderStatsSchema = z - .object({ - requestsTotal: z.number().int().nonnegative().openapi({ example: 18 }), - transactionRequests: z.number().int().nonnegative().openapi({ example: 5 }), - errorsTotal: z.number().int().nonnegative().openapi({ example: 1 }), - averageLatencyMs: z.number().int().nonnegative().openapi({ example: 142 }), - lastRequestAt: z.string().datetime().nullable().openapi({ - description: "Timestamp of the most recent relay request for this provider.", - example: "2026-02-17T19:20:00.000Z", - }), - lastStatusCode: z.number().int().nullable().openapi({ example: 200 }), - lastMethod: z.string().nullable().openapi({ example: "sendTransaction" }), - origins: z.record(z.string(), z.number().int().nonnegative()).openapi({ - description: "Best-effort per-origin request counters.", - example: { "https://dashboard.example.com": 12 }, - }), - }) - .openapi({ description: "Aggregated telemetry for an RPC provider." }); - -const rpcProviderStatusSchema = z - .object({ - id: managedRpcProviderIdSchema, - endpoint: z.string().openapi({ - description: "Provider endpoint with secrets redacted.", - example: "https://rpc.provider.example.com/?api-key=***", - }), - stats: rpcProviderStatsSchema, - }) - .openapi({ description: "Configured managed RPC provider and telemetry." }); - -export const rpcProvidersResponseSchema = z - .object({ - providers: z.array(rpcProviderStatusSchema), - selected: z.object({ - providerId: selectedRpcProviderIdSchema, - projectId: z.string().nullable().openapi({ example: "prj_example" }), - selectionMode: rpcSelectionModeSchema, - endpoint: z.string().openapi({ - description: "Selected endpoint with secrets redacted.", - example: "https://rpc.example.com/?api-key=***", - }), - stats: rpcProviderStatsSchema, - }), - roundRobinOrder: z.array(managedRpcProviderIdSchema).openapi({ - description: "Managed provider order used by round-robin fallback.", - example: ["triton", "helius", "alchemy", "quicknode", "validationcloud", "nodit", "default"], - }), - }) - .openapi({ description: "RPC provider list and selection summary." }); - const rpcRelayPayloadSchema = relayPayloadSchemaBase.openapi({ description: - "JSON-RPC payload proxied to the selected upstream provider. Methods are limited to the Solana JSON-RPC API; a batch carries at most " + + "JSON-RPC payload proxied to a managed upstream provider. Methods are limited to the Solana JSON-RPC API; a batch carries at most " + String(RPC_RELAY_MAX_BATCH) + " requests.", example: { jsonrpc: "2.0", id: 1, method: "getLatestBlockhash", params: [] }, @@ -94,11 +23,9 @@ export const rpcRelayRequestSchema = rpcRelayPayloadSchema; export const rpcRelayResponseSchema = z .object({ provider: z.object({ - id: selectedRpcProviderIdSchema, - selectionMode: rpcSelectionModeSchema, - projectId: z.string().nullable().openapi({ example: "prj_example" }), + id: managedRpcProviderIdSchema, endpoint: z.string().openapi({ - description: "Selected endpoint with secrets redacted.", + description: "The managed endpoint that served the request, with secrets redacted.", example: "https://rpc.provider.example.com/?api-key=***", }), }), diff --git a/apps/sdp-api/src/openapi/spec.test.ts b/apps/sdp-api/src/openapi/spec.test.ts index 0544160341..a4bf452ac5 100644 --- a/apps/sdp-api/src/openapi/spec.test.ts +++ b/apps/sdp-api/src/openapi/spec.test.ts @@ -726,7 +726,6 @@ describe("OpenAPI spec", () => { it("limits the public document to supported public API families", () => { const doc = createPublicOpenApiDocument(); - const updateProject = JSON.stringify(doc.paths?.["/v1/projects/{projectId}"]?.patch); expect(doc.tags?.map((tag) => tag.name)).toEqual([ "Health", @@ -746,12 +745,9 @@ describe("OpenAPI spec", () => { expect(doc.paths?.["/v1/organizations/{orgId}"]).toBeUndefined(); expect(doc.paths?.["/v1/members"]).toBeUndefined(); - expect(doc.paths?.["/v1/rpc/providers"]).toBeUndefined(); expect(doc.paths?.["/admin/allowlist"]).toBeUndefined(); expect(doc.paths?.["/v1/onboarding/status"]).toBeUndefined(); expect(doc.components?.securitySchemes?.adminKey).toBeUndefined(); - expect(updateProject).toContain('"rpcProvider"'); - expect(updateProject).toContain('"nodit"'); expect(doc.paths?.["/health"]?.get).toBeDefined(); expect(doc.paths?.["/v1/wallets"]?.get).toBeDefined(); @@ -794,13 +790,4 @@ describe("OpenAPI spec", () => { expect(createBody).not.toContain('"type":"number"'); expect(createBody).not.toContain('"type":"integer"'); }); - - it("documents the managed RPC round-robin order", () => { - const doc = createOpenApiDocument(); - const rpcProviders = JSON.stringify(doc.paths?.["/v1/rpc/providers"]?.get); - - expect(rpcProviders).toContain( - '"example":["triton","helius","alchemy","quicknode","validationcloud","nodit","default"]' - ); - }); }); diff --git a/apps/sdp-api/src/openapi/spec.ts b/apps/sdp-api/src/openapi/spec.ts index 1498c295fe..5cd433bccb 100644 --- a/apps/sdp-api/src/openapi/spec.ts +++ b/apps/sdp-api/src/openapi/spec.ts @@ -35,7 +35,7 @@ const OPENAPI_TAG = { description: "Wallet signing provider configuration and wallet management.", }, PROJECTS: { name: "Projects", description: "Project and project member management." }, - RPC: { name: "RPC", description: "Managed Solana RPC relay and provider telemetry." }, + RPC: { name: "RPC", description: "Managed Solana RPC relay." }, ISSUANCE: { name: "Issuance", description: "Token issuance, allowlists, and lifecycle operations.", diff --git a/apps/sdp-api/src/routes/custody-wallet-scope.test.ts b/apps/sdp-api/src/routes/custody-wallet-scope.test.ts index 159f36e870..0ed62ff5a0 100644 --- a/apps/sdp-api/src/routes/custody-wallet-scope.test.ts +++ b/apps/sdp-api/src/routes/custody-wallet-scope.test.ts @@ -1,5 +1,4 @@ import { hashString } from "@sdp/payments/hash"; -import * as rpcRelay from "@sdp/rpc/relay"; import * as solanaRpc from "@sdp/rpc/solana"; import type { CachedApiKey, SignerCheckRequest } from "@sdp/types"; import { address, blockhash, generateKeyPairSigner, signature } from "@solana/kit"; @@ -45,12 +44,11 @@ const SEEDED_PUBLIC_KEYS = { const actualCreateSigningService = signingServiceModule.createSigningService; const createRpcMock = vi.spyOn(solanaRpc, "createRpc"); -const createRpcFromTransportSpy = vi.spyOn(solanaRpc, "createRpcFromTransport"); +const createClusterRpcMock = vi.spyOn(solanaRpc, "createClusterRpc"); const getAccountInfoMock = vi.spyOn(solanaRpc, "getAccountInfo"); const getMultipleAccountsLamportsMock = vi.spyOn(solanaRpc, "getMultipleAccountsLamports"); const getSplTokenBalancesMock = vi.spyOn(tokenAccounts, "getSplTokenBalances"); const createSigningServiceMock = vi.spyOn(signingServiceModule, "createSigningService"); -const resolveRpcTargetMock = vi.spyOn(rpcRelay, "resolveRpcTarget"); const getRecentBlockhashMock = vi.spyOn(solanaRpc, "getRecentBlockhash"); const confirmTransactionMock = vi.spyOn(solanaRpc, "confirmTransaction"); const simulateTransactionMock = vi.spyOn(solanaRpc, "simulateTransaction"); @@ -66,6 +64,8 @@ const TEST_PROJECT = { slug: "test-custody-wallet-scope-project", }; +const TEST_PRODUCTION_PROJECT_ID = "prj_test_custody_wallet_scope_production"; + const TEST_USER = { id: "usr_test_custody_wallet_scope", email: "custody-wallet-scope@example.com", @@ -118,7 +118,7 @@ async function seedAuthAndConfigs(): Promise { organizationId: TEST_ORG.id, createdBy: TEST_USER.id, members: [TEST_USER.id], - ids: { sandbox: TEST_PROJECT.id, production: `${TEST_PROJECT.id}_production` }, + ids: { sandbox: TEST_PROJECT.id, production: TEST_PRODUCTION_PROJECT_ID }, }); await getDb(env).batch([ getDb(env) @@ -350,14 +350,6 @@ describe("Custody wallet scope routes", () => { decimals: 6, }, ]); - resolveRpcTargetMock.mockResolvedValue({ - providerId: "default", - projectId: TEST_PROJECT.id, - endpoint: "https://solana-rpc.mock.invalid", - endpointLabel: "test", - headers: {}, - selectionMode: "round_robin_default", - }); getRecentBlockhashMock.mockResolvedValue({ blockhash: blockhash("1".repeat(32)), lastValidBlockHeight: 1_000n, @@ -404,6 +396,7 @@ describe("Custody wallet scope routes", () => { afterEach(async () => { env.PRIVY_BYOK_ENABLED = originalPrivyByokEnabled; + env.SOLANA_MAINNET_RPC_URL = undefined; await clearKVStores(env); createSigningServiceMock.mockReset(); getAccountInfoMock.mockReset(); @@ -439,8 +432,7 @@ describe("Custody wallet scope routes", () => { expect(signerCheckMocks.createOrgSigner).not.toHaveBeenCalled(); expect(signerCheckMocks.createExactSigner).not.toHaveBeenCalled(); expect(signerCheckMocks.createSponsorship).not.toHaveBeenCalled(); - expect(resolveRpcTargetMock).not.toHaveBeenCalled(); - expect(createRpcFromTransportSpy).not.toHaveBeenCalled(); + expect(createClusterRpcMock).not.toHaveBeenCalled(); expect(simulateTransactionMock).not.toHaveBeenCalled(); } ); @@ -479,7 +471,7 @@ describe("Custody wallet scope routes", () => { ); expect(signerCheckMocks.createOrgSigner).not.toHaveBeenCalled(); expect(signerCheckMocks.signAndSend).not.toHaveBeenCalled(); - expect(createRpcFromTransportSpy).toHaveBeenCalledOnce(); + expect(createClusterRpcMock).toHaveBeenCalledExactlyOnceWith(env, "devnet"); }); it.each(["clerk", "api_key"] as const)( @@ -507,7 +499,7 @@ describe("Custody wallet scope routes", () => { expect(signerCheckMocks.createExactSigner).not.toHaveBeenCalled(); expect(signerCheckMocks.createOrgSigner).not.toHaveBeenCalled(); expect(signerCheckMocks.createSponsorship).not.toHaveBeenCalled(); - expect(resolveRpcTargetMock).not.toHaveBeenCalled(); + expect(createClusterRpcMock).not.toHaveBeenCalled(); expect(simulateTransactionMock).not.toHaveBeenCalled(); } ); @@ -574,11 +566,38 @@ describe("Custody wallet scope routes", () => { expect(signerCheckMocks.createExactSigner).not.toHaveBeenCalled(); expect(signerCheckMocks.createOrgSigner).not.toHaveBeenCalled(); expect(signerCheckMocks.createSponsorship).not.toHaveBeenCalled(); - expect(resolveRpcTargetMock).not.toHaveBeenCalled(); + expect(createClusterRpcMock).not.toHaveBeenCalled(); expect(simulateTransactionMock).not.toHaveBeenCalled(); } ); + it.each([ + { environment: "sandbox", projectId: TEST_PROJECT.id, cluster: "devnet" }, + { environment: "production", projectId: TEST_PRODUCTION_PROJECT_ID, cluster: "mainnet-beta" }, + ] as const)( + "simulates a $environment project's signer check on $cluster", + async ({ projectId, cluster }) => { + env.SOLANA_MAINNET_RPC_URL = "https://mainnet-rpc.mock.invalid"; + const response = await app.request( + "/v1/wallets/signer-check", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${await signSeededClerkMember(env, getDb(env), TEST_USER.id, TEST_ORG.id)}`, + "x-project-id": projectId, + }, + body: JSON.stringify({ walletId: "privy_wallet_a" }), + }, + env + ); + + expect(response.status).toBe(200); + expect(createClusterRpcMock).toHaveBeenCalledExactlyOnceWith(env, cluster); + expect(simulateTransactionMock).toHaveBeenCalledOnce(); + } + ); + it("keeps signer-check Config selection when an inactive Config has the same Provider ID", async () => { await getDb(env).batch([ getDb(env) @@ -639,7 +658,7 @@ describe("Custody wallet scope routes", () => { expect(response.status).toBe(403); expect(signerCheckMocks.createExactSigner).not.toHaveBeenCalled(); expect(signerCheckMocks.createSponsorship).not.toHaveBeenCalled(); - expect(resolveRpcTargetMock).not.toHaveBeenCalled(); + expect(createClusterRpcMock).not.toHaveBeenCalled(); expect(simulateTransactionMock).not.toHaveBeenCalled(); } ); @@ -655,7 +674,7 @@ describe("Custody wallet scope routes", () => { }); expect(signerCheckMocks.createExactSigner).not.toHaveBeenCalled(); expect(signerCheckMocks.createSponsorship).not.toHaveBeenCalled(); - expect(resolveRpcTargetMock).not.toHaveBeenCalled(); + expect(createClusterRpcMock).not.toHaveBeenCalled(); } ); @@ -769,7 +788,7 @@ describe("Custody wallet scope routes", () => { } else { expect(signerCheckMocks.createExactSigner).not.toHaveBeenCalled(); expect(signerCheckMocks.createSponsorship).not.toHaveBeenCalled(); - expect(resolveRpcTargetMock).not.toHaveBeenCalled(); + expect(createClusterRpcMock).not.toHaveBeenCalled(); } expect(signerCheckMocks.createOrgSigner).not.toHaveBeenCalled(); } diff --git a/apps/sdp-api/src/routes/custody/handlers/signer-check.ts b/apps/sdp-api/src/routes/custody/handlers/signer-check.ts index aa9e8a8d86..27d72cfe9b 100644 --- a/apps/sdp-api/src/routes/custody/handlers/signer-check.ts +++ b/apps/sdp-api/src/routes/custody/handlers/signer-check.ts @@ -1,7 +1,6 @@ import { SigningError } from "@sdp/custody/signing"; -import { resolveRpcTarget } from "@sdp/rpc/relay"; -import { createRpcFromTransport, getRecentBlockhash, simulateTransaction } from "@sdp/rpc/solana"; -import { MEMO_PROGRAM_ADDRESS } from "@sdp/types"; +import { createClusterRpc, getRecentBlockhash, simulateTransaction } from "@sdp/rpc/solana"; +import { CLUSTER_BY_SDP_ENVIRONMENT, MEMO_PROGRAM_ADDRESS } from "@sdp/types"; import type { Address, SignatureBytes } from "@solana/kit"; import { AccountRole, @@ -21,6 +20,7 @@ import { getDb } from "@/db"; import { getAuth } from "@/lib/auth"; import { AppError, badRequest, conflict } from "@/lib/errors"; import { success } from "@/lib/response"; +import { resolveSdpEnvironment } from "@/lib/sdp-environment"; import { getRequestTenantScope } from "@/lib/tenant-scope"; import type { ValidatedBodyContext } from "@/middleware/validate"; import { @@ -30,7 +30,6 @@ import { import { CustodyRuntimeTargets } from "@/services/domain/signing/custody-runtime-target"; import { createSigningService } from "@/services/domain/signing.service"; import { FeePaymentError } from "@/services/ports"; -import { createRpcTransportForTarget } from "@/services/rpc-egress"; import { createOrgSignerForCustodyWallet } from "@/services/solana"; import { createAuthenticatedSponsorshipFeePayment } from "@/services/sponsorship.service"; import type { SignerCheckResponse, signerCheckSchema } from "../schemas"; @@ -103,29 +102,12 @@ export const signerCheck = async (c: ValidatedBodyContext(); -routes.use("*", rpcAdminAuthMiddleware()); +routes.use("*", organizationCredentialAdminAuthMiddleware()); routes.use("*", projectContextMiddleware()); routes.get("/connections", async (c) => success(c, await listRingsConnections(c))); diff --git a/apps/sdp-api/src/routes/internal-rpc/index.ts b/apps/sdp-api/src/routes/internal-rpc/index.ts deleted file mode 100644 index 3ae0ff0744..0000000000 --- a/apps/sdp-api/src/routes/internal-rpc/index.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { BYOK_RPC_PROVIDERS } from "@sdp/rpc/byok"; -import { Hono } from "hono"; -import { z } from "zod"; -import { badRequest, badRequestParams, badRequestQuery } from "@/lib/errors"; -import { created, success } from "@/lib/response"; -import { rpcAdminAuthMiddleware } from "@/middleware/credential-admin-auth"; -import { meteredQuota } from "@/middleware/metered-quota"; -import { projectContextMiddleware } from "@/middleware/project-context"; -import { validateParams } from "@/middleware/validate"; -import { RPC_QUOTA } from "@/routes/rpc"; -import { - activateRpcConnection, - deactivateRpcConnection, - deleteRpcConnection, - getRpcCredentialMode, - listRpcConnections, - rotateRpcConnection, - setRpcCredentialMode, - setServingRpcProvider, - submitRpcConnection, - testRpcConnection, -} from "@/services/rpc-connection.service"; -import type { Env } from "@/types/env"; - -const connectionParamsSchema = z.strictObject({ connectionId: z.string().trim().min(1) }); - -// `organization` stays readable so connections made before HOO-1226 are still -// listed and can be deactivated. Only creation is project-only. -const listQuerySchema = z.strictObject({ - scope: z.enum(["organization", "project"]).default("project"), - limit: z.coerce.number().int().min(1).max(50).default(20), - offset: z.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER).default(0), -}); - -/** - * The endpoint is supplied by the tenant, not derived from a built-in vendor - * URL — see `@sdp/rpc/byok`. `apiKey` is write-only: it goes to - * CredentialSecretStore and is never returned by any route here. - */ -const createConnectionSchema = z.strictObject({ - provider: z.enum(BYOK_RPC_PROVIDERS as unknown as [string, ...string[]]), - // No `network`: it comes from the project's environment (HOO-1221), so a - // caller cannot name one that disagrees with the project it lands on. - // One way to configure a connection (HOO-1226). Organization scope is not - // accepted here any more; the relay stopped resolving it. - scope: z.literal("project").default("project"), - credentialLabel: z.string().trim().min(1).max(100), - endpointUrl: z.string().trim().url().max(2048).optional(), - apiKey: z.string().min(1).max(4096), -}); - -const credentialModeSchema = z.strictObject({ mode: z.enum(["managed", "byok"]) }); - -// `default` is SDP's own rail and never has a tenant key, so it is accepted -// here and simply stands down whatever is serving. -const servingProviderSchema = z.strictObject({ - provider: z.enum([...BYOK_RPC_PROVIDERS, "default"] as unknown as [string, ...string[]]), -}); - -// The label and the network stay as they were: this replaces a key, it does -// not reconfigure the connection. -const rotateConnectionSchema = z.strictObject({ - endpointUrl: z.string().trim().url().max(2048).optional(), - apiKey: z.string().min(1).max(4096), -}); - -const activateSchema = z - .strictObject({ makeDefault: z.boolean().default(true) }) - .default({ makeDefault: true }); - -const internalRpc = new Hono<{ Bindings: Env }>(); - -internalRpc.use("*", rpcAdminAuthMiddleware()); -internalRpc.use("*", projectContextMiddleware()); - -// Whose credentials this organization runs on. Organization-wide, so it sits -// beside the connections rather than on one of them. -internalRpc.get("/credential-mode", async (c) => { - return success(c, await getRpcCredentialMode(c)); -}); - -internalRpc.put("/credential-mode", async (c) => { - const body = await c.req.json().catch(() => null); - const parsed = credentialModeSchema.safeParse(body); - if (!parsed.success) { - throw badRequest("Invalid request body", { - errors: z.flattenError(parsed.error).fieldErrors, - }); - } - - return success(c, await setRpcCredentialMode(c, parsed.data.mode)); -}); - -// Which provider answers this project. Paired with the organization's provider -// setting by the dashboard so that choosing a provider switches the credential -// too, rather than writing a setting the relay never reaches. -internalRpc.put("/serving-provider", async (c) => { - const body = await c.req.json().catch(() => null); - const parsed = servingProviderSchema.safeParse(body); - if (!parsed.success) { - throw badRequest("Invalid request body", { - errors: z.flattenError(parsed.error).fieldErrors, - }); - } - - return success(c, await setServingRpcProvider(c, parsed.data.provider)); -}); - -internalRpc.get("/connections", async (c) => { - const parsed = listQuerySchema.safeParse(c.req.query()); - if (!parsed.success) { - throw badRequestQuery({ errors: z.flattenError(parsed.error).fieldErrors }); - } - - return success(c, await listRpcConnections(c, parsed.data)); -}); - -internalRpc.post("/connections", async (c) => { - const body = await c.req.json().catch(() => null); - const parsed = createConnectionSchema.safeParse(body); - if (!parsed.success) { - throw badRequest("Invalid request body", { - errors: z.flattenError(parsed.error).fieldErrors, - }); - } - - return created( - c, - await submitRpcConnection(c, parsed.data as Parameters[1]) - ); -}); - -internalRpc.post("/connections/:connectionId/activate", async (c) => { - const params = connectionParamsSchema.safeParse(c.req.param()); - if (!params.success) { - throw badRequestParams({ errors: z.flattenError(params.error).fieldErrors }); - } - - const body = await c.req.json().catch(() => undefined); - const parsed = activateSchema.safeParse(body ?? undefined); - if (!parsed.success) { - throw badRequest("Invalid request body", { - errors: z.flattenError(parsed.error).fieldErrors, - }); - } - - return success(c, await activateRpcConnection(c, params.data.connectionId, parsed.data)); -}); - -internalRpc.post("/connections/:connectionId/deactivate", async (c) => { - const params = connectionParamsSchema.safeParse(c.req.param()); - if (!params.success) { - throw badRequestParams({ errors: z.flattenError(params.error).fieldErrors }); - } - - return success(c, await deactivateRpcConnection(c, params.data.connectionId)); -}); - -// Swap the key without a gap where the project routes nothing (HOO-1229). -internalRpc.post("/connections/:connectionId/rotate", async (c) => { - const params = connectionParamsSchema.safeParse(c.req.param()); - if (!params.success) { - throw badRequestParams({ errors: z.flattenError(params.error).fieldErrors }); - } - - const body = await c.req.json().catch(() => null); - const parsed = rotateConnectionSchema.safeParse(body); - if (!parsed.success) { - throw badRequest("Invalid request body", { - errors: z.flattenError(parsed.error).fieldErrors, - }); - } - - return success(c, await rotateRpcConnection(c, params.data.connectionId, parsed.data)); -}); - -// Checks the stored credential and writes nothing (HOO-1228). It still dials -// the tenant's endpoint, so it draws from the same quota pool as the relay — -// after validation, so a rejected request cannot spend it. -internalRpc.post( - "/connections/:connectionId/test", - validateParams(connectionParamsSchema), - meteredQuota(RPC_QUOTA), - async (c) => { - const params = connectionParamsSchema.safeParse(c.req.param()); - if (!params.success) { - throw badRequestParams({ errors: z.flattenError(params.error).fieldErrors }); - } - - return success(c, await testRpcConnection(c, params.data.connectionId)); - } -); - -internalRpc.delete("/connections/:connectionId", async (c) => { - const params = connectionParamsSchema.safeParse(c.req.param()); - if (!params.success) { - throw badRequestParams({ errors: z.flattenError(params.error).fieldErrors }); - } - - await deleteRpcConnection(c, params.data.connectionId); - return success(c, { deleted: true }); -}); - -export default internalRpc; diff --git a/apps/sdp-api/src/routes/internal-rpc/quota.test.ts b/apps/sdp-api/src/routes/internal-rpc/quota.test.ts deleted file mode 100644 index e7839a2475..0000000000 --- a/apps/sdp-api/src/routes/internal-rpc/quota.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { Hono } from "hono"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { getDb } from "@/db"; -import { AppError } from "@/lib/errors"; -import { kvStoreMiddleware } from "@/middleware/kv-store"; -import internalRpc from "@/routes/internal-rpc"; -import { clerkHeaders } from "@/test/helpers/clerk"; -import { signSeededClerkMember } from "@/test/helpers/clerk-member"; -import { env } from "@/test/helpers/env"; -import { seedTestDatabase } from "@/test/mocks/db"; -import { clearKVStores, seedRateLimit } from "@/test/mocks/kv"; -import type { Env } from "@/types/env"; - -const ORG_ID = "org_test_internal_rpc_quota"; -const ADMIN_USER_ID = "usr_test_internal_rpc_quota_admin"; -let clerkToken: string; -const PROJECT_ID = "prj_internal_rpc_quota"; - -describe("internal RPC connectivity test quota", () => { - beforeEach(async () => { - await seedTestDatabase(env); - - const db = getDb(env); - await db.batch([ - db - .prepare( - "INSERT INTO organizations (id, name, slug, tier, status) VALUES (?, ?, ?, 'individual', 'active')" - ) - .bind(ORG_ID, "Internal RPC Quota Org", "internal-rpc-quota-org"), - db - .prepare("INSERT INTO users (id, email, email_verified, status) VALUES (?, ?, 1, 'active')") - .bind(ADMIN_USER_ID, "internal-rpc-quota@example.com"), - db - .prepare( - `INSERT INTO organization_members (id, organization_id, user_id, role, status) - VALUES ('mem_internal_rpc_quota', ?, ?, 'admin', 'active')` - ) - .bind(ORG_ID, ADMIN_USER_ID), - db - .prepare( - `INSERT INTO projects (id, organization_id, name, slug, environment, status, created_by) - VALUES (?, ?, 'Default Sandbox Project', 'default-sandbox', 'sandbox', 'active', ?)` - ) - .bind(PROJECT_ID, ORG_ID, ADMIN_USER_ID), - db - .prepare( - `INSERT INTO project_members (id, project_id, user_id, role) - VALUES ('pm_internal_rpc_quota', ?, ?, 'admin')` - ) - .bind(PROJECT_ID, ADMIN_USER_ID), - ]); - clerkToken = await signSeededClerkMember(env, db, ADMIN_USER_ID, ORG_ID); - }); - - afterEach(async () => { - await clearKVStores(env); - }); - - it("429s the connection test once the actor's shared rpc quota is exhausted", async () => { - // The internal test dials the tenant's endpoint like the public relay - // does, so it draws from the same `rpc` pool — an admin cannot spend what - // `/v1/rpc` refused. The quota answers before the connection is looked - // up, so no connection row is needed. - await seedRateLimit(env, `metered:rpc:org:${ORG_ID}:user:${ADMIN_USER_ID}`, 100_000); - - const app = new Hono<{ Bindings: Env }>(); - app.use("*", kvStoreMiddleware()); - app.route("/", internalRpc); - app.onError((error, c) => { - if (error instanceof AppError) { - return c.json(error.toResponse(), error.statusCode as 401 | 403 | 429); - } - throw error; - }); - - const response = await app.request( - "/connections/rconn_any/test", - { - method: "POST", - headers: clerkHeaders(clerkToken, PROJECT_ID), - }, - env - ); - - expect(response.status).toBe(429); - const body = await response.json(); - expect(body.error.code).toBe("RATE_LIMITED"); - }); - - it("refuses an invalid connection id without charging the quota", async () => { - await seedRateLimit(env, `metered:rpc:org:${ORG_ID}:user:${ADMIN_USER_ID}`, 100_000); - - const app = new Hono<{ Bindings: Env }>(); - app.use("*", kvStoreMiddleware()); - app.route("/", internalRpc); - app.onError((error, c) => { - if (error instanceof AppError) { - return c.json(error.toResponse(), error.statusCode as 400 | 401 | 403 | 429); - } - throw error; - }); - - const response = await app.request( - "/connections/%20/test", - { - method: "POST", - headers: clerkHeaders(clerkToken, PROJECT_ID), - }, - env - ); - - expect(response.status).toBe(400); - }); -}); diff --git a/apps/sdp-api/src/routes/onboarding/handlers.test.ts b/apps/sdp-api/src/routes/onboarding/handlers.test.ts index ec10a64465..91f42afc09 100644 --- a/apps/sdp-api/src/routes/onboarding/handlers.test.ts +++ b/apps/sdp-api/src/routes/onboarding/handlers.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { getDb } from "@/db"; import { AppError } from "@/lib/errors"; import { validateBody } from "@/middleware/validate"; +import { seedTestCustodySetup } from "@/test/helpers/custody"; import { env } from "@/test/helpers/env"; import { seedDefaultProjects } from "@/test/helpers/projects"; import { seedTestDatabase } from "@/test/mocks/db"; @@ -16,7 +17,7 @@ const CLERK_ORGANIZATION_ID = "org_clerk_onboarding_test"; const USER_ID = "user_onboarding_test"; const PROJECT_ID = "project_onboarding_test"; -function completeRequest(custodyProvider = "privy") { +function completeRequest(custodyProvider: string) { return { method: "POST", headers: { "Content-Type": "application/json" }, @@ -77,76 +78,84 @@ async function seedOrganization() { }); } +async function seedDefaultSandboxCustodyWallet() { + await seedTestCustodySetup( + env, + { + id: "cfg_onboarding_test", + organizationId: ORGANIZATION_ID, + projectId: PROJECT_ID, + provider: "privy", + config: "encrypted", + encryptionVersion: "sdp-custody-encryption-v1", + defaultWalletId: "wallet_onboarding_test", + status: "active", + createdAt: "2026-07-21T00:00:00.000Z", + updatedAt: "2026-07-21T00:00:00.000Z", + }, + { + id: "cw_onboarding_test", + custodyConfigId: "cfg_onboarding_test", + walletId: "wallet_onboarding_test", + publicKey: "11111111111111111111111111111111", + label: "Default wallet", + purpose: null, + status: "active", + createdAt: "2026-07-21T00:00:00.000Z", + } + ); +} + +async function getSetup() { + const response = await createApp().request("/status", {}, env); + expect(response.status).toBe(200); + const body = (await response.json()) as { + data: { + setup: { + status: string; + currentStep: string; + custodyProvider: string | null; + canManage: boolean; + }; + }; + }; + return body.data.setup; +} + describe("organization onboarding handlers", () => { beforeEach(async () => { await seedTestDatabase(env); await seedOrganization(); }); - it("returns resumable setup state for a newly created organization", async () => { - const response = await createApp().request("/status", {}, env); - const body = (await response.json()) as { - data: { setup: { status: string; currentStep: string; canManage: boolean } }; - }; - - expect(response.status).toBe(200); - expect(body.data.setup).toMatchObject({ + it("starts a newly created organization at custody", async () => { + expect(await getSetup()).toMatchObject({ status: "not_started", - currentStep: "rpc", + currentStep: "custody", + custodyProvider: null, canManage: true, }); }); - it("only completes after an RPC choice and active custody wallet exist", async () => { - const app = createApp(); - expect((await app.request("/complete", completeRequest(), env)).status).toBe(400); + it("moves to in progress once the default sandbox custody wallet exists", async () => { + await seedDefaultSandboxCustodyWallet(); - await getDb(env) - .prepare("UPDATE organizations SET settings = ? WHERE id = ?") - .bind(JSON.stringify({ rpcProvider: "default" }), ORGANIZATION_ID) - .run(); - expect((await app.request("/complete", completeRequest(), env)).status).toBe(400); + expect(await getSetup()).toMatchObject({ + status: "in_progress", + currentStep: "custody", + custodyProvider: "privy", + }); + }); + + it("completes once the active custody wallet exists", async () => { + const app = createApp(); + expect((await app.request("/complete", completeRequest("privy"), env)).status).toBe(400); - await getDb(env).batch([ - getDb(env) - .prepare( - `INSERT INTO custody_configs - (id, organization_id, project_id, provider, config_encrypted, - default_wallet_id, status) - VALUES - ('cfg_onboarding_test', ?, ?, 'privy', 'encrypted', - 'wallet_onboarding_test', 'active')` - ) - .bind(ORGANIZATION_ID, PROJECT_ID), - getDb(env).prepare( - `INSERT INTO custody_wallets - (id, custody_config_id, wallet_id, public_key, label, status) - VALUES - ('cw_onboarding_test', 'cfg_onboarding_test', 'wallet_onboarding_test', - '11111111111111111111111111111111', 'Default wallet', 'active')` - ), - getDb(env) - .prepare( - `INSERT INTO custody_scope_defaults - (id, organization_id, project_id, default_custody_config_id) - VALUES ('csd_onboarding_test', ?, ?, 'cfg_onboarding_test')` - ) - .bind(ORGANIZATION_ID, PROJECT_ID), - ]); + await seedDefaultSandboxCustodyWallet(); expect((await app.request("/complete", completeRequest("turnkey"), env)).status).toBe(400); - await getDb(env) - .prepare("UPDATE organizations SET settings = ? WHERE id = ?") - .bind(JSON.stringify({ rpcProvider: "helius" }), ORGANIZATION_ID) - .run(); - expect((await app.request("/complete", completeRequest(), env)).status).toBe(403); - - await getDb(env) - .prepare("UPDATE organizations SET settings = ? WHERE id = ?") - .bind(JSON.stringify({ rpcProvider: "default" }), ORGANIZATION_ID) - .run(); - const response = await app.request("/complete", completeRequest(), env); + const response = await app.request("/complete", completeRequest("privy"), env); const body = (await response.json()) as { data: { setup: { status: string; currentStep: string; custodyProvider: string } }; }; diff --git a/apps/sdp-api/src/routes/onboarding/handlers.ts b/apps/sdp-api/src/routes/onboarding/handlers.ts index b66c806c40..5ff605e7e7 100644 --- a/apps/sdp-api/src/routes/onboarding/handlers.ts +++ b/apps/sdp-api/src/routes/onboarding/handlers.ts @@ -1,17 +1,10 @@ -import { - CUSTODY_PROVIDERS, - type CustodyProvider, - normalizeOrganizationTier, - ORGANIZATION_RPC_PROVIDERS, - type OrganizationRpcProvider, -} from "@sdp/types"; +import { CUSTODY_PROVIDERS, type CustodyProvider, normalizeOrganizationTier } from "@sdp/types"; import type { Context } from "hono"; import { getDb } from "@/db"; import { parseOptionalPostgresJson } from "@/db/postgres-utils"; import { AppError, badRequest, forbidden, notFound } from "@/lib/errors"; import { success } from "@/lib/response"; import type { ValidatedBodyContext } from "@/middleware/validate"; -import { assertProviderAvailable } from "@/services/provider-availability.service"; import type { Env } from "@/types/env"; import type { completeOnboardingSchema } from "./schemas"; import { ONBOARDING_VERSION, resolveOnboardingSetup } from "./state"; @@ -57,17 +50,6 @@ async function fetchOrganization(db: DatabaseClient, orgId: string) { }; } -function resolveRpcProvider(settings: unknown): OrganizationRpcProvider | null { - if (!settings || typeof settings !== "object" || !("rpcProvider" in settings)) { - return null; - } - - const provider = (settings as { rpcProvider?: unknown }).rpcProvider; - return ORGANIZATION_RPC_PROVIDERS.includes(provider as OrganizationRpcProvider) - ? (provider as OrganizationRpcProvider) - : null; -} - async function fetchCustodyProvider( db: DatabaseClient, organizationId: string, @@ -114,7 +96,6 @@ async function buildOnboardingSetup(params: { const custodyProvider = await fetchCustodyProvider(params.db, params.organization.id); return resolveOnboardingSetup({ completedAt: params.organization.onboardingCompletedAt, - rpcProvider: resolveRpcProvider(params.organization.settings), custodyProvider, version: params.organization.onboardingVersion ?? ONBOARDING_VERSION, canManage: canManageOnboarding(params.clerkOrgRole), @@ -177,12 +158,7 @@ export const completeOnboarding = async ( const organization = await fetchOrganization(db, mapping.organization_id); const requestedProvider = c.req.valid("json").custodyProvider; - const rpcProvider = resolveRpcProvider(organization.settings); const custodyProvider = await fetchCustodyProvider(db, organization.id, requestedProvider); - if (!rpcProvider) { - throw badRequest("Select an RPC provider before finishing setup"); - } - await assertProviderAvailable(c.env, db, organization.id, "rpc", rpcProvider); if (!custodyProvider) { throw badRequest( "Create and select a default custody wallet for the sandbox project before finishing setup" diff --git a/apps/sdp-api/src/routes/onboarding/state.test.ts b/apps/sdp-api/src/routes/onboarding/state.test.ts index 5768ecc8fc..7deaaed65f 100644 --- a/apps/sdp-api/src/routes/onboarding/state.test.ts +++ b/apps/sdp-api/src/routes/onboarding/state.test.ts @@ -2,19 +2,17 @@ import { describe, expect, it } from "vitest"; import { resolveOnboardingSetup } from "./state"; describe("resolveOnboardingSetup", () => { - it("starts new organizations at RPC selection", () => { + it("starts new organizations at custody", () => { expect( resolveOnboardingSetup({ completedAt: null, - rpcProvider: null, custodyProvider: null, canManage: true, version: 1, }) ).toEqual({ status: "not_started", - currentStep: "rpc", - rpcProvider: null, + currentStep: "custody", custodyProvider: null, completedAt: null, canManage: true, @@ -22,44 +20,39 @@ describe("resolveOnboardingSetup", () => { }); }); - it("resumes at custody once the RPC choice is persisted", () => { + it("does not trust a custody wallet alone to mark onboarding complete", () => { expect( resolveOnboardingSetup({ completedAt: null, - rpcProvider: "helius", - custodyProvider: null, - canManage: true, - version: 1, - }) - ).toMatchObject({ status: "in_progress", currentStep: "custody" }); - }); - - it("does not trust prerequisites alone to mark onboarding complete", () => { - expect( - resolveOnboardingSetup({ - completedAt: null, - rpcProvider: "default", custodyProvider: "privy", canManage: true, version: 1, }) - ).toMatchObject({ status: "in_progress", currentStep: "custody" }); + ).toEqual({ + status: "in_progress", + currentStep: "custody", + custodyProvider: "privy", + completedAt: null, + canManage: true, + version: 1, + }); }); - it("keeps backfilled organizations complete even without provider selections", () => { + it("keeps backfilled organizations complete even without a custody wallet", () => { expect( resolveOnboardingSetup({ completedAt: "2026-07-21 12:00:00", - rpcProvider: null, custodyProvider: null, canManage: false, version: 1, }) - ).toMatchObject({ + ).toEqual({ status: "complete", currentStep: "complete", + custodyProvider: null, completedAt: "2026-07-21 12:00:00", canManage: false, + version: 1, }); }); }); diff --git a/apps/sdp-api/src/routes/onboarding/state.ts b/apps/sdp-api/src/routes/onboarding/state.ts index c7684aaa07..648e68dbec 100644 --- a/apps/sdp-api/src/routes/onboarding/state.ts +++ b/apps/sdp-api/src/routes/onboarding/state.ts @@ -1,20 +1,30 @@ -import type { CustodyProvider, OrganizationRpcProvider } from "@sdp/types"; +import type { CustodyProvider } from "@sdp/types"; export const ONBOARDING_VERSION = 1; export type OrganizationOnboardingSetup = { status: "not_started" | "in_progress" | "complete"; - currentStep: "rpc" | "custody" | "complete"; - rpcProvider: OrganizationRpcProvider | null; + currentStep: "custody" | "complete"; custodyProvider: CustodyProvider | null; completedAt: string | null; version: number; canManage: boolean; }; +/** + * Derive an organization's onboarding progress. Custody is the only setup step: + * it is in progress once the default sandbox custody wallet exists, and + * complete once setup is finished. + * + * @param input - The organization's onboarding facts. + * @param input.completedAt - When setup was finished, or null while it is open. + * @param input.custodyProvider - Provider of the default sandbox custody wallet, or null before one exists. + * @param input.version - Onboarding version the organization is on. + * @param input.canManage - Whether the caller may finish setup. + * @returns The onboarding status, current step, and the facts it was derived from. + */ export function resolveOnboardingSetup(input: { completedAt: string | null; - rpcProvider: OrganizationRpcProvider | null; custodyProvider: CustodyProvider | null; version: number; canManage: boolean; @@ -23,7 +33,6 @@ export function resolveOnboardingSetup(input: { return { status: "complete", currentStep: "complete", - rpcProvider: input.rpcProvider, custodyProvider: input.custodyProvider, completedAt: input.completedAt, version: input.version, @@ -32,9 +41,8 @@ export function resolveOnboardingSetup(input: { } return { - status: input.rpcProvider ? "in_progress" : "not_started", - currentStep: input.rpcProvider ? "custody" : "rpc", - rpcProvider: input.rpcProvider, + status: input.custodyProvider ? "in_progress" : "not_started", + currentStep: "custody", custodyProvider: input.custodyProvider, completedAt: null, version: input.version, diff --git a/apps/sdp-api/src/routes/organizations.test.ts b/apps/sdp-api/src/routes/organizations.test.ts index 07c7f996dc..c0b490029a 100644 --- a/apps/sdp-api/src/routes/organizations.test.ts +++ b/apps/sdp-api/src/routes/organizations.test.ts @@ -207,32 +207,6 @@ describe("Organizations routes", () => { ); expect(res.status).toBe(200); }); - it("allows Helius for individual-tier organizations when configured", async () => { - const originalHeliusUrl = env.SOLANA_RPC_HELIUS_URL; - env.SOLANA_RPC_HELIUS_URL = "https://rpc.helius.test"; - const res = await app.request( - `/v1/organizations/${TEST_ORG.id}`, - { - method: "PATCH", - headers: { - Authorization: `Bearer ${TEST_API_KEY.raw}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - settings: { - rpcProvider: "helius", - }, - }), - }, - env - ); - env.SOLANA_RPC_HELIUS_URL = originalHeliusUrl; - expect(res.status).toBe(200); - const body = (await res.json()) as { - data: Organization; - }; - expect(required(body.data.settings).rpcProvider).toBe("helius"); - }); it("rejects empty update", async () => { const res = await app.request( `/v1/organizations/${TEST_ORG.id}`, diff --git a/apps/sdp-api/src/routes/organizations/handlers.ts b/apps/sdp-api/src/routes/organizations/handlers.ts index 815172ead5..8d0ab95740 100644 --- a/apps/sdp-api/src/routes/organizations/handlers.ts +++ b/apps/sdp-api/src/routes/organizations/handlers.ts @@ -18,10 +18,7 @@ import { noContent, success } from "@/lib/response"; import type { ValidatedBodyContext } from "@/middleware/validate"; import { getLogger } from "@/runtime/logger"; import { AuditService } from "@/services/audit.service"; -import { - assertProviderAvailable, - getProviderAvailability, -} from "@/services/provider-availability.service"; +import { getProviderAvailability } from "@/services/provider-availability.service"; import type { Env } from "@/types/env"; import type { updateOrgSchema } from "./schemas"; @@ -149,11 +146,6 @@ export const updateOrganization = async (c: ValidatedBodyContext { + it("rejects a removed settings key", () => { + expect(updateOrgSchema.safeParse({ settings: { rpcProvider: "x" } })).toMatchObject({ + success: false, + error: { + issues: [{ code: "unrecognized_keys", keys: ["rpcProvider"], path: ["settings"] }], + }, + }); + }); + + it("accepts the default environment", () => { + const input = { settings: { defaultEnvironment: "sandbox" } }; + + expect(updateOrgSchema.parse(input)).toEqual(input); + }); +}); diff --git a/apps/sdp-api/src/routes/organizations/schemas.ts b/apps/sdp-api/src/routes/organizations/schemas.ts index 7da3c75285..38dcb8366f 100644 --- a/apps/sdp-api/src/routes/organizations/schemas.ts +++ b/apps/sdp-api/src/routes/organizations/schemas.ts @@ -1,4 +1,3 @@ -import { ORGANIZATION_RPC_PROVIDERS } from "@sdp/types"; import { z } from "zod"; import { canonicalizeIpAllowlistEntry } from "@/lib/ip-allowlist"; @@ -26,8 +25,7 @@ const organizationAllowedIpSchema = z.string().transform((value, ctx) => { export const updateOrgSchema = z.object({ name: z.string().min(1).max(100).optional(), settings: z - .object({ - rpcProvider: z.enum(ORGANIZATION_RPC_PROVIDERS).optional(), + .strictObject({ defaultEnvironment: z.enum(["sandbox", "production"]).optional(), allowedIpAddresses: z .array(organizationAllowedIpSchema) diff --git a/apps/sdp-api/src/routes/private-channels/context.ts b/apps/sdp-api/src/routes/private-channels/context.ts index 50b52bbf6e..dbc5c2f10f 100644 --- a/apps/sdp-api/src/routes/private-channels/context.ts +++ b/apps/sdp-api/src/routes/private-channels/context.ts @@ -63,12 +63,11 @@ export function getProjectUserRepository(c: AppContext) { return createProjectUserRepository(c.env); } -/** Resolve this request's selected project RPC without exposing its endpoint. */ +/** Load this request's project RPC client without exposing its endpoint. */ export function loadPrivateChannelProjectRpcClient(c: AppContext) { const auth = getAuth(c); return loadProjectRpcClient({ env: c.env, - kv: c.var.kv, organizationId: auth.organizationId, projectId: requireProjectId(c), environment: c.get("projectEnvironment"), diff --git a/apps/sdp-api/src/routes/private-channels/handlers/instance.ts b/apps/sdp-api/src/routes/private-channels/handlers/instance.ts index 0a91fd72b0..a9a7bc9c55 100644 --- a/apps/sdp-api/src/routes/private-channels/handlers/instance.ts +++ b/apps/sdp-api/src/routes/private-channels/handlers/instance.ts @@ -108,7 +108,6 @@ export const connectPrivateChannelInstance = async ( projectId, gatewayUrl: input.gatewayUrl, authUrl: input.authUrl, - rpcProvider: projectRpc.target.providerId, gateway: summary.gateway, rpc: summary.rpc, auth: summary.auth, @@ -269,7 +268,6 @@ export const updatePrivateChannelInstance = async ( projectId, gatewayUrl: input.gatewayUrl, authUrl: input.authUrl, - rpcProvider: projectRpc.target.providerId, gateway: summary.gateway, rpc: summary.rpc, auth: summary.auth, diff --git a/apps/sdp-api/src/routes/projects/schemas.test.ts b/apps/sdp-api/src/routes/projects/schemas.test.ts index 023996f47e..694d514574 100644 --- a/apps/sdp-api/src/routes/projects/schemas.test.ts +++ b/apps/sdp-api/src/routes/projects/schemas.test.ts @@ -1,27 +1,21 @@ import { describe, expect, it } from "vitest"; -import { updateProjectSchema } from "@/routes/projects/schemas"; +import { updateProjectSchema } from "./schemas"; -describe("updateProjectSchema settings.rpcEndpoint", () => { - const parse = (rpcEndpoint: string) => - updateProjectSchema.safeParse({ settings: { rpcProvider: "custom", rpcEndpoint } }); - - it("accepts an ordinary https endpoint", () => { - expect(parse("https://rpc.example.com/abc").success).toBe(true); +describe("updateProjectSchema settings", () => { + it("rejects a removed settings key", () => { + expect(updateProjectSchema.safeParse({ settings: { rpcProvider: "x" } })).toMatchObject({ + success: false, + error: { + issues: [{ code: "unrecognized_keys", keys: ["rpcProvider"], path: ["settings"] }], + }, + }); }); - it.each([ - ["http://rpc.example.com/", "plaintext"], - ["https://169.254.169.254/latest/meta-data", "the metadata address"], - ["https://127.0.0.1:8899/", "loopback"], - ["https://10.0.0.5/", "a private range"], - ["https://[::1]/", "IPv6 loopback"], - ["https://vault.internal/", "an internal name"], - ["https://user:pass@rpc.example.com/", "embedded credentials"], - ])("refuses %s (%s)", (endpoint) => { - expect(parse(endpoint).success).toBe(false); - }); + it("accepts the webhook URL and metadata", () => { + const input = { + settings: { webhookUrl: "https://hooks.example.com/x", metadata: { team: "payments" } }, + }; - it("leaves an update without settings untouched", () => { - expect(updateProjectSchema.safeParse({ name: "Payments" }).success).toBe(true); + expect(updateProjectSchema.parse(input)).toEqual(input); }); }); diff --git a/apps/sdp-api/src/routes/projects/schemas.ts b/apps/sdp-api/src/routes/projects/schemas.ts index 08b0279662..ebcf21b27d 100644 --- a/apps/sdp-api/src/routes/projects/schemas.ts +++ b/apps/sdp-api/src/routes/projects/schemas.ts @@ -1,35 +1,10 @@ -import { assertReachableTenantEndpoint } from "@sdp/rpc/byok"; -import { PROJECT_RPC_PROVIDERS } from "@sdp/types"; import { z } from "zod"; -const projectRpcProviderSchema = z.enum(PROJECT_RPC_PROVIDERS); - -/** - * The stored value is fetched by the relay and by `/v1/rpc/test`, so it faces - * the same submission rules as a BYOK connection endpoint: https, no embedded - * credentials, no host the egress guard would refuse to dial. - */ -export const projectRpcEndpointSchema = z - .string() - .max(2048) - .superRefine((value, ctx) => { - try { - assertReachableTenantEndpoint(value); - } catch (error) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: error instanceof Error ? error.message : "Invalid RPC endpoint", - }); - } - }); - export const updateProjectSchema = z.object({ name: z.string().min(1).max(100).optional(), description: z.string().max(500).nullable().optional(), settings: z - .object({ - rpcProvider: projectRpcProviderSchema.optional(), - rpcEndpoint: projectRpcEndpointSchema.optional(), + .strictObject({ webhookUrl: z.string().url().optional(), metadata: z.record(z.string(), z.string()).optional(), }) diff --git a/apps/sdp-api/src/routes/rpc.test.ts b/apps/sdp-api/src/routes/rpc.test.ts index 341cc6274a..1bd9aa7014 100644 --- a/apps/sdp-api/src/routes/rpc.test.ts +++ b/apps/sdp-api/src/routes/rpc.test.ts @@ -1,12 +1,22 @@ import { hashString } from "@sdp/payments/hash"; -import type { OrganizationRpcProvider } from "@sdp/types"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + type MockInstance, + vi, +} from "vitest"; import { getDb } from "@/db"; import app from "@/index"; import type { KVStore } from "@/runtime/kv"; import { createKVStoreSet } from "@/runtime/kv-redis"; +import type { CustodyWallet } from "@/services/stores/custody-config.store"; import { TEST_ORG, TEST_USER } from "@/test/fixtures/organizations"; -import { env } from "@/test/helpers/env"; +import { env, resetManagedRpcEnv } from "@/test/helpers/env"; import { seedDefaultProjects } from "@/test/helpers/projects"; import { seedTestDatabase } from "@/test/mocks/db"; import { seedRateLimit } from "@/test/mocks/kv"; @@ -15,168 +25,7 @@ const TEST_PROJECT_ID = "prj_rpc_relay"; const TEST_API_KEY_ID = "key_rpc_relay"; const TEST_API_KEY_PREFIX = "sk_test_rpc"; const TEST_API_KEY_RAW = "sk_test_rpc_relay_key"; -type ManagedProvider = Exclude; - -type MutableRpcEnv = { - SOLANA_RPC_URL?: string; - SOLANA_RPC_DEFAULT_PROVIDER?: string; - SOLANA_RPC_TRITON_URL?: string; - SOLANA_RPC_TRITON_API_KEY?: string; - SOLANA_RPC_HELIUS_URL?: string; - SOLANA_RPC_HELIUS_API_KEY?: string; - SOLANA_RPC_ALCHEMY_URL?: string; - SOLANA_RPC_ALCHEMY_API_KEY?: string; - SOLANA_RPC_QUICKNODE_URL?: string; - SOLANA_RPC_QUICKNODE_API_KEY?: string; -}; - -type ProviderRuntimeConfig = { - provider: ManagedProvider; - url: string; - apiKey?: string; -}; - -const rpcEnv = env as MutableRpcEnv; - -function normalizedValue(value: string | null | undefined): string | undefined { - const trimmed = value?.trim(); - return trimmed ? trimmed : undefined; -} - -function toHost(url: string): string { - try { - return new URL(url).host; - } catch { - return url; - } -} - -function getProviderRuntimeConfig(provider: ManagedProvider): ProviderRuntimeConfig | null { - if (provider === "triton") { - const url = normalizedValue(rpcEnv.SOLANA_RPC_TRITON_URL ?? process.env.SOLANA_RPC_TRITON_URL); - if (!url) { - return null; - } - return { - provider, - url, - apiKey: normalizedValue( - rpcEnv.SOLANA_RPC_TRITON_API_KEY ?? - process.env.SOLANA_RPC_TRITON_API_KEY ?? - process.env.TRITON_API_KEY - ), - }; - } - - if (provider === "helius") { - const url = normalizedValue(rpcEnv.SOLANA_RPC_HELIUS_URL ?? process.env.SOLANA_RPC_HELIUS_URL); - if (!url) { - return null; - } - return { - provider, - url, - apiKey: normalizedValue( - rpcEnv.SOLANA_RPC_HELIUS_API_KEY ?? - process.env.SOLANA_RPC_HELIUS_API_KEY ?? - process.env.HELIUS_API_KEY - ), - }; - } - - if (provider === "quicknode") { - const url = normalizedValue( - rpcEnv.SOLANA_RPC_QUICKNODE_URL ?? process.env.SOLANA_RPC_QUICKNODE_URL - ); - if (!url) { - return null; - } - return { - provider, - url, - apiKey: normalizedValue( - rpcEnv.SOLANA_RPC_QUICKNODE_API_KEY ?? - process.env.SOLANA_RPC_QUICKNODE_API_KEY ?? - process.env.QUICKNODE_API_KEY - ), - }; - } - - const url = normalizedValue(rpcEnv.SOLANA_RPC_ALCHEMY_URL ?? process.env.SOLANA_RPC_ALCHEMY_URL); - if (!url) { - return null; - } - return { - provider, - url, - apiKey: normalizedValue( - rpcEnv.SOLANA_RPC_ALCHEMY_API_KEY ?? - process.env.SOLANA_RPC_ALCHEMY_API_KEY ?? - process.env.ALCHEMY_API_KEY - ), - }; -} - -function applyProviderRuntimeConfigs(configs: ProviderRuntimeConfig[]): void { - rpcEnv.SOLANA_RPC_TRITON_URL = undefined; - rpcEnv.SOLANA_RPC_TRITON_API_KEY = undefined; - rpcEnv.SOLANA_RPC_HELIUS_URL = undefined; - rpcEnv.SOLANA_RPC_HELIUS_API_KEY = undefined; - rpcEnv.SOLANA_RPC_ALCHEMY_URL = undefined; - rpcEnv.SOLANA_RPC_ALCHEMY_API_KEY = undefined; - rpcEnv.SOLANA_RPC_QUICKNODE_URL = undefined; - rpcEnv.SOLANA_RPC_QUICKNODE_API_KEY = undefined; - - for (const config of configs) { - if (config.provider === "triton") { - rpcEnv.SOLANA_RPC_TRITON_URL = config.url; - rpcEnv.SOLANA_RPC_TRITON_API_KEY = config.apiKey; - continue; - } - if (config.provider === "helius") { - rpcEnv.SOLANA_RPC_HELIUS_URL = config.url; - rpcEnv.SOLANA_RPC_HELIUS_API_KEY = config.apiKey; - continue; - } - if (config.provider === "quicknode") { - rpcEnv.SOLANA_RPC_QUICKNODE_URL = config.url; - rpcEnv.SOLANA_RPC_QUICKNODE_API_KEY = config.apiKey; - continue; - } - rpcEnv.SOLANA_RPC_ALCHEMY_URL = config.url; - rpcEnv.SOLANA_RPC_ALCHEMY_API_KEY = config.apiKey; - } -} - -const managedProviders: ManagedProvider[] = ["triton", "helius", "alchemy", "quicknode"]; - -const liveProviderConfigs = managedProviders - .map((provider) => getProviderRuntimeConfig(provider)) - .filter((provider): provider is ProviderRuntimeConfig => provider !== null); - -function hasLiveProviderConfig(provider: ManagedProvider): boolean { - return liveProviderConfigs.some((config) => config.provider === provider); -} - -function getRequiredLiveProviderConfigs( - requiredProviders: readonly ManagedProvider[] -): ProviderRuntimeConfig[] { - const missingProviders = requiredProviders.filter((provider) => !hasLiveProviderConfig(provider)); - - if (missingProviders.length > 0) { - throw new Error( - `Missing live RPC provider config for: ${missingProviders.join(", ")}. Set SOLANA_RPC_TRITON_URL, SOLANA_RPC_HELIUS_URL, SOLANA_RPC_ALCHEMY_URL, and SOLANA_RPC_QUICKNODE_URL (plus API keys if needed).` - ); - } - - return requiredProviders.map((provider) => { - const config = liveProviderConfigs.find((item) => item.provider === provider); - if (!config) { - throw new Error(`Missing provider config for ${provider}`); - } - return config; - }); -} +const OWNED_FAUCET_ADDRESS = "6bh8QhvDDd4rWRXggYpYwwCCkdaqSpkBg77vK39Tvujg"; async function clearKvStore(store: KVStore) { const listed = await store.list(); @@ -194,7 +43,7 @@ async function seedCustodyWalletForOrg( organizationId: string, publicKey: string, suffix: string, - options: { walletStatus?: string } = {} + walletStatus: CustodyWallet["status"] ): Promise { const db = getDb(env); await db @@ -216,24 +65,43 @@ async function seedCustodyWalletForOrg( `cfg_faucet_${suffix}`, `wallet_faucet_${suffix}`, publicKey, - options.walletStatus ?? "active" + walletStatus ) .run(); } +function jsonRpcResponse(body: unknown, status: number): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +async function relayProxy(payload: unknown): Promise { + return app.request( + "/v1/rpc/proxy", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${TEST_API_KEY_RAW}`, + }, + body: JSON.stringify(payload), + }, + env + ); +} + describe("RPC Relay Routes", () => { let apiKeyHash: string; beforeAll(async () => { - await seedTestDatabase(env as Parameters[0]); - apiKeyHash = await hashString( - TEST_API_KEY_RAW, - (env as { API_KEY_PEPPER?: string }).API_KEY_PEPPER - ); + await seedTestDatabase(env); + apiKeyHash = await hashString(TEST_API_KEY_RAW, env.API_KEY_PEPPER); }); afterAll(async () => { - await seedTestDatabase(env as Parameters[0]); + await seedTestDatabase(env); }); beforeEach(async () => { @@ -320,334 +188,207 @@ describe("RPC Relay Routes", () => { expiresAt: null, }) ); + resetManagedRpcEnv(); + }); - rpcEnv.SOLANA_RPC_DEFAULT_PROVIDER = undefined; - rpcEnv.SOLANA_RPC_URL = undefined; - rpcEnv.SOLANA_RPC_TRITON_URL = undefined; - rpcEnv.SOLANA_RPC_TRITON_API_KEY = undefined; - rpcEnv.SOLANA_RPC_HELIUS_URL = undefined; - rpcEnv.SOLANA_RPC_HELIUS_API_KEY = undefined; - rpcEnv.SOLANA_RPC_ALCHEMY_URL = undefined; - rpcEnv.SOLANA_RPC_ALCHEMY_API_KEY = undefined; - rpcEnv.SOLANA_RPC_QUICKNODE_URL = undefined; - rpcEnv.SOLANA_RPC_QUICKNODE_API_KEY = undefined; + afterEach(() => { + vi.restoreAllMocks(); }); - it("uses organization-selected managed provider when configured", async () => { - const db = getDb(env); - await db - .prepare("UPDATE organizations SET settings = ? WHERE id = ?") - .bind(JSON.stringify({ rpcProvider: "helius" }), TEST_ORG.id) - .run(); + it("relays through plain fetch with a 30s bound and answers the masked provider", async () => { + env.SOLANA_RPC_QUICKNODE_URL = "https://rpc.quicknode.test/?api-key={API_KEY}"; + env.SOLANA_RPC_QUICKNODE_API_KEY = "quicknode_key"; + const timeout = vi.spyOn(AbortSignal, "timeout"); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue( + jsonRpcResponse({ jsonrpc: "2.0", id: 1, result: { solanaCore: "2.0.0" } }, 200) + ); - (env as { SOLANA_RPC_TRITON_URL?: string }).SOLANA_RPC_TRITON_URL = "https://rpc.triton.test"; - (env as { SOLANA_RPC_TRITON_API_KEY?: string }).SOLANA_RPC_TRITON_API_KEY = "triton_key"; - (env as { SOLANA_RPC_HELIUS_URL?: string }).SOLANA_RPC_HELIUS_URL = "https://rpc.helius.test/"; - (env as { SOLANA_RPC_HELIUS_API_KEY?: string }).SOLANA_RPC_HELIUS_API_KEY = "helius_key"; - - const response = await app.request( - "/v1/rpc/providers", - { - method: "GET", - headers: { - Authorization: `Bearer ${TEST_API_KEY_RAW}`, - }, - }, - env - ); + const response = await relayProxy({ jsonrpc: "2.0", id: 1, method: "getVersion", params: [] }); expect(response.status).toBe(200); const body = await response.json(); - expect(body.data.selected.providerId).toBe("helius"); - expect(body.data.selected.selectionMode).toBe("organization_provider"); - expect(String(body.data.selected.endpoint)).toContain("rpc.helius.test"); + expect(body.data.provider).toEqual({ + id: "quicknode", + endpoint: "https://rpc.quicknode.test/?api-key=***", + }); + expect(body.data.response.result).toEqual({ solanaCore: "2.0.0" }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith( + "https://rpc.quicknode.test/?api-key=quicknode_key", + expect.objectContaining({ method: "POST", signal: expect.any(AbortSignal) }) + ); + expect(timeout).toHaveBeenCalledWith(30_000); }); - it("supports quicknode as an organization-selected managed provider", async () => { - const db = getDb(env); - await db - .prepare("UPDATE organizations SET settings = ? WHERE id = ?") - .bind(JSON.stringify({ rpcProvider: "quicknode" }), TEST_ORG.id) - .run(); - - rpcEnv.SOLANA_RPC_QUICKNODE_URL = "https://rpc.quicknode.test/?api-key={API_KEY}"; - rpcEnv.SOLANA_RPC_QUICKNODE_API_KEY = "quicknode_key"; + it("alternates two managed providers across requests", async () => { + env.SOLANA_RPC_TRITON_URL = "https://rpc.triton.test"; + env.SOLANA_RPC_HELIUS_URL = "https://rpc.helius.test"; + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async () => + jsonRpcResponse({ jsonrpc: "2.0", id: 1, result: { solanaCore: "2.0.0" } }, 200) + ); - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { solanaCore: "2.0.0" } }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }) - ); + const first = await relayProxy({ jsonrpc: "2.0", id: 1, method: "getVersion", params: [] }); + const second = await relayProxy({ jsonrpc: "2.0", id: 2, method: "getVersion", params: [] }); - const relayResponse = await app.request( - "/v1/rpc/proxy", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${TEST_API_KEY_RAW}`, - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "getVersion", - params: [], - }), - }, - env - ); + expect(first.status).toBe(200); + expect(second.status).toBe(200); + const firstBody = await first.json(); + const secondBody = await second.json(); + expect(firstBody.data.provider.id).toBe("triton"); + expect(secondBody.data.provider.id).toBe("helius"); + expect(fetchSpy.mock.calls.map(([input]) => new URL(String(input)).host)).toEqual([ + "rpc.triton.test", + "rpc.helius.test", + ]); + }); - fetchSpy.mockRestore(); + describe("faucet airdrop failover", () => { + const airdrop = { + jsonrpc: "2.0", + id: "faucet-test", + method: "requestAirdrop", + params: [OWNED_FAUCET_ADDRESS, 1], + }; + const rateLimited = { + jsonrpc: "2.0", + id: "faucet-test", + error: { code: -32429, message: "Too many airdrop requests" }, + }; + const faucetDry = { + jsonrpc: "2.0", + id: "faucet-test", + error: { code: -32603, message: "Faucet has run dry" }, + }; - expect(relayResponse.status).toBe(200); + function upstreamHosts(fetchSpy: MockInstance): string[] { + return fetchSpy.mock.calls.map(([input]) => new URL(String(input)).host); + } - const body = await relayResponse.json(); - expect(body.data.provider.id).toBe("quicknode"); - expect(body.data.provider.selectionMode).toBe("organization_provider"); - expect(String(body.data.provider.endpoint)).toContain("rpc.quicknode.test"); - expect(String(body.data.provider.endpoint)).toContain("api-key=***"); - }); + beforeEach(async () => { + await seedCustodyWalletForOrg(TEST_ORG.id, OWNED_FAUCET_ADDRESS, "failover", "active"); + env.SOLANA_RPC_TRITON_URL = "https://rpc.triton.test"; + env.SOLANA_RPC_HELIUS_URL = "https://rpc.helius.test/"; + }); - for (const provider of managedProviders) { - const itForProvider = it.runIf(provider !== "triton" && hasLiveProviderConfig(provider)); - - itForProvider( - `connectivity check: proxies through ${provider} when org rpcProvider is set`, - async () => { - const [selectedProviderConfig] = getRequiredLiveProviderConfigs([provider]); - const db = getDb(env); - await db - .prepare("UPDATE organizations SET settings = ? WHERE id = ?") - .bind(JSON.stringify({ rpcProvider: provider }), TEST_ORG.id) - .run(); - - applyProviderRuntimeConfigs(liveProviderConfigs); - - const relayResponse = await app.request( - "/v1/rpc/proxy", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${TEST_API_KEY_RAW}`, - Origin: "https://dashboard.example.com", - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "getVersion", - params: [], - }), - }, - env + it("hops to the next provider when the first answers a JSON-RPC error", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + if (String(input).includes("rpc.triton.test")) { + return jsonRpcResponse(rateLimited, 429); + } + return jsonRpcResponse( + { jsonrpc: "2.0", id: "faucet-test", result: "helius_airdrop_sig" }, + 200 ); + }); - expect(relayResponse.status).toBe(200); - const body = await relayResponse.json(); - expect(body.data.provider.id).toBe(provider); - expect(body.data.provider.selectionMode).toBe("organization_provider"); - expect(body.data.upstream.status).toBeGreaterThan(0); - expect(typeof body.data.upstream.ok).toBe("boolean"); - expect(String(body.data.provider.endpoint)).toContain(toHost(selectedProviderConfig.url)); - } - ); - } + const response = await relayProxy(airdrop); - const itWithSwitchProviders = it.runIf( - hasLiveProviderConfig("helius") && hasLiveProviderConfig("alchemy") - ); + expect(response.status).toBe(200); + expect(upstreamHosts(fetchSpy)).toEqual(["rpc.triton.test", "rpc.helius.test"]); + const body = await response.json(); + expect(body.data.provider).toEqual({ id: "helius", endpoint: "https://rpc.helius.test/" }); + expect(body.data.response.result).toBe("helius_airdrop_sig"); + }); - itWithSwitchProviders( - "switches relay endpoint after organization rpcProvider is changed", - async () => { - const [initialProvider, updatedProvider] = getRequiredLiveProviderConfigs([ - "helius", - "alchemy", - ]); + it("hops to the next provider when the first throws", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + if (String(input).includes("rpc.triton.test")) { + throw new TypeError("fetch failed"); + } + return jsonRpcResponse( + { jsonrpc: "2.0", id: "faucet-test", result: "helius_airdrop_sig" }, + 200 + ); + }); - const db = getDb(env); - await db - .prepare("UPDATE organizations SET settings = ? WHERE id = ?") - .bind(JSON.stringify({ rpcProvider: initialProvider.provider }), TEST_ORG.id) - .run(); + const response = await relayProxy(airdrop); - applyProviderRuntimeConfigs(liveProviderConfigs); - - const firstRelay = await app.request( - "/v1/rpc/proxy", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${TEST_API_KEY_RAW}`, - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "getVersion", - params: [], - }), - }, - env - ); + expect(response.status).toBe(200); + expect(upstreamHosts(fetchSpy)).toEqual(["rpc.triton.test", "rpc.helius.test"]); + const body = await response.json(); + expect(body.data.provider.id).toBe("helius"); + expect(body.data.response.result).toBe("helius_airdrop_sig"); + }); - const orgUpdate = await app.request( - `/v1/organizations/${TEST_ORG.id}`, - { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${TEST_API_KEY_RAW}`, - }, - body: JSON.stringify({ - settings: { rpcProvider: updatedProvider.provider }, - }), - }, - env - ); + it("answers the last provider's JSON-RPC error with 200 when every provider refuses", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input) => + String(input).includes("rpc.triton.test") + ? jsonRpcResponse(rateLimited, 429) + : jsonRpcResponse(faucetDry, 200) + ); - const secondRelay = await app.request( - "/v1/rpc/proxy", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${TEST_API_KEY_RAW}`, - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 2, - method: "getVersion", - params: [], - }), - }, - env - ); + const response = await relayProxy(airdrop); - expect(firstRelay.status).toBe(200); - expect(orgUpdate.status).toBe(200); - expect(secondRelay.status).toBe(200); + expect(response.status).toBe(200); + expect(upstreamHosts(fetchSpy)).toEqual(["rpc.triton.test", "rpc.helius.test"]); + const body = await response.json(); + expect(body.data.provider).toEqual({ id: "helius", endpoint: "https://rpc.helius.test/" }); + expect(body.data.upstream).toEqual({ ok: true, status: 200, statusText: "" }); + expect(body.data.response).toEqual(faucetDry); + }); - const firstBody = await firstRelay.json(); - const secondBody = await secondRelay.json(); + it("answers the refusing provider's response when a later provider throws", async () => { + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + if (String(input).includes("rpc.triton.test")) { + return jsonRpcResponse(rateLimited, 429); + } + throw new TypeError("fetch failed"); + }); - expect(firstBody.data.provider.id).toBe(initialProvider.provider); - expect(secondBody.data.provider.id).toBe(updatedProvider.provider); - expect(firstBody.data.upstream.status).toBeGreaterThan(0); - expect(secondBody.data.upstream.status).toBeGreaterThan(0); - expect(String(firstBody.data.provider.endpoint)).toContain(toHost(initialProvider.url)); - expect(String(secondBody.data.provider.endpoint)).toContain(toHost(updatedProvider.url)); - } - ); + const response = await relayProxy(airdrop); - it("round-robins providers when org has no explicit provider setting", async () => { - (env as { SOLANA_RPC_TRITON_URL?: string }).SOLANA_RPC_TRITON_URL = "https://rpc.triton.test"; - (env as { SOLANA_RPC_HELIUS_URL?: string }).SOLANA_RPC_HELIUS_URL = "https://rpc.helius.test"; - - const first = await app.request( - "/v1/rpc/providers", - { - method: "GET", - headers: { - Authorization: `Bearer ${TEST_API_KEY_RAW}`, - }, - }, - env - ); - const second = await app.request( - "/v1/rpc/providers", - { - method: "GET", - headers: { - Authorization: `Bearer ${TEST_API_KEY_RAW}`, - }, - }, - env - ); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.data.provider.id).toBe("triton"); + expect(body.data.upstream.status).toBe(429); + expect(body.data.response).toEqual(rateLimited); + }); - const firstBody = await first.json(); - const secondBody = await second.json(); + it("fails with SOLANA_RPC_ERROR when every provider throws", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new TypeError("fetch failed")); - expect(first.status).toBe(200); - expect(second.status).toBe(200); - expect(firstBody.data.selected.providerId).toBe("triton"); - expect(secondBody.data.selected.providerId).toBe("helius"); - }); + const response = await relayProxy(airdrop); - it("round-robins faucet airdrops and falls back after provider rate limits", async () => { - const db = getDb(env); - const kv = createKVStoreSet(env); - await db - .prepare("UPDATE organizations SET settings = ? WHERE id = ?") - .bind(JSON.stringify({ rpcProvider: "triton" }), TEST_ORG.id) - .run(); - await seedCustodyWalletForOrg( - TEST_ORG.id, - "6bh8QhvDDd4rWRXggYpYwwCCkdaqSpkBg77vK39Tvujg", - "round_robin" - ); - await kv.cache.put("rpc:relay:round-robin-cursor", "1"); - - rpcEnv.SOLANA_RPC_TRITON_URL = "https://rpc.triton.test"; - rpcEnv.SOLANA_RPC_HELIUS_URL = "https://rpc.helius.test"; - - const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { - const url = String(input); - if (url.includes("rpc.helius.test")) { - return new Response( - JSON.stringify({ - jsonrpc: "2.0", - id: "faucet-test", - error: { code: -32429, message: "Too many airdrop requests" }, - }), - { - status: 429, - headers: { "Content-Type": "application/json" }, - } - ); - } + expect(response.status).toBe(502); + expect(upstreamHosts(fetchSpy)).toEqual(["rpc.triton.test", "rpc.helius.test"]); + const body = await response.json(); + expect(body.error.code).toBe("SOLANA_RPC_ERROR"); + }); - return new Response( - JSON.stringify({ jsonrpc: "2.0", id: "faucet-test", result: "triton_airdrop_sig" }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - } - ); + it("fails with SOLANA_RPC_TIMEOUT when every provider times out", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new DOMException("The operation timed out.", "TimeoutError")); + + const response = await relayProxy(airdrop); + + expect(response.status).toBe(504); + expect(upstreamHosts(fetchSpy)).toEqual(["rpc.triton.test", "rpc.helius.test"]); + const body = await response.json(); + expect(body.error.code).toBe("SOLANA_RPC_TIMEOUT"); }); - try { - const relayResponse = await app.request( - "/v1/rpc/proxy", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${TEST_API_KEY_RAW}`, - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: "faucet-test", - method: "requestAirdrop", - params: ["6bh8QhvDDd4rWRXggYpYwwCCkdaqSpkBg77vK39Tvujg", 1], - }), - }, - env - ); + it("sends a batch-wrapped airdrop to a single provider without hopping", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(jsonRpcResponse([rateLimited], 200)); - expect(relayResponse.status).toBe(200); - expect(fetchSpy).toHaveBeenCalledTimes(2); - expect(String(fetchSpy.mock.calls[0][0])).toContain("rpc.helius.test"); - expect(String(fetchSpy.mock.calls[1][0])).toContain("rpc.triton.test"); + const response = await relayProxy([airdrop]); - const body = await relayResponse.json(); + expect(response.status).toBe(200); + expect(upstreamHosts(fetchSpy)).toEqual(["rpc.triton.test"]); + const body = await response.json(); expect(body.data.provider.id).toBe("triton"); - expect(body.data.provider.selectionMode).toBe("round_robin_default"); - expect(body.data.response.result).toBe("triton_airdrop_sig"); - } finally { - fetchSpy.mockRestore(); - } + expect(body.data.response).toEqual([rateLimited]); + }); }); describe("faucet destination binding", () => { @@ -655,21 +396,6 @@ describe("RPC Relay Routes", () => { const OTHER_ORG_ID = "org_other_faucet_tenant"; const OTHER_ORG_ADDRESS = "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM"; - async function relayAirdrop(payload: unknown): Promise { - return app.request( - "/v1/rpc/proxy", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${TEST_API_KEY_RAW}`, - }, - body: JSON.stringify(payload), - }, - env - ); - } - function airdropRequest(destination: unknown) { return { jsonrpc: "2.0", @@ -680,20 +406,18 @@ describe("RPC Relay Routes", () => { } beforeEach(() => { - rpcEnv.SOLANA_RPC_TRITON_URL = "https://rpc.triton.test"; + env.SOLANA_RPC_TRITON_URL = "https://rpc.triton.test"; }); it("refuses a destination no tenant wallet owns, before any upstream call", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); - try { - const response = await relayAirdrop(airdropRequest(UNOWNED_ADDRESS)); - expect(response.status).toBe(403); - const body = await response.json(); - expect(body.error.message).toContain("not a wallet of this organization"); - expect(fetchSpy).not.toHaveBeenCalled(); - } finally { - fetchSpy.mockRestore(); - } + + const response = await relayProxy(airdropRequest(UNOWNED_ADDRESS)); + + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error.message).toContain("not a wallet of this organization"); + expect(fetchSpy).not.toHaveBeenCalled(); }); it("refuses another organization's wallet as a destination", async () => { @@ -704,24 +428,22 @@ describe("RPC Relay Routes", () => { ) .bind(OTHER_ORG_ID) .run(); - await seedCustodyWalletForOrg(OTHER_ORG_ID, OTHER_ORG_ADDRESS, "other_org"); + await seedCustodyWalletForOrg(OTHER_ORG_ID, OTHER_ORG_ADDRESS, "other_org", "active"); - const response = await relayAirdrop(airdropRequest(OTHER_ORG_ADDRESS)); + const response = await relayProxy(airdropRequest(OTHER_ORG_ADDRESS)); expect(response.status).toBe(403); }); it("refuses an unowned destination smuggled inside a JSON-RPC batch array", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); - try { - const response = await relayAirdrop([ - { jsonrpc: "2.0", id: 1, method: "getVersion", params: [] }, - airdropRequest(UNOWNED_ADDRESS), - ]); - expect(response.status).toBe(403); - expect(fetchSpy).not.toHaveBeenCalled(); - } finally { - fetchSpy.mockRestore(); - } + + const response = await relayProxy([ + { jsonrpc: "2.0", id: 1, method: "getVersion", params: [] }, + airdropRequest(UNOWNED_ADDRESS), + ]); + + expect(response.status).toBe(403); + expect(fetchSpy).not.toHaveBeenCalled(); }); it("refuses the tenant's own wallet once it is no longer active", async () => { @@ -729,20 +451,20 @@ describe("RPC Relay Routes", () => { TEST_ORG.id, "4Nd1mBQtrMJVYVfKf2PJy9NZUZdTAsp7D4xWLs4gDB4T", "inactive_wallet", - { walletStatus: "inactive" } + "inactive" ); - const response = await relayAirdrop( + const response = await relayProxy( airdropRequest("4Nd1mBQtrMJVYVfKf2PJy9NZUZdTAsp7D4xWLs4gDB4T") ); expect(response.status).toBe(403); }); it("refuses a malformed destination as bad input rather than forwarding it", async () => { - const response = await relayAirdrop(airdropRequest("not-a-base58-address")); + const response = await relayProxy(airdropRequest("not-a-base58-address")); expect(response.status).toBe(400); - const missingParams = await relayAirdrop({ + const missingParams = await relayProxy({ jsonrpc: "2.0", id: 1, method: "requestAirdrop", @@ -751,139 +473,43 @@ describe("RPC Relay Routes", () => { }); it("relays an airdrop bound to the tenant's own wallet inside a batch array", async () => { - await getDb(env) - .prepare("UPDATE organizations SET settings = ? WHERE id = ?") - .bind(JSON.stringify({ rpcProvider: "triton" }), TEST_ORG.id) - .run(); - rpcEnv.SOLANA_RPC_TRITON_API_KEY = "triton_key"; - await seedCustodyWalletForOrg( - TEST_ORG.id, - "6bh8QhvDDd4rWRXggYpYwwCCkdaqSpkBg77vK39Tvujg", - "batch_owned" - ); - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify([{ jsonrpc: "2.0", id: 1, result: "owned_airdrop_sig" }]), { - status: 200, - headers: { "Content-Type": "application/json" }, - }) - ); - try { - const response = await relayAirdrop([ - airdropRequest("6bh8QhvDDd4rWRXggYpYwwCCkdaqSpkBg77vK39Tvujg"), - ]); - expect(response.status).toBe(200); - expect(fetchSpy).toHaveBeenCalled(); - } finally { - fetchSpy.mockRestore(); - } - }); - }); - - it("tracks transaction telemetry and origins per provider", async () => { - const db = getDb(env); - await db - .prepare("UPDATE organizations SET settings = ? WHERE id = ?") - .bind(JSON.stringify({ rpcProvider: "triton" }), TEST_ORG.id) - .run(); - - (env as { SOLANA_RPC_TRITON_URL?: string }).SOLANA_RPC_TRITON_URL = "https://rpc.triton.test"; - (env as { SOLANA_RPC_TRITON_API_KEY?: string }).SOLANA_RPC_TRITON_API_KEY = "triton_key"; - - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: "tx_sig" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }) - ); - - const relayResponse = await app.request( - "/v1/rpc/proxy", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${TEST_API_KEY_RAW}`, - Origin: "https://wallet.example.com", - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "sendTransaction", - params: ["AQID", { skipPreflight: true }], - }), - }, - env - ); - - const providersResponse = await app.request( - "/v1/rpc/providers", - { - method: "GET", - headers: { - Authorization: `Bearer ${TEST_API_KEY_RAW}`, - }, - }, - env - ); - - fetchSpy.mockRestore(); + env.SOLANA_RPC_TRITON_API_KEY = "triton_key"; + await seedCustodyWalletForOrg(TEST_ORG.id, OWNED_FAUCET_ADDRESS, "batch_owned", "active"); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue( + jsonRpcResponse([{ jsonrpc: "2.0", id: 1, result: "owned_airdrop_sig" }], 200) + ); - expect(relayResponse.status).toBe(200); - expect(providersResponse.status).toBe(200); + const response = await relayProxy([airdropRequest(OWNED_FAUCET_ADDRESS)]); - const providersBody = await providersResponse.json(); - const tritonProvider = providersBody.data.providers.find( - (provider: { id: string }) => provider.id === "triton" - ); - - expect(tritonProvider).toBeDefined(); - expect(tritonProvider.stats.requestsTotal).toBe(1); - expect(tritonProvider.stats.transactionRequests).toBe(1); - expect(tritonProvider.stats.errorsTotal).toBe(0); - expect(tritonProvider.stats.lastMethod).toBe("sendTransaction"); - expect(tritonProvider.stats.origins["https://wallet.example.com"]).toBe(1); + expect(response.status).toBe(200); + expect(fetchSpy).toHaveBeenCalled(); + }); }); describe("relay boundaries", () => { it("refuses a method outside the Solana JSON-RPC surface without dialling upstream", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); - const response = await app.request( - "/v1/rpc/proxy", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${TEST_API_KEY_RAW}`, - }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "qn_fetchNFTs", params: [] }), - }, - env - ); + const response = await relayProxy({ + jsonrpc: "2.0", + id: 1, + method: "qn_fetchNFTs", + params: [], + }); expect(response.status).toBe(400); expect(fetchSpy).not.toHaveBeenCalled(); - fetchSpy.mockRestore(); }); it("refuses an oversized body before parsing it", async () => { - const response = await app.request( - "/v1/rpc/proxy", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${TEST_API_KEY_RAW}`, - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "getVersion", - params: ["x".repeat(1024 * 1024 + 1024)], - }), - }, - env - ); + const response = await relayProxy({ + jsonrpc: "2.0", + id: 1, + method: "getVersion", + params: ["x".repeat(1024 * 1024 + 1024)], + }); expect(response.status).toBe(413); }); @@ -891,148 +517,48 @@ describe("RPC Relay Routes", () => { it("429s the relay once the actor's quota is exhausted", async () => { await seedRateLimit(env, `metered:rpc:org:${TEST_ORG.id}:key:${TEST_API_KEY_ID}`, 100_000); - const response = await app.request( - "/v1/rpc/proxy", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${TEST_API_KEY_RAW}`, - }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getVersion", params: [] }), - }, - env - ); + const response = await relayProxy({ + jsonrpc: "2.0", + id: 1, + method: "getVersion", + params: [], + }); expect(response.status).toBe(429); const body = await response.json(); expect(body.error.code).toBe("RATE_LIMITED"); }); - it("refuses an invalid query without charging the exhausted quota's answer", async () => { - // Validation runs before the quota: a request the route would reject - // must answer 400, not spend the pool and answer 429. - await seedRateLimit(env, `metered:rpc:org:${TEST_ORG.id}:key:${TEST_API_KEY_ID}`, 100_000); - - const response = await app.request( - "/v1/rpc/proxy?projectId=", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${TEST_API_KEY_RAW}`, - }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getVersion", params: [] }), - }, - env - ); - - expect(response.status).toBe(400); - }); - it("answers a distinct code when the upstream times out", async () => { - const db = getDb(env); - await db - .prepare("UPDATE organizations SET settings = ? WHERE id = ?") - .bind(JSON.stringify({ rpcProvider: "triton" }), TEST_ORG.id) - .run(); - rpcEnv.SOLANA_RPC_TRITON_URL = "https://rpc.triton.test"; - rpcEnv.SOLANA_RPC_TRITON_API_KEY = "triton_key"; - - const timeoutError = new DOMException("The operation timed out.", "TimeoutError"); - const fetchSpy = vi.spyOn(globalThis, "fetch").mockRejectedValue(timeoutError); - - const response = await app.request( - "/v1/rpc/proxy", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${TEST_API_KEY_RAW}`, - }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "sendTransaction", params: [] }), - }, - env + env.SOLANA_RPC_TRITON_URL = "https://rpc.triton.test"; + env.SOLANA_RPC_TRITON_API_KEY = "triton_key"; + vi.spyOn(globalThis, "fetch").mockRejectedValue( + new DOMException("The operation timed out.", "TimeoutError") ); - fetchSpy.mockRestore(); + const response = await relayProxy({ + jsonrpc: "2.0", + id: 1, + method: "sendTransaction", + params: [], + }); + expect(response.status).toBe(504); const body = await response.json(); expect(body.error.code).toBe("SOLANA_RPC_TIMEOUT"); }); - it("answers a distinct code when the upstream body exceeds the relay bound", async () => { - const db = getDb(env); - await db - .prepare("UPDATE organizations SET settings = ? WHERE id = ?") - .bind(JSON.stringify({ rpcProvider: "triton" }), TEST_ORG.id) - .run(); - rpcEnv.SOLANA_RPC_TRITON_URL = "https://rpc.triton.test"; - rpcEnv.SOLANA_RPC_TRITON_API_KEY = "triton_key"; - - const { EgressResponseTooLargeError } = await import("@/services/guarded-egress"); - const fetchSpy = vi - .spyOn(globalThis, "fetch") - .mockRejectedValue(new EgressResponseTooLargeError("rpc.example")); - - const response = await app.request( - "/v1/rpc/proxy", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${TEST_API_KEY_RAW}`, - }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getProgramAccounts", params: [] }), - }, - env - ); - - fetchSpy.mockRestore(); - expect(response.status).toBe(502); - const body = await response.json(); - expect(body.error.code).toBe("UPSTREAM_RESPONSE_TOO_LARGE"); - }); - it("charges the quota by batch size", async () => { // One admitted batch of N is N node calls; the pool must see N, or the // per-minute ceiling is really ceiling × batch cap. await seedRateLimit(env, `metered:rpc:org:${TEST_ORG.id}:key:${TEST_API_KEY_ID}`, 299); - const batch = await app.request( - "/v1/rpc/proxy", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${TEST_API_KEY_RAW}`, - }, - body: JSON.stringify([ - { jsonrpc: "2.0", id: 1, method: "getSlot", params: [] }, - { jsonrpc: "2.0", id: 2, method: "getSlot", params: [] }, - ]), - }, - env - ); + const batch = await relayProxy([ + { jsonrpc: "2.0", id: 1, method: "getSlot", params: [] }, + { jsonrpc: "2.0", id: 2, method: "getSlot", params: [] }, + ]); expect(batch.status).toBe(429); }); - - it("429s the connectivity test once the actor's quota is exhausted", async () => { - await seedRateLimit(env, `metered:rpc:org:${TEST_ORG.id}:key:${TEST_API_KEY_ID}`, 100_000); - - const response = await app.request( - "/v1/rpc/test", - { - method: "POST", - headers: { - Authorization: `Bearer ${TEST_API_KEY_RAW}`, - }, - }, - env - ); - - expect(response.status).toBe(429); - }); }); }); diff --git a/apps/sdp-api/src/routes/rpc/handlers.ts b/apps/sdp-api/src/routes/rpc/handlers.ts index baaeb8398a..527df16373 100644 --- a/apps/sdp-api/src/routes/rpc/handlers.ts +++ b/apps/sdp-api/src/routes/rpc/handlers.ts @@ -1,29 +1,18 @@ import { - listRpcProviders, type ResolvedRpcTarget, - recordRpcRelayTelemetry, resolveRoundRobinRpcTargets, resolveRpcTarget, } from "@sdp/rpc/relay"; -import type { Context } from "hono"; -import { z } from "zod"; import { getDb } from "@/db"; import { getAuth } from "@/lib/auth"; -import { AppError, badRequestQuery } from "@/lib/errors"; +import { AppError } from "@/lib/errors"; import { success } from "@/lib/response"; import type { ValidatedBodyContext } from "@/middleware/validate"; import { assertFaucetDestinationsOwned } from "@/services/faucet-destination-guard"; -import { EgressResponseTooLargeError } from "@/services/guarded-egress"; -import { - checkResolvedRpcTargetConnection, - getProviderSetupDefinition, -} from "@/services/provider-setup-registry"; -import { createTenantRpcConnectionLookup } from "@/services/rpc-connection-lookup"; -import { fetchRpcRelayTarget } from "@/services/rpc-egress"; -import type { Env } from "@/types/env"; -import { rpcProjectQuerySchema, type rpcRelayPayloadSchema } from "./schemas"; +import type { rpcRelayPayloadSchema } from "./schemas"; -type AppContext = Context<{ Bindings: Env }>; +/** A stalled managed upstream must not hold the request open indefinitely. */ +const RELAY_TIMEOUT_MS = 30_000; function extractRpcMethodNames(payload: unknown): string[] { if (Array.isArray(payload)) { @@ -41,12 +30,6 @@ function tryParseJson(value: string): unknown { } } -function getTelemetryOrigin(c: AppContext): string | null { - return ( - c.req.header("Origin") ?? c.req.header("X-Forwarded-Host") ?? c.req.header("User-Agent") ?? null - ); -} - function isJsonRpcErrorResponse(value: unknown): boolean { if (Array.isArray(value)) { return value.some((entry) => isJsonRpcErrorResponse(entry)); @@ -59,39 +42,23 @@ function shouldRoundRobinFaucetRequest(payload: unknown, methodNames: string[]): return !Array.isArray(payload) && methodNames.length === 1 && methodNames[0] === "requestAirdrop"; } -async function relayToTarget( - c: AppContext, - target: ResolvedRpcTarget, - payload: unknown, - methodNames: string[], - options: { recordJsonRpcErrorAsFailure?: boolean } = {} -) { - const startedAt = Date.now(); - const headers = { - "Content-Type": "application/json", - ...target.headers, - }; - - const upstream = await fetchRpcRelayTarget(target, { - headers, +/** + * POST a JSON-RPC payload to a managed provider, bounded by the relay timeout. + * + * @param target - The managed provider to send to. + * @param payload - The validated JSON-RPC request or batch. + * @returns The upstream response and its body, parsed as JSON when it is JSON. + */ +async function relayToTarget(target: ResolvedRpcTarget, payload: unknown) { + const upstream = await fetch(target.endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), + signal: AbortSignal.timeout(RELAY_TIMEOUT_MS), }); const rawBody = await upstream.text(); const upstreamBody = rawBody ? tryParseJson(rawBody) : null; - const elapsedMs = Date.now() - startedAt; - - await recordRpcRelayTelemetry(c.var.kv.cache, { - providerId: target.providerId, - connectionId: target.connectionId, - methodNames, - statusCode: upstream.status, - latencyMs: elapsedMs, - ok: - upstream.ok && - (!options.recordJsonRpcErrorAsFailure || !isJsonRpcErrorResponse(upstreamBody)), - origin: getTelemetryOrigin(c), - }); return { upstream, upstreamBody }; } @@ -105,8 +72,6 @@ function buildRelayResponse( return { provider: { id: target.providerId, - selectionMode: target.selectionMode, - projectId: target.projectId, endpoint: target.endpointLabel, }, upstream: { @@ -129,49 +94,18 @@ function isTimeoutError(error: unknown): boolean { // The caller can safely resend the same signed bytes on a timeout, but only // if it can tell "the upstream never answered" apart from "the upstream said // no" — hence distinct codes instead of one generic relay error. -function toRelayError(error: unknown, fallback: string): AppError { - if (error instanceof EgressResponseTooLargeError) { - return new AppError("UPSTREAM_RESPONSE_TOO_LARGE", error.message); - } +function toRelayError(error: unknown): AppError { if (isTimeoutError(error)) { return new AppError("SOLANA_RPC_TIMEOUT"); } - return new AppError("SOLANA_RPC_ERROR", error instanceof Error ? error.message : fallback); + return new AppError( + "SOLANA_RPC_ERROR", + error instanceof Error ? error.message : "RPC relay request failed" + ); } -export const getRpcProviders = async (c: AppContext) => { - const auth = getAuth(c); - const queryParse = rpcProjectQuerySchema.safeParse(c.req.query()); - - if (!queryParse.success) { - throw badRequestQuery({ - errors: z.flattenError(queryParse.error).fieldErrors, - }); - } - - const response = await listRpcProviders({ - env: c.env, - kv: c.var.kv, - db: getDb(c.env), - organizationId: auth.organizationId, - authProjectId: auth.projectId, - requestedProjectId: queryParse.data.projectId ?? null, - connections: createTenantRpcConnectionLookup(c.env, getDb(c.env)), - }); - - return success(c, response); -}; - export const relayRpcRequest = async (c: ValidatedBodyContext) => { const auth = getAuth(c); - const queryParse = rpcProjectQuerySchema.safeParse(c.req.query()); - - if (!queryParse.success) { - throw badRequestQuery({ - errors: z.flattenError(queryParse.error).fieldErrors, - }); - } - const payload = c.req.valid("json"); const methodNames = extractRpcMethodNames(payload); @@ -182,25 +116,14 @@ export const relayRpcRequest = async (c: ValidatedBodyContext | null = null; let lastError: unknown = null; for (const target of targets) { - const startedAt = Date.now(); try { - const { upstream, upstreamBody } = await relayToTarget(c, target, payload, methodNames, { - recordJsonRpcErrorAsFailure: true, - }); + const { upstream, upstreamBody } = await relayToTarget(target, payload); const relayResponse = buildRelayResponse(target, upstream, upstreamBody, methodNames); if (upstream.ok && !isJsonRpcErrorResponse(upstreamBody)) { return success(c, relayResponse); @@ -208,15 +131,6 @@ export const relayRpcRequest = async (c: ValidatedBodyContext {}); } } @@ -224,102 +138,15 @@ export const relayRpcRequest = async (c: ValidatedBodyContext {}); - - throw toRelayError(error, "RPC relay request failed"); - } -}; - -export const testRpcConnection = async (c: AppContext) => { - const auth = getAuth(c); - const queryParse = rpcProjectQuerySchema.safeParse(c.req.query()); - - if (!queryParse.success) { - throw badRequestQuery({ - errors: z.flattenError(queryParse.error).fieldErrors, - }); - } - - const methodNames = ["getVersion"]; - const target = await resolveRpcTarget({ - env: c.env, - kv: c.var.kv, - db: getDb(c.env), - organizationId: auth.organizationId, - authProjectId: auth.projectId, - requestedProjectId: queryParse.data.projectId ?? null, - connections: createTenantRpcConnectionLookup(c.env, getDb(c.env)), - }); - - const startedAt = Date.now(); - try { - const { upstream, upstreamBody, elapsedMs } = - target.providerId === "custom" - ? await checkResolvedRpcTargetConnection({ target }) - : await getProviderSetupDefinition("rpc", target.providerId).checkConnection({ target }); - - await recordRpcRelayTelemetry(c.var.kv.cache, { - providerId: target.providerId, - connectionId: target.connectionId, - methodNames, - statusCode: upstream.status, - latencyMs: elapsedMs, - ok: upstream.ok, - origin: getTelemetryOrigin(c), - }); - - return success(c, { - provider: { - id: target.providerId, - selectionMode: target.selectionMode, - projectId: target.projectId, - endpoint: target.endpointLabel, - }, - upstream: { - ok: upstream.ok, - status: upstream.status, - statusText: upstream.statusText, - }, - methods: methodNames, - response: upstreamBody, - }); - } catch (error) { - await recordRpcRelayTelemetry(c.var.kv.cache, { - providerId: target.providerId, - connectionId: target.connectionId, - methodNames, - statusCode: 0, - latencyMs: Date.now() - startedAt, - ok: false, - origin: getTelemetryOrigin(c), - }).catch(() => {}); - - throw toRelayError(error, "RPC connectivity test failed"); + throw toRelayError(error); } }; diff --git a/apps/sdp-api/src/routes/rpc/index.ts b/apps/sdp-api/src/routes/rpc/index.ts index 19e21859f9..b4eb0fb3aa 100644 --- a/apps/sdp-api/src/routes/rpc/index.ts +++ b/apps/sdp-api/src/routes/rpc/index.ts @@ -4,15 +4,15 @@ import { payloadTooLarge } from "@/lib/errors"; import { requirePermissions, unifiedAuthMiddleware } from "@/middleware/auth"; import { type MeteredQuotaConfig, meteredQuota } from "@/middleware/metered-quota"; import { projectContextMiddleware } from "@/middleware/project-context"; -import { validateBody, validateQuery } from "@/middleware/validate"; +import { validateBody } from "@/middleware/validate"; import type { Env } from "@/types/env"; -import { getRpcProviders, relayRpcRequest, testRpcConnection } from "./handlers"; -import { rpcProjectQuerySchema, rpcRelayPayloadSchema } from "./schemas"; +import { relayRpcRequest } from "./handlers"; +import { rpcRelayPayloadSchema } from "./schemas"; -// Every admitted relay or test call becomes an upstream node call, billed to -// the tenant's provider or to the platform pool. The dashboard playground and -// SDK polling both burst, so the actor ceiling stays above interactive use. -export const RPC_QUOTA: MeteredQuotaConfig = { name: "rpc", actorMax: 300, orgMax: 1200 }; +// Every admitted relay call becomes an upstream node call on SDP's managed +// pool. The dashboard playground and SDK polling both burst, so the actor +// ceiling stays above interactive use. +const RPC_QUOTA: MeteredQuotaConfig = { name: "rpc", actorMax: 300, orgMax: 1200 }; // A JSON-RPC request is small; sendTransaction payloads top out well under // this. Bounding the body keeps a single request from buffering arbitrary @@ -33,20 +33,11 @@ rpc.use( }) ); -rpc.get("/providers", requirePermissions("tokens:read"), getRpcProviders); // The quota sits after the permission gate: callers the route would reject // must not be able to charge the org-wide pool and starve authorized users. -rpc.post( - "/test", - requirePermissions("tokens:read"), - validateQuery(rpcProjectQuerySchema), - meteredQuota(RPC_QUOTA), - testRpcConnection -); rpc.post( "/proxy", requirePermissions("tokens:write"), - validateQuery(rpcProjectQuerySchema), validateBody(rpcRelayPayloadSchema), // A batch of N is N node calls charged as N, so the ceiling means what it // says regardless of how requests are packed. diff --git a/apps/sdp-api/src/routes/rpc/schemas.ts b/apps/sdp-api/src/routes/rpc/schemas.ts index e877b59433..750b7307e2 100644 --- a/apps/sdp-api/src/routes/rpc/schemas.ts +++ b/apps/sdp-api/src/routes/rpc/schemas.ts @@ -22,7 +22,3 @@ export const rpcRelayPayloadSchema = z.union([ rpcRequestSchema, z.array(rpcRequestSchema).min(1).max(RPC_RELAY_MAX_BATCH), ]); - -export const rpcProjectQuerySchema = z.object({ - projectId: z.string().min(1).optional(), -}); diff --git a/apps/sdp-api/src/routes/webhooks.test.ts b/apps/sdp-api/src/routes/webhooks.test.ts index 0df0ab7c9c..f2762924a7 100644 --- a/apps/sdp-api/src/routes/webhooks.test.ts +++ b/apps/sdp-api/src/routes/webhooks.test.ts @@ -147,8 +147,8 @@ describe("Clerk webhooks", () => { sdp: { tier: "pro", providerOverrides: { - rpc: { - helius: true, + compliance: { + elliptic: true, }, }, }, @@ -180,8 +180,8 @@ describe("Clerk webhooks", () => { required(createdOrg).settings ? JSON.parse(required(required(createdOrg).settings)) : null ).toMatchObject({ providerOverrides: { - rpc: { - helius: true, + compliance: { + elliptic: true, }, }, }); diff --git a/apps/sdp-api/src/services/guarded-egress.ts b/apps/sdp-api/src/services/guarded-egress.ts index 2cd3e8b202..f1d0fe804d 100644 --- a/apps/sdp-api/src/services/guarded-egress.ts +++ b/apps/sdp-api/src/services/guarded-egress.ts @@ -123,7 +123,7 @@ export interface RedirectStep { const CROSS_ORIGIN_REDIRECT_HEADERS = new Set(["accept", "content-type"]); /** - * Tenant RPC headers may use provider-specific names, so there is no complete + * Tenant-supplied headers may use provider-specific names, so there is no complete * denylist for credentials. Preserve them only within the same origin. A * cross-origin redirect receives the protocol headers SDP owns, never the * tenant-supplied authentication material. diff --git a/apps/sdp-api/src/services/helius-rings/connection.service.ts b/apps/sdp-api/src/services/helius-rings/connection.service.ts index fdc53dba7c..bcd3b44292 100644 --- a/apps/sdp-api/src/services/helius-rings/connection.service.ts +++ b/apps/sdp-api/src/services/helius-rings/connection.service.ts @@ -1,5 +1,5 @@ import { createRingsGateway, probeRingRpcHealth } from "@sdp/helius-rings-sdk"; -import { assertReachableTenantEndpoint } from "@sdp/rpc/byok"; +import { assertReachableTenantEndpoint } from "@sdp/rpc/blocked-address"; import type { Context } from "hono"; import { getDb } from "@/db"; import { parsePostgresJsonOr } from "@/db/postgres-utils"; diff --git a/apps/sdp-api/src/services/helius-rings/rpc-adapter.test.ts b/apps/sdp-api/src/services/helius-rings/rpc-adapter.test.ts new file mode 100644 index 0000000000..b36b55ce94 --- /dev/null +++ b/apps/sdp-api/src/services/helius-rings/rpc-adapter.test.ts @@ -0,0 +1,65 @@ +import { getBase64Codec } from "@solana/codecs"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as guardedEgress from "@/services/guarded-egress"; +import type { Env } from "@/types/env"; +import { RingsAdapterError } from "./adapter-error"; +import { submitRingsOuterTransaction } from "./rpc-adapter"; + +const productionEnv: Env = { ENVIRONMENT: "production", API_VERSION: "v1" }; +const SIGNED_TX_BASE64 = getBase64Codec().decode(new Uint8Array(8).fill(1)); +const TENANT_RPC_URL = "https://rings-tenant.example.com/rpc"; +const LOOPBACK_RPC_URL = "https://127.0.0.1/rpc"; +const SIGNATURE = "1".repeat(64); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("submitRingsOuterTransaction with a persisted connection URL", () => { + it("dials the tenant URL through the egress guard outside development", async () => { + const guardedFetch = vi.spyOn(guardedEgress, "guardedFetch").mockResolvedValue( + new Response(JSON.stringify({ jsonrpc: "2.0", id: "0", result: SIGNATURE }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + await expect( + submitRingsOuterTransaction({ + env: productionEnv, + signedTxBase64: SIGNED_TX_BASE64, + rpcUrl: TENANT_RPC_URL, + }) + ).resolves.toBe(SIGNATURE); + + expect(guardedFetch).toHaveBeenCalledExactlyOnceWith( + TENANT_RPC_URL, + expect.objectContaining({ method: "POST" }) + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("refuses a loopback tenant URL before any request leaves", async () => { + const guardedFetch = vi.spyOn(guardedEgress, "guardedFetch"); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + const outcome = submitRingsOuterTransaction({ + env: productionEnv, + signedTxBase64: SIGNED_TX_BASE64, + rpcUrl: LOOPBACK_RPC_URL, + }); + + await expect(outcome).rejects.toBeInstanceOf(RingsAdapterError); + await expect(outcome).rejects.toHaveProperty("failureCode", "submit_failed"); + await expect(outcome).rejects.toHaveProperty( + "cause", + expect.any(guardedEgress.EgressBlockedError) + ); + expect(guardedFetch).toHaveBeenCalledExactlyOnceWith( + LOOPBACK_RPC_URL, + expect.objectContaining({ method: "POST" }) + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/sdp-api/src/services/helius-rings/rpc-adapter.ts b/apps/sdp-api/src/services/helius-rings/rpc-adapter.ts index c7e0854049..39a79496ef 100644 --- a/apps/sdp-api/src/services/helius-rings/rpc-adapter.ts +++ b/apps/sdp-api/src/services/helius-rings/rpc-adapter.ts @@ -13,7 +13,7 @@ import { SOLANA_ERROR__INSTRUCTION_ERROR__CUSTOM, SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE, } from "@solana/kit"; -import { createRpcTransportForTarget } from "@/services/rpc-egress"; +import { createCustomerRpcTransport } from "@/services/rpc-egress"; import type { Env } from "@/types/env"; import { RingsAdapterError, type RingsAdapterFailureCode } from "./adapter-error"; import { requireRingsHeliusRpcUrl } from "./rpc-config"; @@ -57,12 +57,7 @@ export async function submitRingsOuterTransaction( rpc = input.env.ENVIRONMENT === "development" ? createRpc(input.env, { rpcUrl: input.rpcUrl }) - : createRpcFromTransport( - createRpcTransportForTarget({ - endpoint: input.rpcUrl, - connectionId: "rings-connection", - }) - ); + : createRpcFromTransport(createCustomerRpcTransport(input.rpcUrl)); } else { const configuredRpc = createRingsHeliusRpc(input.env); rpc = configuredRpc.rpc; diff --git a/apps/sdp-api/src/services/jobs/track-pending-deposits.node.test.ts b/apps/sdp-api/src/services/jobs/track-pending-deposits.node.test.ts index 7b993220a2..c5bffd813b 100644 --- a/apps/sdp-api/src/services/jobs/track-pending-deposits.node.test.ts +++ b/apps/sdp-api/src/services/jobs/track-pending-deposits.node.test.ts @@ -40,7 +40,6 @@ const { loadProjectRpcClient, PROJECT_RPC } = vi.hoisted(() => { loadProjectRpcClient: vi.fn(async () => ({ cluster: "devnet", rpc: PROJECT_RPC, - target: { endpoint: "https://project-rpc.example" }, })), }; }); diff --git a/apps/sdp-api/src/services/jobs/track-pending-deposits.ts b/apps/sdp-api/src/services/jobs/track-pending-deposits.ts index 4e6d1429c9..0ec0bef718 100644 --- a/apps/sdp-api/src/services/jobs/track-pending-deposits.ts +++ b/apps/sdp-api/src/services/jobs/track-pending-deposits.ts @@ -15,9 +15,8 @@ * off-chain and gateway `getTransaction` is Operator-only, so we can't observe * it. The UI surfaces the credit via the channel-balance read. * - * The reconciler resolves the project's CURRENT RPC connection each tick, so - * provider changes and credential rotations apply to in-flight intents. The - * audit context on each deposit is never consulted here. + * The reconciler builds the project's RPC client on SDP's managed pool each + * tick. The audit context on each deposit is never consulted here. * All status transitions are compare-and-swap (`expectedStatus`) so a concurrent * worker can't regress state. */ diff --git a/apps/sdp-api/src/services/jobs/track-pending-withdrawals.node.test.ts b/apps/sdp-api/src/services/jobs/track-pending-withdrawals.node.test.ts index f28dc4ce3c..980f19552e 100644 --- a/apps/sdp-api/src/services/jobs/track-pending-withdrawals.node.test.ts +++ b/apps/sdp-api/src/services/jobs/track-pending-withdrawals.node.test.ts @@ -54,7 +54,6 @@ const { loadProjectRpcClient } = vi.hoisted(() => { loadProjectRpcClient: vi.fn(async () => ({ cluster: "devnet", rpc: PROJECT_RPC, - target: { endpoint: "https://project-rpc.example" }, })), }; }); diff --git a/apps/sdp-api/src/services/jobs/track-pending-withdrawals.ts b/apps/sdp-api/src/services/jobs/track-pending-withdrawals.ts index f220e7f9a9..dd14950dae 100644 --- a/apps/sdp-api/src/services/jobs/track-pending-withdrawals.ts +++ b/apps/sdp-api/src/services/jobs/track-pending-withdrawals.ts @@ -20,9 +20,8 @@ * `TRANSFER_STUCK_WARNING` (debounced via `context.lastStuckWarningAt`), * never auto-`failed` — the balance is already burned. * - * The release reconciler resolves the project's CURRENT RPC connection each - * tick, so provider changes and credential rotations apply to in-flight intents. - * The audit context on each withdrawal is never consulted here. + * The release reconciler builds the project's RPC client on SDP's managed pool + * each tick. The audit context on each withdrawal is never consulted here. * * All status transitions are compare-and-swap (`expectedStatus`) so a concurrent * worker can't regress state. Release attribution is by content diff --git a/apps/sdp-api/src/services/private-channels/project-rpc.test.ts b/apps/sdp-api/src/services/private-channels/project-rpc.test.ts index 526bee486e..a4bbbc8333 100644 --- a/apps/sdp-api/src/services/private-channels/project-rpc.test.ts +++ b/apps/sdp-api/src/services/private-channels/project-rpc.test.ts @@ -1,7 +1,14 @@ import { SANDBOX_DEFAULTS } from "@sdp/private-channels"; import type { SolanaRpc } from "@sdp/rpc/solana"; -import { describe, expect, it, vi } from "vitest"; -import { probeProjectRpcDeployment } from "./project-rpc"; +import * as solanaRpc from "@sdp/rpc/solana"; +import { CLUSTER_BY_SDP_ENVIRONMENT } from "@sdp/types"; +import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest"; +import { getDb } from "@/db"; +import { TEST_ORG, TEST_USER } from "@/test/fixtures/organizations"; +import { env } from "@/test/helpers/env"; +import { type SeededDefaultProjects, seedDefaultProjects } from "@/test/helpers/projects"; +import { seedTestDatabase } from "@/test/mocks/db"; +import { loadProjectRpcClient, probeProjectRpcDeployment } from "./project-rpc"; const OTHER_OWNER = "11111111111111111111111111111111"; @@ -113,3 +120,160 @@ describe("probeProjectRpcDeployment", () => { }); }); }); + +const actualCreateClusterRpc = solanaRpc.createClusterRpc; + +describe("loadProjectRpcClient", () => { + let projects: SeededDefaultProjects; + let managedRpc: SolanaRpc; + let createClusterRpcMock: MockInstance; + + beforeEach(async () => { + await seedTestDatabase(env); + const db = getDb(env); + await db + .prepare("INSERT INTO organizations (id, name, slug) VALUES (?, ?, ?)") + .bind(TEST_ORG.id, TEST_ORG.name, TEST_ORG.slug) + .run(); + await db + .prepare("INSERT INTO users (id, email) VALUES (?, ?)") + .bind(TEST_USER.id, TEST_USER.email) + .run(); + projects = await seedDefaultProjects(db, { + organizationId: TEST_ORG.id, + createdBy: TEST_USER.id, + members: [], + ids: { sandbox: "prj_project_rpc_sandbox", production: "prj_project_rpc_production" }, + }); + managedRpc = rpcWithAccounts({ program: null }); + createClusterRpcMock = vi.spyOn(solanaRpc, "createClusterRpc").mockReturnValue(managedRpc); + }); + + afterEach(() => { + env.SOLANA_MAINNET_RPC_URL = undefined; + vi.restoreAllMocks(); + }); + + it("takes the cluster from the passed environment without reading the project row", async () => { + const client = await loadProjectRpcClient({ + env, + organizationId: TEST_ORG.id, + projectId: "prj_project_rpc_unseeded", + environment: "production", + }); + + expect(client.cluster).toBe(CLUSTER_BY_SDP_ENVIRONMENT.production); + expect(client.rpc).toBe(managedRpc); + }); + + it.each(["sandbox", "production"] as const)( + "reads the cluster from the active %s project row when no environment is passed", + async (environment) => { + const client = await loadProjectRpcClient({ + env, + organizationId: TEST_ORG.id, + projectId: projects[environment].id, + }); + + expect(client.cluster).toBe(CLUSTER_BY_SDP_ENVIRONMENT[environment]); + } + ); + + it("builds the client for the project's cluster and binds the probe to it and the cluster", async () => { + const client = await loadProjectRpcClient({ + env, + organizationId: TEST_ORG.id, + projectId: projects.production.id, + }); + + expect(createClusterRpcMock).toHaveBeenCalledExactlyOnceWith(env, "mainnet-beta"); + await expect(client.probe(deployment)).resolves.toMatchObject({ + ok: false, + error: `Escrow program is not deployed on ${CLUSTER_BY_SDP_ENVIRONMENT.production}.`, + }); + }); + + it("fails closed for a production project when the devnet deployment has no mainnet endpoint", async () => { + createClusterRpcMock.mockImplementation(actualCreateClusterRpc); + + await expect( + loadProjectRpcClient({ + env, + organizationId: TEST_ORG.id, + projectId: projects.production.id, + }) + ).rejects.toThrow("No RPC endpoint is configured for mainnet-beta: set SOLANA_MAINNET_RPC_URL"); + expect(createClusterRpcMock).toHaveBeenCalledExactlyOnceWith(env, "mainnet-beta"); + }); + + it("dials the configured mainnet endpoint for a production project", async () => { + createClusterRpcMock.mockImplementation(actualCreateClusterRpc); + env.SOLANA_MAINNET_RPC_URL = "https://mainnet-rpc.mock.invalid"; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ jsonrpc: "2.0", id: "0", result: { "solana-core": "2.0.0" } }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ) + ); + + const client = await loadProjectRpcClient({ + env, + organizationId: TEST_ORG.id, + projectId: projects.production.id, + }); + await client.rpc.getVersion().send(); + + expect(client.cluster).toBe("mainnet-beta"); + expect(createClusterRpcMock).toHaveBeenCalledExactlyOnceWith(env, "mainnet-beta"); + expect(fetchSpy).toHaveBeenCalledExactlyOnceWith( + "https://mainnet-rpc.mock.invalid", + expect.any(Object) + ); + }); + + it("throws for a project that does not exist", async () => { + await expect( + loadProjectRpcClient({ + env, + organizationId: TEST_ORG.id, + projectId: "prj_project_rpc_missing", + }) + ).rejects.toThrow( + "Active project prj_project_rpc_missing was not found while resolving its RPC" + ); + expect(createClusterRpcMock).not.toHaveBeenCalled(); + }); + + it("throws for a project owned by another organization", async () => { + await expect( + loadProjectRpcClient({ + env, + organizationId: "org_project_rpc_other", + projectId: projects.sandbox.id, + }) + ).rejects.toThrow( + `Active project ${projects.sandbox.id} was not found while resolving its RPC` + ); + }); + + it("throws for an archived project", async () => { + await getDb(env) + .prepare("UPDATE projects SET status = 'archived' WHERE id = ?") + .bind(projects.sandbox.id) + .run(); + + await expect( + loadProjectRpcClient({ + env, + organizationId: TEST_ORG.id, + projectId: projects.sandbox.id, + }) + ).rejects.toThrow( + `Active project ${projects.sandbox.id} was not found while resolving its RPC` + ); + expect(createClusterRpcMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/sdp-api/src/services/private-channels/project-rpc.ts b/apps/sdp-api/src/services/private-channels/project-rpc.ts index ba0dc2cc5e..9424065eb9 100644 --- a/apps/sdp-api/src/services/private-channels/project-rpc.ts +++ b/apps/sdp-api/src/services/private-channels/project-rpc.ts @@ -1,19 +1,13 @@ import type { SolanaRpcProbeResult } from "@sdp/private-channels"; -import { type ResolvedRpcTarget, resolveRpcTarget } from "@sdp/rpc/relay"; -import { createRpcFromTransport, type SolanaRpc } from "@sdp/rpc/solana"; +import { createClusterRpc, type SolanaRpc } from "@sdp/rpc/solana"; import { assertValidAddress } from "@sdp/solana/address"; import { CLUSTER_BY_SDP_ENVIRONMENT, type SdpEnvironment, type SolanaCluster } from "@sdp/types"; import { getDb } from "@/db"; -import type { KVStoreSet } from "@/runtime/kv"; -import { createKVStoreSet } from "@/runtime/kv-redis"; -import { createTenantRpcConnectionLookup } from "@/services/rpc-connection-lookup"; -import { createRpcTransportForTarget } from "@/services/rpc-egress"; import type { Env } from "@/types/env"; export interface PrivateChannelProjectRpcClient { cluster: SolanaCluster; rpc: SolanaRpc; - target: ResolvedRpcTarget; probe: (deployment?: PrivateChannelDeploymentProbeInput) => Promise; } @@ -28,17 +22,21 @@ export interface LoadProjectRpcClientInput { projectId: string; /** Already known on authenticated requests; jobs resolve it from the project row. */ environment?: SdpEnvironment; - /** Reuse the request's stores when available; jobs construct the same Redis-backed set. */ - kv?: KVStoreSet; } /** - * Load a client for the same effective RPC target used by the SDP relay. + * Load a client on SDP's managed RPC pool for a project's Private Channels work. * - * Resolution happens for every request/job pass so provider switches and BYOK - * credential rotation take effect immediately. The returned endpoint and - * headers are execution-only: Private Channels never persists or serializes - * them on its instance records. + * The pool's endpoints are execution-only: Private Channels never persists or + * serializes them on its instance records. + * + * @param input - The project to load the client for. + * @param input.env - Process env carrying the managed RPC pool. + * @param input.organizationId - Organization that owns the project. + * @param input.projectId - Project whose environment picks the cluster. + * @param input.environment - The project's environment when the caller already knows it; otherwise read from the active project row. + * @returns The project's cluster, an RPC client for that cluster, and a deployment probe bound to both. + * @throws Error when the project is not active in the organization, or when the deployment has no RPC endpoint for the project's cluster. */ export async function loadProjectRpcClient( input: LoadProjectRpcClientInput @@ -61,22 +59,12 @@ export async function loadProjectRpcClient( throw new Error(`Active project ${input.projectId} was not found while resolving its RPC`); } - const target = await resolveRpcTarget({ - env: input.env, - kv: input.kv ?? createKVStoreSet(input.env), - db, - organizationId: input.organizationId, - authProjectId: input.projectId, - requestedProjectId: null, - connections: createTenantRpcConnectionLookup(input.env, db), - }); - const rpc = createRpcFromTransport(createRpcTransportForTarget(target)); const cluster = CLUSTER_BY_SDP_ENVIRONMENT[environment]; + const rpc = createClusterRpc(input.env, cluster); return { cluster, rpc, - target, probe: (deployment) => probeProjectRpcDeployment(rpc, cluster, deployment), }; } diff --git a/apps/sdp-api/src/services/private-channels/transfer.node.test.ts b/apps/sdp-api/src/services/private-channels/transfer.node.test.ts index f88174d658..1f0eefdb17 100644 --- a/apps/sdp-api/src/services/private-channels/transfer.node.test.ts +++ b/apps/sdp-api/src/services/private-channels/transfer.node.test.ts @@ -137,7 +137,6 @@ function makeInput(overrides: Partial[1 send: async () => ({ value: { owner: PRIVATE_CHANNEL_ESCROW_PROGRAM_ADDRESS } }), }), } as never, - target: {} as never, probe: vi.fn(), }, ...overrides, diff --git a/apps/sdp-api/src/services/private-channels/value-movement.node.test.ts b/apps/sdp-api/src/services/private-channels/value-movement.node.test.ts index 98a0222aea..9e5f83f003 100644 --- a/apps/sdp-api/src/services/private-channels/value-movement.node.test.ts +++ b/apps/sdp-api/src/services/private-channels/value-movement.node.test.ts @@ -44,14 +44,6 @@ beforeEach(async () => { signedBeforeSend = []; projectRpc = { cluster: "devnet", - target: { - providerId: "custom", - projectId: scope.projectId, - endpoint: "https://rpc.pc-execution.test", - endpointLabel: "PC execution test", - headers: {}, - selectionMode: "project_custom_provider", - }, probe: async () => ({ ok: true, latencyMs: 0, version: "test" }), rpc: solanaRpc.createRpcFromTransport( vi.fn().mockResolvedValue({ diff --git a/apps/sdp-api/src/services/project.service.test.ts b/apps/sdp-api/src/services/project.service.test.ts index ad88c17718..023f34be9f 100644 --- a/apps/sdp-api/src/services/project.service.test.ts +++ b/apps/sdp-api/src/services/project.service.test.ts @@ -13,8 +13,15 @@ import { DEFAULT_PROJECT_SLUG, seedDefaultProjects, } from "@/test/helpers/projects"; +import { required } from "@/test/helpers/required"; import { seedTestDatabase } from "@/test/mocks/db"; +const STORED_SETTINGS_WITH_REMOVED_KEYS = JSON.stringify({ + webhookUrl: "https://hooks.example.com/x", + rpcProvider: "helius", + rpcEndpoint: "https://rpc.example.com", +}); + describe("ProjectService", () => { let projectService: ProjectService; let db: DatabaseClient; @@ -80,6 +87,17 @@ describe("ProjectService", () => { expect(project).toBeNull(); }); + + it("strips removed keys from the stored settings", async () => { + await db + .prepare("UPDATE projects SET settings = ? WHERE id = ?") + .bind(STORED_SETTINGS_WITH_REMOVED_KEYS, TEST_PROJECT.id) + .run(); + + const project = await projectService.getProject(TEST_PROJECT.id); + + expect(required(project).settings).toEqual({ webhookUrl: "https://hooks.example.com/x" }); + }); }); describe("getProjectBySlug", () => { @@ -143,45 +161,71 @@ describe("ProjectService", () => { expect(updated.settings?.webhookUrl).toBe("https://new.example.com/webhook"); }); - it("defaults rpc provider to round robin when settings are omitted", async () => { + it("returns null settings when none are stored", async () => { + await db + .prepare("UPDATE projects SET settings = NULL WHERE id = ?") + .bind(TEST_PROJECT.id) + .run(); + const updated = await projectService.updateProject(TEST_PROJECT.id, { - name: "Default RPC Provider Renamed", + name: "Unset Settings Renamed", }); - expect(updated.settings?.rpcProvider).toBe("default"); + expect(updated.settings).toBeNull(); }); - it("preserves existing rpc provider when settings update omits it", async () => { + it("merges a settings update over the stored settings", async () => { await db .prepare("UPDATE projects SET settings = ? WHERE id = ?") - .bind(JSON.stringify({ rpcProvider: "triton" }), TEST_PROJECT.id) + .bind( + JSON.stringify({ + webhookUrl: "https://old.example.com/webhook", + metadata: { team: "payments" }, + }), + TEST_PROJECT.id + ) .run(); const updated = await projectService.updateProject(TEST_PROJECT.id, { settings: { webhookUrl: "https://updated.example.com/webhook" }, }); - expect(updated.settings?.rpcProvider).toBe("triton"); + expect(updated.settings).toEqual({ + webhookUrl: "https://updated.example.com/webhook", + metadata: { team: "payments" }, + }); }); - it("switches provider to default and clears custom endpoint", async () => { + it("does not write removed stored keys back when merging a settings update", async () => { await db .prepare("UPDATE projects SET settings = ? WHERE id = ?") - .bind( - JSON.stringify({ - rpcProvider: "custom", - rpcEndpoint: "https://rpc.custom.example.com", - }), - TEST_PROJECT.id - ) + .bind(STORED_SETTINGS_WITH_REMOVED_KEYS, TEST_PROJECT.id) .run(); - const updated = await projectService.updateProject(TEST_PROJECT.id, { - settings: { rpcProvider: "default" }, + await projectService.updateProject(TEST_PROJECT.id, { + settings: { metadata: { team: "payments" } }, }); - expect(updated.settings?.rpcProvider).toBe("default"); - expect(updated.settings?.rpcEndpoint).toBeUndefined(); + const row = await db + .prepare("SELECT settings FROM projects WHERE id = ?") + .bind(TEST_PROJECT.id) + .first<{ settings: string }>(); + const persisted: unknown = JSON.parse(required(row).settings); + expect(persisted).toEqual({ + webhookUrl: "https://hooks.example.com/x", + metadata: { team: "payments" }, + }); + }); + + it("clears stored settings when the update sets them to null", async () => { + await db + .prepare("UPDATE projects SET settings = ? WHERE id = ?") + .bind(JSON.stringify({ webhookUrl: "https://old.example.com/webhook" }), TEST_PROJECT.id) + .run(); + + const updated = await projectService.updateProject(TEST_PROJECT.id, { settings: null }); + + expect(updated.settings).toBeNull(); }); it("throws for non-existent project", async () => { diff --git a/apps/sdp-api/src/services/project.service.ts b/apps/sdp-api/src/services/project.service.ts index 5e5866931e..c06800a3e4 100644 --- a/apps/sdp-api/src/services/project.service.ts +++ b/apps/sdp-api/src/services/project.service.ts @@ -12,9 +12,31 @@ import type { ProjectRole, ProjectSettings, } from "@sdp/types"; -import { parsePostgresJsonOr } from "@/db/postgres-utils"; +import { z } from "zod"; +import { parseOptionalPostgresJson } from "@/db/postgres-utils"; import { badRequest, internalError, notFound } from "@/lib/errors"; +/** + * Stored project settings as the API exposes them. Unknown keys are stripped, + * so keys written before a field was removed are neither returned nor merged + * back on the next update. + */ +const storedProjectSettingsSchema = z.object({ + webhookUrl: z.string().optional(), + metadata: z.record(z.string(), z.string()).optional(), +}) satisfies z.ZodType; + +/** + * Parse a project's stored settings column. + * + * @param raw - The `settings` column as read from Postgres. + * @returns The settings with unknown keys stripped, or null when never set. + */ +function parseStoredProjectSettings(raw: string | null): ProjectSettings | null { + const stored = parseOptionalPostgresJson(raw); + return stored === null ? null : storedProjectSettingsSchema.parse(stored); +} + export interface UpdateProjectInput { name?: string; description?: string | null; @@ -181,17 +203,9 @@ export class ProjectService { if (input.settings !== undefined) { updates.push("settings = ?"); - const normalizedSettings = - input.settings === null - ? this.resolveProjectSettings(undefined) - : this.resolveProjectSettings( - { - ...(existing.settings ?? {}), - ...input.settings, - }, - existing.settings - ); - values.push(JSON.stringify(normalizedSettings)); + values.push( + input.settings === null ? null : JSON.stringify({ ...existing.settings, ...input.settings }) + ); } updates.push("updated_at = ?"); @@ -407,11 +421,6 @@ export class ProjectService { created_at: string; updated_at: string; }): Project { - let settings: ProjectSettings | undefined; - if (row.settings) { - settings = parsePostgresJsonOr(row.settings, undefined); - } - return { id: row.id, organizationId: row.organization_id, @@ -419,39 +428,11 @@ export class ProjectService { slug: row.slug, description: row.description, environment: row.environment as ProjectEnvironment, - settings: this.resolveProjectSettings(settings), + settings: parseStoredProjectSettings(row.settings), status: row.status as "active" | "archived", createdBy: row.created_by, createdAt: row.created_at, updatedAt: row.updated_at, }; } - - private resolveProjectSettings( - settings?: ProjectSettings | null, - fallbackSettings?: ProjectSettings | null - ): ProjectSettings { - const resolved: ProjectSettings = { - ...(settings ?? {}), - }; - - if (resolved.rpcProvider === undefined) { - resolved.rpcProvider = - fallbackSettings?.rpcProvider ?? (resolved.rpcEndpoint ? "custom" : "default"); - } - - if ( - resolved.rpcProvider === "custom" && - resolved.rpcEndpoint === undefined && - fallbackSettings?.rpcEndpoint - ) { - resolved.rpcEndpoint = fallbackSettings.rpcEndpoint; - } - - if (resolved.rpcProvider !== "custom") { - resolved.rpcEndpoint = undefined; - } - - return resolved; - } } diff --git a/apps/sdp-api/src/services/provider-availability.service.test.ts b/apps/sdp-api/src/services/provider-availability.service.test.ts index de023f2deb..87dca63db4 100644 --- a/apps/sdp-api/src/services/provider-availability.service.test.ts +++ b/apps/sdp-api/src/services/provider-availability.service.test.ts @@ -9,6 +9,7 @@ import { getProviderAvailability, isPersistedCustodyCompletionEnabled, parseClerkOrganizationTierMetadata, + parseProviderOverridesFromClerkMetadata, syncProviderAccessFromClerk, } from "@/services/provider-availability.service"; import { env } from "@/test/helpers/env"; @@ -39,14 +40,6 @@ const providerEnvKeys = [ "UTILA_SERVICE_ACCOUNT_EMAIL", "UTILA_SERVICE_ACCOUNT_PRIVATE_KEY", "UTILA_VAULT_ID", - "SOLANA_RPC_URL", - "SOLANA_RPC_ALCHEMY_URL", - "SOLANA_RPC_HELIUS_URL", - "SOLANA_RPC_QUICKNODE_URL", - "SOLANA_RPC_TRITON_URL", - "SOLANA_RPC_VALIDATIONCLOUD_URL", - "SOLANA_RPC_NODIT_URL", - "SOLANA_RPC_NODIT_API_KEY", "RANGE_API_KEY", "ELLIPTIC_API_TOKEN", "ELLIPTIC_API_KEY", @@ -91,12 +84,6 @@ function setBaseProviderEnv(): void { writeProviderEnv({ PRIVY_APP_ID: "privy_test_app", PRIVY_APP_SECRET: "privy_test_secret", - SOLANA_RPC_URL: "https://rpc.default.test", - SOLANA_RPC_HELIUS_URL: "https://rpc.helius.test", - SOLANA_RPC_TRITON_URL: "https://rpc.triton.test", - SOLANA_RPC_VALIDATIONCLOUD_URL: "https://rpc.validationcloud.test/v1/{API_KEY}", - SOLANA_RPC_NODIT_URL: "https://solana-devnet.nodit.io/{API_KEY}", - SOLANA_RPC_NODIT_API_KEY: "nodit_test_key", RANGE_API_KEY: "range_test_key", MOONPAY_API_KEY: "moonpay_test_key", MOONPAY_SECRET_KEY: "moonpay_test_secret", @@ -198,9 +185,6 @@ describe("provider-availability.service", () => { custody: { local: true, }, - rpc: { - helius: true, - }, compliance: { range: true, }, @@ -216,11 +200,6 @@ describe("provider-availability.service", () => { expect(resolved.providers.custody.turnkey).toBe(true); expect(resolved.providers.custody.local).toBe(true); expect(resolved.providers.custody.para).toBe(true); - expect(resolved.providers.rpc.default).toBe(true); - expect(resolved.providers.rpc.helius).toBe(true); - expect(resolved.providers.rpc.triton).toBe(true); - expect(resolved.providers.rpc.validationcloud).toBe(true); - expect(resolved.providers.rpc.nodit).toBe(true); expect(resolved.providers.compliance.range).toBe(true); expect(resolved.providers.ramps.moonpay).toBe(true); expect(resolved.providers.ramps.lightspark).toBe(true); @@ -238,19 +217,6 @@ describe("provider-availability.service", () => { expect(availability.providers.custody.coinbase_cdp.enabled).toBe(true); expect(availability.providers.custody.turnkey.enabled).toBe(true); expect(availability.providers.custody.para.enabled).toBe(true); - expect(availability.providers.rpc.default.enabled).toBe(true); - expect(availability.providers.rpc.helius.enabled).toBe(true); - expect(availability.providers.rpc.triton.enabled).toBe(true); - expect(availability.providers.rpc.validationcloud).toEqual({ - entitled: true, - configured: true, - enabled: true, - }); - expect(availability.providers.rpc.nodit).toEqual({ - entitled: true, - configured: true, - enabled: true, - }); expect(availability.providers.compliance.range).toEqual({ entitled: false, configured: true, @@ -303,45 +269,6 @@ describe("provider-availability.service", () => { }); }); - it.each(["individual", "enterprise"] as const)( - "treats configured Nodit as general for the legacy %s tier and honors an explicit disable", - async (tier) => { - await setOrganizationTier(tier); - - const enabled = await getProviderAvailability(env, getDb(env), TEST_ORG_ID); - expect(enabled.providers.rpc.nodit).toEqual({ - entitled: true, - configured: true, - enabled: true, - }); - - await getDb(env) - .prepare("UPDATE organizations SET settings = ? WHERE id = ?") - .bind(JSON.stringify({ providerOverrides: { rpc: { nodit: false } } }), TEST_ORG_ID) - .run(); - - const disabled = await getProviderAvailability(env, getDb(env), TEST_ORG_ID); - expect(disabled.providers.rpc.nodit).toEqual({ - entitled: false, - configured: true, - enabled: false, - }); - } - ); - - it("treats Nodit as configured when its URL is present like other RPC providers", async () => { - env.SOLANA_RPC_NODIT_URL = "https://rpc.proxy.test/nodit"; - env.SOLANA_RPC_NODIT_API_KEY = undefined; - - const availability = await getProviderAvailability(env, getDb(env), TEST_ORG_ID); - - expect(availability.providers.rpc.nodit).toEqual({ - entitled: true, - configured: true, - enabled: true, - }); - }); - it("treats local custody as override-only and configurable only in a self-hosted deployment", async () => { await syncProviderAccessFromClerk(getDb(env), { organizationId: TEST_ORG_ID, @@ -399,9 +326,6 @@ describe("provider-availability.service", () => { local: true, para: false, }, - rpc: { - helius: true, - }, }, }, }, @@ -420,9 +344,6 @@ describe("provider-availability.service", () => { local: true, para: false, }, - rpc: { - helius: true, - }, }, }); }); @@ -567,7 +488,7 @@ describe("provider-availability.service", () => { local: true, }, }, - rpcProvider: "helius", + defaultEnvironment: "sandbox", }), TEST_ORG_ID ) @@ -587,7 +508,7 @@ describe("provider-availability.service", () => { expect(organization?.tier).toBe("enterprise"); expect(organization?.settings ? JSON.parse(organization.settings) : null).toEqual({ - rpcProvider: "helius", + defaultEnvironment: "sandbox", }); }); @@ -612,12 +533,22 @@ describe("provider-availability.service", () => { } }); + it("parseProviderOverridesFromClerkMetadata drops a stale rpc family and keeps the families it knows", () => { + expect( + parseProviderOverridesFromClerkMetadata({ + rpc: { helius: true }, + custody: { privy: true }, + }) + ).toEqual({ custody: { privy: true } }); + expect(parseProviderOverridesFromClerkMetadata({ rpc: { helius: true } })).toBeUndefined(); + }); + it("syncs enableProductionProject into settings when true and strips it when absent, preserving unrelated keys", async () => { await getDb(env) .prepare("UPDATE organizations SET settings = ? WHERE id = ?") .bind( JSON.stringify({ - rpcProvider: "helius", + defaultEnvironment: "sandbox", enableProductionProject: true, }), TEST_ORG_ID @@ -637,7 +568,7 @@ describe("provider-availability.service", () => { .bind(TEST_ORG_ID) .first<{ settings: string | null }>(); expect(stripped?.settings ? JSON.parse(stripped.settings) : null).toEqual({ - rpcProvider: "helius", + defaultEnvironment: "sandbox", }); }); diff --git a/apps/sdp-api/src/services/provider-availability.service.ts b/apps/sdp-api/src/services/provider-availability.service.ts index 65d84cbe36..7c90483955 100644 --- a/apps/sdp-api/src/services/provider-availability.service.ts +++ b/apps/sdp-api/src/services/provider-availability.service.ts @@ -9,11 +9,9 @@ import { isEarnProviderSurfaced, isRampProviderSurfaced, normalizeOrganizationTier, - ORGANIZATION_RPC_PROVIDERS, type OrganizationProviderAvailabilityResponse, type OrganizationProviderFamily, type OrganizationProviderOverrides, - type OrganizationRpcProvider, type OrganizationSettings, type OrganizationTier, type ProviderAvailabilityEntry, @@ -56,7 +54,6 @@ type ProviderAvailabilityDefinition = { type ProviderAvailabilityDefinitions = { custody: Record; - rpc: Record; compliance: Record; ramps: Record; earn: Record; @@ -64,7 +61,6 @@ type ProviderAvailabilityDefinitions = { type ProviderIdByFamily = { custody: CustodyProvider; - rpc: OrganizationRpcProvider; compliance: ComplianceProviderId; ramps: RampProviderId; earn: EarnProviderId; @@ -201,36 +197,6 @@ const PROVIDER_AVAILABILITY_DEFINITIONS = { ]), }, }, - rpc: { - default: { - label: "SDP/default", - isConfigured: (env) => hasEnv(env, "SOLANA_RPC_URL"), - }, - alchemy: { - label: "Alchemy", - isConfigured: (env) => hasEnv(env, "SOLANA_RPC_ALCHEMY_URL"), - }, - helius: { - label: "Helius", - isConfigured: (env) => hasEnv(env, "SOLANA_RPC_HELIUS_URL"), - }, - nodit: { - label: "Nodit", - isConfigured: (env) => hasEnv(env, "SOLANA_RPC_NODIT_URL"), - }, - quicknode: { - label: "QuickNode", - isConfigured: (env) => hasEnv(env, "SOLANA_RPC_QUICKNODE_URL"), - }, - triton: { - label: "Triton", - isConfigured: (env) => hasEnv(env, "SOLANA_RPC_TRITON_URL"), - }, - validationcloud: { - label: "Validation Cloud", - isConfigured: (env) => hasEnv(env, "SOLANA_RPC_VALIDATIONCLOUD_URL"), - }, - }, compliance: { range: { label: "Range", @@ -487,11 +453,6 @@ export function parseProviderOverridesFromClerkMetadata( next.custody = custody; } - const rpc = parseBooleanOverrides(record.rpc, ORGANIZATION_RPC_PROVIDERS); - if (rpc) { - next.rpc = rpc; - } - const compliance = parseBooleanOverrides(record.compliance, COMPLIANCE_PROVIDERS); if (compliance) { next.compliance = compliance; @@ -563,7 +524,6 @@ function buildConfiguredProviderEntries( function getConfiguredProviders(env: Env) { return { custody: buildConfiguredProviderEntries(PROVIDER_AVAILABILITY_DEFINITIONS.custody, env), - rpc: buildConfiguredProviderEntries(PROVIDER_AVAILABILITY_DEFINITIONS.rpc, env), compliance: buildConfiguredProviderEntries(PROVIDER_AVAILABILITY_DEFINITIONS.compliance, env), ramps: buildConfiguredProviderEntries(PROVIDER_AVAILABILITY_DEFINITIONS.ramps, env), earn: buildConfiguredProviderEntries(PROVIDER_AVAILABILITY_DEFINITIONS.earn, env), @@ -615,7 +575,6 @@ export async function getProviderAvailability( tier: resolved.tier, providers: { custody: buildAvailabilityEntries(resolved.providers.custody, configured.custody), - rpc: buildAvailabilityEntries(resolved.providers.rpc, configured.rpc), compliance: buildAvailabilityEntries(resolved.providers.compliance, configured.compliance), ramps: buildAvailabilityEntries(resolved.providers.ramps, configured.ramps), earn: buildAvailabilityEntries(resolved.providers.earn, configured.earn), @@ -714,13 +673,6 @@ export async function assertProviderAvailable( family: "custody", providerId: CustodyProvider ): Promise; -export async function assertProviderAvailable( - env: Env, - db: DatabaseClient, - organizationId: string, - family: "rpc", - providerId: OrganizationRpcProvider -): Promise; export async function assertProviderAvailable( env: Env, db: DatabaseClient, @@ -858,7 +810,6 @@ export async function getEnabledProviders(env: Env, db: DatabaseClient, organiza return { tier: access.tier, custody: CUSTODY_PROVIDERS.filter((provider) => access.providers.custody[provider]?.enabled), - rpc: ORGANIZATION_RPC_PROVIDERS.filter((provider) => access.providers.rpc[provider]?.enabled), compliance: COMPLIANCE_PROVIDERS.filter( (provider) => access.providers.compliance[provider]?.enabled ), diff --git a/apps/sdp-api/src/services/provider-credential-submission.service.ts b/apps/sdp-api/src/services/provider-credential-submission.service.ts index c3aeb66ae2..d34749f297 100644 --- a/apps/sdp-api/src/services/provider-credential-submission.service.ts +++ b/apps/sdp-api/src/services/provider-credential-submission.service.ts @@ -65,7 +65,7 @@ type SubmissionSource = "stored" | "runtime"; export interface SafeProviderCredential { id: string; - /** Widened alongside ProviderCredentialRow: RPC connections store credentials here too. */ + /** Widened alongside ProviderCredentialRow: Helius Rings connections store credentials here too. */ provider: string; label: string; scope: "organization" | "project"; diff --git a/apps/sdp-api/src/services/provider-setup-registry.test.ts b/apps/sdp-api/src/services/provider-setup-registry.test.ts index 77f519af92..0c09bc4b94 100644 --- a/apps/sdp-api/src/services/provider-setup-registry.test.ts +++ b/apps/sdp-api/src/services/provider-setup-registry.test.ts @@ -1,10 +1,4 @@ -import type { ResolvedRpcTarget } from "@sdp/rpc/relay"; -import { - COMPLIANCE_PROVIDERS, - CUSTODY_PROVIDERS, - ORGANIZATION_RPC_PROVIDERS, - RAMP_PROVIDERS, -} from "@sdp/types"; +import { COMPLIANCE_PROVIDERS, CUSTODY_PROVIDERS, RAMP_PROVIDERS } from "@sdp/types"; import type { Context } from "hono"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Env } from "@/types/env"; @@ -41,9 +35,6 @@ describe("provider setup registry", () => { expect(Object.keys(PROVIDER_SETUP_REGISTRY.custody).sort()).toEqual( [...CUSTODY_PROVIDERS].sort() ); - expect(Object.keys(PROVIDER_SETUP_REGISTRY.rpc).sort()).toEqual( - [...ORGANIZATION_RPC_PROVIDERS].sort() - ); expect(Object.keys(PROVIDER_SETUP_REGISTRY.compliance).sort()).toEqual( [...COMPLIANCE_PROVIDERS].sort() ); @@ -110,44 +101,6 @@ describe("provider setup registry", () => { expect(privy.validateSetupPayload({ provider: "privy" }, "replace").success).toBe(false); }); - it("represents managed RPC testing with the existing getVersion probe", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify({ jsonrpc: "2.0", id: "rpc-connectivity-test", result: {} }), { - status: 200, - statusText: "OK", - }) - ); - const target: ResolvedRpcTarget = { - providerId: "helius", - projectId: "prj_1", - endpoint: "https://rpc.example.test/secret-path", - endpointLabel: "https://rpc.example.test/***", - headers: { Authorization: "Bearer secret" }, - selectionMode: "project_provider", - }; - - const result = await getProviderSetupDefinition("rpc", "helius").checkConnection({ target }); - - expect(result.upstream.status).toBe(200); - expect(result.upstreamBody).toMatchObject({ id: "rpc-connectivity-test", result: {} }); - expect(fetchSpy).toHaveBeenCalledWith( - target.endpoint, - expect.objectContaining({ - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer secret", - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: "rpc-connectivity-test", - method: "getVersion", - params: [], - }), - }) - ); - }); - it("checks compliance configuration without screening an address", () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); const range = getProviderSetupDefinition("compliance", "range"); diff --git a/apps/sdp-api/src/services/provider-setup-registry.ts b/apps/sdp-api/src/services/provider-setup-registry.ts index 47d0d777ec..cc96d766f8 100644 --- a/apps/sdp-api/src/services/provider-setup-registry.ts +++ b/apps/sdp-api/src/services/provider-setup-registry.ts @@ -1,10 +1,4 @@ -import type { ResolvedRpcTarget } from "@sdp/rpc/relay"; -import type { - ComplianceProviderId, - CustodyProvider, - OrganizationRpcProvider, - RampProviderId, -} from "@sdp/types"; +import type { ComplianceProviderId, CustodyProvider, RampProviderId } from "@sdp/types"; import type { Context } from "hono"; import { z } from "zod"; import { isProviderConfigured } from "@/services/provider-availability.service"; @@ -16,16 +10,13 @@ import { replaceProviderCredential, submitProviderCredential, } from "@/services/provider-credential-submission.service"; -import { isCustomerSuppliedTarget } from "@/services/rpc-egress"; -import { probeRpcEndpoint } from "@/services/rpc-probe"; import type { Env } from "@/types/env"; -export const PROVIDER_SETUP_FAMILIES = ["custody", "rpc", "compliance", "ramps"] as const; +export const PROVIDER_SETUP_FAMILIES = ["custody", "compliance", "ramps"] as const; export type ProviderSetupFamily = (typeof PROVIDER_SETUP_FAMILIES)[number]; type ProviderIdByFamily = { custody: CustodyProvider; - rpc: OrganizationRpcProvider; compliance: ComplianceProviderId; ramps: RampProviderId; }; @@ -130,36 +121,6 @@ async function storePrivyCredentials(input: PrivyStoreCredentialsInput) { return submitProviderCredential(input.context, input.payload, input.idempotencyKey); } -export interface RpcConnectionCheckInput { - target: ResolvedRpcTarget; -} - -export interface RpcConnectionCheckResult { - elapsedMs: number; - upstream: Response; - upstreamBody: unknown; -} - -/** - * Run the same read-only JSON-RPC probe used by POST /rpc/test. - * - * `/v1/rpc/test` resolves tenant connections and the project's own `custom` - * endpoint, and `projects.settings.rpcEndpoint` behind the latter is validated - * as a URL when written and nothing more, so the probe reaches a - * customer-supplied host in both cases and both go under the guard. Managed - * providers keep the ordinary fetch: their endpoints come from deployment - * config and are private on purpose in local development and in the Surfpool - * suites. The probe does not follow redirects, which it already refused before - * the guard existed. - */ -export async function checkResolvedRpcTargetConnection( - input: RpcConnectionCheckInput -): Promise { - return probeRpcEndpoint(input.target, { - enforcePublicEgress: isCustomerSuppliedTarget(input.target), - }); -} - export interface ProviderConfigurationCheckInput { env: Env; testMode?: boolean; @@ -191,15 +152,6 @@ function rampConfigurationCheck(provider: RampProviderId) { }); } -function rpcSetup(provider: Provider) { - return { - family: "rpc", - provider, - setupMode: "platform_managed", - checkConnection: checkResolvedRpcTargetConnection, - } as const; -} - function complianceSetup(provider: Provider) { return { family: "compliance", @@ -249,15 +201,6 @@ export const PROVIDER_SETUP_REGISTRY = { anchorage: { family: "custody", provider: "anchorage", setupMode: "contact" }, utila: { family: "custody", provider: "utila", setupMode: "contact" }, }, - rpc: { - alchemy: rpcSetup("alchemy"), - default: rpcSetup("default"), - helius: rpcSetup("helius"), - nodit: rpcSetup("nodit"), - quicknode: rpcSetup("quicknode"), - triton: rpcSetup("triton"), - validationcloud: rpcSetup("validationcloud"), - }, compliance: { range: complianceSetup("range"), elliptic: complianceSetup("elliptic"), diff --git a/apps/sdp-api/src/services/rpc-byok-end-to-end.test.ts b/apps/sdp-api/src/services/rpc-byok-end-to-end.test.ts deleted file mode 100644 index 0b4855e9a7..0000000000 --- a/apps/sdp-api/src/services/rpc-byok-end-to-end.test.ts +++ /dev/null @@ -1,481 +0,0 @@ -import { createServer, type Server } from "node:http"; -import type { AddressInfo } from "node:net"; -import { SOLANA_GENESIS_HASHES } from "@sdp/rpc/byok"; -import { resolveRpcTarget } from "@sdp/rpc/relay"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { getDb } from "@/db"; -import type { KVStoreSet } from "@/runtime/kv"; -import { createCredentialSecretStore } from "@/services/credential-secret-store"; -import { - activateRpcConnection, - deactivateRpcConnection, - rotateRpcConnection, - submitRpcConnection, -} from "@/services/rpc-connection.service"; -import { createTenantRpcConnectionLookup } from "@/services/rpc-connection-lookup"; -import { RpcConnectionStore } from "@/services/stores/rpc-connection.store"; -import { env } from "@/test/helpers/env"; -import { seedDefaultProjects } from "@/test/helpers/projects"; -import { seedTestDatabase } from "@/test/mocks/db"; -import type { Env } from "@/types/env"; - -/** - * The whole BYOK chain against real Postgres and real encryption: submit, - * activate, resolve. - * - * Activation and deactivation go through `rpc-connection.service`, not through - * the store. That distinction is the point of this file. An earlier version - * seeded the credential row `active` and called `RpcConnectionStore` directly, - * which meant it passed while `insertCredential` wrote `pending` and nothing - * promoted it -- the relay's effective lookup never matched in production and - * the organization's traffic quietly stayed on SDP's keys. A test that seeds - * the state under test proves nothing about the code that produces it. - * - * Unit tests cover each link with stubs; this is the one that answers "will my - * own credentials work". - */ -/** - * The stand-in provider below is an ordinary loopback server, and activation - * probes tenant endpoints through the egress guard, which refuses loopback and - * plaintext by design. That refusal is the correct production behaviour and is - * asserted in `guarded-egress.test.ts` and `rpc-egress.test.ts`. This file is - * about the credential lifecycle, so the guard is delegated to plain fetch here - * rather than weakened anywhere real. - */ -vi.mock("@/services/guarded-egress", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - guardedFetch: (url: string, init: { method: string; headers: HeadersInit; body: string }) => - fetch(url, { method: init.method, headers: init.headers, body: init.body }), - }; -}); - -/** - * Same reasoning one level up: saving now probes the endpoint (HOO-1228), so - * the submit path runs the literal endpoint check too, and it refuses the - * plaintext loopback host this file's stand-in provider listens on. The rule - * itself is asserted in `rpc-byok-target.test.ts` and the egress suites; here - * it would only be testing that a test server is not a real vendor. - */ -vi.mock("@sdp/rpc/byok", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - assertReachableTenantEndpoint: () => undefined, - }; -}); - -const ORG_ID = "org_rpc_byok_e2e"; -const PROJECT_ID = "prj_rpc_byok_e2e"; -/** - * The other environment project. One connection per project (HOO-1227) means each save needs - * somewhere of its own, so the saving cases cannot share the fixture's. - */ -const PROJECT_ID_2 = "prj_rpc_byok_e2e_2"; -const USER_ID = "usr_rpc_byok_e2e"; -const CREDENTIAL_ID = "pcred_rpc_byok_e2e"; -const CONNECTION_ID = "rconn_rpc_byok_e2e"; -const TENANT_KEY = "tenant-secret-abcd1234"; -const appEnv = env as unknown as Env; - -const kv = { - cache: { - get: async () => null, - put: async () => undefined, - delete: async () => undefined, - list: async () => ({ keys: [] }), - }, -} as unknown as KVStoreSet; - -let server: Server; -let seenKeys: string[] = []; -let rejectNextProbe = false; -let endpointBase = ""; -let originalEncryptionKey: string | undefined; -let originalSecretBackend: string | undefined; - -/** - * The slice of the Hono context the connection service reads: `getAuth` looks - * for a `clerk` session, and scope resolution looks for `projectId`. Building - * the real middleware stack here would test the middleware, not the chain. - */ -function serviceContext(projectId: string = PROJECT_ID) { - const values: Record = { - clerk: { - userId: USER_ID, - organizationId: ORG_ID, - role: "admin", - permissions: ["org:read", "org:write", "org:admin"], - }, - projectId, - }; - return { - env: appEnv, - get: (key: string) => values[key], - } as unknown as Parameters[0]; -} - -async function credentialStatus(): Promise { - const row = await getDb(appEnv) - .prepare("SELECT status FROM provider_credentials WHERE id = ?") - .bind(CREDENTIAL_ID) - .first<{ status: string }>(); - return row?.status; -} - -function relayInput() { - return { - env: { ...appEnv, SOLANA_NETWORK: "devnet", SOLANA_RPC_URL: "https://platform.example" }, - kv, - db: getDb(appEnv), - organizationId: ORG_ID, - authProjectId: PROJECT_ID, - requestedProjectId: null, - connections: createTenantRpcConnectionLookup(appEnv, getDb(appEnv)), - } as Parameters[0]; -} - -beforeAll(async () => { - await seedTestDatabase(env as Parameters[0]); - // Same approach as the custody migration tests: the suite supplies its own - // key so the encrypted_db backend is exercised for real. - originalEncryptionKey = appEnv.CUSTODY_ENCRYPTION_KEY; - appEnv.CUSTODY_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64"); - // The service resolves the backend from env rather than taking one, and the - // test env has no GCP project. Pinning it keeps submission on the same - // encrypted_db path the rest of this file writes through. - originalSecretBackend = appEnv.CREDENTIAL_SECRET_STORE_BACKEND; - appEnv.CREDENTIAL_SECRET_STORE_BACKEND = "encrypted_db"; - - // Stands in for the vendor: records the key it was reached with. - server = createServer((req, res) => { - const url = new URL(req.url ?? "/", "http://localhost"); - seenKeys.push(url.searchParams.get("api-key") ?? ""); - // One-shot rejection, for the "a bad key never becomes a row" case. - if (rejectNextProbe) { - rejectNextProbe = false; - res.writeHead(401, { "content-type": "application/json" }); - res.end(JSON.stringify({ error: "unauthorized" })); - return; - } - res.writeHead(200, { "content-type": "application/json" }); - // The probe asks `getGenesisHash` so it can tell which cluster answered, - // and this stub stands in for a devnet endpoint. Answering `getVersion` - // would pass reachability and prove nothing about the network. - res.end( - JSON.stringify({ - jsonrpc: "2.0", - id: "1", - result: SOLANA_GENESIS_HASHES.devnet, - }) - ); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - endpointBase = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; - - const db = getDb(appEnv); - await db - .prepare( - `INSERT INTO organizations (id, name, slug, tier, status) - VALUES (?, 'BYOK E2E', 'byok-e2e', 'enterprise', 'active')` - ) - .bind(ORG_ID) - .run(); - await db - .prepare( - `INSERT INTO users (id, email, email_verified, status) - VALUES (?, 'byok-e2e@example.com', 1, 'active')` - ) - .bind(USER_ID) - .run(); - await seedDefaultProjects(db, { - organizationId: ORG_ID, - createdBy: USER_ID, - members: [], - ids: { sandbox: PROJECT_ID_2, production: PROJECT_ID }, - }); - - // The real secret path: encrypted through the configured backend. - // The test env has no GCP config; encrypted_db is the backend local dev - // and self-hosted use, and is what the credential row records either way. - const secretStore = createCredentialSecretStore(appEnv, "encrypted_db"); - const stored = await secretStore.write({ - orgId: ORG_ID, - provider: "helius", - providerCredentialId: CREDENTIAL_ID, - payload: { endpointUrl: endpointBase, apiKey: TENANT_KEY }, - }); - - await db - .prepare( - `INSERT INTO provider_credentials ( - id, organization_id, project_id, provider, label, scope, source, - storage_backend, secret_ref, secret_version_ref, encrypted_secret_payload, - status, created_by - ) VALUES (?, ?, ?, 'helius', 'Tenant Helius', 'project', 'stored', - ?, ?, ?, ?, 'pending', ?)` - ) - .bind( - CREDENTIAL_ID, - ORG_ID, - PROJECT_ID, - stored.storageBackend, - stored.secretRef ?? null, - stored.secretVersionRef ?? null, - stored.encryptedSecretPayload ?? null, - USER_ID - ) - .run(); - - const connections = new RpcConnectionStore(getDb(appEnv)); - await connections.insertConnection({ - id: CONNECTION_ID, - organizationId: ORG_ID, - projectId: PROJECT_ID, - provider: "helius", - providerCredentialId: CREDENTIAL_ID, - providerCredentialScopeKey: PROJECT_ID, - network: "devnet", - displayMetadata: { endpointHost: "127.0.0.1", apiKeySuffix: "1234" }, - createdBy: USER_ID, - }); -}); - -afterAll(async () => { - appEnv.CUSTODY_ENCRYPTION_KEY = originalEncryptionKey; - appEnv.CREDENTIAL_SECRET_STORE_BACKEND = - originalSecretBackend as typeof appEnv.CREDENTIAL_SECRET_STORE_BACKEND; - await new Promise((resolve) => server.close(() => resolve())); - await seedTestDatabase(env as Parameters[0]); -}); - -describe("BYOK end to end", () => { - it("stores nothing when the provider rejects the key on save", async () => { - rejectNextProbe = true; - await expect( - submitRpcConnection(serviceContext(PROJECT_ID_2), { - provider: "helius", - scope: "project", - credentialLabel: "Never stored", - endpointUrl: endpointBase, - apiKey: "rejected-key-1111", - }) - ).rejects.toThrow(/rejected this connection/i); - - const stored = await getDb(appEnv) - .prepare("SELECT COUNT(*)::int AS count FROM provider_credentials WHERE label = ?") - .bind("Never stored") - .first<{ count: number }>(); - expect(stored?.count).toBe(0); - }); - - it("checks the key on save and stores a connection that is already live", async () => { - // Saving probes (HOO-1228), so the endpoint has to be the stand-in server - // rather than a vendor host nobody can reach from a test. - seenKeys = []; - const submitted = await submitRpcConnection(serviceContext(PROJECT_ID_2), { - provider: "helius", - // No network: the project is `sandbox`, so the service resolves devnet. - scope: "project", - credentialLabel: "Saved and checked", - endpointUrl: endpointBase, - apiKey: "submitted-key-9999", - }); - - // No pending step to explain: the probe is the evidence, so both rows go - // live together. - expect(submitted.status).toBe("active"); - expect(submitted.isDefault).toBe(true); - expect(submitted.providerCredential.status).toBe("active"); - // The network came from the project rather than the caller (HOO-1221). - expect(submitted.network).toBe("devnet"); - // The check really happened, against the key being saved. - expect(seenKeys).toEqual(["submitted-key-9999"]); - // The response must never be able to carry the key back out. - expect(JSON.stringify(submitted)).not.toContain("submitted-key-9999"); - }); - - it("refuses a second key for the same provider, because that is a rotation", async () => { - // Two credentials for one provider on one project have no way to be told - // apart and no meaning in the relay, which reads the default. - await expect( - submitRpcConnection(serviceContext(PROJECT_ID_2), { - provider: "helius", - scope: "project", - credentialLabel: "Second one", - endpointUrl: endpointBase, - apiKey: "second-key-0000", - }) - ).rejects.toThrow(/already has a connection for this provider/i); - }); - - it("stores a second provider alongside, proven but not serving", async () => { - // The point of the marketplace: keys in several providers, one carrying - // traffic, switching without throwing a working key away. - const alongside = await submitRpcConnection(serviceContext(PROJECT_ID_2), { - provider: "alchemy", - scope: "project", - credentialLabel: "Alchemy alongside Helius", - endpointUrl: endpointBase, - apiKey: "alongside-key-1111", - }); - - // Proven on save like any other, so it can be switched to immediately. - expect(alongside.status).toBe("active"); - expect(alongside.providerCredential.status).toBe("active"); - // ...but it must not have taken traffic off the incumbent by appearing. - expect(alongside.isDefault).toBe(false); - - const state = await new RpcConnectionStore(getDb(env)).findScopeConnectionState({ - organizationId: ORG_ID, - scopeKey: PROJECT_ID_2, - network: "devnet", - }); - expect(state.kind).toBe("active"); - }); - - it("takes the platform rail while a connection is only submitted", async () => { - // A draft is not a promise. Failing closed here would 502 the whole - // organization over a form somebody opened and walked away from. - const target = await resolveRpcTarget(relayInput()); - - expect(target.selectionMode).toBe("round_robin_default"); - expect(target.endpoint).not.toContain(TENANT_KEY); - }); - - it("promotes the credential and routes through the tenant's own key on activation", async () => { - expect(await credentialStatus()).toBe("pending"); - - const activated = await activateRpcConnection(serviceContext(), CONNECTION_ID, { - makeDefault: true, - }); - - expect(activated.status).toBe("active"); - // The half that was missing: the connection went active while the - // credential stayed pending, so the effective lookup never matched. - expect(await credentialStatus()).toBe("active"); - expect(activated.providerCredential.status).toBe("active"); - - const target = await resolveRpcTarget(relayInput()); - - expect(target.selectionMode).toBe("project_connection"); - expect(target.connectionId).toBe(CONNECTION_ID); - expect(target.endpoint).toContain(encodeURIComponent(TENANT_KEY)); - // The label is what surfaces to callers, and it must not carry the key. - expect(target.endpointLabel).not.toContain(TENANT_KEY); - - // And the endpoint genuinely works: reach it the way the relay does. - seenKeys = []; - const upstream = await fetch(target.endpoint, { - method: "POST", - headers: { "Content-Type": "application/json", ...target.headers }, - body: JSON.stringify({ jsonrpc: "2.0", id: "1", method: "getGenesisHash", params: [] }), - }); - - expect(upstream.ok).toBe(true); - expect(seenKeys).toEqual([TENANT_KEY]); - }); - - it("fails closed once a live connection stops passing its check", async () => { - // Not a draft: this organization's traffic was on its own key, so moving it - // back onto SDP's without saying so is the thing being prevented. - const failed = await new RpcConnectionStore(getDb(appEnv)).markCheckFailed({ - organizationId: ORG_ID, - connectionId: CONNECTION_ID, - providerCredentialId: CREDENTIAL_ID, - scopeKeys: [PROJECT_ID], - }); - - expect(failed).toBe(1); - await expect(resolveRpcTarget(relayInput())).rejects.toThrow(/not active/i); - }); - - it("drops a probe verdict that lost a race with a rotation", async () => { - // A probe is a network call, so a rotation can commit while one is in - // flight. Writing the old verdict onto the connection anyway marked a - // freshly rotated, working key failed and cleared it out of the default - // slot, which fails the project closed over a key that no longer exists. - // This suite is a narrative on one row, so put it back to serving first. - await getDb(appEnv) - .prepare(`UPDATE rpc_connections SET status = 'active', is_default = TRUE WHERE id = ?`) - .bind(CONNECTION_ID) - .run(); - - const store = new RpcConnectionStore(getDb(appEnv)); - const failed = await store.markCheckFailed({ - organizationId: ORG_ID, - connectionId: CONNECTION_ID, - providerCredentialId: "pcred_rotated_away", - scopeKeys: [PROJECT_ID], - }); - - expect(failed).toBe(0); - // Still serving the SAME connection, because the verdict was about a - // credential this connection no longer points at. - const target = await resolveRpcTarget(relayInput()); - expect(target.connectionId).toBe(CONNECTION_ID); - }); - - it("recovers on re-activation rather than requiring a new connection", async () => { - const reactivated = await activateRpcConnection(serviceContext(), CONNECTION_ID, { - makeDefault: true, - }); - - expect(reactivated.status).toBe("active"); - const target = await resolveRpcTarget(relayInput()); - expect(target.connectionId).toBe(CONNECTION_ID); - }); - - it("swaps the key on rotation and keeps serving throughout", async () => { - seenKeys = []; - const rotated = await rotateRpcConnection(serviceContext(), CONNECTION_ID, { - endpointUrl: endpointBase, - apiKey: "rotated-key-2222", - }); - - // The new key was proven before anything was written. - expect(seenKeys).toEqual(["rotated-key-2222"]); - expect(rotated.status).toBe("active"); - expect(rotated.providerCredential.id).not.toBe(CREDENTIAL_ID); - expect(JSON.stringify(rotated)).not.toContain("rotated-key-2222"); - - // The connection never stopped resolving, and it resolves on the new key. - seenKeys = []; - const target = await resolveRpcTarget(relayInput()); - await fetch(target.endpoint, { - method: "POST", - headers: { "Content-Type": "application/json", ...target.headers }, - body: JSON.stringify({ jsonrpc: "2.0", id: "1", method: "getGenesisHash", params: [] }), - }); - expect(seenKeys).toEqual(["rotated-key-2222"]); - }); - - it("refuses a rotation whose credential has already been replaced", async () => { - // Two rotations racing each other read the same previous credential. The - // compare-and-swap is what stops the loser committing over the winner and - // leaving a live credential nothing points at. - const store = new RpcConnectionStore(getDb(appEnv)); - const stale = await store.repointConnectionCredential({ - organizationId: ORG_ID, - connectionId: CONNECTION_ID, - scopeKeys: [PROJECT_ID], - expectedCredentialId: CREDENTIAL_ID, - nextCredentialId: "pcred_should_never_land", - nextCredentialScopeKey: PROJECT_ID, - }); - - expect(stale).toBeNull(); - }); - - it("stops using the tenant key the moment the connection is deactivated", async () => { - await deactivateRpcConnection(serviceContext(), CONNECTION_ID); - - const target = await resolveRpcTarget(relayInput()); - - // Deactivated is a deliberate withdrawal, so the platform rail is correct - // here -- unlike a broken connection, which fails closed. - expect(target.selectionMode).toBe("round_robin_default"); - expect(target.endpoint).not.toContain(TENANT_KEY); - }); -}); diff --git a/apps/sdp-api/src/services/rpc-byok-parity.test.ts b/apps/sdp-api/src/services/rpc-byok-parity.test.ts deleted file mode 100644 index 377e6de485..0000000000 --- a/apps/sdp-api/src/services/rpc-byok-parity.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { readdirSync, readFileSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -/** - * BYOK must cover exactly what organization-level RPC selection covers. - * - * `resolveRpcTarget` is the only resolver that reads - * `organizations.settings.rpcProvider`, so every call site that honours an - * organization's chosen provider must also receive the tenant connection - * lookup. A new call site added without it would serve platform credentials to - * an organization running on its own key, and nothing else would notice. - * - * Source-scanning rather than behavioural on purpose: the failure this guards - * against is an omission at a call site, which no runtime test of existing - * call sites can see. - */ -const apiSrc = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); - -function callSitesOf(name: string): Array<{ file: string; passesLookup: boolean }> { - const files = readdirSync(apiSrc, { recursive: true, encoding: "utf8" }) - .filter((entry) => entry.endsWith(".ts") && !entry.includes(".test.")) - .map((entry) => path.join(apiSrc, entry)); - - const sites: Array<{ file: string; passesLookup: boolean }> = []; - for (const file of files) { - const source = readFileSync(file, "utf8"); - let index = source.indexOf(`${name}({`); - while (index !== -1) { - // The call's argument object ends at the first line that closes it. - const tail = source.slice(index, index + 800); - const end = tail.indexOf("});"); - const body = end === -1 ? tail : tail.slice(0, end); - sites.push({ - file: path.relative(apiSrc, file), - passesLookup: body.includes("connections:"), - }); - index = source.indexOf(`${name}({`, index + 1); - } - } - return sites; -} - -/** - * Call sites that stay on the platform rail on purpose. - * - * A tenant connection fails closed, so anything given the lookup inherits the - * blast radius of one mistyped key. `POST /v1/wallets/signer-check` is - * API-key reachable and organization-wide, and it reads chain state for a - * platform operation rather than serving the organization's own RPC traffic — - * an admin's typo on the integrations page must not take it down for every - * caller. Adding a file here is a deliberate decision, not a way past a - * failing test. - */ -const PLATFORM_RAIL_CALL_SITES = new Set(["routes/custody/handlers/signer-check.ts"]); - -describe("BYOK parity with organization RPC selection", () => { - it("passes the tenant lookup at every resolveRpcTarget call site that should have it", () => { - const sites = callSitesOf("resolveRpcTarget"); - - expect(sites.length).toBeGreaterThan(0); - const missing = sites - .filter((site) => !site.passesLookup && !PLATFORM_RAIL_CALL_SITES.has(site.file)) - .map((site) => site.file); - expect(missing).toEqual([]); - }); - - it("passes the tenant lookup at every resolveRoundRobinRpcTargets call site", () => { - // The faucet path resolves separately and was the one branch that still - // spent platform credentials for an organization on its own key. - const sites = callSitesOf("resolveRoundRobinRpcTargets"); - - expect(sites.length).toBeGreaterThan(0); - expect(sites.filter((site) => !site.passesLookup).map((site) => site.file)).toEqual([]); - }); - - it("covers the relay and the connectivity test", () => { - const files = new Set( - callSitesOf("resolveRpcTarget") - .filter((site) => site.passesLookup) - .map((site) => site.file) - ); - - expect(files).toContain("routes/rpc/handlers.ts"); - }); - - it("dials every resolved target through the guarded transport", () => { - // A resolved target can carry the project's own `settings.rpcEndpoint` - // (provider `custom`), which is validated as a URL and nothing more. Any - // file that resolves a target and then builds a raw client from it is an - // SSRF sink reachable by whoever can write that setting — signer-check was - // exactly that (HOO-1560). The transport itself is covered behaviourally in - // rpc-egress.test.ts; what no runtime test can see is a NEW call site - // skipping it, which is what this asserts. - const files = readdirSync(apiSrc, { recursive: true, encoding: "utf8" }) - .filter((entry) => entry.endsWith(".ts") && !entry.includes(".test.")) - .map((entry) => path.join(apiSrc, entry)); - - const unguarded = files.filter((file) => { - const source = readFileSync(file, "utf8"); - if (!source.includes("resolveRpcTarget(")) { - return false; - } - // The binding, not the spelling: an alias or a namespace import is the - // same sink under another name. - const bindings = [ - ...source.matchAll(/import\s*{([^}]*)}\s*from\s*"@sdp\/rpc\/solana"/g), - ].flatMap((match) => - match[1] - .split(",") - .map((entry) => entry.trim()) - .filter((entry) => entry === "createRpc" || entry.startsWith("createRpc as ")) - .map((entry) => entry.split(" as ").at(-1)?.trim() ?? entry) - ); - const namespaces = [ - ...source.matchAll(/import\s*\*\s*as\s*(\w+)\s*from\s*"@sdp\/rpc\/solana"/g), - ].map((match) => `${match[1]}.createRpc`); - - return [...bindings, ...namespaces].some((binding) => source.includes(`${binding}(`)); - }); - - expect(unguarded.map((file) => path.relative(apiSrc, file))).toEqual([]); - }); - - it("keeps the signer check off the tenant rail", () => { - // The decision, asserted rather than described: if someone wires the lookup - // back in, this fails and the exclusion above has to be revisited with it. - const signerCheck = callSitesOf("resolveRpcTarget").filter( - (site) => site.file === "routes/custody/handlers/signer-check.ts" - ); - - expect(signerCheck.length).toBeGreaterThan(0); - expect(signerCheck.every((site) => !site.passesLookup)).toBe(true); - }); -}); diff --git a/apps/sdp-api/src/services/rpc-byok-target.test.ts b/apps/sdp-api/src/services/rpc-byok-target.test.ts deleted file mode 100644 index 4d9dd19c39..0000000000 --- a/apps/sdp-api/src/services/rpc-byok-target.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -// Lives in sdp-api rather than sdp-rpc: the package carries no test runner, -// and this is the code path the RPC connection service depends on. - -import { - assertReachableTenantEndpoint, - BYOK_RPC_PROVIDERS, - buildTenantDisplayMetadata, - buildTenantRpcTarget, - isByokRpcProvider, - maskTenantEndpoint, - requiresExplicitEndpoint, - resolveTenantEndpoint, -} from "@sdp/rpc/byok"; -import { describe, expect, it } from "vitest"; - -const KEY = "tenant-secret-key-1234"; - -describe("buildTenantRpcTarget", () => { - it("authenticates Nodit by header, because a key in its path is ignored", () => { - // Verified against the live host: POST / answers - // NO_AUTHENTICATION_FOUND, the same request with X-API-KEY answers - // AUTHENTICATION_FAILED. Sending no header at all is why a Nodit - // connection could pass its probe only against an endpoint needing no - // auth, and never against Nodit itself. - const target = buildTenantRpcTarget("nodit", { - endpointUrl: "https://solana-devnet.nodit.io", - apiKey: KEY, - }); - expect(target.headers).toEqual({ "X-API-KEY": KEY }); - expect(target.endpoint).toBe("https://solana-devnet.nodit.io"); - expect(target.endpoint).not.toContain(KEY); - }); - - it("still templates a Nodit endpoint that already carries the placeholder", () => { - const target = buildTenantRpcTarget("nodit", { - endpointUrl: "https://solana-devnet.nodit.io/{API_KEY}", - apiKey: KEY, - }); - expect(target.endpoint).toContain(encodeURIComponent(KEY)); - }); - - it("puts a Helius key in the query string", () => { - const target = buildTenantRpcTarget("helius", { - endpointUrl: "https://devnet.helius-rpc.com", - apiKey: KEY, - }); - expect(target.endpoint).toContain(`api-key=${encodeURIComponent(KEY)}`); - expect(target.headers).toEqual({}); - }); - - it("puts an Alchemy key in the path segment", () => { - const target = buildTenantRpcTarget("alchemy", { - endpointUrl: "https://solana-devnet.g.alchemy.com/v2", - apiKey: KEY, - }); - expect(target.endpoint).toBe( - `https://solana-devnet.g.alchemy.com/v2/${encodeURIComponent(KEY)}` - ); - }); - - it("sends a Triton key as a header and never in the query string", () => { - const target = buildTenantRpcTarget("triton", { - endpointUrl: "https://tenant.rpcpool.com", - apiKey: KEY, - }); - expect(target.headers).toEqual({ "x-api-key": KEY }); - // A key in the URL would survive into any log that records the endpoint. - expect(new URL(target.endpoint).search).toBe(""); - }); - - it("honours the {API_KEY} placeholder the platform already uses", () => { - const target = buildTenantRpcTarget("quicknode", { - endpointUrl: "https://example.quiknode.pro/{API_KEY}/", - apiKey: KEY, - }); - expect(target.endpoint).toBe(`https://example.quiknode.pro/${encodeURIComponent(KEY)}/`); - }); - - it("refuses an empty endpoint or key rather than building a broken target", () => { - expect(() => buildTenantRpcTarget("helius", { endpointUrl: " ", apiKey: KEY })).toThrow( - /endpoint URL/ - ); - expect(() => - buildTenantRpcTarget("helius", { endpointUrl: "https://x.example", apiKey: " " }) - ).toThrow(/API key/); - }); - - it("covers every BYOK provider", () => { - for (const provider of BYOK_RPC_PROVIDERS) { - const target = buildTenantRpcTarget(provider, { - endpointUrl: "https://tenant.example/rpc", - apiKey: KEY, - }); - expect(target.endpoint).toMatch(/^https:\/\//); - } - }); - - it("does not treat SDP's own rail as a tenant provider", () => { - expect(isByokRpcProvider("default")).toBe(false); - expect(isByokRpcProvider("helius")).toBe(true); - }); -}); - -describe("tenant redaction", () => { - it("masks the tenant key wherever it landed", () => { - const target = buildTenantRpcTarget("helius", { - endpointUrl: "https://devnet.helius-rpc.com", - apiKey: KEY, - }); - const masked = maskTenantEndpoint(target.endpoint, KEY); - expect(masked).not.toContain(KEY); - expect(masked).not.toContain(encodeURIComponent(KEY)); - }); - - it("keeps only a host and a short suffix for display", () => { - const metadata = buildTenantDisplayMetadata({ - endpointUrl: "https://tenant.example/rpc", - apiKey: KEY, - }); - expect(metadata).toEqual({ endpointHost: "tenant.example", apiKeySuffix: "1234" }); - expect(JSON.stringify(metadata)).not.toContain(KEY); - }); - - it("omits a suffix for a key too short to disambiguate safely", () => { - expect(buildTenantDisplayMetadata({ endpointUrl: "https://a.example", apiKey: "abc" })).toEqual( - { - endpointHost: "a.example", - } - ); - }); -}); - -describe("assertReachableTenantEndpoint", () => { - it("refuses the cloud metadata address", () => { - // The endpoint is fetched on activation and on every relayed request, so a - // stored metadata URL would turn SDP's server into the caller. - expect(() => assertReachableTenantEndpoint("https://169.254.169.254/latest/meta-data")).toThrow( - /not reachable/i - ); - }); - - it("refuses loopback and private ranges", () => { - for (const host of [ - "https://localhost/rpc", - "https://127.0.0.1/rpc", - "https://10.0.0.5/rpc", - "https://192.168.1.10/rpc", - "https://172.16.4.4/rpc", - "https://vault.internal/rpc", - ]) { - expect(() => assertReachableTenantEndpoint(host)).toThrow(/not reachable/i); - } - }); - - it("refuses IPv6 loopback, unique-local and link-local literals", () => { - // `URL.hostname` keeps the brackets on an IPv6 literal, so a blocklist that - // anchors on the address itself has to strip them first. These are the - // cases that got through when it did not. - for (const host of [ - "https://[::1]/rpc", - "https://[0:0:0:0:0:0:0:1]/rpc", - "https://[::]/rpc", - "https://[fd00::1]/rpc", - "https://[fc00::1]/rpc", - "https://[fe80::1]/rpc", - "https://[fe80::a00:27ff:fe4e:66a1]/rpc", - // The IPv6 form of the metadata endpoint. - "https://[fe80::a9fe:a9fe]/rpc", - ]) { - expect(() => assertReachableTenantEndpoint(host)).toThrow(/not reachable/i); - } - }); - - it("refuses an IPv4-mapped private address the parser rewrites to hex", () => { - // `new URL("https://[::ffff:127.0.0.1]/")` reports `[::ffff:7f00:1]`, which - // no dotted-quad pattern can match. Loopback must not re-enter that way. - expect(() => assertReachableTenantEndpoint("https://[::ffff:127.0.0.1]/rpc")).toThrow( - /not reachable/i - ); - expect(() => assertReachableTenantEndpoint("https://[::ffff:169.254.169.254]/rpc")).toThrow( - /not reachable/i - ); - }); - - it("still allows a routable IPv6 endpoint", () => { - // The blocklist is about private reachability, not about IPv6. - expect(() => assertReachableTenantEndpoint("https://[2606:4700::1111]/rpc")).not.toThrow(); - }); - - it("refuses every range the connect-time guard refuses", () => { - // Write-time and connect-time classification must agree, or a row can - // exist that every relay call then rejects — or worse, the reverse. - for (const host of [ - "https://100.64.0.1/rpc", - "https://198.18.0.1/rpc", - "https://192.0.0.170/rpc", - "https://224.0.0.1/rpc", - "https://255.255.255.255/rpc", - "https://0.1.2.3/rpc", - "https://[ff02::1]/rpc", - ]) { - expect(() => assertReachableTenantEndpoint(host)).toThrow(/not reachable/i); - } - }); - - it("refuses credentials embedded in the URL", () => { - expect(() => assertReachableTenantEndpoint("https://user:pass@rpc.example.com/")).toThrow( - /credential/i - ); - expect(() => assertReachableTenantEndpoint("https://token@rpc.example.com/")).toThrow( - /credential/i - ); - }); - - it("refuses plaintext http", () => { - expect(() => assertReachableTenantEndpoint("http://rpc.example.com")).toThrow(/https/i); - }); - - it("refuses a malformed URL", () => { - expect(() => assertReachableTenantEndpoint("not-a-url")).toThrow(/valid URL/i); - }); - - it("allows an ordinary vendor endpoint", () => { - expect(() => assertReachableTenantEndpoint("https://devnet.helius-rpc.com")).not.toThrow(); - expect(() => assertReachableTenantEndpoint("https://example.quiknode.pro/abc/")).not.toThrow(); - }); -}); - -/** - * The published base URLs. Pinned because they are the one part of BYOK a - * tenant never sees before it is used: a wrong host fails at save with a - * provider rejection and reads as a bad key. - * - * Each was confirmed against the live host, which answers a JSON-RPC POST with - * an authentication error rather than a DNS failure or a 404. - */ -describe("DEFAULT_TENANT_ENDPOINTS", () => { - it("carries both clusters for every provider that has a shared host", () => { - for (const provider of ["helius", "alchemy", "validationcloud", "nodit"] as const) { - expect(resolveTenantEndpoint(provider, "devnet")).toMatch(/^https:\/\//); - expect(resolveTenantEndpoint(provider, "mainnet-beta")).toMatch(/^https:\/\//); - } - }); - - it("resolves Validation Cloud with the key templated into the path", () => { - const endpointUrl = resolveTenantEndpoint("validationcloud", "mainnet-beta"); - expect(endpointUrl).toBe("https://mainnet.solana.validationcloud.io/v1/{API_KEY}"); - const target = buildTenantRpcTarget("validationcloud", { endpointUrl, apiKey: KEY }); - expect(target.endpoint).toBe( - `https://mainnet.solana.validationcloud.io/v1/${encodeURIComponent(KEY)}` - ); - }); - - it("resolves Nodit without putting the key anywhere in the URL", () => { - const endpointUrl = resolveTenantEndpoint("nodit", "devnet"); - expect(endpointUrl).toBe("https://solana-devnet.nodit.io"); - const target = buildTenantRpcTarget("nodit", { endpointUrl, apiKey: KEY }); - expect(target.endpoint).not.toContain(KEY); - expect(target.headers["X-API-KEY"]).toBe(KEY); - }); - - it("refuses to guess an account-specific host", () => { - // SDP's own are evocative-old-mansion.solana-devnet.quiknode.pro and - // solanaf-sdp-7436.devnet.rpcpool.com: the subdomain is the account, so - // there is nothing to publish and the tenant must supply it. - for (const provider of ["quicknode", "triton"] as const) { - expect(requiresExplicitEndpoint(provider)).toBe(true); - expect(() => resolveTenantEndpoint(provider, "devnet")).toThrow(/account-specific/i); - } - }); - - it("asks for an endpoint only where one cannot be resolved", () => { - for (const provider of BYOK_RPC_PROVIDERS) { - const needsEndpoint = requiresExplicitEndpoint(provider); - // The form and the resolver have to agree, or the field is hidden for a - // provider the API then refuses for want of an endpoint. - if (needsEndpoint) { - expect(() => resolveTenantEndpoint(provider, "devnet")).toThrow(); - } else { - expect(resolveTenantEndpoint(provider, "devnet")).toBeTruthy(); - } - } - }); -}); diff --git a/apps/sdp-api/src/services/rpc-connection-activation.test.ts b/apps/sdp-api/src/services/rpc-connection-activation.test.ts deleted file mode 100644 index 32e9eb9f51..0000000000 --- a/apps/sdp-api/src/services/rpc-connection-activation.test.ts +++ /dev/null @@ -1,385 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { getDb } from "@/db"; -import { RpcConnectionStore } from "@/services/stores/rpc-connection.store"; -import { env } from "@/test/helpers/env"; -import { seedDefaultProjects } from "@/test/helpers/projects"; -import { seedTestDatabase } from "@/test/mocks/db"; - -/** - * Activation has to move two rows, not one. - * - * `insertCredential` writes the credential `pending`, and the relay's - * effective-connection lookup requires `provider_credentials.status = 'active'` - * as well as a live connection. While activation promoted only the connection, - * the two never agreed: the dashboard read the connection as active and the - * relay quietly kept using SDP's own keys. These cover the promotion and the - * scope qualification on it. - */ -const ORGANIZATION_ID = "org_rpc_activation"; -const OTHER_ORGANIZATION_ID = "org_rpc_activation_other"; -const PROJECT_ID = "prj_rpc_activation"; -const OTHER_PROJECT_ID = "prj_rpc_activation_other"; -const USER_ID = "usr_rpc_activation"; -const ORGANIZATION_SCOPE_KEY = "__organization__"; - -/** - * What `actingScopeKeys` hands the store when a project is selected. The routes - * gate on `org:admin`, which is an organization-wide role, and - * `projectContextMiddleware` requires `x-project-id` on every request, so this - * is the only key set the lifecycle operations are ever called with in the - * dashboard. Dropping the organization key from it would leave every - * organization-scoped connection permanently unactivatable. - */ -const PROJECT_CONTEXT_SCOPE_KEYS = [ORGANIZATION_SCOPE_KEY, PROJECT_ID]; - -async function seedScope(): Promise { - const db = getDb(env); - await db.batch([ - db - .prepare( - `INSERT INTO organizations (id, name, slug, tier, status) - VALUES (?, 'RPC activation', ?, 'individual', 'active')` - ) - .bind(ORGANIZATION_ID, "rpc-activation"), - db - .prepare( - `INSERT INTO organizations (id, name, slug, tier, status) - VALUES (?, 'RPC activation other', ?, 'individual', 'active')` - ) - .bind(OTHER_ORGANIZATION_ID, "rpc-activation-other"), - db - .prepare( - `INSERT INTO users (id, email, email_verified, status) - VALUES (?, 'rpc-activation@example.com', 1, 'active')` - ) - .bind(USER_ID), - ]); - await seedDefaultProjects(db, { - organizationId: ORGANIZATION_ID, - createdBy: USER_ID, - members: [], - ids: { sandbox: PROJECT_ID, production: OTHER_PROJECT_ID }, - }); -} - -async function seedPendingConnection( - connectionId: string, - credentialId: string, - options: { - credentialStatus?: string; - connectionStatus?: string; - isDefault?: boolean; - projectId?: string; - /** One live connection per provider per scope, so a case seeding two needs two. */ - provider?: string; - } = {} -): Promise { - const db = getDb(env); - const connectionStatus = options.connectionStatus ?? "pending"; - const provider = options.provider ?? "helius"; - await db - .prepare( - `INSERT INTO provider_credentials ( - id, organization_id, project_id, provider, label, scope, source, - storage_backend, encrypted_secret_payload, status, deactivated_at, created_by - ) VALUES (?, ?, NULL, ?, 'Tenant credential', 'organization', 'stored', - 'encrypted_db', 'secret', ?, ?, ?)` - ) - .bind( - credentialId, - ORGANIZATION_ID, - provider, - options.credentialStatus ?? "pending", - options.credentialStatus === "deactivated" ? "2026-08-16T12:00:00.000Z" : null, - USER_ID - ) - .run(); - - // A project connection is allowed to borrow the organization credential, so - // only the connection row changes scope here. - await db - .prepare( - `INSERT INTO rpc_connections ( - id, organization_id, project_id, provider, scope, - provider_credential_id, provider_credential_scope_key, - network, status, is_default, activated_at, deactivated_at, created_by - ) VALUES (?, ?, ?, ?, ?, ?, '__organization__', - 'devnet', ?, ?, ?, ?, ?)` - ) - .bind( - connectionId, - ORGANIZATION_ID, - options.projectId ?? null, - provider, - options.projectId ? "project" : "organization", - credentialId, - connectionStatus, - options.isDefault ?? false, - connectionStatus === "pending" || connectionStatus === "checking" - ? null - : "2026-08-16T12:00:00.000Z", - connectionStatus === "deactivated" ? "2026-08-16T12:30:00.000Z" : null, - USER_ID - ) - .run(); -} - -async function credentialStatus(credentialId: string): Promise { - const row = await getDb(env) - .prepare("SELECT status FROM provider_credentials WHERE id = ?") - .bind(credentialId) - .first<{ status: string }>(); - return row?.status; -} - -describe("RpcConnectionStore.activateConnectionCredential", () => { - beforeEach(async () => { - await seedTestDatabase(env); - await seedScope(); - }); - - it("promotes a pending credential so the relay lookup can match it", async () => { - await seedPendingConnection("rconn_activation_ok", "pcred_activation_ok"); - const store = new RpcConnectionStore(getDb(env)); - - expect(await credentialStatus("pcred_activation_ok")).toBe("pending"); - - const promoted = await store.activateConnectionCredential({ - organizationId: ORGANIZATION_ID, - connectionId: "rconn_activation_ok", - scopeKeys: ["__organization__"], - }); - - expect(promoted).toBe(1); - expect(await credentialStatus("pcred_activation_ok")).toBe("active"); - }); - - it("promotes a credential that previously failed validation", async () => { - await seedPendingConnection("rconn_activation_retry", "pcred_activation_retry", { - credentialStatus: "failed_validation", - }); - const store = new RpcConnectionStore(getDb(env)); - - // Retrying after a corrected key is the ordinary recovery path, so a - // failed validation must not be terminal. - const promoted = await store.activateConnectionCredential({ - organizationId: ORGANIZATION_ID, - connectionId: "rconn_activation_retry", - scopeKeys: ["__organization__"], - }); - - expect(promoted).toBe(1); - expect(await credentialStatus("pcred_activation_retry")).toBe("active"); - }); - - it("refuses to resurrect a deactivated credential", async () => { - await seedPendingConnection("rconn_activation_dead", "pcred_activation_dead", { - credentialStatus: "deactivated", - }); - const store = new RpcConnectionStore(getDb(env)); - - const promoted = await store.activateConnectionCredential({ - organizationId: ORGANIZATION_ID, - connectionId: "rconn_activation_dead", - scopeKeys: ["__organization__"], - }); - - // Zero is what the service turns into a 409: activating the connection - // anyway would leave the healthy-looking row that never resolves. - expect(promoted).toBe(0); - expect(await credentialStatus("pcred_activation_dead")).toBe("deactivated"); - }); - - it("does not promote for another organization", async () => { - await seedPendingConnection("rconn_activation_scoped", "pcred_activation_scoped"); - const store = new RpcConnectionStore(getDb(env)); - - const promoted = await store.activateConnectionCredential({ - organizationId: OTHER_ORGANIZATION_ID, - connectionId: "rconn_activation_scoped", - scopeKeys: ["__organization__"], - }); - - expect(promoted).toBe(0); - expect(await credentialStatus("pcred_activation_scoped")).toBe("pending"); - }); - - it("promotes an organization connection for an administrator acting in a project", async () => { - await seedPendingConnection("rconn_activation_ctx", "pcred_activation_ctx"); - const store = new RpcConnectionStore(getDb(env)); - - // Deliberate, and the reason `actingScopeKeys` keeps the organization key - // alongside the selected project: the caller holds the organization-wide - // `org:admin`, and a project header is mandatory on these routes, so an - // organization connection would otherwise be impossible to activate at all. - const promoted = await store.activateConnectionCredential({ - organizationId: ORGANIZATION_ID, - connectionId: "rconn_activation_ctx", - scopeKeys: PROJECT_CONTEXT_SCOPE_KEYS, - }); - - expect(promoted).toBe(1); - expect(await credentialStatus("pcred_activation_ctx")).toBe("active"); - }); - - it("does not promote another project's connection", async () => { - await seedPendingConnection("rconn_activation_other_project", "pcred_activation_other", { - projectId: OTHER_PROJECT_ID, - }); - const store = new RpcConnectionStore(getDb(env)); - - // The boundary that is actually crossable: same organization, same - // administrator, a connection belonging to a project they have not - // selected. Only the selected project's key reaches the store. - const promoted = await store.activateConnectionCredential({ - organizationId: ORGANIZATION_ID, - connectionId: "rconn_activation_other_project", - scopeKeys: PROJECT_CONTEXT_SCOPE_KEYS, - }); - - expect(promoted).toBe(0); - expect(await credentialStatus("pcred_activation_other")).toBe("pending"); - }); - - it("matches only the scope keys it is handed", async () => { - await seedPendingConnection("rconn_activation_project", "pcred_activation_project"); - const store = new RpcConnectionStore(getDb(env)); - - // The store's half of the contract: it filters on exactly the key set the - // caller supplies and infers nothing from the organization id. - const promoted = await store.activateConnectionCredential({ - organizationId: ORGANIZATION_ID, - connectionId: "rconn_activation_project", - scopeKeys: [PROJECT_ID], - }); - - expect(promoted).toBe(0); - expect(await credentialStatus("pcred_activation_project")).toBe("pending"); - }); -}); - -/** - * The fail-closed boundary. - * - * An organization that put its own key on a live connection must never have - * its traffic moved back onto SDP's credentials without saying so. But a row - * that has never carried traffic is not that promise, and treating it as one - * turned an abandoned create form into an org-wide outage — including the - * surfaces an administrator would use to fix it. - */ -describe("RpcConnectionStore.findScopeConnectionState", () => { - beforeEach(async () => { - await seedTestDatabase(env); - await seedScope(); - }); - - const scope = { - organizationId: ORGANIZATION_ID, - scopeKey: "__organization__", - network: "devnet" as const, - }; - - it("reports an activated default as live", async () => { - await seedPendingConnection("rconn_state_live", "pcred_state_live", { - connectionStatus: "active", - credentialStatus: "active", - isDefault: true, - }); - - const state = await new RpcConnectionStore(getDb(env)).findScopeConnectionState(scope); - expect(state).toEqual({ kind: "active", connectionId: "rconn_state_live" }); - }); - - it("treats a submitted-but-never-activated connection as nothing configured", async () => { - await seedPendingConnection("rconn_state_pending", "pcred_state_pending"); - - // The draft the reviewer called out: this used to answer "unusable", and - // the relay turned that into a 502 on every RPC call in the organization. - const state = await new RpcConnectionStore(getDb(env)).findScopeConnectionState(scope); - expect(state).toEqual({ kind: "none" }); - }); - - it("treats an in-flight check as nothing configured", async () => { - await seedPendingConnection("rconn_state_checking", "pcred_state_checking", { - connectionStatus: "checking", - }); - - const state = await new RpcConnectionStore(getDb(env)).findScopeConnectionState(scope); - expect(state).toEqual({ kind: "none" }); - }); - - it("fails closed on a connection that was live and then failed its check", async () => { - await seedPendingConnection("rconn_state_failed", "pcred_state_failed", { - connectionStatus: "failed", - credentialStatus: "active", - }); - - const state = await new RpcConnectionStore(getDb(env)).findScopeConnectionState(scope); - expect(state).toEqual({ kind: "unusable" }); - }); - - it("falls back once the connection is deactivated", async () => { - await seedPendingConnection("rconn_state_off", "pcred_state_off", { - connectionStatus: "deactivated", - credentialStatus: "active", - }); - - const state = await new RpcConnectionStore(getDb(env)).findScopeConnectionState(scope); - expect(state).toEqual({ kind: "none" }); - }); - - it("falls back to the platform when the tenant's keys are deliberately idle", async () => { - // Choosing a provider the project holds no key for stands the incumbent - // down: the key survives, nothing points at it, and SDP's account answers. - // - // `markCheckFailed` also clears `is_default`, so absence of a default was - // never evidence of a fault. Reading it as one made every switch onto a - // platform provider refuse with "no active default connection" while the - // page said the organization was running on SDP's. - await seedPendingConnection("rconn_state_idle", "pcred_state_idle", { - connectionStatus: "active", - credentialStatus: "active", - isDefault: false, - }); - - const state = await new RpcConnectionStore(getDb(env)).findScopeConnectionState(scope); - expect(state).toEqual({ kind: "none" }); - }); - - it("still fails closed when a broken key sits beside an idle one", async () => { - // The guarantee that must survive the change above: the tenant last saw - // this key serving, so answering on SDP's without saying so is exactly - // what they pay their own provider to avoid. - await seedPendingConnection("rconn_state_idle_ok", "pcred_state_idle_ok", { - connectionStatus: "active", - credentialStatus: "active", - isDefault: false, - }); - // A different provider: one live connection per provider now, and the app - // itself already refuses a second key while a failed one is still there. - // The scope-wide lookup under test does not care which provider it is. - await seedPendingConnection("rconn_state_idle_bad", "pcred_state_idle_bad", { - connectionStatus: "failed", - credentialStatus: "active", - provider: "triton", - }); - - const state = await new RpcConnectionStore(getDb(env)).findScopeConnectionState(scope); - expect(state).toEqual({ kind: "unusable" }); - }); - - it("does not call a scope live that the effective lookup would not resolve", async () => { - await seedPendingConnection("rconn_state_split", "pcred_state_split", { - connectionStatus: "active", - credentialStatus: "pending", - isDefault: true, - }); - - // Without the credential predicate this answered "active" while - // findEffectiveConnection returned null, and the relay silently spent SDP's - // keys on an organization that had asked for its own. - const store = new RpcConnectionStore(getDb(env)); - expect(await store.findScopeConnectionState(scope)).toEqual({ kind: "unusable" }); - expect(await store.findEffectiveConnection(scope)).toBeNull(); - }); -}); diff --git a/apps/sdp-api/src/services/rpc-connection-deactivation.test.ts b/apps/sdp-api/src/services/rpc-connection-deactivation.test.ts deleted file mode 100644 index e4be3ac6b6..0000000000 --- a/apps/sdp-api/src/services/rpc-connection-deactivation.test.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { getDb } from "@/db"; -import { getLogger } from "@/runtime/logger"; -import * as credentialSecretStore from "@/services/credential-secret-store"; -import { - type activateRpcConnection, - deactivateRpcConnection, -} from "@/services/rpc-connection.service"; -import { RpcConnectionStore } from "@/services/stores/rpc-connection.store"; -import { env } from "@/test/helpers/env"; -import { seedTestDatabase } from "@/test/mocks/db"; -import type { Env } from "@/types/env"; - -const appEnv = env as Env; -const ORG_ID = "org_rpc_deactivation"; -const USER_ID = "user_rpc_deactivation"; -const CREDENTIAL_ID = "pcred_rpc_deactivation"; -const CONNECTION_ID = "rconn_rpc_deactivation"; -const SECRET_VERSION_REF = "projects/p/secrets/sdp-provider-credentials-x/versions/3"; - -const destroyVersion = vi.fn().mockResolvedValue(undefined); - -let originalEncryptionKey: string | undefined; - -function serviceContext() { - const values: Record = { - clerk: { - userId: USER_ID, - organizationId: ORG_ID, - role: "admin", - permissions: ["org:read", "org:write", "org:admin"], - }, - projectId: null, - }; - return { - env: appEnv, - get: (key: string) => values[key], - } as unknown as Parameters[0]; -} - -async function seedActiveConnection( - connectionId: string, - credentialId: string, - backend: "gcp_secret_manager" | "encrypted_db" = "gcp_secret_manager", - // One live connection per provider per scope, so a case that leaves its row - // behind needs a provider of its own rather than colliding with an earlier - // test's (there is no per-test reset in this file). - provider = "helius" -): Promise { - const db = getDb(appEnv); - await db - .prepare( - `INSERT INTO provider_credentials ( - id, organization_id, project_id, provider, label, scope, source, - storage_backend, secret_ref, secret_version_ref, encrypted_secret_payload, - status, created_by - ) VALUES (?, ?, NULL, ?, 'Tenant credential', 'organization', 'stored', - ?, ?, ?, ?, 'active', ?)` - ) - .bind( - credentialId, - ORG_ID, - provider, - backend, - backend === "gcp_secret_manager" ? "projects/p/secrets/sdp-provider-credentials-x" : null, - backend === "gcp_secret_manager" ? SECRET_VERSION_REF : null, - backend === "encrypted_db" ? "v2.stored-ciphertext" : null, - USER_ID - ) - .run(); - - const connections = new RpcConnectionStore(db); - await connections.insertConnection({ - id: connectionId, - organizationId: ORG_ID, - projectId: null, - provider, - providerCredentialId: credentialId, - providerCredentialScopeKey: "__organization__", - network: "devnet", - displayMetadata: { endpointHost: "127.0.0.1", apiKeySuffix: "1234" }, - createdBy: USER_ID, - }); - await connections.activateConnection({ - organizationId: ORG_ID, - connectionId, - scopeKeys: ["__organization__"], - makeDefault: false, - }); -} - -beforeAll(async () => { - await seedTestDatabase(appEnv as Parameters[0]); - - originalEncryptionKey = appEnv.CUSTODY_ENCRYPTION_KEY; - appEnv.CUSTODY_ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64"); - - vi.spyOn(credentialSecretStore, "createCredentialSecretStore").mockReturnValue({ - storageBackend: "gcp_secret_manager", - write: vi.fn(), - read: vi.fn(), - destroyVersion, - } as unknown as credentialSecretStore.CredentialSecretStore); - - const db = getDb(appEnv); - await db - .prepare( - `INSERT INTO organizations (id, name, slug, tier, status) - VALUES (?, 'RPC Deactivation', 'rpc-deactivation', 'enterprise', 'active')` - ) - .bind(ORG_ID) - .run(); - await db - .prepare( - `INSERT INTO users (id, email, email_verified, status) - VALUES (?, 'rpc-deactivation@example.com', 1, 'active')` - ) - .bind(USER_ID) - .run(); -}); - -afterAll(() => { - appEnv.CUSTODY_ENCRYPTION_KEY = originalEncryptionKey; - vi.restoreAllMocks(); -}); - -describe("deactivateRpcConnection", () => { - it("destroys the stored secret version so a withdrawn credential stops existing", async () => { - await seedActiveConnection(CONNECTION_ID, CREDENTIAL_ID); - - const result = await deactivateRpcConnection(serviceContext(), CONNECTION_ID); - - expect(result.status).toBe("deactivated"); - expect(destroyVersion).toHaveBeenCalledWith({ secretVersionRef: SECRET_VERSION_REF }); - }); - - it("marks the provider credential deactivated alongside the connection", async () => { - const connectionId = `${CONNECTION_ID}_cred`; - const credentialId = `${CREDENTIAL_ID}_cred`; - await seedActiveConnection(connectionId, credentialId); - - const result = await deactivateRpcConnection(serviceContext(), connectionId); - - expect(result.providerCredential.status).toBe("deactivated"); - const row = await getDb(appEnv) - .prepare(`SELECT status FROM provider_credentials WHERE id = ?`) - .bind(credentialId) - .first<{ status: string }>(); - expect(row?.status).toBe("deactivated"); - }); - - it("clears the stored ciphertext for an encrypted_db credential", async () => { - const connectionId = `${CONNECTION_ID}_db`; - const credentialId = `${CREDENTIAL_ID}_db`; - await seedActiveConnection(connectionId, credentialId, "encrypted_db"); - destroyVersion.mockClear(); - - const result = await deactivateRpcConnection(serviceContext(), connectionId); - - expect(result.status).toBe("deactivated"); - expect(destroyVersion).not.toHaveBeenCalled(); - const row = await getDb(appEnv) - .prepare(`SELECT status, encrypted_secret_payload FROM provider_credentials WHERE id = ?`) - .bind(credentialId) - .first<{ status: string; encrypted_secret_payload: string | null }>(); - expect(row?.status).toBe("deactivated"); - expect(row?.encrypted_secret_payload).toBeNull(); - }); - - it("still deactivates and leaves durable cleanup work when destroying the secret version fails", async () => { - const connectionId = `${CONNECTION_ID}_orphan`; - const credentialId = `${CREDENTIAL_ID}_orphan`; - await seedActiveConnection(connectionId, credentialId); - // Rejected for every attempt, not just the first: the destroy retries, so - // a single blip is absorbed rather than left for the sweeper. What is - // pinned here is the outage that outlasts the request. - destroyVersion.mockRejectedValue(new Error("gcp unavailable")); - const logError = vi.spyOn(getLogger(), "error"); - - const result = await deactivateRpcConnection(serviceContext(), connectionId); - destroyVersion.mockReset(); - destroyVersion.mockResolvedValue(undefined); - - expect(result.status).toBe("deactivated"); - const row = await getDb(appEnv) - .prepare(`SELECT status FROM provider_credentials WHERE id = ?`) - .bind(credentialId) - .first<{ status: string }>(); - expect(row?.status).toBe("deactivated"); - - // A failed destroy is no longer only a log line: the version the commit - // orphaned stays queued, so the retirement sweeper collects what this - // request could not. The log is the alert, the row is the guarantee. - const queued = await getDb(appEnv) - .prepare( - `SELECT secret_version_ref FROM secret_retirements - WHERE secret_version_ref = ?` - ) - .bind(SECRET_VERSION_REF) - .first<{ secret_version_ref: string }>(); - expect(queued?.secret_version_ref).toBe(SECRET_VERSION_REF); - expect(logError).toHaveBeenCalledWith( - expect.objectContaining({ queuedForRetry: true }), - "credential_secret_orphan_risk" - ); - }); - - it("rolls back the connection flip when the credential cannot be deactivated", async () => { - const connectionId = `${CONNECTION_ID}_torn`; - const credentialId = `${CREDENTIAL_ID}_torn`; - await seedActiveConnection(connectionId, credentialId); - await getDb(appEnv) - .prepare( - `UPDATE provider_credentials - SET status = 'deactivated', deactivated_at = sdp_iso_now() - WHERE id = ?` - ) - .bind(credentialId) - .run(); - - await expect(deactivateRpcConnection(serviceContext(), connectionId)).rejects.toThrow(); - - const row = await getDb(appEnv) - .prepare(`SELECT status FROM rpc_connections WHERE id = ?`) - .bind(connectionId) - .first<{ status: string }>(); - expect(row?.status).toBe("active"); - }); - - it("refuses a second deactivation", async () => { - const connectionId = `${CONNECTION_ID}_repeat`; - // Its own provider: earlier cases in this file leave a live helius row. - await seedActiveConnection( - connectionId, - `${CREDENTIAL_ID}_repeat`, - "gcp_secret_manager", - "triton" - ); - - await deactivateRpcConnection(serviceContext(), connectionId); - - await expect(deactivateRpcConnection(serviceContext(), connectionId)).rejects.toThrow( - /already deactivated/i - ); - }); -}); diff --git a/apps/sdp-api/src/services/rpc-connection-lookup.ts b/apps/sdp-api/src/services/rpc-connection-lookup.ts deleted file mode 100644 index c5c4b22160..0000000000 --- a/apps/sdp-api/src/services/rpc-connection-lookup.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { - type ByokRpcProvider, - buildTenantRpcTarget, - isByokRpcProvider, - maskTenantEndpoint, -} from "@sdp/rpc/byok"; -import type { - RpcCredentialMode, - TenantRpcConnectionLookup, - TenantRpcConnectionResolution, -} from "@sdp/rpc/relay"; -import type { RpcConnectionNetwork } from "@sdp/types"; -import type { DatabaseExecutor } from "@/db"; -import { - type CredentialSecretStorageBackend, - createCredentialSecretStore, -} from "@/services/credential-secret-store"; -import { RpcConnectionStore } from "@/services/stores/rpc-connection.store"; -import type { Env } from "@/types/env"; - -function isRpcConnectionNetwork(value: string): value is RpcConnectionNetwork { - return value === "devnet" || value === "mainnet-beta"; -} - -/** - * Supplies the relay with tenant connections (HOO-1093). - * - * This is the seam that keeps `@sdp/rpc` free of the secret store: the package - * declares the port, and the resolved value it receives is already a built - * target with a masked label. Secrets are read per request and never cached, - * so a rotation takes effect on the next call without anything to invalidate. - */ -export function createTenantRpcConnectionLookup( - env: Env, - db: DatabaseExecutor -): TenantRpcConnectionLookup { - const store = new RpcConnectionStore(db); - - return { - async credentialMode(organizationId): Promise { - // Unknown organization reads as `managed`: this decides whether to fail - // a request closed, and a missing row is not evidence that somebody - // asked to be on their own keys. - const row = await db.queryOne<{ rpc_credential_mode: string }>( - `SELECT rpc_credential_mode FROM organizations WHERE id = ?`, - [organizationId] - ); - return row?.rpc_credential_mode === "byok" ? "byok" : "managed"; - }, - - async resolve({ organizationId, scopeKey, network }): Promise { - if (!isRpcConnectionNetwork(network)) { - return { kind: "none" }; - } - - const effective = await store.findEffectiveConnection({ - organizationId, - scopeKey, - network, - }); - - if (!effective) { - // Nothing live. Distinguish "never configured" from "configured but - // broken": only the second may fail the request closed. - const state = await store.findScopeConnectionState({ organizationId, scopeKey, network }); - return state.kind === "unusable" - ? { kind: "unusable", reason: "no active default connection" } - : { kind: "none" }; - } - - const { connection, credential } = effective; - if (!isByokRpcProvider(connection.provider)) { - return { kind: "unusable", reason: "unsupported provider" }; - } - - const secretStore = createCredentialSecretStore( - env, - credential.storage_backend as CredentialSecretStorageBackend - ); - - let payload: Record; - try { - payload = await secretStore.read({ - orgId: organizationId, - stored: { - storageBackend: credential.storage_backend as CredentialSecretStorageBackend, - secretRef: credential.secret_ref ?? undefined, - secretVersionRef: credential.secret_version_ref ?? undefined, - encryptedSecretPayload: credential.encrypted_secret_payload ?? undefined, - }, - }); - } catch { - // The reason is deliberately coarse: a secret-store error message can - // name refs, and this string reaches the caller. - return { kind: "unusable", reason: "credential unavailable" }; - } - - const apiKey = String(payload.apiKey ?? ""); - const endpointUrl = String(payload.endpointUrl ?? ""); - if (!apiKey || !endpointUrl) { - return { kind: "unusable", reason: "credential incomplete" }; - } - - const target = buildTenantRpcTarget(connection.provider as ByokRpcProvider, { - endpointUrl, - apiKey, - }); - - return { - kind: "active", - connectionId: connection.id, - providerId: connection.provider as ByokRpcProvider, - endpoint: target.endpoint, - // The platform's maskEndpoint only knows operator env keys, so the - // tenant's own key is masked here before the label leaves this module. - endpointLabel: maskTenantEndpoint(target.endpoint, apiKey), - headers: target.headers, - }; - }, - }; -} diff --git a/apps/sdp-api/src/services/rpc-connection-precedence.test.ts b/apps/sdp-api/src/services/rpc-connection-precedence.test.ts deleted file mode 100644 index 6ddb26cb5a..0000000000 --- a/apps/sdp-api/src/services/rpc-connection-precedence.test.ts +++ /dev/null @@ -1,304 +0,0 @@ -import type { - ResolveRpcTargetInput, - TenantRpcConnectionLookup, - TenantRpcConnectionResolution, -} from "@sdp/rpc/relay"; -import { - recordRpcRelayTelemetry, - resolveRoundRobinRpcTargets, - resolveRpcTarget, -} from "@sdp/rpc/relay"; -import { describe, expect, it, vi } from "vitest"; - -/** - * Precedence for HOO-1093, exercised without a database on purpose: the tenant - * branch must resolve before any platform-managed lookup, so a stub that throws - * on contact is how "did it fall through?" is answered honestly. - */ -const FELL_THROUGH = "FELL_THROUGH_TO_PLATFORM"; - -const db = { - prepare() { - throw new Error(FELL_THROUGH); - }, -} as unknown as ResolveRpcTargetInput["db"]; - -const kv = { - cache: { - get: async () => null, - put: async () => undefined, - delete: async () => undefined, - list: async () => ({ keys: [] }), - }, -} as unknown as ResolveRpcTargetInput["kv"]; - -function activeConnection( - connectionId: string, - providerId: "helius" | "alchemy" = "helius" -): TenantRpcConnectionResolution { - return { - kind: "active", - connectionId, - providerId, - endpoint: `https://tenant.example/${connectionId}?api-key=secret`, - endpointLabel: `https://tenant.example/${connectionId}?api-key=***`, - headers: {}, - }; -} - -function lookupReturning( - byScope: Record -): TenantRpcConnectionLookup { - return { - resolve: vi.fn(async ({ scopeKey }) => byScope[scopeKey] ?? { kind: "none" }), - }; -} - -function resolve( - connections: TenantRpcConnectionLookup, - options: { authProjectId?: string | null } = {} -) { - return resolveRpcTarget({ - env: { SOLANA_NETWORK: "devnet", SOLANA_RPC_URL: "https://platform.example" }, - kv, - db, - organizationId: "org_1", - authProjectId: options.authProjectId ?? null, - requestedProjectId: null, - connections, - }); -} - -describe("tenant RPC connection precedence", () => { - it("resolves the project connection and never consults the organization", async () => { - const connections = lookupReturning({ - prj_1: activeConnection("rconn_project"), - __organization__: activeConnection("rconn_org"), - }); - const target = await resolve(connections, { authProjectId: "prj_1" }); - - expect(target.selectionMode).toBe("project_connection"); - expect(target.connectionId).toBe("rconn_project"); - // A live project connection short-circuits, so the organization scope is - // not even read. - const scopeKeys = (connections.resolve as ReturnType).mock.calls.map( - (call) => call[0].scopeKey - ); - expect(scopeKeys).toEqual(["prj_1"]); - }); - - it("refuses to route when the connection is still organization-scoped", async () => { - // HOO-1226 moved connections onto projects. The rows made before that are - // no longer a rail, and answering on platform keys instead would be the - // silent downgrade the tenant is paying their own provider to avoid. - await expect( - resolve(lookupReturning({ __organization__: activeConnection("rconn_org") }), { - authProjectId: "prj_1", - }) - ).rejects.toThrow(/no longer used/i); - }); - - it("never carries the tenant key in the label it exposes", async () => { - const target = await resolve(lookupReturning({ prj_1: activeConnection("rconn") }), { - authProjectId: "prj_1", - }); - - expect(target.endpointLabel).not.toContain("secret"); - expect(target.endpointLabel).toContain("***"); - }); - - it("fails closed on an unusable project connection instead of spending platform keys", async () => { - await expect( - resolve( - lookupReturning({ - prj_1: { kind: "unusable", reason: "no active default connection" }, - __organization__: activeConnection("rconn_org"), - }), - { authProjectId: "prj_1" } - ) - // Critically it does not silently use the organization connection either. - ).rejects.toThrow(/project.*not active/i); - }); - - it("fails closed on a stranded organization connection that is also broken", async () => { - // Broken or working, it is stranded either way: the point is that neither - // state quietly resolves to a platform provider. - await expect( - resolve( - lookupReturning({ - __organization__: { kind: "unusable", reason: "credential unavailable" }, - }) - ) - ).rejects.toThrow(/no longer used/i); - }); - - it("falls through to platform selection when no connection is configured", async () => { - // The db stub throws on first contact, which is the proof that resolution - // continued past the tenant branch rather than stopping at it. - await expect(resolve(lookupReturning({}))).rejects.toThrow(FELL_THROUGH); - }); - - it("refuses to answer on platform keys for an organization that is byok", async () => { - // The organization has said its RPC leaves on its own credentials. Reaching - // a platform provider here would be SDP paying for, and seeing, traffic - // somebody deliberately moved off us. - const connections = { - ...lookupReturning({}), - credentialMode: async () => "byok" as const, - }; - - await expect(resolve(connections, { authProjectId: "prj_1" })).rejects.toThrow( - /runs RPC on its own credentials/i - ); - }); - - it("still falls through for an organization left on managed", async () => { - const connections = { - ...lookupReturning({}), - credentialMode: async () => "managed" as const, - }; - - await expect(resolve(connections, { authProjectId: "prj_1" })).rejects.toThrow(FELL_THROUGH); - }); - - it("scopes every lookup to the caller's organization", async () => { - const connections = lookupReturning({ prj_1: activeConnection("rconn_project") }); - await resolve(connections, { authProjectId: "prj_1" }); - - for (const call of (connections.resolve as ReturnType).mock.calls) { - expect(call[0].organizationId).toBe("org_1"); - expect(["prj_1", "__organization__"]).toContain(call[0].scopeKey); - } - }); - - it("re-reads the connection on every request so a rotation takes effect", async () => { - const connections = lookupReturning({ prj_1: activeConnection("rconn_v1") }); - await resolve(connections, { authProjectId: "prj_1" }); - await resolve(connections, { authProjectId: "prj_1" }); - - // No caching layer to invalidate: two requests, two reads. - expect((connections.resolve as ReturnType).mock.calls).toHaveLength(2); - }); - - it("ignores tenant connections entirely when no lookup is injected", async () => { - await expect( - resolveRpcTarget({ - env: { SOLANA_NETWORK: "devnet", SOLANA_RPC_URL: "https://platform.example" }, - kv, - db, - organizationId: "org_1", - authProjectId: null, - requestedProjectId: null, - }) - ).rejects.toThrow(FELL_THROUGH); - }); - - it("routes the faucet path through a tenant connection too", async () => { - // The airdrop branch resolves separately; leaving it on managed providers - // let an organization on its own key still spend platform credentials. - const targets = await resolveRoundRobinRpcTargets({ - env: { SOLANA_NETWORK: "devnet", SOLANA_RPC_URL: "https://platform.example" }, - kv, - db, - organizationId: "org_1", - authProjectId: "prj_1", - requestedProjectId: null, - connections: lookupReturning({ prj_1: activeConnection("rconn_project") }), - }); - - expect(targets).toHaveLength(1); - expect(targets[0].selectionMode).toBe("project_connection"); - expect(targets[0].connectionId).toBe("rconn_project"); - }); - - it("fails the faucet path closed on an unusable connection", async () => { - await expect( - resolveRoundRobinRpcTargets({ - env: { SOLANA_NETWORK: "devnet", SOLANA_RPC_URL: "https://platform.example" }, - kv, - db, - organizationId: "org_1", - authProjectId: "prj_1", - requestedProjectId: null, - connections: lookupReturning({ - prj_1: { kind: "unusable", reason: "no active default connection" }, - }), - }) - ).rejects.toThrow(/not active/i); - }); - - it("still round-robins platform providers when no connection is configured", async () => { - await expect( - resolveRoundRobinRpcTargets({ - env: { SOLANA_NETWORK: "devnet", SOLANA_RPC_URL: "https://platform.example" }, - kv, - db, - organizationId: "org_1", - authProjectId: null, - requestedProjectId: null, - connections: lookupReturning({}), - }) - ).rejects.toThrow(FELL_THROUGH); - }); -}); - -/** - * Telemetry buckets. - * - * A tenant connection resolves to the vendor's own id, so keying counters on - * `providerId` alone mixed an organization's BYOK traffic into the platform's - * bucket for that vendor, and the provider list reported requests SDP never - * served as its own. Separation is asserted through the keys actually written. - */ -describe("relay telemetry keys", () => { - function recordingCache() { - const writes: string[] = []; - const cache = { - get: async () => null, - put: async (key: string) => { - writes.push(key); - }, - delete: async () => undefined, - list: async () => ({ keys: [] }), - } as unknown as ResolveRpcTargetInput["kv"]["cache"]; - return { cache, writes }; - } - - const telemetry = { - methodNames: ["getVersion"], - statusCode: 200, - latencyMs: 12, - ok: true, - origin: null, - }; - - it("keeps a tenant connection out of the platform provider's bucket", async () => { - const { cache, writes } = recordingCache(); - - await recordRpcRelayTelemetry(cache, { - ...telemetry, - providerId: "helius", - connectionId: "rconn_1", - }); - await recordRpcRelayTelemetry(cache, { ...telemetry, providerId: "helius" }); - - expect(writes).toEqual(["rpc:relay:stats:tenant:rconn_1", "rpc:relay:stats:helius"]); - }); - - it("gives two organizations on the same vendor separate buckets", async () => { - const { cache, writes } = recordingCache(); - - await recordRpcRelayTelemetry(cache, { - ...telemetry, - providerId: "helius", - connectionId: "rconn_org_a", - }); - await recordRpcRelayTelemetry(cache, { - ...telemetry, - providerId: "helius", - connectionId: "rconn_org_b", - }); - - expect(new Set(writes).size).toBe(2); - }); -}); diff --git a/apps/sdp-api/src/services/rpc-connection-retirement.test.ts b/apps/sdp-api/src/services/rpc-connection-retirement.test.ts deleted file mode 100644 index e1e8871083..0000000000 --- a/apps/sdp-api/src/services/rpc-connection-retirement.test.ts +++ /dev/null @@ -1,497 +0,0 @@ -// Durable retirement of BYOK RPC credential secret versions. -// -// The create path writes the tenant's key to the secret store BEFORE the rows -// that reference it, and deactivation is terminal — so both ends of the -// lifecycle can orphan a GCP secret version. These tests prove the orphan can -// no longer be lost: the obligation is queued before the referencing write, -// cancelled by the commit, refreshed by a failed destroy, and drained by the -// existing retirement sweeper (cleanup recovery). - -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { getDb } from "@/db"; -import { createSecretRetirementsRepository } from "@/db/repositories"; -import { getLogger } from "@/runtime/logger"; -import { retireOrphanedSecrets } from "@/services/jobs/retire-orphaned-secrets"; -import { - deactivateRpcConnection, - rotateRpcConnection, - submitRpcConnection, -} from "@/services/rpc-connection.service"; -import { clearQueuedSecretVersion, queuePendingSecretVersion } from "@/services/secret-retirement"; -import { ProviderCredentialStore } from "@/services/stores/provider-credential.store"; -import { RpcConnectionStore } from "@/services/stores/rpc-connection.store"; -import { env } from "@/test/helpers/env"; -import { seedDefaultProjects } from "@/test/helpers/projects"; -import { seedTestDatabase } from "@/test/mocks/db"; -import type { Env } from "@/types/env"; - -const gcpMock = vi.hoisted(() => ({ - destroyVersion: vi.fn<(input: { secretVersionRef: string }) => Promise>(), -})); - -const retirementQueueControl = vi.hoisted(() => ({ failRecordRetirement: false })); - -// The durable queue is a real table in these tests; this wrapper only exists -// so one test can make the obligation insert fail and prove the create fails -// closed instead of proceeding into the unprotected window. -vi.mock("@/db/repositories", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - createSecretRetirementsRepository: ( - ...args: Parameters - ) => { - const repository = actual.createSecretRetirementsRepository(...args); - return { - ...repository, - recordRetirement: async (input: Parameters[0]) => { - if (retirementQueueControl.failRecordRetirement) { - throw new Error("retirement queue unavailable"); - } - return repository.recordRetirement(input); - }, - }; - }, - }; -}); - -// A stand-in GCP store: the test environment has no Secret Manager, and what -// is under test is the bookkeeping around the destroy, not the destroy itself. -vi.mock("@/services/credential-secret-store", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - createCredentialSecretStore: () => ({ - storageBackend: "gcp_secret_manager" as const, - write: async (input: { providerCredentialId: string }) => ({ - storageBackend: "gcp_secret_manager" as const, - secretRef: `projects/sdp-test/secrets/${input.providerCredentialId}`, - secretVersionRef: `projects/sdp-test/secrets/${input.providerCredentialId}/versions/1`, - }), - read: async () => ({}), - destroyVersion: gcpMock.destroyVersion, - predictFirstVersionRef: (input: { providerCredentialId: string }) => - `projects/sdp-test/secrets/${input.providerCredentialId}/versions/1`, - }), - }; -}); - -// Saving probes the tenant endpoint before it writes anything (HOO-1228). -// What is under test here is the fate of the secret version around that save, -// so the provider answers healthily for the project's cluster; the probe's own -// behaviour is covered by the `rpc-byok-*` suites. -vi.mock("@/services/rpc-probe", async (importOriginal) => { - const actual = await importOriginal(); - const { SOLANA_GENESIS_HASHES } = await import("@sdp/rpc/byok"); - return { - ...actual, - probeRpcEndpoint: async () => ({ - upstream: { ok: true, status: 200 }, - upstreamBody: { result: SOLANA_GENESIS_HASHES.devnet }, - }), - }; -}); - -const ORG_ID = "org_rpc_retirement"; -const USER_ID = "usr_rpc_retirement"; -/** - * A project per saving test. A project holds one connection per provider - * (HOO-1227) and the check runs before the secret is written, so tests sharing - * a project would be refused before reaching the code under test. - */ -const PROJECT_COMMITTED = "prj_rpc_retirement_committed"; -const PROJECT_DEACTIVATE = "prj_rpc_retirement_deactivate"; -const appEnv = env as unknown as Env; - -function serviceContext(projectId: string = PROJECT_DEACTIVATE) { - const values: Record = { - clerk: { - userId: USER_ID, - organizationId: ORG_ID, - role: "admin", - permissions: ["org:read", "org:write", "org:admin"], - }, - projectId, - }; - return { - env: appEnv, - get: (key: string) => values[key], - } as unknown as Parameters[0]; -} - -function submitInput(label: string) { - return { - provider: "helius" as const, - scope: "project" as const, - credentialLabel: label, - endpointUrl: "https://devnet.helius-rpc.com", - apiKey: "tenant-key-retirement", - }; -} - -async function retirementRows(refLike: string): Promise> { - return getDb(appEnv).queryMany<{ secret_version_ref: string }>( - `SELECT secret_version_ref FROM secret_retirements - WHERE secret_version_ref LIKE ?`, - [refLike] - ); -} - -async function seedGcpConnection(suffix: string): Promise<{ - connectionId: string; - versionRef: string; -}> { - const db = getDb(appEnv); - const credentialId = `pcred_retire_${suffix}`; - const connectionId = `rconn_retire_${suffix}`; - const versionRef = `projects/sdp-test/secrets/${credentialId}/versions/1`; - await db - .prepare( - `INSERT INTO provider_credentials ( - id, organization_id, project_id, provider, label, scope, source, - storage_backend, secret_ref, secret_version_ref, status, created_by - ) VALUES (?, ?, NULL, 'helius', 'Retirement fixture', 'organization', 'stored', - 'gcp_secret_manager', ?, ?, 'active', ?)` - ) - .bind(credentialId, ORG_ID, `projects/sdp-test/secrets/${credentialId}`, versionRef, USER_ID) - .run(); - await new RpcConnectionStore(db).insertConnection({ - id: connectionId, - organizationId: ORG_ID, - projectId: null, - provider: "helius", - providerCredentialId: credentialId, - providerCredentialScopeKey: "__organization__", - network: "devnet", - displayMetadata: { endpointHost: "devnet.helius-rpc.com", apiKeySuffix: "1234" }, - createdBy: USER_ID, - }); - return { connectionId, versionRef }; -} - -beforeAll(async () => { - await seedTestDatabase(env as Parameters[0]); - const db = getDb(appEnv); - await db - .prepare( - `INSERT INTO organizations (id, name, slug, tier, status) - VALUES (?, 'RPC Retirement', 'rpc-retirement', 'enterprise', 'active')` - ) - .bind(ORG_ID) - .run(); - await db - .prepare( - `INSERT INTO users (id, email, email_verified, status) - VALUES (?, 'rpc-retirement@example.com', 1, 'active')` - ) - .bind(USER_ID) - .run(); - await seedDefaultProjects(db, { - organizationId: ORG_ID, - createdBy: USER_ID, - members: [], - ids: { sandbox: PROJECT_COMMITTED, production: PROJECT_DEACTIVATE }, - }); -}); - -afterAll(async () => { - await seedTestDatabase(env as Parameters[0]); -}); - -beforeEach(async () => { - retirementQueueControl.failRecordRetirement = false; - gcpMock.destroyVersion.mockReset(); - gcpMock.destroyVersion.mockResolvedValue(undefined); - await getDb(appEnv).execute(`DELETE FROM secret_retirements WHERE secret_version_ref LIKE ?`, [ - "projects/sdp-test/secrets/pcred_%", - ]); -}); - -describe("BYOK RPC secret retirement", () => { - it("clears the pre-commit obligation when submission commits", async () => { - const connection = await submitRpcConnection( - serviceContext(PROJECT_COMMITTED), - submitInput("Committed") - ); - - // Saving proves the key and puts it straight into service (HOO-1228). - expect(connection.status).toBe("active"); - // The rows reference the version, so nothing may destroy it — the - // provisional obligation must have been cancelled by the same commit. - expect(await retirementRows("projects/sdp-test/secrets/pcred_%")).toEqual([]); - expect(gcpMock.destroyVersion).not.toHaveBeenCalled(); - await getDb(appEnv).execute(`DELETE FROM rpc_connections WHERE project_id = ?`, [ - PROJECT_COMMITTED, - ]); - await getDb(appEnv).execute(`DELETE FROM provider_credentials WHERE project_id = ?`, [ - PROJECT_COMMITTED, - ]); - }); - - it("withholds a provisional obligation from the sweeper while the create is in flight", async () => { - // The provisional row is committed BEFORE the transaction that references - // its version, so for a moment the queue names a version that is about to - // go live. A sweep landing in that window must not act on it: destroying - // it would leave the committing transaction pointing at nothing, and the - // tenant's connection installed dead. - const stored = { - storageBackend: "gcp_secret_manager" as const, - secretRef: "projects/sdp-test/secrets/pcred_inflight", - secretVersionRef: "projects/sdp-test/secrets/pcred_inflight/versions/1", - }; - - await queuePendingSecretVersion(appEnv, stored, { - provider: "rpc_connection", - orgId: ORG_ID, - sourceId: "pcred_inflight", - }); - - // On record, so worker loss still cannot lose the version... - const [row] = await getDb(appEnv).queryMany<{ next_attempt_at: string }>( - `SELECT next_attempt_at FROM secret_retirements - WHERE secret_version_ref = ?`, - [stored.secretVersionRef] - ); - expect(row).toBeDefined(); - // ...but not yet due to anyone. - expect(new Date(row.next_attempt_at).getTime()).toBeGreaterThan(Date.now()); - - await retireOrphanedSecrets(appEnv); - - expect(gcpMock.destroyVersion).not.toHaveBeenCalled(); - expect(await retirementRows(stored.secretVersionRef)).toHaveLength(1); - }); - - it("aborts the committing transaction if the obligation was swept first", async () => { - // The grace period makes a sweep during the in-flight window unlikely, but - // nothing bounds how long the caller's transaction takes, so it cannot be - // the guarantee. Clearing is a compare-and-swap: if the obligation is gone - // the version is gone with it, and the rows must not commit pointing at a - // destroyed secret. - const stored = { - storageBackend: "gcp_secret_manager" as const, - secretRef: "projects/sdp-test/secrets/pcred_swept", - secretVersionRef: "projects/sdp-test/secrets/pcred_swept/versions/1", - }; - - // Nothing on record — the state a sweep leaves behind. - await expect(clearQueuedSecretVersion(getDb(appEnv), stored)).rejects.toThrow( - /retired while this request was still running/i - ); - - // With the obligation standing, the same call is the ordinary cancel. - await queuePendingSecretVersion(appEnv, stored, { - provider: "rpc_connection", - orgId: ORG_ID, - sourceId: "pcred_swept", - }); - await expect(clearQueuedSecretVersion(getDb(appEnv), stored)).resolves.toBeUndefined(); - expect(await retirementRows(stored.secretVersionRef)).toEqual([]); - }); - - it("keeps a sweeper and a committing transaction from both acting on one version", async () => { - // Destroying cannot join the transaction that cancels the obligation, so - // the row is the token that decides who acts. Both orderings must resolve - // to exactly one winner. - const repo = createSecretRetirementsRepository(appEnv); - const lease = new Date(Date.now() + 60_000).toISOString(); - - async function queuedRow(secretVersionRef: string) { - const [row] = await getDb(appEnv).queryMany<{ id: string; attempt_count: number }>( - `SELECT id, attempt_count FROM secret_retirements - WHERE secret_version_ref = ?`, - [secretVersionRef] - ); - return row; - } - - // Sweeper first: it is now committed to destroying, so the create must not - // commit rows that would point at the version. - const swept = { - storageBackend: "gcp_secret_manager" as const, - secretRef: "projects/sdp-test/secrets/pcred_claimed", - secretVersionRef: "projects/sdp-test/secrets/pcred_claimed/versions/1", - }; - await queuePendingSecretVersion(appEnv, swept, { - provider: "rpc_connection", - orgId: ORG_ID, - sourceId: "pcred_claimed", - }); - const sweptRow = await queuedRow(swept.secretVersionRef); - expect( - await repo.claimRetirement({ - id: sweptRow.id, - expectedAttemptCount: sweptRow.attempt_count, - nextAttemptAt: lease, - }) - ).toBe(true); - await expect(clearQueuedSecretVersion(getDb(appEnv), swept)).rejects.toThrow( - /retired while this request was still running/i - ); - - // Transaction first: the sweeper is holding a listing that is now stale, - // and must not go on to destroy a version the commit made live. - const kept = { - storageBackend: "gcp_secret_manager" as const, - secretRef: "projects/sdp-test/secrets/pcred_cancelled", - secretVersionRef: "projects/sdp-test/secrets/pcred_cancelled/versions/1", - }; - await queuePendingSecretVersion(appEnv, kept, { - provider: "rpc_connection", - orgId: ORG_ID, - sourceId: "pcred_cancelled", - }); - const keptRow = await queuedRow(kept.secretVersionRef); - await expect(clearQueuedSecretVersion(getDb(appEnv), kept)).resolves.toBeUndefined(); - expect( - await repo.claimRetirement({ - id: keptRow.id, - expectedAttemptCount: keptRow.attempt_count, - nextAttemptAt: lease, - }) - ).toBe(false); - }); - - it("refuses the create BEFORE anything external exists when the obligation cannot be reserved", async () => { - retirementQueueControl.failRecordRetirement = true; - - await expect( - submitRpcConnection(serviceContext(PROJECT_COMMITTED), submitInput("Unrecordable")) - ).rejects.toThrow(/durably reserved/i); - - // Fail closed with a clean slate: the obligation is reserved before the - // write, so a refused reservation means no external version was ever - // created — nothing to destroy, nothing that can leak. - expect(gcpMock.destroyVersion).not.toHaveBeenCalled(); - const credentials = await getDb(appEnv).queryMany<{ id: string }>( - `SELECT id FROM provider_credentials WHERE organization_id = ? AND label = 'Unrecordable'`, - [ORG_ID] - ); - expect(credentials).toEqual([]); - }); - - it("cannot reach the leak case through the create path: an unwritable queue stops the write", async () => { - // Before the pre-write reservation this was the terminal orphan: queue - // unwritable AND destroy failing left a readable version with only a log. - // The ordering closes it — with the queue down the external write never - // happens, so there is no version to destroy and no orphan to admit. - retirementQueueControl.failRecordRetirement = true; - gcpMock.destroyVersion.mockRejectedValue(new Error("secret manager unavailable")); - const logError = vi.spyOn(getLogger(), "error"); - - await expect( - submitRpcConnection(serviceContext(PROJECT_COMMITTED), submitInput("Leaked")) - ).rejects.toThrow(/durably reserved/i); - - expect(gcpMock.destroyVersion).not.toHaveBeenCalled(); - expect(logError).not.toHaveBeenCalledWith( - expect.objectContaining({ reason: "secret_cleanup_failed" }), - "credential_secret_orphan_risk" - ); - const credentials = await getDb(appEnv).queryMany<{ id: string }>( - `SELECT id FROM provider_credentials WHERE organization_id = ? AND label = 'Leaked'`, - [ORG_ID] - ); - expect(credentials).toEqual([]); - logError.mockRestore(); - }); - - it("refuses a rotation before writing anything when the obligation cannot be reserved", async () => { - // A rotation writes a brand-new secret under a fresh credential id, so its - // version is as predictable as a create's and gets the same pre-write - // reservation. Without it, a rotation was the one remaining way to reach - // the terminal orphan through this service. - const { connectionId } = await seedGcpConnection("rotate_unrecordable"); - const credentialsBefore = await getDb(appEnv).queryMany<{ id: string }>( - `SELECT id FROM provider_credentials WHERE organization_id = ?`, - [ORG_ID] - ); - - retirementQueueControl.failRecordRetirement = true; - gcpMock.destroyVersion.mockRejectedValue(new Error("secret manager unavailable")); - - try { - await expect( - rotateRpcConnection(serviceContext(), connectionId, { - endpointUrl: "https://devnet.helius-rpc.com", - apiKey: "rotated-key-retirement", - }) - ).rejects.toThrow(/durably reserved/i); - - // Clean slate: no external version was written, so there is nothing to - // destroy and nothing that could leak... - expect(gcpMock.destroyVersion).not.toHaveBeenCalled(); - // ...and the connection still holds exactly the credential it had. - const credentialsAfter = await getDb(appEnv).queryMany<{ id: string }>( - `SELECT id FROM provider_credentials WHERE organization_id = ?`, - [ORG_ID] - ); - expect(credentialsAfter).toHaveLength(credentialsBefore.length); - } finally { - // This connection stays live (the rotation was refused), and only one - // live connection per provider is allowed — so it has to go before the - // next test seeds its own. - await getDb(appEnv).execute(`DELETE FROM rpc_connections WHERE id = ?`, [connectionId]); - await getDb(appEnv).execute(`DELETE FROM provider_credentials WHERE id = ?`, [ - "pcred_retire_rotate_unrecordable", - ]); - } - }); - - it("keeps a durable obligation when the transaction fails and the destroy also fails", async () => { - gcpMock.destroyVersion.mockRejectedValue(new Error("secret manager unavailable")); - - // A rejected credential insert stands in for any mid-transaction failure - // after the secret write landed — the window the pre-commit obligation - // exists to cover. - const insertCredential = vi - .spyOn(ProviderCredentialStore.prototype, "insertCredential") - .mockRejectedValue(new Error("credential insert failed")); - - try { - await expect( - submitRpcConnection(serviceContext(PROJECT_COMMITTED), submitInput("Doomed")) - ).rejects.toThrow(); - } finally { - insertCredential.mockRestore(); - } - - const rows = await retirementRows("projects/sdp-test/secrets/pcred_%"); - expect(rows).toHaveLength(1); - - // Cleanup recovery: the sweeper destroys what the request could not. - gcpMock.destroyVersion.mockResolvedValue(undefined); - const swept = await retireOrphanedSecrets(appEnv); - expect(swept.retired).toBeGreaterThanOrEqual(1); - expect(gcpMock.destroyVersion).toHaveBeenCalledWith({ - secretVersionRef: rows[0].secret_version_ref, - }); - expect(await retirementRows("projects/sdp-test/secrets/pcred_%")).toEqual([]); - }); - - it("destroys the stored version durably on deactivation", async () => { - const { connectionId, versionRef } = await seedGcpConnection("deact_ok"); - - const deactivated = await deactivateRpcConnection(serviceContext(), connectionId); - - expect(deactivated.status).toBe("deactivated"); - expect(gcpMock.destroyVersion).toHaveBeenCalledWith({ secretVersionRef: versionRef }); - // Destroyed immediately, so the obligation queued by the deactivating - // transaction has been discharged. - expect(await retirementRows(versionRef)).toEqual([]); - }); - - it("leaves the obligation queued when the deactivation destroy fails, until the sweeper collects it", async () => { - const { connectionId, versionRef } = await seedGcpConnection("deact_fail"); - gcpMock.destroyVersion.mockRejectedValue(new Error("secret manager unavailable")); - - const deactivated = await deactivateRpcConnection(serviceContext(), connectionId); - expect(deactivated.status).toBe("deactivated"); - expect(await retirementRows(versionRef)).toHaveLength(1); - - gcpMock.destroyVersion.mockResolvedValue(undefined); - const swept = await retireOrphanedSecrets(appEnv); - expect(swept.retired).toBeGreaterThanOrEqual(1); - expect(await retirementRows(versionRef)).toEqual([]); - }); -}); diff --git a/apps/sdp-api/src/services/rpc-connection-serving-provider.test.ts b/apps/sdp-api/src/services/rpc-connection-serving-provider.test.ts deleted file mode 100644 index 4c7ec0fd54..0000000000 --- a/apps/sdp-api/src/services/rpc-connection-serving-provider.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { getDb } from "@/db"; -import { RpcConnectionStore } from "@/services/stores/rpc-connection.store"; -import { env } from "@/test/helpers/env"; -import { seedDefaultProjects } from "@/test/helpers/projects"; -import { seedTestDatabase } from "@/test/mocks/db"; - -/** - * Switching a project onto a provider has two possible meanings, and the store - * has to be able to tell them apart: the project holds a key for it, so that - * key takes over, or it does not, so nothing tenant-owned may keep serving. - * - * Choosing a provider used to write only the organization's setting, which the - * relay reaches last. A tenant connection kept answering, so "Use this - * provider" reported success and changed nothing observable. - */ -const ORGANIZATION_ID = "org_rpc_serving"; -const PROJECT_ID = "prj_rpc_serving"; -const USER_ID = "usr_rpc_serving"; - -async function seedScope(): Promise { - const db = getDb(env); - await db.batch([ - db - .prepare( - `INSERT INTO organizations (id, name, slug, tier, status) - VALUES (?, 'RPC serving', ?, 'individual', 'active')` - ) - .bind(ORGANIZATION_ID, "rpc-serving"), - db - .prepare( - `INSERT INTO users (id, email, name, status) - VALUES (?, 'rpc-serving@example.com', 'RPC serving', 'active')` - ) - .bind(USER_ID), - ]); - await seedDefaultProjects(db, { - organizationId: ORGANIZATION_ID, - createdBy: USER_ID, - members: [], - ids: { sandbox: PROJECT_ID, production: `${PROJECT_ID}_production` }, - }); -} - -async function seedConnection(options: { - connectionId: string; - credentialId: string; - provider: string; - status?: string; - isDefault?: boolean; -}): Promise { - const db = getDb(env); - const status = options.status ?? "active"; - await db - .prepare( - `INSERT INTO provider_credentials ( - id, organization_id, project_id, provider, label, scope, source, - storage_backend, encrypted_secret_payload, status, created_by - ) VALUES (?, ?, ?, ?, ?, 'project', 'stored', - 'encrypted_db', 'secret', 'active', ?)` - ) - .bind( - options.credentialId, - ORGANIZATION_ID, - PROJECT_ID, - options.provider, - `Tenant ${options.provider}`, - USER_ID - ) - .run(); - - await db - .prepare( - `INSERT INTO rpc_connections ( - id, organization_id, project_id, provider, scope, - provider_credential_id, provider_credential_scope_key, - network, status, is_default, activated_at, deactivated_at, created_by - ) VALUES (?, ?, ?, ?, 'project', ?, ?, - 'devnet', ?, ?, ?, ?, ?)` - ) - .bind( - options.connectionId, - ORGANIZATION_ID, - PROJECT_ID, - options.provider, - options.credentialId, - PROJECT_ID, - status, - options.isDefault ?? false, - status === "deactivated" ? "2026-08-16T12:00:00.000Z" : "2026-08-16T12:00:00.000Z", - status === "deactivated" ? "2026-08-16T12:30:00.000Z" : null, - USER_ID - ) - .run(); -} - -async function servingProvider(): Promise { - const row = await getDb(env) - .prepare( - `SELECT provider FROM rpc_connections - WHERE organization_id = ? AND scope_key = ? AND network = 'devnet' - AND is_default = TRUE AND status = 'active'` - ) - .bind(ORGANIZATION_ID, PROJECT_ID) - .first<{ provider: string }>(); - return row?.provider ?? null; -} - -describe("switching which provider serves a project", () => { - beforeEach(async () => { - await seedTestDatabase(env); - await seedScope(); - }); - - it("finds the project's own key for the provider being switched to", async () => { - await seedConnection({ - connectionId: "rconn_serving_alchemy", - credentialId: "pcred_serving_alchemy", - provider: "alchemy", - isDefault: true, - }); - await seedConnection({ - connectionId: "rconn_serving_triton", - credentialId: "pcred_serving_triton", - provider: "triton", - }); - - const store = new RpcConnectionStore(getDb(env)); - const found = await store.findLiveConnectionForProvider({ - organizationId: ORGANIZATION_ID, - scopeKey: PROJECT_ID, - network: "devnet", - provider: "triton", - }); - - // Narrowed to the provider asked about. A lookup that answered with any - // connection the project held is what let one page name two providers. - expect(found?.id).toBe("rconn_serving_triton"); - }); - - it("does not offer a withdrawn key as something to switch onto", async () => { - // Deactivation destroys the secret, so promoting one would report success - // and route nothing. - await seedConnection({ - connectionId: "rconn_serving_gone", - credentialId: "pcred_serving_gone", - provider: "quicknode", - status: "deactivated", - }); - - const store = new RpcConnectionStore(getDb(env)); - const found = await store.findLiveConnectionForProvider({ - organizationId: ORGANIZATION_ID, - scopeKey: PROJECT_ID, - network: "devnet", - provider: "quicknode", - }); - - expect(found).toBeNull(); - }); - - it("answers for a provider the project holds nothing for", async () => { - await seedConnection({ - connectionId: "rconn_serving_only", - credentialId: "pcred_serving_only", - provider: "alchemy", - isDefault: true, - }); - - const store = new RpcConnectionStore(getDb(env)); - const found = await store.findLiveConnectionForProvider({ - organizationId: ORGANIZATION_ID, - scopeKey: PROJECT_ID, - network: "devnet", - provider: "helius", - }); - - expect(found).toBeNull(); - }); - - it("stands the tenant credential down so SDP's account can answer", async () => { - // The half that had no route at all. Without it, choosing a provider the - // project holds no key for left the old key serving and the switch was - // invisible -- and deactivating instead would have destroyed a working - // secret to change a routing decision. - await seedConnection({ - connectionId: "rconn_serving_incumbent", - credentialId: "pcred_serving_incumbent", - provider: "alchemy", - isDefault: true, - }); - expect(await servingProvider()).toBe("alchemy"); - - const store = new RpcConnectionStore(getDb(env)); - await store.clearDefault({ - organizationId: ORGANIZATION_ID, - scopeKey: PROJECT_ID, - network: "devnet", - }); - - expect(await servingProvider()).toBeNull(); - // Stood down, not withdrawn: the key survives and can serve again. - const kept = await store.findLiveConnectionForProvider({ - organizationId: ORGANIZATION_ID, - scopeKey: PROJECT_ID, - network: "devnet", - provider: "alchemy", - }); - expect(kept?.id).toBe("rconn_serving_incumbent"); - }); - - it("refuses a second live connection for the same provider", async () => { - // The pre-check in submitRpcConnection is an unlocked read, so two - // concurrent saves for one provider both see nothing and both proceed. - // The default-slot index only catches that when both try to serve; with - // another provider already serving neither claims the slot, and before - // this index both rows persisted (HOO-1317). - await seedConnection({ - connectionId: "rconn_dupe_first", - credentialId: "pcred_dupe_first", - provider: "alchemy", - isDefault: true, - }); - - await expect( - seedConnection({ - connectionId: "rconn_dupe_second", - credentialId: "pcred_dupe_second", - provider: "alchemy", - isDefault: false, - }) - ).rejects.toThrow(/rpc_connections_one_live_per_provider/); - }); - - it("still allows a withdrawn key and a live one on the same provider", async () => { - // Deactivated rows are excluded so history keeps its place, which is what - // makes re-adding after a deactivation possible at all. - await seedConnection({ - connectionId: "rconn_dupe_gone", - credentialId: "pcred_dupe_gone", - provider: "triton", - status: "deactivated", - }); - - await expect( - seedConnection({ - connectionId: "rconn_dupe_new", - credentialId: "pcred_dupe_new", - provider: "triton", - isDefault: true, - }) - ).resolves.toBeUndefined(); - }); - - it("leaves a different provider on the same project alone", async () => { - await seedConnection({ - connectionId: "rconn_dupe_a", - credentialId: "pcred_dupe_a", - provider: "alchemy", - isDefault: true, - }); - - await expect( - seedConnection({ - connectionId: "rconn_dupe_b", - credentialId: "pcred_dupe_b", - provider: "quicknode", - isDefault: false, - }) - ).resolves.toBeUndefined(); - }); -}); diff --git a/apps/sdp-api/src/services/rpc-connection.service.ts b/apps/sdp-api/src/services/rpc-connection.service.ts deleted file mode 100644 index 965ce29e41..0000000000 --- a/apps/sdp-api/src/services/rpc-connection.service.ts +++ /dev/null @@ -1,1098 +0,0 @@ -import { - assertReachableTenantEndpoint, - type ByokRpcProvider, - buildTenantDisplayMetadata, - buildTenantRpcTarget, - resolveTenantEndpoint, - SOLANA_GENESIS_HASHES, - type TenantRpcCredential, -} from "@sdp/rpc/byok"; -import type { - ProjectEnvironment, - RpcConnectionNetwork, - RpcConnectionTestResult, - SafeRpcConnection, -} from "@sdp/types"; -import type { Context } from "hono"; -import { getDb } from "@/db"; -import { parsePostgresJsonOr } from "@/db/postgres-utils"; -import { createSecretRetirementsRepository } from "@/db/repositories"; -import { getAuth } from "@/lib/auth"; -import { badRequest, conflict, forbidden, internalError, notFound } from "@/lib/errors"; -import { - type CredentialSecretStorageBackend, - createCredentialSecretStore, - type StoredCredentialSecret, -} from "@/services/credential-secret-store"; -import { probeRpcEndpoint, toRedactedFailureCode } from "@/services/rpc-probe"; -import { - clearQueuedSecretVersion, - destroySecretVersion, - queueOrphanedSecretVersion, - queuePendingSecretVersion, - reserveSecretVersionIntent, -} from "@/services/secret-retirement"; -import { ProviderCredentialStore } from "@/services/stores/provider-credential.store"; -import { - ORGANIZATION_SCOPE_KEY, - type RpcConnectionListRow, - type RpcConnectionRow, - RpcConnectionStore, -} from "@/services/stores/rpc-connection.store"; -import type { Env } from "@/types/env"; - -type AppContext = Context<{ Bindings: Env }>; - -export interface SubmitRpcConnectionInput { - provider: ByokRpcProvider; - /** - * Project-only since HOO-1226. Listing still accepts `organization` so rows - * created before the cutover stay visible, but nothing new lands there. - */ - scope: "project"; - credentialLabel: string; - /** Omitted for providers whose endpoint is the same for every account. */ - endpointUrl?: string; - apiKey: string; -} - -/** - * The mapper is the redaction boundary. It reads only the columns - * `SafeRpcConnection` declares, so a secret ref added to the row type later - * cannot ride out through here by accident. - */ -export function mapRpcConnection(row: RpcConnectionListRow): SafeRpcConnection { - return { - id: row.id, - provider: row.provider as SafeRpcConnection["provider"], - scope: row.scope, - projectId: row.project_id, - network: row.network, - status: row.status, - isDefault: row.is_default, - displayMetadata: parsePostgresJsonOr>(row.display_metadata, {}), - createdAt: row.created_at, - activatedAt: row.activated_at, - deactivatedAt: row.deactivated_at, - providerCredential: { - id: row.credential_id, - label: row.credential_label, - status: row.credential_status, - }, - }; -} - -export type RpcCredentialMode = "managed" | "byok"; - -/** - * Whose credentials this organization's RPC leaves on. - * - * `byok` is the Privy-shaped position: the organization is entirely on its own - * keys, so a project without a live connection fails rather than quietly - * spending SDP's. Reading it is separate from setting it because the relay - * needs it on every request and the dashboard only on a settings page. - */ -export async function getRpcCredentialMode( - c: AppContext -): Promise<{ mode: RpcCredentialMode; liveConnections: number }> { - const auth = getAuth(c); - - // The count comes back with the mode because the two only mean something - // together: `byok` with nothing live is an organization whose RPC is failing, - // and the dashboard has to be able to say so. Neither read depends on the - // other, so they go together rather than one after it. - const [row, liveConnections] = await Promise.all([ - getDb(c.env) - .prepare(`SELECT rpc_credential_mode FROM organizations WHERE id = ?`) - .bind(auth.organizationId) - .first<{ rpc_credential_mode: string }>(), - new RpcConnectionStore(getDb(c.env)).countLiveConnectionsForOrganization({ - organizationId: auth.organizationId, - }), - ]); - - return { - mode: row?.rpc_credential_mode === "byok" ? "byok" : "managed", - liveConnections, - }; -} - -/** - * Switching to `byok` is a fail-closed promise, so it is refused while the - * organization has nothing to fail closed onto: turning it on with no live - * connection anywhere would break every project at once, which is never what - * somebody means by the toggle. - */ -export async function setRpcCredentialMode( - c: AppContext, - mode: RpcCredentialMode -): Promise<{ mode: RpcCredentialMode }> { - const auth = getAuth(c); - requireUserId(c); - - if (mode === "byok") { - const live = await new RpcConnectionStore(getDb(c.env)).countLiveConnectionsForOrganization({ - organizationId: auth.organizationId, - }); - if (live === 0) { - throw conflict( - "Add a working RPC connection before moving this organization onto its own credentials" - ); - } - } - - await getDb(c.env) - .prepare( - `UPDATE organizations SET rpc_credential_mode = ?, updated_at = sdp_datetime_now() WHERE id = ?` - ) - .bind(mode, auth.organizationId) - .run(); - - return { mode }; -} - -/** - * Make one provider the thing that answers this project, whatever that takes. - * - * Choosing a provider and choosing whose credentials serve it used to be two - * separate controls, and a tenant connection always outranked the platform - * selection. So "Use this provider" wrote a setting the relay would not reach - * and nothing observable changed: the page reported a different provider, - * `/v1/rpc/test` answered from the old one, and the button read as broken. - * - * One action now covers both halves: - * - * - the project holds a key for this provider, so that key takes over; - * - it does not, so nothing tenant-owned serves and SDP's account answers. - * - * The caller still writes the organization's selection. This decides only which - * credential the project routes through, which is the half that was unreachable. - */ -export async function setServingRpcProvider( - c: AppContext, - provider: string -): Promise<{ servingProvider: string | null; usesOwnCredential: boolean }> { - const auth = getAuth(c); - requireUserId(c); - const { projectId, scopeKey } = resolveScope(c, "project"); - if (!projectId) { - throw badRequest("Selecting an RPC provider requires a selected project"); - } - const network = await resolveProjectNetwork(c, projectId); - - const store = new RpcConnectionStore(getDb(c.env)); - const own = await store.findLiveConnectionForProvider({ - organizationId: auth.organizationId, - scopeKey, - network, - provider, - }); - - if (own) { - // Probes and promotes in one transaction, exactly as the row control did. - // Reusing it keeps a switch from ever pointing the project at a key that - // has stopped working since it was stored. - await activateRpcConnection(c, own.id, { makeDefault: true }); - return { servingProvider: provider, usesOwnCredential: true }; - } - - // Nothing of the tenant's own for this provider. Standing down whatever is - // serving is the entire point of the switch, so it is not optional -- but on - // an organization that promised to run only on its own credentials it would - // stop RPC rather than fall back, so that is refused instead of silently - // stranding the project. - const { mode } = await getRpcCredentialMode(c); - if (mode === "byok") { - throw conflict( - "This organization runs entirely on its own credentials. Add a key for this provider before switching to it." - ); - } - - await store.clearDefault({ organizationId: auth.organizationId, scopeKey, network }); - return { servingProvider: null, usesOwnCredential: false }; -} - -/** - * The network is the project's, not a choice the form offers (HOO-1221). - * - * A sandbox project is devnet and a production project is mainnet, so picking - * one separately only ever created the chance to disagree with the project the - * connection hangs off. Deriving it is safe because a provider key is the same - * on both networks -- only the URL differs -- so nothing about the credential - * depends on which one is chosen. - */ -async function resolveProjectNetwork( - c: AppContext, - projectId: string -): Promise { - const project = await getDb(c.env) - .prepare(`SELECT environment FROM projects WHERE id = ? AND organization_id = ?`) - .bind(projectId, getAuth(c).organizationId) - .first<{ environment: ProjectEnvironment }>(); - - if (!project) { - throw notFound("Project"); - } - - return project.environment === "production" ? "mainnet-beta" : "devnet"; -} - -function resolveScope( - c: AppContext, - scope: "organization" | "project" -): { projectId: string | null; scopeKey: string } { - if (scope === "organization") { - return { projectId: null, scopeKey: ORGANIZATION_SCOPE_KEY }; - } - - const projectId = c.get("projectId"); - if (!projectId) { - throw badRequest("A project-scoped RPC connection requires a selected project"); - } - return { projectId, scopeKey: projectId }; -} - -/** - * The scopes this request may act on: the organization plus whichever project - * is selected, never another project's. The selected project is already - * membership-checked by middleware, so anchoring to it is what stops one - * project's administrator naming another project's connection by id. - * - * The organization key stays in the set on purpose. These routes gate on - * `org:admin`, which is organization-wide rather than per-project, so a - * project context is an additional grant and not a restriction — and - * `projectContextMiddleware` requires `x-project-id` on every request, so - * narrowing to the project alone would make organization-scoped connections, - * which is what POST /connections creates by default, impossible to activate, - * deactivate or make default. - */ -function actingScopeKeys(c: AppContext): string[] { - const projectId = c.get("projectId"); - return projectId ? [ORGANIZATION_SCOPE_KEY, projectId] : [ORGANIZATION_SCOPE_KEY]; -} - -function requireUserId(c: AppContext): string { - const auth = getAuth(c); - const userId = auth.userId; - if (!userId) { - // The route middleware already refuses API keys; this is the type-level - // half of the same rule. - throw forbidden("RPC connection management requires a signed-in administrator"); - } - return userId; -} - -export async function listRpcConnections( - c: AppContext, - options: { limit: number; offset: number; scope: "organization" | "project" } -) { - const auth = getAuth(c); - const { scopeKey } = resolveScope(c, options.scope); - const store = new RpcConnectionStore(getDb(c.env)); - const { connections, total } = await store.listConnectionsPage(auth.organizationId, scopeKey, { - limit: options.limit, - offset: options.offset, - }); - - return { - connections: connections.map(mapRpcConnection), - pagination: { limit: options.limit, offset: options.offset, total }, - }; -} - -/** - * Create a tenant-owned connection. - * - * Ordering is the whole point: the secret is written first, then the credential - * and connection rows go in together inside one transaction. A failed secret - * write leaves no rows at all, and a failed transaction destroys the secret - * version it already wrote, so neither half can outlive the other. - */ -export async function submitRpcConnection( - c: AppContext, - input: SubmitRpcConnectionInput -): Promise { - const auth = getAuth(c); - const userId = requireUserId(c); - const { projectId, scopeKey } = resolveScope(c, input.scope); - if (!projectId) { - // `resolveScope` already throws for a project scope with no project; this - // is the type-level half of the same rule. - throw badRequest("An RPC connection requires a selected project"); - } - - const network = await resolveProjectNetwork(c, projectId); - - // A project holds one connection per provider, and exactly one of them - // serves. That is what the partial unique index has always modelled -- - // many rows, one `is_default AND active` -- so lifting HOO-1227's - // single-connection rule is this check rather than a migration. - // - // A second key on the *same* provider is still a rotation: two Alchemy - // credentials on one project have no way to be told apart in the UI and - // no meaning in the relay, which reads the default. - const store = new RpcConnectionStore(getDb(c.env)); - const sameProvider = await store.countLiveConnections({ - organizationId: auth.organizationId, - scopeKey, - network, - provider: input.provider, - }); - if (sameProvider > 0) { - throw conflict( - "This project already has a connection for this provider. Rotate its key to replace it." - ); - } - - // Adding a key must not move traffic off whatever is already serving. - // Switching is a deliberate act on the connection, so a new one goes in - // proven and idle unless the project has nothing serving it. - const serving = await store.findScopeConnectionState({ - organizationId: auth.organizationId, - scopeKey, - network, - }); - const shouldServe = serving.kind !== "active"; - - const credential: TenantRpcCredential = { - // A tenant only types an endpoint when their account has its own; for the - // rest the provider's published host is used. - endpointUrl: resolveTenantEndpoint(input.provider, network, input.endpointUrl), - apiKey: input.apiKey, - }; - - // Reject an endpoint we cannot build a target from, or must never reach, - // before anything is written -- no secret stored for a connection that can - // never run, and no row that would point the relay at a private address. - assertReachableTenantEndpoint(credential.endpointUrl); - const target = buildTenantRpcTarget(input.provider, credential); - - // Saving runs the check (HOO-1228). A key that does not work never becomes a - // row, so there is no draft state to explain and nothing to activate - // afterwards: what gets saved is already known to serve traffic. The network - // goes in so an endpoint on the wrong cluster is refused here rather than - // recorded under a network it does not serve. - const probe = await runConnectionProbe(target, network); - if (!probe.ok) { - throw conflict("The RPC provider rejected this connection", { - failureCode: probe.failureCode, - }); - } - - const providerCredentialId = `pcred_${crypto.randomUUID()}`; - const connectionId = `rconn_${crypto.randomUUID()}`; - - const secretStore = createCredentialSecretStore(c.env); - const retirementContext = { - provider: "rpc_connection", - orgId: auth.organizationId, - sourceId: providerCredentialId, - }; - // The obligation is recorded BEFORE the write: the coming version ref is - // known up front, so a durable row exists before anything external does and - // no crash can leave a readable key without a record the sweeper drains. If - // it cannot be recorded, the request is refused while the backend still - // holds nothing. - const predictedVersionRef = secretStore.predictFirstVersionRef({ providerCredentialId }); - if (predictedVersionRef) { - await reserveSecretVersionIntent( - c.env, - { - storageBackend: secretStore.storageBackend, - secretRef: null, - secretVersionRef: predictedVersionRef, - }, - retirementContext - ); - } - const stored = await secretStore.write({ - orgId: auth.organizationId, - provider: input.provider, - providerCredentialId, - payload: { endpointUrl: credential.endpointUrl, apiKey: input.apiKey }, - }); - if (stored.secretVersionRef !== predictedVersionRef) { - await queuePendingSecretVersion(c.env, stored, retirementContext); - if (predictedVersionRef) { - await createSecretRetirementsRepository(c.env) - .deleteRetirementByVersionRef(predictedVersionRef) - .catch(() => undefined); - } - } - - const db = getDb(c.env); - try { - return await db.transaction(async (tx) => { - const credentialStore = new ProviderCredentialStore(tx); - const connectionStore = new RpcConnectionStore(tx); - - const providerCredential = await credentialStore.insertCredential({ - id: providerCredentialId, - organizationId: auth.organizationId, - projectId, - provider: input.provider, - label: input.credentialLabel, - scope: input.scope, - source: "stored", - stored, - displayMetadata: buildTenantDisplayMetadata(credential), - version: 1, - rotatedFromId: null, - idempotencyKey: connectionId, - idempotencyFingerprint: connectionId, - createdBy: userId, - }); - - const connection = await connectionStore.insertConnection({ - id: connectionId, - organizationId: auth.organizationId, - projectId, - provider: input.provider, - providerCredentialId, - providerCredentialScopeKey: providerCredential.scope_key, - network, - displayMetadata: buildTenantDisplayMetadata(credential), - createdBy: userId, - executor: tx, - }); - - // The probe above is the evidence, so the pair goes live here rather - // than waiting for a separate activation. Both rows have to agree or the - // relay's effective lookup reads healthy and still routes to SDP, which - // is why this shares the insert's transaction. - // - // Only the connection that is taking over clears the incumbent. A key - // added alongside a serving one is active and idle: proven, ready to be - // switched to, routing nothing until someone asks for it. - if (shouldServe) { - await connectionStore.clearDefault({ - organizationId: auth.organizationId, - scopeKey, - network, - exceptConnectionId: connectionId, - executor: tx, - }); - } - await connectionStore.activateConnectionCredential({ - organizationId: auth.organizationId, - connectionId, - scopeKeys: [scopeKey], - executor: tx, - }); - const activated = await connectionStore.activateConnection({ - organizationId: auth.organizationId, - connectionId, - scopeKeys: [scopeKey], - makeDefault: shouldServe, - executor: tx, - }); - - // The rows now reference the version; the same commit cancels the - // provisional retirement recorded before this transaction started. - await clearQueuedSecretVersion(tx, stored); - - return mapRpcConnection({ - ...(activated ?? connection), - scope_key: scopeKey, - credential_id: providerCredential.id, - credential_label: providerCredential.label, - credential_status: "active", - }); - }); - } catch (error) { - // The transaction failed, so nothing references the version: destroy it - // now, and let `destroySecretVersion` fall back to the durable retirement - // queue when the destroy itself fails — a silently swallowed failure here - // left the tenant's key readable in the backend with nothing that would - // ever try again. (The pre-commit queue row above already covers the case - // where this process dies before reaching this block.) - await destroySecretVersion(c.env, stored, retirementContext); - - // The serving check above is a read, so two saves racing each other both - // see nothing serving and both try to become the default. The partial - // unique index rejects the loser, and without this it would surface as an - // unhandled database error rather than something the caller can act on. - // Asked before the default check, which accepts a bare 23505 and would - // otherwise claim this was a race for the serving slot. - if (isProviderConflict(error)) { - throw conflict( - "This project already has a connection for this provider. Rotate its key to replace it." - ); - } - if (isDefaultConflict(error)) { - throw conflict( - "Another connection started serving this project at the same time. Add this one again, then switch to it." - ); - } - throw error; - } -} - -/** - * One probe, one shape. - * - * Saving, activating and the on-demand test all ask the same question, and all - * three must answer it without letting an upstream body through: the status is - * reduced to a redacted code here so no caller can be handed a provider's own - * words about a key. - */ -async function runConnectionProbe( - target: ReturnType, - network?: RpcConnectionNetwork -): Promise { - try { - // The endpoint came from the tenant, so it resolves under the egress - // guard: the host passed the literal check when it was submitted, and this - // is what stops the name resolving somewhere internal now. - // - // `getGenesisHash` rather than `getVersion`: it proves reachability just - // as well and also says which cluster answered. Every cluster answers - // `getVersion` alike, so a mainnet endpoint passed on a sandbox project - // and the row recorded `devnet` over it. - const { upstream, upstreamBody } = await probeRpcEndpoint(target, { - enforcePublicEgress: true, - method: network ? "getGenesisHash" : "getVersion", - }); - if (!upstream.ok) { - return { ok: false, failureCode: toRedactedFailureCode(upstream.status) }; - } - if (!network) { - return { ok: true, failureCode: null }; - } - - const genesisHash = (upstreamBody as { result?: unknown } | null)?.result; - if (typeof genesisHash !== "string") { - // A 200 that carries no genesis hash is not this cluster answering, so - // it cannot stand in for one. - return { ok: false, failureCode: "provider_unreachable" }; - } - return genesisHash === SOLANA_GENESIS_HASHES[network] - ? { ok: true, failureCode: null } - : { ok: false, failureCode: "network_mismatch" }; - } catch { - return { ok: false, failureCode: "provider_unreachable" }; - } -} - -/** - * Read the stored secret for a connection and rebuild the target the relay - * would use. Shared by activation and the on-demand test so neither can drift - * into probing something the relay would not. - */ -async function loadConnectionTarget(c: AppContext, connectionId: string) { - const { auth, store, connection, scopeKeys } = await loadConnectionWithSecret(c, connectionId); - - const credential = await store.findConnectionSecret({ - organizationId: auth.organizationId, - connectionId, - scopeKeys, - }); - if (!credential) { - throw notFound("Provider credential"); - } - - const storageBackend = credential.storage_backend as CredentialSecretStorageBackend; - const payload = await createCredentialSecretStore(c.env, storageBackend).read({ - orgId: auth.organizationId, - stored: { - storageBackend, - secretRef: credential.secret_ref ?? undefined, - secretVersionRef: credential.secret_version_ref ?? undefined, - encryptedSecretPayload: credential.encrypted_secret_payload ?? undefined, - }, - }); - - const target = buildTenantRpcTarget(connection.provider as ByokRpcProvider, { - endpointUrl: String(payload.endpointUrl ?? ""), - apiKey: String(payload.apiKey ?? ""), - }); - - return { auth, store, connection, scopeKeys, credential, target }; -} - -/** - * Check a stored connection on demand (HOO-1228). - * - * Nothing is written. Zach asked for "a subtle connection test afterwards - * that's on trigger", and a test that quietly changed the connection's - * lifecycle would be neither subtle nor a test. - */ -export async function testRpcConnection( - c: AppContext, - connectionId: string -): Promise { - const { connection, target } = await loadConnectionTarget(c, connectionId); - return runConnectionProbe(target, connection.network); -} - -async function loadConnectionWithSecret(c: AppContext, connectionId: string) { - const auth = getAuth(c); - const store = new RpcConnectionStore(getDb(c.env)); - const scopeKeys = actingScopeKeys(c); - const connection = await store.findConnection(auth.organizationId, connectionId, scopeKeys); - if (!connection) { - throw notFound("RPC connection"); - } - return { auth, store, connection, scopeKeys }; -} - -/** - * Activation probes the tenant's own endpoint before the relay is allowed to - * depend on it. A failed probe marks the connection unusable rather than - * silently falling back to platform keys. - * - * Saving now activates on its own (HOO-1228), so this is the recovery path: a - * connection that failed its check later, and is being tried again. - */ -export async function activateRpcConnection( - c: AppContext, - connectionId: string, - options: { makeDefault: boolean } -): Promise { - // Checked before the secret is read: deactivation destroys it, so loading - // the target first would surface a missing-secret error instead of the - // reason it is missing. - const existing = await loadConnectionWithSecret(c, connectionId); - if (existing.connection.status === "deactivated") { - throw conflict("A deactivated RPC connection cannot be reactivated; create a new one"); - } - - const { auth, store, connection, scopeKeys, credential, target } = await loadConnectionTarget( - c, - connectionId - ); - - const probe = await runConnectionProbe(target, connection.network); - if (!probe.ok) { - // Qualified by the credential that was probed. A rotation can commit while - // this probe is in flight, and writing the old verdict onto the connection - // would fail a project closed over a key that has already been replaced. - await store.markCheckFailed({ - organizationId: auth.organizationId, - connectionId, - providerCredentialId: credential.id, - scopeKeys, - }); - throw conflict("The RPC provider rejected this connection", { - failureCode: probe.failureCode, - }); - } - - const db = getDb(c.env); - let activated: RpcConnectionRow | null; - try { - activated = await db.transaction(async (tx) => { - const txStore = new RpcConnectionStore(tx); - if (options.makeDefault) { - await txStore.clearDefault({ - organizationId: auth.organizationId, - scopeKey: connection.scope_key, - network: connection.network, - exceptConnectionId: connectionId, - executor: tx, - }); - } - // The probe above is the evidence the key works, so the credential is - // promoted out of `pending` here rather than anywhere else. It has to - // share the transaction with the connection update: the relay's - // effective-connection lookup requires both rows to agree, so a - // half-applied activation would read healthy and still route to SDP. - const promoted = await txStore.activateConnectionCredential({ - organizationId: auth.organizationId, - connectionId, - scopeKeys, - executor: tx, - }); - if (promoted === 0) { - // Only a deactivated or retired credential reaches here. Activating the - // connection anyway would produce the healthy-looking row that never - // resolves, so fail loudly instead of leaving the two rows disagreeing. - throw conflict("The credential behind this RPC connection is no longer usable"); - } - - return txStore.activateConnection({ - organizationId: auth.organizationId, - connectionId, - scopeKeys, - makeDefault: options.makeDefault, - executor: tx, - }); - }); - } catch (error) { - // Two administrators activating different defaults for the same scope and - // network can both clear the previous one before either commits; the - // partial unique index then rejects the loser. That is a conflict the - // caller can retry, not an internal error. - if (isDefaultConflict(error)) { - throw conflict("Another connection was made the default at the same time"); - } - throw error; - } - - if (!activated) { - throw conflict("The RPC connection changed while it was being activated"); - } - - // `credential` was read before the transaction, so its status is the - // pre-promotion one. The transaction committed and refuses to commit without - // promoting, so reporting `active` here is the state on disk, not a guess. - return toSafeWithCredential(activated, { ...credential, status: "active" }); -} - -export interface RotateRpcConnectionInput { - /** Omitted for providers whose endpoint is the same for every account. */ - endpointUrl?: string; - apiKey: string; -} - -/** - * Swap the key behind a connection without ever leaving the project dark - * (HOO-1229). - * - * The old way was deactivate, add, activate: three steps, and the middle one - * destroyed the working key before the replacement had been proven. Here the - * new key is probed first and the old credential is only retired once the - * replacement is committed, so the project is on one working key or the other - * throughout. - */ -export async function rotateRpcConnection( - c: AppContext, - connectionId: string, - input: RotateRpcConnectionInput -): Promise { - const auth = getAuth(c); - const userId = requireUserId(c); - const { store, connection, scopeKeys } = await loadConnectionWithSecret(c, connectionId); - - if (connection.status === "deactivated") { - throw conflict("A deactivated RPC connection cannot be rotated; create a new one"); - } - - const previous = await store.findConnectionSecret({ - organizationId: auth.organizationId, - connectionId, - scopeKeys, - }); - if (!previous) { - throw notFound("Provider credential"); - } - - const provider = connection.provider as ByokRpcProvider; - const candidate: TenantRpcCredential = { - endpointUrl: resolveTenantEndpoint(provider, connection.network, input.endpointUrl), - apiKey: input.apiKey, - }; - - assertReachableTenantEndpoint(candidate.endpointUrl); - const target = buildTenantRpcTarget(provider, candidate); - - const probe = await runConnectionProbe(target, connection.network); - if (!probe.ok) { - // Nothing is touched. The connection carries on with the key it had. - throw conflict("The RPC provider rejected the new key", { failureCode: probe.failureCode }); - } - - const nextCredentialId = `pcred_${crypto.randomUUID()}`; - const secretStore = createCredentialSecretStore(c.env); - const nextRetirementContext = { - provider: "rpc_connection", - orgId: auth.organizationId, - sourceId: nextCredentialId, - }; - const previousRetirementContext = { - provider: "rpc_connection", - orgId: auth.organizationId, - sourceId: previous.id, - }; - const previousStored = toStoredSecret(previous); - - // Reserved before the write, exactly as a create does. A rotation writes a - // BRAND-NEW secret — a fresh credential id, never `existingSecretRef` — so - // the version it will create is known up front and the obligation can exist - // before the version does. That is what keeps the terminal orphan (a readable - // key with no durable record) off this path too: without it, a rotation whose - // post-write queue AND destroy both failed left the tenant's new key behind - // with nothing to collect it. Refusing here costs nothing — the backend still - // holds nothing and the connection carries on with the key it had. - const predictedVersionRef = secretStore.predictFirstVersionRef({ - providerCredentialId: nextCredentialId, - }); - if (predictedVersionRef) { - await reserveSecretVersionIntent( - c.env, - { - storageBackend: secretStore.storageBackend, - secretRef: null, - secretVersionRef: predictedVersionRef, - }, - nextRetirementContext - ); - } - - const stored = await secretStore.write({ - orgId: auth.organizationId, - provider, - providerCredentialId: nextCredentialId, - payload: { endpointUrl: candidate.endpointUrl, apiKey: input.apiKey }, - }); - - // The prediction held for every backend that has versions to predict; if it - // did not, fall back to recording the version that was actually written and - // drop the reservation that named the wrong one. - if (stored.secretVersionRef !== predictedVersionRef) { - await queuePendingSecretVersion(c.env, stored, nextRetirementContext); - if (predictedVersionRef) { - await createSecretRetirementsRepository(c.env) - .deleteRetirementByVersionRef(predictedVersionRef) - .catch(() => undefined); - } - } - - let rotated: Awaited>; - try { - rotated = await getDb(c.env).transaction(async (tx) => { - const credentialStore = new ProviderCredentialStore(tx); - const txStore = new RpcConnectionStore(tx); - - const next = await credentialStore.insertCredential({ - id: nextCredentialId, - organizationId: auth.organizationId, - projectId: connection.project_id, - provider, - label: previous.label, - scope: connection.scope, - source: "stored", - stored, - displayMetadata: buildTenantDisplayMetadata(candidate), - version: (previous.credential_version ?? 1) + 1, - rotatedFromId: previous.id, - idempotencyKey: nextCredentialId, - idempotencyFingerprint: nextCredentialId, - createdBy: userId, - }); - - await txStore.activateCredentialById({ - organizationId: auth.organizationId, - providerCredentialId: nextCredentialId, - executor: tx, - }); - - const row = await txStore.repointConnectionCredential({ - organizationId: auth.organizationId, - connectionId, - scopeKeys, - // Compare-and-swap against what this rotation read, so a second - // rotation racing it loses rather than silently overwriting. - expectedCredentialId: previous.id, - nextCredentialId, - nextCredentialScopeKey: next.scope_key, - executor: tx, - }); - if (!row) { - throw conflict("The RPC connection changed while it was being rotated"); - } - - await txStore.retireCredential({ - organizationId: auth.organizationId, - providerCredentialId: previous.id, - executor: tx, - }); - - // This commit is what makes the incoming version live and the outgoing - // one garbage, so the two obligations flip together with it: the new - // version's provisional retirement is cancelled, and the old version's - // is recorded. Either the rotation is undone entirely or both are true. - await clearQueuedSecretVersion(tx, stored); - await queueOrphanedSecretVersion( - tx, - previousRetirementContext, - previousStored, - "orphaned by RPC connection rotation" - ); - - return row; - }); - } catch (error) { - // Nothing references the incoming version, so destroy it — and when that - // fails, leave durable work behind rather than swallowing it. - await destroySecretVersion(c.env, stored, nextRetirementContext); - throw error; - } - - // Committed, so the old key is no longer reachable through any row. Dropping - // the version is cleanup rather than part of the swap, and a failure here - // must not undo a rotation that already succeeded — it discharges the - // obligation the transaction recorded, or leaves it for the sweeper. - await destroySecretVersion(c.env, previousStored, previousRetirementContext); - - return toSafeWithCredential(rotated, { - id: nextCredentialId, - label: previous.label, - status: "active", - }); -} - -export async function deactivateRpcConnection( - c: AppContext, - connectionId: string -): Promise { - const { auth, store, scopeKeys } = await loadConnectionWithSecret(c, connectionId); - - const credential = await store.findConnectionSecret({ - organizationId: auth.organizationId, - connectionId, - scopeKeys, - }); - - const retirementContext = { - provider: "rpc_connection", - orgId: auth.organizationId, - sourceId: credential?.id ?? connectionId, - }; - const storedSecret = toStoredSecret(credential); - - // The connection and credential flips share one transaction: a crash - // between them would leave a deactivated connection whose retry 409s while - // the withdrawn credential silently stays active — the exact retention this - // endpoint exists to end. The Secret Manager destroy stays outside; it must - // not roll back the committed deactivation. - // - // Deactivation is terminal, so this commit is exactly what orphans the - // stored key: the obligation to destroy it is recorded in the same - // transaction, which is what makes worker loss survivable rather than a - // tenant key left readable in Secret Manager forever. - const deactivated = await getDb(c.env).transaction(async (tx) => { - const txStore = new RpcConnectionStore(tx); - const row = await txStore.deactivateConnection({ - organizationId: auth.organizationId, - connectionId, - scopeKeys, - executor: tx, - }); - if (!row) { - throw conflict("The RPC connection is already deactivated"); - } - const credentialFlips = await txStore.deactivateConnectionCredential({ - organizationId: auth.organizationId, - connectionId, - scopeKeys, - executor: tx, - }); - if (credentialFlips !== 1) { - throw internalError("The credential behind this RPC connection did not deactivate"); - } - await queueOrphanedSecretVersion( - tx, - retirementContext, - storedSecret, - "orphaned by RPC connection deactivation" - ); - return row; - }); - - // Destroy immediately and discharge the queued obligation; a failure leaves - // the queue row for the sweeper. - await destroySecretVersion(c.env, storedSecret, retirementContext); - - return toSafeWithCredential( - deactivated, - credential ? { ...credential, status: "deactivated" } : null - ); -} - -/** - * Clear a deactivated connection out of the list (HOO-1219). - * - * Deactivation is terminal and already destroyed the secret, so these rows - * could only accumulate: they cannot be reactivated and nothing routes through - * them. Deleting takes the credential with it, in one transaction, so a crash - * cannot leave a credential row pointing at a connection that is gone. - */ -export async function deleteRpcConnection(c: AppContext, connectionId: string): Promise { - const auth = getAuth(c); - const scopeKeys = actingScopeKeys(c); - - await getDb(c.env).transaction(async (tx) => { - const txStore = new RpcConnectionStore(tx); - const deleted = await txStore.deleteDeactivatedConnection({ - organizationId: auth.organizationId, - connectionId, - scopeKeys, - executor: tx, - }); - - if (!deleted) { - // Either it does not exist for this caller or it is still live. The - // second is the interesting one, and it is a conflict rather than a 404. - const store = new RpcConnectionStore(tx); - const existing = await store.findConnection(auth.organizationId, connectionId, scopeKeys); - if (existing) { - throw conflict("Deactivate this RPC connection before deleting it"); - } - throw notFound("RPC connection"); - } - - await txStore.deleteOrphanedCredential({ - organizationId: auth.organizationId, - providerCredentialId: deleted.provider_credential_id, - executor: tx, - }); - }); -} - -/** - * A credential row as the retirement queue understands it. Only GCP-backed - * rows carry a version that outlives the row; the rest keep their ciphertext - * inline and die with it, which `destroySecretVersion` treats as nothing to do. - */ -function toStoredSecret( - credential: Awaited> -): StoredCredentialSecret | null { - if (!credential) { - return null; - } - return { - storageBackend: credential.storage_backend as CredentialSecretStorageBackend, - secretRef: credential.secret_ref ?? undefined, - secretVersionRef: credential.secret_version_ref ?? undefined, - }; -} - -function violationMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -/** - * A second live key for the same provider, lost to the uniqueness index. - * - * The pre-check in `submitRpcConnection` is an unlocked read, so two concurrent - * saves for one provider both see nothing and both proceed. Only one can hold - * the index, and the loser has to be told the same thing the pre-check would - * have told it rather than a database error. - * - * Named-index match only. `isDefaultConflict` also accepts a bare `23505`, so - * this has to be asked first or a provider clash reports itself as a race for - * the serving slot, which is a different thing to do about it. - */ -function isProviderConflict(error: unknown): boolean { - return violationMessage(error).includes("rpc_connections_one_live_per_provider"); -} - -function isDefaultConflict(error: unknown): boolean { - const message = violationMessage(error); - return ( - message.includes("rpc_connections_one_default_per_scope_network") || message.includes("23505") - ); -} - -function toSafeWithCredential( - row: RpcConnectionRow, - credential: { id: string; label: string; status: string } | null -): SafeRpcConnection { - return mapRpcConnection({ - ...row, - credential_id: credential?.id ?? row.provider_credential_id, - credential_label: credential?.label ?? "", - credential_status: credential?.status ?? "unknown", - }); -} diff --git a/apps/sdp-api/src/services/rpc-custom-egress.test.ts b/apps/sdp-api/src/services/rpc-custom-egress.test.ts deleted file mode 100644 index 0e44d10545..0000000000 --- a/apps/sdp-api/src/services/rpc-custom-egress.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { createServer, type Server } from "node:http"; -import type { AddressInfo } from "node:net"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { EgressBlockedError } from "@/services/guarded-egress"; -import { checkResolvedRpcTargetConnection } from "@/services/provider-setup-registry"; - -/** - * `POST /v1/rpc/test` probes whatever endpoint the target resolved to, and for - * the `custom` provider that is `projects.settings.rpcEndpoint`, a URL a - * customer typed in and which is validated as a URL and nothing more. - * - * Both directions matter: a guard that refused everything would pass the first - * case here and take local development and the Surfpool suites down with it. - */ -let server: Server; -let origin: string; - -beforeAll(async () => { - server = createServer((_req, res) => { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ jsonrpc: "2.0", id: "rpc-connectivity-test", result: {} })); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; -}); - -afterAll(async () => { - await new Promise((resolve) => server.close(() => resolve())); -}); - -const base = { - projectId: null, - endpointLabel: "local", - headers: {}, - selectionMode: "organization_provider" as const, -}; - -describe("checkResolvedRpcTargetConnection", () => { - it("probes a managed provider at a private address", async () => { - const { upstream } = await checkResolvedRpcTargetConnection({ - target: { ...base, providerId: "helius", endpoint: origin }, - }); - - expect(upstream.status).toBe(200); - }); - - it("refuses a custom endpoint whose host resolves inward", async () => { - await expect( - checkResolvedRpcTargetConnection({ - target: { - ...base, - providerId: "custom", - selectionMode: "project_custom_provider", - endpoint: `https://localhost:${(server.address() as AddressInfo).port}/`, - }, - }) - ).rejects.toBeInstanceOf(EgressBlockedError); - }); -}); diff --git a/apps/sdp-api/src/services/rpc-egress.test.ts b/apps/sdp-api/src/services/rpc-egress.test.ts index f3940226f5..5d86d69cf7 100644 --- a/apps/sdp-api/src/services/rpc-egress.test.ts +++ b/apps/sdp-api/src/services/rpc-egress.test.ts @@ -1,237 +1,169 @@ -import { createServer, type Server } from "node:http"; -import type { AddressInfo } from "node:net"; import { isTransientRpcError } from "@sdp/rpc"; import { confirmTransaction, createRpcFromTransport } from "@sdp/rpc/solana"; -import type { Signature } from "@solana/kit"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { EgressBlockedError } from "@/services/guarded-egress"; -import { checkResolvedRpcTargetConnection } from "@/services/provider-setup-registry"; -import { - createRpcTransportForTarget, - fetchRpcRelayTarget, - RELAY_MAX_RESPONSE_BYTES, - relayGuardInit, -} from "@/services/rpc-egress"; - -/** - * Both directions matter. A guard that refused everything would pass a - * blocklist test and take local development and the Surfpool suites down with - * it, so each path is asserted to reach a private address when the target is - * platform-owned and to refuse when it is tenant-owned. - */ -let server: Server; -let origin: string; -const scriptedStatuses: number[] = []; - -beforeAll(async () => { - server = createServer((req, res) => { - const status = req.url?.match(/^\/status\/(\d{3})$/); - if (status) { - res.writeHead(Number(status[1])); - res.end(); - return; - } - if (req.url === "/scripted") { - const next = scriptedStatuses.shift(); - if (next !== undefined) { - res.writeHead(next); - res.end(); - return; - } - res.writeHead(200, { "Content-Type": "application/json" }); - res.end( - JSON.stringify({ - jsonrpc: "2.0", - id: "0", - result: { - context: { slot: 7 }, - value: [{ slot: 7, confirmations: null, err: null, confirmationStatus: "confirmed" }], - }, - }) - ); - return; - } - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ jsonrpc: "2.0", id: "probe", result: { "solana-core": "0.0.0" } })); +import { signature } from "@solana/kit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as guardedEgress from "@/services/guarded-egress"; +import { createCustomerRpcTransport } from "@/services/rpc-egress"; + +const payload = { jsonrpc: "2.0", id: "probe", method: "getVersion", params: [] }; + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; -}); +} -afterAll(async () => { - await new Promise((resolve) => server.close(() => resolve())); +afterEach(() => { + vi.restoreAllMocks(); }); -describe("fetchRpcRelayTarget", () => { - it("relays a platform target to a private address", async () => { - // The platform rail has to keep working against a local validator. - const upstream = await fetchRpcRelayTarget( - { endpoint: origin }, - { headers: { "Content-Type": "application/json" }, body: "{}" } - ); +describe("createCustomerRpcTransport", () => { + it("refuses an endpoint whose host resolves inward", async () => { + const transport = createCustomerRpcTransport("https://localhost:8899/"); - expect(upstream.status).toBe(200); + await expect(transport({ payload })).rejects.toBeInstanceOf(guardedEgress.EgressBlockedError); }); - it("refuses a custom target whose host resolves inward", async () => { - // `custom` is the project's own stored endpoint, validated only as a URL - // when it is written and carrying no connectionId. It is as much a - // customer-supplied host as a BYOK connection. - await expect( - fetchRpcRelayTarget( - { - endpoint: `https://localhost:${(server.address() as AddressInfo).port}/`, - providerId: "custom", - }, - { headers: { "Content-Type": "application/json" }, body: "{}" } - ) - ).rejects.toBeInstanceOf(EgressBlockedError); - }); + it("refuses a plaintext endpoint", async () => { + const transport = createCustomerRpcTransport("http://rpc.example.com/"); - it("refuses a tenant target whose host resolves inward", async () => { - // Same destination, only the connectionId differs, which is the whole - // rule: a target a customer supplied does not get to name an internal - // address by way of a name that resolves to one. - await expect( - fetchRpcRelayTarget( - { - endpoint: `https://localhost:${(server.address() as AddressInfo).port}/`, - connectionId: "rconn_test", - }, - { headers: { "Content-Type": "application/json" }, body: "{}" } - ) - ).rejects.toBeInstanceOf(EgressBlockedError); + await expect(transport({ payload })).rejects.toBeInstanceOf(guardedEgress.EgressBlockedError); }); -}); -describe("createRpcTransportForTarget", () => { - const payload = { jsonrpc: "2.0", id: "probe", method: "getVersion", params: [] }; - - it("runs a platform Solana transport through the relay executor", async () => { - const transport = createRpcTransportForTarget({ endpoint: origin }); + it("posts through the guard with the redirect, size and time bounds", async () => { + const guardedFetch = vi + .spyOn(guardedEgress, "guardedFetch") + .mockResolvedValue( + jsonResponse({ jsonrpc: "2.0", id: "probe", result: { "solana-core": "0.0.0" } }) + ); + const transport = createCustomerRpcTransport("https://rpc.example.com/v2/key_synthetic"); const response = await transport<{ result: { "solana-core": string } }>({ payload }); expect(response.result["solana-core"]).toBe("0.0.0"); + expect(guardedFetch).toHaveBeenCalledTimes(1); + expect(guardedFetch).toHaveBeenCalledWith("https://rpc.example.com/v2/key_synthetic", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify(payload), + signal: expect.any(AbortSignal), + maxRedirects: 3, + maxResponseBytes: 10 * 1024 * 1024, + rejectOversizeResponse: true, + }); }); - it("guards customer endpoints used by the Solana transport", async () => { - const transport = createRpcTransportForTarget({ - endpoint: `https://localhost:${(server.address() as AddressInfo).port}/`, - connectionId: "rconn_test", - }); + it("bounds each request to 30 seconds", async () => { + vi.spyOn(guardedEgress, "guardedFetch").mockResolvedValue( + jsonResponse({ jsonrpc: "2.0", id: "probe", result: null }) + ); + const timeout = vi.spyOn(AbortSignal, "timeout"); + const transport = createCustomerRpcTransport("https://rpc.example.com/"); + + await transport({ payload }); + + expect(timeout).toHaveBeenCalledWith(30_000); + }); + + it("keeps the time bound when the caller supplies a signal", async () => { + const guardedFetch = vi + .spyOn(guardedEgress, "guardedFetch") + .mockResolvedValue(jsonResponse({ jsonrpc: "2.0", id: "probe", result: null })); + vi.spyOn(AbortSignal, "timeout").mockReturnValue(AbortSignal.abort()); + const controller = new AbortController(); + const transport = createCustomerRpcTransport("https://rpc.example.com/"); + + await transport({ payload, signal: controller.signal }); - await expect(transport({ payload })).rejects.toBeInstanceOf(EgressBlockedError); + const [, init] = guardedFetch.mock.calls[0]; + expect(init.signal).not.toBe(controller.signal); + expect(controller.signal.aborted).toBe(false); + expect(init.signal).toBeInstanceOf(AbortSignal); + expect(init.signal).toMatchObject({ aborted: true }); + }); + + it("follows the caller's cancellation", async () => { + const guardedFetch = vi + .spyOn(guardedEgress, "guardedFetch") + .mockResolvedValue(jsonResponse({ jsonrpc: "2.0", id: "probe", result: null })); + const controller = new AbortController(); + const transport = createCustomerRpcTransport("https://rpc.example.com/"); + + await transport({ payload, signal: controller.signal }); + const [, init] = guardedFetch.mock.calls[0]; + controller.abort(); + + expect(init.signal).toBeInstanceOf(AbortSignal); + expect(init.signal).toMatchObject({ aborted: true }); + }); + + it("surfaces an oversize answer as the guard's error", async () => { + vi.spyOn(guardedEgress, "guardedFetch").mockRejectedValue( + new guardedEgress.EgressResponseTooLargeError("rpc.example.com") + ); + const transport = createCustomerRpcTransport("https://rpc.example.com/"); + + await expect(transport({ payload })).rejects.toBeInstanceOf( + guardedEgress.EgressResponseTooLargeError + ); }); it.each([408, 429, 500, 502, 503, 504])( "classifies an upstream HTTP %i as transient", async (status) => { - const transport = createRpcTransportForTarget({ endpoint: `${origin}/status/${status}` }); + vi.spyOn(guardedEgress, "guardedFetch").mockResolvedValue(new Response(null, { status })); + const transport = createCustomerRpcTransport("https://rpc.example.com/"); - const error = await transport({ payload }).catch((caught: unknown) => caught); + const outcome = transport({ payload }); - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toBe(`RPC request failed with HTTP ${status}`); - expect(isTransientRpcError(error)).toBe(true); + await expect(outcome).rejects.toThrow(`RPC request failed with HTTP ${status}`); + await expect(outcome).rejects.toSatisfy(isTransientRpcError); } ); it.each([400, 401, 403, 404])( "does not classify an upstream HTTP %i as transient", async (status) => { - const transport = createRpcTransportForTarget({ endpoint: `${origin}/status/${status}` }); + vi.spyOn(guardedEgress, "guardedFetch").mockResolvedValue(new Response(null, { status })); + const transport = createCustomerRpcTransport("https://rpc.example.com/"); - const error = await transport({ payload }).catch((caught: unknown) => caught); + const outcome = transport({ payload }); - expect((error as Error).message).toBe(`RPC request failed with HTTP ${status}`); - expect(isTransientRpcError(error)).toBe(false); + await expect(outcome).rejects.toThrow(`RPC request failed with HTTP ${status}`); + await expect(outcome).rejects.toSatisfy((error: unknown) => !isTransientRpcError(error)); } ); it("keeps polling a confirmation through upstream 429 and 503 answers", async () => { - scriptedStatuses.push(429, 503); - const rpc = createRpcFromTransport( - createRpcTransportForTarget({ endpoint: `${origin}/scripted` }) - ); + const guardedFetch = vi + .spyOn(guardedEgress, "guardedFetch") + .mockResolvedValueOnce(new Response(null, { status: 429 })) + .mockResolvedValueOnce(new Response(null, { status: 503 })) + .mockResolvedValue( + jsonResponse({ + jsonrpc: "2.0", + id: "0", + result: { + context: { slot: 7 }, + value: [{ slot: 7, confirmations: null, err: null, confirmationStatus: "confirmed" }], + }, + }) + ); + const rpc = createRpcFromTransport(createCustomerRpcTransport("https://rpc.example.com/")); const confirmation = await confirmTransaction( rpc, - "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW" as Signature, + signature( + "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW" + ), { timeoutMs: 5_000, pollIntervalMs: 1 } ); expect(confirmation.confirmationStatus).toBe("confirmed"); - expect(scriptedStatuses).toEqual([]); - }); -}); - -describe("checkResolvedRpcTargetConnection", () => { - const base = { - providerId: "helius" as const, - projectId: null, - endpointLabel: "local", - headers: {}, - selectionMode: "organization_provider" as const, - }; - - it("probes a platform target at a private address", async () => { - // A managed provider's endpoint comes from deployment config, which is - // what local development and the Surfpool suites rely on. - const { upstream } = await checkResolvedRpcTargetConnection({ - target: { ...base, endpoint: origin }, - }); - - expect(upstream.status).toBe(200); - }); - - it("refuses to probe the project's own custom endpoint when it resolves inward", async () => { - await expect( - checkResolvedRpcTargetConnection({ - target: { - ...base, - providerId: "custom", - endpoint: `https://localhost:${(server.address() as AddressInfo).port}/`, - selectionMode: "project_custom_provider", - }, - }) - ).rejects.toBeInstanceOf(EgressBlockedError); - }); - - it("refuses to probe a tenant target that resolves inward", async () => { - // POST /v1/rpc/test resolves tenant connections, so this path reaches a - // customer endpoint whenever one is active. - await expect( - checkResolvedRpcTargetConnection({ - target: { - ...base, - endpoint: `https://localhost:${(server.address() as AddressInfo).port}/`, - connectionId: "rconn_test", - }, - }) - ).rejects.toBeInstanceOf(EgressBlockedError); - }); -}); - -describe("customer egress limits", () => { - it("bounds redirects, response size and time on the relay path", () => { - const init = relayGuardInit({ headers: {}, body: "{}" }); - - expect(init.maxRedirects).toBe(3); - expect(init.maxResponseBytes).toBe(RELAY_MAX_RESPONSE_BYTES); - expect(init.signal).toBeInstanceOf(AbortSignal); - }); - - it("keeps the time bound even when the caller supplies a signal", () => { - // The Kit transport path always passes a signal; if that replaced the - // ceiling instead of joining it, the 30s bound would never apply there. - const controller = new AbortController(); - const init = relayGuardInit({ headers: {}, body: "{}", signal: controller.signal }); - - expect(init.signal).toBeInstanceOf(AbortSignal); - expect(init.signal).not.toBe(controller.signal); - expect(init.signal?.aborted).toBe(false); - controller.abort(); - expect(init.signal?.aborted).toBe(true); + expect(guardedFetch).toHaveBeenCalledTimes(3); }); }); diff --git a/apps/sdp-api/src/services/rpc-egress.ts b/apps/sdp-api/src/services/rpc-egress.ts index 81fd0e10a7..91f760d41b 100644 --- a/apps/sdp-api/src/services/rpc-egress.ts +++ b/apps/sdp-api/src/services/rpc-egress.ts @@ -1,124 +1,61 @@ -/** - * Where RPC traffic leaves the process. - * - * The question each egress path has to answer is whether the endpoint came - * from a customer or from deployment config, because only the first can be - * pointed anywhere. Two resolutions carry a customer-supplied endpoint: - * - * * a tenant BYOK connection, which sets `connectionId` - * * the `custom` provider, whose endpoint is `projects.settings.rpcEndpoint` - * and is validated only as a URL when it is written - * - * Platform targets keep the ordinary fetch: they come from deployment config - * and are legitimately private in local development and in the Surfpool suites. - */ import { RpcHttpStatusError } from "@sdp/rpc/errors"; import type { RpcTransport } from "@solana/kit"; -import { type GuardedFetchInit, guardedFetch } from "@/services/guarded-egress"; +import { guardedFetch } from "@/services/guarded-egress"; /** - * The relay followed redirects before the guard existed, and a provider - * answering on a canonical or regional host is ordinary. Each hop is resolved - * through the guard again, so following is bounded rather than trusted. + * A provider answering on a canonical or regional host is ordinary. Each hop + * is resolved through the guard again, so following is bounded rather than + * trusted. */ -const RELAY_MAX_REDIRECTS = 3; +const CUSTOMER_RPC_MAX_REDIRECTS = 3; /** - * Upper bound on what the relay buffers back from a customer endpoint. Sized - * for the largest ordinary answers (`getProgramAccounts`, a full block) with - * room to spare; a hostile endpoint cannot stream unbounded bytes into the - * process. + * Upper bound on what is buffered back from a customer endpoint. Sized for the + * largest ordinary answers (`getProgramAccounts`, a full block) with room to + * spare; a hostile endpoint cannot stream unbounded bytes into the process. */ -export const RELAY_MAX_RESPONSE_BYTES = 10 * 1024 * 1024; - -/** How long a customer endpoint gets to answer when the caller sets no signal. */ -export const RELAY_TIMEOUT_MS = 30_000; +const CUSTOMER_RPC_MAX_RESPONSE_BYTES = 10 * 1024 * 1024; -export interface RpcEgressTarget { - endpoint: string; - /** Set only for tenant-owned connections. */ - connectionId?: string; - /** `custom` is the project's own stored endpoint. */ - providerId?: string; -} - -export interface RpcEgressInit { - headers: Record; - body: string; - signal?: AbortSignal; -} - -/** Whether the endpoint came from a customer and so has to be address-checked. */ -export function isCustomerSuppliedTarget(target: RpcEgressTarget): boolean { - return Boolean(target.connectionId) || target.providerId === "custom"; -} +/** How long a customer endpoint gets to answer. */ +const CUSTOMER_RPC_TIMEOUT_MS = 30_000; -/** - * POST a JSON-RPC payload to a resolved target. Identical to the fetch the - * relay made before, except that a customer-supplied target resolves under the - * guard on every hop. - */ /** * The time bound joins the caller's signal rather than yielding to it: the - * Kit transport path always supplies one, and a caller's cancellation must - * not disable the ceiling. + * Kit transport always supplies one, and a caller's cancellation must not + * disable the ceiling. + * + * @param signal - The caller's abort signal, when it supplied one. + * @returns A signal that aborts on the caller's signal or the timeout, whichever fires first. */ function boundedSignal(signal: AbortSignal | undefined): AbortSignal { - const timeout = AbortSignal.timeout(RELAY_TIMEOUT_MS); + const timeout = AbortSignal.timeout(CUSTOMER_RPC_TIMEOUT_MS); return signal ? AbortSignal.any([signal, timeout]) : timeout; } -/** The guarded init for a customer-supplied relay target, limits applied. */ -export function relayGuardInit(init: RpcEgressInit): GuardedFetchInit { - return { - method: "POST", - headers: init.headers, - body: init.body, - signal: boundedSignal(init.signal), - maxRedirects: RELAY_MAX_REDIRECTS, - maxResponseBytes: RELAY_MAX_RESPONSE_BYTES, - rejectOversizeResponse: true, - }; -} - -export async function fetchRpcRelayTarget( - target: RpcEgressTarget, - init: RpcEgressInit -): Promise { - if (isCustomerSuppliedTarget(target)) { - return guardedFetch(target.endpoint, relayGuardInit(init)); - } - - // A managed provider is trusted with its response, not with the caller's - // time: a stalled upstream must not hold the request open indefinitely. - return fetch(target.endpoint, { - method: "POST", - headers: init.headers, - body: init.body, - signal: boundedSignal(init.signal), - }); -} - /** - * Adapt the canonical relay egress executor to a Solana Kit transport. - * Customer/BYOK targets therefore receive the same DNS and redirect guards as - * `/v1/rpc`, while managed platform targets keep their existing direct path. + * A Solana Kit transport for a customer-supplied RPC URL. Every request is + * DNS-checked at connect time and re-guarded on each redirect hop, because the + * customer can point the URL anywhere. + * + * @param endpointUrl - The customer's RPC URL. + * @returns A Kit transport that posts through the egress guard. */ -export function createRpcTransportForTarget( - target: RpcEgressTarget & { headers?: Record } -): RpcTransport { +export function createCustomerRpcTransport(endpointUrl: string): RpcTransport { return async function rpcTransport({ payload, signal, }: Parameters[0]): Promise { - const upstream = await fetchRpcRelayTarget(target, { + const upstream = await guardedFetch(endpointUrl, { + method: "POST", headers: { - ...target.headers, Accept: "application/json", "Content-Type": "application/json; charset=utf-8", }, body: JSON.stringify(payload), - signal, + signal: boundedSignal(signal), + maxRedirects: CUSTOMER_RPC_MAX_REDIRECTS, + maxResponseBytes: CUSTOMER_RPC_MAX_RESPONSE_BYTES, + rejectOversizeResponse: true, }); if (!upstream.ok) { diff --git a/apps/sdp-api/src/services/rpc-probe.test.ts b/apps/sdp-api/src/services/rpc-probe.test.ts deleted file mode 100644 index 67372946d9..0000000000 --- a/apps/sdp-api/src/services/rpc-probe.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { createServer } from "node:http"; -import type { AddressInfo } from "node:net"; -import { describe, expect, it } from "vitest"; -import { PROBE_MAX_RESPONSE_BYTES, probeRpcEndpoint } from "@/services/rpc-probe"; - -describe("probeRpcEndpoint managed branch", () => { - it("bounds what it reads back from a configured endpoint", async () => { - // Managed endpoints come from deployment config, which spares them the - // egress guard — not the read bound: a misbehaving configured endpoint - // must not buffer unbounded bytes into a health check. - const server = createServer((_req, res) => { - res.writeHead(200, { "Content-Type": "text/plain" }); - res.end("x".repeat(PROBE_MAX_RESPONSE_BYTES * 4)); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const { port } = server.address() as AddressInfo; - - try { - const result = await probeRpcEndpoint( - { endpoint: `http://127.0.0.1:${port}/`, headers: {} }, - { enforcePublicEgress: false } - ); - - expect(result.upstream.status).toBe(200); - expect(String(result.upstreamBody).length).toBeLessThanOrEqual(PROBE_MAX_RESPONSE_BYTES); - } finally { - await new Promise((resolve) => server.close(() => resolve())); - } - }); - - it("releases the connection when the body reaches the bound exactly", async () => { - // A body that fills the cap without ending must not leave the socket - // held open until the probe timeout: the reader is cancelled the moment - // the bound is reached, whichever chunking gets it there. - let serverSocketClosed: Promise = Promise.resolve(); - const server = createServer((req, res) => { - serverSocketClosed = new Promise((resolve) => req.socket.once("close", () => resolve())); - res.writeHead(200, { "Content-Type": "text/plain" }); - res.write("x".repeat(PROBE_MAX_RESPONSE_BYTES)); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const { port } = server.address() as AddressInfo; - - try { - const result = await probeRpcEndpoint( - { endpoint: `http://127.0.0.1:${port}/`, headers: {} }, - { enforcePublicEgress: false } - ); - expect(String(result.upstreamBody).length).toBe(PROBE_MAX_RESPONSE_BYTES); - - await Promise.race([ - serverSocketClosed, - new Promise((_resolve, reject) => - setTimeout(() => reject(new Error("connection stayed open past the bound")), 2000) - ), - ]); - } finally { - await new Promise((resolve) => server.close(() => resolve())); - } - }); -}); diff --git a/apps/sdp-api/src/services/rpc-probe.ts b/apps/sdp-api/src/services/rpc-probe.ts deleted file mode 100644 index e0d14f34e6..0000000000 --- a/apps/sdp-api/src/services/rpc-probe.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * The read-only JSON-RPC probe behind `POST /v1/rpc/test` and tenant - * connection activation. - * - * Its own module so the provider setup registry and the RPC connection service - * can both use it: the registry holds the per-provider hooks that call into the - * connection service, so the connection service must not import the registry - * back. - */ -import { guardedFetch } from "@/services/guarded-egress"; - -// A probe reads a status and a short JSON-RPC answer; no endpoint — -// tenant-supplied or configured — gets unbounded time or an unbounded body -// out of a health check. -const PROBE_TIMEOUT_MS = 10_000; -export const PROBE_MAX_RESPONSE_BYTES = 64 * 1024; - -/** - * Read at most `maxBytes` of the body. The guarded branch bounds its read in - * the transport; this covers the plain-fetch branch, where `text()` would - * buffer whatever the endpoint sends. - */ -async function readBodyBounded(response: Response, maxBytes: number): Promise { - const reader = response.body?.getReader(); - if (!reader) { - return ""; - } - - const chunks: Uint8Array[] = []; - let buffered = 0; - while (buffered < maxBytes) { - const { done, value } = await reader.read(); - if (done || !value) { - break; - } - const room = maxBytes - buffered; - chunks.push(value.length > room ? value.subarray(0, room) : value); - buffered += Math.min(value.length, room); - if (buffered >= maxBytes) { - await reader.cancel(); - break; - } - } - return Buffer.concat(chunks).toString(); -} - -export interface RpcProbeTarget { - endpoint: string; - headers: Record; -} - -export interface RpcProbeResult { - elapsedMs: number; - upstream: Response; - upstreamBody: unknown; -} - -export interface RpcProbeOptions { - /** - * Set for an endpoint the tenant supplied. It routes the request through - * `guardedFetch`, which refuses an address the host check cannot see because - * DNS produced it. Platform endpoints leave it off: they come from - * deployment config and are private on purpose in local development and in - * the Surfpool suites. - */ - enforcePublicEgress?: boolean; - /** - * The read-only method to call. `getVersion` proves reachability and nothing - * else; a tenant connection asks `getGenesisHash` instead, because that also - * answers which cluster it reached. - */ - method?: "getVersion" | "getGenesisHash"; -} - -function tryParseJson(value: string): unknown { - try { - return JSON.parse(value); - } catch { - return value; - } -} - -export async function probeRpcEndpoint( - target: RpcProbeTarget, - options: RpcProbeOptions = {} -): Promise { - const startedAt = Date.now(); - const headers = { - "Content-Type": "application/json", - ...target.headers, - }; - const body = JSON.stringify({ - jsonrpc: "2.0", - id: "rpc-connectivity-test", - method: options.method ?? "getVersion", - params: [], - }); - - const upstream = options.enforcePublicEgress - ? await guardedFetch(target.endpoint, { - method: "POST", - headers, - body, - signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), - maxResponseBytes: PROBE_MAX_RESPONSE_BYTES, - }) - : await fetch(target.endpoint, { - method: "POST", - // A validated host can still redirect; following it would land the - // request somewhere the host check already refused. - redirect: "manual", - headers, - body, - signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), - }); - - const rawBody = await readBodyBounded(upstream, PROBE_MAX_RESPONSE_BYTES); - return { - elapsedMs: Date.now() - startedAt, - upstream, - upstreamBody: rawBody ? tryParseJson(rawBody) : null, - }; -} - -/** - * Reduce an upstream failure to a code safe to store and show. The provider's - * own response body never reaches the dashboard: it can echo the key. - */ -export function toRedactedFailureCode(status: number): string { - if (status === 401 || status === 403) { - return "provider_rejected_credentials"; - } - if (status === 404) { - return "provider_endpoint_not_found"; - } - if (status === 429) { - return "provider_rate_limited"; - } - if (status >= 500) { - return "provider_unavailable"; - } - return "provider_check_failed"; -} diff --git a/apps/sdp-api/src/services/rpc-relay.service.test.ts b/apps/sdp-api/src/services/rpc-relay.service.test.ts index 39de514729..803ae97b9c 100644 --- a/apps/sdp-api/src/services/rpc-relay.service.test.ts +++ b/apps/sdp-api/src/services/rpc-relay.service.test.ts @@ -1,40 +1,11 @@ -import { includesTransactionMethod, listRpcProviders, resolveRpcTarget } from "@sdp/rpc/relay"; -import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { getDb } from "@/db"; +import { resolveRoundRobinRpcTargets, resolveRpcTarget } from "@sdp/rpc/relay"; +import { beforeEach, describe, expect, it } from "vitest"; import type { KVStore, KVStoreSet } from "@/runtime/kv"; import { createKVStoreSet } from "@/runtime/kv-redis"; -import { env } from "@/test/helpers/env"; -import { seedDefaultProjects } from "@/test/helpers/projects"; -import { seedTestDatabase } from "@/test/mocks/db"; -import type { Env } from "@/types/env"; - -const TEST_ORG_ID = "org_rpc_service_test"; -const TEST_PROJECT_ID = "prj_rpc_service_test"; -const TEST_USER_ID = "usr_rpc_service_test"; -const appEnv = env as unknown as Env; -const SEND_RAW_TRANSACTION_METHOD = ["sendRaw", "Transaction"].join(""); - -type MutableRpcEnv = { - SOLANA_RPC_URL?: string; - SOLANA_RPC_DEFAULT_PROVIDER?: string; - SOLANA_RPC_TRITON_URL?: string; - SOLANA_RPC_TRITON_API_KEY?: string; - SOLANA_RPC_HELIUS_URL?: string; - SOLANA_RPC_HELIUS_API_KEY?: string; - SOLANA_RPC_ALCHEMY_URL?: string; - SOLANA_RPC_ALCHEMY_API_KEY?: string; - SOLANA_RPC_QUICKNODE_URL?: string; - SOLANA_RPC_QUICKNODE_API_KEY?: string; - SOLANA_RPC_VALIDATIONCLOUD_URL?: string; - SOLANA_RPC_VALIDATIONCLOUD_API_KEY?: string; - SOLANA_RPC_NODIT_URL?: string; - SOLANA_RPC_NODIT_API_KEY?: string; - SDP_DEPLOYMENT_MODE?: string; -}; - -const rpcEnv = env as MutableRpcEnv; -const db = getDb(env as unknown as Env); -const kv: KVStoreSet = createKVStoreSet(appEnv); +import { env, resetManagedRpcEnv } from "@/test/helpers/env"; + +const ROUND_ROBIN_CURSOR_KEY = "rpc:relay:round-robin-cursor"; +const kv: KVStoreSet = createKVStoreSet(env); async function clearKvStore(store: KVStore) { const listed = await store.list(); @@ -43,269 +14,47 @@ async function clearKvStore(store: KVStore) { } } -describe("rpc-relay.service", () => { - beforeAll(async () => { - await seedTestDatabase(env as Parameters[0]); - }); - - afterAll(async () => { - await seedTestDatabase(env as Parameters[0]); - }); +async function resolveNextTarget() { + return resolveRpcTarget({ env, cache: kv.cache }); +} +describe("rpc-relay.service", () => { beforeEach(async () => { await clearKvStore(kv.cache); - - await db - .prepare( - `INSERT INTO organizations (id, name, slug, tier, status, settings) - VALUES (?, 'RPC Service Org', 'rpc-service-org', 'enterprise', 'active', NULL) - ON CONFLICT(id) DO UPDATE SET - name = excluded.name, - slug = excluded.slug, - tier = excluded.tier, - status = excluded.status, - settings = excluded.settings` - ) - .bind(TEST_ORG_ID) - .run(); - - await db - .prepare( - `INSERT INTO users (id, email, email_verified, status) - VALUES (?, 'rpc-service@example.com', 1, 'active') - ON CONFLICT(id) DO UPDATE SET - email = excluded.email, - email_verified = excluded.email_verified, - status = excluded.status` - ) - .bind(TEST_USER_ID) - .run(); - - await seedDefaultProjects(db, { - organizationId: TEST_ORG_ID, - createdBy: TEST_USER_ID, - members: [], - ids: { sandbox: TEST_PROJECT_ID, production: `${TEST_PROJECT_ID}_production` }, - }); - - rpcEnv.SOLANA_RPC_URL = undefined; - rpcEnv.SOLANA_RPC_DEFAULT_PROVIDER = undefined; - rpcEnv.SOLANA_RPC_TRITON_URL = undefined; - rpcEnv.SOLANA_RPC_TRITON_API_KEY = undefined; - rpcEnv.SOLANA_RPC_HELIUS_URL = undefined; - rpcEnv.SOLANA_RPC_HELIUS_API_KEY = undefined; - rpcEnv.SOLANA_RPC_ALCHEMY_URL = undefined; - rpcEnv.SOLANA_RPC_ALCHEMY_API_KEY = undefined; - rpcEnv.SOLANA_RPC_QUICKNODE_URL = undefined; - rpcEnv.SOLANA_RPC_QUICKNODE_API_KEY = undefined; - rpcEnv.SOLANA_RPC_VALIDATIONCLOUD_URL = undefined; - rpcEnv.SOLANA_RPC_VALIDATIONCLOUD_API_KEY = undefined; - rpcEnv.SOLANA_RPC_NODIT_URL = undefined; - rpcEnv.SOLANA_RPC_NODIT_API_KEY = undefined; - rpcEnv.SDP_DEPLOYMENT_MODE = undefined; - }); - - it("identifies transaction JSON-RPC methods", () => { - expect(includesTransactionMethod(["getVersion"])).toBe(false); - expect(includesTransactionMethod(["sendTransaction"])).toBe(true); - expect(includesTransactionMethod([SEND_RAW_TRANSACTION_METHOD])).toBe(true); - }); - - it("resolves quicknode provider from organization settings with redacted endpoint labels", async () => { - await db - .prepare("UPDATE organizations SET settings = ? WHERE id = ?") - .bind(JSON.stringify({ rpcProvider: "quicknode" }), TEST_ORG_ID) - .run(); - - rpcEnv.SOLANA_RPC_QUICKNODE_URL = "https://rpc.quicknode.test/?api-key={API_KEY}"; - rpcEnv.SOLANA_RPC_QUICKNODE_API_KEY = "qn_secret"; - - const target = await resolveRpcTarget({ - env: appEnv, - kv, - db, - organizationId: TEST_ORG_ID, - authProjectId: null, - requestedProjectId: null, - }); - - expect(target.providerId).toBe("quicknode"); - expect(target.selectionMode).toBe("organization_provider"); - expect(target.endpoint).toContain("api-key=qn_secret"); - expect(target.endpointLabel).toContain("api-key=***"); + resetManagedRpcEnv(); }); - it("prefers project-managed provider over organization provider when project setting is set", async () => { - await db - .prepare("UPDATE organizations SET settings = ? WHERE id = ?") - .bind(JSON.stringify({ rpcProvider: "helius" }), TEST_ORG_ID) - .run(); - await db - .prepare("UPDATE projects SET settings = ? WHERE id = ?") - .bind(JSON.stringify({ rpcProvider: "triton" }), TEST_PROJECT_ID) - .run(); - - rpcEnv.SOLANA_RPC_TRITON_URL = "https://rpc.triton.test"; - rpcEnv.SOLANA_RPC_HELIUS_URL = "https://rpc.helius.test"; - - const target = await resolveRpcTarget({ - env: appEnv, - kv, - db, - organizationId: TEST_ORG_ID, - authProjectId: TEST_PROJECT_ID, - requestedProjectId: null, - }); + it("resolves a query-string key into the endpoint and masks it in the label", async () => { + env.SOLANA_RPC_QUICKNODE_URL = "https://rpc.quicknode.test/?api-key={API_KEY}"; + env.SOLANA_RPC_QUICKNODE_API_KEY = "qn_secret"; - expect(target.providerId).toBe("triton"); - expect(target.selectionMode).toBe("project_provider"); - }); + const target = await resolveNextTarget(); - it("uses project custom endpoint when project rpcProvider is custom", async () => { - await db - .prepare("UPDATE projects SET settings = ? WHERE id = ?") - .bind( - JSON.stringify({ - rpcProvider: "custom", - rpcEndpoint: "https://rpc.custom-provider.test/?api-key=custom_secret", - }), - TEST_PROJECT_ID - ) - .run(); - - const target = await resolveRpcTarget({ - env: appEnv, - kv, - db, - organizationId: TEST_ORG_ID, - authProjectId: TEST_PROJECT_ID, - requestedProjectId: null, + expect(target).toEqual({ + providerId: "quicknode", + endpoint: "https://rpc.quicknode.test/?api-key=qn_secret", + endpointLabel: "https://rpc.quicknode.test/?api-key=***", }); - - expect(target.providerId).toBe("custom"); - expect(target.selectionMode).toBe("project_custom_provider"); - expect(target.endpoint).toContain("custom_secret"); - expect(target.endpointLabel).toContain("api-key=***"); - }); - - it("round-robins managed providers when organization preference is not set", async () => { - rpcEnv.SOLANA_RPC_TRITON_URL = "https://rpc.triton.test"; - rpcEnv.SOLANA_RPC_HELIUS_URL = "https://rpc.helius.test"; - - const first = await resolveRpcTarget({ - env: appEnv, - kv, - db, - organizationId: TEST_ORG_ID, - authProjectId: null, - requestedProjectId: null, - }); - - const second = await resolveRpcTarget({ - env: appEnv, - kv, - db, - organizationId: TEST_ORG_ID, - authProjectId: null, - requestedProjectId: null, - }); - - expect(first.providerId).toBe("triton"); - expect(second.providerId).toBe("helius"); - expect(first.selectionMode).toBe("round_robin_default"); - expect(second.selectionMode).toBe("round_robin_default"); - }); - - it("ignores SDP_DEPLOYMENT_MODE and resolves managed providers regardless of its value", async () => { - rpcEnv.SOLANA_RPC_TRITON_URL = "https://rpc.triton.test"; - rpcEnv.SDP_DEPLOYMENT_MODE = "selfhosted"; - - const target = await resolveRpcTarget({ - env: appEnv, - kv, - db, - organizationId: TEST_ORG_ID, - authProjectId: null, - requestedProjectId: null, - }); - - expect(target.providerId).toBe("triton"); - expect(target.selectionMode).toBe("round_robin_default"); }); it("resolves validationcloud, substitutes the path-segment key, and redacts it in the label", async () => { - await db - .prepare("UPDATE organizations SET settings = ? WHERE id = ?") - .bind(JSON.stringify({ rpcProvider: "validationcloud" }), TEST_ORG_ID) - .run(); - - rpcEnv.SOLANA_RPC_VALIDATIONCLOUD_URL = "https://devnet.solana.validationcloud.io/v1/{API_KEY}"; - rpcEnv.SOLANA_RPC_VALIDATIONCLOUD_API_KEY = "vc_secret"; - - const target = await resolveRpcTarget({ - env: appEnv, - kv, - db, - organizationId: TEST_ORG_ID, - authProjectId: null, - requestedProjectId: null, - }); + env.SOLANA_RPC_VALIDATIONCLOUD_URL = "https://devnet.solana.validationcloud.io/v1/{API_KEY}"; + env.SOLANA_RPC_VALIDATIONCLOUD_API_KEY = "vc_secret"; + + const target = await resolveNextTarget(); expect(target.providerId).toBe("validationcloud"); - expect(target.selectionMode).toBe("organization_provider"); - // Upstream URL carries the substituted key as a path segment. expect(target.endpoint).toContain("/v1/vc_secret"); - // The caller-facing label must not leak it. expect(target.endpointLabel).not.toContain("vc_secret"); expect(target.endpointLabel).toContain("/v1/***"); }); - it("redacts path-segment provider keys from the provider list", async () => { - rpcEnv.SOLANA_RPC_ALCHEMY_URL = "https://solana-devnet.g.alchemy.com/v2/{API_KEY}"; - rpcEnv.SOLANA_RPC_ALCHEMY_API_KEY = "alch_secret"; - rpcEnv.SOLANA_RPC_VALIDATIONCLOUD_URL = "https://devnet.solana.validationcloud.io/v1/{API_KEY}"; - rpcEnv.SOLANA_RPC_VALIDATIONCLOUD_API_KEY = "vc_secret"; - - const providers = await listRpcProviders({ - env: appEnv, - kv, - db, - organizationId: TEST_ORG_ID, - authProjectId: null, - requestedProjectId: null, - }); - - expect(providers.roundRobinOrder).toEqual( - expect.arrayContaining(["alchemy", "validationcloud"]) - ); - const allLabels = providers.providers.map((provider) => provider.endpoint).join(" "); - expect(allLabels).not.toContain("alch_secret"); - expect(allLabels).not.toContain("vc_secret"); - expect( - providers.providers.find((provider) => provider.id === "validationcloud")?.endpoint - ).toContain("/v1/***"); - }); - it("masks overlapping provider keys without leaving remnants", async () => { - await db - .prepare("UPDATE organizations SET settings = ? WHERE id = ?") - .bind(JSON.stringify({ rpcProvider: "validationcloud" }), TEST_ORG_ID) - .run(); - - rpcEnv.SOLANA_RPC_VALIDATIONCLOUD_URL = "https://devnet.solana.validationcloud.io/v1/{API_KEY}"; - rpcEnv.SOLANA_RPC_VALIDATIONCLOUD_API_KEY = "vc_secret_long_123"; - // A second configured key that is a prefix of the first — replacement - // order must not mangle the longer key's match and leave "_long_123". - rpcEnv.SOLANA_RPC_TRITON_API_KEY = "vc_secret"; - - const target = await resolveRpcTarget({ - env: appEnv, - kv, - db, - organizationId: TEST_ORG_ID, - authProjectId: null, - requestedProjectId: null, - }); + env.SOLANA_RPC_VALIDATIONCLOUD_URL = "https://devnet.solana.validationcloud.io/v1/{API_KEY}"; + env.SOLANA_RPC_VALIDATIONCLOUD_API_KEY = "vc_secret_long_123"; + env.SOLANA_RPC_TRITON_API_KEY = "vc_secret"; + + const target = await resolveNextTarget(); expect(target.providerId).toBe("validationcloud"); expect(target.endpointLabel).toContain("/v1/***"); @@ -313,27 +62,10 @@ describe("rpc-relay.service", () => { }); it("redacts URL-encoded keys even when the endpoint URL is unparseable", async () => { - await db - .prepare("UPDATE organizations SET settings = ? WHERE id = ?") - .bind(JSON.stringify({ rpcProvider: "validationcloud" }), TEST_ORG_ID) - .run(); - - // Scheme-less URL: new URL() throws, so the label comes from the - // catch fallback — which must return the scrubbed string, not the input. - rpcEnv.SOLANA_RPC_VALIDATIONCLOUD_URL = "devnet.solana.validationcloud.io/v1/{API_KEY}"; - // Key with URL-special characters: the template embeds only its - // encodeURIComponent form, so the encoded-variant scrub is the sole - // redaction path for it. - rpcEnv.SOLANA_RPC_VALIDATIONCLOUD_API_KEY = "vc+secret/with=chars"; - - const target = await resolveRpcTarget({ - env: appEnv, - kv, - db, - organizationId: TEST_ORG_ID, - authProjectId: null, - requestedProjectId: null, - }); + env.SOLANA_RPC_VALIDATIONCLOUD_URL = "devnet.solana.validationcloud.io/v1/{API_KEY}"; + env.SOLANA_RPC_VALIDATIONCLOUD_API_KEY = "vc+secret/with=chars"; + + const target = await resolveNextTarget(); expect(target.providerId).toBe("validationcloud"); expect(target.endpoint).toContain("/v1/vc%2Bsecret%2Fwith%3Dchars"); @@ -342,86 +74,44 @@ describe("rpc-relay.service", () => { expect(target.endpointLabel).not.toContain("vc%2Bsecret"); }); - it("resolves Nodit with URL-only authentication and redacted endpoint labels", async () => { - await db - .prepare("UPDATE organizations SET settings = ? WHERE id = ?") - .bind(JSON.stringify({ rpcProvider: "nodit" }), TEST_ORG_ID) - .run(); - - rpcEnv.SOLANA_RPC_NODIT_URL = "https://solana-devnet.nodit.io/{API_KEY}"; - rpcEnv.SOLANA_RPC_NODIT_API_KEY = "nodit+secret/with=chars"; - - const target = await resolveRpcTarget({ - env: appEnv, - kv, - db, - organizationId: TEST_ORG_ID, - authProjectId: null, - requestedProjectId: null, - }); - const providers = await listRpcProviders({ - env: appEnv, - kv, - db, - organizationId: TEST_ORG_ID, - authProjectId: null, - requestedProjectId: null, - }); - const listedNodit = providers.providers.find((provider) => provider.id === "nodit"); + it("resolves Nodit with URL-only authentication and a redacted endpoint label", async () => { + env.SOLANA_RPC_NODIT_URL = "https://solana-devnet.nodit.io/{API_KEY}"; + env.SOLANA_RPC_NODIT_API_KEY = "nodit+secret/with=chars"; + + const target = await resolveNextTarget(); expect(target.providerId).toBe("nodit"); - expect(target.selectionMode).toBe("organization_provider"); expect(target.endpoint).toBe("https://solana-devnet.nodit.io/nodit%2Bsecret%2Fwith%3Dchars"); - expect(target.headers).toEqual({}); expect(target.endpointLabel).toContain("/***"); expect(target.endpointLabel).not.toContain("nodit+secret"); expect(target.endpointLabel).not.toContain("nodit%2Bsecret"); - expect(listedNodit?.endpoint).toContain("/***"); - expect(listedNodit?.endpoint).not.toContain("nodit+secret"); - expect(listedNodit?.endpoint).not.toContain("nodit%2Bsecret"); }); it("resolves a complete Nodit URL without a separate key like other providers", async () => { - await db - .prepare("UPDATE organizations SET settings = ? WHERE id = ?") - .bind(JSON.stringify({ rpcProvider: "nodit" }), TEST_ORG_ID) - .run(); - - rpcEnv.SOLANA_RPC_NODIT_URL = "https://rpc.nodit.test/rpc"; - rpcEnv.SOLANA_RPC_NODIT_API_KEY = undefined; - - const target = await resolveRpcTarget({ - env: appEnv, - kv, - db, - organizationId: TEST_ORG_ID, - authProjectId: null, - requestedProjectId: null, - }); + env.SOLANA_RPC_NODIT_URL = "https://rpc.nodit.test/rpc"; + + const target = await resolveNextTarget(); + expect(target.providerId).toBe("nodit"); expect(target.endpoint).toBe("https://rpc.nodit.test/rpc"); }); - it("preserves existing provider order and appends Nodit before default", async () => { - rpcEnv.SOLANA_RPC_TRITON_URL = "https://rpc.triton.test"; - rpcEnv.SOLANA_RPC_HELIUS_URL = "https://rpc.helius.test"; - rpcEnv.SOLANA_RPC_ALCHEMY_URL = "https://rpc.alchemy.test"; - rpcEnv.SOLANA_RPC_QUICKNODE_URL = "https://rpc.quicknode.test"; - rpcEnv.SOLANA_RPC_VALIDATIONCLOUD_URL = "https://rpc.validationcloud.test"; - rpcEnv.SOLANA_RPC_NODIT_URL = "https://rpc.nodit.test/{API_KEY}"; - rpcEnv.SOLANA_RPC_NODIT_API_KEY = "nodit-key"; - rpcEnv.SOLANA_RPC_URL = "https://rpc.default.test"; - - const providers = await listRpcProviders({ - env: appEnv, - kv, - db, - organizationId: TEST_ORG_ID, - authProjectId: null, - requestedProjectId: null, - }); + it("rotates through the managed pool in provider order with the default last", async () => { + env.SOLANA_RPC_TRITON_URL = "https://rpc.triton.test"; + env.SOLANA_RPC_HELIUS_URL = "https://rpc.helius.test"; + env.SOLANA_RPC_ALCHEMY_URL = "https://rpc.alchemy.test"; + env.SOLANA_RPC_QUICKNODE_URL = "https://rpc.quicknode.test"; + env.SOLANA_RPC_VALIDATIONCLOUD_URL = "https://rpc.validationcloud.test"; + env.SOLANA_RPC_NODIT_URL = "https://rpc.nodit.test/{API_KEY}"; + env.SOLANA_RPC_NODIT_API_KEY = "nodit-key"; + env.SOLANA_RPC_URL = "https://rpc.default.test"; - expect(providers.roundRobinOrder).toEqual([ + const providerIds: string[] = []; + for (let request = 0; request < 7; request += 1) { + providerIds.push((await resolveNextTarget()).providerId); + } + + expect(providerIds).toEqual([ "triton", "helius", "alchemy", @@ -432,27 +122,89 @@ describe("rpc-relay.service", () => { ]); }); - it("honors default provider ordering and exposes quicknode in provider list", async () => { - rpcEnv.SOLANA_RPC_TRITON_URL = "https://rpc.triton.test"; - rpcEnv.SOLANA_RPC_HELIUS_URL = "https://rpc.helius.test"; - rpcEnv.SOLANA_RPC_ALCHEMY_URL = "https://rpc.alchemy.test"; - rpcEnv.SOLANA_RPC_QUICKNODE_URL = "https://rpc.quicknode.test"; - rpcEnv.SOLANA_RPC_URL = "https://rpc.default.test"; - rpcEnv.SOLANA_RPC_DEFAULT_PROVIDER = "quicknode"; - - const providers = await listRpcProviders({ - env: appEnv, - kv, - db, - organizationId: TEST_ORG_ID, - authProjectId: null, - requestedProjectId: null, + it("starts the rotation at SOLANA_RPC_DEFAULT_PROVIDER", async () => { + env.SOLANA_RPC_TRITON_URL = "https://rpc.triton.test"; + env.SOLANA_RPC_HELIUS_URL = "https://rpc.helius.test"; + env.SOLANA_RPC_QUICKNODE_URL = "https://rpc.quicknode.test"; + env.SOLANA_RPC_DEFAULT_PROVIDER = "quicknode"; + + const target = await resolveNextTarget(); + + expect(target.providerId).toBe("quicknode"); + }); + + it("wraps back to the first provider after the last one", async () => { + env.SOLANA_RPC_TRITON_URL = "https://rpc.triton.test"; + env.SOLANA_RPC_HELIUS_URL = "https://rpc.helius.test"; + + const providerIds: string[] = []; + for (let request = 0; request < 3; request += 1) { + providerIds.push((await resolveNextTarget()).providerId); + } + + expect(providerIds).toEqual(["triton", "helius", "triton"]); + expect(await kv.cache.get(ROUND_ROBIN_CURSOR_KEY)).toBe("1"); + }); + + it("never writes the cursor for a single provider", async () => { + env.SOLANA_RPC_TRITON_URL = "https://rpc.triton.test"; + + expect((await resolveNextTarget()).providerId).toBe("triton"); + expect((await resolveNextTarget()).providerId).toBe("triton"); + expect((await kv.cache.list()).keys).toEqual([]); + }); + + it.each(["not-a-number", "-3"])( + "restarts the rotation at the first provider when the cursor reads %s", + async (storedCursor) => { + env.SOLANA_RPC_TRITON_URL = "https://rpc.triton.test"; + env.SOLANA_RPC_HELIUS_URL = "https://rpc.helius.test"; + await kv.cache.put(ROUND_ROBIN_CURSOR_KEY, storedCursor); + + const target = await resolveNextTarget(); + + expect(target.providerId).toBe("triton"); + expect(await kv.cache.get(ROUND_ROBIN_CURSOR_KEY)).toBe("1"); + } + ); + + it("refuses to resolve a target when no managed provider is configured", async () => { + await expect(resolveNextTarget()).rejects.toMatchObject({ + name: "SdpRpcError", + code: "SOLANA_RPC_ERROR", + }); + await expect(resolveRoundRobinRpcTargets({ env, cache: kv.cache })).rejects.toMatchObject({ + name: "SdpRpcError", + code: "SOLANA_RPC_ERROR", }); + }); + + it("lists every provider rotated to the selected one and advances the shared cursor", async () => { + env.SOLANA_RPC_TRITON_URL = "https://rpc.triton.test"; + env.SOLANA_RPC_HELIUS_URL = "https://rpc.helius.test/?api-key={API_KEY}"; + env.SOLANA_RPC_HELIUS_API_KEY = "helius_secret"; + env.SOLANA_RPC_ALCHEMY_URL = "https://rpc.alchemy.test"; + await resolveNextTarget(); - expect(providers.roundRobinOrder[0]).toBe("quicknode"); - expect(providers.roundRobinOrder).toEqual( - expect.arrayContaining(["alchemy", "default", "helius", "quicknode", "triton"]) - ); - expect(providers.selected.providerId).toBe("quicknode"); + const targets = await resolveRoundRobinRpcTargets({ env, cache: kv.cache }); + + expect(targets).toEqual([ + { + providerId: "helius", + endpoint: "https://rpc.helius.test/?api-key=helius_secret", + endpointLabel: "https://rpc.helius.test/?api-key=***", + }, + { + providerId: "alchemy", + endpoint: "https://rpc.alchemy.test", + endpointLabel: "https://rpc.alchemy.test/", + }, + { + providerId: "triton", + endpoint: "https://rpc.triton.test", + endpointLabel: "https://rpc.triton.test/", + }, + ]); + expect((await resolveNextTarget()).providerId).toBe("alchemy"); }); }); diff --git a/apps/sdp-api/src/services/secret-retirement.ts b/apps/sdp-api/src/services/secret-retirement.ts index a2ee7b6cd6..9b5278d069 100644 --- a/apps/sdp-api/src/services/secret-retirement.ts +++ b/apps/sdp-api/src/services/secret-retirement.ts @@ -7,8 +7,8 @@ // alone: the process can die between the write and the compensating destroy // (worker loss), and the destroy itself can fail (timeout, outage). So the // destroy is a durable obligation in `secret_retirements` plus a sweeper -// (`services/jobs/retire-orphaned-secrets.ts`), shared by every consumer — BYOK -// RPC connections and Privy provider credentials. `source_id` is a nullable +// (`services/jobs/retire-orphaned-secrets.ts`), shared by every consumer +// (today, Privy provider credentials). `source_id` is a nullable // trace column with no foreign key, and the sweeper resolves the store per row // from `storage_backend`. // @@ -34,7 +34,7 @@ import type { Env } from "@/types/env"; /** Where the version came from, for logs and the queue's trace columns. */ export interface SecretRetirementContext { - /** Log label, e.g. "rpc_connection" or "privy". */ + /** Log label, e.g. "privy". */ provider: string; orgId: string | null; /** diff --git a/apps/sdp-api/src/services/stores/provider-credential.store.ts b/apps/sdp-api/src/services/stores/provider-credential.store.ts index 3fb31e2bb0..937e362a23 100644 --- a/apps/sdp-api/src/services/stores/provider-credential.store.ts +++ b/apps/sdp-api/src/services/stores/provider-credential.store.ts @@ -21,7 +21,7 @@ export interface ProviderCredentialRow { id: string; organization_id: string; project_id: string | null; - /** Widened from the privy-only literal: RPC connections store credentials here too. */ + /** Widened from the privy-only literal: Helius Rings connections store credentials here too. */ provider: string; label: string; scope: "organization" | "project"; @@ -1175,7 +1175,7 @@ export class ProviderCredentialStore { id: string; organizationId: string; projectId: string | null; - /** Provider family this credential belongs to; RPC connections reuse this insert. */ + /** Provider family this credential belongs to; Helius Rings connections reuse this insert. */ provider: string; label: string; scope: "organization" | "project"; diff --git a/apps/sdp-api/src/services/stores/rpc-connection.store.ts b/apps/sdp-api/src/services/stores/rpc-connection.store.ts deleted file mode 100644 index 18bef45bbf..0000000000 --- a/apps/sdp-api/src/services/stores/rpc-connection.store.ts +++ /dev/null @@ -1,756 +0,0 @@ -import type { RpcConnectionLifecycle, RpcConnectionNetwork, RpcConnectionScope } from "@sdp/types"; -import type { DatabaseExecutor } from "@/db"; - -/** The organization-scope sentinel `scope_key` is generated as. */ -export const ORGANIZATION_SCOPE_KEY = "__organization__"; - -export interface RpcConnectionRow { - id: string; - organization_id: string; - project_id: string | null; - provider: string; - scope: RpcConnectionScope; - scope_key: string; - provider_credential_id: string; - provider_credential_scope_key: string; - network: RpcConnectionNetwork; - status: RpcConnectionLifecycle; - is_default: boolean; - display_metadata: unknown; - activated_at: string | null; - deactivated_at: string | null; - created_at: string; -} - -export interface RpcConnectionListRow extends RpcConnectionRow { - credential_id: string; - credential_label: string; - credential_status: string; -} - -const CONNECTION_COLUMN_NAMES = [ - "id", - "organization_id", - "project_id", - "provider", - "scope", - "scope_key", - "provider_credential_id", - "provider_credential_scope_key", - "network", - "status", - "is_default", - "display_metadata", - "activated_at", - "deactivated_at", - "created_at", -] as const; - -const CONNECTION_COLUMNS = CONNECTION_COLUMN_NAMES.join(", "); -/** Same list, qualified for the statements that join provider_credentials. */ -const JOINED_CONNECTION_COLUMNS = CONNECTION_COLUMN_NAMES.map((column) => `c.${column}`).join(", "); - -export function toScopeKey(projectId: string | null): string { - return projectId ?? ORGANIZATION_SCOPE_KEY; -} - -export class RpcConnectionStore { - constructor(private readonly db: DatabaseExecutor) {} - - /** - * Everything visible in one scope. Organization scope is listed on its own - * rather than unioned with every project: a project admin reading their own - * connections must not enumerate another project's. - */ - async listConnectionsPage( - organizationId: string, - scopeKey: string, - options: { limit: number; offset: number } - ): Promise<{ connections: RpcConnectionListRow[]; total: number }> { - const totalRow = await this.db.queryOne<{ total: number | string }>( - `SELECT COUNT(*) AS total - FROM rpc_connections - WHERE organization_id = ? AND scope_key = ?`, - [organizationId, scopeKey] - ); - - const connections = await this.db.queryMany( - `SELECT ${JOINED_CONNECTION_COLUMNS}, - pc.id AS credential_id, - pc.label AS credential_label, - pc.status AS credential_status - FROM rpc_connections c - JOIN provider_credentials pc ON pc.id = c.provider_credential_id - WHERE c.organization_id = ? AND c.scope_key = ? - ORDER BY c.created_at DESC, c.id DESC - LIMIT ? OFFSET ?`, - [organizationId, scopeKey, options.limit, options.offset] - ); - - return { connections, total: Number(totalRow?.total ?? 0) }; - } - - /** - * Organization- and scope-qualified: an id alone must never resolve a row. - * - * `scopeKeys` is the set the caller is acting within -- the organization - * sentinel plus the selected project, never another project's. Without it an - * administrator working in one project could name a connection belonging to - * another and read or mutate it. - */ - async findConnection( - organizationId: string, - connectionId: string, - scopeKeys: readonly string[] - ): Promise { - return this.db.queryOne( - `SELECT ${CONNECTION_COLUMNS} - FROM rpc_connections - WHERE id = ? - AND organization_id = ? - AND scope_key IN (${scopeKeys.map(() => "?").join(", ")})`, - [connectionId, organizationId, ...scopeKeys] - ); - } - - async insertConnection(params: { - id: string; - organizationId: string; - projectId: string | null; - provider: string; - providerCredentialId: string; - providerCredentialScopeKey: string; - network: RpcConnectionNetwork; - displayMetadata: Record; - createdBy: string | null; - executor?: DatabaseExecutor; - }): Promise { - const db = params.executor ?? this.db; - const row = await db.queryOne( - `INSERT INTO rpc_connections ( - id, organization_id, project_id, provider, scope, - provider_credential_id, provider_credential_scope_key, - network, display_metadata, status, created_by - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?) - RETURNING ${CONNECTION_COLUMNS}`, - [ - params.id, - params.organizationId, - params.projectId, - params.provider, - params.projectId ? "project" : "organization", - params.providerCredentialId, - params.providerCredentialScopeKey, - params.network, - JSON.stringify(params.displayMetadata), - params.createdBy, - ] - ); - if (!row) { - throw new Error("RPC connection insert returned no row"); - } - return row; - } - - /** - * The connection this scope holds for one provider, if it still has one. - * - * Excludes `deactivated` rows because those are terminal: the secret is - * destroyed, so promoting one would report success and route nothing. Used to - * answer "does switching to this provider mean using their own key or ours". - */ - async findLiveConnectionForProvider(params: { - organizationId: string; - scopeKey: string; - network: RpcConnectionNetwork; - provider: string; - executor?: DatabaseExecutor; - }): Promise<{ id: string; is_default: boolean } | null> { - const db = params.executor ?? this.db; - return db.queryOne<{ id: string; is_default: boolean }>( - `SELECT id, is_default - FROM rpc_connections - WHERE organization_id = ? - AND scope_key = ? - AND network = ? - AND provider = ? - AND status <> 'deactivated' - ORDER BY is_default DESC, created_at DESC - LIMIT 1`, - [params.organizationId, params.scopeKey, params.network, params.provider] - ); - } - - /** - * Demote whatever currently holds the default slot for this scope and - * network. Runs inside the activation transaction so the partial unique - * index never sees two winners. - */ - async clearDefault(params: { - organizationId: string; - scopeKey: string; - network: RpcConnectionNetwork; - exceptConnectionId?: string; - executor?: DatabaseExecutor; - }): Promise { - const db = params.executor ?? this.db; - return db.execute( - `UPDATE rpc_connections - SET is_default = FALSE, updated_at = sdp_iso_now() - WHERE organization_id = ? - AND scope_key = ? - AND network = ? - AND is_default = TRUE - AND id <> ?`, - [params.organizationId, params.scopeKey, params.network, params.exceptConnectionId ?? ""] - ); - } - - async activateConnection(params: { - organizationId: string; - connectionId: string; - scopeKeys: readonly string[]; - makeDefault: boolean; - executor?: DatabaseExecutor; - }): Promise { - const db = params.executor ?? this.db; - return db.queryOne( - `UPDATE rpc_connections - SET status = 'active', - activated_at = COALESCE(activated_at, sdp_iso_now()), - deactivated_at = NULL, - is_default = ?, - updated_at = sdp_iso_now() - WHERE id = ? - AND organization_id = ? - AND scope_key IN (${params.scopeKeys.map(() => "?").join(", ")}) - AND status IN ('pending', 'checking', 'failed', 'active') - RETURNING ${CONNECTION_COLUMNS}`, - [params.makeDefault, params.connectionId, params.organizationId, ...params.scopeKeys] - ); - } - - /** - * Promote the credential the connection points at, in the same transaction - * that activates the connection. - * - * `insertCredential` writes `pending`, and a successful activation probe is - * the only evidence the key works. Without this the credential stays pending - * forever while the connection reads active, `findEffectiveConnection` never - * matches, and the organization's traffic keeps leaving on SDP's keys — the - * exact silent fallback this whole path exists to prevent. - * - * Reached through the connection rather than by id so the organization and - * scope checks are the same ones the caller already passed. `deactivated` - * and `retired` are excluded: neither may be resurrected by an activation. - */ - async activateConnectionCredential(params: { - organizationId: string; - connectionId: string; - scopeKeys: readonly string[]; - executor?: DatabaseExecutor; - }): Promise { - const db = params.executor ?? this.db; - return db.execute( - `UPDATE provider_credentials - SET status = 'active', - last_validated_at = sdp_iso_now(), - last_failure_code = NULL, - updated_at = sdp_iso_now() - WHERE id = ( - SELECT c.provider_credential_id - FROM rpc_connections c - WHERE c.id = ? - AND c.organization_id = ? - AND c.scope_key IN (${params.scopeKeys.map(() => "?").join(", ")}) - ) - AND status IN ('pending', 'failed_validation', 'active')`, - [params.connectionId, params.organizationId, ...params.scopeKeys] - ); - } - - /** - * Deactivation drops the default flag in the same statement. Leaving it set - * would keep a dead connection occupying the slot the relay reads. - */ - async deactivateConnection(params: { - organizationId: string; - connectionId: string; - scopeKeys: readonly string[]; - executor?: DatabaseExecutor; - }): Promise { - const db = params.executor ?? this.db; - return db.queryOne( - `UPDATE rpc_connections - SET status = 'deactivated', - is_default = FALSE, - deactivated_at = sdp_iso_now(), - updated_at = sdp_iso_now() - WHERE id = ? - AND organization_id = ? - AND scope_key IN (${params.scopeKeys.map(() => "?").join(", ")}) - AND status <> 'deactivated' - RETURNING ${CONNECTION_COLUMNS}`, - [params.connectionId, params.organizationId, ...params.scopeKeys] - ); - } - - /** - * Point a connection at a freshly stored credential and retire the old one - * (HOO-1229). - * - * Both halves are one statement pair inside the caller's transaction: a - * connection pointing at a retired credential resolves to nothing, so a - * crash between them would take the project off its own key without anyone - * asking for that. - */ - async repointConnectionCredential(params: { - organizationId: string; - connectionId: string; - scopeKeys: readonly string[]; - /** - * The credential the caller read before it built the replacement. Matching - * on it makes this a compare-and-swap: two rotations racing each other both - * see the same previous credential, and without this both would commit. The - * last write would win while the other replacement stayed active with its - * secret stored, and the losing request would return a credential id the - * connection no longer used. - */ - expectedCredentialId: string; - nextCredentialId: string; - nextCredentialScopeKey: string; - executor?: DatabaseExecutor; - }): Promise { - const db = params.executor ?? this.db; - return db.queryOne( - `UPDATE rpc_connections - SET provider_credential_id = ?, - provider_credential_scope_key = ?, - status = 'active', - updated_at = sdp_iso_now() - WHERE id = ? - AND organization_id = ? - AND scope_key IN (${params.scopeKeys.map(() => "?").join(", ")}) - AND status <> 'deactivated' - AND provider_credential_id = ? - RETURNING ${CONNECTION_COLUMNS}`, - [ - params.nextCredentialId, - params.nextCredentialScopeKey, - params.connectionId, - params.organizationId, - ...params.scopeKeys, - params.expectedCredentialId, - ] - ); - } - - /** - * Promote a freshly inserted credential straight to active. Rotation has - * already probed the key, so the `pending` state the insert starts in would - * only be a window where the connection resolves to nothing. - */ - async activateCredentialById(params: { - organizationId: string; - providerCredentialId: string; - executor?: DatabaseExecutor; - }): Promise { - const db = params.executor ?? this.db; - return db.execute( - `UPDATE provider_credentials - SET status = 'active', - last_validated_at = sdp_iso_now(), - last_failure_code = NULL, - updated_at = sdp_iso_now() - WHERE id = ? - AND organization_id = ? - AND status = 'pending'`, - [params.providerCredentialId, params.organizationId] - ); - } - - /** Retire a credential a rotation has replaced. */ - async retireCredential(params: { - organizationId: string; - providerCredentialId: string; - executor?: DatabaseExecutor; - }): Promise { - const db = params.executor ?? this.db; - return db.execute( - `UPDATE provider_credentials - SET status = 'retired', - encrypted_secret_payload = NULL, - updated_at = sdp_iso_now() - WHERE id = ? - AND organization_id = ?`, - [params.providerCredentialId, params.organizationId] - ); - } - - /** - * Live connections anywhere in the organization, across every project and - * network. Used to refuse a fail-closed switch that would have nothing to - * fall closed onto. - */ - async countLiveConnectionsForOrganization(params: { - organizationId: string; - executor?: DatabaseExecutor; - }): Promise { - const db = params.executor ?? this.db; - const row = await db.queryOne<{ live: number }>( - `SELECT COUNT(*)::int AS live - FROM rpc_connections c - JOIN provider_credentials pc ON pc.id = c.provider_credential_id - WHERE c.organization_id = ? - AND c.status = 'active' - AND pc.status = 'active'`, - [params.organizationId] - ); - return row?.live ?? 0; - } - - /** - * How many connections a scope still has that are not withdrawn (HOO-1227). - * - * Deactivated rows are excluded because they are terminal: they hold no - * secret and route nothing, so counting them would block a project that has - * only ever had connections it already gave up. - */ - async countLiveConnections(params: { - organizationId: string; - scopeKey: string; - network: RpcConnectionNetwork; - /** - * Narrow to one provider. A scope may hold a connection per provider now, - * so "does this project already have one" and "does this project already - * have an Alchemy one" are different questions and only the second blocks - * a save. - */ - provider?: string; - executor?: DatabaseExecutor; - }): Promise { - const db = params.executor ?? this.db; - // Built rather than parameterised against NULL: an untyped placeholder - // compared to NULL leaves Postgres unable to infer the parameter type. - const providerClause = params.provider ? " AND provider = ?" : ""; - const values: unknown[] = [params.organizationId, params.scopeKey, params.network]; - if (params.provider) { - values.push(params.provider); - } - const row = await db.queryOne<{ live: number }>( - `SELECT COUNT(*)::int AS live - FROM rpc_connections - WHERE organization_id = ? - AND scope_key = ? - AND network = ? - AND status <> 'deactivated'${providerClause}`, - values - ); - return row?.live ?? 0; - } - - /** - * Remove a deactivated connection and the credential it hung off (HOO-1219). - * - * Only `deactivated` rows match. Deactivation is what destroys the secret, so - * by the time a row is deletable there is nothing left to leak, and the - * credential row is a record of a key that no longer exists anywhere. The - * guard is in the WHERE clause rather than a prior read so a concurrent - * activation cannot slip between the check and the delete. - */ - async deleteDeactivatedConnection(params: { - organizationId: string; - connectionId: string; - scopeKeys: readonly string[]; - executor?: DatabaseExecutor; - }): Promise<{ id: string; provider_credential_id: string } | null> { - const db = params.executor ?? this.db; - return db.queryOne<{ id: string; provider_credential_id: string }>( - `DELETE FROM rpc_connections - WHERE id = ? - AND organization_id = ? - AND scope_key IN (${params.scopeKeys.map(() => "?").join(", ")}) - AND status = 'deactivated' - RETURNING id, provider_credential_id`, - [params.connectionId, params.organizationId, ...params.scopeKeys] - ); - } - - /** - * Drop the credential a deleted connection pointed at, as long as nothing - * else still references it. A credential is per-connection today, but the - * rotation column is a self-reference, so checking is cheaper than assuming. - */ - async deleteOrphanedCredential(params: { - organizationId: string; - providerCredentialId: string; - executor?: DatabaseExecutor; - }): Promise { - const db = params.executor ?? this.db; - return db.execute( - `DELETE FROM provider_credentials - WHERE id = ? - AND organization_id = ? - AND status = 'deactivated' - AND NOT EXISTS ( - SELECT 1 FROM rpc_connections c WHERE c.provider_credential_id = provider_credentials.id - )`, - [params.providerCredentialId, params.organizationId] - ); - } - - /** - * Withdraw the credential together with its connection. The `encrypted_db` - * ciphertext is dropped in the same statement: on self-hosted deployments it - * is the secret itself, and a deactivated credential must not keep a - * decryptable copy of a key the customer withdrew. Secret Manager versions - * are destroyed separately by the service, best effort. - */ - async deactivateConnectionCredential(params: { - organizationId: string; - connectionId: string; - scopeKeys: readonly string[]; - executor?: DatabaseExecutor; - }): Promise { - const db = params.executor ?? this.db; - return db.execute( - `UPDATE provider_credentials - SET status = 'deactivated', - encrypted_secret_payload = NULL, - deactivated_at = sdp_iso_now(), - updated_at = sdp_iso_now() - WHERE id = ( - SELECT c.provider_credential_id - FROM rpc_connections c - WHERE c.id = ? - AND c.organization_id = ? - AND c.scope_key IN (${params.scopeKeys.map(() => "?").join(", ")}) - ) - AND status <> 'deactivated'`, - [params.connectionId, params.organizationId, ...params.scopeKeys] - ); - } - - /** - * Mark a connection as failing its check. - * - * The check *result* is no longer stored (HOO-1228), but the lifecycle flip - * is not a result -- it is what makes the relay fail closed instead of - * quietly answering on platform keys. The redacted code is reported to the - * caller and deliberately not written down. - */ - /** - * Record that a probe rejected this connection. - * - * Matched on the credential the probe actually tested, not on the connection - * alone. A probe is a network call, so a rotation can land while one is in - * flight: the connection then points at a new, proven key, and writing the - * old verdict onto it would mark a working connection failed and clear it out - * of the default slot, failing the project closed over a key that no longer - * exists. The compare-and-swap makes the stale write miss instead. - * - * Returns the rows changed so a caller can tell a real failure from a verdict - * that arrived too late to mean anything. - */ - async markCheckFailed(params: { - organizationId: string; - connectionId: string; - providerCredentialId: string; - scopeKeys: readonly string[]; - executor?: DatabaseExecutor; - }): Promise { - const db = params.executor ?? this.db; - return db.execute( - `UPDATE rpc_connections - SET status = 'failed', - is_default = FALSE, - updated_at = sdp_iso_now() - WHERE id = ? - AND organization_id = ? - AND provider_credential_id = ? - AND scope_key IN (${params.scopeKeys.map(() => "?").join(", ")})`, - [params.connectionId, params.organizationId, params.providerCredentialId, ...params.scopeKeys] - ); - } - - /** - * The credential secret columns for one connection, organization- and - * scope-qualified. `ProviderCredentialStore.findCredential` deliberately - * omits these and does not filter by organization, so activation reads them - * through here instead. - */ - async findConnectionSecret(params: { - organizationId: string; - connectionId: string; - scopeKeys: readonly string[]; - }): Promise<{ - id: string; - label: string; - status: string; - credential_version: number; - storage_backend: string; - secret_ref: string | null; - secret_version_ref: string | null; - encrypted_secret_payload: string | null; - } | null> { - return this.db.queryOne( - `SELECT pc.id, - pc.label, - pc.status, - pc.credential_version, - pc.storage_backend, - pc.secret_ref, - pc.secret_version_ref, - pc.encrypted_secret_payload - FROM rpc_connections c - JOIN provider_credentials pc ON pc.id = c.provider_credential_id - WHERE c.id = ? - AND c.organization_id = ? - AND c.scope_key IN (${params.scopeKeys.map(() => "?").join(", ")})`, - [params.connectionId, params.organizationId, ...params.scopeKeys] - ); - } - - /** - * What the relay asks before falling back (HOO-1093). - * - * Returns the live default when there is one, and otherwise reports whether - * the scope holds a connection at all. The epic requires an explicit but - * unusable connection to fail closed rather than quietly spend SDP's own - * credentials, so "nothing configured" and "configured but broken" cannot be - * the same answer. - * - * Two things this deliberately does not treat as broken: - * - * `pending` and `checking` are drafts. Submitting a connection is not the - * statement of intent — activating it is. An administrator who opens the form - * and never finishes, or whose first probe is still running, must not take - * every RPC call in the organization down; that row has never carried - * traffic, so falling back to the platform rail changes nothing for them. - * - * `failed` is different and does fail closed: that connection was live, the - * organization's traffic was on it, and moving that traffic back onto SDP's - * keys without saying so is the thing being prevented. Re-activating clears - * it once the key is fixed. - * - * The credential predicate matches `findEffectiveConnection` exactly. When - * the two disagreed, this one could call a scope live that the effective - * lookup would not resolve, and every disagreement resolved toward SDP paying. - */ - async findScopeConnectionState(params: { - organizationId: string; - scopeKey: string; - network: RpcConnectionNetwork; - }): Promise<{ kind: "none" } | { kind: "unusable" } | { kind: "active"; connectionId: string }> { - const rows = await this.db.queryMany<{ - id: string; - status: string; - is_default: boolean; - credential_status: string; - }>( - `SELECT c.id, c.status, c.is_default, pc.status AS credential_status - FROM rpc_connections c - JOIN provider_credentials pc ON pc.id = c.provider_credential_id - WHERE c.organization_id = ? - AND c.scope_key = ? - AND c.network = ? - AND c.status NOT IN ('deactivated', 'pending', 'checking')`, - [params.organizationId, params.scopeKey, params.network] - ); - - if (rows.length === 0) { - return { kind: "none" }; - } - - const live = rows.find( - (row) => row.status === "active" && row.is_default && row.credential_status === "active" - ); - if (live) { - return { kind: "active", connectionId: live.id }; - } - - /** - * Only a connection that was *meant* to serve and cannot may fail requests - * closed. Holding keys that are deliberately idle is an ordinary state now: - * a project keeps a key per provider, and choosing a provider it holds none - * for stands them all down so SDP's account answers. - * - * The absence of a default used to be the test, which conflated the two. - * `markCheckFailed` also clears `is_default`, so a broken connection and a - * stood-down one are indistinguishable by that flag: every deliberate - * switch onto a platform provider read as a fault and the relay refused - * every call with "no active default connection". - * - * A failed row still fails closed even though nothing points at it. The - * tenant last saw it serving, and quietly moving their traffic onto keys - * SDP pays for is the outcome this whole feature exists to prevent. - */ - const broken = rows.some( - (row) => row.status !== "active" || (row.is_default && row.credential_status !== "active") - ); - return broken ? { kind: "unusable" } : { kind: "none" }; - } - - /** - * What the relay reads (HOO-1093): the one active default for a scope and - * network. Returns the credential's stored-secret columns so the caller can - * hand them to CredentialSecretStore — this is the only method that carries - * secret *references*, and it is never mapped into a response. - */ - async findEffectiveConnection(params: { - organizationId: string; - scopeKey: string; - network: RpcConnectionNetwork; - }): Promise<{ - connection: RpcConnectionRow; - credential: { - id: string; - storage_backend: string; - secret_ref: string | null; - secret_version_ref: string | null; - encrypted_secret_payload: string | null; - }; - } | null> { - const row = await this.db.queryOne< - RpcConnectionRow & { - credential_id: string; - storage_backend: string; - secret_ref: string | null; - secret_version_ref: string | null; - encrypted_secret_payload: string | null; - } - >( - `SELECT ${JOINED_CONNECTION_COLUMNS}, - pc.id AS credential_id, - pc.storage_backend, - pc.secret_ref, - pc.secret_version_ref, - pc.encrypted_secret_payload - FROM rpc_connections c - JOIN provider_credentials pc ON pc.id = c.provider_credential_id - WHERE c.organization_id = ? - AND c.scope_key = ? - AND c.network = ? - AND c.status = 'active' - AND c.is_default = TRUE - AND pc.status = 'active' - LIMIT 1`, - [params.organizationId, params.scopeKey, params.network] - ); - - if (!row) { - return null; - } - - return { - connection: row, - credential: { - id: row.credential_id, - storage_backend: row.storage_backend, - secret_ref: row.secret_ref, - secret_version_ref: row.secret_version_ref, - encrypted_secret_payload: row.encrypted_secret_payload, - }, - }; - } -} diff --git a/apps/sdp-api/src/test/helpers/custody.ts b/apps/sdp-api/src/test/helpers/custody.ts index 8ad0851e18..aecd719d60 100644 --- a/apps/sdp-api/src/test/helpers/custody.ts +++ b/apps/sdp-api/src/test/helpers/custody.ts @@ -2,16 +2,22 @@ * Custody test helpers */ -import { getDb } from "@/db"; +import { type DatabaseExecutor, getDb } from "@/db"; import type { SigningConfigRecord } from "@/services/adapters/signing"; import type { CustodyWallet } from "@/services/stores/custody-config.store"; import type { Env } from "@/types/env"; /** - * Seed a custody config into the test database. + * Insert a custody config and point its scope default at it when active. + * @param db - Executor the inserts run on. + * @param config - Custody config row to insert. + * @returns Resolves once the config and scope default are written. */ -export async function seedTestCustodyConfig(env: Env, config: SigningConfigRecord): Promise { - await getDb(env) +async function insertTestCustodyConfig( + db: DatabaseExecutor, + config: SigningConfigRecord +): Promise { + await db .prepare( `INSERT INTO custody_configs (id, organization_id, project_id, provider, config_encrypted, encryption_version, default_wallet_id, status, created_at, updated_at) @@ -32,7 +38,7 @@ export async function seedTestCustodyConfig(env: Env, config: SigningConfigRecor .run(); if (config.status === "active") { - const existingDefault = await getDb(env) + const existingDefault = await db .prepare( config.projectId ? `SELECT id @@ -50,7 +56,7 @@ export async function seedTestCustodyConfig(env: Env, config: SigningConfigRecor .first<{ id: string }>(); if (existingDefault) { - await getDb(env) + await db .prepare( `UPDATE custody_scope_defaults SET default_custody_config_id = ?, updated_at = datetime('now') @@ -59,7 +65,7 @@ export async function seedTestCustodyConfig(env: Env, config: SigningConfigRecor .bind(config.id, existingDefault.id) .run(); } else { - await getDb(env) + await db .prepare( `INSERT INTO custody_scope_defaults (id, organization_id, project_id, default_custody_config_id) VALUES (?, ?, ?, ?)` @@ -71,10 +77,13 @@ export async function seedTestCustodyConfig(env: Env, config: SigningConfigRecor } /** - * Seed a custody wallet into the test database. + * Insert a custody wallet. + * @param db - Executor the insert runs on. + * @param wallet - Custody wallet row to insert. + * @returns Resolves once the wallet is written. */ -export async function seedTestCustodyWallet(env: Env, wallet: CustodyWallet): Promise { - await getDb(env) +async function insertTestCustodyWallet(db: DatabaseExecutor, wallet: CustodyWallet): Promise { + await db .prepare( `INSERT INTO custody_wallets (id, custody_config_id, wallet_id, public_key, label, purpose, status, created_at) @@ -94,15 +103,23 @@ export async function seedTestCustodyWallet(env: Env, wallet: CustodyWallet): Pr } /** - * Seed full custody setup (config + wallet) for an organization. + * Seed a custody config and its wallet in one transaction, so a config whose + * `defaultWalletId` names that wallet satisfies the deferred + * `custody_configs_default_wallet_fkey` at commit. + * @param env - Test environment bindings. + * @param config - Custody config row to insert. + * @param wallet - Custody wallet row owned by `config`. + * @returns Resolves once the transaction commits. */ export async function seedTestCustodySetup( env: Env, config: SigningConfigRecord, wallet: CustodyWallet ): Promise { - await seedTestCustodyConfig(env, config); - await seedTestCustodyWallet(env, wallet); + await getDb(env).transaction(async (tx) => { + await insertTestCustodyConfig(tx, config); + await insertTestCustodyWallet(tx, wallet); + }); } /** diff --git a/apps/sdp-api/src/test/helpers/env.ts b/apps/sdp-api/src/test/helpers/env.ts index 9b3e1001c5..54cf4d664e 100644 --- a/apps/sdp-api/src/test/helpers/env.ts +++ b/apps/sdp-api/src/test/helpers/env.ts @@ -93,3 +93,32 @@ export const env = { ...providedEnv, db: getDb(providedEnv), }; + +const MANAGED_RPC_ENV_KEYS = [ + "SOLANA_RPC_URL", + "SOLANA_RPC_DEFAULT_PROVIDER", + "SOLANA_RPC_TRITON_URL", + "SOLANA_RPC_TRITON_API_KEY", + "SOLANA_RPC_HELIUS_URL", + "SOLANA_RPC_HELIUS_API_KEY", + "SOLANA_RPC_ALCHEMY_URL", + "SOLANA_RPC_ALCHEMY_API_KEY", + "SOLANA_RPC_QUICKNODE_URL", + "SOLANA_RPC_QUICKNODE_API_KEY", + "SOLANA_RPC_VALIDATIONCLOUD_URL", + "SOLANA_RPC_VALIDATIONCLOUD_API_KEY", + "SOLANA_RPC_NODIT_URL", + "SOLANA_RPC_NODIT_API_KEY", +] as const satisfies readonly (keyof Env)[]; + +/** + * Unset every managed RPC pool key on the shared test `env`, so a relay test + * configures exactly the providers it names. + * + * @returns Nothing; mutates `env` in place. + */ +export function resetManagedRpcEnv(): void { + for (const key of MANAGED_RPC_ENV_KEYS) { + env[key] = undefined; + } +} diff --git a/apps/sdp-api/src/test/helpers/migration-db.ts b/apps/sdp-api/src/test/helpers/migration-db.ts index 43d98285ef..f38abb0123 100644 --- a/apps/sdp-api/src/test/helpers/migration-db.ts +++ b/apps/sdp-api/src/test/helpers/migration-db.ts @@ -61,6 +61,40 @@ export async function seedOrgProject( return { organizationId, projectId, userId }; } +/** + * Seeds an active project-scoped credential stored in the database. + * + * @param client - Connection the seed runs on. + * @param input - The credential to seed. + * @param input.id - Credential id. + * @param input.label - Credential label. + * @param input.organizationId - Owning organization. + * @param input.projectId - Project the credential is scoped to. + * @param input.userId - User recorded as its creator. + * @param input.provider - Provider the credential is for. + * @returns Resolves once the row is inserted. + */ +export async function seedStoredProviderCredential( + client: Client, + input: { + id: string; + label: string; + organizationId: string; + projectId: string; + userId: string; + provider: string; + } +): Promise { + await client.query( + `INSERT INTO provider_credentials ( + id, organization_id, project_id, provider, label, scope, source, + storage_backend, encrypted_secret_payload, status, created_by + ) VALUES ($1, $2, $3, $4, $5, 'project', 'stored', + 'encrypted_db', 'test-only', 'active', $6)`, + [input.id, input.organizationId, input.projectId, input.provider, input.label, input.userId] + ); +} + /** Seeds the active project connection required by Helius Rings operations. */ export async function seedHeliusRingsConnection( client: Client, @@ -74,14 +108,14 @@ export async function seedHeliusRingsConnection( const credentialId = `pcred_hr_${input.tag}`; const connectionId = `hrconn_${input.tag}`; - await client.query( - `INSERT INTO provider_credentials ( - id, organization_id, project_id, provider, label, scope, source, - storage_backend, encrypted_secret_payload, status, created_by - ) VALUES ($1, $2, $3, 'helius_rings', $4, 'project', 'stored', - 'encrypted_db', 'test-only', 'active', $5)`, - [credentialId, input.organizationId, input.projectId, input.tag, input.userId] - ); + await seedStoredProviderCredential(client, { + id: credentialId, + label: input.tag, + organizationId: input.organizationId, + projectId: input.projectId, + userId: input.userId, + provider: "helius_rings", + }); await client.query( `INSERT INTO helius_rings_connections ( id, organization_id, project_id, name, provider_credential_id, diff --git a/apps/sdp-docs/public/postman/solana-developer-platform-public.postman_collection.json b/apps/sdp-docs/public/postman/solana-developer-platform-public.postman_collection.json index fe91a7726c..ef9df8932f 100644 --- a/apps/sdp-docs/public/postman/solana-developer-platform-public.postman_collection.json +++ b/apps/sdp-docs/public/postman/solana-developer-platform-public.postman_collection.json @@ -567,7 +567,7 @@ "description": "Updates project attributes.", "body": { "mode": "raw", - "raw": "{\n \"name\": \"Payments Updated\",\n \"description\": \"Updated project description.\",\n \"settings\": {\n \"rpcProvider\": \"custom\",\n \"rpcEndpoint\": \"https://rpc.example.com\"\n }\n}", + "raw": "{\n \"name\": \"Payments Updated\",\n \"description\": \"Updated project description.\",\n \"settings\": {\n \"webhookUrl\": \"https://example.com/webhook\"\n }\n}", "options": { "raw": { "language": "json" diff --git a/apps/sdp-web/messages/en/dashboard-custody.json b/apps/sdp-web/messages/en/dashboard-custody.json index d57f98105e..c3d3f9b038 100644 --- a/apps/sdp-web/messages/en/dashboard-custody.json +++ b/apps/sdp-web/messages/en/dashboard-custody.json @@ -206,44 +206,8 @@ "organizationNotFound": "Organization not found.", "organizationNotLinked": "This Clerk organization is not linked to an SDP organization yet.", "editingOrganization": "Editing organization: {name}", - "viewOnlyRpcSettings": "You can view organization RPC settings, but only admins can change them.", - "noRpcProviders": "No RPC providers are enabled for this organization in the current environment.", - "rpcFallback": "The saved RPC provider for this organization is no longer available on the current tier. The form has fallen back to {provider} until you save an enabled provider.", "saving": "Saving...", "saveFallback": "Save fallback", - "rpcProvider": "RPC provider", - "testing": "Testing...", - "testRpc": "Test RPC", - "rpcDetailTitle": "Last test", - "rpcDetailReachable": "Reachable", - "rpcDetailUnreachable": "Unreachable", - "rpcDetailMismatch": "Another provider answered", - "rpcDetailResolvedProvider": "Resolved provider", - "rpcDetailSelectionMode": "Selection mode", - "rpcSelectionProjectConnection": "This project's own connection", - "rpcSelectionOrganizationConnection": "An organization connection", - "rpcSelectionProjectProvider": "This project's provider setting", - "rpcSelectionProjectCustomProvider": "This project's custom endpoint", - "rpcSelectionOrganizationProvider": "The organization's provider setting", - "rpcSelectionRoundRobinDefault": "SDP's shared providers", - "rpcDetailEndpoint": "Endpoint", - "rpcDetailUpstream": "Upstream status", - "rpcDetailLatency": "Latency", - "rpcDetailLatencyValue": "{ms} ms", - "saveInProgress": "Save in progress.", - "tryAgainSoon": "Try again in a moment.", - "checkingRpcProvider": "Checking RPC provider.", - "rpcCheckPassed": "RPC check passed.", - "rpcCheckFailed": "RPC check failed.", - "anotherProvider": "another provider", - "failedToSaveRpcSettings": "Failed to save RPC settings.", - "missingOrganizationId": "Missing organization id.", - "rpcProviderSaveMismatch": "RPC provider save mismatch (requested {requested}, persisted {persisted}).", - "rpcSettingsSaved": "RPC settings saved.", - "failedToTestRpcProvider": "Failed to test RPC provider.", - "rpcTestMismatch": "{resolved} answered this test, not {requested}. The endpoint was reached; a higher-priority setting picked a different provider.", - "rpcUpstreamReturned": "RPC upstream returned {status} {statusText}.", - "rpcTestPassed": "RPC test passed ({status} {statusText}) in {latency}ms.", "missingApiKeyIdForDeletion": "Missing API key id for deletion.", "missingApiKeyNameForDeletion": "Missing API key name for deletion confirmation.", "confirmApiKeyDeletion": "Type the key name to confirm API key deletion.", @@ -791,13 +755,6 @@ "connectionsLoadError": "Unable to load connections", "connectionsLoadErrorDescription": "Something went wrong reading this project's connections. Try again in a moment.", "policyAmountPlaceholder": "0.00", - "integrationRpcDefaultDescription": "Use SDP-managed RPC infrastructure with no credentials to configure.", - "integrationRpcAlchemyDescription": "Route Solana requests through your configured Alchemy connection.", - "integrationRpcHeliusDescription": "Use Helius infrastructure for Solana RPC requests.", - "integrationRpcNoditDescription": "Use Nodit for your Solana RPC traffic.", - "integrationRpcQuickNodeDescription": "Use your configured QuickNode endpoint for Solana RPC.", - "integrationRpcTritonDescription": "Route Solana requests through Triton's infrastructure.", - "integrationRpcValidationCloudDescription": "Use Validation Cloud for your Solana RPC traffic.", "connectionPageTitle": "Connection", "backToProvider": "Back to {provider}", "connectionsTitle": "Connections", diff --git a/apps/sdp-web/messages/en/dashboard-private-channels.json b/apps/sdp-web/messages/en/dashboard-private-channels.json index 51cf5c8dab..0af2e9b67a 100644 --- a/apps/sdp-web/messages/en/dashboard-private-channels.json +++ b/apps/sdp-web/messages/en/dashboard-private-channels.json @@ -423,8 +423,6 @@ "setupDetailsDescription": "Enter the Private Channels endpoints and deployed program addresses for this project. SDP uses the project's configured RPC automatically.", "gatewayUrl": "Gateway URL", "gatewayPlaceholder": "http://gateway.example:8899", - "chainRpcUrl": "Project RPC", - "chainRpcPlaceholder": "Uses the RPC integration configured for this project.", "authUrl": "Auth URL", "authPlaceholder": "http://auth.example:8903", "escrowProgramId": "Escrow program ID", @@ -447,7 +445,7 @@ "statusFailed": "Failed", "gatewayNotReady": "Gateway responded but is not ready", "connectionTestFailed": "Some endpoints could not be reached. Check the highlighted connection details and try again.", - "projectRpcTestFailed": "The project RPC could not verify the escrow deployment. Fix the project's RPC integration or deployment addresses and try again.", + "projectRpcTestFailed": "The project RPC could not verify the escrow deployment. Check the escrow deployment addresses and try again.", "connectionRequestFailed": "We couldn't test the connection. Check the connection details and try again.", "connectionTestSuccess": "Connection works.", "latency": "{ms} ms", diff --git a/apps/sdp-web/messages/en/shared.json b/apps/sdp-web/messages/en/shared.json index 773ee4199b..118416d836 100644 --- a/apps/sdp-web/messages/en/shared.json +++ b/apps/sdp-web/messages/en/shared.json @@ -373,8 +373,6 @@ "ctaRequestAccess": "Request access", "custodyTitle": "Custody", "custodyDescription": "Wallet infrastructure and signing. Install from the wallet setup flow.", - "rpcTitle": "RPC", - "rpcDescription": "Solana RPC for this organization. One provider serves at a time; open a provider to add your own key, switch to it, or test the connection.", "rampsTitle": "Ramps", "rampsDescription": "Onramp and offramp rails used by deposits and payouts.", "complianceTitle": "Compliance", @@ -394,21 +392,6 @@ "clearFilters": "Clear filters", "emptyTitle": "No integrations match", "emptyBody": "Try a different search or clear the filters.", - "rpcSectionAction": "Change in Settings", - "rpcConnectionTitle": "Connection", - "rpcActiveProviderLabel": "Active provider", - "rpcActiveHere": "This organization's RPC traffic runs through this provider.", - "rpcActiveOverridden": "This organization is set to this provider, but this project runs on your own {provider} connection instead.", - "rpcActiveOwnCredential": "This project runs on your own connection with this provider, not on SDP's account.", - "rpcServedByProject": "This project runs on your own {provider} connection.", - "rpcActiveElsewhere": "This organization currently runs on {provider}.", - "rpcTestConnection": "Test connection", - "rpcUseThisProvider": "Use this provider", - "rpcSwitchedToOwnKey": "Now serving from your own {provider} key.", - "rpcSwitchedToPlatform": "Now serving from SDP's {provider} account.", - "rpcNotConfiguredHere": "This deployment holds no endpoint for this provider, so it cannot be selected here.", - "rpcActiveSelectedOnly": "This organization is set to use this provider.", - "rpcActiveUnavailable": "This deployment no longer holds an endpoint for this provider, so RPC traffic is falling back to another one.", "rampMoonpayDescription": "Card and bank fiat onramp and offramp.", "rampLightsparkDescription": "Fiat settlement rails over the Lightning Network.", "rampBvnkDescription": "Business payments between fiat and stablecoins.", @@ -432,58 +415,6 @@ "connectSelfServe": "Connect this provider yourself using credentials from your own account. Secrets are write-only and never shown again after submission.", "connectYouWillNeed": "You will need", "connectByArrangement": "Available by arrangement. The SDP team grants access and sets up the connection with your organization.", - "connectManaged": "Provisioned per deployment. This provider is configured by your SDP operator rather than self-serve.", - "rpcByokTitle": "Your own credentials", - "rpcByokDescription": "Route this project's RPC through an account you own. Keys are encrypted and never shown again.", - "rpcByokLabel": "Connection name", - "rpcByokNetwork": "Network", - "rpcByokEndpoint": "Endpoint URL", - "rpcByokEndpointHint": "Your account's endpoint. If the key goes inside the URL, mark the spot with the placeholder below.", - "rpcByokApiKey": "API key", - "rpcByokApiKeyHint": "Sent once and stored encrypted. Saving checks it against the provider straight away.", - "rpcByokAdd": "Add connection", - "rpcByokAdding": "Adding…", - "rpcByokAdded": "Connection saved and serving traffic.", - "rpcByokUpdated": "Connection updated.", - "rpcByokDeactivate": "Deactivate", - "rpcByokServing": "Serving traffic", - "rpcByokEmpty": "No credentials of your own yet. This organization is running on SDP's.", - "rpcByokEmptyRoutedElsewhere": "No credentials for this provider. This project runs on your own {provider} connection.", - "rpcByokAdminOnly": "Only organization administrators can add credentials.", - "rpcByokLabelPlaceholder": "Production key", - "rpcByokCancel": "Cancel", - "rpcByokSave": "Save connection", - "rpcByokShowKey": "Reveal key", - "rpcByokHideKey": "Hide key", - "rpcByokUnavailable": "Your own credentials could not be loaded just now. Reload before assuming there are none.", - "rpcByokRestricted": "Only organization administrators can see the credentials stored here.", - "rpcByokOrganizationScoped": "Set up for the whole organization. Connections belong to a project now, so this one is not routing traffic. Add it again on the project that needs it.", - "rpcByokDeactivateHolding": "Hold to deactivate…", - "rpcByokDeactivatedMeaning": "Withdrawn. The stored key was destroyed, so this connection cannot be brought back. Add a new one to route through your own provider again.", - "rpcByokLastActive": "Withdrawing this key puts this project back on SDP's account.", - "rpcByokLastActiveByok": "This organization runs only on its own credentials, so withdrawing this key stops RPC instead of falling back to SDP's.", - "rpcByokServingHasSpare": "Withdrawing this key will not hand over to your other keys. Switch to the provider you want first.", - "rpcByokDelete": "Delete", - "rpcByokDeleteConfirm": "Delete this connection? Its key was destroyed when it was deactivated, so this only clears the record.", - "rpcByokDeleteNamed": "Delete {name}", - "rpcByokDeleted": "Connection deleted.", - "rpcModeTitle": "Run entirely on your own credentials", - "rpcModeToggle": "Run entirely on your own credentials", - "rpcModeManagedHint": "SDP's providers answer for any project without a connection of its own. Turn this on once every project you use has one.", - "rpcModeByokHint": "SDP's providers will not answer for this organization. A project without a live connection fails instead of falling back to our keys.", - "rpcModeSaved": "Credential mode updated.", - "rpcModeStranded": "Nothing is serving. Every RPC call for this organization is failing until a connection is added, or until this is switched back off.", - "rpcByokTakenElsewhere": "This project already routes through {provider}.", - "rpcByokOnlyOne": "This provider already holds a key for this project. Rotate it above to replace it, or deactivate it to start over.", - "rpcByokReady": "Ready", - "rpcByokAddAlongside": "Your own {provider} connection is serving this project. Add a key here and you can switch to it whenever you want; traffic keeps running on {provider} until you do.", - "rpcByokRotate": "Rotate key", - "rpcByokRotateHint": "The connection keeps serving on the current key until the new one has been checked. If the new key is rejected, nothing changes.", - "rpcByokNewApiKey": "New API key", - "rpcByokRotateSave": "Rotate", - "rpcByokRotated": "Key rotated.", - "rpcByokTest": "Test key", - "rpcByokTestPassed": "Reached the provider just now.", - "rpcByokTestFailed": "Could not reach the provider." + "connectManaged": "Provisioned per deployment. This provider is configured by your SDP operator rather than self-serve." } } diff --git a/apps/sdp-web/public/provider-logos/alchemy.svg b/apps/sdp-web/public/provider-logos/alchemy.svg deleted file mode 100644 index 2e2068857a..0000000000 --- a/apps/sdp-web/public/provider-logos/alchemy.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/apps/sdp-web/public/provider-logos/helius.svg b/apps/sdp-web/public/provider-logos/helius.svg deleted file mode 100644 index e8b09a4c2a..0000000000 --- a/apps/sdp-web/public/provider-logos/helius.svg +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/sdp-web/public/provider-logos/nodit.svg b/apps/sdp-web/public/provider-logos/nodit.svg deleted file mode 100644 index e7436f619d..0000000000 --- a/apps/sdp-web/public/provider-logos/nodit.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/sdp-web/public/provider-logos/quicknode.svg b/apps/sdp-web/public/provider-logos/quicknode.svg deleted file mode 100644 index bab3e6b62c..0000000000 --- a/apps/sdp-web/public/provider-logos/quicknode.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/sdp-web/public/provider-logos/triton.svg b/apps/sdp-web/public/provider-logos/triton.svg deleted file mode 100644 index ddc227f34f..0000000000 --- a/apps/sdp-web/public/provider-logos/triton.svg +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/sdp-web/public/provider-logos/validation-cloud.svg b/apps/sdp-web/public/provider-logos/validation-cloud.svg deleted file mode 100644 index b5c2e59b7b..0000000000 --- a/apps/sdp-web/public/provider-logos/validation-cloud.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/apps/sdp-web/src/app/api/dashboard/settings/rpc-test/route.ts b/apps/sdp-web/src/app/api/dashboard/settings/rpc-test/route.ts deleted file mode 100644 index e5be5f52f9..0000000000 --- a/apps/sdp-web/src/app/api/dashboard/settings/rpc-test/route.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { proxyToSdpApi } from "@/lib/sdp-api"; - -export async function POST(request: Request) { - return proxyToSdpApi({ - request, - traceSource: "route.dashboard.settings.rpc-test", - path: "/v1/rpc/test", - }); -} diff --git a/apps/sdp-web/src/app/dashboard/custody/actions.ts b/apps/sdp-web/src/app/dashboard/custody/actions.ts index 78fc45d9eb..d90b5f34bc 100644 --- a/apps/sdp-web/src/app/dashboard/custody/actions.ts +++ b/apps/sdp-web/src/app/dashboard/custody/actions.ts @@ -410,7 +410,6 @@ async function waitForSignatureConfirmation( interface RpcRelayResponse { provider: { id: string; - selectionMode: string; endpoint: string; }; upstream: { diff --git a/apps/sdp-web/src/app/dashboard/custody/actions.unit.test.ts b/apps/sdp-web/src/app/dashboard/custody/actions.unit.test.ts index 4778d88670..fe8d42e9b6 100644 --- a/apps/sdp-web/src/app/dashboard/custody/actions.unit.test.ts +++ b/apps/sdp-web/src/app/dashboard/custody/actions.unit.test.ts @@ -96,7 +96,7 @@ describe("requestDevnetSolanaFaucetAction", () => { function relay(response: unknown) { return { - provider: { id: "helius", selectionMode: "default", endpoint: "https://rpc.example" }, + provider: { id: "helius", endpoint: "https://rpc.example" }, upstream: { ok: true, status: 200, statusText: "OK" }, response, }; diff --git a/apps/sdp-web/src/app/dashboard/custody/wallets-playground-config.ts b/apps/sdp-web/src/app/dashboard/custody/wallets-playground-config.ts index f91f064f8c..9d89628256 100644 --- a/apps/sdp-web/src/app/dashboard/custody/wallets-playground-config.ts +++ b/apps/sdp-web/src/app/dashboard/custody/wallets-playground-config.ts @@ -200,7 +200,7 @@ export function buildWalletsPlaygroundEndpointConfigs({ buildSelectOrTextField( "provider", t("DashboardCustody.playgroundProviderField"), - t("DashboardCustody.rpcProvider"), + t("DashboardCustody.provider"), providerOptions ), { diff --git a/apps/sdp-web/src/app/dashboard/integrations/[provider]/integration-detail-view.tsx b/apps/sdp-web/src/app/dashboard/integrations/[provider]/integration-detail-view.tsx index c559afdf57..d9e811f30d 100644 --- a/apps/sdp-web/src/app/dashboard/integrations/[provider]/integration-detail-view.tsx +++ b/apps/sdp-web/src/app/dashboard/integrations/[provider]/integration-detail-view.tsx @@ -1,4 +1,4 @@ -import type { CustodyWalletSummary, OrganizationRpcProvider, SafeRpcConnection } from "@sdp/types"; +import type { CustodyWalletSummary } from "@sdp/types"; import { VenetianMaskIcon } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; @@ -9,7 +9,6 @@ import type { } from "@/app/dashboard/custody/connections/connections.data"; import { CUSTODY_CAPABILITY_LABEL_KEYS } from "@/app/dashboard/custody/provider-catalog"; import { WalletProviderMark } from "@/app/dashboard/custody/wallet-provider-mark"; -import { RpcProviderMark } from "@/app/dashboard/integrations/rpc-provider-mark"; import { docsHref } from "@/components/dashboard-nav"; import { Button } from "@/components/ui/button"; import { getTranslations } from "@/i18n/server"; @@ -17,48 +16,6 @@ import { COMPLIANCE_PROVIDER_LOGOS } from "@/lib/compliance"; import { RAMP_PROVIDER_LOGOS } from "@/lib/ramps"; import { CustodyConnectionCount, CustodyConnectionsSection } from "../custody-connections-section"; import type { IntegrationDetail } from "../integration-detail"; -import { RpcByokSection } from "../rpc-byok-section"; -import { RpcConnectionPanel } from "../rpc-connection-panel"; - -/** - * What the RPC family needs beyond the shared detail shape. Absent for every - * other family, and absent for RPC only if the organization could not be - * resolved — in which case the page falls back to the Settings link. - */ -export interface RpcConnectionContext { - activeProvider: OrganizationRpcProvider; - canManage: boolean; - /** Whether this deployment holds an endpoint for the provider on the page. */ - isEnabledInDeployment: boolean; - organizationId: string; - /** - * Tenant-owned connections for this provider; absent for SDP's own rail, - * `null` when the read failed and we must not claim there are none. - */ - byokConnections?: SafeRpcConnection[] | null | "restricted"; - /** `null` when it could not be read, or the viewer may not manage it. */ - credentialMode?: "managed" | "byok" | null; - /** Live connections across the whole organization, for the fail-closed warning. */ - liveConnectionCount?: number; - /** - * Live connections this project holds across every provider, not just the one - * on this page. A project may hold a proven key per provider, so "is this the - * last one" cannot be answered from the narrowed list the section renders. - */ - liveProjectConnections?: number; - /** - * Providers this project holds its own key for. Read by the header status, - * which must not call a provider the tenant configured themselves "Not - * configured" just because this deployment carries no URL for it. - */ - providersWithOwnKey?: readonly string[]; - /** - * The provider whose connection the relay would actually route this project - * through, whichever provider that is. `null` when nothing of the tenant's - * own serves it and the platform selection still decides. - */ - servingProvider?: string | null; -} type Translate = Awaited>; @@ -79,11 +36,16 @@ function statusKey(status: IntegrationDetail["status"]): Parameters[0 } } +/** + * The family title key shown under the provider name. + * + * @param family - The provider's integration family. + * @returns The message key for the family title. + */ function familyKey(family: IntegrationDetail["family"]): Parameters[0] { return ( { custody: "Shared.integrations.custodyTitle", - rpc: "Shared.integrations.rpcTitle", ramps: "Shared.integrations.rampsTitle", compliance: "Shared.integrations.complianceTitle", privacy: "Shared.integrations.privacyTitle", @@ -91,13 +53,17 @@ function familyKey(family: IntegrationDetail["family"]): Parameters[0 )[family]; } +/** + * The provider's logo mark for the detail header. + * + * @param props - The component props. + * @param props.detail - The resolved provider detail. + * @returns The family-specific mark. + */ function DetailMark({ detail }: { detail: IntegrationDetail }) { if (detail.family === "custody" && detail.custodyEntry) { return ; } - if (detail.family === "rpc") { - return ; - } if (detail.family === "privacy") { return ; } @@ -126,6 +92,13 @@ function Section({ title, children }: { title: string; children: React.ReactNode ); } +/** + * The header's state-correct action, if the provider's state offers one. + * + * @param detail - The resolved provider detail. + * @param t - The translator. + * @returns The action button, or `null` when no action applies. + */ function resolvePrimaryAction(detail: IntegrationDetail, t: Translate) { // With the connection state unreadable, no state-dependent action is honest. if (detail.status === "unknown") { @@ -156,9 +129,6 @@ function resolvePrimaryAction(detail: IntegrationDetail, t: Translate) { ); } - // RPC acts through the connection panel below. When the organization could - // not be resolved the panel does not render either, and there is no honest - // action left to offer -- Settings no longer holds RPC (HOO-787). return null; } @@ -230,9 +200,15 @@ function DetailHeader({ /** * The project's connections, or the reason there are none on screen. * - * Placed above everything but the header for the same reason as the RPC panel: - * on an integration you can act on, what this project has connected outranks - * what the provider is. + * Placed above everything but the header: on an integration you can act on, + * what this project has connected outranks what the provider is. + * + * @param props - The block's inputs. + * @param props.detail - The integration being shown. + * @param props.custodyConnections - The project's custody connections for this provider. + * @param props.canManageCustody - Whether the caller may manage custody connections. + * @param props.t - The translator. + * @returns The connections block. */ function CustodyConnectionsBlock({ detail, @@ -271,55 +247,6 @@ function CustodyConnectionsBlock({ ); } -function RpcSections({ - detail, - rpc, - t, -}: { - detail: IntegrationDetail; - rpc?: RpcConnectionContext; - t: Translate; -}) { - if (detail.family !== "rpc" || !rpc) { - return null; - } - return ( - <> -
- connection.scope === "project" && connection.status !== "deactivated" - ) - } - provider={detail.provider as OrganizationRpcProvider} - servingProvider={rpc.servingProvider ?? null} - status={detail.status} - /> -
- - {rpc.byokConnections !== undefined ? ( -
- -
- ) : null} - - ); -} - function CapabilitiesSection({ entry, t, @@ -404,14 +331,22 @@ function HowItConnectsBody({ detail, t }: { detail: IntegrationDetail; t: Transl ); } +/** + * One provider's detail page: header, custody connections, and the shared + * about, capabilities, connection and resources sections. + * + * @param props - The component props. + * @param props.detail - The resolved provider detail. + * @param props.custodyConnections - The project's custody connections, or why there are none. + * @param props.canManageCustody - Whether the viewer may manage custody connections. + * @returns The rendered detail page. + */ export async function IntegrationDetailView({ detail, - rpc, custodyConnections = null, canManageCustody = false, }: { detail: IntegrationDetail; - rpc?: RpcConnectionContext; custodyConnections?: CustodyConnectionsContext; canManageCustody?: boolean; }) { @@ -433,8 +368,6 @@ export async function IntegrationDetailView({ t={t} /> - - {detail.descriptionKey ? (

diff --git a/apps/sdp-web/src/app/dashboard/integrations/[provider]/integration-detail-view.unit.test.tsx b/apps/sdp-web/src/app/dashboard/integrations/[provider]/integration-detail-view.unit.test.tsx index d31c67a147..c0811f589c 100644 --- a/apps/sdp-web/src/app/dashboard/integrations/[provider]/integration-detail-view.unit.test.tsx +++ b/apps/sdp-web/src/app/dashboard/integrations/[provider]/integration-detail-view.unit.test.tsx @@ -6,25 +6,13 @@ import { resolveComplianceIntegrations, resolveCustodyIntegrations, resolveRampIntegrations, - resolveRpcIntegrations, } from "../integrations-status"; -import type { RpcConnectionContext } from "./integration-detail-view"; import { IntegrationDetailView } from "./integration-detail-view"; vi.mock("next/headers", () => ({ cookies: async () => ({ get: () => ({ value: "en" }) }), headers: async () => new Headers(), })); -// The panel is a client component with its own test; here we only care that -// the view mounts it for the RPC family and drops the Settings signpost. -vi.mock("../rpc-connection-panel", () => ({ - RpcConnectionPanel: ({ provider }: { provider: string }) => ( -

rpc-connection-panel
- ), -})); -vi.mock("../rpc-byok-section", () => ({ - RpcByokSection: ({ provider }: { provider: string }) =>
, -})); const on = { entitled: true, configured: true, enabled: true }; const off = { entitled: false, configured: false, enabled: false }; @@ -34,10 +22,6 @@ const INPUTS = { connectedProviders: ["privy"], enabledProviders: ["privy", "para"], }), - rpc: resolveRpcIntegrations({ - selectedProvider: "helius", - entries: { helius: on, alchemy: on }, - }), ramps: resolveRampIntegrations({ moonpay: on }), compliance: resolveComplianceIntegrations({ range: off }), }; @@ -91,33 +75,6 @@ describe("IntegrationDetailView", () => { expect(compliance).not.toContain("typeform.com"); }); - it("manages an RPC provider on its own page instead of sending it to Settings", async () => { - const detail = resolveIntegrationDetail({ ...INPUTS, provider: "helius" }); - if (!detail) throw new Error("expected detail"); - const markup = renderToStaticMarkup( - await IntegrationDetailView({ - detail, - rpc: { - activeProvider: "helius", - canManage: true, - isEnabledInDeployment: true, - organizationId: "org_1", - }, - }) - ); - expect(markup).toContain("Connection"); - expect(markup).toContain('data-rpc-panel="helius"'); - expect(markup).not.toContain("Change in Settings"); - }); - - it("offers no RPC action when the organization could not be resolved", async () => { - const markup = await render("helius"); - // No rpc context means no panel. Settings no longer holds RPC, so linking - // there would be a dead end rather than a fallback. - expect(markup).not.toContain("/dashboard/settings"); - expect(markup).not.toContain("rpc-connection-panel"); - }); - it("offers no state-dependent action when the connection state is unknown", async () => { const detail = resolveIntegrationDetail({ ...INPUTS, provider: "privy", custody: null }); if (!detail) throw new Error("expected detail"); @@ -128,32 +85,14 @@ describe("IntegrationDetailView", () => { }); it("keeps the shared skeleton within one block of every family", async () => { - // The skeleton cannot match all four families, so the rule is that it sits - // within one block of each. A new section pushes some family to two. const skeleton = ( renderToStaticMarkup().match(/rounded-2xl/g) ?? [] ).length; - const cases: Array<[string, RpcConnectionContext | undefined]> = [ - [ - "helius", - { - activeProvider: "helius", - canManage: true, - isEnabledInDeployment: true, - organizationId: "o", - byokConnections: [], - }, - ], - ["privy", undefined], - ["moonpay", undefined], - ["range", undefined], - ]; - - for (const [provider, rpc] of cases) { + for (const provider of ["privy", "moonpay", "range"]) { const detail = resolveIntegrationDetail({ provider, ...INPUTS }); if (!detail) throw new Error(provider); - const markup = renderToStaticMarkup(await IntegrationDetailView({ detail, rpc })); + const markup = renderToStaticMarkup(await IntegrationDetailView({ detail })); const blocks = (markup.match(/
connection.scope === "project" && connection.status !== "deactivated" - ).length; - - return { - connections: all.filter((connection) => connection.provider === provider), - liveProjectConnections, - servingProvider: findServingProvider(all), - // A provider the project holds its own key for is usable whatever this - // deployment carries, so the header must not call it Not configured. - providersWithOwnKey: findProvidersWithOwnKey(all), - }; - } catch { - return null; - } -} - -/** - * Flatten the loader's answer into the props the view takes. The sentinels - * (`undefined` not an RPC provider, `"restricted"` not permitted, `null` the - * read failed) name no serving provider, so they pass straight through. - */ -function resolveByokProps(result: Awaited>) { - if (result === undefined || result === null || result === "restricted") { - return { - byokConnections: result, - liveProjectConnections: 0, - servingProvider: null, - providersWithOwnKey: [] as string[], - }; - } - return { - byokConnections: result.connections, - liveProjectConnections: result.liveProjectConnections, - servingProvider: result.servingProvider, - providersWithOwnKey: result.providersWithOwnKey, - }; -} - -/** - * Whose credentials the organization runs on. `null` when it could not be - * read: the control is hidden rather than shown defaulted, because rendering - * "SDP-managed" at an organization that is actually on its own keys is the - * kind of wrong that gets acted on. - */ -async function getRpcCredentialMode( - canManage: boolean -): Promise<{ mode: "managed" | "byok"; liveConnections: number } | null> { - if (!canManage) { - return null; - } - - try { - const client = await createSdpApiClient(); - return await client.fetch<{ mode: "managed" | "byok"; liveConnections: number }>( - "/internal/dashboard/rpc/credential-mode" - ); - } catch { - return null; - } -} - /** * What the banners above the table assert, or a summary that admits it knows * nothing. Degraded on its own because it costs several requests where the @@ -284,26 +168,29 @@ async function resolveRequestContext() { dashboardAccess, projectClient, organizationId: onboarding.organization.id, - // The shell only routes here after onboarding, so a missing setting means - // the organization runs on SDP's default RPC, not "none". - activeRpcProvider: (onboarding.setup?.rpcProvider ?? "default") as OrganizationRpcProvider, }; } type ProviderAvailability = Awaited>; +/** + * Resolves the provider on this page against the same family inputs the + * catalog reads, so the detail header and the catalog card agree. + * + * @param params - The provider and the family inputs to resolve it against. + * @param params.provider - The provider id from the route. + * @param params.connectedProviders - Active custody providers, or `null` when the lookup failed. + * @param params.availability - The organization's provider availability. + * @returns The provider's detail, or `null` when no family lists it. + */ function resolveDetail({ provider, connectedProviders, availability, - activeRpcProvider, - byok, }: { provider: string; connectedProviders: KnownCustodyProvider[] | null; availability: ProviderAvailability; - activeRpcProvider: OrganizationRpcProvider; - byok: ReturnType; }) { return resolveIntegrationDetail({ provider, @@ -314,20 +201,20 @@ function resolveDetail({ connectedProviders, enabledProviders: availability.enabledCustodyProviders, }), - rpc: resolveRpcIntegrations({ - selectedProvider: activeRpcProvider, - // The header badge answers the same question the panel under it does, so - // it has to read the same source. It used to read the selection alone and - // say Connected on a provider the project's traffic never touched. - servingProvider: byok.servingProvider, - providersWithOwnKey: byok.providersWithOwnKey, - entries: availability.providers.rpc, - }), ramps: resolveRampIntegrations(availability.providers.ramps), compliance: resolveComplianceIntegrations(availability.providers.compliance), }); } +/** + * One provider's detail page, 404ing for unknown providers and for providers + * whose module flag is off. + * + * @param props - The route props. + * @param props.params - The route params carrying the provider id. + * @param props.searchParams - The query, read by the custody connections table. + * @returns The rendered detail view. + */ export default async function IntegrationDetailPage({ params, searchParams, @@ -357,30 +244,26 @@ export default async function IntegrationDetailPage({ notFound(); } - const { dashboardAccess, projectClient, organizationId, activeRpcProvider } = - await resolveRequestContext(); + const { dashboardAccess, projectClient, organizationId } = await resolveRequestContext(); const connectionsProvider = resolveConnectionsProvider(provider, custodyEnabled); const custodyConnectionsApply = connectionsProvider !== null && (await privyByok()); const resolvedSearchParams = (await searchParams) ?? {}; - const [availability, connectedProviders, credentialModeState, byokState, connectionsRead] = - await Promise.all([ - fetchProviderAvailability(projectClient.request, organizationId), - custodyEnabled - ? getConnectedCustodyProviders(projectClient.request).catch(() => null) - : Promise.resolve([]), - getRpcCredentialMode(dashboardAccess.capabilities.canManageOrgSettings), - getByokConnections(provider, dashboardAccess.capabilities.canManageOrgSettings), - custodyConnectionsApply && connectionsProvider - ? getCustodyConnections( - projectClient.request, - connectionsProvider, - dashboardAccess.capabilities.canManageCustody, - resolvedSearchParams - ) - : Promise.resolve(null), - ]); + const [availability, connectedProviders, connectionsRead] = await Promise.all([ + fetchProviderAvailability(projectClient.request, organizationId), + custodyEnabled + ? getConnectedCustodyProviders(projectClient.request).catch(() => null) + : Promise.resolve([]), + custodyConnectionsApply && connectionsProvider + ? getCustodyConnections( + projectClient.request, + connectionsProvider, + dashboardAccess.capabilities.canManageCustody, + resolvedSearchParams + ) + : Promise.resolve(null), + ]); // A `?page=` past the end is answered with the address that page lives at, // not with its rows under the stale URL: served in place, the footer read @@ -396,13 +279,10 @@ export default async function IntegrationDetailPage({ ); } - const byok = resolveByokProps(byokState); const detail = resolveDetail({ provider, connectedProviders, availability, - activeRpcProvider, - byok, }); if (!detail) { @@ -414,20 +294,6 @@ export default async function IntegrationDetailPage({ detail={detail} custodyConnections={connectionsRead?.context ?? null} canManageCustody={dashboardAccess.capabilities.canManageCustody} - rpc={ - detail.family === "rpc" - ? { - activeProvider: activeRpcProvider, - canManage: dashboardAccess.capabilities.canManageOrgSettings, - isEnabledInDeployment: - availability.providers.rpc[provider as OrganizationRpcProvider]?.enabled ?? false, - organizationId, - ...byok, - credentialMode: credentialModeState?.mode ?? null, - liveConnectionCount: credentialModeState?.liveConnections ?? 0, - } - : undefined - } /> ); } diff --git a/apps/sdp-web/src/app/dashboard/integrations/integration-detail.ts b/apps/sdp-web/src/app/dashboard/integrations/integration-detail.ts index ffb2fde9f2..8b83500ec6 100644 --- a/apps/sdp-web/src/app/dashboard/integrations/integration-detail.ts +++ b/apps/sdp-web/src/app/dashboard/integrations/integration-detail.ts @@ -1,5 +1,5 @@ -import type { ComplianceProviderId, OrganizationRpcProvider, RampProviderId } from "@sdp/types"; -import { COMPLIANCE_PROVIDERS, ORGANIZATION_RPC_PROVIDERS, RAMP_PROVIDERS } from "@sdp/types"; +import type { ComplianceProviderId, RampProviderId } from "@sdp/types"; +import { COMPLIANCE_PROVIDERS, RAMP_PROVIDERS } from "@sdp/types"; import { CUSTODY_PROVIDER_CATALOG, type CustodyProviderCatalogEntry, @@ -34,7 +34,6 @@ export interface IntegrationDetail { * click, because nothing typed the literals against the provider unions. */ const KNOWN_NON_CUSTODY_PROVIDERS: ReadonlySet = new Set([ - ...ORGANIZATION_RPC_PROVIDERS.filter((provider) => provider !== "default"), ...RAMP_PROVIDERS, ...COMPLIANCE_PROVIDERS, ]); @@ -45,10 +44,19 @@ export function isKnownIntegrationProvider(id: string): boolean { ); } +/** + * Finds the provider in the family inputs the catalog renders from. + * + * @param input - The provider and the resolved family entries. + * @param input.provider - The provider id from the route. + * @param input.custody - Custody availability, or `null` when the connection lookup failed. + * @param input.ramps - Ramp integration entries. + * @param input.compliance - Compliance integration entries. + * @returns The provider's detail, or `null` when no family lists it. + */ export function resolveIntegrationDetail(input: { provider: string; custody: CustodyProviderAvailability[] | null; - rpc: IntegrationEntry[]; ramps: IntegrationEntry[]; compliance: IntegrationEntry[]; }): IntegrationDetail | null { @@ -89,7 +97,6 @@ export function resolveIntegrationDetail(input: { } for (const [family, entries] of [ - ["rpc", input.rpc], ["ramps", input.ramps], ["compliance", input.compliance], ] as const) { diff --git a/apps/sdp-web/src/app/dashboard/integrations/integration-detail.unit.test.ts b/apps/sdp-web/src/app/dashboard/integrations/integration-detail.unit.test.ts index d49838d2e0..f4d2568889 100644 --- a/apps/sdp-web/src/app/dashboard/integrations/integration-detail.unit.test.ts +++ b/apps/sdp-web/src/app/dashboard/integrations/integration-detail.unit.test.ts @@ -4,7 +4,6 @@ import { resolveComplianceIntegrations, resolveCustodyIntegrations, resolveRampIntegrations, - resolveRpcIntegrations, } from "./integrations-status"; const on = { entitled: true, configured: true, enabled: true }; @@ -14,7 +13,6 @@ const INPUTS = { connectedProviders: ["privy"], enabledProviders: ["privy", "para"], }), - rpc: resolveRpcIntegrations({ selectedProvider: "helius", entries: { helius: on } }), ramps: resolveRampIntegrations({ moonpay: on }), compliance: resolveComplianceIntegrations({}), }; @@ -42,7 +40,6 @@ describe("integration detail", () => { }); it("resolves every non-custody family", () => { - expect(resolveIntegrationDetail({ provider: "helius", ...INPUTS })?.family).toBe("rpc"); expect(resolveIntegrationDetail({ provider: "moonpay", ...INPUTS })?.status).toBe("enabled"); expect(resolveIntegrationDetail({ provider: "range", ...INPUTS })?.family).toBe("compliance"); }); @@ -57,7 +54,7 @@ describe("integration detail", () => { it("recognises every provider the catalog can render, without a hand-written list", () => { // Guards the drift Opeyemi flagged: a newly added ramp used to get a card // that 404'd on click, because the id lists here were literals. - for (const family of [INPUTS.rpc, INPUTS.ramps, INPUTS.compliance]) { + for (const family of [INPUTS.ramps, INPUTS.compliance]) { for (const row of family) { expect(isKnownIntegrationProvider(row.provider)).toBe(true); } @@ -71,9 +68,4 @@ describe("integration detail", () => { expect(isKnownIntegrationProvider("not-a-provider")).toBe(false); expect(resolveIntegrationDetail({ provider: "nope", ...INPUTS })).toBeNull(); }); - - it("rejects SDP's round-robin routing mode as an integration provider", () => { - expect(isKnownIntegrationProvider("default")).toBe(false); - expect(resolveIntegrationDetail({ provider: "default", ...INPUTS })).toBeNull(); - }); }); diff --git a/apps/sdp-web/src/app/dashboard/integrations/integration-feature-gates.ts b/apps/sdp-web/src/app/dashboard/integrations/integration-feature-gates.ts index eca75514b3..18a9258c41 100644 --- a/apps/sdp-web/src/app/dashboard/integrations/integration-feature-gates.ts +++ b/apps/sdp-web/src/app/dashboard/integrations/integration-feature-gates.ts @@ -11,8 +11,11 @@ export type IntegrationFeatureFlags = { /** * Product-owned integration families follow the same release switch as their - * dashboard module. RPC remains generally available because it is platform - * infrastructure rather than a gated product workspace. + * dashboard module. + * + * @param family - The integration family to check. + * @param flags - The resolved dashboard feature flags. + * @returns Whether the family is shown in the catalog. */ export function isIntegrationFamilyEnabled( family: IntegrationFamily, @@ -27,12 +30,16 @@ export function isIntegrationFamilyEnabled( return flags.policies; case "privacy": return flags.privateChannels; - case "rpc": - return true; } } -/** Keeps provider deep links aligned with the families shown in the catalog. */ +/** + * Keeps provider deep links aligned with the families shown in the catalog. + * + * @param provider - A provider id that `isKnownIntegrationProvider` accepted. + * @param flags - The resolved dashboard feature flags. + * @returns Whether the provider's family is enabled; false for an id outside every family. + */ export function isIntegrationProviderEnabled( provider: string, flags: Pick @@ -40,5 +47,5 @@ export function isIntegrationProviderEnabled( if (isKnownCustodyProvider(provider)) return flags.custody; if ((RAMP_PROVIDERS as readonly string[]).includes(provider)) return flags.payments; if ((COMPLIANCE_PROVIDERS as readonly string[]).includes(provider)) return flags.policies; - return true; + return false; } diff --git a/apps/sdp-web/src/app/dashboard/integrations/integration-feature-gates.unit.test.ts b/apps/sdp-web/src/app/dashboard/integrations/integration-feature-gates.unit.test.ts index 6b2e585d1c..329749b1fd 100644 --- a/apps/sdp-web/src/app/dashboard/integrations/integration-feature-gates.unit.test.ts +++ b/apps/sdp-web/src/app/dashboard/integrations/integration-feature-gates.unit.test.ts @@ -14,13 +14,18 @@ const ALL_DISABLED = { }; describe("integration feature gates", () => { - it("keeps only the general RPC family when every product module is disabled", () => { - const families: IntegrationFamily[] = ["custody", "rpc", "ramps", "compliance", "privacy"]; - - expect(families.filter((family) => isIntegrationFamilyEnabled(family, ALL_DISABLED))).toEqual([ - "rpc", - ]); - }); + it.each([ + ["custody", "custody"], + ["ramps", "payments"], + ["compliance", "policies"], + ["privacy", "privateChannels"], + ] as const satisfies ReadonlyArray)( + "shows the %s family only with the %s module", + (family, flag) => { + expect(isIntegrationFamilyEnabled(family, ALL_DISABLED)).toBe(false); + expect(isIntegrationFamilyEnabled(family, { ...ALL_DISABLED, [flag]: true })).toBe(true); + } + ); it.each([ ["privy", "custody"], @@ -30,8 +35,4 @@ describe("integration feature gates", () => { expect(isIntegrationProviderEnabled(provider, ALL_DISABLED)).toBe(false); expect(isIntegrationProviderEnabled(provider, { ...ALL_DISABLED, [flag]: true })).toBe(true); }); - - it("keeps general RPC provider routes available", () => { - expect(isIntegrationProviderEnabled("helius", ALL_DISABLED)).toBe(true); - }); }); diff --git a/apps/sdp-web/src/app/dashboard/integrations/integration-provider-feature-gate.unit.test.ts b/apps/sdp-web/src/app/dashboard/integrations/integration-provider-feature-gate.unit.test.ts index 8dbbd630a1..e93fceb49b 100644 --- a/apps/sdp-web/src/app/dashboard/integrations/integration-provider-feature-gate.unit.test.ts +++ b/apps/sdp-web/src/app/dashboard/integrations/integration-provider-feature-gate.unit.test.ts @@ -40,11 +40,4 @@ describe("integration provider route feature gates", () => { ); expect(mocks.auth).not.toHaveBeenCalled(); }); - - it("keeps RPC provider routes independent of product module flags", async () => { - await expect( - IntegrationDetailPage({ params: Promise.resolve({ provider: "helius" }) }) - ).rejects.toThrow("NEXT_REDIRECT"); - expect(mocks.auth).toHaveBeenCalledOnce(); - }); }); diff --git a/apps/sdp-web/src/app/dashboard/integrations/integrations-catalog.tsx b/apps/sdp-web/src/app/dashboard/integrations/integrations-catalog.tsx index a8ac33c85a..5944300669 100644 --- a/apps/sdp-web/src/app/dashboard/integrations/integrations-catalog.tsx +++ b/apps/sdp-web/src/app/dashboard/integrations/integrations-catalog.tsx @@ -1,10 +1,9 @@ "use client"; -import type { ComplianceProviderId, OrganizationRpcProvider, RampProviderId } from "@sdp/types"; +import type { ComplianceProviderId, RampProviderId } from "@sdp/types"; import { ArrowLeftRightIcon, ChevronRight, - CircleDotDashedIcon, ShieldCheckIcon, VenetianMaskIcon, WalletIcon, @@ -14,7 +13,6 @@ import Link from "next/link"; import { type ReactNode, useMemo, useState } from "react"; import type { CustodyProviderAvailability } from "@/app/dashboard/custody/provider-display-status"; import { WalletProviderMark } from "@/app/dashboard/custody/wallet-provider-mark"; -import { RpcProviderMark } from "@/app/dashboard/integrations/rpc-provider-mark"; import { Button } from "@/components/ui/button"; import { SearchInput } from "@/components/ui/search-input"; import { useTranslations } from "@/i18n/provider"; @@ -135,7 +133,7 @@ function IntegrationCard({ row, t }: { row: IntegrationRowModel; t: Translate }) } function LogoMark({ src, label }: { src: string; label: string }) { - // Mirrors the wallet and RPC marks: logos sit on a white chip so dark-mode + // Mirrors the wallet marks: logos sit on a white chip so dark-mode // artwork with transparent backgrounds stays legible. return ( []; ramps: IntegrationEntry[]; compliance: IntegrationEntry[]; privacy?: IntegrationEntry[]; @@ -256,16 +273,6 @@ export function IntegrationsCatalog({ description: t(provider.entry.descriptionKey), })); - // No card carries an action: the detail page's header owns the - // state-correct one, and the section header links shared destinations. - const rpcRows: IntegrationRowModel[] = rpc.map((provider) => ({ - family: "rpc", - provider: provider.provider, - label: provider.label, - status: provider.status, - icon: , - description: provider.descriptionKey ? t(provider.descriptionKey) : undefined, - })); const rampRows: IntegrationRowModel[] = ramps.map((provider) => ({ family: "ramps", provider: provider.provider, @@ -292,8 +299,8 @@ export function IntegrationsCatalog({ description: provider.descriptionKey ? t(provider.descriptionKey) : undefined, })); - return [...custodyRows, ...rpcRows, ...rampRows, ...complianceRows, ...privacyRows]; - }, [custody, rpc, ramps, compliance, privacy, t]); + return [...custodyRows, ...rampRows, ...complianceRows, ...privacyRows]; + }, [custody, ramps, compliance, privacy, t]); const visible = rows.filter( (row) => diff --git a/apps/sdp-web/src/app/dashboard/integrations/integrations-catalog.unit.test.tsx b/apps/sdp-web/src/app/dashboard/integrations/integrations-catalog.unit.test.tsx index 0beb8fdb5c..9692f11799 100644 --- a/apps/sdp-web/src/app/dashboard/integrations/integrations-catalog.unit.test.tsx +++ b/apps/sdp-web/src/app/dashboard/integrations/integrations-catalog.unit.test.tsx @@ -20,7 +20,7 @@ vi.mock("@/lib/dashboard-url-state", () => ({ useDashboardTab: () => urlState.tab, })); -function renderCatalog(overrides: Partial[0]> = {}) { +function renderCatalog(overrides: Partial[0]>) { return render( enabledProviders: ["privy", "para"], }) } - rpc={[ + ramps={[ { - provider: "helius", - label: "Helius", - status: "active", - descriptionKey: "DashboardCustody.integrationRpcHeliusDescription", + provider: "moonpay", + label: "MoonPay", + status: "enabled", + descriptionKey: "Shared.integrations.rampMoonpayDescription", }, - { provider: "alchemy", label: "Alchemy", status: "available" }, + { provider: "lightspark", label: "Lightspark", status: "enabled" }, ]} - ramps={[{ provider: "moonpay", label: "MoonPay", status: "enabled" }]} compliance={[{ provider: "range", label: "Range", status: "request_access" }]} privacy={overrides.privacy} enabledFamilies={overrides.enabledFamilies} @@ -63,10 +62,10 @@ describe("IntegrationsCatalog", () => { }); it("uses a category hub on the landing page without status filters", () => { - renderCatalog(); + renderCatalog({}); expect(document.querySelector("[data-integrations-hub='true']")).toBeTruthy(); - expect(document.querySelectorAll("[data-integration-hub-action]")).toHaveLength(5); + expect(document.querySelectorAll("[data-integration-hub-action]")).toHaveLength(4); expect(screen.queryAllByRole("listitem")).toHaveLength(0); expect(screen.queryByRole("searchbox")).toBeNull(); expect(screen.queryByText("All")).toBeNull(); @@ -76,38 +75,38 @@ describe("IntegrationsCatalog", () => { }); it("shows a provider catalog only for the sidebar category that is selected", () => { - urlState.tab = "rpc"; - renderCatalog(); + urlState.tab = "ramps"; + renderCatalog({}); - expect(visibleRowLabels()).toEqual(["Helius", "Alchemy"]); + expect(visibleRowLabels()).toEqual(["MoonPay", "Lightspark"]); expect(document.querySelector("[data-integrations-hub='true']")).toBeNull(); expect(screen.getByRole("searchbox")).toBeTruthy(); }); it("returns disabled and unknown categories to the hub", () => { urlState.tab = "custody"; - renderCatalog({ enabledFamilies: ["rpc"] }); + renderCatalog({ enabledFamilies: ["ramps"] }); expect(document.querySelectorAll("[data-integration-hub-action]")).toHaveLength(1); - expect(document.querySelector("[data-integration-hub-action='rpc']")).toBeTruthy(); + expect(document.querySelector("[data-integration-hub-action='ramps']")).toBeTruthy(); }); it("searches within the selected category", async () => { const user = userEvent.setup(); - urlState.tab = "rpc"; - renderCatalog(); + urlState.tab = "ramps"; + renderCatalog({}); - await user.type(screen.getByRole("searchbox"), "alchemy"); - expect(visibleRowLabels()).toEqual(["Alchemy"]); + await user.type(screen.getByRole("searchbox"), "lightspark"); + expect(visibleRowLabels()).toEqual(["Lightspark"]); await user.clear(screen.getByRole("searchbox")); - expect(visibleRowLabels()).toEqual(["Helius", "Alchemy"]); + expect(visibleRowLabels()).toEqual(["MoonPay", "Lightspark"]); }); it("offers an empty state with a reset when nothing matches", async () => { const user = userEvent.setup(); - urlState.tab = "rpc"; - renderCatalog(); + urlState.tab = "ramps"; + renderCatalog({}); await user.type(screen.getByRole("searchbox"), "zzz-no-such-provider"); @@ -119,10 +118,8 @@ describe("IntegrationsCatalog", () => { it("keeps cards action-free: browsing here, acting on the detail page", () => { urlState.tab = "custody"; - renderCatalog(); + renderCatalog({}); - // RPC is managed on each provider's own page now (HOO-787), so the section - // no longer signposts Settings. expect(screen.queryAllByRole("link", { name: "Change in Settings" })).toHaveLength(0); expect(screen.queryAllByRole("link", { name: "Manage" })).toHaveLength(0); expect(screen.queryAllByRole("link", { name: "Configure" })).toHaveLength(0); @@ -133,14 +130,14 @@ describe("IntegrationsCatalog", () => { }); it("fills every row with a description instead of dead space", () => { - urlState.tab = "rpc"; - renderCatalog(); + urlState.tab = "ramps"; + renderCatalog({}); - expect(screen.getByText("Use Helius infrastructure for Solana RPC requests.")).toBeTruthy(); + expect(screen.getByText("Card and bank fiat onramp and offramp.")).toBeTruthy(); }); it("keeps the custody-unknown alert off other family tabs", () => { - urlState.tab = "rpc"; + urlState.tab = "ramps"; renderCatalog({ custody: null }); expect(screen.queryByRole("alert")).toBeNull(); diff --git a/apps/sdp-web/src/app/dashboard/integrations/integrations-filter.ts b/apps/sdp-web/src/app/dashboard/integrations/integrations-filter.ts index aff0a49f41..64886186b3 100644 --- a/apps/sdp-web/src/app/dashboard/integrations/integrations-filter.ts +++ b/apps/sdp-web/src/app/dashboard/integrations/integrations-filter.ts @@ -1,10 +1,9 @@ import type { IntegrationStatus } from "./integrations-status"; -export type IntegrationFamily = "custody" | "rpc" | "ramps" | "compliance" | "privacy"; +export type IntegrationFamily = "custody" | "ramps" | "compliance" | "privacy"; export const INTEGRATION_FAMILIES: IntegrationFamily[] = [ "custody", - "rpc", "ramps", "compliance", "privacy", diff --git a/apps/sdp-web/src/app/dashboard/integrations/integrations-filter.unit.test.ts b/apps/sdp-web/src/app/dashboard/integrations/integrations-filter.unit.test.ts index 29fb109cb4..a660206cba 100644 --- a/apps/sdp-web/src/app/dashboard/integrations/integrations-filter.unit.test.ts +++ b/apps/sdp-web/src/app/dashboard/integrations/integrations-filter.unit.test.ts @@ -4,7 +4,7 @@ import { type FilterableIntegration, matchesFilters, NO_FILTERS } from "./integr const ROWS: FilterableIntegration[] = [ { family: "custody", provider: "privy", label: "Privy", status: "active" }, { family: "custody", provider: "fireblocks", label: "Fireblocks", status: "request_access" }, - { family: "rpc", provider: "helius", label: "Helius", status: "active" }, + { family: "privacy", provider: "private-channels", label: "Private Channels", status: "active" }, { family: "ramps", provider: "moonpay", label: "MoonPay", status: "enabled" }, ]; @@ -24,14 +24,14 @@ describe("integration filters", () => { const connected = ROWS.filter((row) => matchesFilters(row, { ...NO_FILTERS, status: "connected" }) ); - expect(connected.map((row) => row.provider)).toEqual(["privy", "helius", "moonpay"]); + expect(connected.map((row) => row.provider)).toEqual(["privy", "private-channels", "moonpay"]); }); it("folds the two off states into one chip", () => { // `available` and `not_configured` both mean "not running"; the difference // is whether it could be switched on, which is the detail page's business. const rows: FilterableIntegration[] = [ - { family: "rpc", provider: "alchemy", label: "Alchemy", status: "not_configured" }, + { family: "compliance", provider: "elliptic", label: "Elliptic", status: "not_configured" }, { family: "custody", provider: "turnkey", label: "Turnkey", status: "available" }, ]; diff --git a/apps/sdp-web/src/app/dashboard/integrations/integrations-skeleton.tsx b/apps/sdp-web/src/app/dashboard/integrations/integrations-skeleton.tsx index 4685795d04..3fc544dd02 100644 --- a/apps/sdp-web/src/app/dashboard/integrations/integrations-skeleton.tsx +++ b/apps/sdp-web/src/app/dashboard/integrations/integrations-skeleton.tsx @@ -1,4 +1,4 @@ -import { COMPLIANCE_PROVIDERS, ORGANIZATION_RPC_PROVIDERS, RAMP_PROVIDERS } from "@sdp/types"; +import { COMPLIANCE_PROVIDERS, RAMP_PROVIDERS } from "@sdp/types"; import { CUSTODY_PROVIDER_CATALOG } from "@/app/dashboard/custody/provider-catalog"; import { INTEGRATION_FAMILIES, type IntegrationFamily } from "./integrations-filter"; @@ -6,13 +6,9 @@ import { INTEGRATION_FAMILIES, type IntegrationFamily } from "./integrations-fil * How many cards each family settles at, read from the same catalogues the page * maps over. A flat four per section left the placeholder 22% shorter than the * page it stood in for, because custody alone renders ten. - * - * `default` is only listed while the organization runs on it, so RPC is counted - * one short of the union rather than assuming it shows. */ const FAMILY_CARD_COUNTS: Record = { custody: CUSTODY_PROVIDER_CATALOG.filter((entry) => entry.visible).length, - rpc: ORGANIZATION_RPC_PROVIDERS.length - 1, ramps: RAMP_PROVIDERS.length, compliance: COMPLIANCE_PROVIDERS.length, privacy: 1, @@ -97,9 +93,9 @@ export function IntegrationDetailSkeleton() {
{/* One shape stands in for every family, and they differ: ramps and - compliance settle at 4 blocks, custody at 5, RPC at 6 once Connection - and "Your own credentials" are counted. Five is the median, so no - family jumps more than one block. Measured by the test beside this. */} + compliance settle at 4 blocks, custody at 5. Five matches custody, so + no family jumps more than one block. Measured by the test beside + this. */} diff --git a/apps/sdp-web/src/app/dashboard/integrations/integrations-skeleton.unit.test.tsx b/apps/sdp-web/src/app/dashboard/integrations/integrations-skeleton.unit.test.tsx index 53671939fe..7d47a013e1 100644 --- a/apps/sdp-web/src/app/dashboard/integrations/integrations-skeleton.unit.test.tsx +++ b/apps/sdp-web/src/app/dashboard/integrations/integrations-skeleton.unit.test.tsx @@ -1,4 +1,4 @@ -import { COMPLIANCE_PROVIDERS, ORGANIZATION_RPC_PROVIDERS, RAMP_PROVIDERS } from "@sdp/types"; +import { COMPLIANCE_PROVIDERS, RAMP_PROVIDERS } from "@sdp/types"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { CUSTODY_PROVIDER_CATALOG } from "@/app/dashboard/custody/provider-catalog"; @@ -22,9 +22,6 @@ describe("integration detail skeleton", () => { it("carries a block for every section the detail page renders", () => { const markup = renderToStaticMarkup(); - // Header, Connection, credentials, About, How it connects, Resources — - // the RPC family renders the most sections, and the skeleton is sized - // close to it. expect(markup.match(/rounded-2xl/g)).toHaveLength(5); }); @@ -61,7 +58,6 @@ describe("integration detail skeleton", () => { const privacyCards = 1; const expected = CUSTODY_PROVIDER_CATALOG.filter((entry) => entry.visible).length + - (ORGANIZATION_RPC_PROVIDERS.length - 1) + RAMP_PROVIDERS.length + COMPLIANCE_PROVIDERS.length + privacyCards; diff --git a/apps/sdp-web/src/app/dashboard/integrations/integrations-status.ts b/apps/sdp-web/src/app/dashboard/integrations/integrations-status.ts index dfbcd0a2a9..2adb8c7db9 100644 --- a/apps/sdp-web/src/app/dashboard/integrations/integrations-status.ts +++ b/apps/sdp-web/src/app/dashboard/integrations/integrations-status.ts @@ -1,17 +1,11 @@ -import type { - ComplianceProviderId, - OrganizationRpcProvider, - ProviderAvailabilityEntry, - RampProviderId, -} from "@sdp/types"; -import { COMPLIANCE_PROVIDERS, ORGANIZATION_RPC_PROVIDERS, RAMP_PROVIDERS } from "@sdp/types"; +import type { ComplianceProviderId, ProviderAvailabilityEntry, RampProviderId } from "@sdp/types"; +import { COMPLIANCE_PROVIDERS, RAMP_PROVIDERS } from "@sdp/types"; import type { KnownCustodyProvider } from "@/app/dashboard/custody/provider-catalog"; import { type CustodyProviderAvailability, resolveCustodyProviderAvailability, } from "@/app/dashboard/custody/provider-display-status"; import type { MessageKey } from "@/i18n/messages"; -import { RPC_PROVIDER_LABELS } from "@/lib/rpc-providers"; /** * One vocabulary across every provider family, aligned with the @@ -19,8 +13,9 @@ import { RPC_PROVIDER_LABELS } from "@/lib/rpc-providers"; * built and runnable, so a status only ever answers "what is my next step": * * - `active` — running for this organization now. Only families that hold a - * real per-organization link (a custody connection, the selected RPC) may - * report it; a deployment-wide rail is never "connected" to anyone. + * real per-organization link (a custody connection, an active Private + * Channels instance) may report it; a deployment-wide rail is never + * "connected" to anyone. * - `available` — the organization can use or set this up from here. * - `enabled` — a deployment-wide rail (ramps, compliance) that is on for this * organization; there is nothing to connect. @@ -52,16 +47,6 @@ export interface IntegrationEntry { descriptionKey?: MessageKey; } -const RPC_DESCRIPTION_KEYS: Record = { - alchemy: "DashboardCustody.integrationRpcAlchemyDescription", - default: "DashboardCustody.integrationRpcDefaultDescription", - helius: "DashboardCustody.integrationRpcHeliusDescription", - nodit: "DashboardCustody.integrationRpcNoditDescription", - quicknode: "DashboardCustody.integrationRpcQuickNodeDescription", - triton: "DashboardCustody.integrationRpcTritonDescription", - validationcloud: "DashboardCustody.integrationRpcValidationCloudDescription", -}; - const RAMP_DESCRIPTION_KEYS: Record = { moonpay: "Shared.integrations.rampMoonpayDescription", lightspark: "Shared.integrations.rampLightsparkDescription", @@ -79,12 +64,6 @@ const COMPLIANCE_DESCRIPTION_KEYS: Record = { chainalysis: "Shared.integrations.complianceChainalysisDescription", }; -/** - * Re-exported so the catalog's consumers keep one import path now that the - * labels are shared with Settings and the integration detail controls. - */ -export { RPC_PROVIDER_LABELS }; - export const RAMP_PROVIDER_LABELS: Record = { moonpay: "MoonPay", lightspark: "Lightspark", @@ -109,72 +88,6 @@ export function resolveCustodyIntegrations(input: { return resolveCustodyProviderAvailability(input); } -/** - * Which RPC provider is actually carrying this project's traffic — that one is - * active, and the rest of the enabled set is available to switch to. - * - * A tenant connection outranks the organization's selection, because that is - * the order the relay resolves in: it only reaches `organization_provider` - * after tenant resolution returns nothing. Reading `active` off the selection - * alone marked the provider a project had merely *picked* as Connected while - * the provider its requests really went through read "Ready to connect" — the - * two answers exactly inverted, on the catalog and on the detail header. - * - * A serving provider is active even when this deployment holds no URL for it: - * BYOK runs on the tenant's own endpoint, so deployment availability decides - * nothing about whether their own key is live. - * - * `default` is the platform's round-robin routing mode, not a provider. It may - * still be the active organization setting, but it must never appear as an - * integration card or provider detail page. - */ -export function resolveRpcIntegrations(input: { - selectedProvider: OrganizationRpcProvider | null; - /** - * The provider whose tenant-owned connection serves this project. `null` when - * nothing of the tenant's own does — and also when the viewer may not read - * connections at all, in which case the organization's selection is the best - * answer available and the behaviour is unchanged from before BYOK. - */ - servingProvider?: OrganizationRpcProvider | null; - /** - * Providers this project holds a live key of its own for. A tenant key runs - * on the tenant's endpoint, so it makes a provider usable no matter what this - * deployment holds — without this, a provider they had configured themselves - * and could switch to read "Not configured" beside its own Use this provider - * button. - */ - providersWithOwnKey?: readonly string[]; - entries: Partial>; -}): IntegrationEntry[] { - const activeProvider = input.servingProvider ?? input.selectedProvider; - const ownKeys = new Set(input.providersWithOwnKey ?? []); - const integrations: IntegrationEntry[] = []; - - for (const provider of ORGANIZATION_RPC_PROVIDERS) { - if (provider === "default") continue; - const entry = input.entries[provider]; - // Every RPC provider is generally available; an unconfigured one lacks a - // URL in this deployment *and* a key of the tenant's own, because either - // one is enough to route through it. Deployment availability is never - // organization access. - const status: IntegrationStatus = - provider === activeProvider - ? "active" - : entry?.enabled || ownKeys.has(provider) - ? "available" - : "not_configured"; - integrations.push({ - provider, - label: RPC_PROVIDER_LABELS[provider], - status, - descriptionKey: RPC_DESCRIPTION_KEYS[provider], - }); - } - - return integrations; -} - /** * A deployment-wide rail is on or off; no organization ever connects one, so * these families never report `active`. All three flags, not just `enabled`: diff --git a/apps/sdp-web/src/app/dashboard/integrations/integrations-status.unit.test.ts b/apps/sdp-web/src/app/dashboard/integrations/integrations-status.unit.test.ts index 84fcf91af7..9cba65d281 100644 --- a/apps/sdp-web/src/app/dashboard/integrations/integrations-status.unit.test.ts +++ b/apps/sdp-web/src/app/dashboard/integrations/integrations-status.unit.test.ts @@ -4,7 +4,6 @@ import { resolveCustodyIntegrations, resolvePrivacyIntegrations, resolveRampIntegrations, - resolveRpcIntegrations, } from "./integrations-status"; const entry = (entitled: boolean, configured: boolean, enabled: boolean) => ({ @@ -42,89 +41,6 @@ describe("integrations status", () => { expect(selfHosted.find((p) => p.entry.id === "local")?.status).toBe("active"); }); - it("marks the organization's selected RPC provider active and the rest switchable", () => { - const rpc = resolveRpcIntegrations({ - selectedProvider: "helius", - entries: { - helius: entry(true, true, true), - alchemy: entry(true, true, true), - triton: entry(true, true, false), - }, - }); - - expect(rpc.find((p) => p.provider === "helius")?.status).toBe("active"); - expect(rpc.find((p) => p.provider === "alchemy")?.status).toBe("available"); - // An unconfigured RPC provider lacks a URL in this deployment — that is - // environment availability, never organization access (decision-map.md #4). - expect(rpc.find((p) => p.provider === "triton")?.status).toBe("not_configured"); - }); - - it("calls the provider that serves the project connected, not the one merely selected", () => { - // The relay reaches the organization's selection only after tenant - // resolution returns nothing, so a BYOK connection outranks it. Reading - // `active` off the selection alone inverted both answers: the selected - // provider read Connected while the one actually carrying the requests - // read "Ready to connect". - const rpc = resolveRpcIntegrations({ - selectedProvider: "helius", - servingProvider: "quicknode", - entries: { - helius: entry(true, true, true), - quicknode: entry(true, true, true), - }, - }); - - expect(rpc.find((p) => p.provider === "quicknode")?.status).toBe("active"); - expect(rpc.find((p) => p.provider === "helius")?.status).toBe("available"); - expect(rpc.filter((p) => p.status === "active")).toHaveLength(1); - }); - - it("calls a serving provider connected even where this deployment holds no URL", () => { - // BYOK runs on the tenant's own endpoint, so whether SDP credentialed the - // provider decides nothing about whether the tenant's key is live. - const rpc = resolveRpcIntegrations({ - selectedProvider: "helius", - servingProvider: "nodit", - entries: { helius: entry(true, true, true), nodit: entry(true, false, false) }, - }); - - expect(rpc.find((p) => p.provider === "nodit")?.status).toBe("active"); - }); - - it("never calls a provider the tenant configured themselves Not configured", () => { - // BYOK runs on the tenant's own endpoint, so this deployment carrying no - // URL for the provider decides nothing. Nodit held a live key, could be - // switched to, and its header still read "Not configured" directly above - // its own Use this provider button. - const rpc = resolveRpcIntegrations({ - selectedProvider: "helius", - servingProvider: "quicknode", - providersWithOwnKey: ["quicknode", "nodit"], - entries: { helius: entry(true, true, true), nodit: entry(true, false, false) }, - }); - - expect(rpc.find((p) => p.provider === "nodit")?.status).toBe("available"); - // Still gone for a provider with neither a deployment URL nor a key. - expect(rpc.find((p) => p.provider === "validationcloud")?.status).toBe("not_configured"); - }); - - it("falls back to the selection when the serving connection cannot be read", () => { - // A member may not read connections at all. Unknown must degrade to the - // pre-BYOK answer rather than to a claim we cannot support. - const rpc = resolveRpcIntegrations({ - selectedProvider: "helius", - servingProvider: null, - entries: { helius: entry(true, true, true) }, - }); - - expect(rpc.find((p) => p.provider === "helius")?.status).toBe("active"); - }); - - it("never presents SDP's round-robin routing mode as a provider", () => { - const rpc = resolveRpcIntegrations({ selectedProvider: "default", entries: {} }); - expect(rpc.some((provider) => provider.provider === "default")).toBe(false); - }); - it("never calls a payment rail connected, because no organization ever connected one", () => { // Every ramp is entitled to every organization by default, so `enabled` // reduces to "this deployment holds the secret". Reporting that as @@ -163,7 +79,6 @@ describe("integrations status", () => { ...resolveCustodyIntegrations({ connectedProviders: [], enabledProviders: [] }).map( (p) => p.status ), - ...resolveRpcIntegrations({ selectedProvider: "helius", entries: {} }).map((p) => p.status), ...resolveRampIntegrations({}).map((p) => p.status), ...resolveComplianceIntegrations({}).map((p) => p.status), ]; diff --git a/apps/sdp-web/src/app/dashboard/integrations/page.tsx b/apps/sdp-web/src/app/dashboard/integrations/page.tsx index 6153d93eb1..79af412121 100644 --- a/apps/sdp-web/src/app/dashboard/integrations/page.tsx +++ b/apps/sdp-web/src/app/dashboard/integrations/page.tsx @@ -6,7 +6,6 @@ import type { OnboardingStatusResponse } from "@/app/dashboard/onboarding-status import { custody, payments, policies, privateChannels } from "@/flags"; import { getTranslations } from "@/i18n/server"; import { getAuthEntryPath } from "@/lib/auth-entry"; -import { resolveDashboardAccess } from "@/lib/dashboard-access"; import { fetchProviderAvailability } from "@/lib/provider-availability"; import { createTimedTrace } from "@/lib/request-tracing"; import { createRequestScopedSdpApiClients, type SdpApiClient } from "@/lib/sdp-api"; @@ -17,9 +16,7 @@ import { resolveCustodyIntegrations, resolvePrivacyIntegrations, resolveRampIntegrations, - resolveRpcIntegrations, } from "./integrations-status"; -import { fetchRpcTenantState } from "./rpc-serving-provider.server"; async function getConnectedCustodyProviders(request: SdpApiClient["request"]) { const res = await request("/v1/wallets/configs"); @@ -45,15 +42,20 @@ async function getPrivateChannelsActive(client: SdpApiClient): Promise @@ -80,31 +82,22 @@ export default async function IntegrationsPage() { policies: policiesEnabled, privateChannels: privateChannelsEnabled, }; - const [availability, connectedProviders, privateChannelsActive, rpcTenantState] = - await Promise.all([ - trace.step("fetch_provider_access", () => - fetchProviderAvailability(projectClient.request, organizationId) - ), - // null, not [] — an empty list claims nothing is connected and offers - // Configure for providers that are already active. Unknown must render as - // unknown, never as installable. - custodyEnabled - ? trace.step("fetch_custody_configs", () => - getConnectedCustodyProviders(projectClient.request).catch(() => null) - ) - : Promise.resolve([]), - privateChannelsEnabled - ? trace.step("fetch_private_channels_instance", () => - getPrivateChannelsActive(projectClient) - ) - : Promise.resolve(false), - // The catalog and the provider's own page must not answer "which RPC is - // connected" differently, so both read the serving connection. `null` here - // and on the detail page alike falls back to the organization's selection. - trace.step("fetch_rpc_tenant_state", () => - fetchRpcTenantState(dashboardAccess.capabilities.canManageOrgSettings) - ), - ]); + const [availability, connectedProviders, privateChannelsActive] = await Promise.all([ + trace.step("fetch_provider_access", () => + fetchProviderAvailability(projectClient.request, organizationId) + ), + // null, not [] — an empty list claims nothing is connected and offers + // Configure for providers that are already active. Unknown must render as + // unknown, never as installable. + custodyEnabled + ? trace.step("fetch_custody_configs", () => + getConnectedCustodyProviders(projectClient.request).catch(() => null) + ) + : Promise.resolve([]), + privateChannelsEnabled + ? trace.step("fetch_private_channels_instance", () => getPrivateChannelsActive(projectClient)) + : Promise.resolve(false), + ]); trace.log({ ok: true }); @@ -120,14 +113,6 @@ export default async function IntegrationsPage() { enabledProviders: availability.enabledCustodyProviders, }) } - rpc={resolveRpcIntegrations({ - // The shell only routes here after onboarding, so a missing setting - // means the organization runs on SDP's default RPC, not "none". - selectedProvider: onboarding.setup?.rpcProvider ?? "default", - servingProvider: rpcTenantState.servingProvider, - providersWithOwnKey: rpcTenantState.providersWithOwnKey, - entries: availability.providers.rpc, - })} ramps={ isIntegrationFamilyEnabled("ramps", integrationFlags) ? resolveRampIntegrations(availability.providers.ramps) @@ -145,7 +130,6 @@ export default async function IntegrationsPage() { })} enabledFamilies={[ ...(custodyEnabled ? (["custody"] as const) : []), - "rpc", ...(paymentsEnabled ? (["ramps"] as const) : []), ...(policiesEnabled ? (["compliance"] as const) : []), ...(privateChannelsEnabled ? (["privacy"] as const) : []), diff --git a/apps/sdp-web/src/app/dashboard/integrations/private-channels/setup/probe-error.ts b/apps/sdp-web/src/app/dashboard/integrations/private-channels/setup/probe-error.ts index 89f02cc59b..19b096e13c 100644 --- a/apps/sdp-web/src/app/dashboard/integrations/private-channels/setup/probe-error.ts +++ b/apps/sdp-web/src/app/dashboard/integrations/private-channels/setup/probe-error.ts @@ -9,7 +9,7 @@ export function isProjectRpcProbeFailure(probe: ConnectionProbeDetails): boolean /** Concise server-action fallback for connect-time probe failures. */ export function summarizeProbeFailure(probe: ConnectionProbeDetails): string { if (probe.rpc.ok === false) { - return `Project RPC check failed: ${probe.rpc.error} Fix the project's RPC integration or escrow deployment and try again.`; + return `Project RPC check failed: ${probe.rpc.error} Check the escrow deployment addresses and try again.`; } if (probe.auth.ok === false) { return `Auth failed: ${probe.auth.error}`; diff --git a/apps/sdp-web/src/app/dashboard/integrations/private-channels/setup/probe-error.unit.test.ts b/apps/sdp-web/src/app/dashboard/integrations/private-channels/setup/probe-error.unit.test.ts index cd5c22fd68..f42fe4e8d8 100644 --- a/apps/sdp-web/src/app/dashboard/integrations/private-channels/setup/probe-error.unit.test.ts +++ b/apps/sdp-web/src/app/dashboard/integrations/private-channels/setup/probe-error.unit.test.ts @@ -17,12 +17,12 @@ function probe(rpc: ConnectionProbeResult["rpc"]): ConnectionProbeResult { } describe("Private Channels probe errors", () => { - it("directs project RPC failures to the project integration and deployment", () => { + it("directs project RPC failures to the escrow deployment", () => { const result = probe({ ok: false, latencyMs: 10, error: "Program not found." }); expect(isProjectRpcProbeFailure(result)).toBe(true); expect(summarizeProbeFailure(result)).toBe( - "Project RPC check failed: Program not found. Fix the project's RPC integration or escrow deployment and try again." + "Project RPC check failed: Program not found. Check the escrow deployment addresses and try again." ); }); diff --git a/apps/sdp-web/src/app/dashboard/integrations/rpc-byok-section.tsx b/apps/sdp-web/src/app/dashboard/integrations/rpc-byok-section.tsx deleted file mode 100644 index f30330f824..0000000000 --- a/apps/sdp-web/src/app/dashboard/integrations/rpc-byok-section.tsx +++ /dev/null @@ -1,1012 +0,0 @@ -"use client"; - -import { rpcProviderNeedsEndpoint, type SafeRpcConnection } from "@sdp/types"; -import { - ActivityIcon, - CircleSlashIcon, - EyeIcon, - EyeOffIcon, - RefreshCwIcon, - Trash2Icon, -} from "lucide-react"; -import { useId, useState } from "react"; -import { toast } from "sonner"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Callout } from "@/components/ui/callout"; -import { Input } from "@/components/ui/input"; -import { ToggleSwitch } from "@/components/ui/toggle-switch"; -import { useTranslations } from "@/i18n/provider"; -import { rpcProviderLabel } from "@/lib/rpc-providers"; -import { - deactivateRpcConnectionAction, - deleteRpcConnectionAction, - rotateRpcConnectionAction, - setRpcCredentialModeAction, - submitRpcConnectionAction, - testRpcConnectionAction, -} from "./rpc-connection-actions"; - -/** - * Every row control posts a connection id and reads back a status. Delete - * answers `deleted` rather than a connection, so the shared handler is typed on - * what they have in common rather than on one of them. - */ -type ConnectionAction = ( - formData: FormData -) => Promise<{ status: string; message?: string } | { status: "deleted" }>; - -/** What a manual check answered, held per row and never persisted. */ -type TestOutcome = { ok: boolean; failureCode: string | null }; - -type TranslationKey = Parameters>[0]; - -/** - * The stored-credential rows. - * - * Extracted so the section's own branching stays under the repository's - * cognitive-complexity limit: the list has its own per-row state to reason - * about and reads better on its own. - */ -function ConnectionList({ - canManage, - connections, - failsClosed, - liveProjectConnections, - pendingId, - onAction, - onTest, - onRotateToggle, - onRotate, - rotatingId, - testResults, - t, -}: { - canManage: boolean; - connections: SafeRpcConnection[]; - /** The organization runs on its own keys, so losing this one stops RPC. */ - failsClosed: boolean; - /** Across every provider — `connections` is narrowed to the one on this page. */ - liveProjectConnections: number; - pendingId: string | null; - onAction: (action: ConnectionAction, connectionId: string) => void; - onTest: (connectionId: string) => void; - onRotateToggle: (connectionId: string | null) => void; - onRotate: (connectionId: string, apiKey: string, endpointUrl: string) => void; - rotatingId: string | null; - testResults: Record; - t: ReturnType; -}) { - // Deactivating what is serving puts the project back on SDP's keys, and the - // relay says nothing when it happens, so the warning has to be here. - // - // Counted across providers rather than inside `connections`, which the page - // has already narrowed to this one: a per-page count is at most 1, so every - // provider's own key claimed to be the only thing routing the project. - const hasSpare = liveProjectConnections > 1; - - return ( -
    - {connections.map((connection) => ( - - ))} -
- ); -} - -/** - * Which of three things this row is: carrying traffic, proven and idle, or - * neither. - * - * Its own function because "active" and "serving" stopped being the same - * thing once a project could hold a key per provider, and the distinction is - * the first question anyone asks of this list. - */ -function ConnectionStatusBadge({ - connection, - isLive, - isServing, - t, -}: { - connection: SafeRpcConnection; - isLive: boolean; - isServing: boolean; - t: ReturnType; -}) { - if (isServing) { - return {t("Shared.integrations.rpcByokServing")}; - } - // Proven and idle: a key that works and routes nothing, waiting to be - // switched to. "active" alone read as though it were the one serving. - if (isLive && connection.status === "active") { - return {t("Shared.integrations.rpcByokReady")}; - } - return {connection.status}; -} - -/** A line under the badge: which message, and how loudly to say it. */ -type RowNote = { key: TranslationKey; tone: string }; - -/** - * Which lines a row shows under its badge, in the order they are read. - * - * A plain function rather than more props on the component below: deciding - * this took five flags, and a component taking five booleans has thirty-two - * shapes nobody can hold in their head or test. The component renders what - * this returns and branches on nothing. - */ -function resolveRowNotes(input: { - failsClosed: boolean; - hasSpare: boolean; - isDeactivated: boolean; - isOrganizationScoped: boolean; - isServing: boolean; -}): RowNote[] { - const notes: RowNote[] = []; - - if (input.isOrganizationScoped) { - notes.push({ key: "Shared.integrations.rpcByokOrganizationScoped", tone: "text-warning" }); - } - // "What does deactivated mean?" was the question on the mock, so the answer - // sits on the row rather than in a badge. - if (input.isDeactivated) { - notes.push({ key: "Shared.integrations.rpcByokDeactivatedMeaning", tone: "text-tertiary" }); - } - // Only on the row actually carrying traffic. A Ready key routes nothing, so - // warning that removing it changes where requests go was simply false, and - // it appeared on every provider's page at once because the count behind it - // came from a list narrowed to one. - // - // Only the fail-closed case is a warning. The rest describes where traffic - // goes if this key is withdrawn, which is ordinary context and was being - // shouted in amber beside a green "Serving traffic" badge. - if (input.isServing) { - if (input.hasSpare) { - notes.push({ key: "Shared.integrations.rpcByokServingHasSpare", tone: "text-tertiary" }); - } else if (input.failsClosed) { - notes.push({ key: "Shared.integrations.rpcByokLastActiveByok", tone: "text-warning" }); - } else { - notes.push({ key: "Shared.integrations.rpcByokLastActive", tone: "text-tertiary" }); - } - } - - return notes; -} - -/** - * What the row has to say about itself beyond its badge. - * - * Its own component for the same reason the badge is: the row's controls and - * its explanations are two separate pieces of branching, and counting them - * together put `ConnectionRow` over the repository's complexity limit. - */ -function ConnectionRowNotes({ - notes, - testResult, - t, -}: { - notes: readonly RowNote[]; - testResult: TestOutcome | undefined; - t: ReturnType; -}) { - return ( - <> - {notes.map((note) => ( -

- {t(note.key)} -

- ))} - {/* Only ever the answer to the click that asked for it: nothing about a - check is stored any more (HOO-1228). */} - {testResult ? ( -

- {testResult.ok - ? t("Shared.integrations.rpcByokTestPassed") - : (testResult.failureCode ?? t("Shared.integrations.rpcByokTestFailed"))} -

- ) : null} - - ); -} - -/** - * One stored credential and the things that can be done to it. - * - * Split out from the list so each row's branching is counted on its own: the - * controls a row offers depend on scope, lifecycle and whether a rotation is - * open, and the combined function tripped the repository's complexity limit. - */ -function ConnectionRow({ - canManage, - connection, - hasSpare, - isRotating, - failsClosed, - pendingId, - onAction, - onTest, - onRotateToggle, - onRotate, - testResult, - t, -}: { - canManage: boolean; - connection: SafeRpcConnection; - /** Another live connection on some other provider could take this one's place. */ - hasSpare: boolean; - isRotating: boolean; - failsClosed: boolean; - pendingId: string | null; - onAction: (action: ConnectionAction, connectionId: string) => void; - onTest: (connectionId: string) => void; - onRotateToggle: (connectionId: string | null) => void; - onRotate: (connectionId: string, apiKey: string, endpointUrl: string) => void; - testResult: TestOutcome | undefined; - t: ReturnType; -}) { - // Pre-HOO-1226 rows. The relay no longer resolves them, so activating - // one would report success and route nothing. - const isOrganizationScoped = connection.scope === "organization"; - // Deactivation destroys the secret, so the API refuses to reactivate - // (409). Offering the control anyway was a button that could only ever - // produce an error (HOO-1219). - const isDeactivated = connection.status === "deactivated"; - // What the relay routes through: one default per project, not merely one - // that works. A project can hold a proven key per provider. - const isServing = connection.isDefault && connection.status === "active" && !isOrganizationScoped; - // A withdrawn or stranded row has nothing worth checking or replacing. - const isLive = !isDeactivated && !isOrganizationScoped; - // Delete used to fire on a single click with nothing in between. It only ever - // removes a row whose secret is already gone, so a second look is enough -- - // but "enough" is not "none". - const [confirmingDelete, setConfirmingDelete] = useState(false); - - return ( -
  • - {/* The status block owns the full width and the controls sit on their own - line beneath it. Sharing one line only worked while the notes were - short: a row explaining why it is not routing pushed the buttons onto - a second line anyway, but ragged and full-bleed. */} -
    -
    - - {connection.providerCredential.label} - - -
    -

    - {connection.network} - {typeof connection.displayMetadata.endpointHost === "string" - ? ` · ${connection.displayMetadata.endpointHost}` - : ""} - {typeof connection.displayMetadata.apiKeySuffix === "string" - ? ` · ····${connection.displayMetadata.apiKeySuffix}` - : ""} -

    - -
    - - {canManage ? ( - /* One action group, one place, whatever state the row is in. The - lifecycle controls keep a fixed order left to right -- rotate, - deactivate, delete -- so the same action never moves between states, - and the check sits apart on the right because it is both the most - reached for and the only one that changes nothing. */ -
    -
    - {/* Rotation asks for the replacement up front rather than - leaving people to deactivate and re-add (HOO-1229). */} - {isLive ? ( - - ) : null} - {/* Offered on every row that is not already deactivated, which - includes stranded organization-scoped rows -- those cannot be - rotated or checked, so this is their only way out. */} - {isDeactivated ? null : ( - - )} - {/* Secondary styling rather than red: this control only opens the - question, and the strip it opens carries the warning. Red on - both said "danger" twice for one decision. */} - {isDeactivated ? ( - - ) : null} -
    - {/* No per-key switch here. "Use this provider" above does the same - thing on this page and also moves the organization's selection - with it, so a key and the provider it belongs to can no longer - be pointed in two directions. */} - {/* Live connections can be re-checked whenever somebody wants to - know, rather than reading a stored verdict. */} - {isLive ? ( - - ) : null} -
    - ) : null} - - {/* Answered where it was asked, rather than over the top of the page. The - row is already the thing being talked about, so a modal would only - hide it behind the question about it. */} - {confirmingDelete ? ( - -

    {t("Shared.integrations.rpcByokDeleteConfirm")}

    -
    - - {/* Named for the connection rather than just "Delete": while the - strip is open the row carries two buttons reading Delete, and - the one that actually destroys the record should not be the - ambiguous one to anything reading the page aloud. */} - -
    -
    - ) : null} - - {isRotating ? ( - - ) : null} -
  • - ); -} - -/** - * Why there is no list. Two different answers that must not be confused: not - * permitted is a settled fact, a failed read is not, so only the second gets a - * warning and an invitation to retry. - */ -function ConnectionsUnavailable({ - reason, - t, -}: { - reason: "restricted" | null; - t: ReturnType; -}) { - return reason === "restricted" ? ( -

    {t("Shared.integrations.rpcByokRestricted")}

    - ) : ( -

    {t("Shared.integrations.rpcByokUnavailable")}

    - ); -} - -/** - * Adding a credential. - * - * Its own component so the fields, and the key in particular, live and die with - * the open form: collapsing it must not leave a secret sitting in a mounted - * input, and the parent holding that state made it the largest component in - * the file. - */ -function AddConnectionForm({ - needsEndpoint, - onAdd, - t, -}: { - needsEndpoint: boolean; - /** Resolves true when the connection was stored, so the form can clear. */ - onAdd: (label: string, endpointUrl: string, apiKey: string) => Promise; - t: ReturnType; -}) { - const [isFormOpen, setIsFormOpen] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const [credentialLabel, setCredentialLabel] = useState(""); - const [endpointUrl, setEndpointUrl] = useState(""); - const [apiKey, setApiKey] = useState(""); - const [showKey, setShowKey] = useState(false); - const apiKeyHintId = useId(); - const endpointHintId = useId(); - const labelFieldId = useId(); - const endpointFieldId = useId(); - const apiKeyFieldId = useId(); - - const submit = async () => { - setIsSubmitting(true); - try { - const stored = await onAdd(credentialLabel, endpointUrl, apiKey); - if (!stored) { - return; - } - // Clear the secret first: a failed re-render must not leave it sitting - // in a mounted input. - setApiKey(""); - setCredentialLabel(""); - setEndpointUrl(""); - setIsFormOpen(false); - setShowKey(false); - } finally { - setIsSubmitting(false); - } - }; - - return ( -
    - {/* Collapsed by default: most visits are to read what is connected, - not to add a credential, and a permanently open secret field is - noise on a page that is mostly status. */} - - - -
    - ); -} - -/** - * Whose credentials the whole organization runs on. - * - * Organization-wide rather than per connection, so it sits above the list and - * not inside a row. Its own component to keep the section's branching under - * the repository's complexity limit. - */ -function CredentialModeCard({ - mode, - stranded, - saving, - onChange, - t, -}: { - mode: "managed" | "byok"; - /** On its own keys with nothing live: every RPC call is failing right now. */ - stranded: boolean; - saving: boolean; - onChange: (next: "managed" | "byok") => void; - t: ReturnType; -}) { - return ( -
    -
    -

    {t("Shared.integrations.rpcModeTitle")}

    -

    - {mode === "byok" - ? t("Shared.integrations.rpcModeByokHint") - : t("Shared.integrations.rpcModeManagedHint")} -

    - {stranded ? ( -

    - {t("Shared.integrations.rpcModeStranded")} -

    - ) : null} -
    - onChange(next ? "byok" : "managed")} - /> -
    - ); -} - -/** - * The replacement key, asked for in place. - * - * Its own component so the value lives and dies with the open form: a key - * typed here must not survive the row being collapsed, and per-row state in - * the list would outlive it. - */ -function RotateForm({ - connectionId, - needsEndpoint, - pending, - onRotate, - t, -}: { - connectionId: string; - needsEndpoint: boolean; - pending: boolean; - onRotate: (connectionId: string, apiKey: string, endpointUrl: string) => void; - t: ReturnType; -}) { - const [apiKey, setApiKey] = useState(""); - const [endpointUrl, setEndpointUrl] = useState(""); - const keyFieldId = useId(); - const endpointFieldId = useId(); - - return ( -
    { - event.preventDefault(); - onRotate(connectionId, apiKey, endpointUrl); - }} - > -

    {t("Shared.integrations.rpcByokRotateHint")}

    - {needsEndpoint ? ( - - ) : null} - -
    - -
    -
    - ); -} - -/** - * Tenant-owned credentials for one provider (HOO-1090). - * - * The key field is write-only by construction: it is cleared on submit and the - * API's response type carries no field that could return it, so nothing here - * can repopulate a stored secret. - */ -export function RpcByokSection({ - canManage, - connections, - credentialMode, - liveConnectionCount = 0, - liveProjectConnections = 0, - servingProvider, - provider, -}: { - canManage: boolean; - /** `null` when it could not be read; the control is hidden rather than guessed. */ - credentialMode?: "managed" | "byok" | null; - /** Live connections across the whole organization, not just this provider. */ - liveConnectionCount?: number; - /** Live connections this project holds across every provider. */ - liveProjectConnections?: number; - /** - * The provider whose connection actually carries this project's traffic, when - * it is not this one. Must be the serving connection and not merely any key - * the project holds elsewhere — the copy below says traffic runs there, and - * a project can hold a proven key per provider with only one of them serving. - */ - servingProvider?: string | null; - /** - * `null` when the read failed and `"restricted"` when the viewer may not make - * it at all. Three different answers: unknown, not allowed, and none. - */ - connections: SafeRpcConnection[] | null | "restricted"; - provider: string; -}) { - const t = useTranslations(); - const [pendingId, setPendingId] = useState(null); - const [testResults, setTestResults] = useState>({}); - const [rotatingId, setRotatingId] = useState(null); - // Held locally so the switch reflects the change straight away; the server - // action revalidates the page behind it. - const [mode, setMode] = useState(credentialMode ?? "managed"); - const [isSavingMode, setIsSavingMode] = useState(false); - const needsEndpoint = rpcProviderNeedsEndpoint(provider); - // A stranded organization row is not a connection this project can use, so - // it must not be what stops a project connection being added. - const hasLiveConnection = - Array.isArray(connections) && - connections.some((item) => item.scope === "project" && item.status !== "deactivated"); - // Context, never a blocker: a project holds a key per provider, and adding one - // here is exactly how you get a second one to switch to. - const servedElsewhere = Boolean(servingProvider) && servingProvider !== provider; - - /** A check describes the connection as it was; any change makes it a lie. */ - const forgetTest = (connectionId: string) => - setTestResults((current) => { - const { [connectionId]: _dropped, ...rest } = current; - return rest; - }); - - const runConnectionAction = async (action: ConnectionAction, connectionId: string) => { - setPendingId(connectionId); - forgetTest(connectionId); - const formData = new FormData(); - formData.set("connectionId", connectionId); - formData.set("provider", provider); - try { - const result = await action(formData); - if (result.status === "success") { - toast.success(t("Shared.integrations.rpcByokUpdated"), { position: "bottom-right" }); - } else if (result.status === "deleted") { - toast.success(t("Shared.integrations.rpcByokDeleted"), { position: "bottom-right" }); - } else { - toast.error("message" in result ? result.message : undefined, { position: "bottom-right" }); - } - } finally { - setPendingId(null); - } - }; - - const runTest = async (connectionId: string) => { - setPendingId(connectionId); - forgetTest(connectionId); - const formData = new FormData(); - formData.set("connectionId", connectionId); - try { - const result = await testRpcConnectionAction(formData); - if (result.status === "tested") { - setTestResults((current) => ({ - ...current, - [connectionId]: { ok: result.ok, failureCode: result.failureCode }, - })); - return; - } - toast.error(result.message, { position: "bottom-right" }); - } finally { - setPendingId(null); - } - }; - - const saveMode = async (next: "managed" | "byok") => { - setIsSavingMode(true); - const formData = new FormData(); - formData.set("mode", next); - formData.set("provider", provider); - try { - const result = await setRpcCredentialModeAction(formData); - if (result.status === "saved") { - setMode(next); - toast.success(t("Shared.integrations.rpcModeSaved"), { position: "bottom-right" }); - return; - } - // Refused, so the switch must not look like it moved. - toast.error(result.message, { position: "bottom-right" }); - } finally { - setIsSavingMode(false); - } - }; - - const runRotate = async (connectionId: string, apiKey: string, endpointUrl: string) => { - setPendingId(connectionId); - const formData = new FormData(); - formData.set("connectionId", connectionId); - formData.set("provider", provider); - formData.set("apiKey", apiKey); - formData.set("endpointUrl", endpointUrl); - forgetTest(connectionId); - try { - const result = await rotateRpcConnectionAction(formData); - if (result.status === "success") { - // Closed before the toast: the field holding the new key must not - // stay mounted once it has been accepted. - setRotatingId(null); - toast.success(t("Shared.integrations.rpcByokRotated"), { position: "bottom-right" }); - return; - } - toast.error(result.message, { position: "bottom-right" }); - } finally { - setPendingId(null); - } - }; - - /** Returns whether the connection was stored, so the form knows to clear. */ - const handleAdd = async (label: string, endpoint: string, key: string) => { - const formData = new FormData(); - formData.set("provider", provider); - formData.set("scope", "project"); - formData.set("credentialLabel", label); - formData.set("endpointUrl", endpoint); - formData.set("apiKey", key); - - const result = await submitRpcConnectionAction(formData); - if (result.status === "success") { - toast.success(t("Shared.integrations.rpcByokAdded"), { position: "bottom-right" }); - return true; - } - toast.error(result.message, { position: "bottom-right" }); - return false; - }; - - return ( -
    -

    - {t("Shared.integrations.rpcByokDescription")} -

    - - {connections === "restricted" || connections === null ? ( - - ) : connections.length > 0 ? ( - { - void runConnectionAction(action, id); - }} - onTest={(id) => { - void runTest(id); - }} - onRotateToggle={setRotatingId} - onRotate={(id, key, endpoint) => { - void runRotate(id, key, endpoint); - }} - rotatingId={rotatingId} - testResults={testResults} - t={t} - /> - ) : ( -

    - {/* "Running on SDP's" is only true when nothing of the tenant's own - serves this project. Another provider's connection carrying the - traffic is exactly the case where it is false. */} - {servedElsewhere - ? t("Shared.integrations.rpcByokEmptyRoutedElsewhere", { - provider: rpcProviderLabel(servingProvider ?? ""), - }) - : t("Shared.integrations.rpcByokEmpty")} -

    - )} - - {canManage && credentialMode ? ( - { - void saveMode(next); - }} - t={t} - /> - ) : null} - - {/* A project holds a key per provider, so only this provider already - having one closes the form. Another provider serving is context, not - a blocker: adding here is how you get a second one to switch to. */} - {canManage && hasLiveConnection ? ( -

    {t("Shared.integrations.rpcByokOnlyOne")}

    - ) : null} - - {canManage && !hasLiveConnection ? ( - <> - {servedElsewhere ? ( -

    - {t("Shared.integrations.rpcByokAddAlongside", { - provider: rpcProviderLabel(servingProvider ?? ""), - })} -

    - ) : null} - - - ) : null} - - {/* Only a viewer who cannot manage needs telling why there is no form. - An admin already read the reason above, and being told they are not - an admin is worse than silence. */} - {canManage ? null : ( -

    - {t("Shared.integrations.rpcByokAdminOnly")} -

    - )} -
    - ); -} diff --git a/apps/sdp-web/src/app/dashboard/integrations/rpc-byok-section.unit.test.tsx b/apps/sdp-web/src/app/dashboard/integrations/rpc-byok-section.unit.test.tsx deleted file mode 100644 index 74cbeee317..0000000000 --- a/apps/sdp-web/src/app/dashboard/integrations/rpc-byok-section.unit.test.tsx +++ /dev/null @@ -1,571 +0,0 @@ -// @vitest-environment jsdom - -import type { SafeRpcConnection } from "@sdp/types"; -import { cleanup, render, screen, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import type { ComponentProps, ReactNode } from "react"; -import { toast } from "sonner"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const submitRpcConnectionAction = vi.fn(); -const deactivateRpcConnectionAction = vi.fn(); -const deleteRpcConnectionAction = vi.fn(); -const testRpcConnectionAction = vi.fn(); -const rotateRpcConnectionAction = vi.fn(); -const setRpcCredentialModeAction = vi.fn(); - -vi.mock("./rpc-connection-actions", () => ({ - submitRpcConnectionAction: (fd: FormData) => submitRpcConnectionAction(fd), - deactivateRpcConnectionAction: (fd: FormData) => deactivateRpcConnectionAction(fd), - deleteRpcConnectionAction: (fd: FormData) => deleteRpcConnectionAction(fd), - testRpcConnectionAction: (fd: FormData) => testRpcConnectionAction(fd), - rotateRpcConnectionAction: (fd: FormData) => rotateRpcConnectionAction(fd), - setRpcCredentialModeAction: (fd: FormData) => setRpcCredentialModeAction(fd), -})); -vi.mock("sonner", () => ({ - toast: Object.assign(vi.fn(), { error: vi.fn(), loading: vi.fn(), success: vi.fn() }), -})); - -// jsdom ships no matchMedia, and the deactivate control is a hold-to-confirm -// button that asks about reduced motion on mount. -if (typeof window !== "undefined" && !window.matchMedia) { - window.matchMedia = ((query: string) => ({ - matches: false, - media: query, - onchange: null, - addEventListener: () => undefined, - removeEventListener: () => undefined, - addListener: () => undefined, - removeListener: () => undefined, - dispatchEvent: () => false, - })) as unknown as typeof window.matchMedia; -} - -import { getMessages } from "@/i18n/messages"; -import { I18nProvider } from "@/i18n/provider"; -import { RpcByokSection } from "./rpc-byok-section"; - -function connection(overrides: Partial = {}): SafeRpcConnection { - return { - id: "rconn_1", - provider: "helius", - scope: "project", - projectId: "prj_1", - network: "devnet", - status: "active", - isDefault: true, - displayMetadata: { endpointHost: "tenant.example", apiKeySuffix: "1234" }, - createdAt: "2026-08-16T00:00:00.000Z", - activatedAt: "2026-08-16T00:00:00.000Z", - deactivatedAt: null, - providerCredential: { id: "pcred_1", label: "Production key", status: "active" }, - ...overrides, - }; -} - -function renderSection(props: Partial> = {}) { - const wrapper = ({ children }: { children: ReactNode }) => ( - - {children} - - ); - return render(, { - wrapper, - }); -} - -beforeEach(() => { - submitRpcConnectionAction.mockReset(); - deactivateRpcConnectionAction.mockReset(); - deleteRpcConnectionAction.mockReset(); - testRpcConnectionAction.mockReset(); - rotateRpcConnectionAction.mockReset(); - setRpcCredentialModeAction.mockReset(); - setRpcCredentialModeAction.mockResolvedValue({ status: "saved", mode: "byok" }); - rotateRpcConnectionAction.mockResolvedValue({ status: "success", connection: connection() }); - submitRpcConnectionAction.mockResolvedValue({ status: "success", connection: connection() }); - deactivateRpcConnectionAction.mockResolvedValue({ status: "success", connection: connection() }); - deleteRpcConnectionAction.mockResolvedValue({ status: "deleted" }); - testRpcConnectionAction.mockResolvedValue({ status: "tested", ok: true, failureCode: null }); -}); - -afterEach(cleanup); - -describe("RpcByokSection", () => { - it("keeps the credential form collapsed until asked for", async () => { - const user = userEvent.setup(); - renderSection(); - - const toggle = screen.getByRole("button", { name: "Add connection" }); - expect(toggle.getAttribute("aria-expanded")).toBe("false"); - // A page that is mostly status should not sit with a secret field open. - expect(screen.queryByRole("button", { name: "Save connection" })).toBeNull(); - - await user.click(toggle); - expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Save connection" })).toBeTruthy(); - }); - - it("collapses again once the credential is saved", async () => { - const user = userEvent.setup(); - renderSection(); - - await user.click(screen.getByRole("button", { name: "Add connection" })); - await user.type(screen.getByRole("textbox", { name: /Connection name/i }), "Prod"); - await user.type(screen.getByLabelText(/API key/i), "tenant-key-9999"); - await user.click(screen.getByRole("button", { name: "Save connection" })); - - expect(screen.queryByRole("button", { name: "Save connection" })).toBeNull(); - expect(screen.getByRole("button", { name: "Add connection" })).toBeTruthy(); - }); - - it("says the organization is on SDP's credentials when it has none of its own", () => { - renderSection(); - expect(screen.getByText(/running on SDP's/)).toBeTruthy(); - }); - - it("submits the key the organization typed", async () => { - const user = userEvent.setup(); - renderSection(); - - await user.click(screen.getByRole("button", { name: "Add connection" })); - await user.type(screen.getByRole("textbox", { name: /Connection name/i }), "Prod"); - // A password input has no textbox role, so it is reached by label text. - await user.type(screen.getByLabelText(/API key/i), "tenant-key-9999"); - await user.click(screen.getByRole("button", { name: "Save connection" })); - - expect(submitRpcConnectionAction).toHaveBeenCalledTimes(1); - const sent = submitRpcConnectionAction.mock.calls[0][0] as FormData; - expect(sent.get("apiKey")).toBe("tenant-key-9999"); - expect(sent.get("provider")).toBe("helius"); - expect(sent.get("scope")).toBe("project"); - // The project decides the network now, so the form must not send one - // that could disagree with it (HOO-1221). - expect(sent.get("network")).toBeNull(); - }); - - it("clears the key field after a successful save so it is never re-shown", async () => { - const user = userEvent.setup(); - renderSection(); - - await user.click(screen.getByRole("button", { name: "Add connection" })); - await user.type(screen.getByRole("textbox", { name: /Connection name/i }), "Prod"); - const key = screen.getByLabelText(/API key/i) as HTMLInputElement; - await user.type(key, "tenant-key-9999"); - await user.click(screen.getByRole("button", { name: "Save connection" })); - - expect(key.value).toBe(""); - }); - - it("does not ask for an endpoint a provider publishes for every account", async () => { - const user = userEvent.setup(); - renderSection({ provider: "helius" }); - - await user.click(screen.getByRole("button", { name: "Add connection" })); - expect(screen.queryByRole("textbox", { name: /Endpoint URL/i })).toBeNull(); - }); - - it("asks for an endpoint when the provider issues an account-specific one", async () => { - const user = userEvent.setup(); - renderSection({ provider: "quicknode" }); - - await user.click(screen.getByRole("button", { name: "Add connection" })); - expect(screen.getByRole("textbox", { name: /Endpoint URL/i })).toBeTruthy(); - }); - - it("lets the key be revealed so a typo is catchable before saving", async () => { - const user = userEvent.setup(); - renderSection(); - - await user.click(screen.getByRole("button", { name: "Add connection" })); - const key = screen.getByLabelText(/API key/i) as HTMLInputElement; - expect(key.type).toBe("password"); - - await user.click(screen.getByRole("button", { name: "Reveal key" })); - expect(key.type).toBe("text"); - - await user.click(screen.getByRole("button", { name: "Hide key" })); - expect(key.type).toBe("password"); - }); - - it("masks a stored credential down to a host and suffix", () => { - renderSection({ connections: [connection()] }); - expect(screen.getByText(/tenant\.example/)).toBeTruthy(); - expect(screen.getByText(/1234/)).toBeTruthy(); - expect(screen.getByText("Serving traffic")).toBeTruthy(); - }); - - it("keeps no per-key switch beside the provider's own switch", () => { - // "Use this provider" above does the same thing on this page and moves the - // organization's selection with it. Two buttons meant a key and the - // provider it belongs to could be pointed in two directions. - renderSection({ connections: [connection({ status: "pending", isDefault: false })] }); - - expect(screen.queryByRole("button", { name: "Use this connection" })).toBeNull(); - expect(screen.getByRole("button", { name: "Test key" })).toBeTruthy(); - }); - - it("tells the tenant an organization-scoped connection is not routing", () => { - // Made before HOO-1226. The relay refuses to resolve it, so offering - // activation here would report success over a connection carrying nothing. - renderSection({ - connections: [connection({ scope: "organization", projectId: null, isDefault: false })], - }); - - expect(screen.getByText(/not routing traffic/)).toBeTruthy(); - expect(screen.queryByRole("button", { name: "Use this connection" })).toBeNull(); - }); - - it("still lets a stranded organization connection be deactivated", async () => { - // The only way out of a row that cannot be rotated or checked. Gating this - // on "is live" instead of "is not already deactivated" strands it. - const user = userEvent.setup(); - renderSection({ - connections: [connection({ scope: "organization", projectId: null, isDefault: false })], - }); - - expect(screen.queryByRole("button", { name: "Test key" })).toBeNull(); - - // One click, no confirmation: the design review asked for deactivate to be - // an ordinary control rather than a five second press-and-hold. - await user.click(screen.getByRole("button", { name: /Deactivate/ })); - expect(deactivateRpcConnectionAction).toHaveBeenCalledTimes(1); - }); - - it("offers delete on a deactivated connection and nothing that would error", async () => { - const user = userEvent.setup(); - renderSection({ - connections: [connection({ status: "deactivated", isDefault: false })], - }); - - expect(screen.queryByRole("button", { name: "Use this connection" })).toBeNull(); - expect(screen.getByText(/stored key was destroyed/)).toBeTruthy(); - - // Delete asks before it acts now, so the row control opens the strip and - // the strip's own button is what actually deletes. - await user.click(screen.getByRole("button", { name: "Delete" })); - expect(deleteRpcConnectionAction).not.toHaveBeenCalled(); - - // The confirming button names the connection. Two controls reading only - // "Delete" sit on the row while the strip is open, and the destructive one - // should not be the ambiguous one -- asking for it by the bare word here - // would match the opener instead. - const confirmation = screen.getByRole("alert"); - await user.click(within(confirmation).getByRole("button", { name: "Delete Production key" })); - expect(deleteRpcConnectionAction).toHaveBeenCalledTimes(1); - }); - - it("keeps what was typed when adding a connection is refused", async () => { - // Same reason the rotate form stays open on a refusal: the field holds a - // secret the reader pasted once. Clearing it on failure makes them go and - // find it again to retry. - const user = userEvent.setup(); - submitRpcConnectionAction.mockResolvedValue({ - status: "error", - message: "Provider rejected the key.", - }); - renderSection(); - - await user.click(screen.getByRole("button", { name: "Add connection" })); - await user.type(screen.getByRole("textbox", { name: /Connection name/i }), "Prod"); - await user.type(screen.getByLabelText(/API key/i), "tenant-key-9999"); - await user.click(screen.getByRole("button", { name: "Save connection" })); - - expect(toast.error).toHaveBeenCalledWith("Provider rejected the key.", expect.anything()); - const name = screen.getByRole("textbox", { name: /Connection name/i }) as HTMLInputElement; - expect(name.value).toBe("Prod"); - }); - - it("keeps the rotate form open when the replacement key is refused", async () => { - // Rotation only closes on success, because the field holds the key the - // reader just typed. Closing on a refusal would throw it away and leave - // them to find it again. - const user = userEvent.setup(); - rotateRpcConnectionAction.mockResolvedValue({ - status: "error", - message: "Provider rejected the key.", - }); - renderSection({ connections: [connection()] }); - - await user.click(screen.getByRole("button", { name: "Rotate key" })); - await user.type(screen.getByLabelText(/New API key/i), "replacement-key-1234"); - // Exactly "Rotate": the row's own control reads "Rotate key". - await user.click(screen.getByRole("button", { name: "Rotate" })); - - expect(toast.error).toHaveBeenCalledWith("Provider rejected the key.", expect.anything()); - expect(screen.getByLabelText(/New API key/i)).toBeTruthy(); - }); - - it("surfaces why a refused delete did not happen", async () => { - // The row cannot tell the difference between a delete that did nothing and - // one that was refused, so the server's reason has to reach the reader - // rather than the strip just closing as though it worked. - const user = userEvent.setup(); - deleteRpcConnectionAction.mockResolvedValue({ - status: "error", - message: "Connection is still serving traffic.", - }); - renderSection({ - connections: [connection({ status: "deactivated", isDefault: false })], - }); - - await user.click(screen.getByRole("button", { name: "Delete" })); - const confirmation = screen.getByRole("alert"); - await user.click(within(confirmation).getByRole("button", { name: "Delete Production key" })); - - expect(toast.error).toHaveBeenCalledWith( - "Connection is still serving traffic.", - expect.anything() - ); - }); - - it("backs out of a delete without touching the connection", async () => { - const user = userEvent.setup(); - renderSection({ - connections: [connection({ status: "deactivated", isDefault: false })], - }); - - await user.click(screen.getByRole("button", { name: "Delete" })); - const confirmation = screen.getByRole("alert"); - await user.click(within(confirmation).getByRole("button", { name: "Cancel" })); - - expect(deleteRpcConnectionAction).not.toHaveBeenCalled(); - expect(screen.queryByRole("alert")).toBeNull(); - }); - - it("offers deactivate or delete on a row, never both", () => { - renderSection({ - connections: [ - connection(), - connection({ id: "rconn_2", status: "deactivated", isDefault: false }), - ], - }); - - // A live row can be withdrawn but not removed; a withdrawn one is the - // other way round. Showing both would offer an action that always errors. - expect(screen.getAllByRole("button", { name: /Deactivate/ })).toHaveLength(1); - expect(screen.getAllByRole("button", { name: "Delete" })).toHaveLength(1); - }); - - it("asks for the replacement key instead of making people re-add", async () => { - const user = userEvent.setup(); - renderSection({ connections: [connection()] }); - - await user.click(screen.getByRole("button", { name: "Rotate key" })); - await user.type(screen.getByLabelText("New API key"), "tenant-key-rotated"); - await user.click(screen.getByRole("button", { name: "Rotate" })); - - const sent = rotateRpcConnectionAction.mock.calls[0][0] as FormData; - expect(sent.get("apiKey")).toBe("tenant-key-rotated"); - expect(sent.get("connectionId")).toBe("rconn_1"); - }); - - it("offers the organization-wide credential mode to an admin", async () => { - const user = userEvent.setup(); - renderSection({ connections: [connection()], credentialMode: "managed" }); - - await user.click(screen.getByRole("switch", { name: /own credentials/ })); - - const sent = setRpcCredentialModeAction.mock.calls[0][0] as FormData; - expect(sent.get("mode")).toBe("byok"); - }); - - it("says so when the organization is on its own keys with nothing serving", () => { - // Deleting the last connection while the toggle is on leaves every RPC - // call failing, and nothing else on the page would say why. - renderSection({ connections: [], credentialMode: "byok", liveConnectionCount: 0 }); - expect(screen.getByText(/Every RPC call for this organization is failing/)).toBeTruthy(); - }); - - it("warns that deactivating the last one stops RPC rather than falling back", () => { - renderSection({ - connections: [connection()], - credentialMode: "byok", - liveConnectionCount: 1, - }); - expect(screen.getByText(/stops RPC instead of falling back/)).toBeTruthy(); - }); - - it("drops a check result once the row is acted on", async () => { - const user = userEvent.setup(); - renderSection({ connections: [connection({ status: "pending", isDefault: false })] }); - - await user.click(screen.getByRole("button", { name: "Test key" })); - expect(await screen.findByText(/Reached the provider just now/)).toBeTruthy(); - - // The check described the connection as it was; rotating changes it. - await user.click(screen.getByRole("button", { name: "Rotate key" })); - await user.type(screen.getByLabelText(/New API key/i), "replacement-key"); - await user.click(screen.getByRole("button", { name: /^Rotate$/ })); - expect(screen.queryByText(/Reached the provider just now/)).toBeNull(); - }); - - it("hides the credential mode control when it could not be read", () => { - // Showing "SDP-managed" at an organization that is actually on its own - // keys is the kind of wrong somebody acts on. - renderSection({ connections: [connection()], credentialMode: null }); - expect(screen.queryByRole("switch")).toBeNull(); - }); - - it("marks a proven connection that is not the one serving as Ready", () => { - // An active non-default connection is a key that works and routes nothing. - // "active" alone read as though it were the one carrying traffic. - renderSection({ connections: [connection({ status: "active", isDefault: false })] }); - - expect(screen.getByText("Ready")).toBeTruthy(); - expect(screen.queryByText("Serving traffic")).toBeNull(); - }); - - it("offers no switch on the connection that is already serving", () => { - renderSection({ connections: [connection({ status: "active", isDefault: true })] }); - - expect(screen.queryByRole("button", { name: "Use this connection" })).toBeNull(); - expect(screen.getByText("Serving traffic")).toBeTruthy(); - }); - - it("hides the add form once THIS provider already has a connection", () => { - // A second key for the same provider is a rotation: two credentials for - // one provider have no way to be told apart and no meaning in the relay. - renderSection({ connections: [connection()] }); - - expect(screen.queryByRole("button", { name: "Add connection" })).toBeNull(); - expect(screen.getByText(/already holds a key for this project/)).toBeTruthy(); - }); - - it("still offers the form when a DIFFERENT provider is serving the project", async () => { - // The point of the marketplace: keys in several providers, one serving, - // switching between them without throwing a working key away. Closing the - // form here was what made BYOK a one-provider decision. - renderSection({ connections: [], provider: "triton", servingProvider: "helius" }); - - expect(screen.getByRole("button", { name: "Add connection" })).toBeTruthy(); - // ...and it says adding will not move traffic, so the switch stays explicit. - expect(screen.getByText(/traffic keeps running on Helius until you do/)).toBeTruthy(); - }); - - it("does not claim the organization runs on SDP's when the project has its own connection", () => { - // The empty state is provider-scoped, so "running on SDP's" was false - // exactly when another provider held this project's connection. - renderSection({ connections: [], provider: "triton", servingProvider: "alchemy" }); - - expect(screen.queryByText(/running on SDP's/)).toBeNull(); - expect(screen.getByText(/runs on your own Alchemy connection/)).toBeTruthy(); - }); - - it("names the provider that is serving, not merely one holding a key", () => { - // The page used to hand this the first project connection on any other - // provider. With a Ready key on one and the serving key on another, the - // panel and this section named two different providers on one page. - renderSection({ connections: [], provider: "alchemy", servingProvider: "quicknode" }); - - expect(screen.getByText(/runs on your own QuickNode connection/)).toBeTruthy(); - expect(screen.queryByText(/Triton/)).toBeNull(); - }); - - it("does not claim traffic runs elsewhere when nothing of the tenant's own serves", () => { - // A Ready key on another provider routes nothing, so SDP's is what answers - // and the empty state must say so. - renderSection({ connections: [], provider: "alchemy", servingProvider: null }); - - expect(screen.getByText(/running on SDP's/)).toBeTruthy(); - }); - - it("does not tell an admin that only admins can add credentials", () => { - // canManage is true here, so the admin-only note is addressed to the - // wrong reader; the reason the form is closed is stated above it. - renderSection({ connections: [], provider: "triton", servingProvider: "alchemy" }); - - expect(screen.queryByText(/Only organization administrators/)).toBeNull(); - }); - - it("still offers add when the only rows are stranded or withdrawn", () => { - renderSection({ - connections: [ - connection({ scope: "organization", projectId: null, isDefault: false }), - connection({ id: "rconn_2", status: "deactivated", isDefault: false }), - ], - }); - - expect(screen.getByRole("button", { name: "Add connection" })).toBeTruthy(); - }); - - it("warns when the connection about to be deactivated is the only one", () => { - renderSection({ connections: [connection()], liveProjectConnections: 1 }); - expect(screen.getByText(/puts this project back on SDP's account/)).toBeTruthy(); - }); - - it("does not call a Ready key the only thing routing this project", () => { - // The count came from the list, which the page narrows to one provider, so - // it was at most 1 on every page: a key that routes nothing warned that - // removing it would put traffic back on SDP's. - renderSection({ - connections: [connection({ status: "active", isDefault: false })], - liveProjectConnections: 2, - }); - - expect(screen.queryByText(/back on SDP's account/)).toBeNull(); - expect(screen.queryByText(/will not hand over to your other keys/)).toBeNull(); - }); - - it("tells the serving connection that its siblings do not take over by themselves", () => { - // Deactivating never promotes a sibling, so with a spare in hand the - // honest instruction is to switch first rather than "back to SDP's keys". - renderSection({ - connections: [connection({ status: "active", isDefault: true })], - liveProjectConnections: 2, - }); - - expect(screen.getByText(/will not hand over to your other keys/)).toBeTruthy(); - expect(screen.queryByText(/back on SDP's account/)).toBeNull(); - }); - - it("gives a non-admin the connections but no controls", () => { - renderSection({ canManage: false, connections: [connection()] }); - expect(screen.queryByRole("button")).toBeNull(); - expect(screen.getByText(/Only organization administrators/)).toBeTruthy(); - }); - - it("surfaces a redacted failure code from a manual test, not a provider response", async () => { - const user = userEvent.setup(); - testRpcConnectionAction.mockResolvedValue({ - status: "tested", - ok: false, - failureCode: "provider_rejected_credentials", - }); - renderSection({ connections: [connection({ status: "failed", isDefault: false })] }); - - await user.click(screen.getByRole("button", { name: "Test key" })); - - expect(await screen.findByText("provider_rejected_credentials")).toBeTruthy(); - }); - - it("reports a passing check without writing anything down", async () => { - const user = userEvent.setup(); - testRpcConnectionAction.mockResolvedValue({ status: "tested", ok: true, failureCode: null }); - renderSection({ connections: [connection()] }); - - await user.click(screen.getByRole("button", { name: "Test key" })); - - expect(await screen.findByText(/Reached the provider just now/)).toBeTruthy(); - }); - - it("says the credentials could not be read rather than claiming there are none", () => { - // An empty array is a claim; a failed read is not. Telling an organization - // with stored credentials that it has none is the worse of the two. - renderSection({ connections: null }); - - expect(screen.getByText(/could not be loaded/)).toBeTruthy(); - expect(screen.queryByText(/running on SDP's/)).toBeNull(); - }); - - it("tells a member the list is admin-only instead of asking them to reload", () => { - // The internal routes are org:admin for reads too, so a member's request - // was always going to 403. Rendering that as a failed read told everyone - // below admin to reload a page that could never load for them. - renderSection({ canManage: false, connections: "restricted" }); - - expect(screen.getByText(/Only organization administrators can see/)).toBeTruthy(); - expect(screen.queryByText(/could not be loaded/)).toBeNull(); - expect(screen.queryByText(/running on SDP's/)).toBeNull(); - }); -}); diff --git a/apps/sdp-web/src/app/dashboard/integrations/rpc-connection-actions.ts b/apps/sdp-web/src/app/dashboard/integrations/rpc-connection-actions.ts deleted file mode 100644 index 96b77a6d8c..0000000000 --- a/apps/sdp-web/src/app/dashboard/integrations/rpc-connection-actions.ts +++ /dev/null @@ -1,269 +0,0 @@ -"use server"; - -import type { SafeRpcConnection } from "@sdp/types"; -import { revalidatePath } from "next/cache"; -import { updateOrganizationRpcSettingsAction } from "@/app/dashboard/settings/actions"; -import { createSdpApiClient } from "@/lib/sdp-api"; - -/** - * Tenant-owned RPC credentials (HOO-1090), reaching the dashboard-only routes - * the same way the Privy custody flow does: server actions over - * `/internal/dashboard/*`, never a browser-visible proxy. The API refuses API - * keys on these routes, so a signed-in session is the only way in. - * - * `apiKey` travels one way. Nothing here ever returns it, and the API's - * response type has no field that could carry it back. - */ -export type RpcConnectionActionResult = - | { status: "success"; connection: SafeRpcConnection } - | { status: "invalid"; message: string } - | { status: "error"; message: string }; - -function extractApiMessage(error: unknown): string { - const raw = error instanceof Error ? error.message : String(error); - const match = /^SDP API request failed \((\d+)\):\s*([\s\S]*)$/.exec(raw.trim()); - if (!match) { - return raw; - } - const body = match[2] ?? ""; - try { - const json = JSON.parse(body) as { error?: { message?: string } }; - return json.error?.message ?? body; - } catch { - return body; - } -} - -function revalidateProvider(provider: string) { - revalidatePath(`/dashboard/integrations/${provider}`); - revalidatePath("/dashboard/integrations"); -} - -export async function submitRpcConnectionAction( - formData: FormData -): Promise { - const provider = String(formData.get("provider") ?? "").trim(); - const scope = String(formData.get("scope") ?? "project").trim(); - const credentialLabel = String(formData.get("credentialLabel") ?? "").trim(); - const endpointUrl = String(formData.get("endpointUrl") ?? "").trim(); - const apiKey = String(formData.get("apiKey") ?? ""); - - // Trimmed-empty passes the browser's `required` check but is a known reject. - // The endpoint is deliberately absent: providers that publish one host for - // every account resolve it server-side, and only the rest are asked for it. - if (!provider || !credentialLabel || !apiKey.trim()) { - return { status: "invalid", message: "A name and an API key are required." }; - } - - try { - const client = await createSdpApiClient(); - const connection = await client.fetch( - "/internal/dashboard/rpc/connections", - { - method: "POST", - body: JSON.stringify({ - provider, - scope, - credentialLabel, - // Omitted, not empty: the API validates this as a URL when present. - ...(endpointUrl ? { endpointUrl } : {}), - apiKey, - }), - } - ); - revalidateProvider(provider); - return { status: "success", connection }; - } catch (error) { - return { status: "error", message: extractApiMessage(error) }; - } -} - -/** - * Switch this project onto a provider, both halves of it. - * - * Choosing a provider and choosing whose credentials answer for it were two - * separate controls, and the credential always won. So pressing "Use this - * provider" wrote a setting the relay never reached: the page named the new - * provider, the old one kept answering, and the button looked broken. - * - * The credential goes first on purpose. It is the half that can be refused -- - * an organization running only on its own keys cannot move to a provider it - * holds no key for -- and failing there must leave the selection as it was - * rather than pointing at a provider that is not serving. - */ -export async function switchRpcProviderAction( - formData: FormData -): Promise< - | { status: "success"; provider: string; usesOwnCredential: boolean } - | { status: "error"; message: string } -> { - const provider = String(formData.get("provider") ?? "").trim(); - const organizationId = String(formData.get("organizationId") ?? "").trim(); - if (!provider || !organizationId) { - return { status: "error", message: "A provider is required." }; - } - - let usesOwnCredential = false; - try { - const client = await createSdpApiClient(); - const result = await client.fetch<{ - servingProvider: string | null; - usesOwnCredential: boolean; - }>("/internal/dashboard/rpc/serving-provider", { - method: "PUT", - body: JSON.stringify({ provider }), - }); - usesOwnCredential = result.usesOwnCredential; - } catch (error) { - return { status: "error", message: extractApiMessage(error) }; - } - - // The selection still decides what answers once a tenant connection is gone, - // so it is written even when a key of their own is what serves today. - const settings = new FormData(); - settings.set("organizationId", organizationId); - settings.set("rpcProvider", provider); - const saved = await updateOrganizationRpcSettingsAction(settings); - if (saved.status !== "success") { - return { status: "error", message: saved.message }; - } - - revalidateProvider(provider); - return { status: "success", provider, usesOwnCredential }; -} - -/** - * Move the organization between SDP-managed RPC and running entirely on its - * own credentials. Organization-wide, so it revalidates the whole section - * rather than one provider's rows. - */ -export async function setRpcCredentialModeAction( - formData: FormData -): Promise<{ status: "saved"; mode: string } | { status: "error"; message: string }> { - const mode = String(formData.get("mode") ?? "").trim(); - const provider = String(formData.get("provider") ?? "").trim(); - if (mode !== "managed" && mode !== "byok") { - return { status: "error", message: "Pick a credential mode." }; - } - - try { - const client = await createSdpApiClient(); - const result = await client.fetch<{ mode: string }>("/internal/dashboard/rpc/credential-mode", { - method: "PUT", - body: JSON.stringify({ mode }), - }); - revalidateProvider(provider); - return { status: "saved", mode: result.mode }; - } catch (error) { - return { status: "error", message: extractApiMessage(error) }; - } -} - -/** - * Replace the key behind a connection (HOO-1229). The old key stays in place - * until the new one has been checked, so a rejected key changes nothing. - */ -export async function rotateRpcConnectionAction( - formData: FormData -): Promise { - const connectionId = String(formData.get("connectionId") ?? "").trim(); - const provider = String(formData.get("provider") ?? "").trim(); - const endpointUrl = String(formData.get("endpointUrl") ?? "").trim(); - const apiKey = String(formData.get("apiKey") ?? ""); - - if (!connectionId || !apiKey.trim()) { - return { status: "invalid", message: "A new API key is required." }; - } - - try { - const client = await createSdpApiClient(); - const connection = await client.fetch( - `/internal/dashboard/rpc/connections/${encodeURIComponent(connectionId)}/rotate`, - { - method: "POST", - body: JSON.stringify({ - ...(endpointUrl ? { endpointUrl } : {}), - apiKey, - }), - } - ); - revalidateProvider(provider); - return { status: "success", connection }; - } catch (error) { - return { status: "error", message: extractApiMessage(error) }; - } -} - -/** - * Check a stored connection on demand (HOO-1228). Nothing is persisted, so the - * answer is only ever as old as the click that asked for it. - */ -export async function testRpcConnectionAction( - formData: FormData -): Promise< - | { status: "tested"; ok: boolean; failureCode: string | null } - | { status: "error"; message: string } -> { - const connectionId = String(formData.get("connectionId") ?? "").trim(); - if (!connectionId) { - return { status: "error", message: "A connection is required." }; - } - - try { - const client = await createSdpApiClient(); - const result = await client.fetch<{ ok: boolean; failureCode: string | null }>( - `/internal/dashboard/rpc/connections/${encodeURIComponent(connectionId)}/test`, - { method: "POST" } - ); - return { status: "tested", ok: result.ok, failureCode: result.failureCode }; - } catch (error) { - return { status: "error", message: extractApiMessage(error) }; - } -} - -/** - * Clear a deactivated connection out of the list (HOO-1219). Nothing comes - * back but the outcome, so the result carries no connection. - */ -export async function deleteRpcConnectionAction( - formData: FormData -): Promise { - const connectionId = String(formData.get("connectionId") ?? "").trim(); - const provider = String(formData.get("provider") ?? "").trim(); - if (!connectionId) { - return { status: "invalid", message: "A connection is required." }; - } - - try { - const client = await createSdpApiClient(); - await client.fetch(`/internal/dashboard/rpc/connections/${encodeURIComponent(connectionId)}`, { - method: "DELETE", - }); - revalidateProvider(provider); - return { status: "deleted" }; - } catch (error) { - return { status: "error", message: extractApiMessage(error) }; - } -} - -export async function deactivateRpcConnectionAction( - formData: FormData -): Promise { - const connectionId = String(formData.get("connectionId") ?? "").trim(); - const provider = String(formData.get("provider") ?? "").trim(); - if (!connectionId) { - return { status: "invalid", message: "A connection is required." }; - } - - try { - const client = await createSdpApiClient(); - const connection = await client.fetch( - `/internal/dashboard/rpc/connections/${encodeURIComponent(connectionId)}/deactivate`, - { method: "POST" } - ); - revalidateProvider(provider); - return { status: "success", connection }; - } catch (error) { - return { status: "error", message: extractApiMessage(error) }; - } -} diff --git a/apps/sdp-web/src/app/dashboard/integrations/rpc-connection-actions.unit.test.ts b/apps/sdp-web/src/app/dashboard/integrations/rpc-connection-actions.unit.test.ts deleted file mode 100644 index 48e68b0552..0000000000 --- a/apps/sdp-web/src/app/dashboard/integrations/rpc-connection-actions.unit.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const fetchMock = vi.fn(); - -vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); -vi.mock("@/lib/sdp-api", () => ({ - createSdpApiClient: async () => ({ fetch: fetchMock }), -})); - -import { submitRpcConnectionAction } from "./rpc-connection-actions"; - -/** - * These actions were the one layer without tests, and the gap showed: the - * submit action kept requiring an endpoint after the form stopped sending one - * for providers that publish a single host, so every Helius submission was - * rejected before it left the browser. - */ -function form(fields: Record): FormData { - const data = new FormData(); - for (const [key, value] of Object.entries(fields)) { - data.set(key, value); - } - return data; -} - -const BASE = { - provider: "helius", - network: "devnet", - scope: "organization", - credentialLabel: "dev-key", - apiKey: "tenant-key-1234", -}; - -beforeEach(() => { - fetchMock.mockReset(); - fetchMock.mockResolvedValue({ id: "rconn_1", provider: "helius" }); -}); - -describe("submitRpcConnectionAction", () => { - it("accepts a submission with no endpoint for a provider that publishes one", async () => { - // The form omits the field entirely for Helius and Alchemy. - const result = await submitRpcConnectionAction(form(BASE)); - - expect(result.status).toBe("success"); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it("omits the endpoint rather than sending an empty string", async () => { - // The API validates endpointUrl as a URL when present, so "" is a 400. - await submitRpcConnectionAction(form({ ...BASE, endpointUrl: "" })); - - const body = JSON.parse(fetchMock.mock.calls[0][1].body as string); - expect(body).not.toHaveProperty("endpointUrl"); - expect(body.apiKey).toBe("tenant-key-1234"); - }); - - it("passes an endpoint through when the provider needs one", async () => { - await submitRpcConnectionAction( - form({ ...BASE, provider: "quicknode", endpointUrl: "https://x.quiknode.pro" }) - ); - - const body = JSON.parse(fetchMock.mock.calls[0][1].body as string); - expect(body.endpointUrl).toBe("https://x.quiknode.pro"); - }); - - it("still rejects a submission with no name or no key", async () => { - const noLabel = await submitRpcConnectionAction(form({ ...BASE, credentialLabel: " " })); - const noKey = await submitRpcConnectionAction(form({ ...BASE, apiKey: " " })); - - expect(noLabel.status).toBe("invalid"); - expect(noKey.status).toBe("invalid"); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("surfaces the API's message rather than a raw request failure", async () => { - fetchMock.mockRejectedValue( - new Error('SDP API request failed (409): {"error":{"message":"Provider rejected it"}}') - ); - - const result = await submitRpcConnectionAction(form(BASE)); - - expect(result).toEqual({ status: "error", message: "Provider rejected it" }); - }); -}); diff --git a/apps/sdp-web/src/app/dashboard/integrations/rpc-connection-panel.tsx b/apps/sdp-web/src/app/dashboard/integrations/rpc-connection-panel.tsx deleted file mode 100644 index 7da16fcfaa..0000000000 --- a/apps/sdp-web/src/app/dashboard/integrations/rpc-connection-panel.tsx +++ /dev/null @@ -1,301 +0,0 @@ -"use client"; - -import type { OrganizationRpcProvider } from "@sdp/types"; -import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { useTranslations } from "@/i18n/provider"; -import { type RpcTestResult, RpcTestResultPanel, runRpcProviderTest } from "@/lib/rpc-connection"; -import { rpcProviderLabel } from "@/lib/rpc-providers"; -import type { IntegrationStatus } from "./integrations-status"; -import { switchRpcProviderAction } from "./rpc-connection-actions"; - -/** - * What is serving this project, in one sentence, most specific first. - * - * Six answers rather than the original two, because the organization's - * selection and the project's own connection are different questions and the - * page used to answer only the first while claiming to answer both. - */ -function serviceSummary( - input: { - isActive: boolean; - isStrandedDefault: boolean; - orgProvider: OrganizationRpcProvider; - provider: OrganizationRpcProvider; - servingProvider?: string | null; - }, - t: ReturnType -): string { - const { isActive, isStrandedDefault, orgProvider, provider, servingProvider } = input; - - if (isStrandedDefault) { - return t("Shared.integrations.rpcActiveSelectedOnly"); - } - if (servingProvider === provider) { - return t("Shared.integrations.rpcActiveOwnCredential"); - } - if (servingProvider) { - return isActive - ? t("Shared.integrations.rpcActiveOverridden", { - provider: rpcProviderLabel(servingProvider), - }) - : t("Shared.integrations.rpcServedByProject", { - provider: rpcProviderLabel(servingProvider), - }); - } - return isActive - ? t("Shared.integrations.rpcActiveHere") - : t("Shared.integrations.rpcActiveElsewhere", { provider: rpcProviderLabel(orgProvider) }); -} - -/** - * The RPC half of HOO-787: an organization used to have to leave the - * integration it was reading about and go find the provider dropdown in - * Settings. The controls live on the provider's own page now. - * - * Only the *active* provider gets a test button. `/v1/rpc/test` resolves - * whatever is saved, so offering it on any other provider's page would report - * a mismatch against a provider the reader never asked about. - */ -export function RpcConnectionPanel({ - activeProvider, - canManage, - hasOwnKey = false, - isEnabledInDeployment, - organizationId, - provider, - servingProvider, - status, -}: { - activeProvider: OrganizationRpcProvider; - canManage: boolean; - /** - * Whether this project holds a live key of its own for this provider. The - * switch runs on the tenant's endpoint in that case, so a provider this - * deployment has no URL for is still something they can move to. - */ - hasOwnKey?: boolean; - /** - * The provider actually routing this project, whichever one that is. A - * tenant connection outranks the organization's selection, so this panel - * cannot describe what serves the project without it. - */ - servingProvider?: string | null; - /** - * Whether this deployment actually holds an endpoint for the provider. The - * catalog marks the organization's saved provider `active` whatever the - * deployment offers, so a provider dropped from the tier still reads as - * connected -- and the relay quietly serves someone else. - */ - isEnabledInDeployment: boolean; - organizationId: string; - provider: OrganizationRpcProvider; - status: IntegrationStatus | "unknown"; -}) { - const t = useTranslations(); - const router = useRouter(); - const [currentProvider, setCurrentProvider] = useState(activeProvider); - const [isSwitching, setIsSwitching] = useState(false); - const [isTesting, setIsTesting] = useState(false); - const [lastTest, setLastTest] = useState(null); - - // A server re-render after the switch is the authority; local state only - // covers the gap before it arrives. - useEffect(() => { - setCurrentProvider(activeProvider); - }, [activeProvider]); - - const isActive = provider === currentProvider; - // Saved here, but unserviceable: the relay is falling back to another - // provider, so there is nothing honest to test on this page. - const isStrandedDefault = isActive && !isEnabledInDeployment; - /** - * Whether this provider is what answers the project right now, which is what - * `status === "active"` encodes: a tenant connection first, the - * organization's selection when none serves. - */ - const isServingProvider = status === "active"; - /** - * Whether to offer the switch. - * - * A key of the tenant's own is enough on its own: it runs on their endpoint, - * so a provider this deployment holds no URL for is still switchable to when - * they hold a key for it. - */ - const canSelect = !isServingProvider && canManage && (isEnabledInDeployment || hasOwnKey); - - const switchToProvider = async () => { - setIsSwitching(true); - const formData = new FormData(); - formData.set("organizationId", organizationId); - formData.set("provider", provider); - - try { - // One action, both halves: the credential this project routes through and - // the selection that answers once no connection does. Writing only the - // second left the button with nothing to show for itself. - const result = await switchRpcProviderAction(formData); - if (result.status !== "success") { - toast.error(t("DashboardCustody.failedToSaveRpcSettings"), { - description: result.message, - position: "bottom-right", - }); - return; - } - - setCurrentProvider(provider); - setLastTest(null); - toast.success(t("DashboardCustody.rpcSettingsSaved"), { - description: result.usesOwnCredential - ? t("Shared.integrations.rpcSwitchedToOwnKey", { provider: rpcProviderLabel(provider) }) - : t("Shared.integrations.rpcSwitchedToPlatform", { - provider: rpcProviderLabel(provider), - }), - position: "bottom-right", - }); - router.refresh(); - } finally { - setIsSwitching(false); - } - }; - - const testProvider = async () => { - if (isSwitching) { - toast.error(t("DashboardCustody.saveInProgress"), { - description: t("DashboardCustody.tryAgainSoon"), - position: "bottom-right", - }); - return; - } - - setIsTesting(true); - const toastId = toast.loading(t("DashboardCustody.checkingRpcProvider"), { - position: "bottom-right", - }); - - try { - const result = await runRpcProviderTest(provider, t); - setLastTest(result); - const requestedLabel = rpcProviderLabel(result.requestedProvider); - const resolvedLabel = result.resolvedProvider - ? rpcProviderLabel(result.resolvedProvider) - : null; - const latency = result.latencyMs !== undefined ? `${result.latencyMs}ms` : null; - - if (result.status === "success") { - toast.success(t("DashboardCustody.rpcCheckPassed"), { - id: toastId, - description: [requestedLabel, latency].filter(Boolean).join(" • "), - position: "bottom-right", - }); - return; - } - - const isProviderMismatch = - result.requestedProvider !== "default" && - !!result.resolvedProvider && - result.resolvedProvider !== result.requestedProvider; - - // A mismatch is not a failure, and the result panel says so in amber - // beside a 200 OK. Raising a red error toast for the same event told the - // reader two different things at once. - const notify = isProviderMismatch ? toast.warning : toast.error; - notify( - isProviderMismatch - ? t("DashboardCustody.rpcDetailMismatch") - : t("DashboardCustody.rpcCheckFailed"), - { - id: toastId, - description: isProviderMismatch - ? t("DashboardCustody.rpcTestMismatch", { - requested: requestedLabel, - resolved: resolvedLabel ?? t("DashboardCustody.anotherProvider"), - }) - : [resolvedLabel ?? requestedLabel, result.upstreamStatus, latency] - .filter((value) => value !== undefined && value !== null && value !== "") - .join(" • "), - position: "bottom-right", - } - ); - } finally { - setIsTesting(false); - } - }; - - return ( -
    -
    -
    -

    - {t("Shared.integrations.rpcActiveProviderLabel")} -

    -

    - {/* A stranded default is selected but not serving, so it must not - also claim traffic runs through it. */} - {serviceSummary( - { - isActive, - isStrandedDefault, - orgProvider: currentProvider, - provider, - servingProvider, - }, - t - )} -

    -
    - - {isServingProvider && !isStrandedDefault && canManage ? ( - - ) : canSelect ? ( - - ) : null} -
    - - {/* The note that used to sit here explained that choosing a provider - would not change what serves the project. That is no longer true: - the switch moves the credential too, so the explanation would be - describing behaviour the button no longer has. */} - {isStrandedDefault ? ( -

    - {t("Shared.integrations.rpcActiveUnavailable")} -

    - ) : null} - - {!isActive && isEnabledInDeployment && !canManage ? ( -

    - {t("DashboardCustody.viewOnlyRpcSettings")} -

    - ) : null} - - {status === "not_configured" && !isStrandedDefault ? ( -

    - {t("Shared.integrations.rpcNotConfiguredHere")} -

    - ) : null} - - {lastTest ? : null} -
    - ); -} diff --git a/apps/sdp-web/src/app/dashboard/integrations/rpc-connection-panel.unit.test.tsx b/apps/sdp-web/src/app/dashboard/integrations/rpc-connection-panel.unit.test.tsx deleted file mode 100644 index 548cc11c84..0000000000 --- a/apps/sdp-web/src/app/dashboard/integrations/rpc-connection-panel.unit.test.tsx +++ /dev/null @@ -1,286 +0,0 @@ -// @vitest-environment jsdom - -import { cleanup, render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import type { ComponentProps, ReactNode } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const refresh = vi.fn(); -const switchRpcProviderAction = vi.fn(); - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ refresh }), -})); -vi.mock("./rpc-connection-actions", () => ({ - switchRpcProviderAction: (formData: FormData) => switchRpcProviderAction(formData), -})); -vi.mock("sonner", () => ({ - toast: Object.assign(vi.fn(), { - error: vi.fn(), - loading: vi.fn(), - success: vi.fn(), - warning: vi.fn(), - }), -})); -// The relay probe is shared code exercised by the settings form; the panel only -// has to decide whether to offer it. -vi.mock("@/lib/rpc-connection", () => ({ - RpcTestResultPanel: () =>
    , - runRpcProviderTest: vi.fn(async () => ({ - status: "success" as const, - message: "ok", - requestedProvider: "helius" as const, - })), -})); - -import { toast } from "sonner"; -import { getMessages } from "@/i18n/messages"; -import { I18nProvider } from "@/i18n/provider"; -import { runRpcProviderTest } from "@/lib/rpc-connection"; -import { RpcConnectionPanel } from "./rpc-connection-panel"; - -function renderPanel(props: Partial> = {}) { - const wrapper = ({ children }: { children: ReactNode }) => ( - - {children} - - ); - return render( - , - { wrapper } - ); -} - -beforeEach(() => { - refresh.mockClear(); - vi.mocked(toast.warning).mockClear(); - vi.mocked(toast.error).mockClear(); - vi.mocked(toast.success).mockClear(); - switchRpcProviderAction.mockReset(); - switchRpcProviderAction.mockResolvedValue({ - status: "success", - provider: "alchemy", - usesOwnCredential: false, - }); -}); - -afterEach(cleanup); - -describe("RpcConnectionPanel", () => { - it("offers the relay test only on the provider the organization actually runs", () => { - renderPanel(); - expect(screen.getByRole("button", { name: "Test connection" })).toBeTruthy(); - expect(screen.queryByRole("button", { name: "Use this provider" })).toBeNull(); - expect(screen.getByText(/runs through this provider/)).toBeTruthy(); - }); - - it("warns rather than errors when another provider answered the test", async () => { - // The result panel renders this in amber next to a 200 OK. A red error - // toast for the same event put two severities on screen at once. - vi.mocked(runRpcProviderTest).mockResolvedValueOnce({ - status: "error", - reason: "mismatch", - message: "Alchemy answered this test, not Helius.", - requestedProvider: "helius", - resolvedProvider: "alchemy", - upstreamStatus: 200, - }); - - renderPanel(); - await userEvent.click(screen.getByRole("button", { name: "Test connection" })); - - expect(toast.warning).toHaveBeenCalled(); - expect(toast.error).not.toHaveBeenCalled(); - }); - - it("still raises an error toast when the upstream genuinely failed", async () => { - vi.mocked(runRpcProviderTest).mockResolvedValueOnce({ - status: "error", - reason: "upstream", - message: "RPC upstream returned 502 Bad Gateway.", - requestedProvider: "helius", - resolvedProvider: "helius", - upstreamStatus: 502, - }); - - renderPanel(); - await userEvent.click(screen.getByRole("button", { name: "Test connection" })); - - expect(toast.error).toHaveBeenCalled(); - expect(toast.warning).not.toHaveBeenCalled(); - }); - - it("offers the switch while another provider's own key is serving, and disclaims nothing", () => { - // The switch moves the credential too now, so the note that used to warn - // it would change nothing describes behaviour the button no longer has. - renderPanel({ - provider: "helius", - activeProvider: "alchemy", - servingProvider: "alchemy", - status: "available", - }); - - expect(screen.getByRole("button", { name: "Use this provider" })).toBeTruthy(); - expect(screen.queryByText(/will not change what serves this project/)).toBeNull(); - }); - - it("offers no switch on the provider that is already serving", () => { - // The old gate asked whether this was the organization's selection, which - // a serving tenant key overrides. That put "Use this provider" on a page - // whose own badge read Connected. - renderPanel({ provider: "alchemy", activeProvider: "helius", servingProvider: "alchemy" }); - - expect(screen.queryByRole("button", { name: "Use this provider" })).toBeNull(); - expect(screen.getByRole("button", { name: "Test connection" })).toBeTruthy(); - }); - - it("offers the switch for a provider this deployment holds no URL for, given their own key", () => { - // BYOK runs on the tenant's endpoint, so deployment availability decides - // nothing about whether they can move to it. - renderPanel({ - provider: "nodit", - activeProvider: "helius", - servingProvider: "alchemy", - isEnabledInDeployment: false, - hasOwnKey: true, - status: "available", - }); - - expect(screen.getByRole("button", { name: "Use this provider" })).toBeTruthy(); - }); - - it("names the tenant's own key when this provider serves on it", () => { - // Org selection and project connection can name the same vendor. Same - // logo, different bill, and the page used to claim SDP's account. - renderPanel({ provider: "alchemy", activeProvider: "alchemy", servingProvider: "alchemy" }); - - expect(screen.queryByText(/runs through this provider/)).toBeNull(); - expect(screen.getByText(/your own connection with this provider/)).toBeTruthy(); - }); - - it("does not claim traffic runs here when the project's own connection wins", () => { - // A tenant connection outranks the organization's selection, so the - // selected provider can be serving nothing at all. Claiming otherwise is - // what made a healthy Alchemy connection read as a broken Helius one. - // Keyed on what actually routes: a pending row elsewhere does not take - // the project off the platform selection, an active one does. - renderPanel({ servingProvider: "alchemy" }); - - expect(screen.queryByText(/runs through this provider/)).toBeNull(); - expect(screen.getByText(/runs on your own Alchemy connection instead/)).toBeTruthy(); - }); - - it("names the active provider on a page for a different one", () => { - renderPanel({ provider: "alchemy", status: "available" }); - // Reading Alchemy's page must not leave you guessing what is live. - expect(screen.getByText("This organization currently runs on Helius.")).toBeTruthy(); - expect(screen.getByRole("button", { name: "Use this provider" })).toBeTruthy(); - expect(screen.queryByRole("button", { name: "Test connection" })).toBeNull(); - }); - - it("switches the whole project onto the provider whose page this is", async () => { - const user = userEvent.setup(); - renderPanel({ provider: "alchemy", status: "available" }); - - await user.click(screen.getByRole("button", { name: "Use this provider" })); - - // One action covering both halves. Writing only the organization setting - // left a tenant connection still serving the old provider, so the button - // reported success and changed nothing anyone could observe. - expect(switchRpcProviderAction).toHaveBeenCalledTimes(1); - const formData = switchRpcProviderAction.mock.calls[0][0] as FormData; - expect(formData.get("provider")).toBe("alchemy"); - expect(formData.get("organizationId")).toBe("org_1"); - // The server render owns the active provider; without a refresh the page - // would keep claiming the old one is live. - expect(refresh).toHaveBeenCalledTimes(1); - }); - - it("says which account is answering after the switch", async () => { - switchRpcProviderAction.mockResolvedValue({ - status: "success", - provider: "alchemy", - usesOwnCredential: true, - }); - const user = userEvent.setup(); - renderPanel({ provider: "alchemy", status: "available" }); - - await user.click(screen.getByRole("button", { name: "Use this provider" })); - - // Same logo, different bill. "Switched to Alchemy" alone never said whose - // Alchemy account is about to be charged. - expect(vi.mocked(toast.success).mock.calls[0]?.[1]?.description).toMatch( - /your own Alchemy key/ - ); - }); - - it("offers the way back to SDP RPC from a vendor page", async () => { - const user = userEvent.setup(); - // The catalog lists `default` alongside the vendors, so this page exists - // and is the only route back off a vendor now that the Settings dropdown - // is gone. Without it an organization on Helius was stuck there. - renderPanel({ provider: "default", status: "available" }); - - expect(screen.getByText("This organization currently runs on Helius.")).toBeTruthy(); - await user.click(screen.getByRole("button", { name: "Use this provider" })); - - const formData = switchRpcProviderAction.mock.calls[0][0] as FormData; - expect(formData.get("provider")).toBe("default"); - }); - - it("gives a non-admin the state but no way to change it", () => { - renderPanel({ canManage: false, provider: "alchemy", status: "available" }); - expect(screen.queryByRole("button")).toBeNull(); - expect(screen.getByText(/only admins can change them/)).toBeTruthy(); - }); - - it("does not let a read-only member invoke the relay probe", () => { - // Testing reaches an upstream provider; a member who may only read state - // must not be able to spend that call. - renderPanel({ canManage: false }); - expect(screen.queryByRole("button", { name: "Test connection" })).toBeNull(); - }); - - it("says the saved provider is stranded instead of offering a dead test", () => { - // The catalog marks the saved provider active whatever the deployment - // holds, so without this the page offers a probe that silently measures a - // different provider. - renderPanel({ isEnabledInDeployment: false }); - expect(screen.queryByRole("button", { name: "Test connection" })).toBeNull(); - expect(screen.getByText(/falling back to another one/)).toBeTruthy(); - // ...and must not simultaneously claim traffic runs through it. - expect(screen.queryByText(/runs through this provider/)).toBeNull(); - expect(screen.getByText("This organization is set to use this provider.")).toBeTruthy(); - }); - - it("explains an unconfigured provider instead of offering a dead switch", () => { - // `not_configured` is derived from the deployment holding no URL, so the - // two have to agree here; passing them apart described a page the loader - // cannot produce. - renderPanel({ provider: "triton", status: "not_configured", isEnabledInDeployment: false }); - expect(screen.queryByRole("button")).toBeNull(); - expect(screen.getByText(/holds no endpoint/)).toBeTruthy(); - }); - - it("does not switch when the save fails", async () => { - switchRpcProviderAction.mockResolvedValue({ - status: "error", - message: "nope", - }); - const user = userEvent.setup(); - renderPanel({ provider: "alchemy", status: "available" }); - - await user.click(screen.getByRole("button", { name: "Use this provider" })); - - expect(refresh).not.toHaveBeenCalled(); - expect(screen.getByText("This organization currently runs on Helius.")).toBeTruthy(); - }); -}); diff --git a/apps/sdp-web/src/app/dashboard/integrations/rpc-provider-mark.tsx b/apps/sdp-web/src/app/dashboard/integrations/rpc-provider-mark.tsx deleted file mode 100644 index f7e0d352b7..0000000000 --- a/apps/sdp-web/src/app/dashboard/integrations/rpc-provider-mark.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import type { OrganizationRpcProvider } from "@sdp/types"; -import Image from "next/image"; - -const RPC_PROVIDER_LOGOS: Record< - Exclude, - { src: string; backgroundClassName: string; paddingClassName: string } -> = { - alchemy: { - src: "/provider-logos/alchemy.svg", - backgroundClassName: "bg-[white]", - paddingClassName: "p-0.5", - }, - helius: { - src: "/provider-logos/helius.svg", - backgroundClassName: "bg-[white]", - paddingClassName: "p-0.5", - }, - nodit: { - src: "/provider-logos/nodit.svg", - backgroundClassName: "bg-[white]", - paddingClassName: "p-1", - }, - quicknode: { - src: "/provider-logos/quicknode.svg", - backgroundClassName: "bg-[#080b09]", - paddingClassName: "p-1", - }, - triton: { - src: "/provider-logos/triton.svg", - backgroundClassName: "bg-[#060a14]", - paddingClassName: "p-0", - }, - validationcloud: { - src: "/provider-logos/validation-cloud.svg", - backgroundClassName: "bg-[#d63d57]", - paddingClassName: "p-0", - }, -}; - -export function RpcProviderMark({ provider }: { provider: OrganizationRpcProvider }) { - if (provider === "default") { - return ( - - ); - } - - const logo = RPC_PROVIDER_LOGOS[provider]; - - return ( - - ); -} diff --git a/apps/sdp-web/src/app/dashboard/integrations/rpc-serving-provider.server.ts b/apps/sdp-web/src/app/dashboard/integrations/rpc-serving-provider.server.ts deleted file mode 100644 index aa5b94d70e..0000000000 --- a/apps/sdp-web/src/app/dashboard/integrations/rpc-serving-provider.server.ts +++ /dev/null @@ -1,110 +0,0 @@ -import "server-only"; - -import type { OrganizationRpcProvider, RpcConnectionListResponse } from "@sdp/types"; -import { ORGANIZATION_RPC_PROVIDERS } from "@sdp/types"; -import { createSdpApiClient } from "@/lib/sdp-api"; - -export const CONNECTION_PAGE_SIZE = 50; - -type Connection = RpcConnectionListResponse["connections"][number]; - -/** - * Read one scope's connections to the end. - * - * Pages rather than taking the first response: the list is organization-wide, - * so truncating at one page and then narrowing by provider would hide - * credentials that exist but sit past the cut. - */ -export async function collectConnections( - client: Awaited>, - scope: "project" | "organization" -): Promise { - const collected: Connection[] = []; - let offset = 0; - let total = 0; - - do { - const payload = await client.fetch( - `/internal/dashboard/rpc/connections?scope=${scope}&limit=${CONNECTION_PAGE_SIZE}&offset=${offset}` - ); - collected.push(...payload.connections); - total = payload.pagination.total; - offset += CONNECTION_PAGE_SIZE; - // A page that comes back short means the list ended, whatever total says. - if (payload.connections.length < CONNECTION_PAGE_SIZE) { - break; - } - } while (collected.length < total); - - return collected; -} - -/** - * The provider the relay would route this project through, if any. - * - * Deliberately the same three-part test the row's "Serving traffic" badge uses - * — project-scoped, active, and the default. A project may hold a proven key - * per provider now, so `active` alone answers "does this key work", not "is - * this the one". Anything looser named a provider that routes nothing, and the - * copy built on it told people traffic ran somewhere it did not. - */ -export function findServingProvider( - connections: readonly Connection[] -): OrganizationRpcProvider | null { - const serving = connections.find( - (connection) => - connection.scope === "project" && connection.status === "active" && connection.isDefault - ); - if (!serving) { - return null; - } - return (ORGANIZATION_RPC_PROVIDERS as readonly string[]).includes(serving.provider) - ? (serving.provider as OrganizationRpcProvider) - : null; -} - -/** Every provider this scope holds a key for that has not been withdrawn. */ -export function findProvidersWithOwnKey(connections: readonly Connection[]): string[] { - const providers = new Set(); - for (const connection of connections) { - if (connection.scope === "project" && connection.status !== "deactivated") { - providers.add(connection.provider); - } - } - return [...providers]; -} - -/** - * What the tenant's own credentials say about this project, for surfaces that - * need the answer and not the list — the catalog states which providers are - * connected but offers no control over any individual credential. - * - * The empty answer covers three things the caller treats alike on purpose: - * nothing of the tenant's own is here, the viewer may not read connections (the - * internal routes are org:admin for reads as well as writes), or the read - * failed. In every one of them the organization's selection is the best answer - * we hold, which is what the catalog showed before BYOK existed — so a member - * sees exactly what they saw before rather than a claim we cannot support. - */ -export async function fetchRpcTenantState(canManage: boolean): Promise<{ - servingProvider: OrganizationRpcProvider | null; - providersWithOwnKey: string[]; -}> { - if (!canManage) { - return { servingProvider: null, providersWithOwnKey: [] }; - } - - try { - const client = await createSdpApiClient(); - // Project scope only. Organization-scoped rows are the pre-HOO-1226 - // leftovers the relay no longer resolves, so none of them can be serving - // and fetching them here would only cost a round trip. - const connections = await collectConnections(client, "project"); - return { - servingProvider: findServingProvider(connections), - providersWithOwnKey: findProvidersWithOwnKey(connections), - }; - } catch { - return { servingProvider: null, providersWithOwnKey: [] }; - } -} diff --git a/apps/sdp-web/src/app/dashboard/onboarding-status.ts b/apps/sdp-web/src/app/dashboard/onboarding-status.ts index f334655232..b4a8f46b3c 100644 --- a/apps/sdp-web/src/app/dashboard/onboarding-status.ts +++ b/apps/sdp-web/src/app/dashboard/onboarding-status.ts @@ -5,8 +5,7 @@ export type OnboardingStatusResponse = { } | null; setup?: { status: "not_started" | "in_progress" | "complete"; - currentStep: "rpc" | "custody" | "complete"; - rpcProvider: import("@sdp/types").OrganizationRpcProvider | null; + currentStep: "custody" | "complete"; custodyProvider: import("@sdp/types").CustodyProvider | null; completedAt: string | null; version: number; diff --git a/apps/sdp-web/src/app/dashboard/settings/actions.ts b/apps/sdp-web/src/app/dashboard/settings/actions.ts deleted file mode 100644 index 7bab3c6f3a..0000000000 --- a/apps/sdp-web/src/app/dashboard/settings/actions.ts +++ /dev/null @@ -1,84 +0,0 @@ -"use server"; - -import { ORGANIZATION_RPC_PROVIDERS, type OrganizationRpcProvider } from "@sdp/types"; -import { getTranslations } from "@/i18n/server"; -import { createOrgSdpApiClient } from "@/lib/sdp-api"; - -type OrganizationSettings = { - rpcProvider?: OrganizationRpcProvider; -}; - -type OrganizationRecord = { - id: string; - settings: OrganizationSettings | null; -}; - -type UpdateOrganizationRpcSettingsResult = { - status: "success" | "error"; - message: string; - savedOrganizationId?: string; - savedRpcProvider?: OrganizationRpcProvider; -}; - -function isOrganizationRpcProvider(value: string): value is OrganizationRpcProvider { - return ORGANIZATION_RPC_PROVIDERS.includes(value as OrganizationRpcProvider); -} - -function toErrorMessage(error: unknown, fallback: string): string { - if (error instanceof Error && error.message) { - return error.message; - } - return fallback; -} - -export async function updateOrganizationRpcSettingsAction( - formData: FormData -): Promise { - const t = await getTranslations(); - const organizationId = String(formData.get("organizationId") ?? "").trim(); - const rpcProvider = String(formData.get("rpcProvider") ?? "default").trim(); - - if (!organizationId) { - return { - status: "error", - message: t("DashboardCustody.missingOrganizationId"), - }; - } - - const resolvedProvider: OrganizationRpcProvider = isOrganizationRpcProvider(rpcProvider) - ? rpcProvider - : "default"; - - try { - const client = await createOrgSdpApiClient(); - const updated = await client.fetch(`/v1/organizations/${organizationId}`, { - method: "PATCH", - body: JSON.stringify({ - settings: { rpcProvider: resolvedProvider }, - }), - }); - - const persistedProvider = updated.settings?.rpcProvider ?? "default"; - if (persistedProvider !== resolvedProvider) { - return { - status: "error", - message: t("DashboardCustody.rpcProviderSaveMismatch", { - requested: resolvedProvider, - persisted: persistedProvider, - }), - }; - } - - return { - status: "success", - message: t("DashboardCustody.rpcSettingsSaved"), - savedOrganizationId: organizationId, - savedRpcProvider: persistedProvider, - }; - } catch (error) { - return { - status: "error", - message: toErrorMessage(error, t("DashboardCustody.failedToSaveRpcSettings")), - }; - } -} diff --git a/apps/sdp-web/src/app/dashboard/settings/page.tsx b/apps/sdp-web/src/app/dashboard/settings/page.tsx index 1f75da074b..ac322fe4be 100644 --- a/apps/sdp-web/src/app/dashboard/settings/page.tsx +++ b/apps/sdp-web/src/app/dashboard/settings/page.tsx @@ -13,10 +13,12 @@ function resolveMembersPage(value: string | string[] | undefined): number { } /** - * RPC selection used to live here (HOO-787). It is managed on each provider's - * page under Integrations now, which is also where the fallback warning for a - * provider the deployment no longer offers is raised — so this page no longer - * loads the organization or its provider availability at all. + * Organization settings: the quick start for API key managers and the members + * section for organization writers. + * + * @param props - The route props. + * @param props.searchParams - The query, carrying the members page. + * @returns The rendered settings page. */ export default async function SettingsPage({ searchParams, diff --git a/apps/sdp-web/src/components/dashboard-header.tsx b/apps/sdp-web/src/components/dashboard-header.tsx index 94f85d9b7e..9e4cb02a9d 100644 --- a/apps/sdp-web/src/components/dashboard-header.tsx +++ b/apps/sdp-web/src/components/dashboard-header.tsx @@ -1086,7 +1086,7 @@ export function getDashboardPageConfig( if (pathname.startsWith("/dashboard/settings") || pathname === "/dashboard/members") { // Settings was the only route left on the `max-w-5xl` default, which stranded a // wide empty gutter beside its cards. Widened rather than set to `max-w-none`: - // the members table and the RPC form are label/value rows, and letting them span + // the members table rows are label/value pairs, and letting them span // an ultrawide display pushes each value far from its label. return { title: t("Shared.dashboardShell.settings"), diff --git a/apps/sdp-web/src/components/dashboard-nav.ts b/apps/sdp-web/src/components/dashboard-nav.ts index 95485678ef..cb9b6560c6 100644 --- a/apps/sdp-web/src/components/dashboard-nav.ts +++ b/apps/sdp-web/src/components/dashboard-nav.ts @@ -200,11 +200,6 @@ export function getIntegrationActions( }, ] : []), - { - label: t("Shared.integrations.rpcTitle"), - href: DASHBOARD_INTEGRATIONS_SUBNAV_HREFS.rpc, - icon: CircleDotDashedIcon, - }, ...(options.paymentsEnabled ? [ { diff --git a/apps/sdp-web/src/components/dashboard-nav.unit.test.tsx b/apps/sdp-web/src/components/dashboard-nav.unit.test.tsx index 771a850114..3a9ea58a60 100644 --- a/apps/sdp-web/src/components/dashboard-nav.unit.test.tsx +++ b/apps/sdp-web/src/components/dashboard-nav.unit.test.tsx @@ -255,7 +255,6 @@ describe("Integrations dashboard navigation", () => { expect(item?.subnavKey).toBe("integrations"); expect(item?.children?.map((child) => child.href)).toEqual([ "/dashboard/integrations?tab=custody", - "/dashboard/integrations?tab=rpc", "/dashboard/integrations?tab=ramps", "/dashboard/integrations?tab=compliance", "/dashboard/integrations?tab=privacy", @@ -263,7 +262,7 @@ describe("Integrations dashboard navigation", () => { expect(item?.children?.every((child) => child.icon)).toBe(true); }); - it("keeps only RPC when every owning module is disabled", () => { + it("lists no family when every owning module is disabled", () => { const item = findIntegrationsItem( navOptions({ custodyEnabled: false, @@ -273,7 +272,7 @@ describe("Integrations dashboard navigation", () => { }) ); - expect(item?.children?.map((child) => child.href)).toEqual(["/dashboard/integrations?tab=rpc"]); + expect(item?.children).toEqual([]); }); }); diff --git a/apps/sdp-web/src/contexts/dashboard-workspace-url-state.unit.test.ts b/apps/sdp-web/src/contexts/dashboard-workspace-url-state.unit.test.ts index 67db54ea73..5f385f5bb6 100644 --- a/apps/sdp-web/src/contexts/dashboard-workspace-url-state.unit.test.ts +++ b/apps/sdp-web/src/contexts/dashboard-workspace-url-state.unit.test.ts @@ -90,7 +90,7 @@ describe("dashboard workspace tab URL state", () => { } ); - it.each(["custody", "rpc", "ramps", "compliance", "privacy"])( + it.each(["custody", "ramps", "compliance", "privacy"])( "keeps the %s tab an Integrations submenu link opens from another page", (tab) => { expect( diff --git a/apps/sdp-web/src/i18n/ui-copy-baseline.json b/apps/sdp-web/src/i18n/ui-copy-baseline.json index 52ff7b9adc..9bde28db92 100644 --- a/apps/sdp-web/src/i18n/ui-copy-baseline.json +++ b/apps/sdp-web/src/i18n/ui-copy-baseline.json @@ -5,9 +5,6 @@ "src/app/dashboard/helius-rings/helius-rings-skeleton.tsx:75:3:text", "src/app/dashboard/home-workspace.tsx:684:23:medium", "src/app/dashboard/home-workspace.tsx:684:23:short", - "src/app/dashboard/integrations/rpc-byok-section.tsx:583:15:https://your-endpoint.example", - "src/app/dashboard/integrations/rpc-byok-section.tsx:591:71:{API_KEY}", - "src/app/dashboard/integrations/rpc-byok-section.tsx:740:13:https://your-endpoint.example", "src/app/dashboard/issuance/[tokenId]/asset-profile/asset-profile-header.tsx:126:23:·", "src/app/dashboard/issuance/[tokenId]/asset-profile/asset-profile-mapping.ts:176:5:description", "src/app/dashboard/issuance/[tokenId]/asset-profile/tabs/operation-rows.model.ts:151:9:force-burn", diff --git a/apps/sdp-web/src/lib/dashboard-navigation-loading.ts b/apps/sdp-web/src/lib/dashboard-navigation-loading.ts index 025edbd337..2f28b54664 100644 --- a/apps/sdp-web/src/lib/dashboard-navigation-loading.ts +++ b/apps/sdp-web/src/lib/dashboard-navigation-loading.ts @@ -29,7 +29,6 @@ export const DASHBOARD_PAYMENTS_SUBNAV_HREFS = { export const DASHBOARD_INTEGRATIONS_SUBNAV_HREFS = { custody: "/dashboard/integrations?tab=custody", - rpc: "/dashboard/integrations?tab=rpc", ramps: "/dashboard/integrations?tab=ramps", compliance: "/dashboard/integrations?tab=compliance", privacy: "/dashboard/integrations?tab=privacy", diff --git a/apps/sdp-web/src/lib/dashboard-navigation-loading.unit.test.ts b/apps/sdp-web/src/lib/dashboard-navigation-loading.unit.test.ts index 2bea26befa..1be4a7e44c 100644 --- a/apps/sdp-web/src/lib/dashboard-navigation-loading.unit.test.ts +++ b/apps/sdp-web/src/lib/dashboard-navigation-loading.unit.test.ts @@ -85,7 +85,7 @@ describe("integrations route", () => { expect( isDashboardNavItemActive( "/dashboard/integrations?tab=custody", - "/dashboard/integrations?tab=rpc" + "/dashboard/integrations?tab=ramps" ) ).toBe(false); }); diff --git a/apps/sdp-web/src/lib/provider-availability.ts b/apps/sdp-web/src/lib/provider-availability.ts index e2d548b80d..e841d89f15 100644 --- a/apps/sdp-web/src/lib/provider-availability.ts +++ b/apps/sdp-web/src/lib/provider-availability.ts @@ -1,7 +1,6 @@ import type { ComplianceProviderId, OrganizationProviderAvailabilityResponse, - OrganizationRpcProvider, ProviderAvailabilityEntry, RampProviderId, } from "@sdp/types"; @@ -13,7 +12,6 @@ import type { SdpApiClient } from "@/lib/sdp-api"; export interface DashboardProviderAvailability extends OrganizationProviderAvailabilityResponse { enabledCustodyProviders: KnownCustodyProvider[]; - enabledRpcProviders: OrganizationRpcProvider[]; enabledComplianceProviders: ComplianceProviderId[]; rampProviderAccess: RampProviderAccess; } @@ -70,9 +68,6 @@ export async function fetchProviderAvailability( .filter(([, entry]) => entry.enabled) .map(([provider]) => provider) .filter(isKnownCustodyProvider), - enabledRpcProviders: Object.entries(data.providers.rpc) - .filter(([, entry]) => entry.enabled) - .map(([provider]) => provider as OrganizationRpcProvider), enabledComplianceProviders: Object.entries(data.providers.compliance) .filter(([, entry]) => entry.enabled) .map(([provider]) => provider as ComplianceProviderId), diff --git a/apps/sdp-web/src/lib/rpc-connection.tsx b/apps/sdp-web/src/lib/rpc-connection.tsx deleted file mode 100644 index 116156f0d6..0000000000 --- a/apps/sdp-web/src/lib/rpc-connection.tsx +++ /dev/null @@ -1,245 +0,0 @@ -"use client"; - -import type { OrganizationRpcProvider } from "@sdp/types"; -import { Badge } from "@/components/ui/badge"; -import type { MessageKey } from "@/i18n/messages"; -import { useTranslations } from "@/i18n/provider"; -import { dashboardFetch } from "@/lib/dashboard-fetch"; -import { rpcProviderLabel } from "@/lib/rpc-providers"; - -type Translate = ReturnType; - -type RpcProxyResponse = { - provider: { - id: string; - selectionMode: string; - endpoint: string; - }; - upstream: { - ok: boolean; - status: number; - statusText: string; - }; -}; - -/** - * Why a failed test failed. A mismatch is not an unreachable endpoint: the - * upstream answered, another provider just owned the request. Collapsing the - * two reported a healthy Alchemy connection as "Unreachable" beside its own - * 200 OK. - */ -export type RpcTestFailure = "mismatch" | "upstream" | "request_failed"; - -export type RpcTestResult = { - status: "success" | "error"; - reason?: RpcTestFailure; - message: string; - requestedProvider: OrganizationRpcProvider; - resolvedProvider?: string; - selectionMode?: string; - endpoint?: string; - upstreamStatus?: number; - upstreamStatusText?: string; - latencyMs?: number; -}; - -function toRpcTestErrorMessage(error: unknown, fallback: string): string { - if (error instanceof Error && error.message) { - return error.message; - } - return fallback; -} - -/** - * `/v1/rpc/test` resolves whatever provider the organization has *saved*, not - * one passed in — so the caller must only offer this for the active provider. - * The requested/resolved comparison stays as the guard against a save that - * silently did not take. - */ -export async function runRpcProviderTest( - requestedProvider: OrganizationRpcProvider, - t: Translate -): Promise { - const startedAt = Date.now(); - - try { - const result = await dashboardFetch<{ data: RpcProxyResponse }>( - "/api/dashboard/settings/rpc-test", - { - method: "POST", - body: { - jsonrpc: "2.0", - id: "org-rpc-test", - method: "getVersion", - params: [], - }, - } - ); - - const latencyMs = Date.now() - startedAt; - - if (!result.ok) { - return { - status: "error", - reason: "request_failed", - message: result.error, - requestedProvider, - latencyMs, - }; - } - - const { - provider: { id: resolvedProvider, endpoint, selectionMode }, - upstream, - } = result.data.data; - - if (requestedProvider !== "default" && resolvedProvider !== requestedProvider) { - return { - status: "error", - reason: "mismatch", - message: t("DashboardCustody.rpcTestMismatch", { - requested: rpcProviderLabel(requestedProvider), - resolved: rpcProviderLabel(resolvedProvider), - }), - requestedProvider, - resolvedProvider, - selectionMode, - endpoint, - upstreamStatus: upstream.status, - upstreamStatusText: upstream.statusText, - latencyMs, - }; - } - - if (!upstream.ok) { - return { - status: "error", - reason: "upstream", - message: t("DashboardCustody.rpcUpstreamReturned", { - status: upstream.status, - statusText: upstream.statusText, - }), - requestedProvider, - resolvedProvider, - selectionMode, - endpoint, - upstreamStatus: upstream.status, - upstreamStatusText: upstream.statusText, - latencyMs, - }; - } - - return { - status: "success", - message: t("DashboardCustody.rpcTestPassed", { - status: upstream.status, - statusText: upstream.statusText, - latency: latencyMs, - }), - requestedProvider, - resolvedProvider, - selectionMode, - endpoint, - upstreamStatus: upstream.status, - upstreamStatusText: upstream.statusText, - latencyMs, - }; - } catch (error) { - return { - status: "error", - reason: "request_failed", - message: toRpcTestErrorMessage(error, t("DashboardCustody.failedToTestRpcProvider")), - requestedProvider, - latencyMs: Date.now() - startedAt, - }; - } -} - -/** - * The relay's own vocabulary for how it picked an endpoint. Rendering the raw - * enum put `project_connection` in front of readers who have no reason to know - * the API's spelling. - */ -const SELECTION_MODE_KEYS: Record = { - project_connection: "DashboardCustody.rpcSelectionProjectConnection", - organization_connection: "DashboardCustody.rpcSelectionOrganizationConnection", - project_provider: "DashboardCustody.rpcSelectionProjectProvider", - project_custom_provider: "DashboardCustody.rpcSelectionProjectCustomProvider", - organization_provider: "DashboardCustody.rpcSelectionOrganizationProvider", - round_robin_default: "DashboardCustody.rpcSelectionRoundRobinDefault", -}; - -/** Falls back to the raw mode so an unmapped one still says something. */ -function selectionModeLabel(mode: string, t: Translate): string { - const key = SELECTION_MODE_KEYS[mode]; - return key ? t(key) : mode; -} - -export function RpcTestResultPanel({ result }: { result: RpcTestResult }) { - const t = useTranslations(); - // Three outcomes, not two: a mismatch reached the upstream fine. - const badge = - result.status === "success" - ? { variant: "success" as const, label: t("DashboardCustody.rpcDetailReachable") } - : result.reason === "mismatch" - ? { variant: "warning" as const, label: t("DashboardCustody.rpcDetailMismatch") } - : { variant: "danger" as const, label: t("DashboardCustody.rpcDetailUnreachable") }; - return ( -
    -
    - - {t("DashboardCustody.rpcDetailTitle")} - - {badge.label} -
    - {result.status === "error" ? ( -

    - {result.message} -

    - ) : null} -
    - {result.resolvedProvider ? ( -
    -
    {t("DashboardCustody.rpcDetailResolvedProvider")}
    -
    {rpcProviderLabel(result.resolvedProvider)}
    -
    - ) : null} - {result.selectionMode ? ( -
    -
    {t("DashboardCustody.rpcDetailSelectionMode")}
    -
    {selectionModeLabel(result.selectionMode, t)}
    -
    - ) : null} - {result.endpoint ? ( -
    -
    {t("DashboardCustody.rpcDetailEndpoint")}
    - {/* Not a code surface: an endpoint in a settings row reads as - product UI, so it keeps the body text style. */} -
    {result.endpoint}
    -
    - ) : null} - {result.upstreamStatus !== undefined ? ( -
    -
    {t("DashboardCustody.rpcDetailUpstream")}
    -
    - {result.upstreamStatus} - {result.upstreamStatusText ? ` ${result.upstreamStatusText}` : ""} -
    -
    - ) : null} - {result.latencyMs !== undefined ? ( -
    -
    {t("DashboardCustody.rpcDetailLatency")}
    -
    - {t("DashboardCustody.rpcDetailLatencyValue", { ms: result.latencyMs })} -
    -
    - ) : null} -
    -
    - ); -} diff --git a/apps/sdp-web/src/lib/rpc-connection.unit.test.tsx b/apps/sdp-web/src/lib/rpc-connection.unit.test.tsx deleted file mode 100644 index 348b2c8396..0000000000 --- a/apps/sdp-web/src/lib/rpc-connection.unit.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -// @vitest-environment jsdom - -import { cleanup, render, screen } from "@testing-library/react"; -import type { ReactNode } from "react"; -import { afterEach, describe, expect, it } from "vitest"; - -import { getMessages } from "@/i18n/messages"; -import { I18nProvider } from "@/i18n/provider"; -import { type RpcTestResult, RpcTestResultPanel } from "./rpc-connection"; - -function renderResult(result: Partial = {}) { - const wrapper = ({ children }: { children: ReactNode }) => ( - - {children} - - ); - return render( - , - { wrapper } - ); -} - -afterEach(cleanup); - -describe("RpcTestResultPanel", () => { - it("does not call a mismatch unreachable", () => { - // The endpoint answered 200. Reporting "Unreachable" beside its own - // "200 OK" row is the contradiction this state exists to remove. - renderResult({ - reason: "mismatch", - resolvedProvider: "alchemy", - upstreamStatus: 200, - upstreamStatusText: "OK", - latencyMs: 1341, - }); - - expect(screen.queryByText("Unreachable")).toBeNull(); - expect(screen.getByText("Another provider answered")).toBeTruthy(); - }); - - it("still reports a genuinely unreachable upstream as unreachable", () => { - renderResult({ reason: "upstream", upstreamStatus: 502, upstreamStatusText: "Bad Gateway" }); - - expect(screen.getByText("Unreachable")).toBeTruthy(); - expect(screen.queryByText("Another provider answered")).toBeNull(); - }); - - it("reports a passing check as reachable", () => { - renderResult({ status: "success", reason: undefined, upstreamStatus: 200 }); - - expect(screen.getByText("Reachable")).toBeTruthy(); - }); - - it("renders the relay's selection mode as words, not its enum", () => { - // `project_connection` is the API's spelling; nobody reading the dashboard - // has a reason to know it. - renderResult({ reason: "mismatch", selectionMode: "project_connection" }); - - expect(screen.queryByText("project_connection")).toBeNull(); - expect(screen.getByText("This project's own connection")).toBeTruthy(); - }); - - it("falls back to the raw mode when the relay adds one we do not map", () => { - renderResult({ reason: "mismatch", selectionMode: "some_future_mode" }); - - expect(screen.getByText("some_future_mode")).toBeTruthy(); - }); -}); diff --git a/apps/sdp-web/src/lib/rpc-providers.ts b/apps/sdp-web/src/lib/rpc-providers.ts deleted file mode 100644 index 26c08b1e0d..0000000000 --- a/apps/sdp-web/src/lib/rpc-providers.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { OrganizationRpcProvider } from "@sdp/types"; - -/** - * Display names for the RPC provider ids. Server-safe on purpose: the - * integrations catalog resolves these during a server render, so the labels - * cannot live next to the client-only connection controls. - * - * Settings and the integrations surface used to carry separate copies that had - * already drifted — `default` read "SDP" on one and "SDP RPC" on the other. - * One provider must not have two names now that both surfaces can manage it. - */ -export const RPC_PROVIDER_LABELS: Record = { - alchemy: "Alchemy", - default: "SDP RPC", - helius: "Helius", - nodit: "Nodit", - quicknode: "QuickNode", - triton: "Triton", - validationcloud: "Validation Cloud", -}; - -/** Falls back to the raw id so an unrecognised provider still renders as itself. */ -export function rpcProviderLabel(provider: string): string { - return RPC_PROVIDER_LABELS[provider as OrganizationRpcProvider] ?? provider; -} diff --git a/packages/sdp-rpc/package.json b/packages/sdp-rpc/package.json index 172abe3fb4..84ec780458 100644 --- a/packages/sdp-rpc/package.json +++ b/packages/sdp-rpc/package.json @@ -14,11 +14,6 @@ "import": "./src/blocked-address.ts", "default": "./src/blocked-address.ts" }, - "./byok": { - "types": "./src/byok.ts", - "import": "./src/byok.ts", - "default": "./src/byok.ts" - }, "./errors": { "types": "./src/errors.ts", "import": "./src/errors.ts", @@ -52,7 +47,6 @@ }, "dependencies": { "@sdp/types": "workspace:*", - "@solana/addresses": "catalog:", "@solana/kit": "catalog:" }, "devDependencies": { diff --git a/packages/sdp-rpc/src/blocked-address.test.ts b/packages/sdp-rpc/src/blocked-address.test.ts new file mode 100644 index 0000000000..8feb4c0fd8 --- /dev/null +++ b/packages/sdp-rpc/src/blocked-address.test.ts @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { assertReachableTenantEndpoint, isBlockedAddress } from "./blocked-address"; + +function assertUnreachable(endpointUrl: string): void { + assert.throws( + () => assertReachableTenantEndpoint(endpointUrl), + { name: "SdpRpcError", code: "BAD_REQUEST", message: /not reachable/i }, + endpointUrl + ); +} + +describe("assertReachableTenantEndpoint", () => { + it("refuses the cloud metadata address", () => { + assertUnreachable("https://169.254.169.254/latest/meta-data"); + }); + + it("refuses loopback and private IPv4 ranges", () => { + for (const endpointUrl of [ + "https://127.0.0.1/rpc", + "https://10.0.0.5/rpc", + "https://192.168.1.10/rpc", + "https://172.16.4.4/rpc", + ]) { + assertUnreachable(endpointUrl); + } + }); + + it("refuses localhost and reserved internal suffixes", () => { + for (const endpointUrl of [ + "https://localhost/rpc", + "https://LOCALHOST/rpc", + "https://api.localhost/rpc", + "https://vault.internal/rpc", + "https://printer.local/rpc", + ]) { + assertUnreachable(endpointUrl); + } + }); + + it("refuses IPv6 loopback, unspecified, unique-local and link-local literals", () => { + for (const endpointUrl of [ + "https://[::1]/rpc", + "https://[0:0:0:0:0:0:0:1]/rpc", + "https://[::]/rpc", + "https://[fd00::1]/rpc", + "https://[fc00::1]/rpc", + "https://[fe80::1]/rpc", + "https://[fe80::a00:27ff:fe4e:66a1]/rpc", + "https://[fe80::a9fe:a9fe]/rpc", + ]) { + assertUnreachable(endpointUrl); + } + }); + + it("refuses an IPv4-mapped private address the parser rewrites to hex", () => { + assert.equal(new URL("https://[::ffff:127.0.0.1]/rpc").hostname, "[::ffff:7f00:1]"); + assertUnreachable("https://[::ffff:127.0.0.1]/rpc"); + assertUnreachable("https://[::ffff:169.254.169.254]/rpc"); + assertUnreachable("https://[::ffff:7f00:1]/rpc"); + }); + + it("allows a routable IPv6 endpoint and a routable IPv4-mapped one", () => { + assert.doesNotThrow(() => assertReachableTenantEndpoint("https://[2606:4700::1111]/rpc")); + assert.doesNotThrow(() => assertReachableTenantEndpoint("https://[::ffff:8.8.8.8]/rpc")); + }); + + it("refuses every range the connect-time guard refuses", () => { + const blockedHosts = [ + { endpointUrl: "https://100.64.0.1/rpc", address: "100.64.0.1" }, + { endpointUrl: "https://100.127.255.254/rpc", address: "100.127.255.254" }, + { endpointUrl: "https://198.18.0.1/rpc", address: "198.18.0.1" }, + { endpointUrl: "https://192.0.0.170/rpc", address: "192.0.0.170" }, + { endpointUrl: "https://224.0.0.1/rpc", address: "224.0.0.1" }, + { endpointUrl: "https://255.255.255.255/rpc", address: "255.255.255.255" }, + { endpointUrl: "https://0.1.2.3/rpc", address: "0.1.2.3" }, + { endpointUrl: "https://[ff02::1]/rpc", address: "ff02::1" }, + ]; + for (const { endpointUrl, address } of blockedHosts) { + assert.equal(isBlockedAddress(address), true, address); + assertUnreachable(endpointUrl); + } + }); + + it("allows the addresses just outside the CGNAT range, matching the connect-time guard", () => { + for (const address of ["100.63.255.255", "100.128.0.1"]) { + assert.equal(isBlockedAddress(address), false, address); + assert.doesNotThrow(() => assertReachableTenantEndpoint(`https://${address}/rpc`)); + } + }); + + it("refuses credentials embedded in the URL", () => { + for (const endpointUrl of [ + "https://user:pass@rpc.example.com/", + "https://token@rpc.example.com/", + ]) { + assert.throws(() => assertReachableTenantEndpoint(endpointUrl), { + name: "SdpRpcError", + code: "BAD_REQUEST", + message: /credential/i, + }); + } + }); + + it("refuses plaintext http", () => { + assert.throws(() => assertReachableTenantEndpoint("http://rpc.example.com"), { + name: "SdpRpcError", + code: "BAD_REQUEST", + message: /https/i, + }); + }); + + it("refuses a malformed URL", () => { + assert.throws(() => assertReachableTenantEndpoint("not-a-url"), { + name: "SdpRpcError", + code: "BAD_REQUEST", + message: /valid URL/i, + }); + }); + + it("allows an ordinary public endpoint", () => { + assert.doesNotThrow(() => assertReachableTenantEndpoint("https://rpc.example.com")); + assert.doesNotThrow(() => assertReachableTenantEndpoint("https://tenant.example.org/v1/rpc")); + }); +}); diff --git a/packages/sdp-rpc/src/blocked-address.ts b/packages/sdp-rpc/src/blocked-address.ts index 51de9ac5e3..b30f73d944 100644 --- a/packages/sdp-rpc/src/blocked-address.ts +++ b/packages/sdp-rpc/src/blocked-address.ts @@ -1,10 +1,5 @@ -/** - * Address classification shared by the write-time endpoint check - * (`assertReachableTenantEndpoint`) and the connect-time egress guard in - * sdp-api. One list, so a URL that passes submission cannot name an address - * the transport then refuses, and the transport cannot dial one submission - * would have blocked. - */ +import { isIP } from "node:net"; +import { SdpRpcError } from "./errors"; function isBlockedIpv4(address: string): boolean { const octets = address.split(".").map(Number); @@ -59,7 +54,97 @@ function isBlockedIpv6(address: string): boolean { return false; } -/** Whether SDP refuses to open a connection to this address. */ +/** + * Whether SDP refuses to open a connection to this address. Shared by the + * write-time endpoint check (`assertReachableTenantEndpoint` below) and the + * connect-time egress guard in sdp-api, so a URL that passes submission cannot + * name an address the transport then refuses, and the transport cannot dial + * one submission would have blocked. + * + * @param address - A literal IPv4 or IPv6 address. + * @returns Whether the address is loopback, private, link-local or otherwise blocked. + */ export function isBlockedAddress(address: string): boolean { return address.includes(":") ? isBlockedIpv6(address) : isBlockedIpv4(address); } + +/** + * Names a tenant endpoint may never point at. + * + * SDP fetches whatever URL a tenant stores (the Helius Rings tenant RPC URL), + * so without this a tenant could aim SDP's server at loopback, a private range, + * or a cloud metadata service and read back coarse reachability from the status + * and timing. Blocking at submission keeps such a row from existing. + * + * Name matching only: the URL parser canonicalises every literal-address + * spelling, and those go through `isBlockedAddress`, the same classification + * the egress guard applies at connect time. A hostname that resolves to a + * private address passes here and is caught by that guard instead. + */ +const BLOCKED_HOST_PATTERNS: RegExp[] = [ + /^localhost$/i, + /\.localhost$/i, + /\.internal$/i, + /\.local$/i, +]; + +/** + * The host with IPv6 brackets removed, lowercased. + * + * `URL` also rewrites an IPv4-mapped literal into hex (`::ffff:127.0.0.1` + * becomes `::ffff:7f00:1`), so the mapped IPv4 tail is expanded back to dotted + * quad before the IPv4 patterns run — otherwise loopback re-enters as hex. + * + * @param hostname - `URL.hostname` of the submitted endpoint. + * @returns The normalized host, and its dotted-quad form when it is an IPv4-mapped IPv6 literal. + */ +function normalizeHost(hostname: string): { host: string; mappedIpv4: string | null } { + const host = hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase(); + const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host); + if (!mapped) { + return { host, mappedIpv4: null }; + } + + const high = Number.parseInt(mapped[1], 16); + const low = Number.parseInt(mapped[2], 16); + return { + host, + mappedIpv4: [high >> 8, high & 0xff, low >> 8, low & 0xff].join("."), + }; +} + +/** + * Reject a tenant-supplied RPC URL that is not https, embeds credentials, or names a blocked host. + * + * @param endpointUrl - The URL the tenant submitted. + * @throws SdpRpcError `BAD_REQUEST` naming the rule the URL breaks. + */ +export function assertReachableTenantEndpoint(endpointUrl: string): void { + let parsed: URL; + try { + parsed = new URL(endpointUrl); + } catch { + throw new SdpRpcError("BAD_REQUEST", "The RPC endpoint is not a valid URL"); + } + + if (parsed.protocol !== "https:") { + throw new SdpRpcError("BAD_REQUEST", "An RPC endpoint must use https"); + } + + if (parsed.username || parsed.password) { + throw new SdpRpcError("BAD_REQUEST", "An RPC endpoint URL must not embed credentials"); + } + + const { host, mappedIpv4 } = normalizeHost(parsed.hostname); + const candidates = mappedIpv4 ? [host, mappedIpv4] : [host]; + + if ( + candidates.some( + (candidate) => + BLOCKED_HOST_PATTERNS.some((pattern) => pattern.test(candidate)) || + (isIP(candidate) !== 0 && isBlockedAddress(candidate)) + ) + ) { + throw new SdpRpcError("BAD_REQUEST", "That RPC endpoint host is not reachable from SDP"); + } +} diff --git a/packages/sdp-rpc/src/byok.ts b/packages/sdp-rpc/src/byok.ts deleted file mode 100644 index b465e21bbb..0000000000 --- a/packages/sdp-rpc/src/byok.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { isIP } from "node:net"; -import type { OrganizationRpcProvider } from "@sdp/types"; -import { rpcProviderNeedsEndpoint } from "@sdp/types"; -import { isBlockedAddress } from "./blocked-address"; -import { - applyApiKeyTemplate, - withAlchemyApiKey, - withHeliusApiKey, - withOptionalApiKeyTemplate, -} from "./config"; -import { SdpRpcError } from "./errors"; -import { maskCredentialShapes } from "./relay"; - -/** - * A tenant supplies the same pair the operator supplies today: an endpoint and - * a key. `resolveManagedProviders` reads `SOLANA_RPC__URL` plus - * `SOLANA_RPC__API_KEY` and applies a per-provider rule; BYOK applies - * the identical rule to credentials the organization owns. - * - * The endpoint is required rather than derived from a built-in vendor URL: - * QuickNode, Triton and Validation Cloud endpoints are account-specific, and - * guessing a host for the others would put traffic somewhere nobody chose. - */ -export interface TenantRpcCredential { - /** May carry the `{API_KEY}` placeholder the platform templates already use. */ - endpointUrl: string; - apiKey: string; -} - -export interface TenantRpcTarget { - endpoint: string; - headers: Record; -} - -/** `default` is SDP's own platform-managed rail — it has no tenant credential. */ -export type ByokRpcProvider = Exclude; - -export const BYOK_RPC_PROVIDERS: readonly ByokRpcProvider[] = [ - "alchemy", - "helius", - "nodit", - "quicknode", - "triton", - "validationcloud", -]; - -export function isByokRpcProvider(value: string): value is ByokRpcProvider { - return (BYOK_RPC_PROVIDERS as readonly string[]).includes(value); -} - -/** - * Endpoints a tenant does not have to type. - * - * Only providers whose host is the same for every account belong here. - * QuickNode and Triton issue an account-specific subdomain -- SDP's own are - * `evocative-old-mansion.solana-devnet.quiknode.pro` and - * `solanaf-sdp-7436.devnet.rpcpool.com` -- so there is no base to publish and - * those two must always be supplied. - * - * Every URL below was confirmed against the live host rather than guessed: - * each answers a JSON-RPC POST with an authentication error rather than a DNS - * failure or a 404, and each vendor's documented form agrees with the one SDP - * uses for its own account. - */ -export const DEFAULT_TENANT_ENDPOINTS: Partial< - Record> -> = { - helius: { - devnet: "https://devnet.helius-rpc.com", - "mainnet-beta": "https://mainnet.helius-rpc.com", - }, - alchemy: { - devnet: "https://solana-devnet.g.alchemy.com/v2", - "mainnet-beta": "https://solana-mainnet.g.alchemy.com/v2", - }, - // Key in the path: `/v1/` alone answers "missing api key" and `/v1/` - // answers "invalid api key", so the placeholder has to be templated in. - validationcloud: { - devnet: "https://devnet.solana.validationcloud.io/v1/{API_KEY}", - "mainnet-beta": "https://mainnet.solana.validationcloud.io/v1/{API_KEY}", - }, - // No placeholder: Nodit reads the key from a header, and a key in the path - // is not read at all. See `buildTenantRpcTarget`. - nodit: { - devnet: "https://solana-devnet.nodit.io", - "mainnet-beta": "https://solana-mainnet.nodit.io", - }, -}; - -/** - * The genesis hash each cluster reports, which is what makes a connection's - * network claim checkable. - * - * The probe used to ask `getVersion`, which every cluster answers alike, so an - * endpoint on the wrong cluster passed. A project's network comes from its - * environment rather than a field precisely so the two cannot disagree, and - * without this the disagreement simply moved into the endpoint: a sandbox - * project could be pointed at mainnet and read `devnet` in its own row. - * - * Read from the public clusters rather than transcribed. - */ -export const SOLANA_GENESIS_HASHES: Record<"devnet" | "mainnet-beta", string> = { - // biome-ignore lint/security/noSecrets: Public cluster genesis hash, not a credential. - devnet: "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG", - // biome-ignore lint/security/noSecrets: Public cluster genesis hash, not a credential. - "mainnet-beta": "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d", -}; - -/** Whether the tenant must supply an endpoint because we cannot know theirs. */ -export function requiresExplicitEndpoint(provider: ByokRpcProvider): boolean { - return rpcProviderNeedsEndpoint(provider); -} - -export function resolveTenantEndpoint( - provider: ByokRpcProvider, - network: "devnet" | "mainnet-beta", - supplied?: string -): string { - const trimmed = supplied?.trim(); - if (trimmed) { - return trimmed; - } - const fallback = DEFAULT_TENANT_ENDPOINTS[provider]?.[network]; - if (!fallback) { - throw new SdpRpcError( - "BAD_REQUEST", - `${provider} issues an account-specific endpoint, so one must be supplied` - ); - } - return fallback; -} - -/** - * Names a tenant endpoint may never point at. - * - * Activation and the relay both fetch whatever URL the tenant stored, so - * without this a connection could aim SDP's server at loopback, a private - * range, or a cloud metadata service and read back coarse reachability from the - * status and timing. Blocking at submission keeps such a row from existing. - * - * Name matching only: the URL parser canonicalises every literal-address - * spelling, and those go through `isBlockedAddress`, the same classification - * the egress guard applies at connect time. A hostname that resolves to a - * private address passes here and is caught by that guard instead. - */ -const BLOCKED_HOST_PATTERNS: RegExp[] = [ - /^localhost$/i, - /\.localhost$/i, - /\.internal$/i, - /\.local$/i, -]; - -/** - * The host with IPv6 brackets removed, lowercased. - * - * `URL` also rewrites an IPv4-mapped literal into hex (`::ffff:127.0.0.1` - * becomes `::ffff:7f00:1`), so the mapped IPv4 tail is expanded back to dotted - * quad before the IPv4 patterns run — otherwise loopback re-enters as hex. - */ -function normalizeHost(hostname: string): { host: string; mappedIpv4: string | null } { - const host = hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase(); - const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host); - if (!mapped) { - return { host, mappedIpv4: null }; - } - - const high = Number.parseInt(mapped[1], 16); - const low = Number.parseInt(mapped[2], 16); - return { - host, - mappedIpv4: [high >> 8, high & 0xff, low >> 8, low & 0xff].join("."), - }; -} - -export function assertReachableTenantEndpoint(endpointUrl: string): void { - let parsed: URL; - try { - parsed = new URL(endpointUrl); - } catch { - throw new SdpRpcError("BAD_REQUEST", "The RPC endpoint is not a valid URL"); - } - - if (parsed.protocol !== "https:") { - throw new SdpRpcError("BAD_REQUEST", "An RPC endpoint must use https"); - } - - if (parsed.username || parsed.password) { - throw new SdpRpcError("BAD_REQUEST", "An RPC endpoint URL must not embed credentials"); - } - - const { host, mappedIpv4 } = normalizeHost(parsed.hostname); - const candidates = mappedIpv4 ? [host, mappedIpv4] : [host]; - - if ( - candidates.some( - (candidate) => - BLOCKED_HOST_PATTERNS.some((pattern) => pattern.test(candidate)) || - (isIP(candidate) !== 0 && isBlockedAddress(candidate)) - ) - ) { - throw new SdpRpcError("BAD_REQUEST", "That RPC endpoint host is not reachable from SDP"); - } -} - -/** - * Build the outbound target for a tenant-owned credential. Pure on purpose: - * the relay and the activation check must construct targets the same way, and - * neither should need a database or a secret store to do it. - */ -export function buildTenantRpcTarget( - provider: ByokRpcProvider, - credential: TenantRpcCredential -): TenantRpcTarget { - const endpointUrl = credential.endpointUrl.trim(); - const apiKey = credential.apiKey.trim(); - - if (!endpointUrl) { - throw new SdpRpcError("BAD_REQUEST", "A tenant RPC connection requires an endpoint URL"); - } - if (!apiKey) { - throw new SdpRpcError("BAD_REQUEST", "A tenant RPC connection requires an API key"); - } - - switch (provider) { - case "helius": - return { endpoint: withHeliusApiKey(endpointUrl, apiKey), headers: {} }; - case "alchemy": - return { endpoint: withAlchemyApiKey(endpointUrl, apiKey), headers: {} }; - case "quicknode": - return { endpoint: withOptionalApiKeyTemplate(endpointUrl, apiKey), headers: {} }; - // Nodit authenticates by header. A key in the path is not read at all: - // `POST /` answers NO_AUTHENTICATION_FOUND while the same request - // with `X-API-KEY` answers AUTHENTICATION_FAILED, so the header is the - // mechanism and sending none is why a Nodit connection could never work. - // Templating stays optional so an endpoint that already carries the key - // keeps resolving. - case "nodit": - return { - endpoint: withOptionalApiKeyTemplate(endpointUrl, apiKey), - headers: { "X-API-KEY": apiKey }, - }; - // Triton authenticates by header; the key must not also be templated into - // the URL, where it would end up in logs that only redact query strings. - case "triton": - return { - endpoint: applyApiKeyTemplate(endpointUrl, apiKey), - headers: { "x-api-key": apiKey }, - }; - case "validationcloud": - return { endpoint: applyApiKeyTemplate(endpointUrl, apiKey), headers: {} }; - } -} - -/** - * Redact a tenant endpoint for display. The platform's `maskEndpoint` only - * knows the operator's own env keys, so a tenant key would survive it. - */ -export function maskTenantEndpoint(endpoint: string, apiKey: string): string { - const key = apiKey.trim(); - let masked = endpoint; - if (key) { - masked = masked.replaceAll(key, "***"); - const encoded = encodeURIComponent(key); - if (encoded !== key) { - masked = masked.replaceAll(encoded, "***"); - } - } - - return maskCredentialShapes(masked); -} - -/** - * What is safe to show about a stored connection. Never the endpoint with its - * key applied — only the host and a short suffix so an admin can tell two - * credentials apart. - */ -export function buildTenantDisplayMetadata( - credential: TenantRpcCredential -): Record { - const metadata: Record = {}; - try { - metadata.endpointHost = new URL(credential.endpointUrl).host; - } catch { - // An unparseable URL is caught by validation before storage; display - // metadata must not be the thing that fails a submission. - } - const key = credential.apiKey.trim(); - if (key.length > 4) { - metadata.apiKeySuffix = key.slice(-4); - } - return metadata; -} diff --git a/packages/sdp-rpc/src/config.ts b/packages/sdp-rpc/src/config.ts index bbf6a7daa1..e69ea5caef 100644 --- a/packages/sdp-rpc/src/config.ts +++ b/packages/sdp-rpc/src/config.ts @@ -8,7 +8,7 @@ export interface SolanaConfig { const API_KEY_TEMPLATE = ["$", "{API_KEY}"].join(""); -export function applyApiKeyTemplate(url: string, apiKey: string): string { +function applyApiKeyTemplate(url: string, apiKey: string): string { return url .replaceAll(API_KEY_TEMPLATE, encodeURIComponent(apiKey)) .replaceAll("{API_KEY}", encodeURIComponent(apiKey)); @@ -39,7 +39,7 @@ export function withHeliusApiKey(url: string, apiKey?: string): string { return appendQueryParam(url, "api-key", apiKey); } -export function withAlchemyApiKey(url: string, apiKey?: string): string { +function withAlchemyApiKey(url: string, apiKey?: string): string { if (!apiKey) { return url; } @@ -59,7 +59,7 @@ export function withAlchemyApiKey(url: string, apiKey?: string): string { return appendQueryParam(url, "api_key", apiKey); } -export function withOptionalApiKeyTemplate(url: string, apiKey?: string): string { +function withOptionalApiKeyTemplate(url: string, apiKey?: string): string { if (!apiKey) { return url; } @@ -67,10 +67,11 @@ export function withOptionalApiKeyTemplate(url: string, apiKey?: string): string return applyApiKeyTemplate(url, apiKey); } -type ManagedRpcProvider = { +/** One configured provider in this deployment's managed pool. */ +export interface ManagedRpcProvider { id: OrganizationRpcProvider; url: string; -}; +} function buildManagedRpcProviders(env: RpcEnv): ManagedRpcProvider[] { const providers: ManagedRpcProvider[] = []; @@ -133,15 +134,30 @@ function buildManagedRpcProviders(env: RpcEnv): ManagedRpcProvider[] { return providers; } -export function resolveSolanaRpcProviderUrls(env: RpcEnv): string[] { +/** + * The deployment's managed providers, with `SOLANA_RPC_DEFAULT_PROVIDER` first when it names one. + * + * @param env - Process env carrying the managed provider URLs and keys. + * @returns The configured providers in failover order. + */ +export function resolveManagedRpcProviders(env: RpcEnv): ManagedRpcProvider[] { const providers = buildManagedRpcProviders(env); const preferred = env.SOLANA_RPC_DEFAULT_PROVIDER ? providers.find((provider) => provider.id === env.SOLANA_RPC_DEFAULT_PROVIDER) : undefined; - const ordered = preferred + return preferred ? [preferred, ...providers.filter((provider) => provider !== preferred)] : providers; - return [...new Set(ordered.map((provider) => provider.url))]; +} + +/** + * The managed pool's endpoint URLs in failover order, without duplicates. + * + * @param env - Process env carrying the managed provider URLs and keys. + * @returns The distinct provider URLs. + */ +export function resolveSolanaRpcProviderUrls(env: RpcEnv): string[] { + return [...new Set(resolveManagedRpcProviders(env).map((provider) => provider.url))]; } /** The cluster this process's single-cluster configuration (`SOLANA_NETWORK`) serves. */ diff --git a/packages/sdp-rpc/src/errors.ts b/packages/sdp-rpc/src/errors.ts index f0aa93022c..bc2b5c4c16 100644 --- a/packages/sdp-rpc/src/errors.ts +++ b/packages/sdp-rpc/src/errors.ts @@ -1,15 +1,7 @@ -export type SdpRpcErrorCode = - | "BAD_REQUEST" - | "FORBIDDEN" - | "NOT_FOUND" - | "INTERNAL_ERROR" - | "SOLANA_RPC_ERROR"; +export type SdpRpcErrorCode = "BAD_REQUEST" | "SOLANA_RPC_ERROR"; const ERROR_STATUS_CODES: Record = { BAD_REQUEST: 400, - FORBIDDEN: 403, - NOT_FOUND: 404, - INTERNAL_ERROR: 500, SOLANA_RPC_ERROR: 502, }; diff --git a/packages/sdp-rpc/src/index.ts b/packages/sdp-rpc/src/index.ts index 8b781183cc..20a9c8d8c8 100644 --- a/packages/sdp-rpc/src/index.ts +++ b/packages/sdp-rpc/src/index.ts @@ -15,7 +15,7 @@ export { isUnauthorizedRpcError, withTransientRpcRetry, } from "./transient"; -export type { DatabaseClient, KVStore, KVStoreSet, PreparedStatement, RpcEnv } from "./types"; +export type { KVStore, KVStoreSet, RpcEnv } from "./types"; export { type VerifyTransactionLandedOptions, type VerifyTransactionLandedResult, diff --git a/packages/sdp-rpc/src/relay-mask.test.ts b/packages/sdp-rpc/src/relay-mask.test.ts index e78c91c1f9..9194be7ea6 100644 --- a/packages/sdp-rpc/src/relay-mask.test.ts +++ b/packages/sdp-rpc/src/relay-mask.test.ts @@ -1,13 +1,12 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { maskEndpoint } from "./relay"; +import type { RpcEnv } from "./types"; -const env = {} as Parameters[1]; +const env: RpcEnv = {}; describe("maskEndpoint", () => { - it("masks a credential-shaped path segment of a customer endpoint", () => { - // A custom endpoint's key is not among the platform's own keys, so only - // the shape of the segment can catch it. + it("masks a credential-shaped path segment of a managed URL whose key SDP does not hold", () => { assert.equal( maskEndpoint("https://rpc.example.com/AbC123xyz456QwErTy789012", env), "https://rpc.example.com/***" @@ -35,21 +34,3 @@ describe("maskEndpoint", () => { ); }); }); - -it("maskTenantEndpoint masks a credential-shaped path segment the apiKey field does not carry", async () => { - const { maskTenantEndpoint } = await import("./byok"); - const masked = maskTenantEndpoint( - "https://rpc.example.com/v2/AbCdEf1234567890XyZ?cluster=devnet", - "some-other-key-1234567890abcdef" - ); - assert.equal(masked, "https://rpc.example.com/v2/***?cluster=devnet"); -}); - -it("maskTenantEndpoint keeps masking the known key and query heuristics", async () => { - const { maskTenantEndpoint } = await import("./byok"); - const masked = maskTenantEndpoint( - "https://rpc.example.com/rpc?api-key=tenantsecret", - "tenantsecret" - ); - assert.equal(masked, "https://rpc.example.com/rpc?api-key=***"); -}); diff --git a/packages/sdp-rpc/src/relay.ts b/packages/sdp-rpc/src/relay.ts index eff021eb72..c36ec87637 100644 --- a/packages/sdp-rpc/src/relay.ts +++ b/packages/sdp-rpc/src/relay.ts @@ -1,263 +1,22 @@ -import { - normalizeOrganizationTier, - ORGANIZATION_RPC_PROVIDERS, - type OrganizationRpcProvider, - type OrganizationSettings, - PROJECT_RPC_PROVIDERS, - type ProjectRpcProvider, - type ProjectSettings, - type ProviderAvailabilityEntry, - resolveOrganizationProviderEntitlements, -} from "@sdp/types"; -import { - applyApiKeyTemplate, - resolveDefaultCluster, - withAlchemyApiKey, - withHeliusApiKey, - withOptionalApiKeyTemplate, -} from "./config"; +import type { OrganizationRpcProvider } from "@sdp/types"; +import { type ManagedRpcProvider, resolveManagedRpcProviders } from "./config"; import { SdpRpcError } from "./errors"; -import type { DatabaseClient, KVStore, KVStoreSet, RpcEnv } from "./types"; +import type { KVStore, RpcEnv } from "./types"; export { withHeliusApiKey } from "./config"; -export type ManagedRpcProviderId = OrganizationRpcProvider; -export type ResolvedRpcProviderId = ManagedRpcProviderId | "custom"; -export type RpcSelectionMode = - | "project_connection" - | "organization_connection" - | "project_provider" - | "project_custom_provider" - | "organization_provider" - | "round_robin_default"; - -interface ManagedRpcProvider { - id: ManagedRpcProviderId; - url: string; - headers: Record; -} - -interface RpcProviderStatsRecord { - requestsTotal: number; - transactionRequests: number; - errorsTotal: number; - latencyTotalMs: number; - lastRequestAt: string | null; - lastStatusCode: number | null; - lastMethod: string | null; - origins: Record; -} - -export interface RpcProviderStatsSummary { - requestsTotal: number; - transactionRequests: number; - errorsTotal: number; - averageLatencyMs: number; - lastRequestAt: string | null; - lastStatusCode: number | null; - lastMethod: string | null; - origins: Record; -} - -export interface RpcProviderStatus { - id: ManagedRpcProviderId; - endpoint: string; - stats: RpcProviderStatsSummary; -} - -/** - * What a tenant-owned connection resolves to, as seen from the relay. - * - * Deliberately carries no secret: the caller reads the credential through - * CredentialSecretStore, builds the target, and masks the label before handing - * it over, so key material never enters this package or anything it logs. - */ -export type TenantRpcConnectionResolution = - | { kind: "none" } - /** Configured for this scope but not usable -- must not fall back silently. */ - | { kind: "unusable"; reason: string } - | { - kind: "active"; - connectionId: string; - providerId: ManagedRpcProviderId; - endpoint: string; - endpointLabel: string; - headers: Record; - }; - -/** - * Whose credentials an organization's RPC traffic leaves on. - * - * `managed` lets platform providers answer when the organization has no live - * connection of its own. `byok` says it never should. - */ -export type RpcCredentialMode = "managed" | "byok"; - -export interface TenantRpcConnectionLookup { - resolve(input: { - organizationId: string; - scopeKey: string; - network: string; - }): Promise; - /** - * Optional so an injected stub can omit it; absent is read as `managed`, - * which is the behaviour that existed before the mode did. - */ - credentialMode?(organizationId: string): Promise; -} - export interface ResolveRpcTargetInput { env: RpcEnv; - kv: KVStoreSet; - db: DatabaseClient; - organizationId: string; - authProjectId: string | null; - requestedProjectId: string | null; - /** - * Injected rather than imported: CredentialSecretStore lives in the API app, - * and this package may not depend on it. - */ - connections?: TenantRpcConnectionLookup; + cache: KVStore; } export interface ResolvedRpcTarget { - providerId: ResolvedRpcProviderId; - projectId: string | null; + providerId: OrganizationRpcProvider; endpoint: string; endpointLabel: string; - headers: Record; - selectionMode: RpcSelectionMode; - /** Set only for tenant-owned connections; telemetry uses ids, never endpoints. */ - connectionId?: string; -} - -export interface RelayTelemetryInput { - providerId: ResolvedRpcProviderId; - /** Set for tenant-owned targets so their traffic keeps its own bucket. */ - connectionId?: string; - methodNames: string[]; - statusCode: number; - latencyMs: number; - ok: boolean; - origin: string | null; } const ROUND_ROBIN_CURSOR_KEY = "rpc:relay:round-robin-cursor"; -const STATS_KEY_PREFIX = "rpc:relay:stats:"; - -/** - * Where a target's counters live. - * - * A tenant connection resolves to the vendor's own id, so keying on - * `providerId` alone put an organization's BYOK traffic in the same bucket as - * the platform's endpoint for that vendor. The provider list then reported - * requests, errors and latency SDP never served as its own. Tenant traffic is - * keyed by connection instead, which also keeps one organization's volume out - * of another's. - */ -function statsKey(providerId: ResolvedRpcProviderId, connectionId?: string): string { - return connectionId - ? `${STATS_KEY_PREFIX}tenant:${connectionId}` - : `${STATS_KEY_PREFIX}${providerId}`; -} -const MAX_ORIGIN_BUCKETS = 20; -const SEND_TRANSACTION_METHOD = ["send", "Transaction"].join(""); -const SEND_RAW_TRANSACTION_METHOD = ["sendRaw", "Transaction"].join(""); -const TRANSACTION_METHOD_NAMES = new Set([SEND_TRANSACTION_METHOD, SEND_RAW_TRANSACTION_METHOD]); -const MANAGED_RPC_PROVIDER_SET = new Set(ORGANIZATION_RPC_PROVIDERS); -const PROJECT_RPC_PROVIDER_SET = new Set(PROJECT_RPC_PROVIDERS); - -type OrganizationProviderRow = { - tier: string; - settings: unknown | null; -}; - -type ProviderAvailabilityDefinition = { - isConfigured: (env: RpcEnv) => boolean; -}; - -const RPC_PROVIDER_AVAILABILITY_DEFINITIONS = { - default: { - isConfigured: (env) => hasEnv(env, "SOLANA_RPC_URL"), - }, - alchemy: { - isConfigured: (env) => hasEnv(env, "SOLANA_RPC_ALCHEMY_URL"), - }, - helius: { - isConfigured: (env) => hasEnv(env, "SOLANA_RPC_HELIUS_URL"), - }, - nodit: { - isConfigured: (env) => hasEnv(env, "SOLANA_RPC_NODIT_URL"), - }, - quicknode: { - isConfigured: (env) => hasEnv(env, "SOLANA_RPC_QUICKNODE_URL"), - }, - triton: { - isConfigured: (env) => hasEnv(env, "SOLANA_RPC_TRITON_URL"), - }, - validationcloud: { - isConfigured: (env) => hasEnv(env, "SOLANA_RPC_VALIDATIONCLOUD_URL"), - }, -} satisfies Record; - -function parsePostgresJson(value: unknown): T { - return typeof value === "string" ? (JSON.parse(value) as T) : (value as T); -} - -function parseOrganizationSettings(raw: unknown | null): OrganizationSettings | null { - if (!raw) { - return null; - } - - try { - return parsePostgresJson(raw); - } catch { - throw new SdpRpcError("INTERNAL_ERROR", "Organization settings are invalid JSON"); - } -} - -function hasEnv(env: RpcEnv, key: keyof RpcEnv): boolean { - const value = env[key]; - return typeof value === "string" && value.trim().length > 0; -} - -function buildConfiguredRpcProviders(env: RpcEnv): Record { - return Object.fromEntries( - Object.entries(RPC_PROVIDER_AVAILABILITY_DEFINITIONS).map(([providerId, definition]) => [ - providerId, - definition.isConfigured(env), - ]) - ) as Record; -} - -function buildAvailabilityEntries( - entitled: Record, - configured: Record -): Record { - return Object.fromEntries( - Object.keys(entitled).map((key) => { - const isEntitled = entitled[key as T] ?? false; - const isConfigured = configured[key as T] ?? false; - - return [ - key, - { - entitled: isEntitled, - configured: isConfigured, - enabled: isEntitled && isConfigured, - }, - ]; - }) - ) as Record; -} - -function isManagedRpcProviderId(value: string): value is ManagedRpcProviderId { - return MANAGED_RPC_PROVIDER_SET.has(value); -} - -function isProjectRpcProviderId(value: string): value is ProjectRpcProvider { - return PROJECT_RPC_PROVIDER_SET.has(value); -} function collectRpcApiKeys(env: RpcEnv): string[] { const secrets: string[] = []; @@ -284,11 +43,12 @@ function collectRpcApiKeys(env: RpcEnv): string[] { * letters and digits. The digit requirement is what separates keys from real * path vocabulary — network names like `solana-mainnet-beta` clear the length * bar but carry no digit, while Alchemy/QuickNode/Triton-style keys always - * mix both. Applied to every endpoint because a CUSTOMER-supplied custom - * endpoint carries a secret we cannot know by value — the known-key pass - * below only covers the platform's own keys. + * mix both. + * + * @param segment - One URL path segment, possibly percent-encoded. + * @returns Whether the segment looks like an API credential. */ -export function isCredentialPathSegment(segment: string): boolean { +function isCredentialPathSegment(segment: string): boolean { // Percent-encoded segments decode first, so a Base64-style credential // carrying +, / or = (spelled %2B/%2F/%3D in the URL) is classified by its // real content instead of slipping past on the % characters. @@ -303,12 +63,16 @@ export function isCredentialPathSegment(segment: string): boolean { ); } -// Redact provider API keys before an endpoint is exposed to callers. The -// query-param heuristic covers keys passed as query values (Helius) and -// customer-supplied custom endpoints whose secret we don't know. The known-key -// redaction covers path-segment keys (Alchemy, QuickNode, Triton, Validation -// Cloud, Nodit); the path heuristic covers the same shape on endpoints whose -// key the platform does not hold. +/** + * Redact provider API keys before an endpoint is exposed to callers. The + * known-key pass covers path-segment keys (Alchemy, QuickNode, Triton, + * Validation Cloud, Nodit); the shape pass covers query-value keys (Helius) + * and any key embedded in a URL template the platform does not hold by value. + * + * @param url - The provider endpoint, keys included. + * @param env - Process env holding the managed providers' keys. + * @returns The endpoint with every key replaced by `***`. + */ export function maskEndpoint(url: string, env: RpcEnv): string { let masked = url; for (const secret of collectRpcApiKeys(env)) { @@ -324,11 +88,12 @@ export function maskEndpoint(url: string, env: RpcEnv): string { /** * The value-blind half of endpoint masking: credential-looking query values - * (`key`/`token` names) and credential-shaped path segments. Shared by the - * platform masker above and the tenant masker in `byok.ts`, so the two cannot - * drift on what counts as a credential shape. + * (`key`/`token` names) and credential-shaped path segments. + * + * @param url - The endpoint to mask. + * @returns The endpoint with credential-shaped parts replaced by `***`, or the input unchanged when it is not a URL. */ -export function maskCredentialShapes(url: string): string { +function maskCredentialShapes(url: string): string { try { const parsed = new URL(url); for (const key of parsed.searchParams.keys()) { @@ -346,602 +111,75 @@ export function maskCredentialShapes(url: string): string { } } -function normalizeOrigin(value: string | null): string | null { - if (!value) { - return null; - } - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - - try { - const parsed = new URL(trimmed); - return parsed.origin.slice(0, 200); - } catch { - return trimmed.slice(0, 200); - } -} - -function emptyStats(): RpcProviderStatsRecord { - return { - requestsTotal: 0, - transactionRequests: 0, - errorsTotal: 0, - latencyTotalMs: 0, - lastRequestAt: null, - lastStatusCode: null, - lastMethod: null, - origins: {}, - }; -} - -function toStatsSummary(record: RpcProviderStatsRecord): RpcProviderStatsSummary { - return { - requestsTotal: record.requestsTotal, - transactionRequests: record.transactionRequests, - errorsTotal: record.errorsTotal, - averageLatencyMs: - record.requestsTotal > 0 ? Math.round(record.latencyTotalMs / record.requestsTotal) : 0, - lastRequestAt: record.lastRequestAt, - lastStatusCode: record.lastStatusCode, - lastMethod: record.lastMethod, - origins: record.origins, - }; -} - -function resolveManagedProviders(env: RpcEnv): ManagedRpcProvider[] { - const providers: ManagedRpcProvider[] = []; - - if (env.SOLANA_RPC_TRITON_URL) { - const headers: Record = {}; - if (env.SOLANA_RPC_TRITON_API_KEY) { - headers["x-api-key"] = env.SOLANA_RPC_TRITON_API_KEY; - } - providers.push({ - id: "triton", - url: applyApiKeyTemplate(env.SOLANA_RPC_TRITON_URL, env.SOLANA_RPC_TRITON_API_KEY ?? ""), - headers, - }); - } - - if (env.SOLANA_RPC_HELIUS_URL) { - providers.push({ - id: "helius", - url: withHeliusApiKey(env.SOLANA_RPC_HELIUS_URL, env.SOLANA_RPC_HELIUS_API_KEY), - headers: {}, - }); - } - - if (env.SOLANA_RPC_ALCHEMY_URL) { - providers.push({ - id: "alchemy", - url: withAlchemyApiKey(env.SOLANA_RPC_ALCHEMY_URL, env.SOLANA_RPC_ALCHEMY_API_KEY), - headers: {}, - }); - } - - if (env.SOLANA_RPC_QUICKNODE_URL) { - providers.push({ - id: "quicknode", - url: withOptionalApiKeyTemplate( - env.SOLANA_RPC_QUICKNODE_URL, - env.SOLANA_RPC_QUICKNODE_API_KEY - ), - headers: {}, - }); - } - - if (env.SOLANA_RPC_VALIDATIONCLOUD_URL) { - providers.push({ - id: "validationcloud", - url: applyApiKeyTemplate( - env.SOLANA_RPC_VALIDATIONCLOUD_URL, - env.SOLANA_RPC_VALIDATIONCLOUD_API_KEY ?? "" - ), - headers: {}, - }); - } - - if (env.SOLANA_RPC_NODIT_URL) { - providers.push({ - id: "nodit", - url: withOptionalApiKeyTemplate(env.SOLANA_RPC_NODIT_URL, env.SOLANA_RPC_NODIT_API_KEY), - headers: {}, - }); - } - - if (env.SOLANA_RPC_URL) { - providers.push({ - id: "default", - url: env.SOLANA_RPC_URL, - headers: {}, - }); - } - - const preferredDefault = env.SOLANA_RPC_DEFAULT_PROVIDER; - if (preferredDefault && isManagedRpcProviderId(preferredDefault)) { - const preferred = providers.find((provider) => provider.id === preferredDefault); - if (preferred) { - return [preferred, ...providers.filter((provider) => provider.id !== preferredDefault)]; - } - } - - return providers; -} - -async function getOrganizationSettings( - db: DatabaseClient, - organizationId: string -): Promise { - const row = await db - .prepare( - `SELECT settings - FROM organizations - WHERE id = ?` - ) - .bind(organizationId) - .first<{ settings: string | null }>(); - - if (!row) { - throw new SdpRpcError("NOT_FOUND", "Organization not found"); - } - - if (!row.settings) { - return null; - } - - return parseOrganizationSettings(row.settings); -} - -async function getRpcProviderAvailability( - env: RpcEnv, - db: DatabaseClient, - organizationId: string -): Promise> { - const row = await db - .prepare( - `SELECT tier, settings - FROM organizations - WHERE id = ?` - ) - .bind(organizationId) - .first(); - - if (!row) { - throw new SdpRpcError("NOT_FOUND", "Organization not found"); - } - - const settings = parseOrganizationSettings(row.settings); - const tier = normalizeOrganizationTier(row.tier); - const resolved = resolveOrganizationProviderEntitlements({ - tier, - providerOverrides: settings?.providerOverrides, - }); - const configured = buildConfiguredRpcProviders(env); - - return buildAvailabilityEntries(resolved.providers.rpc, configured); -} - -async function getProjectSettings( - db: DatabaseClient, - organizationId: string, - projectId: string -): Promise { - const row = await db - .prepare( - `SELECT settings - FROM projects - WHERE id = ? - AND organization_id = ? - AND status = 'active'` - ) - .bind(projectId, organizationId) - .first<{ settings: string | null }>(); - - if (!row) { - throw new SdpRpcError("NOT_FOUND", "Project not found"); - } - - if (!row.settings) { - return null; - } - - try { - return parsePostgresJson(row.settings); - } catch { - throw new SdpRpcError("INTERNAL_ERROR", "Project settings are invalid JSON"); - } -} - -function resolveProjectRpcPreference( - projectSettings: ProjectSettings | null -): - | { providerType: "default" } - | { providerType: "managed"; providerId: ManagedRpcProviderId } - | { providerType: "custom"; endpoint: string } { - const explicitProvider = projectSettings?.rpcProvider; - if (explicitProvider && !isProjectRpcProviderId(explicitProvider)) { - throw new SdpRpcError( - "INTERNAL_ERROR", - `Project RPC provider '${explicitProvider}' is invalid` - ); - } - - const provider = explicitProvider ?? (projectSettings?.rpcEndpoint ? "custom" : "default"); - if (provider === "default") { - return { providerType: "default" }; - } - - if (provider === "custom") { - const endpoint = projectSettings?.rpcEndpoint?.trim(); - if (!endpoint) { - throw new SdpRpcError( - "BAD_REQUEST", - "Project RPC provider is 'custom' but rpcEndpoint is not configured" - ); - } - return { providerType: "custom", endpoint }; - } - - return { providerType: "managed", providerId: provider }; -} - -async function pickRoundRobinProvider( +/** + * Advance the shared round-robin cursor and return the providers starting at the selected one. + * + * @param cache - KV store holding the cursor. + * @param providers - The configured managed providers. + * @returns Every provider, rotated so the selected one is first. + */ +async function rotateProviders( cache: KVStore, providers: ManagedRpcProvider[] -): Promise { +): Promise { if (providers.length === 0) { throw new SdpRpcError("SOLANA_RPC_ERROR", "No managed Solana RPC providers are configured"); } if (providers.length === 1) { - return providers[0]; + return providers; } const rawCursor = await cache.get(ROUND_ROBIN_CURSOR_KEY); const parsedCursor = rawCursor ? Number.parseInt(rawCursor, 10) : 0; const cursor = Number.isFinite(parsedCursor) && parsedCursor >= 0 ? parsedCursor : 0; const index = cursor % providers.length; - const nextCursor = (index + 1) % providers.length; - - await cache.put(ROUND_ROBIN_CURSOR_KEY, String(nextCursor)); - return providers[index]; -} - -async function pickRoundRobinProviderOrder( - cache: KVStore, - providers: ManagedRpcProvider[] -): Promise { - const selectedProvider = await pickRoundRobinProvider(cache, providers); - const selectedIndex = providers.findIndex((provider) => provider.id === selectedProvider.id); - if (selectedIndex <= 0) { - return providers; - } - - return [...providers.slice(selectedIndex), ...providers.slice(0, selectedIndex)]; -} - -function validateRequestedProjectScope( - authProjectId: string | null, - requestedProjectId: string | null -) { - if (authProjectId && requestedProjectId && requestedProjectId !== authProjectId) { - throw new SdpRpcError( - "FORBIDDEN", - "Project-scoped API keys cannot relay requests for another project" - ); - } -} - -function getEffectiveProjectId( - authProjectId: string | null, - requestedProjectId: string | null -): string | null { - validateRequestedProjectScope(authProjectId, requestedProjectId); - return requestedProjectId ?? authProjectId; -} -function isTransactionMethod(methodName: string): boolean { - return TRANSACTION_METHOD_NAMES.has(methodName); + await cache.put(ROUND_ROBIN_CURSOR_KEY, String((index + 1) % providers.length)); + return [...providers.slice(index), ...providers.slice(0, index)]; } -export function includesTransactionMethod(methodNames: string[]): boolean { - return methodNames.some((methodName) => isTransactionMethod(methodName)); -} - -const ORGANIZATION_SCOPE_KEY = "__organization__"; - /** - * Tenant connections outrank every platform-managed selection (HOO-1093). + * The relay target for one managed provider, with a masked label for responses. * - * A project that holds a connection which is not live fails closed rather than - * falling through: an organization that has said "use my key" must never have - * its traffic quietly moved onto credentials SDP pays for. - * - * Only the project scope resolves (HOO-1226). Organization-scoped connections - * are no longer a rail, but they are still checked, because ignoring one is - * the silent downgrade this whole path exists to prevent. + * @param provider - A managed provider. + * @param env - Process env, used to mask the provider's keys out of the label. + * @returns The relay target for that provider. */ -async function resolveTenantConnection( - input: ResolveRpcTargetInput, - projectId: string | null -): Promise { - if (!input.connections) { - return null; - } - - const network = resolveDefaultCluster(input.env); - - if (projectId) { - const resolution = await input.connections.resolve({ - organizationId: input.organizationId, - scopeKey: projectId, - network, - }); - - if (resolution.kind === "unusable") { - throw new SdpRpcError( - "SOLANA_RPC_ERROR", - `The RPC connection for this project is not active (${resolution.reason})` - ); - } - - if (resolution.kind === "active") { - return { - providerId: resolution.providerId, - projectId, - endpoint: resolution.endpoint, - endpointLabel: resolution.endpointLabel, - headers: resolution.headers, - selectionMode: "project_connection", - connectionId: resolution.connectionId, - }; - } - } - - await assertNoStrandedOrganizationConnection(input, network); - - // Nothing of the tenant's own resolved. Whether that may fall through to a - // platform provider is the organization's call, not ours. - const mode = (await input.connections.credentialMode?.(input.organizationId)) ?? "managed"; - if (mode === "byok") { - throw new SdpRpcError( - "SOLANA_RPC_ERROR", - "This organization runs RPC on its own credentials and this project has no live connection. Add one, or switch the organization back to SDP-managed RPC." - ); - } - - return null; +function toRelayTarget(provider: ManagedRpcProvider, env: RpcEnv): ResolvedRpcTarget { + return { + providerId: provider.id, + endpoint: provider.url, + endpointLabel: maskEndpoint(provider.url, env), + }; } /** - * Connections were organization-scoped until HOO-1226, and the dashboard only - * ever created them that way, so every connection made before the cutover sits - * on a scope the relay no longer reads. + * The managed provider the next relay request goes to, chosen round-robin across the pool. * - * Falling through to a platform provider here would answer the request on SDP's - * keys and say nothing, which is precisely the outcome the tenant paid their - * own provider to avoid. Refusing is the loud version of the same failure: the - * connection is visible in the dashboard, and recreating it on a project fixes - * it. + * @param input - Process env and the KV store holding the round-robin cursor. + * @param input.env - Process env carrying the managed provider URLs and keys. + * @param input.cache - KV store holding the round-robin cursor. + * @returns The selected relay target. + * @throws SdpRpcError `SOLANA_RPC_ERROR` when no managed provider is configured. */ -async function assertNoStrandedOrganizationConnection( - input: ResolveRpcTargetInput, - network: string -): Promise { - const resolution = await input.connections?.resolve({ - organizationId: input.organizationId, - scopeKey: ORGANIZATION_SCOPE_KEY, - network, - }); - - // `none` is the ordinary case: no organization connection was ever made. - if (!resolution || resolution.kind === "none") { - return; - } - - throw new SdpRpcError( - "SOLANA_RPC_ERROR", - "This organization has an RPC connection that is no longer used. Recreate it on a project to route through your own provider." - ); -} - export async function resolveRpcTarget(input: ResolveRpcTargetInput): Promise { - // Precedence 1: an explicit tenant connection on the project, ahead of - // anything platform-managed. - const tenantTarget = await resolveTenantConnection( - input, - getEffectiveProjectId(input.authProjectId, input.requestedProjectId) - ); - if (tenantTarget) { - return tenantTarget; - } - - const managedProviders = resolveManagedProviders(input.env); - const access = await getRpcProviderAvailability(input.env, input.db, input.organizationId); - const enabledManagedProviders = managedProviders.filter( - (provider) => access[provider.id]?.enabled - ); - const projectId = getEffectiveProjectId(input.authProjectId, input.requestedProjectId); - - if (projectId) { - const projectSettings = await getProjectSettings(input.db, input.organizationId, projectId); - const projectPreference = resolveProjectRpcPreference(projectSettings); - - if (projectPreference.providerType === "custom") { - return { - providerId: "custom", - projectId, - endpoint: projectPreference.endpoint, - endpointLabel: maskEndpoint(projectPreference.endpoint, input.env), - headers: {}, - selectionMode: "project_custom_provider", - }; - } - - if (projectPreference.providerType === "managed") { - const selectedProvider = enabledManagedProviders.find( - (provider) => provider.id === projectPreference.providerId - ); - - if (selectedProvider) { - return { - providerId: selectedProvider.id, - projectId, - endpoint: selectedProvider.url, - endpointLabel: maskEndpoint(selectedProvider.url, input.env), - headers: selectedProvider.headers, - selectionMode: "project_provider", - }; - } - } - } - - const organizationSettings = await getOrganizationSettings(input.db, input.organizationId); - const preferredProvider = organizationSettings?.rpcProvider; - - if (preferredProvider && preferredProvider !== "default") { - const selectedProvider = enabledManagedProviders.find( - (provider) => provider.id === preferredProvider - ); - - if (selectedProvider) { - return { - providerId: selectedProvider.id, - projectId, - endpoint: selectedProvider.url, - endpointLabel: maskEndpoint(selectedProvider.url, input.env), - headers: selectedProvider.headers, - selectionMode: "organization_provider", - }; - } - } - - const selectedProvider = await pickRoundRobinProvider(input.kv.cache, enabledManagedProviders); - return { - providerId: selectedProvider.id, - projectId, - endpoint: selectedProvider.url, - endpointLabel: maskEndpoint(selectedProvider.url, input.env), - headers: selectedProvider.headers, - selectionMode: "round_robin_default", - }; + const [selected] = await rotateProviders(input.cache, resolveManagedRpcProviders(input.env)); + return toRelayTarget(selected, input.env); } +/** + * Every managed provider in round-robin order, for callers that try each in turn. + * + * @param input - Process env and the KV store holding the round-robin cursor. + * @param input.env - Process env carrying the managed provider URLs and keys. + * @param input.cache - KV store holding the round-robin cursor. + * @returns The relay targets, starting at the selected provider. + * @throws SdpRpcError `SOLANA_RPC_ERROR` when no managed provider is configured. + */ export async function resolveRoundRobinRpcTargets( input: ResolveRpcTargetInput ): Promise { - // The faucet path resolves tenant connections on the same terms as the - // ordinary relay. Without this an organization that said "use my key" would - // still have airdrop requests served by platform credentials, and a - // connection that should fail closed would be bypassed rather than honoured. - const tenantTarget = await resolveTenantConnection( - input, - getEffectiveProjectId(input.authProjectId, input.requestedProjectId) - ); - if (tenantTarget) { - // One connection, so there is nothing to rotate between. - return [tenantTarget]; - } - - const managedProviders = resolveManagedProviders(input.env); - const access = await getRpcProviderAvailability(input.env, input.db, input.organizationId); - const enabledManagedProviders = managedProviders.filter( - (provider) => access[provider.id]?.enabled - ); - const projectId = getEffectiveProjectId(input.authProjectId, input.requestedProjectId); - const orderedProviders = await pickRoundRobinProviderOrder( - input.kv.cache, - enabledManagedProviders - ); - - return orderedProviders.map((provider) => ({ - providerId: provider.id, - projectId, - endpoint: provider.url, - endpointLabel: maskEndpoint(provider.url, input.env), - headers: provider.headers, - selectionMode: "round_robin_default", - })); -} - -export async function recordRpcRelayTelemetry(cache: KVStore, telemetry: RelayTelemetryInput) { - const key = statsKey(telemetry.providerId, telemetry.connectionId); - const existing = (await cache.get(key, "json")) as Partial | null; - const stats: RpcProviderStatsRecord = { - ...emptyStats(), - ...existing, - origins: existing?.origins ?? {}, - }; - - stats.requestsTotal += 1; - stats.latencyTotalMs += Math.max(0, Math.round(telemetry.latencyMs)); - if (!telemetry.ok) { - stats.errorsTotal += 1; - } - if (includesTransactionMethod(telemetry.methodNames)) { - stats.transactionRequests += 1; - } - - stats.lastRequestAt = new Date().toISOString(); - stats.lastStatusCode = telemetry.statusCode; - stats.lastMethod = telemetry.methodNames[0] ?? null; - - const origin = normalizeOrigin(telemetry.origin); - if (origin) { - const nextOrigins = { - ...stats.origins, - [origin]: (stats.origins[origin] ?? 0) + 1, - }; - const entries = Object.entries(nextOrigins).sort((a, b) => b[1] - a[1]); - stats.origins = Object.fromEntries(entries.slice(0, MAX_ORIGIN_BUCKETS)); - } - - await cache.put(key, JSON.stringify(stats)); -} - -async function getProviderStats( - cache: KVStore, - providerId: ResolvedRpcProviderId, - connectionId?: string -): Promise { - const key = statsKey(providerId, connectionId); - const existing = (await cache.get(key, "json")) as RpcProviderStatsRecord | null; - return toStatsSummary(existing ?? emptyStats()); -} - -export async function listRpcProviders(input: ResolveRpcTargetInput) { - const managedProviders = resolveManagedProviders(input.env); - const access = await getRpcProviderAvailability(input.env, input.db, input.organizationId); - const enabledManagedProviders = managedProviders.filter( - (provider) => access[provider.id]?.enabled - ); - const providerStatuses: RpcProviderStatus[] = []; - - for (const provider of enabledManagedProviders) { - providerStatuses.push({ - id: provider.id, - endpoint: maskEndpoint(provider.url, input.env), - stats: await getProviderStats(input.kv.cache, provider.id), - }); - } - - const resolvedTarget = await resolveRpcTarget(input); - - return { - providers: providerStatuses, - selected: { - providerId: resolvedTarget.providerId, - projectId: resolvedTarget.projectId, - selectionMode: resolvedTarget.selectionMode, - endpoint: resolvedTarget.endpointLabel, - stats: await getProviderStats( - input.kv.cache, - resolvedTarget.providerId, - resolvedTarget.connectionId - ), - }, - roundRobinOrder: enabledManagedProviders.map((provider) => provider.id), - }; + const ordered = await rotateProviders(input.cache, resolveManagedRpcProviders(input.env)); + return ordered.map((provider) => toRelayTarget(provider, input.env)); } diff --git a/packages/sdp-rpc/src/types.ts b/packages/sdp-rpc/src/types.ts index 249d945253..55328ec4da 100644 --- a/packages/sdp-rpc/src/types.ts +++ b/packages/sdp-rpc/src/types.ts @@ -22,17 +22,6 @@ export interface RpcEnv { SDP_DEPLOYMENT_MODE?: string; } -export interface PreparedStatement { - bind(...values: unknown[]): PreparedStatement; - first>(columnName?: string): Promise; - all>(): Promise<{ results: T[]; rows: T[] }>; - run(): Promise; -} - -export interface DatabaseClient { - prepare(query: string): PreparedStatement; -} - export interface KVPutOptions { expirationTtl?: number; } diff --git a/packages/sdp-types/src/index.ts b/packages/sdp-types/src/index.ts index ad4039de41..fd1f6c341a 100644 --- a/packages/sdp-types/src/index.ts +++ b/packages/sdp-types/src/index.ts @@ -27,7 +27,6 @@ export * from "./policy"; export * from "./private-channels"; export * from "./projects"; export * from "./provider-access"; -export * from "./rpc-connections"; export * from "./site"; export * from "./tokens"; export * from "./unified-transactions"; diff --git a/packages/sdp-types/src/organizations.ts b/packages/sdp-types/src/organizations.ts index 2cea689e10..4322917781 100644 --- a/packages/sdp-types/src/organizations.ts +++ b/packages/sdp-types/src/organizations.ts @@ -37,7 +37,6 @@ export interface Organization { } export interface OrganizationSettings { - rpcProvider?: OrganizationRpcProvider; defaultEnvironment?: "sandbox" | "production"; webhookSecret?: string; allowedIpAddresses?: string[]; diff --git a/packages/sdp-types/src/projects.ts b/packages/sdp-types/src/projects.ts index 7ce8cf51ee..c19ecf910f 100644 --- a/packages/sdp-types/src/projects.ts +++ b/packages/sdp-types/src/projects.ts @@ -4,22 +4,16 @@ * Projects group API keys by team or environment within an organization. */ -import { ORGANIZATION_RPC_PROVIDERS } from "./organizations"; import type { ProjectRole } from "./permissions"; export type ProjectEnvironment = "sandbox" | "production"; export type ProjectStatus = "active" | "archived"; -export const PROJECT_RPC_PROVIDERS = [...ORGANIZATION_RPC_PROVIDERS, "custom"] as const; -export type ProjectRpcProvider = (typeof PROJECT_RPC_PROVIDERS)[number]; - // Re-export ProjectRole for convenience export type { ProjectRole } from "./permissions"; export interface ProjectSettings { - rpcProvider?: ProjectRpcProvider; - rpcEndpoint?: string; webhookUrl?: string; metadata?: Record; } diff --git a/packages/sdp-types/src/provider-access.ts b/packages/sdp-types/src/provider-access.ts index fdd7b4ac81..59ac08feb7 100644 --- a/packages/sdp-types/src/provider-access.ts +++ b/packages/sdp-types/src/provider-access.ts @@ -5,12 +5,7 @@ import { HASTRA_DEPLOYMENTS } from "./hastra-programs"; import { JUPITER_LEND_EARN_PROGRAM_IDS } from "./jupiter-lend-programs"; import { KAMINO_KVAULT_DEPOSIT_FLOOR_SUPPORT, KAMINO_KVAULT_PROGRAM_IDS } from "./kamino-programs"; import { ONDO_DEPLOYMENTS } from "./ondo-programs"; -import { - normalizeOrganizationTier, - ORGANIZATION_RPC_PROVIDERS, - type OrganizationRpcProvider, - type OrganizationTier, -} from "./organizations"; +import { normalizeOrganizationTier, type OrganizationTier } from "./organizations"; import { VEDA_DEPLOYMENTS } from "./veda-programs"; import { CLUSTER_BY_SDP_ENVIRONMENT, @@ -564,18 +559,11 @@ export function surfacedRampProviders(environment: SdpEnvironment): RampProvider return RAMP_PROVIDERS.filter((provider) => isRampProviderSurfaced(provider, environment)); } -export const ORGANIZATION_PROVIDER_FAMILIES = [ - "custody", - "rpc", - "compliance", - "ramps", - "earn", -] as const; +export const ORGANIZATION_PROVIDER_FAMILIES = ["custody", "compliance", "ramps", "earn"] as const; export type OrganizationProviderFamily = (typeof ORGANIZATION_PROVIDER_FAMILIES)[number]; export interface OrganizationProviderOverrides { custody?: Partial>; - rpc?: Partial>; compliance?: Partial>; ramps?: Partial>; earn?: Partial>; @@ -589,7 +577,6 @@ export interface ProviderAvailabilityEntry { export interface OrganizationProviderAvailability { custody: Record; - rpc: Record; compliance: Record; ramps: Record; earn: Record; @@ -597,7 +584,6 @@ export interface OrganizationProviderAvailability { export interface OrganizationProviderEntitlements { custody: Record; - rpc: Record; compliance: Record; ramps: Record; earn: Record; @@ -643,7 +629,6 @@ function applyOverrides( export const GENERAL_PROVIDER_DEFAULTS: OrganizationProviderEntitlements = { custody: createBooleanRecord(CUSTODY_PROVIDERS, ["privy", "coinbase_cdp", "para", "turnkey"]), - rpc: createBooleanRecord(ORGANIZATION_RPC_PROVIDERS, ORGANIZATION_RPC_PROVIDERS), compliance: createBooleanRecord(COMPLIANCE_PROVIDERS, []), ramps: createBooleanRecord(RAMP_PROVIDERS, RAMP_PROVIDERS), earn: createBooleanRecord(EARN_PROVIDERS, []), @@ -663,7 +648,6 @@ export function resolveOrganizationProviderEntitlements(input: { tier, providers: { custody: applyOverrides(defaults.custody, input.providerOverrides?.custody), - rpc: applyOverrides(defaults.rpc, input.providerOverrides?.rpc), compliance: applyOverrides(defaults.compliance, input.providerOverrides?.compliance), ramps: applyOverrides(defaults.ramps, input.providerOverrides?.ramps), earn: applyOverrides(defaults.earn, input.providerOverrides?.earn), diff --git a/packages/sdp-types/src/rpc-connections.ts b/packages/sdp-types/src/rpc-connections.ts deleted file mode 100644 index 54b069411f..0000000000 --- a/packages/sdp-types/src/rpc-connections.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { OrganizationRpcProvider } from "./organizations"; - -/** - * Lifecycle an RPC Connection can be in. Same vocabulary as custody - * Connections on purpose — one setup model across provider families, so a - * dashboard that can render one can render the other. - */ -export const RPC_CONNECTION_LIFECYCLES = [ - "pending", - "checking", - "active", - "failed", - "deactivated", -] as const; -export type RpcConnectionLifecycle = (typeof RPC_CONNECTION_LIFECYCLES)[number]; - -export const RPC_CONNECTION_SCOPES = ["organization", "project"] as const; -export type RpcConnectionScope = (typeof RPC_CONNECTION_SCOPES)[number]; - -/** - * A credential is only ever good for one cluster, so a Connection is bound to - * one. Matches `RpcEnv["SOLANA_NETWORK"]`. - */ -export const RPC_CONNECTION_NETWORKS = ["devnet", "mainnet-beta"] as const; -export type RpcConnectionNetwork = (typeof RPC_CONNECTION_NETWORKS)[number]; - -/** - * The outcome of a connectivity probe, returned to the caller and never - * stored (HOO-1228). A connection is checked when it is saved and again on - * demand, so a persisted copy could only go stale. - */ -export interface RpcConnectionTestResult { - ok: boolean; - /** Redacted code only — never an upstream provider response. */ - failureCode: string | null; -} - -/** - * Everything an RPC Connection may leave the API as. Deliberately has no field - * for a secret ref, secret version, or decrypted payload: the type is the - * boundary, so a future field cannot leak one by being forgotten in a mapper. - */ -export interface SafeRpcConnection { - id: string; - provider: OrganizationRpcProvider; - scope: RpcConnectionScope; - projectId: string | null; - network: RpcConnectionNetwork; - status: RpcConnectionLifecycle; - /** The connection the relay picks for this scope and network. */ - isDefault: boolean; - displayMetadata: Record; - createdAt: string; - activatedAt: string | null; - deactivatedAt: string | null; - providerCredential: { - id: string; - label: string; - status: string; - }; -} - -export interface RpcConnectionListResponse { - connections: SafeRpcConnection[]; - pagination: { - limit: number; - offset: number; - total: number; - }; -} - -/** - * Providers whose RPC host is the same for every account, so a tenant never - * has to type an endpoint. Only QuickNode and Triton are left out, and not for - * want of checking: both issue an account-specific subdomain, so there is no - * base host to publish and the endpoint has to come from the tenant. - * - * `@sdp/rpc/byok` holds the actual URLs and the per-provider rule for where the - * key goes; the dashboard only needs to know whether to ask for an endpoint. - */ -export const RPC_PROVIDERS_WITH_DEFAULT_ENDPOINT = [ - "helius", - "alchemy", - "validationcloud", - "nodit", -] as const; - -export function rpcProviderNeedsEndpoint(provider: string): boolean { - return !(RPC_PROVIDERS_WITH_DEFAULT_ENDPOINT as readonly string[]).includes(provider); -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59648d55a9..8f491fb27f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1009,9 +1009,6 @@ importers: '@sdp/types': specifier: workspace:* version: link:../sdp-types - '@solana/addresses': - specifier: 'catalog:' - version: 7.1.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3) '@solana/kit': specifier: 'catalog:' version: 7.1.1(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@6.0.3)(utf-8-validate@6.0.6)