Compare commits

..

1 Commits

Author SHA1 Message Date
987dd9e59a refactor: replace eslint/prettier with oxlint/oxfmt in admin-dashboard
Co-authored-by: Ona <no-reply@ona.com>
2026-04-04 15:11:44 +00:00
68 changed files with 458 additions and 2724 deletions

View File

@@ -8,5 +8,5 @@
"ignoreCase": true, "ignoreCase": true,
"newlinesBetween": true "newlinesBetween": true
}, },
"ignorePatterns": [".claude", ".ona", "drizzle", "fixtures"] "ignorePatterns": [".claude", "fixtures"]
} }

View File

@@ -1,3 +0,0 @@
{
"js/ts.experimental.useTsgo": true
}

View File

@@ -34,7 +34,7 @@
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"typescript": "^6", "typescript": "~5.9.3",
"vite": "^7.2.4" "vite": "^7.2.4"
} }
} }

View File

@@ -74,24 +74,19 @@ export function SourceConfigPanel({ source, onUpdate }: SourceConfigPanelProps)
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const promises: Promise<void>[] = [
replaceSource(source.id, { enabled, config: getUserConfig() }),
]
const credentialFields = getCredentialFields() const credentialFields = getCredentialFields()
const hasCredentials = Object.values(credentialFields).some( const hasCredentials = Object.values(credentialFields).some(
(v) => typeof v === "string" && v.length > 0, (v) => typeof v === "string" && v.length > 0,
) )
if (hasCredentials) {
promises.push(updateProviderConfig(source.id, { credentials: credentialFields }))
}
const body: Parameters<typeof replaceSource>[1] = { await Promise.all(promises)
enabled,
config: getUserConfig(),
}
if (hasCredentials && source.perUserCredentials) {
body.credentials = credentialFields
}
await replaceSource(source.id, body)
// For non-per-user credentials (provider-level), still use the admin endpoint.
if (hasCredentials && !source.perUserCredentials) {
await updateProviderConfig(source.id, { credentials: credentialFields })
}
}, },
onSuccess() { onSuccess() {
setDirty({}) setDirty({})

View File

@@ -23,8 +23,6 @@ export interface SourceDefinition {
name: string name: string
description: string description: string
alwaysEnabled?: boolean alwaysEnabled?: boolean
/** When true, secret fields are stored as per-user credentials via /api/sources/:id/credentials. */
perUserCredentials?: boolean
fields: Record<string, ConfigFieldDef> fields: Record<string, ConfigFieldDef>
} }
@@ -80,44 +78,6 @@ const sourceDefinitions: SourceDefinition[] = [
}, },
}, },
}, },
{
id: "aelis.caldav",
name: "CalDAV",
description: "Calendar events from any CalDAV server (Nextcloud, Radicale, Baikal, etc.).",
perUserCredentials: true,
fields: {
serverUrl: {
type: "string",
label: "Server URL",
required: true,
secret: false,
description: "CalDAV server URL (e.g. https://nextcloud.example.com/remote.php/dav)",
},
username: {
type: "string",
label: "Username",
required: true,
secret: false,
},
password: {
type: "string",
label: "Password",
required: true,
secret: true,
},
lookAheadDays: {
type: "number",
label: "Look-ahead Days",
defaultValue: 0,
description: "Number of additional days beyond today to fetch events for",
},
timeZone: {
type: "string",
label: "Timezone",
description: 'IANA timezone for determining "today" (e.g. Europe/London). Defaults to UTC.',
},
},
},
{ {
id: "aelis.tfl", id: "aelis.tfl",
name: "TfL", name: "TfL",
@@ -174,7 +134,7 @@ export async function fetchConfigs(): Promise<SourceConfig[]> {
export async function replaceSource( export async function replaceSource(
sourceId: string, sourceId: string,
body: { enabled: boolean; config: unknown; credentials?: Record<string, unknown> }, body: { enabled: boolean; config: unknown },
): Promise<void> { ): Promise<void> {
const res = await fetch(`${serverBase()}/sources/${sourceId}`, { const res = await fetch(`${serverBase()}/sources/${sourceId}`, {
method: "PUT", method: "PUT",
@@ -204,22 +164,6 @@ export async function updateProviderConfig(
} }
} }
export async function updateSourceCredentials(
sourceId: string,
credentials: Record<string, unknown>,
): Promise<void> {
const res = await fetch(`${serverBase()}/sources/${sourceId}/credentials`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(credentials),
})
if (!res.ok) {
const data = (await res.json()) as { error?: string }
throw new Error(data.error ?? `Failed to update credentials: ${res.status}`)
}
}
export interface LocationInput { export interface LocationInput {
lat: number lat: number
lng: number lng: number

View File

@@ -3,11 +3,12 @@
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022", "target": "ES2022",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2022", "DOM"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"types": ["vite/client"], "types": ["vite/client"],
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
@@ -15,12 +16,14 @@
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
/* Linting */
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true, "noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }

View File

@@ -2,6 +2,7 @@
"files": [], "files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }], "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
"compilerOptions": { "compilerOptions": {
"baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }

View File

@@ -7,12 +7,14 @@
"types": ["node"], "types": ["node"],
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
/* Linting */
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,

View File

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

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { Hono } from "hono" import { Hono } from "hono"
import { describe, expect, test } from "bun:test"
import type { Auth } from "./index.ts" import type { Auth } from "./index.ts"
import type { AuthSession, AuthUser } from "./session.ts" import type { AuthSession, AuthUser } from "./session.ts"

View File

@@ -1,85 +0,0 @@
import { describe, expect, test } from "bun:test"
import { CalDavSourceProvider } from "./provider.ts"
describe("CalDavSourceProvider", () => {
const provider = new CalDavSourceProvider()
test("sourceId is aelis.caldav", () => {
expect(provider.sourceId).toBe("aelis.caldav")
})
test("throws when credentials are null", async () => {
const config = { serverUrl: "https://caldav.icloud.com", username: "user@icloud.com" }
await expect(provider.feedSourceForUser("user-1", config, null)).rejects.toThrow(
"No CalDAV credentials configured",
)
})
test("throws when credentials are missing password", async () => {
const config = { serverUrl: "https://caldav.icloud.com", username: "user@icloud.com" }
await expect(provider.feedSourceForUser("user-1", config, {})).rejects.toThrow(
"password must be a string",
)
})
test("throws when config is missing serverUrl", async () => {
const credentials = { password: "app-specific-password" }
await expect(
provider.feedSourceForUser("user-1", { username: "user@icloud.com" }, credentials),
).rejects.toThrow("Invalid CalDAV config")
})
test("throws when config is missing username", async () => {
const credentials = { password: "app-specific-password" }
await expect(
provider.feedSourceForUser("user-1", { serverUrl: "https://caldav.icloud.com" }, credentials),
).rejects.toThrow("Invalid CalDAV config")
})
test("throws when config has extra keys", async () => {
const config = {
serverUrl: "https://caldav.icloud.com",
username: "user@icloud.com",
extra: true,
}
const credentials = { password: "app-specific-password" }
await expect(provider.feedSourceForUser("user-1", config, credentials)).rejects.toThrow(
"Invalid CalDAV config",
)
})
test("throws when credentials have extra keys", async () => {
const config = { serverUrl: "https://caldav.icloud.com", username: "user@icloud.com" }
const credentials = { password: "app-specific-password", extra: true }
await expect(provider.feedSourceForUser("user-1", config, credentials)).rejects.toThrow(
"extra must be removed",
)
})
test("returns CalDavSource with valid config and credentials", async () => {
const config = {
serverUrl: "https://caldav.icloud.com",
username: "user@icloud.com",
lookAheadDays: 3,
timeZone: "Europe/London",
}
const credentials = { password: "app-specific-password" }
const source = await provider.feedSourceForUser("user-1", config, credentials)
expect(source).toBeDefined()
expect(source.id).toBe("aelis.caldav")
})
test("returns CalDavSource with minimal config", async () => {
const config = {
serverUrl: "https://caldav.icloud.com",
username: "user@icloud.com",
}
const credentials = { password: "app-specific-password" }
const source = await provider.feedSourceForUser("user-1", config, credentials)
expect(source).toBeDefined()
expect(source.id).toBe("aelis.caldav")
})
})

View File

@@ -1,53 +0,0 @@
import { CalDavSource } from "@aelis/source-caldav"
import { type } from "arktype"
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
import { InvalidSourceCredentialsError } from "../sources/errors.ts"
const caldavConfig = type({
"+": "reject",
serverUrl: "string",
username: "string",
"lookAheadDays?": "number",
"timeZone?": "string",
})
const caldavCredentials = type({
"+": "reject",
password: "string",
})
export class CalDavSourceProvider implements FeedSourceProvider {
readonly sourceId = "aelis.caldav"
readonly configSchema = caldavConfig
async feedSourceForUser(
_userId: string,
config: unknown,
credentials: unknown,
): Promise<CalDavSource> {
const parsed = caldavConfig(config)
if (parsed instanceof type.errors) {
throw new Error(`Invalid CalDAV config: ${parsed.summary}`)
}
if (!credentials) {
throw new InvalidSourceCredentialsError("aelis.caldav", "No CalDAV credentials configured")
}
const creds = caldavCredentials(credentials)
if (creds instanceof type.errors) {
throw new InvalidSourceCredentialsError("aelis.caldav", creds.summary)
}
return new CalDavSource({
serverUrl: parsed.serverUrl,
authMethod: "basic",
username: parsed.username,
password: creds.password,
lookAheadDays: parsed.lookAheadDays,
timeZone: parsed.timeZone,
})
}
}

View File

@@ -1,12 +1,9 @@
import type { PgDatabase } from "drizzle-orm/pg-core"
import { SQL } from "bun" import { SQL } from "bun"
import { drizzle, type BunSQLQueryResultHKT } from "drizzle-orm/bun-sql" import { drizzle, type BunSQLDatabase } from "drizzle-orm/bun-sql"
import * as schema from "./schema.ts" import * as schema from "./schema.ts"
/** Covers both the top-level drizzle instance and transaction handles. */ export type Database = BunSQLDatabase<typeof schema>
export type Database = PgDatabase<BunSQLQueryResultHKT, typeof schema>
export interface DatabaseConnection { export interface DatabaseConnection {
db: Database db: Database

View File

@@ -47,3 +47,5 @@ export function createFeedEnhancer(config: FeedEnhancerConfig): FeedEnhancer {
return mergeEnhancement(items, result, currentTime) return mergeEnhancement(items, result, currentTime)
} }
} }

View File

@@ -36,7 +36,8 @@ export function buildPrompt(
for (const item of items) { for (const item of items) {
const hasUnfilledSlots = const hasUnfilledSlots =
item.slots && Object.values(item.slots).some((slot) => slot.content === null) item.slots &&
Object.values(item.slots).some((slot) => slot.content === null)
if (hasUnfilledSlots) { if (hasUnfilledSlots) {
enhanceItems.push({ enhanceItems.push({
@@ -78,7 +79,9 @@ export function buildPrompt(
*/ */
export function hasUnfilledSlots(items: FeedItem[]): boolean { export function hasUnfilledSlots(items: FeedItem[]): boolean {
return items.some( return items.some(
(item) => item.slots && Object.values(item.slots).some((slot) => slot.content === null), (item) =>
item.slots &&
Object.values(item.slots).some((slot) => slot.content === null),
) )
} }
@@ -126,20 +129,7 @@ function extractCalendarEntry(item: FeedItem): CalendarEntry | null {
} }
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const
const MONTHS = [ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
] as const
function pad2(n: number): string { function pad2(n: number): string {
return n.toString().padStart(2, "0") return n.toString().padStart(2, "0")
@@ -154,11 +144,7 @@ function formatDayShort(date: Date): string {
} }
function formatDayLabel(date: Date, currentTime: Date): string { function formatDayLabel(date: Date, currentTime: Date): string {
const currentDay = Date.UTC( const currentDay = Date.UTC(currentTime.getUTCFullYear(), currentTime.getUTCMonth(), currentTime.getUTCDate())
currentTime.getUTCFullYear(),
currentTime.getUTCMonth(),
currentTime.getUTCDate(),
)
const targetDay = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) const targetDay = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
const diffDays = Math.round((targetDay - currentDay) / (1000 * 60 * 60 * 24)) const diffDays = Math.round((targetDay - currentDay) / (1000 * 60 * 60 * 24))

View File

@@ -135,7 +135,9 @@ describe("schema sync", () => {
// JSON Schema structure matches // JSON Schema structure matches
const jsonSchema = enhancementResultJsonSchema const jsonSchema = enhancementResultJsonSchema
expect(Object.keys(jsonSchema.properties).sort()).toEqual(Object.keys(payload).sort()) expect(Object.keys(jsonSchema.properties).sort()).toEqual(
Object.keys(payload).sort(),
)
expect([...jsonSchema.required].sort()).toEqual(Object.keys(payload).sort()) expect([...jsonSchema.required].sort()).toEqual(Object.keys(payload).sort())
// syntheticItems item schema has the right required fields // syntheticItems item schema has the right required fields
@@ -165,7 +167,11 @@ describe("schema sync", () => {
// JSON Schema only allows string or null for slot values // JSON Schema only allows string or null for slot values
const slotValueSchema = const slotValueSchema =
enhancementResultJsonSchema.properties.slotFills.additionalProperties.additionalProperties enhancementResultJsonSchema.properties.slotFills.additionalProperties
expect(slotValueSchema.anyOf).toEqual([{ type: "string" }, { type: "null" }]) .additionalProperties
expect(slotValueSchema.anyOf).toEqual([
{ type: "string" },
{ type: "null" },
])
}) })
}) })

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { randomBytes } from "node:crypto" import { randomBytes } from "node:crypto"
import { describe, expect, test } from "bun:test"
import { CredentialEncryptor } from "./crypto.ts" import { CredentialEncryptor } from "./crypto.ts"

View File

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

View File

@@ -6,12 +6,10 @@ import { createRequireAdmin } from "./auth/admin-middleware.ts"
import { registerAuthHandlers } from "./auth/http.ts" import { registerAuthHandlers } from "./auth/http.ts"
import { createAuth } from "./auth/index.ts" import { createAuth } from "./auth/index.ts"
import { createRequireSession } from "./auth/session-middleware.ts" import { createRequireSession } from "./auth/session-middleware.ts"
import { CalDavSourceProvider } from "./caldav/provider.ts"
import { createDatabase } from "./db/index.ts" 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"
@@ -36,20 +34,9 @@ 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: [
new CalDavSourceProvider(),
new LocationSourceProvider(), new LocationSourceProvider(),
new WeatherSourceProvider({ new WeatherSourceProvider({
credentials: { credentials: {
@@ -62,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()

View File

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

View File

@@ -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,39 +68,10 @@ mock.module("../sources/user-sources.ts", () => ({
updatedAt: now, updatedAt: now,
} }
}, },
async findForUpdate(sourceId: string) {
// Delegates to find — row locking is a no-op in tests.
if (mockFindResult !== undefined) return mockFindResult
const now = new Date()
return {
id: crypto.randomUUID(),
userId,
sourceId,
enabled: true,
config: {},
credentials: null,
createdAt: now,
updatedAt: now,
}
},
async updateConfig(_sourceId: string, _update: { enabled?: boolean; config?: unknown }) {
// no-op for tests
},
async upsertConfig(_sourceId: string, _data: { enabled: boolean; config: unknown }) {
// no-op for tests
},
async updateCredentials(sourceId: string, credentials: Buffer) {
if (mockUpdateCredentialsError) {
throw mockUpdateCredentialsError
}
mockUpdateCredentialsCalls.push({ sourceId, credentials })
},
}), }),
})) }))
const fakeDb = { const fakeDb = {} as Database
transaction: <T>(fn: (tx: unknown) => Promise<T>) => fn(fakeDb),
} as unknown as Database
function createStubSource(id: string, items: FeedItem[] = []): FeedSource { function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
return { return {
@@ -135,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 }
} }
@@ -161,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", () => {
@@ -728,240 +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()
})
})
describe("UserSessionManager.saveSourceConfig", () => {
test("upserts config without credentials (existing behavior)", 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,
})
// Create a session first so we can verify the source is refreshed
await manager.getOrCreate("user-1")
await manager.saveSourceConfig("user-1", "test", {
enabled: true,
config: { key: "value" },
})
// feedSourceForUser called once for session creation, once for upsert refresh
expect(factory).toHaveBeenCalledTimes(2)
// No credentials should have been persisted
expect(mockUpdateCredentialsCalls).toHaveLength(0)
})
test("upserts config with credentials — persists both and passes credentials to source", async () => {
setEnabledSources(["test"])
let receivedCredentials: unknown = null
const factory = mock(async (_userId: string, _config: unknown, creds: unknown) => {
receivedCredentials = creds
return createStubSource("test")
})
const provider: FeedSourceProvider = { sourceId: "test", feedSourceForUser: factory }
const manager = new UserSessionManager({
db: fakeDb,
providers: [provider],
credentialEncryptor: testEncryptor,
})
// Create a session so the source refresh path runs
await manager.getOrCreate("user-1")
const creds = { username: "alice", password: "s3cret" }
await manager.saveSourceConfig("user-1", "test", {
enabled: true,
config: { serverUrl: "https://example.com" },
credentials: creds,
})
// Credentials were encrypted and persisted
expect(mockUpdateCredentialsCalls).toHaveLength(1)
const decrypted = JSON.parse(testEncryptor.decrypt(mockUpdateCredentialsCalls[0]!.credentials))
expect(decrypted).toEqual(creds)
// feedSourceForUser received the provided credentials (not null)
expect(receivedCredentials).toEqual(creds)
})
test("upserts config with credentials adds source to session when not already present", async () => {
// Start with no enabled sources so the session is empty
setEnabledSources([])
const factory = mock(async () => createStubSource("test"))
const provider: FeedSourceProvider = { sourceId: "test", feedSourceForUser: factory }
const manager = new UserSessionManager({
db: fakeDb,
providers: [provider],
credentialEncryptor: testEncryptor,
})
const session = await manager.getOrCreate("user-1")
expect(session.hasSource("test")).toBe(false)
// Set mockFindResult to undefined so find() returns a row (simulating the row was just created by upsertConfig)
await manager.saveSourceConfig("user-1", "test", {
enabled: true,
config: {},
credentials: { token: "abc" },
})
// Source should now be in the session
expect(session.hasSource("test")).toBe(true)
expect(mockUpdateCredentialsCalls).toHaveLength(1)
})
test("throws CredentialStorageUnavailableError when credentials provided without encryptor", async () => {
setEnabledSources(["test"])
const provider = createStubProvider("test")
const manager = new UserSessionManager({
db: fakeDb,
providers: [provider],
// No credentialEncryptor
})
await expect(
manager.saveSourceConfig("user-1", "test", {
enabled: true,
config: {},
credentials: { token: "abc" },
}),
).rejects.toBeInstanceOf(CredentialStorageUnavailableError)
})
test("throws SourceNotFoundError for unknown provider", async () => {
const manager = new UserSessionManager({
db: fakeDb,
providers: [],
credentialEncryptor: testEncryptor,
})
await expect(
manager.saveSourceConfig("user-1", "unknown", {
enabled: true,
config: {},
}),
).rejects.toBeInstanceOf(SourceNotFoundError)
})
})

View File

@@ -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,14 +120,14 @@ export class UserSessionManager {
return return
} }
// Use a transaction with SELECT FOR UPDATE to prevent lost updates // When config is provided, fetch existing to deep-merge before validating.
// when concurrent PATCH requests merge config against the same base. // NOTE: find + updateConfig is not atomic. A concurrent update could
const { existingRow, mergedConfig } = await this.db.transaction(async (tx) => { // read stale config. Use SELECT FOR UPDATE or atomic jsonb merge if
const existingRow = await sources(tx, userId).findForUpdate(sourceId) // this becomes a problem.
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)
@@ -143,14 +137,11 @@ export class UserSessionManager {
} }
// Throws SourceNotFoundError if the row doesn't exist // Throws SourceNotFoundError if the row doesn't exist
await sources(tx, userId).updateConfig(sourceId, { await sources(this.db, userId).updateConfig(sourceId, {
enabled: update.enabled, enabled: update.enabled,
config: mergedConfig, config: mergedConfig,
}) })
return { existingRow, mergedConfig }
})
// Refresh the specific source in the active session instead of // Refresh the specific source in the active session instead of
// destroying the entire session. // destroying the entire session.
const session = this.sessions.get(userId) const session = this.sessions.get(userId)
@@ -158,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)
} }
} }
@@ -173,18 +161,13 @@ export class UserSessionManager {
* inserts a new row if one doesn't exist and fully replaces config * inserts a new row if one doesn't exist and fully replaces config
* (no merge). * (no merge).
* *
* When `credentials` is provided, they are encrypted and persisted
* alongside the config in the same flow, avoiding the race condition
* of separate config + credential requests.
*
* @throws {SourceNotFoundError} if the sourceId has no registered provider * @throws {SourceNotFoundError} if the sourceId has no registered provider
* @throws {InvalidSourceConfigError} if config fails schema validation * @throws {InvalidSourceConfigError} if config fails schema validation
* @throws {CredentialStorageUnavailableError} if credentials are provided but no encryptor is configured
*/ */
async saveSourceConfig( async upsertSourceConfig(
userId: string, userId: string,
sourceId: string, sourceId: string,
data: { enabled: boolean; config?: unknown; credentials?: unknown }, data: { enabled: boolean; config?: unknown },
): Promise<void> { ): Promise<void> {
const provider = this.providers.get(sourceId) const provider = this.providers.get(sourceId)
if (!provider) { if (!provider) {
@@ -198,43 +181,18 @@ export class UserSessionManager {
} }
} }
if (data.credentials !== undefined && !this.encryptor) {
throw new CredentialStorageUnavailableError()
}
const config = data.config ?? {} const config = data.config ?? {}
await sources(this.db, userId).upsertConfig(sourceId, {
// Run the upsert + credential update atomically so a failure in
// either step doesn't leave the row in an inconsistent state.
const existingRow = await this.db.transaction(async (tx) => {
const existing = await sources(tx, userId).find(sourceId)
await sources(tx, userId).upsertConfig(sourceId, {
enabled: data.enabled, enabled: data.enabled,
config, config,
}) })
if (data.credentials !== undefined && this.encryptor) {
const encrypted = this.encryptor.encrypt(JSON.stringify(data.credentials))
await sources(tx, userId).updateCredentials(sourceId, encrypted)
}
return existing
})
const session = this.sessions.get(userId) const session = this.sessions.get(userId)
if (session) { if (session) {
if (!data.enabled) { if (!data.enabled) {
session.removeSource(sourceId) session.removeSource(sourceId)
} else { } else {
// Prefer the just-provided credentials over what was in the DB. const source = await provider.feedSourceForUser(userId, config)
let credentials: unknown = null
if (data.credentials !== undefined) {
credentials = data.credentials
} else if (existingRow?.credentials) {
credentials = this.decryptCredentials(existingRow.credentials)
}
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 {
@@ -244,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.
@@ -334,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(
@@ -356,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))
} }
} }
@@ -388,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
}
}
} }

