diff --git a/apps/aelis-backend/.env.example b/apps/aelis-backend/.env.example index 50dc5ea..ffd672e 100644 --- a/apps/aelis-backend/.env.example +++ b/apps/aelis-backend/.env.example @@ -5,7 +5,7 @@ DATABASE_URL=postgresql://user:password@localhost:5432/aris BETTER_AUTH_SECRET= # Encryption key for source credentials at rest (32 bytes, generate with: openssl rand -base64 32) -CREDENTIALS_ENCRYPTION_KEY= +CREDENTIAL_ENCRYPTION_KEY= # Base URL of the backend BETTER_AUTH_URL=http://localhost:3000 diff --git a/apps/aelis-backend/src/location/provider.ts b/apps/aelis-backend/src/location/provider.ts index 7e3bd6d..18e56b9 100644 --- a/apps/aelis-backend/src/location/provider.ts +++ b/apps/aelis-backend/src/location/provider.ts @@ -5,7 +5,11 @@ import type { FeedSourceProvider } from "../session/feed-source-provider.ts" export class LocationSourceProvider implements FeedSourceProvider { readonly sourceId = "aelis.location" - async feedSourceForUser(_userId: string, _config: unknown): Promise { + async feedSourceForUser( + _userId: string, + _config: unknown, + _credentials: unknown, + ): Promise { return new LocationSource() } } diff --git a/apps/aelis-backend/src/server.ts b/apps/aelis-backend/src/server.ts index ccaa5d4..92c50dc 100644 --- a/apps/aelis-backend/src/server.ts +++ b/apps/aelis-backend/src/server.ts @@ -10,6 +10,7 @@ import { createDatabase } from "./db/index.ts" import { registerFeedHttpHandlers } from "./engine/http.ts" import { createFeedEnhancer } from "./enhancement/enhance-feed.ts" import { createLlmClient } from "./enhancement/llm-client.ts" +import { CredentialEncryptor } from "./lib/crypto.ts" import { registerLocationHttpHandlers } from "./location/http.ts" import { LocationSourceProvider } from "./location/provider.ts" import { UserSessionManager } from "./session/index.ts" @@ -34,6 +35,16 @@ function main() { console.warn("[enhancement] OPENROUTER_API_KEY not set — feed enhancement disabled") } + const credentialEncryptionKey = process.env.CREDENTIAL_ENCRYPTION_KEY + const credentialEncryptor = credentialEncryptionKey + ? new CredentialEncryptor(credentialEncryptionKey) + : null + if (!credentialEncryptor) { + console.warn( + "[credentials] CREDENTIAL_ENCRYPTION_KEY not set — per-user credential storage disabled", + ) + } + const sessionManager = new UserSessionManager({ db, providers: [ @@ -49,6 +60,7 @@ function main() { new TflSourceProvider({ apiKey: process.env.TFL_API_KEY! }), ], feedEnhancer, + credentialEncryptor, }) const app = new Hono() diff --git a/apps/aelis-backend/src/session/feed-source-provider.ts b/apps/aelis-backend/src/session/feed-source-provider.ts index 7569a05..f3a587b 100644 --- a/apps/aelis-backend/src/session/feed-source-provider.ts +++ b/apps/aelis-backend/src/session/feed-source-provider.ts @@ -8,5 +8,5 @@ export interface FeedSourceProvider { readonly sourceId: string /** Arktype schema for validating user-provided config. Omit if the source has no config. */ readonly configSchema?: ConfigSchema - feedSourceForUser(userId: string, config: unknown): Promise + feedSourceForUser(userId: string, config: unknown, credentials: unknown): Promise } diff --git a/apps/aelis-backend/src/session/user-session-manager.test.ts b/apps/aelis-backend/src/session/user-session-manager.test.ts index cbe15b2..5d08924 100644 --- a/apps/aelis-backend/src/session/user-session-manager.test.ts +++ b/apps/aelis-backend/src/session/user-session-manager.test.ts @@ -7,6 +7,12 @@ import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test" import type { Database } from "../db/index.ts" import type { FeedSourceProvider } from "./feed-source-provider.ts" +import { CredentialEncryptor } from "../lib/crypto.ts" +import { + CredentialStorageUnavailableError, + InvalidSourceCredentialsError, +} from "../sources/errors.ts" +import { SourceNotFoundError } from "../sources/errors.ts" import { UserSessionManager } from "./user-session-manager.ts" /** @@ -38,6 +44,13 @@ function getEnabledSourceIds(userId: string): string[] { */ let mockFindResult: unknown | undefined +/** + * Spy for `updateCredentials` calls. Tests can inspect calls via + * `mockUpdateCredentialsCalls` or override behavior. + */ +const mockUpdateCredentialsCalls: Array<{ sourceId: string; credentials: Buffer }> = [] +let mockUpdateCredentialsError: Error | null = null + // Mock the sources module so UserSessionManager's DB query returns controlled data. mock.module("../sources/user-sources.ts", () => ({ sources: (_db: Database, userId: string) => ({ @@ -68,6 +81,12 @@ mock.module("../sources/user-sources.ts", () => ({ updatedAt: now, } }, + async updateCredentials(sourceId: string, credentials: Buffer) { + if (mockUpdateCredentialsError) { + throw mockUpdateCredentialsError + } + mockUpdateCredentialsCalls.push({ sourceId, credentials }) + }, }), })) @@ -93,8 +112,11 @@ function createStubSource(id: string, items: FeedItem[] = []): FeedSource { function createStubProvider( sourceId: string, - factory: (userId: string, config: Record) => Promise = async () => - createStubSource(sourceId), + factory: ( + userId: string, + config: Record, + credentials: unknown, + ) => Promise = async () => createStubSource(sourceId), ): FeedSourceProvider { return { sourceId, feedSourceForUser: factory } } @@ -116,6 +138,8 @@ const weatherProvider: FeedSourceProvider = { beforeEach(() => { enabledByUser.clear() mockFindResult = undefined + mockUpdateCredentialsCalls.length = 0 + mockUpdateCredentialsError = null }) describe("UserSessionManager", () => { @@ -681,3 +705,122 @@ describe("UserSessionManager.replaceProvider", () => { expect(feedAfter.items[0]!.data.version).toBe(1) }) }) + +const TEST_ENCRYPTION_KEY = "/bv1nbzC4ozZkT/pcv5oQfl+JAMuMZDUSVDesG2dur8=" +const testEncryptor = new CredentialEncryptor(TEST_ENCRYPTION_KEY) + +describe("UserSessionManager.updateSourceCredentials", () => { + test("encrypts and persists credentials", async () => { + setEnabledSources(["test"]) + const provider = createStubProvider("test") + const manager = new UserSessionManager({ + db: fakeDb, + providers: [provider], + credentialEncryptor: testEncryptor, + }) + + await manager.updateSourceCredentials("user-1", "test", { token: "secret-123" }) + + expect(mockUpdateCredentialsCalls).toHaveLength(1) + expect(mockUpdateCredentialsCalls[0]!.sourceId).toBe("test") + + // Verify the persisted buffer decrypts to the original credentials + const decrypted = JSON.parse(testEncryptor.decrypt(mockUpdateCredentialsCalls[0]!.credentials)) + expect(decrypted).toEqual({ token: "secret-123" }) + }) + + test("throws CredentialStorageUnavailableError when encryptor is not configured", async () => { + setEnabledSources(["test"]) + const provider = createStubProvider("test") + const manager = new UserSessionManager({ + db: fakeDb, + providers: [provider], + // no credentialEncryptor + }) + + await expect( + manager.updateSourceCredentials("user-1", "test", { token: "x" }), + ).rejects.toBeInstanceOf(CredentialStorageUnavailableError) + }) + + test("throws SourceNotFoundError for unknown source", async () => { + setEnabledSources([]) + const manager = new UserSessionManager({ + db: fakeDb, + providers: [], + credentialEncryptor: testEncryptor, + }) + + await expect( + manager.updateSourceCredentials("user-1", "unknown", { token: "x" }), + ).rejects.toBeInstanceOf(SourceNotFoundError) + }) + + test("propagates InvalidSourceCredentialsError from provider", async () => { + setEnabledSources(["test"]) + let callCount = 0 + const provider: FeedSourceProvider = { + sourceId: "test", + async feedSourceForUser(_userId: string, _config: unknown, _credentials: unknown) { + callCount++ + // Succeed on first call (session creation), throw on refresh + if (callCount > 1) { + throw new InvalidSourceCredentialsError("test", "bad credentials") + } + return createStubSource("test") + }, + } + const manager = new UserSessionManager({ + db: fakeDb, + providers: [provider], + credentialEncryptor: testEncryptor, + }) + + // Create a session first so the refresh path is exercised + await manager.getOrCreate("user-1") + + await expect( + manager.updateSourceCredentials("user-1", "test", { token: "bad" }), + ).rejects.toBeInstanceOf(InvalidSourceCredentialsError) + + // Credentials should still have been persisted before the provider threw + expect(mockUpdateCredentialsCalls).toHaveLength(1) + }) + + test("refreshes source in active session after credential update", async () => { + setEnabledSources(["test"]) + let receivedCredentials: unknown = null + const provider = createStubProvider("test", async (_userId, _config, credentials) => { + receivedCredentials = credentials + return createStubSource("test") + }) + const manager = new UserSessionManager({ + db: fakeDb, + providers: [provider], + credentialEncryptor: testEncryptor, + }) + + await manager.getOrCreate("user-1") + await manager.updateSourceCredentials("user-1", "test", { token: "refreshed" }) + + expect(receivedCredentials).toEqual({ token: "refreshed" }) + }) + + test("persists credentials without session refresh when no active session", async () => { + setEnabledSources(["test"]) + const factory = mock(async () => createStubSource("test")) + const provider: FeedSourceProvider = { sourceId: "test", feedSourceForUser: factory } + const manager = new UserSessionManager({ + db: fakeDb, + providers: [provider], + credentialEncryptor: testEncryptor, + }) + + // No session created — just update credentials + await manager.updateSourceCredentials("user-1", "test", { token: "stored" }) + + expect(mockUpdateCredentialsCalls).toHaveLength(1) + // feedSourceForUser should not have been called (no session to refresh) + expect(factory).not.toHaveBeenCalled() + }) +}) diff --git a/apps/aelis-backend/src/session/user-session-manager.ts b/apps/aelis-backend/src/session/user-session-manager.ts index 1dbaca5..d9dcc86 100644 --- a/apps/aelis-backend/src/session/user-session-manager.ts +++ b/apps/aelis-backend/src/session/user-session-manager.ts @@ -5,9 +5,14 @@ import merge from "lodash.merge" import type { Database } from "../db/index.ts" import type { FeedEnhancer } from "../enhancement/enhance-feed.ts" +import type { CredentialEncryptor } from "../lib/crypto.ts" import type { FeedSourceProvider } from "./feed-source-provider.ts" -import { InvalidSourceConfigError, SourceNotFoundError } from "../sources/errors.ts" +import { + CredentialStorageUnavailableError, + InvalidSourceConfigError, + SourceNotFoundError, +} from "../sources/errors.ts" import { sources } from "../sources/user-sources.ts" import { UserSession } from "./user-session.ts" @@ -15,6 +20,7 @@ export interface UserSessionManagerConfig { db: Database providers: FeedSourceProvider[] feedEnhancer?: FeedEnhancer | null + credentialEncryptor?: CredentialEncryptor | null } export class UserSessionManager { @@ -23,7 +29,7 @@ export class UserSessionManager { private readonly db: Database private readonly providers = new Map() private readonly feedEnhancer: FeedEnhancer | null - private readonly db: Database + private readonly encryptor: CredentialEncryptor | null constructor(config: UserSessionManagerConfig) { this.db = config.db @@ -31,7 +37,7 @@ export class UserSessionManager { this.providers.set(provider.sourceId, provider) } this.feedEnhancer = config.feedEnhancer ?? null - this.db = config.db + this.encryptor = config.credentialEncryptor ?? null } getProvider(sourceId: string): FeedSourceProvider | undefined { @@ -120,14 +126,15 @@ export class UserSessionManager { return } - // When config is provided, fetch existing to deep-merge before validating. + // Fetch the existing row for config merging and credential access. // NOTE: find + updateConfig is not atomic. A concurrent update could // read stale config. Use SELECT FOR UPDATE or atomic jsonb merge if // this becomes a problem. + const existingRow = await sources(this.db, userId).find(sourceId) + let mergedConfig: Record | undefined if (update.config !== undefined && provider.configSchema) { - const existing = await sources(this.db, userId).find(sourceId) - const existingConfig = (existing?.config ?? {}) as Record + const existingConfig = (existingRow?.config ?? {}) as Record mergedConfig = merge({}, existingConfig, update.config) const validated = provider.configSchema(mergedConfig) @@ -149,7 +156,10 @@ export class UserSessionManager { if (update.enabled === false) { session.removeSource(sourceId) } else { - const source = await provider.feedSourceForUser(userId, mergedConfig ?? {}) + const credentials = existingRow?.credentials + ? this.decryptCredentials(existingRow.credentials) + : null + const source = await provider.feedSourceForUser(userId, mergedConfig ?? {}, credentials) session.replaceSource(sourceId, source) } } @@ -182,6 +192,11 @@ export class UserSessionManager { } const config = data.config ?? {} + + // Fetch existing row before upsert to capture credentials for session refresh. + // For new rows this will be undefined — credentials will be null. + const existingRow = await sources(this.db, userId).find(sourceId) + await sources(this.db, userId).upsertConfig(sourceId, { enabled: data.enabled, config, @@ -192,7 +207,10 @@ export class UserSessionManager { if (!data.enabled) { session.removeSource(sourceId) } else { - const source = await provider.feedSourceForUser(userId, config) + const credentials = existingRow?.credentials + ? this.decryptCredentials(existingRow.credentials) + : null + const source = await provider.feedSourceForUser(userId, config, credentials) if (session.hasSource(sourceId)) { session.replaceSource(sourceId, source) } else { @@ -202,6 +220,44 @@ export class UserSessionManager { } } + /** + * Validates, encrypts, and persists per-user credentials for a source, + * then refreshes the active session. + * + * @throws {SourceNotFoundError} if the source row doesn't exist or has no registered provider + * @throws {CredentialStorageUnavailableError} if no CredentialEncryptor is configured + */ + async updateSourceCredentials( + userId: string, + sourceId: string, + credentials: unknown, + ): Promise { + const provider = this.providers.get(sourceId) + if (!provider) { + throw new SourceNotFoundError(sourceId, userId) + } + + if (!this.encryptor) { + throw new CredentialStorageUnavailableError() + } + + const encrypted = this.encryptor.encrypt(JSON.stringify(credentials)) + await sources(this.db, userId).updateCredentials(sourceId, encrypted) + + // Refresh the source in the active session. + // If feedSourceForUser throws (e.g. provider rejects the credentials), + // the DB already has the new credentials but the session keeps the old + // source. The next session creation will pick up the persisted credentials. + const session = this.sessions.get(userId) + if (session && session.hasSource(sourceId)) { + const row = await sources(this.db, userId).find(sourceId) + if (row?.enabled) { + const source = await provider.feedSourceForUser(userId, row.config ?? {}, credentials) + session.replaceSource(sourceId, source) + } + } + } + /** * Replaces a provider and updates all active sessions. * The new provider must have the same sourceId as an existing one. @@ -254,7 +310,12 @@ export class UserSessionManager { const row = await sources(this.db, session.userId).find(provider.sourceId) if (!row?.enabled) return - const newSource = await provider.feedSourceForUser(session.userId, row.config ?? {}) + const credentials = row.credentials ? this.decryptCredentials(row.credentials) : null + const newSource = await provider.feedSourceForUser( + session.userId, + row.config ?? {}, + credentials, + ) session.replaceSource(provider.sourceId, newSource) } catch (err) { console.error( @@ -271,7 +332,8 @@ export class UserSessionManager { for (const row of enabledRows) { const provider = this.providers.get(row.sourceId) if (provider) { - promises.push(provider.feedSourceForUser(userId, row.config ?? {})) + const credentials = row.credentials ? this.decryptCredentials(row.credentials) : null + promises.push(provider.feedSourceForUser(userId, row.config ?? {}, credentials)) } } @@ -302,4 +364,19 @@ export class UserSessionManager { return new UserSession(userId, feedSources, this.feedEnhancer) } + + /** + * Decrypts a credentials buffer from the DB, returning parsed JSON or null. + * Returns null (with a warning) if decryption or parsing fails — e.g. due to + * key rotation, data corruption, or malformed JSON. + */ + private decryptCredentials(credentials: Buffer): unknown { + if (!this.encryptor) return null + try { + return JSON.parse(this.encryptor.decrypt(credentials)) + } catch (err) { + console.warn("[UserSessionManager] Failed to decrypt credentials:", err) + return null + } + } } diff --git a/apps/aelis-backend/src/sources/errors.ts b/apps/aelis-backend/src/sources/errors.ts index 29f853d..5121f41 100644 --- a/apps/aelis-backend/src/sources/errors.ts +++ b/apps/aelis-backend/src/sources/errors.ts @@ -24,3 +24,26 @@ export class InvalidSourceConfigError extends Error { this.sourceId = sourceId } } + +/** + * Thrown by providers when credentials fail validation. + */ +export class InvalidSourceCredentialsError extends Error { + readonly sourceId: string + + constructor(sourceId: string, summary: string) { + super(summary) + this.name = "InvalidSourceCredentialsError" + this.sourceId = sourceId + } +} + +/** + * Thrown when credential storage is not configured (missing encryption key). + */ +export class CredentialStorageUnavailableError extends Error { + constructor() { + super("Credential storage is not configured") + this.name = "CredentialStorageUnavailableError" + } +} diff --git a/apps/aelis-backend/src/sources/http.test.ts b/apps/aelis-backend/src/sources/http.test.ts index 3bd92c9..95af829 100644 --- a/apps/aelis-backend/src/sources/http.test.ts +++ b/apps/aelis-backend/src/sources/http.test.ts @@ -7,10 +7,11 @@ import type { Database } from "../db/index.ts" import type { ConfigSchema, FeedSourceProvider } from "../session/feed-source-provider.ts" import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts" +import { CredentialEncryptor } from "../lib/crypto.ts" import { UserSessionManager } from "../session/user-session-manager.ts" import { tflConfig } from "../tfl/provider.ts" import { weatherConfig } from "../weather/provider.ts" -import { SourceNotFoundError } from "./errors.ts" +import { InvalidSourceCredentialsError, SourceNotFoundError } from "./errors.ts" import { registerSourcesHttpHandlers } from "./http.ts" // --------------------------------------------------------------------------- @@ -39,7 +40,7 @@ function createStubProvider(sourceId: string, configSchema?: ConfigSchema): Feed return { sourceId, configSchema, - async feedSourceForUser() { + async feedSourceForUser(_userId: string, _config: unknown, _credentials: unknown) { return createStubSource(sourceId) }, } @@ -105,6 +106,12 @@ function createInMemoryStore() { }) } }, + async updateCredentials(sourceId: string, _credentials: Buffer) { + const existing = rows.get(key(userId, sourceId)) + if (!existing) { + throw new SourceNotFoundError(sourceId, userId) + } + }, } }, } @@ -142,6 +149,30 @@ function get(app: Hono, sourceId: string) { return app.request(`/api/sources/${sourceId}`, { method: "GET" }) } +const TEST_ENCRYPTION_KEY = "/bv1nbzC4ozZkT/pcv5oQfl+JAMuMZDUSVDesG2dur8=" + +function createAppWithEncryptor(providers: FeedSourceProvider[], userId?: string) { + const sessionManager = new UserSessionManager({ + providers, + db: fakeDb, + credentialEncryptor: new CredentialEncryptor(TEST_ENCRYPTION_KEY), + }) + const app = new Hono() + registerSourcesHttpHandlers(app, { + sessionManager, + authSessionMiddleware: mockAuthSessionMiddleware(userId), + }) + return { app, sessionManager } +} + +function putCredentials(app: Hono, sourceId: string, body: unknown) { + return app.request(`/api/sources/${sourceId}/credentials`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) +} + function put(app: Hono, sourceId: string, body: unknown) { return app.request(`/api/sources/${sourceId}`, { method: "PUT", @@ -708,3 +739,86 @@ describe("PUT /api/sources/:sourceId", () => { expect(res.status).toBe(204) }) }) + +describe("PUT /api/sources/:sourceId/credentials", () => { + test("returns 401 without auth", async () => { + activeStore = createInMemoryStore() + const { app } = createAppWithEncryptor([createStubProvider("aelis.location")]) + + const res = await putCredentials(app, "aelis.location", { token: "x" }) + + expect(res.status).toBe(401) + }) + + test("returns 404 for unknown source", async () => { + activeStore = createInMemoryStore() + const { app } = createAppWithEncryptor([createStubProvider("aelis.location")], MOCK_USER_ID) + + const res = await putCredentials(app, "unknown.source", { token: "x" }) + + expect(res.status).toBe(404) + }) + + test("returns 400 for invalid JSON", async () => { + activeStore = createInMemoryStore() + activeStore.seed(MOCK_USER_ID, "aelis.location") + const { app } = createAppWithEncryptor([createStubProvider("aelis.location")], MOCK_USER_ID) + + const res = await app.request("/api/sources/aelis.location/credentials", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: "not-json", + }) + + expect(res.status).toBe(400) + const body = (await res.json()) as { error: string } + expect(body.error).toBe("Invalid JSON") + }) + + test("returns 204 and persists credentials", async () => { + activeStore = createInMemoryStore() + activeStore.seed(MOCK_USER_ID, "aelis.location") + const { app } = createAppWithEncryptor([createStubProvider("aelis.location")], MOCK_USER_ID) + + const res = await putCredentials(app, "aelis.location", { token: "secret" }) + + expect(res.status).toBe(204) + }) + + test("returns 400 when provider throws InvalidSourceCredentialsError", async () => { + activeStore = createInMemoryStore() + activeStore.seed(MOCK_USER_ID, "test.creds") + let callCount = 0 + const provider: FeedSourceProvider = { + sourceId: "test.creds", + async feedSourceForUser(_userId: string, _config: unknown, _credentials: unknown) { + callCount++ + if (callCount > 1) { + throw new InvalidSourceCredentialsError("test.creds", "invalid token format") + } + return createStubSource("test.creds") + }, + } + const { app, sessionManager } = createAppWithEncryptor([provider], MOCK_USER_ID) + + await sessionManager.getOrCreate(MOCK_USER_ID) + + const res = await putCredentials(app, "test.creds", { token: "bad" }) + + expect(res.status).toBe(400) + const body = (await res.json()) as { error: string } + expect(body.error).toContain("invalid token format") + }) + + test("returns 503 when credential encryption is not configured", async () => { + activeStore = createInMemoryStore() + activeStore.seed(MOCK_USER_ID, "aelis.location") + const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID) + + const res = await putCredentials(app, "aelis.location", { token: "x" }) + + expect(res.status).toBe(503) + const body = (await res.json()) as { error: string } + expect(body.error).toContain("not configured") + }) +}) diff --git a/apps/aelis-backend/src/sources/http.ts b/apps/aelis-backend/src/sources/http.ts index 23d23f3..76e4c61 100644 --- a/apps/aelis-backend/src/sources/http.ts +++ b/apps/aelis-backend/src/sources/http.ts @@ -6,7 +6,12 @@ import { createMiddleware } from "hono/factory" import type { AuthSessionMiddleware } from "../auth/session-middleware.ts" import type { UserSessionManager } from "../session/index.ts" -import { InvalidSourceConfigError, SourceNotFoundError } from "./errors.ts" +import { + CredentialStorageUnavailableError, + InvalidSourceConfigError, + InvalidSourceCredentialsError, + SourceNotFoundError, +} from "./errors.ts" type Env = { Variables: { @@ -48,6 +53,12 @@ export function registerSourcesHttpHandlers( app.get("/api/sources/:sourceId", inject, authSessionMiddleware, handleGetSource) app.patch("/api/sources/:sourceId", inject, authSessionMiddleware, handleUpdateSource) app.put("/api/sources/:sourceId", inject, authSessionMiddleware, handleReplaceSource) + app.put( + "/api/sources/:sourceId/credentials", + inject, + authSessionMiddleware, + handleUpdateCredentials, + ) } async function handleGetSource(c: Context) { @@ -171,3 +182,43 @@ async function handleReplaceSource(c: Context) { return c.body(null, 204) } + +async function handleUpdateCredentials(c: Context) { + const sourceId = c.req.param("sourceId") + if (!sourceId) { + return c.body(null, 404) + } + + const sessionManager = c.get("sessionManager") + + const provider = sessionManager.getProvider(sourceId) + if (!provider) { + return c.json({ error: `Source "${sourceId}" not found` }, 404) + } + + let body: unknown + try { + body = await c.req.json() + } catch { + return c.json({ error: "Invalid JSON" }, 400) + } + + const user = c.get("user")! + + try { + await sessionManager.updateSourceCredentials(user.id, sourceId, body) + } catch (err) { + if (err instanceof SourceNotFoundError) { + return c.json({ error: err.message }, 404) + } + if (err instanceof InvalidSourceCredentialsError) { + return c.json({ error: err.message }, 400) + } + if (err instanceof CredentialStorageUnavailableError) { + return c.json({ error: err.message }, 503) + } + throw err + } + + return c.body(null, 204) +} diff --git a/apps/aelis-backend/src/tfl/provider.ts b/apps/aelis-backend/src/tfl/provider.ts index e57fd67..2fe4a6c 100644 --- a/apps/aelis-backend/src/tfl/provider.ts +++ b/apps/aelis-backend/src/tfl/provider.ts @@ -23,7 +23,11 @@ export class TflSourceProvider implements FeedSourceProvider { this.client = "client" in options ? options.client : undefined } - async feedSourceForUser(_userId: string, config: unknown): Promise { + async feedSourceForUser( + _userId: string, + config: unknown, + _credentials: unknown, + ): Promise { const parsed = tflConfig(config) if (parsed instanceof type.errors) { throw new Error(`Invalid TFL config: ${parsed.summary}`) diff --git a/apps/aelis-backend/src/weather/provider.ts b/apps/aelis-backend/src/weather/provider.ts index 1ce11a3..861ad4b 100644 --- a/apps/aelis-backend/src/weather/provider.ts +++ b/apps/aelis-backend/src/weather/provider.ts @@ -26,7 +26,11 @@ export class WeatherSourceProvider implements FeedSourceProvider { this.client = options.client } - async feedSourceForUser(_userId: string, config: unknown): Promise { + async feedSourceForUser( + _userId: string, + config: unknown, + _credentials: unknown, + ): Promise { const parsed = weatherConfig(config) if (parsed instanceof type.errors) { throw new Error(`Invalid weather config: ${parsed.summary}`)