mirror of
https://github.com/kennethnym/aris.git
synced 2026-04-06 10:01:23 +01:00
Compare commits
1 Commits
kn/per-use
...
kn/admin-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
987dd9e59a
|
@@ -5,7 +5,7 @@ DATABASE_URL=postgresql://user:password@localhost:5432/aris
|
|||||||
BETTER_AUTH_SECRET=
|
BETTER_AUTH_SECRET=
|
||||||
|
|
||||||
# Encryption key for source credentials at rest (32 bytes, generate with: openssl rand -base64 32)
|
# Encryption key for source credentials at rest (32 bytes, generate with: openssl rand -base64 32)
|
||||||
CREDENTIAL_ENCRYPTION_KEY=
|
CREDENTIALS_ENCRYPTION_KEY=
|
||||||
|
|
||||||
# Base URL of the backend
|
# Base URL of the backend
|
||||||
BETTER_AUTH_URL=http://localhost:3000
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
|||||||
@@ -5,11 +5,7 @@ import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
|||||||
export class LocationSourceProvider implements FeedSourceProvider {
|
export class LocationSourceProvider implements FeedSourceProvider {
|
||||||
readonly sourceId = "aelis.location"
|
readonly sourceId = "aelis.location"
|
||||||
|
|
||||||
async feedSourceForUser(
|
async feedSourceForUser(_userId: string, _config: unknown): Promise<LocationSource> {
|
||||||
_userId: string,
|
|
||||||
_config: unknown,
|
|
||||||
_credentials: unknown,
|
|
||||||
): Promise<LocationSource> {
|
|
||||||
return new LocationSource()
|
return new LocationSource()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { createDatabase } from "./db/index.ts"
|
|||||||
import { registerFeedHttpHandlers } from "./engine/http.ts"
|
import { registerFeedHttpHandlers } from "./engine/http.ts"
|
||||||
import { createFeedEnhancer } from "./enhancement/enhance-feed.ts"
|
import { createFeedEnhancer } from "./enhancement/enhance-feed.ts"
|
||||||
import { createLlmClient } from "./enhancement/llm-client.ts"
|
import { createLlmClient } from "./enhancement/llm-client.ts"
|
||||||
import { CredentialEncryptor } from "./lib/crypto.ts"
|
|
||||||
import { registerLocationHttpHandlers } from "./location/http.ts"
|
import { registerLocationHttpHandlers } from "./location/http.ts"
|
||||||
import { LocationSourceProvider } from "./location/provider.ts"
|
import { LocationSourceProvider } from "./location/provider.ts"
|
||||||
import { UserSessionManager } from "./session/index.ts"
|
import { UserSessionManager } from "./session/index.ts"
|
||||||
@@ -35,16 +34,6 @@ function main() {
|
|||||||
console.warn("[enhancement] OPENROUTER_API_KEY not set — feed enhancement disabled")
|
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({
|
const sessionManager = new UserSessionManager({
|
||||||
db,
|
db,
|
||||||
providers: [
|
providers: [
|
||||||
@@ -60,7 +49,6 @@ function main() {
|
|||||||
new TflSourceProvider({ apiKey: process.env.TFL_API_KEY! }),
|
new TflSourceProvider({ apiKey: process.env.TFL_API_KEY! }),
|
||||||
],
|
],
|
||||||
feedEnhancer,
|
feedEnhancer,
|
||||||
credentialEncryptor,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ export interface FeedSourceProvider {
|
|||||||
readonly sourceId: string
|
readonly sourceId: string
|
||||||
/** Arktype schema for validating user-provided config. Omit if the source has no config. */
|
/** Arktype schema for validating user-provided config. Omit if the source has no config. */
|
||||||
readonly configSchema?: ConfigSchema
|
readonly configSchema?: ConfigSchema
|
||||||
feedSourceForUser(userId: string, config: unknown, credentials: unknown): Promise<FeedSource>
|
feedSourceForUser(userId: string, config: unknown): Promise<FeedSource>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ import { beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
|
|||||||
import type { Database } from "../db/index.ts"
|
import type { Database } from "../db/index.ts"
|
||||||
import type { FeedSourceProvider } from "./feed-source-provider.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"
|
import { UserSessionManager } from "./user-session-manager.ts"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,13 +38,6 @@ function getEnabledSourceIds(userId: string): string[] {
|
|||||||
*/
|
*/
|
||||||
let mockFindResult: unknown | undefined
|
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 the sources module so UserSessionManager's DB query returns controlled data.
|
||||||
mock.module("../sources/user-sources.ts", () => ({
|
mock.module("../sources/user-sources.ts", () => ({
|
||||||
sources: (_db: Database, userId: string) => ({
|
sources: (_db: Database, userId: string) => ({
|
||||||
@@ -81,12 +68,6 @@ mock.module("../sources/user-sources.ts", () => ({
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async updateCredentials(sourceId: string, credentials: Buffer) {
|
|
||||||
if (mockUpdateCredentialsError) {
|
|
||||||
throw mockUpdateCredentialsError
|
|
||||||
}
|
|
||||||
mockUpdateCredentialsCalls.push({ sourceId, credentials })
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -112,11 +93,8 @@ function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
|
|||||||
|
|
||||||
function createStubProvider(
|
function createStubProvider(
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
factory: (
|
factory: (userId: string, config: Record<string, unknown>) => Promise<FeedSource> = async () =>
|
||||||
userId: string,
|
createStubSource(sourceId),
|
||||||
config: Record<string, unknown>,
|
|
||||||
credentials: unknown,
|
|
||||||
) => Promise<FeedSource> = async () => createStubSource(sourceId),
|
|
||||||
): FeedSourceProvider {
|
): FeedSourceProvider {
|
||||||
return { sourceId, feedSourceForUser: factory }
|
return { sourceId, feedSourceForUser: factory }
|
||||||
}
|
}
|
||||||
@@ -138,8 +116,6 @@ const weatherProvider: FeedSourceProvider = {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
enabledByUser.clear()
|
enabledByUser.clear()
|
||||||
mockFindResult = undefined
|
mockFindResult = undefined
|
||||||
mockUpdateCredentialsCalls.length = 0
|
|
||||||
mockUpdateCredentialsError = null
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("UserSessionManager", () => {
|
describe("UserSessionManager", () => {
|
||||||
@@ -705,122 +681,3 @@ describe("UserSessionManager.replaceProvider", () => {
|
|||||||
expect(feedAfter.items[0]!.data.version).toBe(1)
|
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -5,14 +5,9 @@ import merge from "lodash.merge"
|
|||||||
|
|
||||||
import type { Database } from "../db/index.ts"
|
import type { Database } from "../db/index.ts"
|
||||||
import type { FeedEnhancer } from "../enhancement/enhance-feed.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 type { FeedSourceProvider } from "./feed-source-provider.ts"
|
||||||
|
|
||||||
import {
|
import { InvalidSourceConfigError, SourceNotFoundError } from "../sources/errors.ts"
|
||||||
CredentialStorageUnavailableError,
|
|
||||||
InvalidSourceConfigError,
|
|
||||||
SourceNotFoundError,
|
|
||||||
} from "../sources/errors.ts"
|
|
||||||
import { sources } from "../sources/user-sources.ts"
|
import { sources } from "../sources/user-sources.ts"
|
||||||
import { UserSession } from "./user-session.ts"
|
import { UserSession } from "./user-session.ts"
|
||||||
|
|
||||||
@@ -20,7 +15,6 @@ export interface UserSessionManagerConfig {
|
|||||||
db: Database
|
db: Database
|
||||||
providers: FeedSourceProvider[]
|
providers: FeedSourceProvider[]
|
||||||
feedEnhancer?: FeedEnhancer | null
|
feedEnhancer?: FeedEnhancer | null
|
||||||
credentialEncryptor?: CredentialEncryptor | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserSessionManager {
|
export class UserSessionManager {
|
||||||
@@ -29,7 +23,7 @@ export class UserSessionManager {
|
|||||||
private readonly db: Database
|
private readonly db: Database
|
||||||
private readonly providers = new Map<string, FeedSourceProvider>()
|
private readonly providers = new Map<string, FeedSourceProvider>()
|
||||||
private readonly feedEnhancer: FeedEnhancer | null
|
private readonly feedEnhancer: FeedEnhancer | null
|
||||||
private readonly encryptor: CredentialEncryptor | null
|
private readonly db: Database
|
||||||
|
|
||||||
constructor(config: UserSessionManagerConfig) {
|
constructor(config: UserSessionManagerConfig) {
|
||||||
this.db = config.db
|
this.db = config.db
|
||||||
@@ -37,7 +31,7 @@ export class UserSessionManager {
|
|||||||
this.providers.set(provider.sourceId, provider)
|
this.providers.set(provider.sourceId, provider)
|
||||||
}
|
}
|
||||||
this.feedEnhancer = config.feedEnhancer ?? null
|
this.feedEnhancer = config.feedEnhancer ?? null
|
||||||
this.encryptor = config.credentialEncryptor ?? null
|
this.db = config.db
|
||||||
}
|
}
|
||||||
|
|
||||||
getProvider(sourceId: string): FeedSourceProvider | undefined {
|
getProvider(sourceId: string): FeedSourceProvider | undefined {
|
||||||
@@ -126,15 +120,14 @@ export class UserSessionManager {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the existing row for config merging and credential access.
|
// When config is provided, fetch existing to deep-merge before validating.
|
||||||
// NOTE: find + updateConfig is not atomic. A concurrent update could
|
// NOTE: find + updateConfig is not atomic. A concurrent update could
|
||||||
// read stale config. Use SELECT FOR UPDATE or atomic jsonb merge if
|
// read stale config. Use SELECT FOR UPDATE or atomic jsonb merge if
|
||||||
// this becomes a problem.
|
// this becomes a problem.
|
||||||
const existingRow = await sources(this.db, userId).find(sourceId)
|
|
||||||
|
|
||||||
let mergedConfig: Record<string, unknown> | undefined
|
let mergedConfig: Record<string, unknown> | undefined
|
||||||
if (update.config !== undefined && provider.configSchema) {
|
if (update.config !== undefined && provider.configSchema) {
|
||||||
const existingConfig = (existingRow?.config ?? {}) as Record<string, unknown>
|
const existing = await sources(this.db, userId).find(sourceId)
|
||||||
|
const existingConfig = (existing?.config ?? {}) as Record<string, unknown>
|
||||||
mergedConfig = merge({}, existingConfig, update.config)
|
mergedConfig = merge({}, existingConfig, update.config)
|
||||||
|
|
||||||
const validated = provider.configSchema(mergedConfig)
|
const validated = provider.configSchema(mergedConfig)
|
||||||
@@ -156,10 +149,7 @@ export class UserSessionManager {
|
|||||||
if (update.enabled === false) {
|
if (update.enabled === false) {
|
||||||
session.removeSource(sourceId)
|
session.removeSource(sourceId)
|
||||||
} else {
|
} else {
|
||||||
const credentials = existingRow?.credentials
|
const source = await provider.feedSourceForUser(userId, mergedConfig ?? {})
|
||||||
? this.decryptCredentials(existingRow.credentials)
|
|
||||||
: null
|
|
||||||
const source = await provider.feedSourceForUser(userId, mergedConfig ?? {}, credentials)
|
|
||||||
session.replaceSource(sourceId, source)
|
session.replaceSource(sourceId, source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,11 +182,6 @@ export class UserSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = data.config ?? {}
|
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, {
|
await sources(this.db, userId).upsertConfig(sourceId, {
|
||||||
enabled: data.enabled,
|
enabled: data.enabled,
|
||||||
config,
|
config,
|
||||||
@@ -207,10 +192,7 @@ export class UserSessionManager {
|
|||||||
if (!data.enabled) {
|
if (!data.enabled) {
|
||||||
session.removeSource(sourceId)
|
session.removeSource(sourceId)
|
||||||
} else {
|
} else {
|
||||||
const credentials = existingRow?.credentials
|
const source = await provider.feedSourceForUser(userId, config)
|
||||||
? this.decryptCredentials(existingRow.credentials)
|
|
||||||
: null
|
|
||||||
const source = await provider.feedSourceForUser(userId, config, credentials)
|
|
||||||
if (session.hasSource(sourceId)) {
|
if (session.hasSource(sourceId)) {
|
||||||
session.replaceSource(sourceId, source)
|
session.replaceSource(sourceId, source)
|
||||||
} else {
|
} else {
|
||||||
@@ -220,44 +202,6 @@ 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<void> {
|
|
||||||
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.
|
* Replaces a provider and updates all active sessions.
|
||||||
* The new provider must have the same sourceId as an existing one.
|
* The new provider must have the same sourceId as an existing one.
|
||||||
@@ -310,12 +254,7 @@ export class UserSessionManager {
|
|||||||
const row = await sources(this.db, session.userId).find(provider.sourceId)
|
const row = await sources(this.db, session.userId).find(provider.sourceId)
|
||||||
if (!row?.enabled) return
|
if (!row?.enabled) return
|
||||||
|
|
||||||
const credentials = row.credentials ? this.decryptCredentials(row.credentials) : null
|
const newSource = await provider.feedSourceForUser(session.userId, row.config ?? {})
|
||||||
const newSource = await provider.feedSourceForUser(
|
|
||||||
session.userId,
|
|
||||||
row.config ?? {},
|
|
||||||
credentials,
|
|
||||||
)
|
|
||||||
session.replaceSource(provider.sourceId, newSource)
|
session.replaceSource(provider.sourceId, newSource)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -332,8 +271,7 @@ export class UserSessionManager {
|
|||||||
for (const row of enabledRows) {
|
for (const row of enabledRows) {
|
||||||
const provider = this.providers.get(row.sourceId)
|
const provider = this.providers.get(row.sourceId)
|
||||||
if (provider) {
|
if (provider) {
|
||||||
const credentials = row.credentials ? this.decryptCredentials(row.credentials) : null
|
promises.push(provider.feedSourceForUser(userId, row.config ?? {}))
|
||||||
promises.push(provider.feedSourceForUser(userId, row.config ?? {}, credentials))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,19 +302,4 @@ export class UserSessionManager {
|
|||||||
|
|
||||||
return new UserSession(userId, feedSources, this.feedEnhancer)
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,26 +24,3 @@ export class InvalidSourceConfigError extends Error {
|
|||||||
this.sourceId = sourceId
|
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ import type { Database } from "../db/index.ts"
|
|||||||
import type { ConfigSchema, FeedSourceProvider } from "../session/feed-source-provider.ts"
|
import type { ConfigSchema, FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||||
|
|
||||||
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
|
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||||
import { CredentialEncryptor } from "../lib/crypto.ts"
|
|
||||||
import { UserSessionManager } from "../session/user-session-manager.ts"
|
import { UserSessionManager } from "../session/user-session-manager.ts"
|
||||||
import { tflConfig } from "../tfl/provider.ts"
|
import { tflConfig } from "../tfl/provider.ts"
|
||||||
import { weatherConfig } from "../weather/provider.ts"
|
import { weatherConfig } from "../weather/provider.ts"
|
||||||
import { InvalidSourceCredentialsError, SourceNotFoundError } from "./errors.ts"
|
import { SourceNotFoundError } from "./errors.ts"
|
||||||
import { registerSourcesHttpHandlers } from "./http.ts"
|
import { registerSourcesHttpHandlers } from "./http.ts"
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -40,7 +39,7 @@ function createStubProvider(sourceId: string, configSchema?: ConfigSchema): Feed
|
|||||||
return {
|
return {
|
||||||
sourceId,
|
sourceId,
|
||||||
configSchema,
|
configSchema,
|
||||||
async feedSourceForUser(_userId: string, _config: unknown, _credentials: unknown) {
|
async feedSourceForUser() {
|
||||||
return createStubSource(sourceId)
|
return createStubSource(sourceId)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -106,12 +105,6 @@ function createInMemoryStore() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async updateCredentials(sourceId: string, _credentials: Buffer) {
|
|
||||||
const existing = rows.get(key(userId, sourceId))
|
|
||||||
if (!existing) {
|
|
||||||
throw new SourceNotFoundError(sourceId, userId)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -149,30 +142,6 @@ function get(app: Hono, sourceId: string) {
|
|||||||
return app.request(`/api/sources/${sourceId}`, { method: "GET" })
|
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) {
|
function put(app: Hono, sourceId: string, body: unknown) {
|
||||||
return app.request(`/api/sources/${sourceId}`, {
|
return app.request(`/api/sources/${sourceId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -739,86 +708,3 @@ describe("PUT /api/sources/:sourceId", () => {
|
|||||||
expect(res.status).toBe(204)
|
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")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -6,12 +6,7 @@ import { createMiddleware } from "hono/factory"
|
|||||||
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||||
import type { UserSessionManager } from "../session/index.ts"
|
import type { UserSessionManager } from "../session/index.ts"
|
||||||
|
|
||||||
import {
|
import { InvalidSourceConfigError, SourceNotFoundError } from "./errors.ts"
|
||||||
CredentialStorageUnavailableError,
|
|
||||||
InvalidSourceConfigError,
|
|
||||||
InvalidSourceCredentialsError,
|
|
||||||
SourceNotFoundError,
|
|
||||||
} from "./errors.ts"
|
|
||||||
|
|
||||||
type Env = {
|
type Env = {
|
||||||
Variables: {
|
Variables: {
|
||||||
@@ -53,12 +48,6 @@ export function registerSourcesHttpHandlers(
|
|||||||
app.get("/api/sources/:sourceId", inject, authSessionMiddleware, handleGetSource)
|
app.get("/api/sources/:sourceId", inject, authSessionMiddleware, handleGetSource)
|
||||||
app.patch("/api/sources/:sourceId", inject, authSessionMiddleware, handleUpdateSource)
|
app.patch("/api/sources/:sourceId", inject, authSessionMiddleware, handleUpdateSource)
|
||||||
app.put("/api/sources/:sourceId", inject, authSessionMiddleware, handleReplaceSource)
|
app.put("/api/sources/:sourceId", inject, authSessionMiddleware, handleReplaceSource)
|
||||||
app.put(
|
|
||||||
"/api/sources/:sourceId/credentials",
|
|
||||||
inject,
|
|
||||||
authSessionMiddleware,
|
|
||||||
handleUpdateCredentials,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGetSource(c: Context<Env>) {
|
async function handleGetSource(c: Context<Env>) {
|
||||||
@@ -182,43 +171,3 @@ async function handleReplaceSource(c: Context<Env>) {
|
|||||||
|
|
||||||
return c.body(null, 204)
|
return c.body(null, 204)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpdateCredentials(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 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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,11 +23,7 @@ export class TflSourceProvider implements FeedSourceProvider {
|
|||||||
this.client = "client" in options ? options.client : undefined
|
this.client = "client" in options ? options.client : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
async feedSourceForUser(
|
async feedSourceForUser(_userId: string, config: unknown): Promise<TflSource> {
|
||||||
_userId: string,
|
|
||||||
config: unknown,
|
|
||||||
_credentials: unknown,
|
|
||||||
): Promise<TflSource> {
|
|
||||||
const parsed = tflConfig(config)
|
const parsed = tflConfig(config)
|
||||||
if (parsed instanceof type.errors) {
|
if (parsed instanceof type.errors) {
|
||||||
throw new Error(`Invalid TFL config: ${parsed.summary}`)
|
throw new Error(`Invalid TFL config: ${parsed.summary}`)
|
||||||
|
|||||||
@@ -26,11 +26,7 @@ export class WeatherSourceProvider implements FeedSourceProvider {
|
|||||||
this.client = options.client
|
this.client = options.client
|
||||||
}
|
}
|
||||||
|
|
||||||
async feedSourceForUser(
|
async feedSourceForUser(_userId: string, config: unknown): Promise<WeatherSource> {
|
||||||
_userId: string,
|
|
||||||
config: unknown,
|
|
||||||
_credentials: unknown,
|
|
||||||
): Promise<WeatherSource> {
|
|
||||||
const parsed = weatherConfig(config)
|
const parsed = weatherConfig(config)
|
||||||
if (parsed instanceof type.errors) {
|
if (parsed instanceof type.errors) {
|
||||||
throw new Error(`Invalid weather config: ${parsed.summary}`)
|
throw new Error(`Invalid weather config: ${parsed.summary}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user