diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index c06c2285..a2b568a4 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -24,6 +24,13 @@ export const env = createEnv({ XPOSTBOUNTY1_RSS_API_SECRET: z.string().optional(), AFROBEATS_RSS_API_SECRET: z.string().optional(), AFRICA_RSS_API_SECRET: z.string().optional(), + + SECRET_SERVICE_URL: z + .string() + .url({ message: "SECRET_SERVICE_URL must be a valid URL" }), + SECRET_SERVICE_INTERNAL_API_KEY: z + .string() + .min(1, { message: "SECRET_SERVICE_INTERNAL_API_KEY must be defined" }), }, runtimeEnv: process.env, diff --git a/apps/api/src/services/distribution.service.ts b/apps/api/src/services/distribution.service.ts index 2c0c01fb..3549ec82 100644 --- a/apps/api/src/services/distribution.service.ts +++ b/apps/api/src/services/distribution.service.ts @@ -2,9 +2,9 @@ import { DistributorConfig, RichSubmission } from "@curatedotfun/shared-db"; import type { ActionArgs } from "@curatedotfun/types"; import { PluginError, PluginErrorCode } from "@curatedotfun/utils"; import type { Logger } from "pino"; -import { isStaging } from "./config.service"; import { logPluginError } from "../utils/error"; import { sanitizeJson } from "../utils/sanitize"; +import { isStaging } from "./config.service"; import type { IBaseService } from "./interfaces/base-service.interface"; import { PluginService } from "./plugin.service"; @@ -21,6 +21,7 @@ export class DistributionService implements IBaseService { async distributeContent( distributor: DistributorConfig, input: T, + feedId: string, ): Promise { const sanitizedInput = sanitizeJson(input) as T; @@ -32,6 +33,7 @@ export class DistributionService implements IBaseService { type: "distributor", config: pluginConfig || {}, }, + feedId, ); try { diff --git a/apps/api/src/services/feed.service.ts b/apps/api/src/services/feed.service.ts index 59c3dd30..bf0b27c3 100644 --- a/apps/api/src/services/feed.service.ts +++ b/apps/api/src/services/feed.service.ts @@ -68,10 +68,10 @@ export class FeedService implements IBaseService { { feedId }, "FeedService: processFeed - Feed not found", ); - throw new Error(`Feed not found: ${feedId}`); // Or a custom NotFoundError + throw new Error(`Feed not found: ${feedId}`); } - const feedConfig = await this.feedRepository.getFeedConfig(feedId); // Get config from DB + const feedConfig = await this.feedRepository.getFeedConfig(feedId); if (!feedConfig) { this.logger.error( { feedId }, @@ -154,14 +154,13 @@ export class FeedService implements IBaseService { } } - await this.processorService.process(submission, streamConfig); + await this.processorService.process(submission, streamConfig, feedId); processedCount++; } catch (error) { this.logger.error( { error, submissionId: submission.tweetId, feedId }, `Error processing submission ${submission.tweetId} for feed ${feedId}`, ); - // Decide if one error should stop all processing or just skip this one } } diff --git a/apps/api/src/services/plugin.service.ts b/apps/api/src/services/plugin.service.ts index 212a4654..dbacbf78 100644 --- a/apps/api/src/services/plugin.service.ts +++ b/apps/api/src/services/plugin.service.ts @@ -12,15 +12,14 @@ import { PluginError, PluginErrorCode } from "@curatedotfun/utils"; import { performReload } from "@module-federation/node/utils"; import { init, loadRemote } from "@module-federation/runtime"; import type { Logger } from "pino"; -import Mustache from "mustache"; import { PluginConfig } from "types/config"; -import { env } from "../env"; import { db } from "../db"; import { logPluginError } from "../utils/error"; import { logger } from "../utils/logger"; import { createPluginInstanceKey } from "../utils/plugin"; import { isProduction } from "./config.service"; import { IBaseService } from "./interfaces/base-service.interface"; +import { SecretServiceApiClient } from "./secret-service-client"; /** * Cache entry for a loaded plugin @@ -64,8 +63,8 @@ type PluginContainer< TConfig extends Record = Record, > = | { - default?: new () => PluginTypeMap[T]; - } + default?: new () => PluginTypeMap[T]; + } | (new () => PluginTypeMap[T]); /** @@ -76,6 +75,7 @@ export class PluginService implements IBaseService { private remotes: Map = new Map(); private instances: Map> = new Map(); private pluginRepository: PluginRepository; + private secretServiceApiClient: SecretServiceApiClient; // Time in milliseconds before cached items are considered stale private readonly instanceCacheTimeout: number = 7 * 24 * 60 * 60 * 1000; // 7 days (instance of a plugin with config) @@ -86,9 +86,10 @@ export class PluginService implements IBaseService { private readonly retryDelays: number[] = [1000, 5000]; // Delays between retries in ms public readonly logger: Logger; - constructor(logger: Logger) { + constructor(logger: Logger, secretServiceApiClient: SecretServiceApiClient) { this.logger = logger; this.pluginRepository = new PluginRepository(db); + this.secretServiceApiClient = secretServiceApiClient; } /** @@ -102,9 +103,9 @@ export class PluginService implements IBaseService { >( name: string, pluginConfig: { type: T; config: TConfig }, + feedId: string, ): Promise[T]> { try { - // Get plugin metadata from database const registeredPlugin = await this.pluginRepository.getPluginByName(name); @@ -144,7 +145,6 @@ export class PluginService implements IBaseService { throw error; } - // Create full config with URL from database const config: PluginConfig = { type: pluginConfig.type, url: registeredPlugin.entryPoint, @@ -190,7 +190,6 @@ export class PluginService implements IBaseService { this.remotes.set(normalizedName, remote); } - // Create and initialize instance with retries let lastError: Error | null = null; for (let attempt = 0; attempt <= this.retryDelays.length; attempt++) { try { @@ -215,24 +214,62 @@ export class PluginService implements IBaseService { TConfig >[T]; - // Hydrate config with environment variables - const stringifiedConfig = JSON.stringify(config.config); - const populatedConfigString = Mustache.render(stringifiedConfig, env); // TODO: Whitelist values - const hydratedConfig = JSON.parse(populatedConfigString) as TConfig; + this.logger.debug( + `PluginService: Preparing to hydrate config for plugin '${name}', feedId '${feedId}'.`, + ); + + let processedConfig: TConfig = JSON.parse( + JSON.stringify(config.config), + ); + + // --- Secret Hydration Logic --- + for (const key in processedConfig) { + if (Object.prototype.hasOwnProperty.call(processedConfig, key)) { + const value = processedConfig[key]; + + if ( + typeof value === "string" && + value.startsWith("{{") && + value.endsWith("}}") + ) { + const varName = value.substring(2, value.length - 2).trim(); // e.g., "OPENROUTER_API_KEY" + + this.logger.debug( + `PluginService: Found placeholder '${value}' for key '${key}' in plugin '${name}', feedId '${feedId}'. Attempting to resolve.`, + ); + + const secret = + await this.secretServiceApiClient.getPlaintextSecret( + feedId, + varName, + ); + + if (secret !== null) { + (processedConfig as any)[key] = secret; + this.logger.info( + `PluginService: Hydrated config key '${key}' for plugin '${name}' (feedId '${feedId}') from secret-service for varName '${varName}'.`, + ); + } else { + this.logger.warn( + `PluginService: Config key '${key}' for plugin '${name}' (feedId '${feedId}') references unknown secret/env var '${varName}'. Placeholder '${value}' remains.`, + ); + // Optionally, throw an error if a required secret is missing: + throw new PluginError(`Required secret '${varName}' not found for plugin '${name}'`, + { + pluginName: name, operation: "hydrate" + }, PluginErrorCode.PLUGIN_INITIALIZATION_FAILED + ); + } + } + } + } - await newInstance.initialize(hydratedConfig); + this.logger.debug( + `PluginService: Config hydration complete for plugin '${name}', feedId '${feedId}'.`, + ); - // // Validate instance implements required interface - // if (!this.validatePluginInterface(newInstance, config.type)) { - // throw new PluginInitError( - // name, - // new Error( - // `Plugin does not implement required ${config.type} interface`, - // ), - // ); - // } + await newInstance.initialize(processedConfig); - // Cache successful instance const instanceState: InstanceState = { instance: newInstance as PluginTypeMap< unknown, @@ -302,17 +339,17 @@ export class PluginService implements IBaseService { throw error instanceof PluginError ? error : new PluginError( - `Unexpected error with plugin ${name}`, - { - pluginName: name, - operation: "unknown", - }, - PluginErrorCode.UNKNOWN_PLUGIN_ERROR, - false, - { - cause: error instanceof Error ? error : undefined, - }, - ); + `Unexpected error with plugin ${name}`, + { + pluginName: name, + operation: "unknown", + }, + PluginErrorCode.UNKNOWN_PLUGIN_ERROR, + false, + { + cause: error instanceof Error ? error : undefined, + }, + ); } } diff --git a/apps/api/src/services/processor.service.ts b/apps/api/src/services/processor.service.ts index 974a59f7..eab80c11 100644 --- a/apps/api/src/services/processor.service.ts +++ b/apps/api/src/services/processor.service.ts @@ -9,9 +9,9 @@ import { logger } from "../utils/logger"; import { sanitizeJson } from "../utils/sanitize"; import { DistributionService } from "./distribution.service"; import { IBaseService } from "./interfaces/base-service.interface"; -import { TransformationService } from "./transformation.service"; +import { TransformationService } from "./transformation.service.js"; -interface ProcessConfig { +export interface ProcessConfig { enabled?: boolean; transform?: TransformConfig[]; distribute?: DistributorConfig[]; @@ -30,9 +30,12 @@ export class ProcessorService implements IBaseService { /** * Process content through transformation pipeline and distribute - * Can be used for both individual submissions and bulk content (like recaps) */ - async process(content: RichSubmission, config: ProcessConfig) { + async process( + content: RichSubmission, + config: ProcessConfig, + feedId: string, + ) { try { // Apply global transforms if any let processed = content; @@ -42,6 +45,7 @@ export class ProcessorService implements IBaseService { processed, config.transform, "global", + feedId, ); processed = sanitizeJson(processed); @@ -75,6 +79,7 @@ export class ProcessorService implements IBaseService { distributorContent, distributor.transform, "distributor", + feedId, ); distributorContent = sanitizeJson(distributorContent); } catch (error) { @@ -95,6 +100,7 @@ export class ProcessorService implements IBaseService { await this.distributionService.distributeContent( distributor, distributorContent, + feedId, ); } catch (error) { // Collect errors but continue with other distributors @@ -132,8 +138,9 @@ export class ProcessorService implements IBaseService { async processBatch( items: any[], config: ProcessConfig & { - batchTransform?: TransformConfig[]; // Optional transforms to apply to collected results + batchTransform?: TransformConfig[]; }, + feedId: string, ) { try { // Process each item through global and distributor transforms @@ -147,6 +154,7 @@ export class ProcessorService implements IBaseService { processed, config.transform, "global", + feedId, ); processed = sanitizeJson(processed); @@ -168,6 +176,7 @@ export class ProcessorService implements IBaseService { results, config.batchTransform, "batch", + feedId, ); batchResult = sanitizeJson(batchResult); @@ -197,6 +206,7 @@ export class ProcessorService implements IBaseService { distributorContent, distributor.transform, "distributor", + feedId, ); distributorContent = sanitizeJson(distributorContent); @@ -206,6 +216,7 @@ export class ProcessorService implements IBaseService { await this.distributionService.distributeContent( distributor, distributorContent, + feedId, ); } catch (error) { errors.push( diff --git a/apps/api/src/services/secret-service-client.ts b/apps/api/src/services/secret-service-client.ts new file mode 100644 index 00000000..53ae8c6b --- /dev/null +++ b/apps/api/src/services/secret-service-client.ts @@ -0,0 +1,162 @@ +import { Logger } from "pino"; + +export class SecretServiceApiClient { + private baseUrl: string; + private apiKey: string; + private logger: Logger; + + constructor(baseUrl: string, apiKey: string, logger: Logger) { + if (!baseUrl) { + throw new Error("SecretServiceApiClient: baseUrl is required."); + } + if (!apiKey) { + throw new Error("SecretServiceApiClient: apiKey is required."); + } + this.baseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; + this.apiKey = apiKey; + this.logger = logger; + this.logger.info( + `SecretServiceApiClient initialized for URL: ${this.baseUrl}`, + ); + } + + private async makeRequest(endpoint: string, body: any): Promise { + const url = `${this.baseUrl}${endpoint}`; + this.logger.debug(`SecretServiceApiClient: Making request to ${url}`, { + body, + }); + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": this.apiKey, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + let errorData: any = { + error: `Request failed with status ${response.status}`, + }; + try { + errorData = await response.json(); + } catch (e) { + this.logger.warn( + `SecretServiceApiClient: Could not parse error response as JSON from ${url}. Status: ${response.status}`, + ); + } + this.logger.error( + `SecretServiceApiClient: API error for ${endpoint}. Status: ${response.status}`, + { responseBody: errorData }, + ); + throw new Error( + `SecretService error: ${errorData.error || response.statusText || "Unknown error"}`, + ); + } + + const responseText = await response.text(); + if (!responseText) { + this.logger.info( + `SecretServiceApiClient: Received empty successful response from ${url}`, + ); + return {} as T; + } + + return JSON.parse(responseText) as T; + } catch (error: any) { + this.logger.error( + `SecretServiceApiClient: Error communicating with SecretService at ${url}.`, + { errorMessage: error.message, error }, + ); + throw new Error( + `Failed to communicate with secret service: ${error.message}`, + ); + } + } + + async getPlaintextSecret( + feedId: string, + keyName: string, + ): Promise { + this.logger.debug( + `SecretServiceApiClient: Requesting plaintext secret for feedId: ${feedId}, keyName: ${keyName}`, + ); + try { + type PlaintextResponse = { plaintext: string }; + + const result = await this.makeRequest< + PlaintextResponse | { error?: string } + >("/v1/secrets/get-plaintext", { + feedId, + keyName, + }); + + if ("error" in result && result.error) { + this.logger.warn( + `SecretServiceApiClient: getPlaintextSecret failed for '${keyName}', feed '${feedId}'. Error: ${result.error}`, + ); + return null; + } + if ("plaintext" in result) { + this.logger.info( + `SecretServiceApiClient: Successfully retrieved plaintext secret for '${keyName}', feed '${feedId}'.`, + ); + return (result as PlaintextResponse).plaintext; + } + this.logger.warn( + `SecretServiceApiClient: Unexpected response format for getPlaintextSecret for '${keyName}', feed '${feedId}'.`, + ); + return null; + } catch (error: any) { + this.logger.warn( + `SecretServiceApiClient: Could not retrieve plaintext secret '${keyName}' for feed '${feedId}'. Error: ${error.message}`, + ); + return null; + } + } + + async signPayloadWithNearKey( + feedId: string, + keyName: string, + payload: string, + ): Promise<{ signature: string; publicKey: string } | null> { + this.logger.debug( + `SecretServiceApiClient: Requesting NEAR signature for feedId: ${feedId}, keyName: ${keyName}`, + ); + try { + type NearSignatureResponse = { signature: string; publicKey: string }; + + const result = await this.makeRequest< + NearSignatureResponse | { error?: string } + >("/v1/secrets/sign-near", { + feedId, + keyName, + payload, + }); + + if ("error" in result && result.error) { + this.logger.warn( + `SecretServiceApiClient: signPayloadWithNearKey failed for '${keyName}', feed '${feedId}'. Error: ${result.error}`, + ); + return null; + } + if ("signature" in result && "publicKey" in result) { + this.logger.info( + `SecretServiceApiClient: Successfully signed payload with NEAR key for '${keyName}', feed '${feedId}'.`, + ); + return result as NearSignatureResponse; + } + this.logger.warn( + `SecretServiceApiClient: Unexpected response format for signPayloadWithNearKey for '${keyName}', feed '${feedId}'.`, + ); + return null; + } catch (error: any) { + this.logger.warn( + `SecretServiceApiClient: Could not sign payload with NEAR key '${keyName}' for feed '${feedId}'. Error: ${error.message}`, + ); + return null; + } + } +} diff --git a/apps/api/src/services/transformation.service.ts b/apps/api/src/services/transformation.service.ts index b10ce7a5..890e343d 100644 --- a/apps/api/src/services/transformation.service.ts +++ b/apps/api/src/services/transformation.service.ts @@ -7,7 +7,7 @@ import { logPluginError } from "../utils/error"; import { logger } from "../utils/logger"; import { sanitizeJson } from "../utils/sanitize"; import { IBaseService } from "./interfaces/base-service.interface"; -import { PluginService } from "./plugin.service"; +import { PluginService } from "./plugin.service.js"; export type TransformStage = "global" | "distributor" | "batch"; @@ -47,16 +47,21 @@ export class TransformationService implements IBaseService { content: any, transforms: TransformConfig[] = [], stage: TransformStage = "global", + feedId: string, ) { let result = content; for (let i = 0; i < transforms.length; i++) { const transform = transforms[i]; try { - const plugin = await this.pluginService.getPlugin(transform.plugin, { - type: "transformer", - config: transform.config, - }); + const plugin = await this.pluginService.getPlugin( + transform.plugin, + { + type: "transformer", + config: transform.config, + }, + feedId, + ); const args: ActionArgs> = { input: result, diff --git a/apps/api/src/utils/service-provider.ts b/apps/api/src/utils/service-provider.ts index dd82905c..5b5d3167 100644 --- a/apps/api/src/utils/service-provider.ts +++ b/apps/api/src/utils/service-provider.ts @@ -19,6 +19,8 @@ import { TransformationService } from "../services/transformation.service"; import { TwitterService } from "../services/twitter/client"; import { UserService } from "../services/users.service"; import { logger } from "./logger"; +import { SecretServiceApiClient } from "../services/secret-service-client"; +import { env } from "../env"; export class ServiceProvider { private static instance: ServiceProvider; @@ -32,6 +34,18 @@ export class ServiceProvider { const configService = new ConfigService(); + if (!env.SECRET_SERVICE_URL || !env.SECRET_SERVICE_INTERNAL_API_KEY) { + logger.error( + "ServiceProvider: SECRET_SERVICE_URL or SECRET_SERVICE_INTERNAL_API_KEY is not defined. SecretServiceApiClient may not function correctly.", + ); + } + const secretServiceApiClient = new SecretServiceApiClient( + env.SECRET_SERVICE_URL!, + env.SECRET_SERVICE_INTERNAL_API_KEY!, + logger, + ); + this.services.set("secretServiceApiClient", secretServiceApiClient); + let twitterService: TwitterService | null = null; if (isProduction) { twitterService = new TwitterService( @@ -49,7 +63,7 @@ export class ServiceProvider { twitterService = new MockTwitterService(); } - const pluginService = new PluginService(logger); + const pluginService = new PluginService(logger, secretServiceApiClient); const transformationService = new TransformationService( pluginService, logger, @@ -201,6 +215,10 @@ export class ServiceProvider { return this.getService("feedService"); } + public getSecretServiceApiClient(): SecretServiceApiClient { + return this.getService("secretServiceApiClient"); + } + /** * Get all services that implement IBackgroundTaskService * @returns An array of background task services diff --git a/apps/secret-service/.dockerignore b/apps/secret-service/.dockerignore new file mode 100644 index 00000000..3daee622 --- /dev/null +++ b/apps/secret-service/.dockerignore @@ -0,0 +1,41 @@ +# .dockerignore for secret-service + +# Ignore Git files +.git +.gitignore + +# Ignore Node.js modules (these will be installed in the Docker image) +node_modules + +# Ignore build output directory (this will be created during the Docker build) +dist + +# Ignore local development environment files +.env* +*.env +!.env.example + +# Ignore Docker files themselves if they are in the context +Dockerfile +.dockerignore + +# Ignore TypeScript cache files +*.tsbuildinfo + +# Ignore test files if not needed in production image (though build might need them) +# src/**/*.test.ts +# src/**/*.spec.ts + +# Ignore local logs or temporary files +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Editor/IDE specific files +.vscode +.idea +*.sublime-project +*.sublime-workspace diff --git a/apps/secret-service/.gitignore b/apps/secret-service/.gitignore new file mode 100644 index 00000000..36fabb6c --- /dev/null +++ b/apps/secret-service/.gitignore @@ -0,0 +1,28 @@ +# dev +.yarn/ +!.yarn/releases +.vscode/* +!.vscode/launch.json +!.vscode/*.code-snippets +.idea/workspace.xml +.idea/usage.statistics.xml +.idea/shelf + +# deps +node_modules/ + +# env +.env +.env.production + +# logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# misc +.DS_Store diff --git a/apps/secret-service/Dockerfile b/apps/secret-service/Dockerfile new file mode 100644 index 00000000..7f8f8b33 --- /dev/null +++ b/apps/secret-service/Dockerfile @@ -0,0 +1,106 @@ +# ---- Base Stage ---- +# Standardize on node:20-alpine +FROM node:20-alpine AS base + +# Install pnpm and turbo globally for use in pruner/builder stages +# Corepack will be used for pnpm in later stages if only pnpm is needed. +RUN npm install -g pnpm turbo + +# ---- Pruner Stage ---- +# Creates a pruned monorepo subset for the secret-service +FROM base AS pruner +WORKDIR /app + +# COPY the entire monorepo context. Docker build context should be the monorepo root. +COPY . . + +# Disable telemetry and prune the monorepo +# Include @curatedotfun/secret-service and its direct workspace dependencies +# Assuming secret-service might depend on shared packages like shared-db, types, utils +# Adjust scopes if dependencies are different. +RUN turbo telemetry disable +RUN turbo prune --scope=@curatedotfun/secret-service --docker +# If secret-service has other direct workspace dependencies, add them here: +# e.g., --scope=@curatedotfun/shared-db --scope=@curatedotfun/types + +# ---- Builder Stage ---- +# Installs all dependencies for the pruned subset and builds the secret-service +FROM base AS builder +WORKDIR /app + +# Enable corepack for pnpm +RUN corepack enable + +# Copy pruned manifests and lockfile from pruner stage +COPY --from=pruner /app/out/full/ . +COPY --from=pruner /app/out/json/ . +COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml + +# Copy other monorepo config files +COPY --from=pruner /app/turbo.json ./turbo.json +COPY --from=pruner /app/pnpm-workspace.yaml ./pnpm-workspace.yaml +COPY --from=pruner /app/tsconfig.json ./tsconfig.json # Root tsconfig if needed by build + +# Install ALL dependencies for the pruned monorepo subset (including devDependencies for build) +RUN pnpm install --frozen-lockfile + +# Copy the full source code of the pruned monorepo subset +COPY --from=pruner /app/out/full/ . + +# Build ONLY the secret-service application +ENV NODE_ENV="production" +# Ensure the filter targets the correct package name for secret-service +RUN pnpm run build --filter=@curatedotfun/secret-service + +# ---- Production Stage ---- +# Minimal image for running the secret-service +FROM node:20-alpine AS production +WORKDIR /app + +# Create a non-root user for security +RUN addgroup -S app && adduser -S app -G app + +# Enable corepack for pnpm to run 'pnpm install --prod' +RUN corepack enable + +# Copy only necessary production manifests from the builder stage +COPY --from=builder --chown=app:app /app/apps/secret-service/package.json ./apps/secret-service/package.json +COPY --from=builder --chown=app:app /app/pnpm-lock.yaml ./pnpm-lock.yaml +COPY --from=builder --chown=app:app /app/pnpm-workspace.yaml ./pnpm-workspace.yaml + +# If secret-service depends on other workspace packages (e.g., shared-db), copy their package.json too +# COPY --from=builder --chown=app:app /app/packages/shared-db/package.json ./packages/shared-db/package.json + +# Set WORKDIR to the root for installing production dependencies from the workspace root perspective +WORKDIR /app + +# Install only production dependencies for the entire pruned workspace. +# This ensures that dependencies of workspace packages (like shared-db) are also installed if needed at runtime. +# The --filter ensures we only install for secret-service and its dependencies. +RUN pnpm install --prod --frozen-lockfile --filter=@curatedotfun/secret-service... + +# Copy built application code from the builder stage +# Copy the entire apps/secret-service directory which includes dist and other necessary files +COPY --from=builder --chown=app:app /app/apps/secret-service ./apps/secret-service + +# Copy necessary built workspace dependencies from builder stage (if any) +# Example: if secret-service uses compiled output from shared-db +# COPY --from=builder --chown=app:app /app/packages/shared-db/dist ./packages/shared-db/dist + +# Use the non-root user +USER app + +# Expose the port the app runs on +EXPOSE 3000 + +# Define environment variables that might be needed (can also be set at runtime) +ENV NODE_ENV=production +# PORT, DATABASE_URL, ENCRYPTION_MASTER_KEY, SECRET_SERVICE_INTERNAL_API_KEY +# should be provided by the Railway environment. + +# Set the final working directory to the secret-service application's root +WORKDIR /app/apps/secret-service + +# Command to run the application +# This uses the 'start' script from package.json: "node dist/index.js" +CMD ["node", "dist/index.js"] diff --git a/apps/secret-service/README.md b/apps/secret-service/README.md new file mode 100644 index 00000000..59eb167b --- /dev/null +++ b/apps/secret-service/README.md @@ -0,0 +1,10 @@ +# secret service + +```bash +pnpm install +pnpm run dev +``` + +```bash +open http://localhost:3000 +``` diff --git a/apps/secret-service/package.json b/apps/secret-service/package.json new file mode 100644 index 00000000..105acf5f --- /dev/null +++ b/apps/secret-service/package.json @@ -0,0 +1,31 @@ +{ + "name": "@curatedotfun/secret-service", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "lint": "eslint . --ext .ts", + "lint:fix": "eslint . --ext .ts --fix", + "format": "prettier --write \"src/**/*.ts\"" + }, + "dependencies": { + "@curatedotfun/shared-db": "workspace:*", + "@hono/node-server": "^1.14.4", + "drizzle-orm": "^0.43.1", + "hono": "^4.7.11", + "near-api-js": "^4.0.3", + "pg": "^8.15.6" + }, + "devDependencies": { + "@types/eslint": "^9", + "@types/pg": "^8.11.11", + "eslint": "^9.8.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "prettier": "^3.3.3", + "@types/node": "^20.11.17", + "tsx": "^4.7.1", + "typescript": "^5.8.3" + } +} diff --git a/apps/secret-service/src/db/index.ts b/apps/secret-service/src/db/index.ts new file mode 100644 index 00000000..6aa43fc9 --- /dev/null +++ b/apps/secret-service/src/db/index.ts @@ -0,0 +1,10 @@ +import { schema, type DB } from "@curatedotfun/shared-db"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL!, +}); +const db: DB = drizzle(pool, { schema }); + +export { db, pool }; diff --git a/apps/secret-service/src/index.ts b/apps/secret-service/src/index.ts new file mode 100644 index 00000000..92c9bdcc --- /dev/null +++ b/apps/secret-service/src/index.ts @@ -0,0 +1,248 @@ +import { Hono } from "hono"; +import { serve } from "@hono/node-server"; +import { env } from "hono/adapter"; +import { db } from "./db/index.js"; +import { EncryptionService } from "./services/encryption.service.js"; +import { SecretStoreService } from "./services/secret-store.service.js"; +import { logger } from "./utils/logger.js"; +import { KeyPair } from "near-api-js"; +import { bufferToHex } from "./utils/crypto-helpers.js"; + +// Define types for environment variables for clarity +interface AppEnv { + ENCRYPTION_MASTER_KEY: string; + SECRET_SERVICE_INTERNAL_API_KEY: string; + PORT?: string; // Optional port from environment + DATABASE_URL: string; // Already checked in db/index.js, but good to have here + [key: string]: string | undefined; // Index signature for Hono compatibility +} + +const app = new Hono<{ Variables: AppEnv }>(); + +// Initialize services - these will be set in the 'request' hook or a startup function +let encryptionService: EncryptionService; +let secretStoreService: SecretStoreService; + +// Middleware for API Key Authentication & Service Initialization +app.use("*", async (c, next) => { + // Service initialization logic (lazy, on first request) + // This ensures env vars are loaded via Hono's adapter before services use them. + if (!encryptionService) { + const { ENCRYPTION_MASTER_KEY, DATABASE_URL } = env(c); + if (!ENCRYPTION_MASTER_KEY) { + logger.error( + "FATAL: ENCRYPTION_MASTER_KEY is not defined in the environment.", + ); + return c.json( + { error: "Server configuration error: Missing master key." }, + 500, + ); + } + if (!DATABASE_URL) { + // Though db/index.js checks, good to be defensive + logger.error( + "FATAL: DATABASE_URL is not defined in the environment for service initialization.", + ); + return c.json( + { error: "Server configuration error: Missing database URL." }, + 500, + ); + } + try { + encryptionService = new EncryptionService(ENCRYPTION_MASTER_KEY); + secretStoreService = new SecretStoreService( + db, + encryptionService, + logger, + ); + logger.info("Services initialized successfully."); + } catch (error) { + logger.error("FATAL: Failed to initialize services.", { error }); + return c.json({ error: "Server initialization failed." }, 500); + } + } + + // API Key Authentication + const { SECRET_SERVICE_INTERNAL_API_KEY } = env(c); + if (!SECRET_SERVICE_INTERNAL_API_KEY) { + logger.error( + "FATAL: SECRET_SERVICE_INTERNAL_API_KEY is not defined in the environment.", + ); + return c.json( + { error: "Server configuration error: Missing internal API key." }, + 500, + ); + } + + const providedKey = c.req.header("X-API-Key"); + if (providedKey !== SECRET_SERVICE_INTERNAL_API_KEY) { + logger.warn( + `Unauthorized access attempt to secret-service. Provided key: ${providedKey ? providedKey.substring(0, 5) + "..." : "None"}`, + ); + return c.json({ error: "Unauthorized" }, 401); + } + + await next(); +}); + +// Endpoint to retrieve a plaintext secret +app.post("/v1/secrets/get-plaintext", async (c) => { + try { + const { feedId, keyName } = await c.req.json<{ + feedId: string; + keyName: string; + }>(); + if (!feedId || !keyName) { + logger.warn( + "/v1/secrets/get-plaintext: Missing feedId or keyName in request.", + ); + return c.json({ error: "Missing feedId or keyName" }, 400); + } + + const plaintext = await secretStoreService.getPlaintextSecret( + feedId, + keyName, + ); + if (plaintext === null) { + logger.info( + `/v1/secrets/get-plaintext: Secret not found or decryption failed for feedId '${feedId}', keyName '${keyName}'.`, + ); + return c.json({ error: "Secret not found or decryption failed" }, 404); + } + + // CRITICAL: Ensure this endpoint is NOT used for private keys that should only be used for signing. + // Add checks here if certain keyNames (e.g., those ending with _PRIVATE_KEY) should be disallowed. + if (keyName.toUpperCase().includes("PRIVATE_KEY")) { + logger.error( + `/v1/secrets/get-plaintext: Attempt to retrieve a private key ('${keyName}') via plaintext endpoint for feedId '${feedId}'. This is disallowed.`, + ); + return c.json( + { + error: + "Retrieving this type of key via plaintext is not allowed. Use a signing endpoint.", + }, + 403, + ); + } + + logger.info( + `/v1/secrets/get-plaintext: Successfully retrieved secret for feedId '${feedId}', keyName '${keyName}'.`, + ); + return c.json({ plaintext }); + } catch (error) { + logger.error("/v1/secrets/get-plaintext: Unexpected error.", { error }); + return c.json({ error: "An unexpected error occurred." }, 500); + } +}); + +// Endpoint to sign a payload with a NEAR private key +app.post("/v1/secrets/sign-near", async (c) => { + try { + const { feedId, keyName, payload } = await c.req.json<{ + feedId: string; + keyName: string; + payload: string; + }>(); + if (!feedId || !keyName || !payload) { + logger.warn( + "/v1/secrets/sign-near: Missing feedId, keyName, or payload in request.", + ); + return c.json({ error: "Missing feedId, keyName, or payload" }, 400); + } + + const privateKey = await secretStoreService.getPlaintextSecret( + feedId, + keyName, + ); + if (privateKey === null) { + logger.warn( + `/v1/secrets/sign-near: Attempt to sign with non-existent or inaccessible key '${keyName}' for feed '${feedId}'.`, + ); + return c.json( + { error: "Private key not found or decryption failed" }, + 404, + ); + } + + try { + const keyPair = KeyPair.fromString(privateKey as any); // Cast to satisfy KeyPairString type + const signature = keyPair.sign(new TextEncoder().encode(payload)); + + logger.info( + `/v1/secrets/sign-near: Successfully signed payload for feedId '${feedId}', keyName '${keyName}'.`, + ); + return c.json({ + signature: bufferToHex(Buffer.from(signature.signature)), + publicKey: keyPair.getPublicKey().toString(), // Use getPublicKey() for consistency + }); + } catch (signError) { + logger.error( + `/v1/secrets/sign-near: Error signing payload for feed '${feedId}' with key '${keyName}'.`, + { signError }, + ); + return c.json({ error: "Signing failed" }, 500); + } + } catch (error) { + logger.error("/v1/secrets/sign-near: Unexpected error.", { error }); + return c.json({ error: "An unexpected error occurred." }, 500); + } +}); + +// Endpoint to add/update secrets +app.post("/v1/secrets/set-secret", async (c) => { + try { + // The spec mentions publicKey here, but SecretStoreService.setSecret doesn't use it. + // If publicKey needs to be stored, the service and schema would need an update. + // For now, matching the service method. + const { feedId, keyName, plaintextValue } = await c.req.json<{ + feedId: string; + keyName: string; + plaintextValue: string; + publicKey?: string; + }>(); + if (!feedId || !keyName || plaintextValue === undefined) { + // Check plaintextValue for undefined as empty string might be valid + logger.warn( + "/v1/secrets/set-secret: Missing feedId, keyName, or plaintextValue in request.", + ); + return c.json( + { error: "Missing feedId, keyName, or plaintextValue" }, + 400, + ); + } + + await secretStoreService.setSecret(feedId, keyName, plaintextValue); + logger.info( + `/v1/secrets/set-secret: Secret stored/updated successfully for feedId '${feedId}', keyName '${keyName}'.`, + ); + return c.json({ message: "Secret stored/updated successfully" }); + } catch (error) { + logger.error("/v1/secrets/set-secret: Error setting secret.", { error }); + // secretStoreService.setSecret already logs details if it throws + return c.json({ error: "Failed to store secret" }, 500); + } +}); + +// Basic health check endpoint +app.get("/health", (c) => { + logger.info("Health check successful."); + return c.json({ status: "ok", timestamp: new Date().toISOString() }); +}); + +// For environment variables at startup, use process.env directly. +// The AppEnv interface helps type 'c.var' within Hono handlers. +const port = parseInt(process.env.PORT || "3000", 10); + +logger.info(`SecretService is starting on port ${port}...`); + +serve( + { + fetch: app.fetch, + port: port, + }, + (info) => { + logger.info(`SecretService is running at http://localhost:${info.port}`); + }, +); + +export default app; diff --git a/apps/secret-service/src/services/encryption.service.ts b/apps/secret-service/src/services/encryption.service.ts new file mode 100644 index 00000000..16bff551 --- /dev/null +++ b/apps/secret-service/src/services/encryption.service.ts @@ -0,0 +1,100 @@ +import { + createCipheriv, + createDecipheriv, + randomBytes, + type CipherGCMTypes, +} from "crypto"; +import { logger } from "../utils/logger.js"; + +export class EncryptionService { + private readonly algorithm: CipherGCMTypes = "aes-256-gcm"; + private readonly encryptionKey: Buffer; + + constructor(masterKeyHex: string) { + if (!masterKeyHex || masterKeyHex.length !== 64) { + logger.error( + "EncryptionService: Invalid or missing ENCRYPTION_MASTER_KEY. Must be a 64-character hex string (32 bytes).", + ); + throw new Error( + "EncryptionService: Invalid or missing ENCRYPTION_MASTER_KEY. Must be a 32-byte hex string.", + ); + } + try { + this.encryptionKey = Buffer.from(masterKeyHex, "hex"); + if (this.encryptionKey.length !== 32) { + // Double check byte length after hex conversion + logger.error( + "EncryptionService: ENCRYPTION_MASTER_KEY does not produce 32 bytes after hex decoding.", + ); + throw new Error( + "EncryptionService: ENCRYPTION_MASTER_KEY must be 32 bytes after hex decoding.", + ); + } + logger.info("EncryptionService initialized successfully."); + } catch (error) { + logger.error( + "EncryptionService: Failed to decode ENCRYPTION_MASTER_KEY from hex.", + { error }, + ); + throw new Error( + "EncryptionService: Failed to decode ENCRYPTION_MASTER_KEY. Ensure it is a valid hex string.", + ); + } + } + + encrypt(plaintext: string): { + encryptedValue: Buffer; + iv: Buffer; + authTag: Buffer; + } { + if (typeof plaintext !== "string" || plaintext.length === 0) { + // It's often better to encrypt an empty string than to throw an error, + // depending on application requirements. For secrets, an empty secret might be valid. + logger.warn( + "EncryptionService: Attempting to encrypt empty or non-string plaintext.", + ); + } + const iv = randomBytes(12); // AES-GCM standard IV size is 12 bytes (96 bits) for better performance and security. Spec used 16, but 12 is common. Let's stick to 12. + const cipher = createCipheriv(this.algorithm, this.encryptionKey, iv); + + let encrypted = cipher.update(plaintext, "utf8", "hex"); + encrypted += cipher.final("hex"); + + const authTag = cipher.getAuthTag(); + + return { + encryptedValue: Buffer.from(encrypted, "hex"), + iv: iv, + authTag: authTag, + }; + } + + decrypt(encryptedValue: Buffer, iv: Buffer, authTag: Buffer): string { + if (!encryptedValue || !iv || !authTag) { + logger.error( + "EncryptionService: Missing encryptedValue, iv, or authTag for decryption.", + ); + throw new Error("Decryption inputs missing."); + } + try { + const decipher = createDecipheriv(this.algorithm, this.encryptionKey, iv); + decipher.setAuthTag(authTag); + + let decrypted = decipher.update( + encryptedValue.toString("hex"), + "hex", + "utf8", + ); // Provide 'hex' as inputEncoding for encryptedValue + decrypted += decipher.final("utf8"); + + return decrypted; + } catch (error) { + logger.error( + "EncryptionService: Decryption failed. This could be due to an incorrect key, IV, authTag, or corrupted data.", + { error }, + ); + // Do not throw the original error to avoid leaking crypto details. + throw new Error("Decryption failed."); + } + } +} diff --git a/apps/secret-service/src/services/secret-store.service.ts b/apps/secret-service/src/services/secret-store.service.ts new file mode 100644 index 00000000..6921eac3 --- /dev/null +++ b/apps/secret-service/src/services/secret-store.service.ts @@ -0,0 +1,127 @@ +import { + secrets, + type DB, + type InsertSecret, + type SelectSecret, +} from "@curatedotfun/shared-db"; +import { and, eq } from "drizzle-orm"; +import type { Logger } from "../utils/logger.js"; +import type { EncryptionService } from "./encryption.service.js"; + +export class SecretStoreService { + private db: DB; + private encryptionService: EncryptionService; + private logger: Logger; + + constructor( + db: DB, + encryptionService: EncryptionService, + logger: Logger, + ) { + this.db = db; + this.encryptionService = encryptionService; + this.logger = logger; + this.logger.info("SecretStoreService initialized."); + } + + async setSecret( + feedId: string, + keyName: string, + plaintextValue: string, + ): Promise { + this.logger.debug( + `Attempting to set secret for feedId: ${feedId}, keyName: ${keyName}`, + ); + const { encryptedValue, iv, authTag } = + this.encryptionService.encrypt(plaintextValue); + + const insertSecretValues: InsertSecret = { + feedId: feedId, + keyName, + encryptedValue, + encryptionIv: iv, + authenticationTag: authTag, + }; + + try { + await this.db + .insert(secrets) + .values(insertSecretValues) + .onConflictDoUpdate({ + target: [secrets.feedId, secrets.keyName], + set: { + encryptedValue, + encryptionIv: iv, + authenticationTag: authTag, + }, + }) + .execute(); + this.logger.info( + `Secret '${keyName}' for feed '${feedId}' stored/updated successfully.`, + ); + } catch (error) { + this.logger.error( + `Error storing/updating secret '${keyName}' for feed '${feedId}':`, + { error }, + ); + throw new Error(`Failed to store/update secret '${keyName}'.`); + } + } + + async getPlaintextSecret( + feedId: string, + keyName: string, + ): Promise { + this.logger.debug( + `Attempting to get plaintext secret for feedId: ${feedId}, keyName: ${keyName}`, + ); + let secretRecord: SelectSecret | undefined; + + try { + secretRecord = await this.db.query.secrets.findFirst({ + where: and(eq(secrets.feedId, feedId), eq(secrets.keyName, keyName)), + }); + } catch (error) { + this.logger.error( + `Database error retrieving secret '${keyName}' for feed '${feedId}':`, + { error }, + ); + return null; + } + + if (!secretRecord) { + this.logger.warn(`Secret '${keyName}' not found for feed '${feedId}'.`); + return null; + } + + // Ensure all necessary bytea fields are present and are Buffers + if ( + !(secretRecord.encryptedValue instanceof Buffer) || + !(secretRecord.encryptionIv instanceof Buffer) || + !(secretRecord.authenticationTag instanceof Buffer) + ) { + this.logger.error( + `Corrupted or invalid data format for secret '${keyName}', feed '${feedId}'. Expected Buffers.`, + ); + return null; + } + + try { + const decryptedText = this.encryptionService.decrypt( + secretRecord.encryptedValue, + secretRecord.encryptionIv, + secretRecord.authenticationTag, + ); + this.logger.info( + `Secret '${keyName}' for feed '${feedId}' decrypted successfully.`, + ); + return decryptedText; + } catch (error) { + // EncryptionService's decrypt method already logs detailed errors + this.logger.warn( + `Failed to decrypt secret '${keyName}' for feed '${feedId}'. This might indicate a data integrity issue or incorrect master key.`, + ); + return null; + } + } +} diff --git a/apps/secret-service/src/utils/crypto-helpers.ts b/apps/secret-service/src/utils/crypto-helpers.ts new file mode 100644 index 00000000..d7325641 --- /dev/null +++ b/apps/secret-service/src/utils/crypto-helpers.ts @@ -0,0 +1,17 @@ +/** + * Converts a Buffer to its hexadecimal string representation. + * @param buffer The Buffer to convert. + * @returns The hexadecimal string. + */ +export function bufferToHex(buffer: Buffer): string { + return buffer.toString("hex"); +} + +/** + * Converts a hexadecimal string to a Buffer. + * @param hexString The hexadecimal string to convert. + * @returns The Buffer. + */ +export function hexToBuffer(hexString: string): Buffer { + return Buffer.from(hexString, "hex"); +} diff --git a/apps/secret-service/src/utils/logger.ts b/apps/secret-service/src/utils/logger.ts new file mode 100644 index 00000000..6c518b53 --- /dev/null +++ b/apps/secret-service/src/utils/logger.ts @@ -0,0 +1,26 @@ +export interface Logger { + info: (message: string, ...args: any[]) => void; + warn: (message: string, ...args: any[]) => void; + error: (message: string, ...args: any[]) => void; + debug: (message: string, ...args: any[]) => void; +} + +export const logger: Logger = { + info: (message, ...args) => { + console.log(`[INFO] ${new Date().toISOString()}: ${message}`, ...args); + }, + warn: (message, ...args) => { + console.warn(`[WARN] ${new Date().toISOString()}: ${message}`, ...args); + }, + error: (message, ...args) => { + console.error(`[ERROR] ${new Date().toISOString()}: ${message}`, ...args); + }, + debug: (message, ...args) => { + if ( + process.env.NODE_ENV === "development" || + process.env.DEBUG === "true" + ) { + console.debug(`[DEBUG] ${new Date().toISOString()}: ${message}`, ...args); + } + }, +}; diff --git a/apps/secret-service/tsconfig.json b/apps/secret-service/tsconfig.json new file mode 100644 index 00000000..71aa2b23 --- /dev/null +++ b/apps/secret-service/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "noEmit": false, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ESNext", + "strict": true, + "baseUrl": ".", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "verbatimModuleSyntax": true, + "types": ["node"], + "paths": { + "@curatedotfun/shared-db": ["../../packages/shared-db/src"], + "*": ["src/*"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"], + "references": [{ "path": "../../packages/shared-db" }] +} diff --git a/packages/shared-db/migrations/0009_secret_juggernaut.sql b/packages/shared-db/migrations/0009_secret_juggernaut.sql index f731f8e0..f9c85494 100644 --- a/packages/shared-db/migrations/0009_secret_juggernaut.sql +++ b/packages/shared-db/migrations/0009_secret_juggernaut.sql @@ -1,60 +1,73 @@ -ALTER TABLE "activities" ALTER COLUMN "timestamp" SET DATA TYPE timestamp with time zone;--> statement-breakpoint +ALTER TABLE "activities" ALTER COLUMN "timestamp" SET DATA TYPE timestamp with time zone USING timestamp::timestamp with time zone;--> statement-breakpoint ALTER TABLE "activities" ALTER COLUMN "timestamp" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "activities" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint +ALTER TABLE "activities" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone USING created_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "activities" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "activities" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint +ALTER TABLE "activities" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone USING updated_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "activities" ALTER COLUMN "updated_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "feed_user_stats" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint + +ALTER TABLE "feed_user_stats" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone USING created_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "feed_user_stats" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "feed_user_stats" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint +ALTER TABLE "feed_user_stats" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone USING updated_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "feed_user_stats" ALTER COLUMN "updated_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "user_stats" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint + +ALTER TABLE "user_stats" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone USING created_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "user_stats" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "user_stats" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint +ALTER TABLE "user_stats" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone USING updated_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "user_stats" ALTER COLUMN "updated_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "feed_plugins" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint + +ALTER TABLE "feed_plugins" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone USING created_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "feed_plugins" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "feed_plugins" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint +ALTER TABLE "feed_plugins" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone USING updated_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "feed_plugins" ALTER COLUMN "updated_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "feed_recaps_state" ALTER COLUMN "last_successful_completion" SET DATA TYPE timestamp with time zone;--> statement-breakpoint -ALTER TABLE "feed_recaps_state" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint + +ALTER TABLE "feed_recaps_state" ALTER COLUMN "last_successful_completion" SET DATA TYPE timestamp with time zone USING last_successful_completion::timestamp with time zone;--> statement-breakpoint +ALTER TABLE "feed_recaps_state" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone USING created_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "feed_recaps_state" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "feed_recaps_state" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint +ALTER TABLE "feed_recaps_state" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone USING updated_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "feed_recaps_state" ALTER COLUMN "updated_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "feeds" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint + +ALTER TABLE "feeds" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone USING created_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "feeds" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "feeds" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint +ALTER TABLE "feeds" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone USING updated_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "feeds" ALTER COLUMN "updated_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "twitter_cache" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint + +ALTER TABLE "twitter_cache" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone USING created_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "twitter_cache" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "twitter_cache" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint +ALTER TABLE "twitter_cache" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone USING updated_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "twitter_cache" ALTER COLUMN "updated_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "twitter_cookies" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint + +ALTER TABLE "twitter_cookies" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone USING created_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "twitter_cookies" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "twitter_cookies" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint +ALTER TABLE "twitter_cookies" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone USING updated_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "twitter_cookies" ALTER COLUMN "updated_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "submission_counts" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint + +ALTER TABLE "submission_counts" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone USING created_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "submission_counts" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "submission_counts" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint +ALTER TABLE "submission_counts" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone USING updated_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "submission_counts" ALTER COLUMN "updated_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "submission_feeds" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint + +ALTER TABLE "submission_feeds" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone USING created_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "submission_feeds" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "submission_feeds" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint +ALTER TABLE "submission_feeds" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone USING updated_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "submission_feeds" ALTER COLUMN "updated_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "submissions" ALTER COLUMN "submitted_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint -ALTER TABLE "submissions" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint + +ALTER TABLE "submissions" ALTER COLUMN "submitted_at" SET DATA TYPE timestamp with time zone USING submitted_at::timestamp with time zone;--> statement-breakpoint +ALTER TABLE "submissions" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone USING created_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "submissions" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "submissions" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint +ALTER TABLE "submissions" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone USING updated_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "submissions" ALTER COLUMN "updated_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "users" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint + +ALTER TABLE "users" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone USING created_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "users" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "users" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint +ALTER TABLE "users" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone USING updated_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "users" ALTER COLUMN "updated_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "plugins" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint + +ALTER TABLE "plugins" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone USING created_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "plugins" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "plugins" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint +ALTER TABLE "plugins" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone USING updated_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "plugins" ALTER COLUMN "updated_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "moderation_history" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint + +ALTER TABLE "moderation_history" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone USING created_at::timestamp with time zone;--> statement-breakpoint ALTER TABLE "moderation_history" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint -ALTER TABLE "moderation_history" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone;--> statement-breakpoint -ALTER TABLE "moderation_history" ALTER COLUMN "updated_at" SET DEFAULT now(); \ No newline at end of file +ALTER TABLE "moderation_history" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone USING updated_at::timestamp with time zone;--> statement-breakpoint +ALTER TABLE "moderation_history" ALTER COLUMN "updated_at" SET DEFAULT now();--> statement-breakpoint \ No newline at end of file diff --git a/packages/shared-db/src/schema/index.ts b/packages/shared-db/src/schema/index.ts index 0663f5ad..21c80abe 100644 --- a/packages/shared-db/src/schema/index.ts +++ b/packages/shared-db/src/schema/index.ts @@ -6,3 +6,4 @@ export * from "./submissions"; export * from "./users"; export * from "./plugins"; export * from "./moderation"; +export * from "./secrets"; diff --git a/packages/shared-db/src/schema/secrets.ts b/packages/shared-db/src/schema/secrets.ts new file mode 100644 index 00000000..7590a871 --- /dev/null +++ b/packages/shared-db/src/schema/secrets.ts @@ -0,0 +1,54 @@ +import { + customType, + pgTable, + uniqueIndex, + uuid, + varchar, +} from "drizzle-orm/pg-core"; +import { timestamps } from "./common"; + +// Define a custom type for bytea, assuming it will be handled as Buffer in JS/TS +const byteaType = customType<{ data: Buffer; driverData: string }>({ + dataType() { + return "bytea"; + }, + toDriver(value: Buffer): string { + // Convert Buffer to hex string for storage, or let the driver handle it + // For node-postgres, it can often handle Buffers directly. + // If direct Buffer handling is problematic, convert to hex: value.toString('hex') + // However, for now, let's assume the driver handles Buffer correctly. + // If not, this mapping function would be: `return '\\x' + value.toString('hex');` + return value as any; + }, + fromDriver(value: string | Buffer): Buffer { + // node-postgres might return Buffer directly for bytea. + // If it returns a hex string (e.g., '\\x...'), convert it. + if (typeof value === "string") { + // Assuming hex format like \x... from postgres + if (value.startsWith("\\x")) { + return Buffer.from(value.substring(2), "hex"); + } + return Buffer.from(value, "hex"); + } + return value; + }, +}); + +export const secrets = pgTable( + "secrets", + { + id: uuid("id").primaryKey().defaultRandom(), + feedId: uuid("feed_id").notNull(), + keyName: varchar("key_name", { length: 255 }).notNull(), + encryptedValue: byteaType("encrypted_value").notNull(), + encryptionIv: byteaType("encryption_iv").notNull(), // Initialization Vector for AES + authenticationTag: byteaType("authentication_tag").notNull(), // Authentication Tag for AES-256-GCM + ...timestamps + }, + (table) => [ + uniqueIndex("ux_feed_key_name").on( + table.feedId, + table.keyName, + ) + ], +); diff --git a/packages/shared-db/src/validators.ts b/packages/shared-db/src/validators.ts index a9982660..e74e0dd3 100644 --- a/packages/shared-db/src/validators.ts +++ b/packages/shared-db/src/validators.ts @@ -21,6 +21,7 @@ const { submissionFeeds, moderationHistory, submissionCounts, + secrets } = schema; // User Schemas and Types @@ -175,3 +176,16 @@ export const selectSubmissionCountSchema = createSelectSchema(submissionCounts); export type InsertSubmissionCount = z.infer; export type UpdateSubmissionCount = z.infer; export type SelectSubmissionCount = z.infer; + +export const insertSecretSchema = createInsertSchema( + secrets, + { + createdAt: z.undefined(), + updatedAt: z.undefined(), + }, +); +export const updateSecretSchema = createUpdateSchema(secrets); +export const selectSecretSchema = createSelectSchema(secrets); +export type InsertSecret = z.infer; +export type UpdateSecret = z.infer; +export type SelectSecret = z.infer; \ No newline at end of file diff --git a/packages/shared-db/tsconfig.json b/packages/shared-db/tsconfig.json index 4061cddc..f37ae1e8 100644 --- a/packages/shared-db/tsconfig.json +++ b/packages/shared-db/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../../tsconfig.json", + // "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", @@ -7,9 +7,12 @@ "declaration": true, // Generate .d.ts files "declarationMap": true, // Generate sourcemaps for .d.ts files "composite": true, // Required for project references and building this package - "module": "ESNext", - "moduleResolution": "bundler", - "baseUrl": "." + "module": "CommonJS", + "moduleResolution": "Node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist", "tests", "**/*.test.ts", "**/*.spec.ts"] diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json index 0b71a6d6..363652d9 100644 --- a/packages/utils/tsconfig.json +++ b/packages/utils/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { - "target": "ES2020", + "rootDir": "src", + "outDir": "dist", "module": "CommonJS", "moduleResolution": "Node", "composite": true, @@ -10,8 +11,6 @@ "forceConsistentCasingInFileNames": true, "declaration": true, "declarationMap": true, - "rootDir": "src", - "outDir": "dist", "paths": { "@curatedotfun/types/*": ["../types/src"], "@/*": ["./src/*"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d842a94a..f497adf2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,13 +119,13 @@ importers: devDependencies: '@module-federation/node': specifier: ^2.6.30 - version: 2.6.33(@rspack/core@1.2.8(@swc/helpers@0.5.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)(webpack@5.99.7) + version: 2.6.33(@rspack/core@1.2.8(@swc/helpers@0.5.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)(webpack@5.99.7(esbuild@0.25.5)) '@rspack/binding': specifier: 1.2.8 version: 1.2.8 '@rspack/cli': specifier: 1.2.8 - version: 1.2.8(@rspack/core@1.2.8(@swc/helpers@0.5.17))(@types/express@4.17.22)(webpack@5.99.7) + version: 1.2.8(@rspack/core@1.2.8(@swc/helpers@0.5.17))(@types/express@4.17.21)(webpack@5.99.7(esbuild@0.25.5)) '@rspack/core': specifier: 1.2.8 version: 1.2.8(@swc/helpers@0.5.17) @@ -383,6 +383,55 @@ importers: specifier: ^8.15.0 version: 8.31.0(eslint@9.28.0(jiti@1.21.7))(typescript@5.6.3) + apps/secret-service: + dependencies: + '@curatedotfun/shared-db': + specifier: workspace:* + version: link:../../packages/shared-db + '@hono/node-server': + specifier: ^1.14.4 + version: 1.14.4(hono@4.7.11) + drizzle-orm: + specifier: ^0.43.1 + version: 0.43.1(@types/pg@8.11.11)(bun-types@1.2.15)(gel@2.0.2)(pg@8.15.6)(postgres@3.4.7) + hono: + specifier: ^4.7.11 + version: 4.7.11 + near-api-js: + specifier: ^4.0.3 + version: 4.0.4(encoding@0.1.13) + pg: + specifier: ^8.15.6 + version: 8.15.6 + devDependencies: + '@types/eslint': + specifier: ^9 + version: 9.6.1 + '@types/node': + specifier: ^20.11.17 + version: 20.19.0 + '@types/pg': + specifier: ^8.11.11 + version: 8.11.11 + eslint: + specifier: ^9.8.0 + version: 9.28.0(jiti@1.21.7) + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.0(eslint@9.28.0(jiti@1.21.7)) + eslint-plugin-prettier: + specifier: ^5.2.1 + version: 5.4.1(@types/eslint@9.6.1)(eslint-config-prettier@9.1.0(eslint@9.28.0(jiti@1.21.7)))(eslint@9.28.0(jiti@1.21.7))(prettier@3.5.3) + prettier: + specifier: ^3.3.3 + version: 3.5.3 + tsx: + specifier: ^4.7.1 + version: 4.19.3 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + packages/shared-db: dependencies: async-retry: @@ -403,7 +452,7 @@ importers: devDependencies: '@rspack/cli': specifier: 1.2.8 - version: 1.2.8(@rspack/core@1.2.8(@swc/helpers@0.5.17))(@types/express@4.17.21)(webpack@5.99.7(esbuild@0.25.5)) + version: 1.2.8(@rspack/core@1.2.8(@swc/helpers@0.5.17))(@types/express@4.17.22)(webpack@5.99.7) '@rspack/core': specifier: 1.2.8 version: 1.2.8(@swc/helpers@0.5.17) @@ -1110,6 +1159,12 @@ packages: peerDependencies: hono: ^4 + '@hono/node-server@1.14.4': + resolution: {integrity: sha512-DnxpshhYewr2q9ZN8ez/M5mmc3sucr8CT1sIgIy1bkeUXut9XWDkqHoFHRhWIQgkYnKpVRxunyhK7WzpJeJ6qQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@hono/zod-openapi@0.9.10': resolution: {integrity: sha512-v/b/z0qPxDo952gjRyhJ0n9ifbPoIluR2KmXDL20np0hj99+XvakoIHK5/T/3+hUmXlTj1Kn3TiGsSV6hwZesg==} engines: {node: '>=16.0.0'} @@ -1615,6 +1670,10 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@pkgr/core@0.2.7': + resolution: {integrity: sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -2631,6 +2690,9 @@ packages: '@types/node-forge@1.3.11': resolution: {integrity: sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==} + '@types/node@20.19.0': + resolution: {integrity: sha512-hfrc+1tud1xcdVTABC2JiomZJEklMcXYNTVtZLAeqTVWD+qL5jkHKT+1lOtqDdGxt+mB53DTtiz673vfjU8D1Q==} + '@types/node@22.15.29': resolution: {integrity: sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ==} @@ -3741,6 +3803,26 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + eslint-config-prettier@9.1.0: + resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-prettier@5.4.1: + resolution: {integrity: sha512-9dF+KuU/Ilkq27A8idRP7N2DH8iUR6qXcjF3FR2wETY21PZdBrIjwCau8oboyGj9b7etWmTGEeM8e7oOed6ZWg==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + eslint-plugin-react-hooks@5.2.0: resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} engines: {node: '>=10'} @@ -3855,6 +3937,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -4133,6 +4218,10 @@ packages: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} engines: {node: '>=0.10.0'} + hono@4.7.11: + resolution: {integrity: sha512-rv0JMwC0KALbbmwJDEnxvQCeJh+xbS3KEWW5PC9cMJ08Ur9xgatI0HmtgYZfOdOSOeYsp5LO2cOhdI8cLEbDEQ==} + engines: {node: '>=16.9.0'} + hono@4.7.8: resolution: {integrity: sha512-PCibtFdxa7/Ldud9yddl1G81GjYaeMYYTq4ywSaNsYbB1Lug4mwtOMJf2WXykL0pntYwmpRJeOI3NmoDgD+Jxw==} engines: {node: '>=16.9.0'} @@ -5173,6 +5262,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier-linter-helpers@1.0.0: + resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} + engines: {node: '>=6.0.0'} + prettier@3.5.3: resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} engines: {node: '>=14'} @@ -5752,6 +5845,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + synckit@0.11.8: + resolution: {integrity: sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==} + engines: {node: ^14.18.0 || >=16.0.0} + tailwind-merge@3.2.0: resolution: {integrity: sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==} @@ -6565,7 +6662,7 @@ snapshots: '@crosspost/types@0.1.14': dependencies: - hono: 4.7.8 + hono: 4.7.11 zod: 3.25.42 '@crosspost/types@0.2.0': @@ -6886,6 +6983,10 @@ snapshots: dependencies: hono: 4.7.8 + '@hono/node-server@1.14.4(hono@4.7.11)': + dependencies: + hono: 4.7.11 + '@hono/zod-openapi@0.9.10(hono@4.7.8)(zod@3.25.42)': dependencies: '@asteasolutions/zod-to-openapi': 5.5.0(zod@3.25.42) @@ -7078,7 +7179,7 @@ snapshots: - supports-color - utf-8-validate - '@module-federation/enhanced@0.11.4(@rspack/core@1.2.8(@swc/helpers@0.5.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)(webpack@5.99.7)': + '@module-federation/enhanced@0.11.4(@rspack/core@1.2.8(@swc/helpers@0.5.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)(webpack@5.99.7(esbuild@0.25.5))': dependencies: '@module-federation/bridge-react-webpack-plugin': 0.11.4 '@module-federation/cli': 0.11.4(typescript@5.8.3) @@ -7095,7 +7196,7 @@ snapshots: upath: 2.0.1 optionalDependencies: typescript: 5.8.3 - webpack: 5.99.7 + webpack: 5.99.7(esbuild@0.25.5) transitivePeerDependencies: - '@rspack/core' - bufferutil @@ -7134,16 +7235,16 @@ snapshots: - utf-8-validate - vue-tsc - '@module-federation/node@2.6.33(@rspack/core@1.2.8(@swc/helpers@0.5.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)(webpack@5.99.7)': + '@module-federation/node@2.6.33(@rspack/core@1.2.8(@swc/helpers@0.5.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)(webpack@5.99.7(esbuild@0.25.5))': dependencies: - '@module-federation/enhanced': 0.11.4(@rspack/core@1.2.8(@swc/helpers@0.5.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)(webpack@5.99.7) + '@module-federation/enhanced': 0.11.4(@rspack/core@1.2.8(@swc/helpers@0.5.17))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.8.3)(webpack@5.99.7(esbuild@0.25.5)) '@module-federation/runtime': 0.11.4 '@module-federation/sdk': 0.11.4 - '@module-federation/utilities': 3.1.51(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(webpack@5.99.7) + '@module-federation/utilities': 3.1.51(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(webpack@5.99.7(esbuild@0.25.5)) btoa: 1.2.1 encoding: 0.1.13 node-fetch: 2.7.0(encoding@0.1.13) - webpack: 5.99.7 + webpack: 5.99.7(esbuild@0.25.5) optionalDependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -7224,10 +7325,10 @@ snapshots: fs-extra: 9.1.0 resolve: 1.22.8 - '@module-federation/utilities@3.1.51(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(webpack@5.99.7)': + '@module-federation/utilities@3.1.51(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(webpack@5.99.7(esbuild@0.25.5))': dependencies: '@module-federation/sdk': 0.11.4 - webpack: 5.99.7 + webpack: 5.99.7(esbuild@0.25.5) optionalDependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -7740,6 +7841,8 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@pkgr/core@0.2.7': {} + '@polka/url@1.0.0-next.29': {} '@radix-ui/number@1.1.1': {} @@ -8879,6 +8982,10 @@ snapshots: dependencies: '@types/node': 22.15.29 + '@types/node@20.19.0': + dependencies: + undici-types: 6.21.0 + '@types/node@22.15.29': dependencies: undici-types: 6.21.0 @@ -10163,6 +10270,20 @@ snapshots: escape-string-regexp@4.0.0: {} + eslint-config-prettier@9.1.0(eslint@9.28.0(jiti@1.21.7)): + dependencies: + eslint: 9.28.0(jiti@1.21.7) + + eslint-plugin-prettier@5.4.1(@types/eslint@9.6.1)(eslint-config-prettier@9.1.0(eslint@9.28.0(jiti@1.21.7)))(eslint@9.28.0(jiti@1.21.7))(prettier@3.5.3): + dependencies: + eslint: 9.28.0(jiti@1.21.7) + prettier: 3.5.3 + prettier-linter-helpers: 1.0.0 + synckit: 0.11.8 + optionalDependencies: + '@types/eslint': 9.6.1 + eslint-config-prettier: 9.1.0(eslint@9.28.0(jiti@1.21.7)) + eslint-plugin-react-hooks@5.2.0(eslint@9.28.0(jiti@1.21.7)): dependencies: eslint: 9.28.0(jiti@1.21.7) @@ -10342,6 +10463,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-diff@1.3.0: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -10624,6 +10747,8 @@ snapshots: dependencies: parse-passwd: 1.0.0 + hono@4.7.11: {} + hono@4.7.8: {} hpack.js@2.1.6: @@ -11714,6 +11839,10 @@ snapshots: prelude-ls@1.2.1: {} + prettier-linter-helpers@1.0.0: + dependencies: + fast-diff: 1.3.0 + prettier@3.5.3: {} process-nextick-args@2.0.1: {} @@ -12363,6 +12492,10 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + synckit@0.11.8: + dependencies: + '@pkgr/core': 0.2.7 + tailwind-merge@3.2.0: {} tailwindcss-animate@1.0.7(tailwindcss@3.4.17): @@ -12408,7 +12541,6 @@ snapshots: webpack: 5.99.7(esbuild@0.25.5) optionalDependencies: esbuild: 0.25.5 - optional: true terser-webpack-plugin@5.3.14(webpack@5.99.7): dependencies: @@ -12418,6 +12550,7 @@ snapshots: serialize-javascript: 6.0.2 terser: 5.40.0 webpack: 5.99.7 + optional: true terser@5.40.0: dependencies: @@ -12881,6 +13014,7 @@ snapshots: - '@swc/core' - esbuild - uglify-js + optional: true webpack@5.99.7(esbuild@0.25.5): dependencies: @@ -12912,7 +13046,6 @@ snapshots: - '@swc/core' - esbuild - uglify-js - optional: true websocket-driver@0.7.4: dependencies: diff --git a/tsconfig.json b/tsconfig.json index 348eeae5..d59c3f45 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "references": [ { "path": "./apps/api" }, { "path": "./apps/app" }, + { "path": "./apps/secret-service" }, // SHARED PACKAGES { "path": "./packages/types" }, { "path": "./packages/shared-db" },