View File

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

View File

@@ -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)
}, },
} }
@@ -80,9 +79,6 @@ function createInMemoryStore() {
async find(sourceId: string) { async find(sourceId: string) {
return rows.get(key(userId, sourceId)) return rows.get(key(userId, sourceId))
}, },
async findForUpdate(sourceId: string) {
return rows.get(key(userId, sourceId))
},
async updateConfig(sourceId: string, update: { enabled?: boolean; config?: unknown }) { async updateConfig(sourceId: string, update: { enabled?: boolean; config?: unknown }) {
const existing = rows.get(key(userId, sourceId)) const existing = rows.get(key(userId, sourceId))
if (!existing) { if (!existing) {
@@ -109,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)
}
},
} }
}, },
} }
@@ -128,9 +118,7 @@ mock.module("../sources/user-sources.ts", () => ({
}, },
})) }))
const fakeDb = { const fakeDb = {} as Database
transaction: <T>(fn: (tx: unknown) => Promise<T>) => fn(fakeDb),
} as unknown as Database
function createApp(providers: FeedSourceProvider[], userId?: string) { function createApp(providers: FeedSourceProvider[], userId?: string) {
const sessionManager = new UserSessionManager({ providers, db: fakeDb }) const sessionManager = new UserSessionManager({ providers, db: fakeDb })
@@ -154,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",
@@ -743,123 +707,4 @@ describe("PUT /api/sources/:sourceId", () => {
expect(res.status).toBe(204) expect(res.status).toBe(204)
}) })
test("returns 204 when credentials are included alongside config", async () => {
activeStore = createInMemoryStore()
const { app } = createAppWithEncryptor(
[createStubProvider("aelis.weather", weatherConfig)],
MOCK_USER_ID,
)
const res = await put(app, "aelis.weather", {
enabled: true,
config: { units: "metric" },
credentials: { apiKey: "secret123" },
})
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 503 when credentials are provided but no encryptor is configured", async () => {
activeStore = createInMemoryStore()
// createApp does NOT configure an encryptor
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await put(app, "aelis.weather", {
enabled: true,
config: { units: "metric" },
credentials: { apiKey: "secret123" },
})
expect(res.status).toBe(503)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("not configured")
})
})
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")
})
}) })

View File

