diff --git a/apps/aelis-backend/package.json b/apps/aelis-backend/package.json index e222a16..2f7b96b 100644 --- a/apps/aelis-backend/package.json +++ b/apps/aelis-backend/package.json @@ -25,9 +25,11 @@ "arktype": "^2.1.29", "better-auth": "^1", "drizzle-orm": "^0.45.1", - "hono": "^4" + "hono": "^4", + "lodash.merge": "^4.6.2" }, "devDependencies": { + "@types/lodash.merge": "^4.6.9", "drizzle-kit": "^0.31.9" } } diff --git a/apps/aelis-backend/src/server.ts b/apps/aelis-backend/src/server.ts index 10c0cc5..22fa66b 100644 --- a/apps/aelis-backend/src/server.ts +++ b/apps/aelis-backend/src/server.ts @@ -12,6 +12,7 @@ import { createLlmClient } from "./enhancement/llm-client.ts" import { registerLocationHttpHandlers } from "./location/http.ts" import { LocationSourceProvider } from "./location/provider.ts" import { UserSessionManager } from "./session/index.ts" +import { registerSourcesHttpHandlers } from "./sources/http.ts" import { WeatherSourceProvider } from "./weather/provider.ts" function main() { @@ -61,6 +62,7 @@ function main() { authSessionMiddleware, }) registerLocationHttpHandlers(app, { sessionManager, authSessionMiddleware }) + registerSourcesHttpHandlers(app, { sessionManager, authSessionMiddleware }) registerAdminHttpHandlers(app, { sessionManager, adminMiddleware, db }) process.on("SIGTERM", async () => { diff --git a/apps/aelis-backend/src/session/feed-source-provider.ts b/apps/aelis-backend/src/session/feed-source-provider.ts index 3072a18..7569a05 100644 --- a/apps/aelis-backend/src/session/feed-source-provider.ts +++ b/apps/aelis-backend/src/session/feed-source-provider.ts @@ -1,7 +1,12 @@ import type { FeedSource } from "@aelis/core" +import type { type } from "arktype" + +export type ConfigSchema = ReturnType export interface FeedSourceProvider { /** The source ID this provider is responsible for (e.g., "aelis.location"). */ 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 } diff --git a/apps/aelis-backend/src/session/user-session-manager.ts b/apps/aelis-backend/src/session/user-session-manager.ts index 146cc31..1d8e6a8 100644 --- a/apps/aelis-backend/src/session/user-session-manager.ts +++ b/apps/aelis-backend/src/session/user-session-manager.ts @@ -1,10 +1,14 @@ import type { FeedSource } from "@aelis/core" +import { type } from "arktype" +import merge from "lodash.merge" + import type { Database } from "../db/index.ts" import type { FeedEnhancer } from "../enhancement/enhance-feed.ts" +import { InvalidSourceConfigError, SourceNotFoundError } from "../sources/errors.ts" +import { sources } from "../sources/user-sources.ts" import type { FeedSourceProvider } from "./feed-source-provider.ts" -import { sources } from "../sources/user-sources.ts" import { UserSession } from "./user-session.ts" export interface UserSessionManagerConfig { @@ -19,6 +23,7 @@ export class UserSessionManager { private readonly db: Database private readonly providers = new Map() private readonly feedEnhancer: FeedEnhancer | null + private readonly db: Database constructor(config: UserSessionManagerConfig) { this.db = config.db @@ -26,6 +31,7 @@ export class UserSessionManager { this.providers.set(provider.sourceId, provider) } this.feedEnhancer = config.feedEnhancer ?? null + this.db = config.db } getProvider(sourceId: string): FeedSourceProvider | undefined { @@ -66,6 +72,70 @@ export class UserSessionManager { this.pending.delete(userId) } + /** + * Merges, validates, and persists a user's source config and/or enabled + * state, then invalidates the cached session. + * + * @throws {SourceNotFoundError} if the source row doesn't exist + * @throws {InvalidSourceConfigError} if the merged config fails schema validation + */ + async updateSourceConfig( + userId: string, + sourceId: string, + update: { enabled?: boolean; config?: unknown }, + ): Promise { + const provider = this.providers.get(sourceId) + if (!provider) { + throw new SourceNotFoundError(sourceId, userId) + } + + // Nothing to update + if (update.enabled === undefined && update.config === undefined) { + // Still validate existence — updateConfig would throw, but + // we can avoid the DB write entirely. + if (!(await sources(this.db, userId).find(sourceId))) { + throw new SourceNotFoundError(sourceId, userId) + } + return + } + + // When config is provided, fetch existing to deep-merge before validating. + // 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. + let mergedConfig: Record | undefined + if (update.config !== undefined) { + const existing = await sources(this.db, userId).find(sourceId) + const existingConfig = (existing?.config ?? {}) as Record + mergedConfig = merge({}, existingConfig, update.config) + + if (provider.configSchema) { + const validated = provider.configSchema(mergedConfig) + if (validated instanceof type.errors) { + throw new InvalidSourceConfigError(sourceId, validated.summary) + } + } + } + + // Throws SourceNotFoundError if the row doesn't exist + await sources(this.db, userId).updateConfig(sourceId, { + enabled: update.enabled, + config: mergedConfig, + }) + + // Refresh the specific source in the active session instead of + // destroying the entire session. + const session = this.sessions.get(userId) + if (session) { + if (update.enabled === false) { + session.removeSource(sourceId) + } else { + const source = await provider.feedSourceForUser(userId, mergedConfig ?? {}) + session.replaceSource(sourceId, source) + } + } + } + /** * Replaces a provider and updates all active sessions. * The new provider must have the same sourceId as an existing one. diff --git a/apps/aelis-backend/src/sources/errors.ts b/apps/aelis-backend/src/sources/errors.ts index 8ee845f..29f853d 100644 --- a/apps/aelis-backend/src/sources/errors.ts +++ b/apps/aelis-backend/src/sources/errors.ts @@ -12,3 +12,15 @@ export class SourceNotFoundError extends Error { this.userId = userId } } + +/** + * Thrown when a source config update fails schema validation. + */ +export class InvalidSourceConfigError extends Error { + readonly sourceId: string + + constructor(sourceId: string, summary: string) { + super(summary) + this.sourceId = sourceId + } +} diff --git a/apps/aelis-backend/src/sources/http.test.ts b/apps/aelis-backend/src/sources/http.test.ts new file mode 100644 index 0000000..08f9078 --- /dev/null +++ b/apps/aelis-backend/src/sources/http.test.ts @@ -0,0 +1,344 @@ +import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core" + +import { describe, expect, mock, spyOn, test } from "bun:test" +import { Hono } from "hono" + +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 { 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 { registerSourcesHttpHandlers } from "./http.ts" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createStubSource(id: string): FeedSource { + return { + id, + async listActions(): Promise> { + return {} + }, + async executeAction(): Promise { + return undefined + }, + async fetchContext(): Promise { + return null + }, + async fetchItems(): Promise { + return [] + }, + } +} + +function createStubProvider(sourceId: string, configSchema?: ConfigSchema): FeedSourceProvider { + return { + sourceId, + configSchema, + async feedSourceForUser() { + return createStubSource(sourceId) + }, + } +} + +const MOCK_USER_ID = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn" + +type SourceRow = { + userId: string + sourceId: string + enabled: boolean + config: Record +} + +function createInMemoryStore() { + const rows = new Map() + + function key(userId: string, sourceId: string) { + return `${userId}:${sourceId}` + } + + return { + rows, + seed(userId: string, sourceId: string, data: Partial = {}) { + rows.set(key(userId, sourceId), { + userId, + sourceId, + enabled: data.enabled ?? true, + config: data.config ?? {}, + }) + }, + forUser(userId: string) { + return { + async enabled() { + return [...rows.values()].filter((r) => r.userId === userId && r.enabled) + }, + async find(sourceId: string) { + return rows.get(key(userId, sourceId)) + }, + async updateConfig(sourceId: string, update: { enabled?: boolean; config?: unknown }) { + const existing = rows.get(key(userId, sourceId)) + if (!existing) { + throw new SourceNotFoundError(sourceId, userId) + } + if (update.enabled !== undefined) { + existing.enabled = update.enabled + } + if (update.config !== undefined) { + existing.config = update.config as Record + } + }, + } + }, + } +} + +let activeStore: ReturnType + +mock.module("../sources/user-sources.ts", () => ({ + sources(_db: unknown, userId: string) { + return activeStore.forUser(userId) + }, +})) + +const fakeDb = {} as Database + +function createApp(providers: FeedSourceProvider[], userId?: string) { + const sessionManager = new UserSessionManager({ providers, db: fakeDb }) + const app = new Hono() + registerSourcesHttpHandlers(app, { + sessionManager, + authSessionMiddleware: mockAuthSessionMiddleware(userId), + }) + return { app, sessionManager } +} + +function patch(app: Hono, sourceId: string, body: unknown) { + return app.request(`/api/sources/${sourceId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("PATCH /api/sources/:sourceId", () => { + test("returns 401 without auth", async () => { + activeStore = createInMemoryStore() + const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)]) + + const res = await patch(app, "aelis.weather", { enabled: true }) + + expect(res.status).toBe(401) + }) + + test("returns 404 for unknown source", async () => { + activeStore = createInMemoryStore() + const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID) + + const res = await patch(app, "unknown.source", { enabled: true }) + + expect(res.status).toBe(404) + const body = (await res.json()) as { error: string } + expect(body.error).toContain("not found") + }) + + test("returns 404 when user has no existing row for source", async () => { + activeStore = createInMemoryStore() + const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID) + + const res = await patch(app, "aelis.weather", { enabled: true }) + + expect(res.status).toBe(404) + const body = (await res.json()) as { error: string } + expect(body.error).toContain("not found") + }) + + test("returns 204 when body is empty object (no-op) on existing source", async () => { + activeStore = createInMemoryStore() + activeStore.seed(MOCK_USER_ID, "aelis.weather") + const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID) + + const res = await patch(app, "aelis.weather", {}) + + expect(res.status).toBe(204) + }) + + test("returns 404 when body is empty object on nonexistent user source", async () => { + activeStore = createInMemoryStore() + const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID) + + const res = await patch(app, "aelis.weather", {}) + + expect(res.status).toBe(404) + }) + + test("returns 400 for invalid JSON body", async () => { + activeStore = createInMemoryStore() + activeStore.seed(MOCK_USER_ID, "aelis.weather") + const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID) + + const res = await app.request("/api/sources/aelis.weather", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: "not json", + }) + + expect(res.status).toBe(400) + const body = (await res.json()) as { error: string } + expect(body.error).toContain("Invalid JSON") + }) + + test("returns 400 when weather config fails validation", async () => { + activeStore = createInMemoryStore() + activeStore.seed(MOCK_USER_ID, "aelis.weather") + const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID) + + const res = await patch(app, "aelis.weather", { + config: { units: "invalid" }, + }) + + expect(res.status).toBe(400) + }) + + test("returns 204 and updates enabled", async () => { + activeStore = createInMemoryStore() + activeStore.seed(MOCK_USER_ID, "aelis.weather", { + enabled: true, + config: { units: "metric" }, + }) + const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID) + + const res = await patch(app, "aelis.weather", { enabled: false }) + + expect(res.status).toBe(204) + const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.weather`) + expect(row!.enabled).toBe(false) + expect(row!.config).toEqual({ units: "metric" }) + }) + + test("returns 204 and updates config", async () => { + activeStore = createInMemoryStore() + activeStore.seed(MOCK_USER_ID, "aelis.weather", { + config: { units: "metric" }, + }) + const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID) + + const res = await patch(app, "aelis.weather", { + config: { units: "imperial" }, + }) + + expect(res.status).toBe(204) + const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.weather`) + expect(row!.config).toEqual({ units: "imperial" }) + }) + + test("preserves config when only updating enabled", async () => { + activeStore = createInMemoryStore() + activeStore.seed(MOCK_USER_ID, "aelis.tfl", { + enabled: true, + config: { lines: ["bakerloo"] }, + }) + const { app } = createApp([createStubProvider("aelis.tfl", tflConfig)], MOCK_USER_ID) + + const res = await patch(app, "aelis.tfl", { enabled: false }) + + expect(res.status).toBe(204) + const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.tfl`) + expect(row!.enabled).toBe(false) + expect(row!.config).toEqual({ lines: ["bakerloo"] }) + }) + + test("deep-merges config on update", async () => { + activeStore = createInMemoryStore() + activeStore.seed(MOCK_USER_ID, "aelis.weather", { + config: { units: "metric", hourlyLimit: 12 }, + }) + const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID) + + const res = await patch(app, "aelis.weather", { + config: { dailyLimit: 5 }, + }) + + expect(res.status).toBe(204) + const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.weather`) + expect(row!.config).toEqual({ + units: "metric", + hourlyLimit: 12, + dailyLimit: 5, + }) + }) + + test("refreshes source in active session after config update", async () => { + activeStore = createInMemoryStore() + activeStore.seed(MOCK_USER_ID, "aelis.weather", { + config: { units: "metric" }, + }) + const { app, sessionManager } = createApp( + [createStubProvider("aelis.weather", weatherConfig)], + MOCK_USER_ID, + ) + + const session = await sessionManager.getOrCreate(MOCK_USER_ID) + const replaceSpy = spyOn(session, "replaceSource") + + const res = await patch(app, "aelis.weather", { + config: { units: "imperial" }, + }) + + expect(res.status).toBe(204) + expect(replaceSpy).toHaveBeenCalled() + replaceSpy.mockRestore() + }) + + test("removes source from session when disabled", async () => { + activeStore = createInMemoryStore() + activeStore.seed(MOCK_USER_ID, "aelis.weather", { + enabled: true, + config: { units: "metric" }, + }) + const { app, sessionManager } = createApp( + [createStubProvider("aelis.weather", weatherConfig)], + MOCK_USER_ID, + ) + + const session = await sessionManager.getOrCreate(MOCK_USER_ID) + const removeSpy = spyOn(session, "removeSource") + + const res = await patch(app, "aelis.weather", { enabled: false }) + + expect(res.status).toBe(204) + expect(removeSpy).toHaveBeenCalledWith("aelis.weather") + removeSpy.mockRestore() + }) + + test("accepts location source with arbitrary config (no schema)", async () => { + activeStore = createInMemoryStore() + activeStore.seed(MOCK_USER_ID, "aelis.location") + const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID) + + const res = await patch(app, "aelis.location", { + config: { something: "value" }, + }) + + expect(res.status).toBe(204) + }) + + test("updates enabled on location source", async () => { + activeStore = createInMemoryStore() + activeStore.seed(MOCK_USER_ID, "aelis.location", { enabled: true }) + const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID) + + const res = await patch(app, "aelis.location", { enabled: false }) + + expect(res.status).toBe(204) + const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.location`) + expect(row!.enabled).toBe(false) + }) +}) diff --git a/apps/aelis-backend/src/sources/http.ts b/apps/aelis-backend/src/sources/http.ts new file mode 100644 index 0000000..8ad7ca7 --- /dev/null +++ b/apps/aelis-backend/src/sources/http.ts @@ -0,0 +1,85 @@ +import type { Context, Hono } from "hono" + +import { type } from "arktype" +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" + +type Env = { + Variables: { + sessionManager: UserSessionManager + } +} + +interface SourcesHttpHandlersDeps { + sessionManager: UserSessionManager + authSessionMiddleware: AuthSessionMiddleware +} + +const UpdateSourceConfigRequestBody = type({ + "enabled?": "boolean", + "config?": "unknown", +}) + +export function registerSourcesHttpHandlers( + app: Hono, + { sessionManager, authSessionMiddleware }: SourcesHttpHandlersDeps, +) { + const inject = createMiddleware(async (c, next) => { + c.set("sessionManager", sessionManager) + await next() + }) + + app.patch("/api/sources/:sourceId", inject, authSessionMiddleware, handleUpdateSource) +} + +async function handleUpdateSource(c: Context) { + const sourceId = c.req.param("sourceId") + if (!sourceId) { + return c.body(null, 404) + } + + const sessionManager = c.get("sessionManager") + + // Validate source exists as a registered provider + const provider = sessionManager.getProvider(sourceId) + if (!provider) { + return c.json({ error: `Source "${sourceId}" not found` }, 404) + } + + // Parse request body + let body: unknown + try { + body = await c.req.json() + } catch { + return c.json({ error: "Invalid JSON" }, 400) + } + + const parsed = UpdateSourceConfigRequestBody(body) + if (parsed instanceof type.errors) { + return c.json({ error: parsed.summary }, 400) + } + + const { enabled, config: newConfig } = parsed + const user = c.get("user")! + + try { + await sessionManager.updateSourceConfig(user.id, sourceId, { + enabled, + config: newConfig, + }) + } catch (err) { + if (err instanceof SourceNotFoundError) { + return c.json({ error: err.message }, 404) + } + if (err instanceof InvalidSourceConfigError) { + return c.json({ error: err.message }, 400) + } + throw err + } + + return c.body(null, 204) +} diff --git a/apps/aelis-backend/src/sources/user-sources.ts b/apps/aelis-backend/src/sources/user-sources.ts index 0426908..1bfe4f0 100644 --- a/apps/aelis-backend/src/sources/user-sources.ts +++ b/apps/aelis-backend/src/sources/user-sources.ts @@ -52,15 +52,24 @@ export function sources(db: Database, userId: string) { } }, - /** Creates or updates the config for a source. */ - async upsertConfig(sourceId: string, config: Record) { - await db - .insert(userSources) - .values({ userId, sourceId, config }) - .onConflictDoUpdate({ - target: [userSources.userId, userSources.sourceId], - set: { config, updatedAt: new Date() }, - }) + /** Updates an existing user source row. Throws if the row doesn't exist. */ + async updateConfig(sourceId: string, update: { enabled?: boolean; config?: unknown }) { + const set: Record = { updatedAt: new Date() } + if (update.enabled !== undefined) { + set.enabled = update.enabled + } + if (update.config !== undefined) { + set.config = update.config + } + const rows = await db + .update(userSources) + .set(set) + .where(and(eq(userSources.userId, userId), eq(userSources.sourceId, sourceId))) + .returning({ id: userSources.id }) + + if (rows.length === 0) { + throw new SourceNotFoundError(sourceId, userId) + } }, /** Updates the encrypted credentials for a source. Throws if the source row doesn't exist. */ diff --git a/apps/aelis-backend/src/tfl/provider.ts b/apps/aelis-backend/src/tfl/provider.ts index 039cba8..9d937f3 100644 --- a/apps/aelis-backend/src/tfl/provider.ts +++ b/apps/aelis-backend/src/tfl/provider.ts @@ -7,12 +7,13 @@ export type TflSourceProviderOptions = | { apiKey: string; client?: never } | { apiKey?: never; client: ITflApi } -const tflConfig = type({ +export const tflConfig = type({ "lines?": "string[]", }) export class TflSourceProvider implements FeedSourceProvider { readonly sourceId = "aelis.tfl" + readonly configSchema = tflConfig private readonly apiKey: string | undefined private readonly client: ITflApi | undefined diff --git a/apps/aelis-backend/src/weather/provider.ts b/apps/aelis-backend/src/weather/provider.ts index ce29107..e1dcb33 100644 --- a/apps/aelis-backend/src/weather/provider.ts +++ b/apps/aelis-backend/src/weather/provider.ts @@ -8,7 +8,7 @@ export interface WeatherSourceProviderOptions { client?: WeatherSourceOptions["client"] } -const weatherConfig = type({ +export const weatherConfig = type({ "units?": "'metric' | 'imperial'", "hourlyLimit?": "number", "dailyLimit?": "number", @@ -16,6 +16,7 @@ const weatherConfig = type({ export class WeatherSourceProvider implements FeedSourceProvider { readonly sourceId = "aelis.weather" + readonly configSchema = weatherConfig private readonly credentials: WeatherSourceOptions["credentials"] private readonly client: WeatherSourceOptions["client"] diff --git a/bun.lock b/bun.lock index 90bdadd..ff0cf0f 100644 --- a/bun.lock +++ b/bun.lock @@ -30,8 +30,10 @@ "better-auth": "^1", "drizzle-orm": "^0.45.1", "hono": "^4", + "lodash.merge": "^4.6.2", }, "devDependencies": { + "@types/lodash.merge": "^4.6.9", "drizzle-kit": "^0.31.9", }, }, @@ -1246,6 +1248,10 @@ "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], + "@types/lodash": ["@types/lodash@4.17.24", "", {}, "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ=="], + + "@types/lodash.merge": ["@types/lodash.merge@4.6.9", "", { "dependencies": { "@types/lodash": "*" } }, "sha512-23sHDPmzd59kUgWyKGiOMO2Qb9YtqRO/x4IhkgNUiPQ1+5MUVqi6bCZeq9nBJ17msjIMbEIO5u+XW4Kz6aGUhQ=="], + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],