feat: surface per-user credentials to feed source providers (#110)

Add credentials parameter to FeedSourceProvider.feedSourceForUser so
providers can receive decrypted per-user credentials (OAuth tokens,
passwords) from the user_sources table.

Wire CredentialEncryptor into UserSessionManager to handle
encrypt/decrypt. Providers receive plaintext and handle validation
internally. Existing providers ignore the new parameter.

Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
2026-04-11 15:18:24 +01:00
committed by GitHub
parent bfc25fa704
commit 98ce546eff
11 changed files with 452 additions and 20 deletions

View File

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