@@ -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: {
@@ -34,13 +29,11 @@ const ReplaceSourceConfigRequestBody = type({
"+": "reject", "+": "reject",
enabled: "boolean", enabled: "boolean",
config: "unknown", config: "unknown",
"credentials?": "unknown",
}) })
const ReplaceSourceConfigNoConfigRequestBody = type({ const ReplaceSourceConfigNoConfigRequestBody = type({
"+": "reject", "+": "reject",
enabled: "boolean", enabled: "boolean",
"credentials?": "unknown",
}) })
export function registerSourcesHttpHandlers( export function registerSourcesHttpHandlers(
@@ -55,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>) {
@@ -163,15 +150,14 @@ async function handleReplaceSource(c: Context<Env>) {
return c.json({ error: parsed.summary }, 400) return c.json({ error: parsed.summary }, 400)
} }
const { enabled, credentials } = parsed const { enabled } = parsed
const config = "config" in parsed ? parsed.config : undefined const config = "config" in parsed ? parsed.config : undefined
const user = c.get("user")! const user = c.get("user")!
try { try {
await sessionManager.saveSourceConfig(user.id, sourceId, { await sessionManager.upsertSourceConfig(user.id, sourceId, {
enabled, enabled,
config, config,
credentials,
}) })
} catch (err) { } catch (err) {
if (err instanceof SourceNotFoundError) { if (err instanceof SourceNotFoundError) {
@@ -180,49 +166,6 @@ async function handleReplaceSource(c: Context<Env>) {
if (err instanceof InvalidSourceConfigError) { if (err instanceof InvalidSourceConfigError) {
return c.json({ error: err.message }, 400) 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)
}
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 throw err
} }

View File

@@ -26,18 +26,6 @@ export function sources(db: Database, userId: string) {
return rows[0] return rows[0]
}, },
/** Like find(), but acquires a row lock to prevent concurrent modifications. Must be called inside a transaction. */
async findForUpdate(sourceId: string) {
const rows = await db
.select()
.from(userSources)
.where(and(eq(userSources.userId, userId), eq(userSources.sourceId, sourceId)))
.limit(1)
.for("update")
return rows[0]
},
/** Enables a source for the user. Throws if the source row doesn't exist. */ /** Enables a source for the user. Throws if the source row doesn't exist. */
async enableSource(sourceId: string) { async enableSource(sourceId: string) {
const rows = await db const rows = await db

View File

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

View File

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

View File

@@ -55,112 +55,44 @@
"fontFamily": "Inter", "fontFamily": "Inter",
"fontDefinitions": [ "fontDefinitions": [
{ "path": "./assets/fonts/Inter_100Thin.ttf", "weight": 100 }, { "path": "./assets/fonts/Inter_100Thin.ttf", "weight": 100 },
{ { "path": "./assets/fonts/Inter_100Thin_Italic.ttf", "weight": 100, "style": "italic" },
"path": "./assets/fonts/Inter_100Thin_Italic.ttf",
"weight": 100,
"style": "italic"
},
{ "path": "./assets/fonts/Inter_200ExtraLight.ttf", "weight": 200 }, { "path": "./assets/fonts/Inter_200ExtraLight.ttf", "weight": 200 },
{ { "path": "./assets/fonts/Inter_200ExtraLight_Italic.ttf", "weight": 200, "style": "italic" },
"path": "./assets/fonts/Inter_200ExtraLight_Italic.ttf",
"weight": 200,
"style": "italic"
},
{ "path": "./assets/fonts/Inter_300Light.ttf", "weight": 300 }, { "path": "./assets/fonts/Inter_300Light.ttf", "weight": 300 },
{ { "path": "./assets/fonts/Inter_300Light_Italic.ttf", "weight": 300, "style": "italic" },
"path": "./assets/fonts/Inter_300Light_Italic.ttf",
"weight": 300,
"style": "italic"
},
{ "path": "./assets/fonts/Inter_400Regular.ttf", "weight": 400 }, { "path": "./assets/fonts/Inter_400Regular.ttf", "weight": 400 },
{ { "path": "./assets/fonts/Inter_400Regular_Italic.ttf", "weight": 400, "style": "italic" },
"path": "./assets/fonts/Inter_400Regular_Italic.ttf",
"weight": 400,
"style": "italic"
},
{ "path": "./assets/fonts/Inter_500Medium.ttf", "weight": 500 }, { "path": "./assets/fonts/Inter_500Medium.ttf", "weight": 500 },
{ { "path": "./assets/fonts/Inter_500Medium_Italic.ttf", "weight": 500, "style": "italic" },
"path": "./assets/fonts/Inter_500Medium_Italic.ttf",
"weight": 500,
"style": "italic"
},
{ "path": "./assets/fonts/Inter_600SemiBold.ttf", "weight": 600 }, { "path": "./assets/fonts/Inter_600SemiBold.ttf", "weight": 600 },
{ { "path": "./assets/fonts/Inter_600SemiBold_Italic.ttf", "weight": 600, "style": "italic" },
"path": "./assets/fonts/Inter_600SemiBold_Italic.ttf",
"weight": 600,
"style": "italic"
},
{ "path": "./assets/fonts/Inter_700Bold.ttf", "weight": 700 }, { "path": "./assets/fonts/Inter_700Bold.ttf", "weight": 700 },
{ { "path": "./assets/fonts/Inter_700Bold_Italic.ttf", "weight": 700, "style": "italic" },
"path": "./assets/fonts/Inter_700Bold_Italic.ttf",
"weight": 700,
"style": "italic"
},
{ "path": "./assets/fonts/Inter_800ExtraBold.ttf", "weight": 800 }, { "path": "./assets/fonts/Inter_800ExtraBold.ttf", "weight": 800 },
{ { "path": "./assets/fonts/Inter_800ExtraBold_Italic.ttf", "weight": 800, "style": "italic" },
"path": "./assets/fonts/Inter_800ExtraBold_Italic.ttf",
"weight": 800,
"style": "italic"
},
{ "path": "./assets/fonts/Inter_900Black.ttf", "weight": 900 }, { "path": "./assets/fonts/Inter_900Black.ttf", "weight": 900 },
{ { "path": "./assets/fonts/Inter_900Black_Italic.ttf", "weight": 900, "style": "italic" }
"path": "./assets/fonts/Inter_900Black_Italic.ttf",
"weight": 900,
"style": "italic"
}
] ]
}, },
{ {
"fontFamily": "Source Serif 4", "fontFamily": "Source Serif 4",
"fontDefinitions": [ "fontDefinitions": [
{ "path": "./assets/fonts/SourceSerif4_200ExtraLight.ttf", "weight": 200 }, { "path": "./assets/fonts/SourceSerif4_200ExtraLight.ttf", "weight": 200 },
{ { "path": "./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf", "weight": 200, "style": "italic" },
"path": "./assets/fonts/SourceSerif4_200ExtraLight_Italic.ttf",
"weight": 200,
"style": "italic"
},
{ "path": "./assets/fonts/SourceSerif4_300Light.ttf", "weight": 300 }, { "path": "./assets/fonts/SourceSerif4_300Light.ttf", "weight": 300 },
{ { "path": "./assets/fonts/SourceSerif4_300Light_Italic.ttf", "weight": 300, "style": "italic" },
"path": "./assets/fonts/SourceSerif4_300Light_Italic.ttf",
"weight": 300,
"style": "italic"
},
{ "path": "./assets/fonts/SourceSerif4_400Regular.ttf", "weight": 400 }, { "path": "./assets/fonts/SourceSerif4_400Regular.ttf", "weight": 400 },
{ { "path": "./assets/fonts/SourceSerif4_400Regular_Italic.ttf", "weight": 400, "style": "italic" },
"path": "./assets/fonts/SourceSerif4_400Regular_Italic.ttf",
"weight": 400,
"style": "italic"
},
{ "path": "./assets/fonts/SourceSerif4_500Medium.ttf", "weight": 500 }, { "path": "./assets/fonts/SourceSerif4_500Medium.ttf", "weight": 500 },
{ { "path": "./assets/fonts/SourceSerif4_500Medium_Italic.ttf", "weight": 500, "style": "italic" },
"path": "./assets/fonts/SourceSerif4_500Medium_Italic.ttf",
"weight": 500,
"style": "italic"
},
{ "path": "./assets/fonts/SourceSerif4_600SemiBold.ttf", "weight": 600 }, { "path": "./assets/fonts/SourceSerif4_600SemiBold.ttf", "weight": 600 },
{ { "path": "./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf", "weight": 600, "style": "italic" },
"path": "./assets/fonts/SourceSerif4_600SemiBold_Italic.ttf",
"weight": 600,
"style": "italic"
},
{ "path": "./assets/fonts/SourceSerif4_700Bold.ttf", "weight": 700 }, { "path": "./assets/fonts/SourceSerif4_700Bold.ttf", "weight": 700 },
{ { "path": "./assets/fonts/SourceSerif4_700Bold_Italic.ttf", "weight": 700, "style": "italic" },
"path": "./assets/fonts/SourceSerif4_700Bold_Italic.ttf",
"weight": 700,
"style": "italic"
},
{ "path": "./assets/fonts/SourceSerif4_800ExtraBold.ttf", "weight": 800 }, { "path": "./assets/fonts/SourceSerif4_800ExtraBold.ttf", "weight": 800 },
{ { "path": "./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf", "weight": 800, "style": "italic" },
"path": "./assets/fonts/SourceSerif4_800ExtraBold_Italic.ttf",
"weight": 800,
"style": "italic"
},
{ "path": "./assets/fonts/SourceSerif4_900Black.ttf", "weight": 900 }, { "path": "./assets/fonts/SourceSerif4_900Black.ttf", "weight": 900 },
{ { "path": "./assets/fonts/SourceSerif4_900Black_Italic.ttf", "weight": 900, "style": "italic" }
"path": "./assets/fonts/SourceSerif4_900Black_Italic.ttf",
"weight": 900,
"style": "italic"
}
] ]
} }
] ]

View File

@@ -55,6 +55,6 @@
"eas-cli": "^18.0.1", "eas-cli": "^18.0.1",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
"typescript": "^6" "typescript": "~5.9.2"
} }
} }

View File

@@ -27,8 +27,8 @@ export class ApiClient {
(prevInit, middleware) => middleware(url, prevInit), (prevInit, middleware) => middleware(url, prevInit),
init, init,
) )
return fetch(this.baseUrl ? new URL(url.toString(), this.baseUrl) : url, finalInit).then( return fetch(this.baseUrl ? new URL(url.toString(), this.baseUrl) : url, finalInit).then((res) =>
(res) => Promise.all([Promise.resolve(res), res.json()]), Promise.all([Promise.resolve(res), res.json()]),
) )
} }
} }

View File

