Compare commits

..

5 Commits

Author SHA1 Message Date
682df6a573 fix(backend): disable reasoning and fallback to reasoning field
Set reasoning effort to none in the LLM client to reduce latency
and token usage. Fall back to the reasoning field when content is
absent in the response.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-22 22:46:03 +00:00
a52addebd8 feat(backend): add GET /api/sources/:sourceId (#89)
Return { enabled, config } for a user's source. Defaults to
{ enabled: false, config: {} } when no row exists.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-22 21:45:17 +00:00
4cef7f2ea1 feat(backend): add PUT /api/sources/:sourceId (#87)
Add a PUT endpoint that inserts or fully replaces a user's source
config. Unlike PATCH (which deep-merges and requires an existing row),
PUT requires both `enabled` and `config`, performs an upsert via
INSERT ... ON CONFLICT DO UPDATE, and replaces config entirely.

- Add `upsertConfig` to user-sources data layer
- Add `upsertSourceConfig` to UserSessionManager
- Add `addSource` to UserSession for new source registration
- 12 new tests covering insert, replace, validation, and session refresh

Co-authored-by: Ona <no-reply@ona.com>
2026-03-22 18:37:40 +00:00
dd2b37938f feat(backend): add PATCH /api/sources/:sourceId (#86)
Add endpoint for users to update their source config
and enabled state. Config is deep-merged with existing
values via lodash.merge and validated against the
provider's schema before persisting.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-22 17:57:54 +00:00
a6be7b31e7 feat(session): query enabled sources before providers (#85)
UserSessionManager now queries the user_sources table for enabled
sources before calling any provider. Providers receive the per-user
JSON config directly instead of querying the DB themselves, removing
their db dependency and eliminating redundant round-trips.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-22 16:28:19 +00:00
13 changed files with 1021 additions and 9 deletions

View File

@@ -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"
}
}

View File

@@ -50,11 +50,13 @@ export function createLlmClient(config: LlmClientConfig): LlmClient {
schema: enhancementResultJsonSchema,
},
},
reasoning: { effort: "none" },
stream: false,
},
})
const content = response.choices?.[0]?.message?.content
const message = response.choices?.[0]?.message
const content = message?.content ?? message?.reasoning
if (typeof content !== "string") {
console.warn("[enhancement] LLM returned no content in response")
return null

View File

@@ -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 () => {

View File

@@ -1,7 +1,12 @@
import type { FeedSource } from "@aelis/core"
import type { type } from "arktype"
export type ConfigSchema = ReturnType<typeof type>
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<FeedSource>
}

View File

@@ -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<string, FeedSourceProvider>()
private readonly feedEnhancer: FeedEnhancer | null
private readonly db: Database
constructor(config: UserSessionManagerConfig) {
this.db = config.db
@@ -26,12 +31,34 @@ export class UserSessionManager {
this.providers.set(provider.sourceId, provider)
}
this.feedEnhancer = config.feedEnhancer ?? null
this.db = config.db
}
getProvider(sourceId: string): FeedSourceProvider | undefined {
return this.providers.get(sourceId)
}
/**
* Returns the user's config for a source, or defaults if no row exists.
*
* @throws {SourceNotFoundError} if the sourceId has no registered provider
*/
async fetchSourceConfig(
userId: string,
sourceId: string,
): Promise<{ enabled: boolean; config: unknown }> {
const provider = this.providers.get(sourceId)
if (!provider) {
throw new SourceNotFoundError(sourceId, userId)
}
const row = await sources(this.db, userId).find(sourceId)
return {
enabled: row?.enabled ?? false,
config: row?.config ?? {},
}
}
async getOrCreate(userId: string): Promise<UserSession> {
const existing = this.sessions.get(userId)
if (existing) return existing
@@ -66,6 +93,116 @@ 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<void> {
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<string, unknown> | undefined
if (update.config !== undefined) {
const existing = await sources(this.db, userId).find(sourceId)
const existingConfig = (existing?.config ?? {}) as Record<string, unknown>
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)
}
}
}
/**
* Validates, persists, and upserts a user's source config, then
* refreshes the cached session. Unlike updateSourceConfig, this
* inserts a new row if one doesn't exist and fully replaces config
* (no merge).
*
* @throws {SourceNotFoundError} if the sourceId has no registered provider
* @throws {InvalidSourceConfigError} if config fails schema validation
*/
async upsertSourceConfig(
userId: string,
sourceId: string,
data: { enabled: boolean; config: unknown },
): Promise<void> {
const provider = this.providers.get(sourceId)
if (!provider) {
throw new SourceNotFoundError(sourceId, userId)
}
if (provider.configSchema) {
const validated = provider.configSchema(data.config)
if (validated instanceof type.errors) {
throw new InvalidSourceConfigError(sourceId, validated.summary)
}
}
await sources(this.db, userId).upsertConfig(sourceId, {
enabled: data.enabled,
config: data.config,
})
const session = this.sessions.get(userId)
if (session) {
if (!data.enabled) {
session.removeSource(sourceId)
} else {
const source = await provider.feedSourceForUser(userId, data.config)
if (session.hasSource(sourceId)) {
session.replaceSource(sourceId, source)
} else {
session.addSource(source)
}
}
}
}
/**
* Replaces a provider and updates all active sessions.
* The new provider must have the same sourceId as an existing one.

View File

@@ -73,6 +73,32 @@ export class UserSession {
return this.sources.has(sourceId)
}
/**
* Registers a new source in the engine and invalidates all caches.
* Stops and restarts the engine to establish reactive subscriptions.
*/
addSource(source: FeedSource): void {
if (this.sources.has(source.id)) {
throw new Error(`Cannot add source "${source.id}": already registered`)
}
const wasStarted = this.engine.isStarted()
if (wasStarted) {
this.engine.stop()
}
this.engine.register(source)
this.sources.set(source.id, source)
this.invalidateEnhancement()
this.enhancingPromise = null
if (wasStarted) {
this.engine.start()
}
}
/**
* Replaces a source in the engine and invalidates all caches.
* Stops and restarts the engine to re-establish reactive subscriptions.

View File

@@ -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
}
}

View File

@@ -0,0 +1,628 @@
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<Record<string, ActionDefinition>> {
return {}
},
async executeAction(): Promise<unknown> {
return undefined
},
async fetchContext(): Promise<readonly ContextEntry[] | null> {
return null
},
async fetchItems(): Promise<FeedItem[]> {
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<string, unknown>
}
function createInMemoryStore() {
const rows = new Map<string, SourceRow>()
function key(userId: string, sourceId: string) {
return `${userId}:${sourceId}`
}
return {
rows,
seed(userId: string, sourceId: string, data: Partial<SourceRow> = {}) {
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<string, unknown>
}
},
async upsertConfig(sourceId: string, data: { enabled: boolean; config: unknown }) {
const existing = rows.get(key(userId, sourceId))
if (existing) {
existing.enabled = data.enabled
existing.config = data.config as Record<string, unknown>
} else {
rows.set(key(userId, sourceId), {
userId,
sourceId,
enabled: data.enabled,
config: (data.config ?? {}) as Record<string, unknown>,
})
}
},
}
},
}
}
let activeStore: ReturnType<typeof createInMemoryStore>
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),
})
}
function get(app: Hono, sourceId: string) {
return app.request(`/api/sources/${sourceId}`, { method: "GET" })
}
function put(app: Hono, sourceId: string, body: unknown) {
return app.request(`/api/sources/${sourceId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("GET /api/sources/:sourceId", () => {
test("returns 401 without auth", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)])
const res = await get(app, "aelis.weather")
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 get(app, "unknown.source")
expect(res.status).toBe(404)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("not found")
})
test("returns enabled and config for existing source", 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 get(app, "aelis.weather")
expect(res.status).toBe(200)
const body = (await res.json()) as { enabled: boolean; config: unknown }
expect(body.enabled).toBe(true)
expect(body.config).toEqual({ units: "metric" })
})
test("returns defaults when user has no row for source", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await get(app, "aelis.weather")
expect(res.status).toBe(200)
const body = (await res.json()) as { enabled: boolean; config: unknown }
expect(body.enabled).toBe(false)
expect(body.config).toEqual({})
})
test("returns disabled source", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
enabled: false,
config: { units: "imperial" },
})
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await get(app, "aelis.weather")
expect(res.status).toBe(200)
const body = (await res.json()) as { enabled: boolean; config: unknown }
expect(body.enabled).toBe(false)
expect(body.config).toEqual({ units: "imperial" })
})
})
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)
})
})
// ---------------------------------------------------------------------------
// PUT /api/sources/:sourceId
// ---------------------------------------------------------------------------
describe("PUT /api/sources/:sourceId", () => {
test("returns 401 without auth", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)])
const res = await put(app, "aelis.weather", { enabled: true, config: {} })
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 put(app, "unknown.source", { enabled: true, config: {} })
expect(res.status).toBe(404)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("not found")
})
test("returns 400 for invalid JSON", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await app.request("/api/sources/aelis.weather", {
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).toContain("Invalid JSON")
})
test("returns 400 when enabled is missing", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await put(app, "aelis.weather", { config: {} })
expect(res.status).toBe(400)
})
test("returns 400 when config is missing", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await put(app, "aelis.weather", { enabled: true })
expect(res.status).toBe(400)
})
test("returns 400 when config fails schema validation", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await put(app, "aelis.weather", {
enabled: true,
config: { units: "invalid" },
})
expect(res.status).toBe(400)
})
test("returns 204 and inserts when row does not exist", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await put(app, "aelis.weather", {
enabled: true,
config: { units: "metric" },
})
expect(res.status).toBe(204)
const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.weather`)
expect(row).toBeDefined()
expect(row!.enabled).toBe(true)
expect(row!.config).toEqual({ units: "metric" })
})
test("returns 204 and fully replaces existing row", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
enabled: true,
config: { units: "metric", hourlyLimit: 12 },
})
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await put(app, "aelis.weather", {
enabled: false,
config: { units: "imperial" },
})
expect(res.status).toBe(204)
const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.weather`)
expect(row!.enabled).toBe(false)
// hourlyLimit should be gone — full replace, not merge
expect(row!.config).toEqual({ units: "imperial" })
})
test("refreshes source in active session after upsert", 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 put(app, "aelis.weather", {
enabled: true,
config: { units: "imperial" },
})
expect(res.status).toBe(204)
expect(replaceSpy).toHaveBeenCalled()
replaceSpy.mockRestore()
})
test("removes source from session when disabled via upsert", 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 put(app, "aelis.weather", {
enabled: false,
config: { units: "metric" },
})
expect(res.status).toBe(204)
expect(removeSpy).toHaveBeenCalledWith("aelis.weather")
removeSpy.mockRestore()
})
test("adds source to active session when inserting a new source", async () => {
activeStore = createInMemoryStore()
// Seed a different source so the session can be created
activeStore.seed(MOCK_USER_ID, "aelis.location", { enabled: true })
const { app, sessionManager } = createApp(
[createStubProvider("aelis.location"), createStubProvider("aelis.weather", weatherConfig)],
MOCK_USER_ID,
)
// Create session — only has aelis.location
const session = await sessionManager.getOrCreate(MOCK_USER_ID)
expect(session.hasSource("aelis.weather")).toBe(false)
// PUT a new source that didn't exist before
const res = await put(app, "aelis.weather", {
enabled: true,
config: { units: "metric" },
})
expect(res.status).toBe(204)
expect(session.hasSource("aelis.weather")).toBe(true)
})
test("accepts location source with arbitrary config (no schema)", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID)
const res = await put(app, "aelis.location", {
enabled: true,
config: { something: "value" },
})
expect(res.status).toBe(204)
const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.location`)
expect(row).toBeDefined()
expect(row!.config).toEqual({ something: "value" })
})
})

View File

@@ -0,0 +1,158 @@
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",
})
const ReplaceSourceConfigRequestBody = type({
enabled: "boolean",
config: "unknown",
})
export function registerSourcesHttpHandlers(
app: Hono,
{ sessionManager, authSessionMiddleware }: SourcesHttpHandlersDeps,
) {
const inject = createMiddleware<Env>(async (c, next) => {
c.set("sessionManager", sessionManager)
await next()
})
app.get("/api/sources/:sourceId", inject, authSessionMiddleware, handleGetSource)
app.patch("/api/sources/:sourceId", inject, authSessionMiddleware, handleUpdateSource)
app.put("/api/sources/:sourceId", inject, authSessionMiddleware, handleReplaceSource)
}
async function handleGetSource(c: Context<Env>) {
const sourceId = c.req.param("sourceId")
if (!sourceId) {
return c.body(null, 404)
}
const sessionManager = c.get("sessionManager")
const user = c.get("user")!
try {
const result = await sessionManager.fetchSourceConfig(user.id, sourceId)
return c.json(result)
} catch (err) {
if (err instanceof SourceNotFoundError) {
return c.json({ error: err.message }, 404)
}
throw err
}
}
async function handleUpdateSource(c: Context<Env>) {
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)
}
async function handleReplaceSource(c: Context<Env>) {
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 parsed = ReplaceSourceConfigRequestBody(body)
if (parsed instanceof type.errors) {
return c.json({ error: parsed.summary }, 400)
}
const { enabled, config } = parsed
const user = c.get("user")!
try {
await sessionManager.upsertSourceConfig(user.id, sourceId, {
enabled,
config,
})
} 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)
}

View File

@@ -52,14 +52,46 @@ export function sources(db: Database, userId: string) {
}
},
/** Creates or updates the config for a source. */
async upsertConfig(sourceId: string, config: Record<string, unknown>) {
/** 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<string, unknown> = { 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)
}
},
/** Inserts a new user source row or fully replaces enabled/config on an existing one. */
async upsertConfig(sourceId: string, data: { enabled: boolean; config: unknown }) {
const now = new Date()
await db
.insert(userSources)
.values({ userId, sourceId, config })
.values({
userId,
sourceId,
enabled: data.enabled,
config: data.config,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [userSources.userId, userSources.sourceId],
set: { config, updatedAt: new Date() },
set: {
enabled: data.enabled,
config: data.config,
updatedAt: now,
},
})
},

View File

@@ -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

View File

@@ -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"]

View File

@@ -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=="],