@@ -3,13 +3,13 @@ import { useEffect } from "react"
import { ScrollView, View } from "react-native" import { ScrollView, View } from "react-native"
import tw from "twrnc" import tw from "twrnc"
import { type Showcase } from "@/components/showcase"
import { buttonShowcase } from "@/components/ui/button.showcase" import { buttonShowcase } from "@/components/ui/button.showcase"
import { feedCardShowcase } from "@/components/ui/feed-card.showcase" import { feedCardShowcase } from "@/components/ui/feed-card.showcase"
import { monospaceTextShowcase } from "@/components/ui/monospace-text.showcase" import { monospaceTextShowcase } from "@/components/ui/monospace-text.showcase"
import { SansSerifText } from "@/components/ui/sans-serif-text"
import { sansSerifTextShowcase } from "@/components/ui/sans-serif-text.showcase" import { sansSerifTextShowcase } from "@/components/ui/sans-serif-text.showcase"
import { serifTextShowcase } from "@/components/ui/serif-text.showcase" import { serifTextShowcase } from "@/components/ui/serif-text.showcase"
import { type Showcase } from "@/components/showcase"
import { SansSerifText } from "@/components/ui/sans-serif-text"
const showcases: Record<string, Showcase> = { const showcases: Record<string, Showcase> = {
button: buttonShowcase, button: buttonShowcase,
@@ -41,10 +41,7 @@ export default function ComponentDetailScreen() {
const ShowcaseComponent = showcase.component const ShowcaseComponent = showcase.component
return ( return (
<ScrollView <ScrollView style={tw`bg-stone-100 dark:bg-stone-900 flex-1`} contentContainerStyle={tw`px-5 pb-10 pt-4 gap-6`}>
style={tw`bg-stone-100 dark:bg-stone-900 flex-1`}
contentContainerStyle={tw`px-5 pb-10 pt-4 gap-6`}
>
<ShowcaseComponent /> <ShowcaseComponent />
</ScrollView> </ScrollView>
) )

View File

@@ -15,9 +15,7 @@ const components = [
export default function ComponentsScreen() { export default function ComponentsScreen() {
return ( return (
<View style={tw`flex-1`}> <View style={tw`flex-1`}>
<View <View style={tw`mx-4 mt-4 rounded-xl border border-stone-200 dark:border-stone-800 overflow-hidden`}>
style={tw`mx-4 mt-4 rounded-xl border border-stone-200 dark:border-stone-800 overflow-hidden`}
>
<FlatList <FlatList
data={components} data={components}
keyExtractor={(item) => item.name} keyExtractor={(item) => item.name}

View File

@@ -1,8 +1,8 @@
import { View } from "react-native" import { View } from "react-native"
import tw from "twrnc" import tw from "twrnc"
import { type Showcase, Section } from "../showcase"
import { Button } from "./button" import { Button } from "./button"
import { type Showcase, Section } from "../showcase"
function ButtonShowcase() { function ButtonShowcase() {
return ( return (
@@ -11,7 +11,11 @@ function ButtonShowcase() {
<Button style={tw`self-start`} label="Press me" /> <Button style={tw`self-start`} label="Press me" />
</Section> </Section>
<Section title="Leading icon"> <Section title="Leading icon">
<Button style={tw`self-start`} label="Add item" leadingIcon={<Button.Icon name="plus" />} /> <Button
style={tw`self-start`}
label="Add item"
leadingIcon={<Button.Icon name="plus" />}
/>
</Section> </Section>
<Section title="Trailing icon"> <Section title="Trailing icon">
<Button <Button

View File

@@ -23,11 +23,7 @@ type ButtonProps = Omit<PressableProps, "children"> & {
export function Button({ style, label, leadingIcon, trailingIcon, ...props }: ButtonProps) { export function Button({ style, label, leadingIcon, trailingIcon, ...props }: ButtonProps) {
const hasIcons = leadingIcon != null || trailingIcon != null const hasIcons = leadingIcon != null || trailingIcon != null
const textElement = ( const textElement = <SansSerifText style={tw`text-stone-100 dark:text-stone-200 font-medium`}>{label}</SansSerifText>
<SansSerifText style={tw`text-stone-100 dark:text-stone-200 font-medium`}>
{label}
</SansSerifText>
)
return ( return (
<Pressable style={[tw`rounded-full bg-teal-600 px-4 py-3 w-fit`, style]} {...props}> <Pressable style={[tw`rounded-full bg-teal-600 px-4 py-3 w-fit`, style]} {...props}>

View File

@@ -1,11 +1,11 @@
import { View } from "react-native" import { View } from "react-native"
import tw from "twrnc" import tw from "twrnc"
import { type Showcase, Section } from "../showcase"
import { Button } from "./button" import { Button } from "./button"
import { FeedCard } from "./feed-card" import { FeedCard } from "./feed-card"
import { SansSerifText } from "./sans-serif-text" import { SansSerifText } from "./sans-serif-text"
import { SerifText } from "./serif-text" import { SerifText } from "./serif-text"
import { type Showcase, Section } from "../showcase"
function FeedCardShowcase() { function FeedCardShowcase() {
return ( return (

View File

@@ -2,10 +2,5 @@ import { View, type ViewProps } from "react-native"
import tw from "twrnc" import tw from "twrnc"
export function FeedCard({ style, ...props }: ViewProps) { export function FeedCard({ style, ...props }: ViewProps) {
return ( return <View style={[tw`border border-stone-200 dark:border-stone-800 rounded-lg`, style]} {...props} />
<View
style={[tw`border border-stone-200 dark:border-stone-800 rounded-lg`, style]}
{...props}
/>
)
} }

View File

@@ -1,8 +1,8 @@
import { View } from "react-native" import { View } from "react-native"
import tw from "twrnc" import tw from "twrnc"
import { type Showcase, Section } from "../showcase"
import { MonospaceText } from "./monospace-text" import { MonospaceText } from "./monospace-text"
import { type Showcase, Section } from "../showcase"
function MonospaceTextShowcase() { function MonospaceTextShowcase() {
return ( return (

View File

@@ -3,10 +3,7 @@ import tw from "twrnc"
export function MonospaceText({ children, style, ...props }: TextProps) { export function MonospaceText({ children, style, ...props }: TextProps) {
return ( return (
<Text <Text style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Menlo" }, style]} {...props}>
style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Menlo" }, style]}
{...props}
>
{children} {children}
</Text> </Text>
) )

View File

@@ -1,8 +1,8 @@
import { View } from "react-native" import { View } from "react-native"
import tw from "twrnc" import tw from "twrnc"
import { type Showcase, Section } from "../showcase"
import { SansSerifText } from "./sans-serif-text" import { SansSerifText } from "./sans-serif-text"
import { type Showcase, Section } from "../showcase"
function SansSerifTextShowcase() { function SansSerifTextShowcase() {
return ( return (

View File

@@ -3,10 +3,7 @@ import tw from "twrnc"
export function SansSerifText({ children, style, ...props }: TextProps) { export function SansSerifText({ children, style, ...props }: TextProps) {
return ( return (
<Text <Text style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Inter" }, style]} {...props}>
style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Inter" }, style]}
{...props}
>
{children} {children}
</Text> </Text>
) )

View File

@@ -1,8 +1,8 @@
import { View } from "react-native" import { View } from "react-native"
import tw from "twrnc" import tw from "twrnc"
import { type Showcase, Section } from "../showcase"
import { SerifText } from "./serif-text" import { SerifText } from "./serif-text"
import { type Showcase, Section } from "../showcase"
function SerifTextShowcase() { function SerifTextShowcase() {
return ( return (

View File

@@ -3,10 +3,7 @@ import tw from "twrnc"
export function SerifText({ children, style, ...props }: TextProps) { export function SerifText({ children, style, ...props }: TextProps) {
return ( return (
<Text <Text style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Source Serif 4" }, style]} {...props}>
style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Source Serif 4" }, style]}
{...props}
>
{children} {children}
</Text> </Text>
) )

View File

@@ -30,8 +30,7 @@ export const catalog = defineCatalog(schema, {
style: z.string().nullable(), style: z.string().nullable(),
}), }),
slots: ["default"], slots: ["default"],
description: description: "Bordered card container for feed content. The style prop accepts a twrnc class string.",
"Bordered card container for feed content. The style prop accepts a twrnc class string.",
example: { style: "p-4 gap-2" }, example: { style: "p-4 gap-2" },
}, },
SansSerifText: { SansSerifText: {

View File

@@ -14,20 +14,12 @@ type ButtonIconName = React.ComponentProps<typeof Button.Icon>["name"]
export const { registry } = defineRegistry(catalog, { export const { registry } = defineRegistry(catalog, {
components: { components: {
View: ({ props, children }) => ( View: ({ props, children }) => <View style={props.style ? tw`${props.style}` : undefined}>{children}</View>,
<View style={props.style ? tw`${props.style}` : undefined}>{children}</View>
),
Button: ({ props, emit }) => ( Button: ({ props, emit }) => (
<Button <Button
label={props.label} label={props.label}
leadingIcon={ leadingIcon={props.leadingIcon ? <Button.Icon name={props.leadingIcon as ButtonIconName} /> : undefined}
props.leadingIcon ? <Button.Icon name={props.leadingIcon as ButtonIconName} /> : undefined trailingIcon={props.trailingIcon ? <Button.Icon name={props.trailingIcon as ButtonIconName} /> : undefined}
}
trailingIcon={
props.trailingIcon ? (
<Button.Icon name={props.trailingIcon as ButtonIconName} />
) : undefined
}
onPress={() => emit("press")} onPress={() => emit("press")}
/> />
), ),
@@ -35,17 +27,13 @@ export const { registry } = defineRegistry(catalog, {
<FeedCard style={props.style ? tw`${props.style}` : undefined}>{children}</FeedCard> <FeedCard style={props.style ? tw`${props.style}` : undefined}>{children}</FeedCard>
), ),
SansSerifText: ({ props }) => ( SansSerifText: ({ props }) => (
<SansSerifText style={props.style ? tw`${props.style}` : undefined}> <SansSerifText style={props.style ? tw`${props.style}` : undefined}>{props.text}</SansSerifText>
{props.text}
</SansSerifText>
), ),
SerifText: ({ props }) => ( SerifText: ({ props }) => (
<SerifText style={props.style ? tw`${props.style}` : undefined}>{props.text}</SerifText> <SerifText style={props.style ? tw`${props.style}` : undefined}>{props.text}</SerifText>
), ),
MonospaceText: ({ props }) => ( MonospaceText: ({ props }) => (
<MonospaceText style={props.style ? tw`${props.style}` : undefined}> <MonospaceText style={props.style ? tw`${props.style}` : undefined}>{props.text}</MonospaceText>
{props.text}
</MonospaceText>
), ),
}, },
}) })

View File

@@ -1,131 +1 @@
{ {"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":7,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,53]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,122]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":0,"k":[160,53]},"s":{"a":0,"k":[320,106]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":0,"k":[160,53]},"p":{"a":0,"k":[200.5,200]},"r":{"a":1,"k":[{"t":0,"s":[-30],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":6,"s":[-10],"h":1}]}},"ip":0,"op":7,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":7,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,53]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,122]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":0,"k":[160,53]},"s":{"a":0,"k":[320,106]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":0,"k":[160,53]},"p":{"a":0,"k":[200.594,200.176]},"r":{"a":1,"k":[{"t":0,"s":[30],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":6,"s":[10],"h":1}]}},"ip":0,"op":7,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":7,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":7,"st":0}],"meta":{"g":"https://jitter.video"},"op":6,"v":"5.7.4","w":400}
"fr": 60,
"h": 400,
"ip": 0,
"layers": [
{
"ind": 3,
"ty": 4,
"parent": 2,
"ks": {},
"ip": 0,
"op": 7,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 53] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 122] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{ "ty": "el", "p": { "a": 0, "k": [160, 53] }, "s": { "a": 0, "k": [320, 106] } },
{
"ty": "st",
"c": { "a": 0, "k": [0.906, 0.898, 0.894] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 2,
"ty": 3,
"parent": 1,
"ks": {
"a": { "a": 0, "k": [160, 53] },
"p": { "a": 0, "k": [200.5, 200] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [-30], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 6, "s": [-10], "h": 1 }
]
}
},
"ip": 0,
"op": 7,
"st": 0
},
{
"ind": 5,
"ty": 4,
"parent": 4,
"ks": {},
"ip": 0,
"op": 7,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 53] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 122] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{ "ty": "el", "p": { "a": 0, "k": [160, 53] }, "s": { "a": 0, "k": [320, 106] } },
{
"ty": "st",
"c": { "a": 0, "k": [0.906, 0.898, 0.894] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 4,
"ty": 3,
"parent": 1,
"ks": {
"a": { "a": 0, "k": [160, 53] },
"p": { "a": 0, "k": [200.594, 200.176] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [30], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 6, "s": [10], "h": 1 }
]
}
},
"ip": 0,
"op": 7,
"st": 0
},
{ "ind": 1, "ty": 3, "parent": 0, "ks": {}, "ip": 0, "op": 7, "st": 0 },
{ "ind": 0, "ty": 3, "ks": {}, "ip": 0, "op": 7, "st": 0 }
],
"meta": { "g": "https://jitter.video" },
"op": 6,
"v": "5.7.4",
"w": 400
}

View File

@@ -1,131 +1 @@
{ {"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":7,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,53]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,122]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":0,"k":[160,53]},"s":{"a":0,"k":[320,106]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":0,"k":[160,53]},"p":{"a":0,"k":[200.5,200]},"r":{"a":1,"k":[{"t":0,"s":[-30],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":6,"s":[-10],"h":1}]}},"ip":0,"op":7,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":7,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,53]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,122]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":0,"k":[160,53]},"s":{"a":0,"k":[320,106]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":0,"k":[160,53]},"p":{"a":0,"k":[200.594,200.176]},"r":{"a":1,"k":[{"t":0,"s":[30],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":6,"s":[10],"h":1}]}},"ip":0,"op":7,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":7,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":7,"st":0}],"meta":{"g":"https://jitter.video"},"op":6,"v":"5.7.4","w":400}
"fr": 60,
"h": 400,
"ip": 0,
"layers": [
{
"ind": 3,
"ty": 4,
"parent": 2,
"ks": {},
"ip": 0,
"op": 7,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 53] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 122] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{ "ty": "el", "p": { "a": 0, "k": [160, 53] }, "s": { "a": 0, "k": [320, 106] } },
{
"ty": "st",
"c": { "a": 0, "k": [0.11, 0.098, 0.09] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 2,
"ty": 3,
"parent": 1,
"ks": {
"a": { "a": 0, "k": [160, 53] },
"p": { "a": 0, "k": [200.5, 200] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [-30], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 6, "s": [-10], "h": 1 }
]
}
},
"ip": 0,
"op": 7,
"st": 0
},
{
"ind": 5,
"ty": 4,
"parent": 4,
"ks": {},
"ip": 0,
"op": 7,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 53] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 122] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{ "ty": "el", "p": { "a": 0, "k": [160, 53] }, "s": { "a": 0, "k": [320, 106] } },
{
"ty": "st",
"c": { "a": 0, "k": [0.11, 0.098, 0.09] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 4,
"ty": 3,
"parent": 1,
"ks": {
"a": { "a": 0, "k": [160, 53] },
"p": { "a": 0, "k": [200.594, 200.176] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [30], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 6, "s": [10], "h": 1 }
]
}
},
"ip": 0,
"op": 7,
"st": 0
},
{ "ind": 1, "ty": 3, "parent": 0, "ks": {}, "ip": 0, "op": 7, "st": 0 },
{ "ind": 0, "ty": 3, "ks": {}, "ip": 0, "op": 7, "st": 0 }
],
"meta": { "g": "https://jitter.video" },
"op": 6,
"v": "5.7.4",
"w": 400
}

View File

@@ -1,281 +1 @@
{ {"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,1],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[320,1],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[320,106],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[320,106],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200,200.014]},"r":{"a":1,"k":[{"t":0,"s":[-90],"h":1},{"t":8.4,"s":[-90],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":30,"s":[0],"h":1},{"t":37.8,"s":[0],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":60,"s":[90],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,1],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[320,106],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.094,200.19]},"r":{"a":1,"k":[{"t":0,"s":[-90],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":30,"s":[0],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":60,"s":[90],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":61,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":61,"st":0}],"meta":{"g":"https://jitter.video"},"op":60,"v":"5.7.4","w":400}
"fr": 60,
"h": 400,
"ip": 0,
"layers": [
{
"ind": 3,
"ty": 4,
"parent": 2,
"ks": {},
"ip": 0,
"op": 61,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 26.75] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 174.5] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{
"ty": "el",
"p": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 0.5],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 8.4,
"s": [160, 0.5],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [160, 53],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 37.8,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [160, 0.5], "h": 1 }
]
},
"s": {
"a": 1,
"k": [
{
"t": 0,
"s": [320, 1],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 8.4,
"s": [320, 1],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [320, 106],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 37.8,
"s": [320, 106],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [320, 1], "h": 1 }
]
}
},
{
"ty": "st",
"c": { "a": 0, "k": [0.906, 0.898, 0.894] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 2,
"ty": 3,
"parent": 1,
"ks": {
"a": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 0.5],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 8.4,
"s": [160, 0.5],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [160, 53],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 37.8,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [160, 0.5], "h": 1 }
]
},
"p": { "a": 0, "k": [200, 200.014] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [-90], "h": 1 },
{ "t": 8.4, "s": [-90], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 30, "s": [0], "h": 1 },
{ "t": 37.8, "s": [0], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 60, "s": [90], "h": 1 }
]
}
},
"ip": 0,
"op": 61,
"st": 0
},
{
"ind": 5,
"ty": 4,
"parent": 4,
"ks": {},
"ip": 0,
"op": 61,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 26.75] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 174.5] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{
"ty": "el",
"p": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 0.5],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [160, 0.5], "h": 1 }
]
},
"s": {
"a": 1,
"k": [
{
"t": 0,
"s": [320, 1],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [320, 106],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [320, 1], "h": 1 }
]
}
},
{
"ty": "st",
"c": { "a": 0, "k": [0.906, 0.898, 0.894] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 4,
"ty": 3,
"parent": 1,
"ks": {
"a": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 0.5],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [160, 0.5], "h": 1 }
]
},
"p": { "a": 0, "k": [200.094, 200.19] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [-90], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 30, "s": [0], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 60, "s": [90], "h": 1 }
]
}
},
"ip": 0,
"op": 61,
"st": 0
},
{ "ind": 1, "ty": 3, "parent": 0, "ks": {}, "ip": 0, "op": 61, "st": 0 },
{ "ind": 0, "ty": 3, "ks": {}, "ip": 0, "op": 61, "st": 0 }
],
"meta": { "g": "https://jitter.video" },
"op": 60,
"v": "5.7.4",
"w": 400
}

View File

@@ -1,281 +1 @@
{ {"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,1],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[320,1],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[320,106],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[320,106],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":8.4,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":37.8,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200,200.014]},"r":{"a":1,"k":[{"t":0,"s":[-90],"h":1},{"t":8.4,"s":[-90],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":30,"s":[0],"h":1},{"t":37.8,"s":[0],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":60,"s":[90],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":61,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,1],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[320,106],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,0.5],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":30,"s":[160,53],"i":{"x":[1,0],"y":[1,1]},"o":{"x":[0,0.5],"y":[0,0]}},{"t":60,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.094,200.19]},"r":{"a":1,"k":[{"t":0,"s":[-90],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":30,"s":[0],"i":{"x":0,"y":1},"o":{"x":0.5,"y":0}},{"t":60,"s":[90],"h":1}]}},"ip":0,"op":61,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":61,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":61,"st":0}],"meta":{"g":"https://jitter.video"},"op":60,"v":"5.7.4","w":400}
"fr": 60,
"h": 400,
"ip": 0,
"layers": [
{
"ind": 3,
"ty": 4,
"parent": 2,
"ks": {},
"ip": 0,
"op": 61,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 26.75] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 174.5] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{
"ty": "el",
"p": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 0.5],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 8.4,
"s": [160, 0.5],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [160, 53],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 37.8,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [160, 0.5], "h": 1 }
]
},
"s": {
"a": 1,
"k": [
{
"t": 0,
"s": [320, 1],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 8.4,
"s": [320, 1],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [320, 106],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 37.8,
"s": [320, 106],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [320, 1], "h": 1 }
]
}
},
{
"ty": "st",
"c": { "a": 0, "k": [0.11, 0.098, 0.09] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 2,
"ty": 3,
"parent": 1,
"ks": {
"a": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 0.5],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 8.4,
"s": [160, 0.5],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [160, 53],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 37.8,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [160, 0.5], "h": 1 }
]
},
"p": { "a": 0, "k": [200, 200.014] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [-90], "h": 1 },
{ "t": 8.4, "s": [-90], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 30, "s": [0], "h": 1 },
{ "t": 37.8, "s": [0], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 60, "s": [90], "h": 1 }
]
}
},
"ip": 0,
"op": 61,
"st": 0
},
{
"ind": 5,
"ty": 4,
"parent": 4,
"ks": {},
"ip": 0,
"op": 61,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 26.75] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 174.5] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{
"ty": "el",
"p": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 0.5],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [160, 0.5], "h": 1 }
]
},
"s": {
"a": 1,
"k": [
{
"t": 0,
"s": [320, 1],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [320, 106],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [320, 1], "h": 1 }
]
}
},
{
"ty": "st",
"c": { "a": 0, "k": [0.11, 0.098, 0.09] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 4,
"ty": 3,
"parent": 1,
"ks": {
"a": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 0.5],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{
"t": 30,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 1] },
"o": { "x": [0, 0.5], "y": [0, 0] }
},
{ "t": 60, "s": [160, 0.5], "h": 1 }
]
},
"p": { "a": 0, "k": [200.094, 200.19] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [-90], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 30, "s": [0], "i": { "x": 0, "y": 1 }, "o": { "x": 0.5, "y": 0 } },
{ "t": 60, "s": [90], "h": 1 }
]
}
},
"ip": 0,
"op": 61,
"st": 0
},
{ "ind": 1, "ty": 3, "parent": 0, "ks": {}, "ip": 0, "op": 61, "st": 0 },
{ "ind": 0, "ty": 3, "ks": {}, "ip": 0, "op": 61, "st": 0 }
],
"meta": { "g": "https://jitter.video" },
"op": 60,
"v": "5.7.4",
"w": 400
}

View File

@@ -1,224 +1 @@
{ {"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":31,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[160,53],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,106],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[320,106],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[160,53],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.5,200]},"r":{"a":1,"k":[{"t":0,"s":[-30],"h":1},{"t":5.28,"s":[-30],"i":{"x":0.001,"y":0.998},"o":{"x":0.349,"y":0}},{"t":30,"s":[-90],"h":1}]}},"ip":0,"op":31,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":31,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,106],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.906,0.898,0.894]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.594,200.176]},"r":{"a":1,"k":[{"t":0,"s":[30],"i":{"x":0,"y":0.999},"o":{"x":0.348,"y":0}},{"t":30,"s":[90],"h":1}]}},"ip":0,"op":31,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":31,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":31,"st":0}],"meta":{"g":"https://jitter.video"},"op":30,"v":"5.7.4","w":400}
"fr": 60,
"h": 400,
"ip": 0,
"layers": [
{
"ind": 3,
"ty": 4,
"parent": 2,
"ks": {},
"ip": 0,
"op": 31,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 26.75] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 174.5] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{
"ty": "el",
"p": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 53],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 5.28,
"s": [160, 53],
"i": { "x": [1, 0.001], "y": [1, 0.998] },
"o": { "x": [0, 0.349], "y": [0, 0] }
},
{ "t": 30, "s": [160, 0.5], "h": 1 }
]
},
"s": {
"a": 1,
"k": [
{
"t": 0,
"s": [320, 106],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 5.28,
"s": [320, 106],
"i": { "x": [1, 0.001], "y": [1, 0.998] },
"o": { "x": [0, 0.349], "y": [0, 0] }
},
{ "t": 30, "s": [320, 1], "h": 1 }
]
}
},
{
"ty": "st",
"c": { "a": 0, "k": [0.906, 0.898, 0.894] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 2,
"ty": 3,
"parent": 1,
"ks": {
"a": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 53],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 5.28,
"s": [160, 53],
"i": { "x": [1, 0.001], "y": [1, 0.998] },
"o": { "x": [0, 0.349], "y": [0, 0] }
},
{ "t": 30, "s": [160, 0.5], "h": 1 }
]
},
"p": { "a": 0, "k": [200.5, 200] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [-30], "h": 1 },
{ "t": 5.28, "s": [-30], "i": { "x": 0.001, "y": 0.998 }, "o": { "x": 0.349, "y": 0 } },
{ "t": 30, "s": [-90], "h": 1 }
]
}
},
"ip": 0,
"op": 31,
"st": 0
},
{
"ind": 5,
"ty": 4,
"parent": 4,
"ks": {},
"ip": 0,
"op": 31,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 26.75] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 174.5] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{
"ty": "el",
"p": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 0.999] },
"o": { "x": [0, 0.348], "y": [0, 0] }
},
{ "t": 30, "s": [160, 0.5], "h": 1 }
]
},
"s": {
"a": 1,
"k": [
{
"t": 0,
"s": [320, 106],
"i": { "x": [1, 0], "y": [1, 0.999] },
"o": { "x": [0, 0.348], "y": [0, 0] }
},
{ "t": 30, "s": [320, 1], "h": 1 }
]
}
},
{
"ty": "st",
"c": { "a": 0, "k": [0.906, 0.898, 0.894] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 4,
"ty": 3,
"parent": 1,
"ks": {
"a": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 0.999] },
"o": { "x": [0, 0.348], "y": [0, 0] }
},
{ "t": 30, "s": [160, 0.5], "h": 1 }
]
},
"p": { "a": 0, "k": [200.594, 200.176] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [30], "i": { "x": 0, "y": 0.999 }, "o": { "x": 0.348, "y": 0 } },
{ "t": 30, "s": [90], "h": 1 }
]
}
},
"ip": 0,
"op": 31,
"st": 0
},
{ "ind": 1, "ty": 3, "parent": 0, "ks": {}, "ip": 0, "op": 31, "st": 0 },
{ "ind": 0, "ty": 3, "ks": {}, "ip": 0, "op": 31, "st": 0 }
],
"meta": { "g": "https://jitter.video" },
"op": 30,
"v": "5.7.4",
"w": 400
}

View File

@@ -1,224 +1 @@
{ {"fr":60,"h":400,"ip":0,"layers":[{"ind":3,"ty":4,"parent":2,"ks":{},"ip":0,"op":31,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[160,53],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,106],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[320,106],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":2,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,1],"y":[1,1]},"o":{"x":[0,0],"y":[0,0]}},{"t":5.28,"s":[160,53],"i":{"x":[1,0.001],"y":[1,0.998]},"o":{"x":[0,0.349],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.5,200]},"r":{"a":1,"k":[{"t":0,"s":[-30],"h":1},{"t":5.28,"s":[-30],"i":{"x":0.001,"y":0.998},"o":{"x":0.349,"y":0}},{"t":30,"s":[-90],"h":1}]}},"ip":0,"op":31,"st":0},{"ind":5,"ty":4,"parent":4,"ks":{},"ip":0,"op":31,"st":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","p":{"a":0,"k":[160,26.75]},"r":{"a":0,"k":0},"s":{"a":0,"k":[336,174.5]}},{"ty":"fl","c":{"a":0,"k":[0,0,0,0]},"o":{"a":0,"k":0}},{"ty":"tr","o":{"a":0,"k":100}}]},{"ty":"gr","it":[{"ty":"el","p":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"s":{"a":1,"k":[{"t":0,"s":[320,106],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[320,1],"h":1}]}},{"ty":"st","c":{"a":0,"k":[0.11,0.098,0.09]},"lc":1,"lj":1,"ml":10,"o":{"a":0,"k":100},"w":{"a":0,"k":16}},{"ty":"tr","o":{"a":0,"k":100}}]}]},{"ind":4,"ty":3,"parent":1,"ks":{"a":{"a":1,"k":[{"t":0,"s":[160,53],"i":{"x":[1,0],"y":[1,0.999]},"o":{"x":[0,0.348],"y":[0,0]}},{"t":30,"s":[160,0.5],"h":1}]},"p":{"a":0,"k":[200.594,200.176]},"r":{"a":1,"k":[{"t":0,"s":[30],"i":{"x":0,"y":0.999},"o":{"x":0.348,"y":0}},{"t":30,"s":[90],"h":1}]}},"ip":0,"op":31,"st":0},{"ind":1,"ty":3,"parent":0,"ks":{},"ip":0,"op":31,"st":0},{"ind":0,"ty":3,"ks":{},"ip":0,"op":31,"st":0}],"meta":{"g":"https://jitter.video"},"op":30,"v":"5.7.4","w":400}
"fr": 60,
"h": 400,
"ip": 0,
"layers": [
{
"ind": 3,
"ty": 4,
"parent": 2,
"ks": {},
"ip": 0,
"op": 31,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 26.75] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 174.5] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{
"ty": "el",
"p": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 53],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 5.28,
"s": [160, 53],
"i": { "x": [1, 0.001], "y": [1, 0.998] },
"o": { "x": [0, 0.349], "y": [0, 0] }
},
{ "t": 30, "s": [160, 0.5], "h": 1 }
]
},
"s": {
"a": 1,
"k": [
{
"t": 0,
"s": [320, 106],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 5.28,
"s": [320, 106],
"i": { "x": [1, 0.001], "y": [1, 0.998] },
"o": { "x": [0, 0.349], "y": [0, 0] }
},
{ "t": 30, "s": [320, 1], "h": 1 }
]
}
},
{
"ty": "st",
"c": { "a": 0, "k": [0.11, 0.098, 0.09] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 2,
"ty": 3,
"parent": 1,
"ks": {
"a": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 53],
"i": { "x": [1, 1], "y": [1, 1] },
"o": { "x": [0, 0], "y": [0, 0] }
},
{
"t": 5.28,
"s": [160, 53],
"i": { "x": [1, 0.001], "y": [1, 0.998] },
"o": { "x": [0, 0.349], "y": [0, 0] }
},
{ "t": 30, "s": [160, 0.5], "h": 1 }
]
},
"p": { "a": 0, "k": [200.5, 200] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [-30], "h": 1 },
{ "t": 5.28, "s": [-30], "i": { "x": 0.001, "y": 0.998 }, "o": { "x": 0.349, "y": 0 } },
{ "t": 30, "s": [-90], "h": 1 }
]
}
},
"ip": 0,
"op": 31,
"st": 0
},
{
"ind": 5,
"ty": 4,
"parent": 4,
"ks": {},
"ip": 0,
"op": 31,
"st": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ty": "rc",
"p": { "a": 0, "k": [160, 26.75] },
"r": { "a": 0, "k": 0 },
"s": { "a": 0, "k": [336, 174.5] }
},
{ "ty": "fl", "c": { "a": 0, "k": [0, 0, 0, 0] }, "o": { "a": 0, "k": 0 } },
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
},
{
"ty": "gr",
"it": [
{
"ty": "el",
"p": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 0.999] },
"o": { "x": [0, 0.348], "y": [0, 0] }
},
{ "t": 30, "s": [160, 0.5], "h": 1 }
]
},
"s": {
"a": 1,
"k": [
{
"t": 0,
"s": [320, 106],
"i": { "x": [1, 0], "y": [1, 0.999] },
"o": { "x": [0, 0.348], "y": [0, 0] }
},
{ "t": 30, "s": [320, 1], "h": 1 }
]
}
},
{
"ty": "st",
"c": { "a": 0, "k": [0.11, 0.098, 0.09] },
"lc": 1,
"lj": 1,
"ml": 10,
"o": { "a": 0, "k": 100 },
"w": { "a": 0, "k": 16 }
},
{ "ty": "tr", "o": { "a": 0, "k": 100 } }
]
}
]
},
{
"ind": 4,
"ty": 3,
"parent": 1,
"ks": {
"a": {
"a": 1,
"k": [
{
"t": 0,
"s": [160, 53],
"i": { "x": [1, 0], "y": [1, 0.999] },
"o": { "x": [0, 0.348], "y": [0, 0] }
},
{ "t": 30, "s": [160, 0.5], "h": 1 }
]
},
"p": { "a": 0, "k": [200.594, 200.176] },
"r": {
"a": 1,
"k": [
{ "t": 0, "s": [30], "i": { "x": 0, "y": 0.999 }, "o": { "x": 0.348, "y": 0 } },
{ "t": 30, "s": [90], "h": 1 }
]
}
},
"ip": 0,
"op": 31,
"st": 0
},
{ "ind": 1, "ty": 3, "parent": 0, "ks": {}, "ip": 0, "op": 31, "st": 0 },
{ "ind": 0, "ty": 3, "ks": {}, "ip": 0, "op": 31, "st": 0 }
],
"meta": { "g": "https://jitter.video" },
"op": 30,
"v": "5.7.4",
"w": 400
}

View File

@@ -9,14 +9,14 @@ primary_region = 'lhr'
[build] [build]
[http_service] [http_service]
internal_port = 3000 internal_port = 3000
force_https = true force_https = true
auto_stop_machines = 'stop' auto_stop_machines = 'stop'
auto_start_machines = true auto_start_machines = true
min_machines_running = 0 min_machines_running = 0
processes = ['app'] processes = ['app']
[[vm]] [[vm]]
memory = '1gb' memory = '1gb'
cpus = 1 cpus = 1
memory_mb = 1024 memory_mb = 1024

View File

@@ -30,7 +30,7 @@
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.13",
"typescript": "^6", "typescript": "^5.9.2",
"vite": "^7.1.7", "vite": "^7.1.7",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
} }

View File

@@ -1,16 +1,18 @@
{ {
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"], "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
"compilerOptions": { "compilerOptions": {
"lib": ["DOM", "ES2022"], "lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "vite/client"], "types": ["node", "vite/client"],
"target": "ES2022", "target": "ES2022",
"module": "ES2022", "module": "ES2022",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"jsx": "react-jsx", "jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"], "rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": { "paths": {
"~/*": ["./app/*"] "~/*": ["./app/*"]
}, },
"esModuleInterop": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"noEmit": true, "noEmit": true,
"resolveJsonModule": true, "resolveJsonModule": true,

View File

@@ -8,12 +8,11 @@
"@json-render/core": "^0.12.1", "@json-render/core": "^0.12.1",
"@nym.sh/jrx": "^0.2.0", "@nym.sh/jrx": "^0.2.0",
"@types/bun": "latest", "@types/bun": "latest",
"@typescript/native-preview": "^7.0.0-dev.20260412.1",
"oxfmt": "^0.24.0", "oxfmt": "^0.24.0",
"oxlint": "^1.39.0", "oxlint": "^1.39.0",
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^6", "typescript": "^5",
}, },
}, },
"apps/admin-dashboard": { "apps/admin-dashboard": {
@@ -42,7 +41,7 @@
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"typescript": "^6", "typescript": "~5.9.3",
"vite": "^7.2.4", "vite": "^7.2.4",
}, },
}, },
@@ -112,7 +111,7 @@
"eas-cli": "^18.0.1", "eas-cli": "^18.0.1",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0", "eslint-config-expo": "~10.0.0",
"typescript": "^6", "typescript": "~5.9.2",
}, },
}, },
"apps/waitlist-website": { "apps/waitlist-website": {
@@ -139,7 +138,7 @@
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.13",
"typescript": "^6", "typescript": "^5.9.2",
"vite": "^7.1.7", "vite": "^7.1.7",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
}, },
@@ -1453,22 +1452,6 @@
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg=="], "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg=="],
"@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260412.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260412.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260412.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260412.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260412.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260412.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260412.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260412.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-tDw3XZt2BkjAlt/MJmnFGmbe9lgKmc5wezmrMoBIEvJcqz+/KVpVBVvjbkZoaiABnJmuG3G3b6IUFrEveTw6UQ=="],
"@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260412.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-sSkFG+hjtRWffg6FddF3dEkk7N3TRMEqfiUpixwcWhXgyocMdPw8wutTvQRBxQdgxeL9y01M2SO8A8YPPiEgVg=="],
"@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260412.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-m2BTeaLkrHEEDg0D9snigddy01qTY+wgx+W+GpXAfx36PPvW4xWuGXNVWfSaB8bqAC9C8NeLnT/C9/G/rJ5v2w=="],
"@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260412.1", "", { "os": "linux", "cpu": "arm" }, "sha512-wDLekbfsfmKMWORg7CTnEnpKj8oXpU/6AEBrtVN9CEUCiQAe6yH878nZHhJNzWQXHtrtFf3lY49Yplqmdxja3w=="],
"@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260412.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JAdsG6MlVV1hoAUKPy8zxAL7xLeNxz8JgCbLCJVqW8EyH29R9FD4cFTqr7CSIRTNUEDzDTrgnXUyoRtDe1gr+w=="],
"@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260412.1", "", { "os": "linux", "cpu": "x64" }, "sha512-gYgppiQIqid3jZ7D8THh4k3Q+4bwidrQH6SL9Xgbk1qfP6/jwv8twuPqDOfZ+cK2OD55lQHp15fOh2lMNAC40Q=="],
"@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260412.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-TOh7rH5H3jisHJqRXJSjmUGMzcbNBocS/hufhXPQIv+g3pdG5IKZoSnv3SV62I5d12FFDSS5KQon5MQPnOKAHg=="],
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260412.1", "", { "os": "win32", "cpu": "x64" }, "sha512-u+70wL89wspN1wKoX6FVNUATRGCG3BpleByP3H/UqOZvlwuMm8N7Gy8hEbM0U8bDyAuyP/daUfTBVkqXjjv9mA=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="], "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="],
@@ -3445,7 +3428,7 @@
"typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="],
"typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ua-parser-js": ["ua-parser-js@1.0.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug=="], "ua-parser-js": ["ua-parser-js@1.0.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug=="],
@@ -3921,6 +3904,8 @@
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"aelis-client/@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],
"aelis-client/@types/react": ["@types/react@19.1.17", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA=="], "aelis-client/@types/react": ["@types/react@19.1.17", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA=="],
"aelis-client/react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], "aelis-client/react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
@@ -4559,6 +4544,8 @@
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"aelis-client/@tanstack/react-query/@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
"aelis-client/react-dom/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], "aelis-client/react-dom/scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
"better-opn/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], "better-opn/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],

View File

@@ -41,7 +41,7 @@ Sources → Source Graph → FeedEngine
### One harness, not many agents ### One harness, not many agents
The "agents" in this doc describe _behaviors_, not separate running processes. A human PA is one person — they don't have a "calendar agent" and a "follow-up agent" in their head. They look at your whole situation and act on whatever matters. The "agents" in this doc describe *behaviors*, not separate running processes. A human PA is one person — they don't have a "calendar agent" and a "follow-up agent" in their head. They look at your whole situation and act on whatever matters.
AELIS works the same way. One LLM harness receives all feed items, all context, all user memory, and all available tools. It returns a single `FeedEnhancement`. Every behavior (preparation, follow-up, anomaly detection, tone adjustment, cross-source reasoning) is an instruction in the system prompt, not a separate agent. AELIS works the same way. One LLM harness receives all feed items, all context, all user memory, and all available tools. It returns a single `FeedEnhancement`. Every behavior (preparation, follow-up, anomaly detection, tone adjustment, cross-source reasoning) is an instruction in the system prompt, not a separate agent.
@@ -50,7 +50,6 @@ The advantage: the LLM sees everything at once. It doesn't need agent-to-agent c
The only separate LLM call is the **Query Agent** — because it's user-initiated and synchronous. But it uses the same system prompt and context. It's the same "person," just responding to a question instead of proactively enhancing the feed. The only separate LLM call is the **Query Agent** — because it's user-initiated and synchronous. But it uses the same system prompt and context. It's the same "person," just responding to a question instead of proactively enhancing the feed.
Everything else is either: Everything else is either:
- **Rule-based post-processors** — pure functions, no LLM, run on every refresh - **Rule-based post-processors** — pure functions, no LLM, run on every refresh
- **The single LLM harness** — runs periodically, produces cached `FeedEnhancement` - **The single LLM harness** — runs periodically, produces cached `FeedEnhancement`
- **Background jobs** — daily summary compression, weekly pattern discovery - **Background jobs** — daily summary compression, weekly pattern discovery
@@ -58,7 +57,7 @@ Everything else is either:
### Component categories ### Component categories
| Component | What it is | Examples | | Component | What it is | Examples |
| ------------------------------ | ----------------------------------------- | --------------------------------------------------------------------- | |---|---|---|
| **FeedSource nodes** | Graph participants that produce items | Briefing, Preparation, Anomaly Detection, Follow-up, Social Awareness | | **FeedSource nodes** | Graph participants that produce items | Briefing, Preparation, Anomaly Detection, Follow-up, Social Awareness |
| **Rule-based post-processors** | Pure functions that rerank/filter/group | TimeOfDay, CalendarGrouping, Deduplication, UserAffinity | | **Rule-based post-processors** | Pure functions that rerank/filter/group | TimeOfDay, CalendarGrouping, Deduplication, UserAffinity |
| **LLM enhancement harness** | Single background LLM call, cached output | Card rewriting, cross-source synthesis, tone, narrative arcs | | **LLM enhancement harness** | Single background LLM call, cached output | Card rewriting, cross-source synthesis, tone, narrative arcs |
@@ -72,7 +71,7 @@ The LLM harness and post-processors need a unified view of the user's world: cur
`AgentContext` is **not** on the engine. The engine's job is source orchestration — running sources in dependency order, accumulating context, collecting items. It shouldn't know about user preferences, conversation history, or feed snapshots. Those are separate concerns. `AgentContext` is **not** on the engine. The engine's job is source orchestration — running sources in dependency order, accumulating context, collecting items. It shouldn't know about user preferences, conversation history, or feed snapshots. Those are separate concerns.
`AgentContext` is a separate object that _reads from_ the engine and composes its output with other data stores: `AgentContext` is a separate object that *reads from* the engine and composes its output with other data stores:
```typescript ```typescript
interface AgentContext { interface AgentContext {
@@ -143,7 +142,7 @@ interface FeedEnhancement {
annotations: Record<string, string> annotations: Record<string, string>
/** Items to group together with a summary card */ /** Items to group together with a summary card */
groups: Array<{ itemIds: string[]; summary: string }> groups: Array<{ itemIds: string[], summary: string }>
/** Item IDs to suppress or deprioritize */ /** Item IDs to suppress or deprioritize */
suppress: string[] suppress: string[]
@@ -186,7 +185,6 @@ These run on every refresh. Fast, deterministic, and cover most of the ranking q
**Anomaly detection.** Compare event start times against the user's historical distribution. A 6am meeting when the user never has meetings before 9am is a statistical outlier — flag it. **Anomaly detection.** Compare event start times against the user's historical distribution. A 6am meeting when the user never has meetings before 9am is a statistical outlier — flag it.
**User affinity scoring.** Track implicit signals per source type per time-of-day bucket: **User affinity scoring.** Track implicit signals per source type per time-of-day bucket:
- Dismissals: user swipes away weather cards → decay affinity for weather - Dismissals: user swipes away weather cards → decay affinity for weather
- Taps: user taps calendar items frequently → boost affinity for calendar - Taps: user taps calendar items frequently → boost affinity for calendar
- Dwell time: user reads TfL alerts carefully → boost - Dwell time: user reads TfL alerts carefully → boost
@@ -311,7 +309,7 @@ There are three layers:
None of these have `if` statements. The LLM reads the feed, reads the user's memory, and decides what to say. Add a new source (Spotify, email, tasks) and the LLM automatically incorporates it — no new behavior code needed. None of these have `if` statements. The LLM reads the feed, reads the user's memory, and decides what to say. Add a new source (Spotify, email, tasks) and the LLM automatically incorporates it — no new behavior code needed.
**Infrastructure (plumbing needed, but logic is emergent).** These need tables, APIs, and background jobs. But the _decision-making_ — what to extract, when to surface, how to phrase — is all LLM. **Infrastructure (plumbing needed, but logic is emergent).** These need tables, APIs, and background jobs. But the *decision-making* — what to extract, when to surface, how to phrase — is all LLM.
- Gentle Follow-up — needs: extraction pipeline after each conversation turn, `commitments` table. The LLM decides what counts as a commitment and when to remind. - Gentle Follow-up — needs: extraction pipeline after each conversation turn, `commitments` table. The LLM decides what counts as a commitment and when to remind.
- Memory — needs: `memories` table, read/write API. The LLM decides what to remember and how to use it. - Memory — needs: `memories` table, read/write API. The LLM decides what to remember and how to use it.
@@ -323,7 +321,7 @@ None of these have `if` statements. The LLM reads the feed, reads the user's mem
- Delegation — needs: confirmation flow, write-back infrastructure. The LLM decides what the user wants done. - Delegation — needs: confirmation flow, write-back infrastructure. The LLM decides what the user wants done.
- Financial Awareness — needs: `financial_events` table, email extraction. The LLM decides what financial events matter. - Financial Awareness — needs: `financial_events` table, email extraction. The LLM decides what financial events matter.
**Hardcoded rules (fast path, must be deterministic).** These run on every refresh in <10ms. They _should_ be rules because they need to be fast and predictable. **Hardcoded rules (fast path, must be deterministic).** These run on every refresh in <10ms. They *should* be rules because they need to be fast and predictable.
- User affinity scoring decay/boost math on tap/dismiss events - User affinity scoring decay/boost math on tap/dismiss events
- Deduplication title + time matching across sources - Deduplication title + time matching across sources
@@ -421,7 +419,10 @@ class EnhancementManager {
private lastInputHash: string | null = null private lastInputHash: string | null = null
private running = false private running = false
async enhance(items: FeedItem[], context: AgentContext): Promise<FeedEnhancement> { async enhance(
items: FeedItem[],
context: AgentContext,
): Promise<FeedEnhancement> {
const hash = computeHash(items, context) const hash = computeHash(items, context)
// Nothing changed — return cache // Nothing changed — return cache
@@ -437,14 +438,12 @@ class EnhancementManager {
// Run in background, update cache when done // Run in background, update cache when done
this.running = true this.running = true
this.runHarness(items, context, hash) this.runHarness(items, context, hash)
.then((enhancement) => { .then(enhancement => {
this.cache = enhancement this.cache = enhancement
this.lastInputHash = hash this.lastInputHash = hash
this.notifySubscribers(enhancement) this.notifySubscribers(enhancement)
}) })
.finally(() => { .finally(() => { this.running = false })
this.running = false
})
// Return stale cache immediately // Return stale cache immediately
return this.cache ?? emptyEnhancement() return this.cache ?? emptyEnhancement()
@@ -523,7 +522,7 @@ These are `FeedSource` nodes that depend on calendar, tasks, weather, and other
#### Anticipatory Logistics #### Anticipatory Logistics
Works backward from events to tell you what you need to _do_ to be ready. Works backward from events to tell you what you need to *do* to be ready.
- Flight at 6am → "You need to leave by 4am, which means waking at 3:30. I'd suggest packing tonight." - Flight at 6am → "You need to leave by 4am, which means waking at 3:30. I'd suggest packing tonight."
- Dinner at a new restaurant → "It's a 25-minute walk or 8-minute Uber. Street parking is difficult — there's a car park on the next street." - Dinner at a new restaurant → "It's a 25-minute walk or 8-minute Uber. Street parking is difficult — there's a car park on the next street."
@@ -580,7 +579,7 @@ Tracks loose ends — things you said but never wrote down as tasks.
- "You told James you'd review his PR — it's been 3 days" - "You told James you'd review his PR — it's been 3 days"
- "You promised to call your mom this weekend" - "You promised to call your mom this weekend"
The key difference from task tracking: this catches things that fell through the cracks _because_ they were never formalized. The key difference from task tracking: this catches things that fell through the cracks *because* they were never formalized.
**How intent extraction works:** **How intent extraction works:**
@@ -615,14 +614,12 @@ Long-term memory of interactions and preferences. Feeds into every other agent.
A persistent profile that builds over time. Not an agent itself — a system that makes every other agent smarter. A persistent profile that builds over time. Not an agent itself — a system that makes every other agent smarter.
Learns from: Learns from:
- Explicit statements: "I prefer morning meetings" - Explicit statements: "I prefer morning meetings"
- Implicit behavior: user always dismisses evening suggestions - Implicit behavior: user always dismisses evening suggestions
- Feedback: user rates suggestions as helpful/not - Feedback: user rates suggestions as helpful/not
- Cross-source patterns: always books aisle seats, always picks the cheaper option - Cross-source patterns: always books aisle seats, always picks the cheaper option
Used by: Used by:
- Proactive Agent suggests restaurants the user would actually like - Proactive Agent suggests restaurants the user would actually like
- Delegation Agent books the right kind of hotel room - Delegation Agent books the right kind of hotel room
- Summary Agent uses the user's preferred level of detail - Summary Agent uses the user's preferred level of detail
@@ -654,20 +651,17 @@ interface DailySummary {
date: string date: string
feedCheckTimes: string[] // when the user opened the feed feedCheckTimes: string[] // when the user opened the feed
itemTypeCounts: Record<string, number> // how many of each type appeared itemTypeCounts: Record<string, number> // how many of each type appeared
interactions: Array<{ interactions: Array<{ // what the user tapped/dismissed
// what the user tapped/dismissed
itemType: string itemType: string
action: "tap" | "dismiss" | "dwell" action: "tap" | "dismiss" | "dwell"
time: string time: string
}> }>
locations: Array<{ locations: Array<{ // where the user was throughout the day
// where the user was throughout the day
lat: number lat: number
lng: number lng: number
time: string time: string
}> }>
calendarSummary: Array<{ calendarSummary: Array<{ // what events happened
// what events happened
title: string title: string
startTime: string startTime: string
endTime: string endTime: string
@@ -691,7 +685,7 @@ interface DiscoveredPattern {
/** When this pattern is relevant */ /** When this pattern is relevant */
relevance: { relevance: {
daysOfWeek?: number[] daysOfWeek?: number[]
timeRange?: { start: string; end: string } timeRange?: { start: string, end: string }
conditions?: string[] conditions?: string[]
} }
/** How this should affect the feed */ /** How this should affect the feed */
@@ -723,9 +717,9 @@ Maintains awareness of relationships and surfaces timely nudges.
Needs: contacts with birthday/anniversary data, calendar history for meeting frequency, email/message signals, optionally social media. Needs: contacts with birthday/anniversary data, calendar history for meeting frequency, email/message signals, optionally social media.
This is what makes an assistant feel like it _cares_. Most tools are transactional. This one remembers the people in your life. This is what makes an assistant feel like it *cares*. Most tools are transactional. This one remembers the people in your life.
Beyond frequency, the assistant can understand relationship _dynamics_: Beyond frequency, the assistant can understand relationship *dynamics*:
- "You and Sarah always have productive meetings. You and Alex tend to go off-track — maybe set a tighter agenda." - "You and Sarah always have productive meetings. You and Alex tend to go off-track — maybe set a tighter agenda."
- "You've cancelled on Tom three times — he might be feeling deprioritized." - "You've cancelled on Tom three times — he might be feeling deprioritized."
@@ -791,7 +785,7 @@ This is where the source graph pays off. All the data is already there — the a
#### Tone & Timing #### Tone & Timing
Controls _when_ and _how_ information is delivered. The difference between useful and annoying. Controls *when* and *how* information is delivered. The difference between useful and annoying.
- Bad news before morning coffee? Hold it. - Bad news before morning coffee? Hold it.
- Three notifications in a row? Batch them. - Three notifications in a row? Batch them.
@@ -855,7 +849,6 @@ The primary interface. This isn't a feed query tool — it's the person you talk
The user should be able to ask AELIS anything they'd ask a knowledgeable friend. Some questions are about their data. Most aren't. The user should be able to ask AELIS anything they'd ask a knowledgeable friend. Some questions are about their data. Most aren't.
**About their life (reads from the source graph):** **About their life (reads from the source graph):**
- "What's on my calendar tomorrow?" - "What's on my calendar tomorrow?"
- "When's my next flight?" - "When's my next flight?"
- "Do I have any conflicts this week?" - "Do I have any conflicts this week?"
@@ -863,7 +856,6 @@ The user should be able to ask AELIS anything they'd ask a knowledgeable friend.
- "Tell me more about this" (anchored to a feed item) - "Tell me more about this" (anchored to a feed item)
**About the world (falls through to web search):** **About the world (falls through to web search):**
- "How do I unclog a drain?" - "How do I unclog a drain?"
- "What should I make with chicken and broccoli?" - "What should I make with chicken and broccoli?"
- "What's the best way to get from King's Cross to Heathrow?" - "What's the best way to get from King's Cross to Heathrow?"
@@ -872,7 +864,6 @@ The user should be able to ask AELIS anything they'd ask a knowledgeable friend.
- "What are some good date night restaurants in Shoreditch?" - "What are some good date night restaurants in Shoreditch?"
**Contextual blend (graph + web):** **Contextual blend (graph + web):**
- "What's the dress code for The Ivy?" (calendar shows dinner there tonight) - "What's the dress code for The Ivy?" (calendar shows dinner there tonight)
- "Will I need an umbrella?" (location + weather, but could also web-search venue for indoor/outdoor) - "Will I need an umbrella?" (location + weather, but could also web-search venue for indoor/outdoor)
- "What should I know before my meeting with Acme Corp?" (calendar + web search for company info) - "What should I know before my meeting with Acme Corp?" (calendar + web search for company info)
@@ -888,12 +879,10 @@ This is also where intent extraction happens for the Gentle Follow-up Agent. Eve
The backbone for general knowledge. Makes AELIS a person you can ask things, not just a dashboard you look at. The backbone for general knowledge. Makes AELIS a person you can ask things, not just a dashboard you look at.
**Reactive (user asks):** **Reactive (user asks):**
- Recipe ideas, how-to questions, factual lookups, recommendations - Recipe ideas, how-to questions, factual lookups, recommendations
- Anything the source graph can't answer - Anything the source graph can't answer
**Proactive (agents trigger):** **Proactive (agents trigger):**
- Contextual Preparation enriches calendar events: venue info, attendee backgrounds, parking - Contextual Preparation enriches calendar events: venue info, attendee backgrounds, parking
- Feed shows a concert → pre-fetches setlist, venue details - Feed shows a concert → pre-fetches setlist, venue details
- Ambient Context checks for disruptions, closures, news - Ambient Context checks for disruptions, closures, news
@@ -960,7 +949,7 @@ Handles tasks the user delegates via natural language.
Requires write access to sources. Confirmation UX for anything destructive or costly. Requires write access to sources. Confirmation UX for anything destructive or costly.
**Implementation:** Extends the Query Agent. When the LLM determines the user wants to _do_ something (not just ask), it calls a delegation tool with structured output: `{ action: "create_reminder" | "schedule_meeting" | "add_task", params: {...} }`. The backend maps this to `executeAction()` on the relevant source. For "find a time that works for both me and Sarah," the agent queries both calendars (requires Sarah to be a known contact with calendar access — or the agent asks the user to share availability). All write actions go through a confirmation step: the backend sends a `delegation.confirm` notification with the proposed action, and the client shows a confirmation UI. The user approves or modifies before execution. Store delegation history for the Follow-up Agent. **Implementation:** Extends the Query Agent. When the LLM determines the user wants to *do* something (not just ask), it calls a delegation tool with structured output: `{ action: "create_reminder" | "schedule_meeting" | "add_task", params: {...} }`. The backend maps this to `executeAction()` on the relevant source. For "find a time that works for both me and Sarah," the agent queries both calendars (requires Sarah to be a known contact with calendar access — or the agent asks the user to share availability). All write actions go through a confirmation step: the backend sends a `delegation.confirm` notification with the proposed action, and the client shows a confirmation UI. The user approves or modifies before execution. Store delegation history for the Follow-up Agent.
#### Actions #### Actions

View File

@@ -238,15 +238,20 @@ The user never waits for the LLM. They see the feed instantly with the previous
The harness serializes items with their unfilled slots into a single prompt. Items without slots are excluded. The LLM sees everything at once and fills whatever slots are relevant. The harness serializes items with their unfilled slots into a single prompt. Items without slots are excluded. The LLM sees everything at once and fills whatever slots are relevant.
```typescript ```typescript
function buildHarnessInput(items: FeedItem[], context: AgentContext): HarnessInput { function buildHarnessInput(
items: FeedItem[],
context: AgentContext,
): HarnessInput {
const itemsWithSlots = items const itemsWithSlots = items
.filter((item) => item.slots && Object.keys(item.slots).length > 0) .filter(item => item.slots && Object.keys(item.slots).length > 0)
.map((item) => ({ .map(item => ({
id: item.id, id: item.id,
type: item.type, type: item.type,
data: item.data, data: item.data,
slots: Object.fromEntries( slots: Object.fromEntries(
Object.entries(item.slots!).map(([name, slot]) => [name, slot.description]), Object.entries(item.slots!).map(
([name, slot]) => [name, slot.description]
)
), ),
})) }))
@@ -275,11 +280,7 @@ The LLM sees:
{ {
"id": "calendar-event-456", "id": "calendar-event-456",
"type": "calendar-event", "type": "calendar-event",
"data": { "data": { "title": "Dinner at The Ivy", "startTime": "19:00", "location": "The Ivy, West St" },
"title": "Dinner at The Ivy",
"startTime": "19:00",
"location": "The Ivy, West St"
},
"slots": { "slots": {
"context": "Background on this event, attendees, or previous meetings with these people", "context": "Background on this event, attendees, or previous meetings with these people",
"logistics": "Travel time, parking, directions to the venue", "logistics": "Travel time, parking, directions to the venue",
@@ -314,10 +315,7 @@ A flat map of item ID → slot name → text content. Slots left null are unfill
"id": "briefing-morning", "id": "briefing-morning",
"type": "briefing", "type": "briefing",
"data": {}, "data": {},
"ui": { "ui": { "component": "Text", "props": { "text": "Light afternoon — just your dinner at 7. Rain clears by then." } }
"component": "Text",
"props": { "text": "Light afternoon — just your dinner at 7. Rain clears by then." }
}
} }
], ],
"suppress": [], "suppress": [],
@@ -335,7 +333,10 @@ class EnhancementManager {
private lastInputHash: string | null = null private lastInputHash: string | null = null
private running = false private running = false
async enhance(items: FeedItem[], context: AgentContext): Promise<EnhancementResult> { async enhance(
items: FeedItem[],
context: AgentContext,
): Promise<EnhancementResult> {
const hash = computeHash(items, context) const hash = computeHash(items, context)
if (hash === this.lastInputHash && this.cache) { if (hash === this.lastInputHash && this.cache) {
@@ -348,14 +349,12 @@ class EnhancementManager {
this.running = true this.running = true
this.runHarness(items, context) this.runHarness(items, context)
.then((result) => { .then(result => {
this.cache = result this.cache = result
this.lastInputHash = hash this.lastInputHash = hash
this.notifySubscribers(result) this.notifySubscribers(result)
}) })
.finally(() => { .finally(() => { this.running = false })
this.running = false
})
return this.cache ?? emptyResult() return this.cache ?? emptyResult()
} }
@@ -374,8 +373,11 @@ interface EnhancementResult {
After the harness runs, the engine merges slot fills into items: After the harness runs, the engine merges slot fills into items:
```typescript ```typescript
function mergeEnhancement(items: FeedItem[], result: EnhancementResult): FeedItem[] { function mergeEnhancement(
return items.map((item) => { items: FeedItem[],
result: EnhancementResult,
): FeedItem[] {
return items.map(item => {
const fills = result.slotFills[item.id] const fills = result.slotFills[item.id]
if (!fills || !item.slots) return item if (!fills || !item.slots) return item

View File

@@ -25,13 +25,13 @@ The backend uses a raw `pg` Pool for Better Auth and has no ORM. We need a persi
A `user_sources` table stores per-user source state: A `user_sources` table stores per-user source state:
| Column | Type | Description | | Column | Type | Description |
| ------------- | --------------------- | -------------------------------------------------------------- | | ------------ | ------------------------ | ------------------------------------------------------------ |
| `id` | `uuid` PK | Row ID | | `id` | `uuid` PK | Row ID |
| `user_id` | `text` FK → `user.id` | Owner | | `user_id` | `text` FK → `user.id` | Owner |
| `source_id` | `text` | Source identifier (e.g., `aelis.tfl`, `aelis.weather`) | | `source_id` | `text` | Source identifier (e.g., `aelis.tfl`, `aelis.weather`) |
| `enabled` | `boolean` | Whether this source is active in the user's feed | | `enabled` | `boolean` | Whether this source is active in the user's feed |
| `config` | `jsonb` | Source-specific configuration (validated by source at runtime) | | `config` | `jsonb` | Source-specific configuration (validated by source at runtime)|
| `credentials` | `bytea` | Encrypted OAuth tokens / secrets (AES-256-GCM) | | `credentials`| `bytea` | Encrypted OAuth tokens / secrets (AES-256-GCM) |
| `created_at` | `timestamp with tz` | Row creation time | | `created_at` | `timestamp with tz` | Row creation time |
| `updated_at` | `timestamp with tz` | Last modification time | | `updated_at` | `timestamp with tz` | Last modification time |
@@ -51,7 +51,7 @@ A `user_sources` table stores per-user source state:
When a new user is created, seed `user_sources` rows for default sources: When a new user is created, seed `user_sources` rows for default sources:
| Source | Default config | | Source | Default config |
| ---------------- | ----------------------------------------------------------- | | ------------------ | --------------------------------------------------------------- |
| `aelis.location` | `{}` | | `aelis.location` | `{}` |
| `aelis.weather` | `{ "units": "metric", "hourlyLimit": 12, "dailyLimit": 7 }` | | `aelis.weather` | `{ "units": "metric", "hourlyLimit": 12, "dailyLimit": 7 }` |
| `aelis.tfl` | `{ "lines": <all default lines> }` | | `aelis.tfl` | `{ "lines": <all default lines> }` |
@@ -67,22 +67,16 @@ Each provider receives the Drizzle DB instance and queries `user_sources` intern
```typescript ```typescript
class TflSourceProvider implements FeedSourceProvider { class TflSourceProvider implements FeedSourceProvider {
constructor( constructor(private db: DrizzleDb, private apiKey: string) {}
private db: DrizzleDb,
private apiKey: string,
) {}
async feedSourceForUser(userId: string): Promise<TflSource> { async feedSourceForUser(userId: string): Promise<TflSource> {
const row = await this.db const row = await this.db.select()
.select()
.from(userSources) .from(userSources)
.where( .where(and(
and(
eq(userSources.userId, userId), eq(userSources.userId, userId),
eq(userSources.sourceId, "aelis.tfl"), eq(userSources.sourceId, "aelis.tfl"),
eq(userSources.enabled, true), eq(userSources.enabled, true),
), ))
)
.limit(1) .limit(1)
if (!row[0]) { if (!row[0]) {
@@ -216,19 +210,16 @@ _`feed-source-provider.ts`, `user-session-manager.ts`, `engine/http.ts`, and `lo
## Dependencies ## Dependencies
**Add:** **Add:**
- `drizzle-orm` - `drizzle-orm`
- `drizzle-kit` (dev) - `drizzle-kit` (dev)
**Remove:** **Remove:**
- `pg` - `pg`
- `@types/pg` (dev) - `@types/pg` (dev)
## Environment Variables ## Environment Variables
**Add to `.env.example`:** **Add to `.env.example`:**
- `CREDENTIALS_ENCRYPTION_KEY` — 32-byte hex or base64 key for AES-256-GCM - `CREDENTIALS_ENCRYPTION_KEY` — 32-byte hex or base64 key for AES-256-GCM
## Open Questions (Deferred) ## Open Questions (Deferred)

View File

@@ -40,7 +40,7 @@ Source IDs use reverse domain notation. Built-in sources use `aelis.<name>`. Thi
Action IDs are descriptive verb-noun pairs in kebab-case, scoped to their source. The globally unique form is `<sourceId>/<actionId>`. Action IDs are descriptive verb-noun pairs in kebab-case, scoped to their source. The globally unique form is `<sourceId>/<actionId>`.
| Source ID | Action IDs | | Source ID | Action IDs |
| ---------------- | -------------------------------------------------------------- | | --------------- | -------------------------------------------------------------- |
| `aelis.location` | `update-location` (migrated from `pushLocation()`) | | `aelis.location` | `update-location` (migrated from `pushLocation()`) |
| `aelis.tfl` | `set-lines-of-interest` (migrated from `setLinesOfInterest()`) | | `aelis.tfl` | `set-lines-of-interest` (migrated from `setLinesOfInterest()`) |
| `aelis.weather` | _(none)_ | | `aelis.weather` | _(none)_ |
@@ -241,16 +241,8 @@ class SpotifySource implements FeedSource<SpotifyFeedItem> {
type: "View", type: "View",
className: "flex-1", className: "flex-1",
children: [ children: [
{ { type: "Text", className: "font-semibold text-black dark:text-white", text: track.name },
type: "Text", { type: "Text", className: "text-sm text-gray-500 dark:text-gray-400", text: track.artist },
className: "font-semibold text-black dark:text-white",
text: track.name,
},
{
type: "Text",
className: "text-sm text-gray-500 dark:text-gray-400",
text: track.artist,
},
], ],
}, },
], ],
@@ -269,8 +261,8 @@ class SpotifySource implements FeedSource<SpotifyFeedItem> {
4. `FeedSource.listActions()` is a required method returning `Record<string, ActionDefinition>` (empty record if no actions) 4. `FeedSource.listActions()` is a required method returning `Record<string, ActionDefinition>` (empty record if no actions)
5. `FeedSource.executeAction()` is a required method (no-op for sources without actions) 5. `FeedSource.executeAction()` is a required method (no-op for sources without actions)
6. `FeedItem.actions` is an optional readonly array of `ItemAction` 6. `FeedItem.actions` is an optional readonly array of `ItemAction`
6b. `FeedItem.ui` is an optional json-render tree describing server-driven UI 6b. `FeedItem.ui` is an optional json-render tree describing server-driven UI
6c. `FeedItem.slots` is an optional record of named LLM-fillable slots 6c. `FeedItem.slots` is an optional record of named LLM-fillable slots
7. `FeedEngine.executeAction()` routes to correct source, returns `ActionResult` 7. `FeedEngine.executeAction()` routes to correct source, returns `ActionResult`
8. `FeedEngine.listActions()` aggregates actions from all sources 8. `FeedEngine.listActions()` aggregates actions from all sources
9. Existing tests pass unchanged (all changes are additive) 9. Existing tests pass unchanged (all changes are additive)

View File

@@ -17,11 +17,10 @@
"@json-render/core": "^0.12.1", "@json-render/core": "^0.12.1",
"@nym.sh/jrx": "^0.2.0", "@nym.sh/jrx": "^0.2.0",
"@types/bun": "latest", "@types/bun": "latest",
"@typescript/native-preview": "^7.0.0-dev.20260412.1",
"oxfmt": "^0.24.0", "oxfmt": "^0.24.0",
"oxlint": "^1.39.0" "oxlint": "^1.39.0"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^6" "typescript": "^5"
} }
} }

View File

@@ -7,11 +7,11 @@
"scripts": { "scripts": {
"test": "bun test ." "test": "bun test ."
}, },
"peerDependencies": {
"@nym.sh/jrx": "*",
"@json-render/core": "*"
},
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.1.0" "@standard-schema/spec": "^1.1.0"
},
"peerDependencies": {
"@json-render/core": "*",
"@nym.sh/jrx": "*"
} }
} }

View File

@@ -1,14 +1,17 @@
import type { Context, FeedEnhancement, FeedItem, FeedPostProcessor } from "@aelis/core" import type { Context, FeedEnhancement, FeedItem, FeedPostProcessor } from "@aelis/core"
import { TimeRelevance } from "@aelis/core"
import type { CalDavEventData } from "@aelis/source-caldav" import type { CalDavEventData } from "@aelis/source-caldav"
import type { CalendarEventData } from "@aelis/source-google-calendar" import type { CalendarEventData } from "@aelis/source-google-calendar"
import type { CurrentWeatherData } from "@aelis/source-weatherkit" import type { CurrentWeatherData } from "@aelis/source-weatherkit"
import { TimeRelevance } from "@aelis/core"
import { CalDavFeedItemType } from "@aelis/source-caldav" import { CalDavFeedItemType } from "@aelis/source-caldav"
import { CalendarFeedItemType } from "@aelis/source-google-calendar" import { CalendarFeedItemType } from "@aelis/source-google-calendar"
import { TflFeedItemType } from "@aelis/source-tfl" import { TflFeedItemType } from "@aelis/source-tfl"
import { WeatherFeedItemType } from "@aelis/source-weatherkit" import { WeatherFeedItemType } from "@aelis/source-weatherkit"
export const TimePeriod = { export const TimePeriod = {
Morning: "morning", Morning: "morning",
Afternoon: "afternoon", Afternoon: "afternoon",
@@ -25,6 +28,7 @@ export const DayType = {
export type DayType = (typeof DayType)[keyof typeof DayType] export type DayType = (typeof DayType)[keyof typeof DayType]
const PRE_MEETING_WINDOW_MS = 30 * 60 * 1000 const PRE_MEETING_WINDOW_MS = 30 * 60 * 1000
const TRANSITION_WINDOW_MS = 30 * 60 * 1000 const TRANSITION_WINDOW_MS = 30 * 60 * 1000
@@ -140,6 +144,7 @@ export function createTimeOfDayEnhancer(options?: TimeOfDayEnhancerOptions): Fee
return timeOfDayEnhancer return timeOfDayEnhancer
} }
export function getTimePeriod(date: Date): TimePeriod { export function getTimePeriod(date: Date): TimePeriod {
const hour = date.getHours() const hour = date.getHours()
if (hour >= 22 || hour < 6) return TimePeriod.Night if (hour >= 22 || hour < 6) return TimePeriod.Night
@@ -177,9 +182,7 @@ function getNextPeriodBoundary(date: Date): { period: TimePeriod; msUntil: numbe
* Google Calendar uses `startTime`, CalDAV uses `startDate`. * Google Calendar uses `startTime`, CalDAV uses `startDate`.
*/ */
function getEventStartTime(data: CalendarEventData | CalDavEventData): Date { function getEventStartTime(data: CalendarEventData | CalDavEventData): Date {
return "startTime" in data return "startTime" in data ? (data as CalendarEventData).startTime : (data as CalDavEventData).startDate
? (data as CalendarEventData).startTime
: (data as CalDavEventData).startDate
} }
/** /**
@@ -193,6 +196,7 @@ function hasPrecipitationOrExtreme(item: FeedItem): boolean {
return false return false
} }
interface PreMeetingInfo { interface PreMeetingInfo {
/** IDs of calendar items starting within the pre-meeting window */ /** IDs of calendar items starting within the pre-meeting window */
upcomingMeetingIds: Set<string> upcomingMeetingIds: Set<string>
@@ -221,6 +225,7 @@ function detectPreMeetingItems(items: FeedItem[], now: Date): PreMeetingInfo {
return { upcomingMeetingIds, hasLocationMeeting } return { upcomingMeetingIds, hasLocationMeeting }
} }
function findFirstEventOfDay(items: FeedItem[], now: Date): string | null { function findFirstEventOfDay(items: FeedItem[], now: Date): string | null {
let earliest: { id: string; time: number } | null = null let earliest: { id: string; time: number } | null = null
@@ -247,6 +252,7 @@ function findFirstEventOfDay(items: FeedItem[], now: Date): string | null {
return earliest?.id ?? null return earliest?.id ?? null
} }
function applyMorningWeekday( function applyMorningWeekday(
items: FeedItem[], items: FeedItem[],
boost: Record<string, number>, boost: Record<string, number>,
@@ -409,6 +415,7 @@ function applyNight(items: FeedItem[], boost: Record<string, number>, suppress:
} }
} }
function applyPreMeetingOverrides( function applyPreMeetingOverrides(
items: FeedItem[], items: FeedItem[],
preMeeting: PreMeetingInfo, preMeeting: PreMeetingInfo,
@@ -480,6 +487,7 @@ function applyWindDown(
} }
} }
function applyTransitionLookahead( function applyTransitionLookahead(
items: FeedItem[], items: FeedItem[],
now: Date, now: Date,
@@ -536,6 +544,7 @@ function getNextPeriodBoostTargets(period: TimePeriod, dayType: DayType): Readon
return targets return targets
} }
function applyWeatherTimeCorrelation( function applyWeatherTimeCorrelation(
items: FeedItem[], items: FeedItem[],
period: TimePeriod, period: TimePeriod,
@@ -553,11 +562,7 @@ function applyWeatherTimeCorrelation(
break break
} }
case WeatherFeedItemType.Current: case WeatherFeedItemType.Current:
if ( if (period === TimePeriod.Morning && dayType === DayType.Weekday && hasPrecipitationOrExtreme(item)) {
period === TimePeriod.Morning &&
dayType === DayType.Weekday &&
hasPrecipitationOrExtreme(item)
) {
boost[item.id] = (boost[item.id] ?? 0) + 0.1 boost[item.id] = (boost[item.id] ?? 0) + 0.1
} }
if (period === TimePeriod.Evening && hasEveningEventWithLocation) { if (period === TimePeriod.Evening && hasEveningEventWithLocation) {
@@ -586,3 +591,5 @@ function hasEveningCalendarEventWithLocation(items: FeedItem[], now: Date): bool
return false return false
} }

View File

@@ -7,10 +7,11 @@
* Writes feed items (with slots) to scripts/.cache/feed-items.json for inspection. * Writes feed items (with slots) to scripts/.cache/feed-items.json for inspection.
*/ */
import { Context } from "@aelis/core"
import { mkdirSync, writeFileSync } from "node:fs" import { mkdirSync, writeFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import { Context } from "@aelis/core"
import { CalDavSource } from "../src/index.ts" import { CalDavSource } from "../src/index.ts"
const serverUrl = prompt("CalDAV server URL:") const serverUrl = prompt("CalDAV server URL:")

View File

@@ -9,8 +9,8 @@
"fetch-fixtures": "bun run scripts/fetch-fixtures.ts" "fetch-fixtures": "bun run scripts/fetch-fixtures.ts"
}, },
"dependencies": { "dependencies": {
"@aelis/components": "workspace:*",
"@aelis/core": "workspace:*", "@aelis/core": "workspace:*",
"@aelis/components": "workspace:*",
"@aelis/source-location": "workspace:*", "@aelis/source-location": "workspace:*",
"arktype": "^2.1.0" "arktype": "^2.1.0"
}, },

View File

@@ -9,14 +9,15 @@
* Usage: bun packages/aelis-source-weatherkit/scripts/query.ts * Usage: bun packages/aelis-source-weatherkit/scripts/query.ts
*/ */
import { Context } from "@aelis/core"
import { LocationKey } from "@aelis/source-location"
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import { createInterface } from "node:readline/promises" import { createInterface } from "node:readline/promises"
import { WeatherSource, Units } from "../src/weather-source" import { Context } from "@aelis/core"
import { LocationKey } from "@aelis/source-location"
import { DefaultWeatherKitClient } from "../src/weatherkit" import { DefaultWeatherKitClient } from "../src/weatherkit"
import { WeatherSource, Units } from "../src/weather-source"
const SCRIPT_DIR = import.meta.dirname const SCRIPT_DIR = import.meta.dirname
const CACHE_DIR = join(SCRIPT_DIR, ".cache") const CACHE_DIR = join(SCRIPT_DIR, ".cache")

View File

@@ -4,12 +4,7 @@ import { Context } from "@aelis/core"
import { LocationKey } from "@aelis/source-location" import { LocationKey } from "@aelis/source-location"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { import type { WeatherKitClient, WeatherKitResponse, HourlyForecast, DailyForecast } from "./weatherkit"
WeatherKitClient,
WeatherKitResponse,
HourlyForecast,
DailyForecast,
} from "./weatherkit"
import fixture from "../fixtures/san-francisco.json" import fixture from "../fixtures/san-francisco.json"
import { WeatherFeedItemType, type DailyWeatherData, type HourlyWeatherData } from "./feed-items" import { WeatherFeedItemType, type DailyWeatherData, type HourlyWeatherData } from "./feed-items"

View File

@@ -3,12 +3,7 @@ import type { ActionDefinition, ContextEntry, FeedItemSignals, FeedSource } from
import { Context, TimeRelevance, UnknownActionError } from "@aelis/core" import { Context, TimeRelevance, UnknownActionError } from "@aelis/core"
import { LocationKey } from "@aelis/source-location" import { LocationKey } from "@aelis/source-location"
import { import { WeatherFeedItemType, type DailyWeatherEntry, type HourlyWeatherEntry, type WeatherFeedItem } from "./feed-items"
WeatherFeedItemType,
type DailyWeatherEntry,
type HourlyWeatherEntry,
type WeatherFeedItem,
} from "./feed-items"
import currentWeatherInsightPrompt from "./prompts/current-weather-insight.txt" import currentWeatherInsightPrompt from "./prompts/current-weather-insight.txt"
import { WeatherKey, type Weather } from "./weather-context" import { WeatherKey, type Weather } from "./weather-context"
import { import {

View File

@@ -1,5 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"], "lib": ["ESNext"],
"target": "ESNext", "target": "ESNext",
"module": "Preserve", "module": "Preserve",
@@ -7,19 +8,20 @@
"jsx": "react-jsx", "jsx": "react-jsx",
"allowJs": true, "allowJs": true,
// Bundler mode
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"noEmit": true, "noEmit": true,
"types": ["bun"], // Best practices
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"noImplicitOverride": true, "noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false, "noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false "noPropertyAccessFromIndexSignature": false