mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
Compare commits
3 Commits
ca2664b617
...
feat/compo
| Author | SHA1 | Date | |
|---|---|---|---|
|
bf94c6aff6
|
|||
|
80be2b711b
|
|||
|
fc08f828f2
|
42
.github/workflows/build-waitlist-website.yml
vendored
42
.github/workflows/build-waitlist-website.yml
vendored
@@ -1,42 +0,0 @@
|
|||||||
name: Build waitlist website
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
paths:
|
|
||||||
- apps/waitlist-website/**
|
|
||||||
- .github/workflows/build-waitlist-website.yml
|
|
||||||
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: cr.nym.sh
|
|
||||||
IMAGE_NAME: aelis-waitlist-website
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Log in to container registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
|
||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: apps/waitlist-website
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -21,4 +21,4 @@ jobs:
|
|||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: bun run test
|
run: bun test
|
||||||
|
|||||||
@@ -6,14 +6,3 @@ services:
|
|||||||
- postDevcontainerStart
|
- postDevcontainerStart
|
||||||
commands:
|
commands:
|
||||||
start: cd apps/aelis-client && ./scripts/run-dev-server.sh
|
start: cd apps/aelis-client && ./scripts/run-dev-server.sh
|
||||||
|
|
||||||
drizzle-studio:
|
|
||||||
name: Drizzle Studio
|
|
||||||
description: Drizzle Studio database browser for aelis-backend
|
|
||||||
triggeredBy:
|
|
||||||
- manual
|
|
||||||
commands:
|
|
||||||
start: |
|
|
||||||
FORWARD_URL=$(gitpod environment port open 4983 --name drizzle-studio-server | sed 's|https://||')
|
|
||||||
echo "Drizzle Studio: https://local.drizzle.studio/?host=${FORWARD_URL}&port=443"
|
|
||||||
cd apps/aelis-backend && bunx drizzle-kit studio --host 0.0.0.0 --port 4983
|
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ DATABASE_URL=postgresql://user:password@localhost:5432/aris
|
|||||||
# BetterAuth secret (min 32 chars, generate with: openssl rand -base64 32)
|
# BetterAuth secret (min 32 chars, generate with: openssl rand -base64 32)
|
||||||
BETTER_AUTH_SECRET=
|
BETTER_AUTH_SECRET=
|
||||||
|
|
||||||
# Encryption key for source credentials at rest (32 bytes, generate with: openssl rand -base64 32)
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
// Used by Better Auth CLI for schema generation.
|
|
||||||
// Run: bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts
|
|
||||||
import { betterAuth } from "better-auth"
|
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle"
|
|
||||||
import { admin } from "better-auth/plugins"
|
|
||||||
import { SQL } from "bun"
|
|
||||||
import { drizzle } from "drizzle-orm/bun-sql"
|
|
||||||
|
|
||||||
const client = new SQL({ url: process.env.DATABASE_URL })
|
|
||||||
const db = drizzle({ client })
|
|
||||||
|
|
||||||
export const auth = betterAuth({
|
|
||||||
database: drizzleAdapter(db, { provider: "pg" }),
|
|
||||||
emailAndPassword: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
plugins: [admin()],
|
|
||||||
})
|
|
||||||
|
|
||||||
export default auth
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { defineConfig } from "drizzle-kit"
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
out: "./drizzle",
|
|
||||||
schema: "./src/db/schema.ts",
|
|
||||||
dialect: "postgresql",
|
|
||||||
dbCredentials: {
|
|
||||||
url: process.env.DATABASE_URL!,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
CREATE TABLE "account" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"account_id" text NOT NULL,
|
|
||||||
"provider_id" text NOT NULL,
|
|
||||||
"user_id" text NOT NULL,
|
|
||||||
"access_token" text,
|
|
||||||
"refresh_token" text,
|
|
||||||
"id_token" text,
|
|
||||||
"access_token_expires_at" timestamp,
|
|
||||||
"refresh_token_expires_at" timestamp,
|
|
||||||
"scope" text,
|
|
||||||
"password" text,
|
|
||||||
"created_at" timestamp NOT NULL,
|
|
||||||
"updated_at" timestamp NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "session" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"expires_at" timestamp NOT NULL,
|
|
||||||
"token" text NOT NULL,
|
|
||||||
"created_at" timestamp NOT NULL,
|
|
||||||
"updated_at" timestamp NOT NULL,
|
|
||||||
"ip_address" text,
|
|
||||||
"user_agent" text,
|
|
||||||
"user_id" text NOT NULL,
|
|
||||||
CONSTRAINT "session_token_unique" UNIQUE("token")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "user" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"email" text NOT NULL,
|
|
||||||
"email_verified" boolean DEFAULT false NOT NULL,
|
|
||||||
"image" text,
|
|
||||||
"created_at" timestamp NOT NULL,
|
|
||||||
"updated_at" timestamp NOT NULL,
|
|
||||||
CONSTRAINT "user_email_unique" UNIQUE("email")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "user_sources" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
|
||||||
"user_id" text NOT NULL,
|
|
||||||
"source_id" text NOT NULL,
|
|
||||||
"enabled" boolean DEFAULT true NOT NULL,
|
|
||||||
"config" jsonb DEFAULT '{}'::jsonb,
|
|
||||||
"credentials" "bytea",
|
|
||||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT "user_sources_user_id_source_id_unique" UNIQUE("user_id","source_id")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "verification" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"identifier" text NOT NULL,
|
|
||||||
"value" text NOT NULL,
|
|
||||||
"expires_at" timestamp NOT NULL,
|
|
||||||
"created_at" timestamp NOT NULL,
|
|
||||||
"updated_at" timestamp NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "user_sources" ADD CONSTRAINT "user_sources_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
|
|
||||||
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
CREATE INDEX "user_sources_user_id_enabled_idx" ON "user_sources" USING btree ("user_id","enabled");
|
|
||||||
@@ -1,457 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "d8c59ec7-b686-41a7-a472-da29f3ab6727",
|
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
|
||||||
"version": "7",
|
|
||||||
"dialect": "postgresql",
|
|
||||||
"tables": {
|
|
||||||
"public.account": {
|
|
||||||
"name": "account",
|
|
||||||
"schema": "",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"account_id": {
|
|
||||||
"name": "account_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"provider_id": {
|
|
||||||
"name": "provider_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"user_id": {
|
|
||||||
"name": "user_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"access_token": {
|
|
||||||
"name": "access_token",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"refresh_token": {
|
|
||||||
"name": "refresh_token",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"id_token": {
|
|
||||||
"name": "id_token",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"access_token_expires_at": {
|
|
||||||
"name": "access_token_expires_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"refresh_token_expires_at": {
|
|
||||||
"name": "refresh_token_expires_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"scope": {
|
|
||||||
"name": "scope",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"password": {
|
|
||||||
"name": "password",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"updated_at": {
|
|
||||||
"name": "updated_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {
|
|
||||||
"account_userId_idx": {
|
|
||||||
"name": "account_userId_idx",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"expression": "user_id",
|
|
||||||
"isExpression": false,
|
|
||||||
"asc": true,
|
|
||||||
"nulls": "last"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": false,
|
|
||||||
"concurrently": false,
|
|
||||||
"method": "btree",
|
|
||||||
"with": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"foreignKeys": {
|
|
||||||
"account_user_id_user_id_fk": {
|
|
||||||
"name": "account_user_id_user_id_fk",
|
|
||||||
"tableFrom": "account",
|
|
||||||
"tableTo": "user",
|
|
||||||
"columnsFrom": [
|
|
||||||
"user_id"
|
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
|
||||||
"onUpdate": "no action"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {},
|
|
||||||
"policies": {},
|
|
||||||
"checkConstraints": {},
|
|
||||||
"isRLSEnabled": false
|
|
||||||
},
|
|
||||||
"public.session": {
|
|
||||||
"name": "session",
|
|
||||||
"schema": "",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"expires_at": {
|
|
||||||
"name": "expires_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"token": {
|
|
||||||
"name": "token",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"updated_at": {
|
|
||||||
"name": "updated_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"ip_address": {
|
|
||||||
"name": "ip_address",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"user_agent": {
|
|
||||||
"name": "user_agent",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"user_id": {
|
|
||||||
"name": "user_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {
|
|
||||||
"session_userId_idx": {
|
|
||||||
"name": "session_userId_idx",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"expression": "user_id",
|
|
||||||
"isExpression": false,
|
|
||||||
"asc": true,
|
|
||||||
"nulls": "last"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": false,
|
|
||||||
"concurrently": false,
|
|
||||||
"method": "btree",
|
|
||||||
"with": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"foreignKeys": {
|
|
||||||
"session_user_id_user_id_fk": {
|
|
||||||
"name": "session_user_id_user_id_fk",
|
|
||||||
"tableFrom": "session",
|
|
||||||
"tableTo": "user",
|
|
||||||
"columnsFrom": [
|
|
||||||
"user_id"
|
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
|
||||||
"onUpdate": "no action"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {
|
|
||||||
"session_token_unique": {
|
|
||||||
"name": "session_token_unique",
|
|
||||||
"nullsNotDistinct": false,
|
|
||||||
"columns": [
|
|
||||||
"token"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"policies": {},
|
|
||||||
"checkConstraints": {},
|
|
||||||
"isRLSEnabled": false
|
|
||||||
},
|
|
||||||
"public.user": {
|
|
||||||
"name": "user",
|
|
||||||
"schema": "",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"name": "name",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"email": {
|
|
||||||
"name": "email",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"email_verified": {
|
|
||||||
"name": "email_verified",
|
|
||||||
"type": "boolean",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": false
|
|
||||||
},
|
|
||||||
"image": {
|
|
||||||
"name": "image",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"updated_at": {
|
|
||||||
"name": "updated_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {},
|
|
||||||
"foreignKeys": {},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {
|
|
||||||
"user_email_unique": {
|
|
||||||
"name": "user_email_unique",
|
|
||||||
"nullsNotDistinct": false,
|
|
||||||
"columns": [
|
|
||||||
"email"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"policies": {},
|
|
||||||
"checkConstraints": {},
|
|
||||||
"isRLSEnabled": false
|
|
||||||
},
|
|
||||||
"public.user_sources": {
|
|
||||||
"name": "user_sources",
|
|
||||||
"schema": "",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "uuid",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "gen_random_uuid()"
|
|
||||||
},
|
|
||||||
"user_id": {
|
|
||||||
"name": "user_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"source_id": {
|
|
||||||
"name": "source_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"enabled": {
|
|
||||||
"name": "enabled",
|
|
||||||
"type": "boolean",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"name": "config",
|
|
||||||
"type": "jsonb",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false,
|
|
||||||
"default": "'{}'::jsonb"
|
|
||||||
},
|
|
||||||
"credentials": {
|
|
||||||
"name": "credentials",
|
|
||||||
"type": "bytea",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "now()"
|
|
||||||
},
|
|
||||||
"updated_at": {
|
|
||||||
"name": "updated_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "now()"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {},
|
|
||||||
"foreignKeys": {
|
|
||||||
"user_sources_user_id_user_id_fk": {
|
|
||||||
"name": "user_sources_user_id_user_id_fk",
|
|
||||||
"tableFrom": "user_sources",
|
|
||||||
"tableTo": "user",
|
|
||||||
"columnsFrom": [
|
|
||||||
"user_id"
|
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
|
||||||
"onUpdate": "no action"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {
|
|
||||||
"user_sources_user_id_source_id_unique": {
|
|
||||||
"name": "user_sources_user_id_source_id_unique",
|
|
||||||
"nullsNotDistinct": false,
|
|
||||||
"columns": [
|
|
||||||
"user_id",
|
|
||||||
"source_id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"policies": {},
|
|
||||||
"checkConstraints": {},
|
|
||||||
"isRLSEnabled": false
|
|
||||||
},
|
|
||||||
"public.verification": {
|
|
||||||
"name": "verification",
|
|
||||||
"schema": "",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"identifier": {
|
|
||||||
"name": "identifier",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"value": {
|
|
||||||
"name": "value",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"expires_at": {
|
|
||||||
"name": "expires_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"updated_at": {
|
|
||||||
"name": "updated_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {
|
|
||||||
"verification_identifier_idx": {
|
|
||||||
"name": "verification_identifier_idx",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"expression": "identifier",
|
|
||||||
"isExpression": false,
|
|
||||||
"asc": true,
|
|
||||||
"nulls": "last"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": false,
|
|
||||||
"concurrently": false,
|
|
||||||
"method": "btree",
|
|
||||||
"with": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"foreignKeys": {},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {},
|
|
||||||
"policies": {},
|
|
||||||
"checkConstraints": {},
|
|
||||||
"isRLSEnabled": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"enums": {},
|
|
||||||
"schemas": {},
|
|
||||||
"sequences": {},
|
|
||||||
"roles": {},
|
|
||||||
"policies": {},
|
|
||||||
"views": {},
|
|
||||||
"_meta": {
|
|
||||||
"columns": {},
|
|
||||||
"schemas": {},
|
|
||||||
"tables": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,479 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "d963322c-77e2-4ac9-bd3c-ca544c85ae35",
|
|
||||||
"prevId": "d8c59ec7-b686-41a7-a472-da29f3ab6727",
|
|
||||||
"version": "7",
|
|
||||||
"dialect": "postgresql",
|
|
||||||
"tables": {
|
|
||||||
"public.account": {
|
|
||||||
"name": "account",
|
|
||||||
"schema": "",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"account_id": {
|
|
||||||
"name": "account_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"provider_id": {
|
|
||||||
"name": "provider_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"user_id": {
|
|
||||||
"name": "user_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"access_token": {
|
|
||||||
"name": "access_token",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"refresh_token": {
|
|
||||||
"name": "refresh_token",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"id_token": {
|
|
||||||
"name": "id_token",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"access_token_expires_at": {
|
|
||||||
"name": "access_token_expires_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"refresh_token_expires_at": {
|
|
||||||
"name": "refresh_token_expires_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"scope": {
|
|
||||||
"name": "scope",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"password": {
|
|
||||||
"name": "password",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"updated_at": {
|
|
||||||
"name": "updated_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {
|
|
||||||
"account_userId_idx": {
|
|
||||||
"name": "account_userId_idx",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"expression": "user_id",
|
|
||||||
"isExpression": false,
|
|
||||||
"asc": true,
|
|
||||||
"nulls": "last"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": false,
|
|
||||||
"concurrently": false,
|
|
||||||
"method": "btree",
|
|
||||||
"with": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"foreignKeys": {
|
|
||||||
"account_user_id_user_id_fk": {
|
|
||||||
"name": "account_user_id_user_id_fk",
|
|
||||||
"tableFrom": "account",
|
|
||||||
"tableTo": "user",
|
|
||||||
"columnsFrom": [
|
|
||||||
"user_id"
|
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
|
||||||
"onUpdate": "no action"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {},
|
|
||||||
"policies": {},
|
|
||||||
"checkConstraints": {},
|
|
||||||
"isRLSEnabled": false
|
|
||||||
},
|
|
||||||
"public.session": {
|
|
||||||
"name": "session",
|
|
||||||
"schema": "",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"expires_at": {
|
|
||||||
"name": "expires_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"token": {
|
|
||||||
"name": "token",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"updated_at": {
|
|
||||||
"name": "updated_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"ip_address": {
|
|
||||||
"name": "ip_address",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"user_agent": {
|
|
||||||
"name": "user_agent",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"user_id": {
|
|
||||||
"name": "user_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {
|
|
||||||
"session_userId_idx": {
|
|
||||||
"name": "session_userId_idx",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"expression": "user_id",
|
|
||||||
"isExpression": false,
|
|
||||||
"asc": true,
|
|
||||||
"nulls": "last"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": false,
|
|
||||||
"concurrently": false,
|
|
||||||
"method": "btree",
|
|
||||||
"with": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"foreignKeys": {
|
|
||||||
"session_user_id_user_id_fk": {
|
|
||||||
"name": "session_user_id_user_id_fk",
|
|
||||||
"tableFrom": "session",
|
|
||||||
"tableTo": "user",
|
|
||||||
"columnsFrom": [
|
|
||||||
"user_id"
|
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
|
||||||
"onUpdate": "no action"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {
|
|
||||||
"session_token_unique": {
|
|
||||||
"name": "session_token_unique",
|
|
||||||
"nullsNotDistinct": false,
|
|
||||||
"columns": [
|
|
||||||
"token"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"policies": {},
|
|
||||||
"checkConstraints": {},
|
|
||||||
"isRLSEnabled": false
|
|
||||||
},
|
|
||||||
"public.user": {
|
|
||||||
"name": "user",
|
|
||||||
"schema": "",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"name": "name",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"email": {
|
|
||||||
"name": "email",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"email_verified": {
|
|
||||||
"name": "email_verified",
|
|
||||||
"type": "boolean",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": false
|
|
||||||
},
|
|
||||||
"image": {
|
|
||||||
"name": "image",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"updated_at": {
|
|
||||||
"name": "updated_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {},
|
|
||||||
"foreignKeys": {},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {
|
|
||||||
"user_email_unique": {
|
|
||||||
"name": "user_email_unique",
|
|
||||||
"nullsNotDistinct": false,
|
|
||||||
"columns": [
|
|
||||||
"email"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"policies": {},
|
|
||||||
"checkConstraints": {},
|
|
||||||
"isRLSEnabled": false
|
|
||||||
},
|
|
||||||
"public.user_sources": {
|
|
||||||
"name": "user_sources",
|
|
||||||
"schema": "",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "uuid",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "gen_random_uuid()"
|
|
||||||
},
|
|
||||||
"user_id": {
|
|
||||||
"name": "user_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"source_id": {
|
|
||||||
"name": "source_id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"enabled": {
|
|
||||||
"name": "enabled",
|
|
||||||
"type": "boolean",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"name": "config",
|
|
||||||
"type": "jsonb",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false,
|
|
||||||
"default": "'{}'::jsonb"
|
|
||||||
},
|
|
||||||
"credentials": {
|
|
||||||
"name": "credentials",
|
|
||||||
"type": "bytea",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "now()"
|
|
||||||
},
|
|
||||||
"updated_at": {
|
|
||||||
"name": "updated_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true,
|
|
||||||
"default": "now()"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {
|
|
||||||
"user_sources_user_id_enabled_idx": {
|
|
||||||
"name": "user_sources_user_id_enabled_idx",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"expression": "user_id",
|
|
||||||
"isExpression": false,
|
|
||||||
"asc": true,
|
|
||||||
"nulls": "last"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"expression": "enabled",
|
|
||||||
"isExpression": false,
|
|
||||||
"asc": true,
|
|
||||||
"nulls": "last"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": false,
|
|
||||||
"concurrently": false,
|
|
||||||
"method": "btree",
|
|
||||||
"with": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"foreignKeys": {
|
|
||||||
"user_sources_user_id_user_id_fk": {
|
|
||||||
"name": "user_sources_user_id_user_id_fk",
|
|
||||||
"tableFrom": "user_sources",
|
|
||||||
"tableTo": "user",
|
|
||||||
"columnsFrom": [
|
|
||||||
"user_id"
|
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
|
||||||
"onUpdate": "no action"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {
|
|
||||||
"user_sources_user_id_source_id_unique": {
|
|
||||||
"name": "user_sources_user_id_source_id_unique",
|
|
||||||
"nullsNotDistinct": false,
|
|
||||||
"columns": [
|
|
||||||
"user_id",
|
|
||||||
"source_id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"policies": {},
|
|
||||||
"checkConstraints": {},
|
|
||||||
"isRLSEnabled": false
|
|
||||||
},
|
|
||||||
"public.verification": {
|
|
||||||
"name": "verification",
|
|
||||||
"schema": "",
|
|
||||||
"columns": {
|
|
||||||
"id": {
|
|
||||||
"name": "id",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": true,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"identifier": {
|
|
||||||
"name": "identifier",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"value": {
|
|
||||||
"name": "value",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"expires_at": {
|
|
||||||
"name": "expires_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"created_at": {
|
|
||||||
"name": "created_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
"updated_at": {
|
|
||||||
"name": "updated_at",
|
|
||||||
"type": "timestamp",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"indexes": {
|
|
||||||
"verification_identifier_idx": {
|
|
||||||
"name": "verification_identifier_idx",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"expression": "identifier",
|
|
||||||
"isExpression": false,
|
|
||||||
"asc": true,
|
|
||||||
"nulls": "last"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"isUnique": false,
|
|
||||||
"concurrently": false,
|
|
||||||
"method": "btree",
|
|
||||||
"with": {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"foreignKeys": {},
|
|
||||||
"compositePrimaryKeys": {},
|
|
||||||
"uniqueConstraints": {},
|
|
||||||
"policies": {},
|
|
||||||
"checkConstraints": {},
|
|
||||||
"isRLSEnabled": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"enums": {},
|
|
||||||
"schemas": {},
|
|
||||||
"sequences": {},
|
|
||||||
"roles": {},
|
|
||||||
"policies": {},
|
|
||||||
"views": {},
|
|
||||||
"_meta": {
|
|
||||||
"columns": {},
|
|
||||||
"schemas": {},
|
|
||||||
"tables": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "7",
|
|
||||||
"dialect": "postgresql",
|
|
||||||
"entries": [
|
|
||||||
{
|
|
||||||
"idx": 0,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1773620066366,
|
|
||||||
"tag": "0000_wakeful_scorpion",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 1,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1773624297794,
|
|
||||||
"tag": "0001_misty_white_tiger",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -6,13 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --watch src/server.ts",
|
"dev": "bun run --watch src/server.ts",
|
||||||
"start": "bun run src/server.ts",
|
"start": "bun run src/server.ts",
|
||||||
"test": "bun test src/",
|
"test": "bun test src/"
|
||||||
"db:generate": "bunx drizzle-kit generate",
|
|
||||||
"db:generate-auth": "bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts -y",
|
|
||||||
"db:push": "bunx drizzle-kit push",
|
|
||||||
"db:migrate": "bunx drizzle-kit migrate",
|
|
||||||
"db:studio": "bunx drizzle-kit studio",
|
|
||||||
"create-admin": "bun run src/scripts/create-admin.ts"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aelis/core": "workspace:*",
|
"@aelis/core": "workspace:*",
|
||||||
@@ -24,10 +18,10 @@
|
|||||||
"@openrouter/sdk": "^0.9.11",
|
"@openrouter/sdk": "^0.9.11",
|
||||||
"arktype": "^2.1.29",
|
"arktype": "^2.1.29",
|
||||||
"better-auth": "^1",
|
"better-auth": "^1",
|
||||||
"drizzle-orm": "^0.45.1",
|
"hono": "^4",
|
||||||
"hono": "^4"
|
"pg": "^8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"drizzle-kit": "^0.31.9"
|
"@types/pg": "^8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Hono } from "hono"
|
import type { Hono } from "hono"
|
||||||
|
|
||||||
import type { Auth } from "./index.ts"
|
import { auth } from "./index.ts"
|
||||||
|
|
||||||
export function registerAuthHandlers(app: Hono, auth: Auth): void {
|
export function registerAuthHandlers(app: Hono): void {
|
||||||
app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw))
|
app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,10 @@
|
|||||||
import { betterAuth } from "better-auth"
|
import { betterAuth } from "better-auth"
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle"
|
|
||||||
import { admin } from "better-auth/plugins"
|
|
||||||
|
|
||||||
import type { Database } from "../db/index.ts"
|
import { pool } from "../db.ts"
|
||||||
|
|
||||||
import * as schema from "../db/schema.ts"
|
export const auth = betterAuth({
|
||||||
|
database: pool,
|
||||||
export function createAuth(db: Database) {
|
|
||||||
if (!process.env.BETTER_AUTH_SECRET) {
|
|
||||||
throw new Error("BETTER_AUTH_SECRET is not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
return betterAuth({
|
|
||||||
database: drizzleAdapter(db, {
|
|
||||||
provider: "pg",
|
|
||||||
schema,
|
|
||||||
}),
|
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
plugins: [admin()],
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
export type Auth = ReturnType<typeof createAuth>
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { Context, MiddlewareHandler, Next } from "hono"
|
import type { Context, MiddlewareHandler, Next } from "hono"
|
||||||
|
|
||||||
import type { Auth } from "./index.ts"
|
|
||||||
import type { AuthSession, AuthUser } from "./session.ts"
|
import type { AuthSession, AuthUser } from "./session.ts"
|
||||||
|
|
||||||
|
import { auth } from "./index.ts"
|
||||||
|
|
||||||
export interface SessionVariables {
|
export interface SessionVariables {
|
||||||
user: AuthUser | null
|
user: AuthUser | null
|
||||||
session: AuthSession | null
|
session: AuthSession | null
|
||||||
@@ -17,11 +18,10 @@ declare module "hono" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a middleware that attaches session and user to the context.
|
* Middleware that attaches session and user to the context.
|
||||||
* Does not reject unauthenticated requests - use createRequireSession for that.
|
* Does not reject unauthenticated requests - use requireSession for that.
|
||||||
*/
|
*/
|
||||||
export function createSessionMiddleware(auth: Auth): AuthSessionMiddleware {
|
export async function sessionMiddleware(c: Context, next: Next): Promise<void> {
|
||||||
return async (c: Context, next: Next): Promise<void> => {
|
|
||||||
const session = await auth.api.getSession({ headers: c.req.raw.headers })
|
const session = await auth.api.getSession({ headers: c.req.raw.headers })
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
@@ -34,13 +34,11 @@ export function createSessionMiddleware(auth: Auth): AuthSessionMiddleware {
|
|||||||
|
|
||||||
await next()
|
await next()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a middleware that requires a valid session. Returns 401 if not authenticated.
|
* Middleware that requires a valid session. Returns 401 if not authenticated.
|
||||||
*/
|
*/
|
||||||
export function createRequireSession(auth: Auth): AuthSessionMiddleware {
|
export async function requireSession(c: Context, next: Next): Promise<Response | void> {
|
||||||
return async (c: Context, next: Next): Promise<Response | void> => {
|
|
||||||
const session = await auth.api.getSession({ headers: c.req.raw.headers })
|
const session = await auth.api.getSession({ headers: c.req.raw.headers })
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -51,17 +49,16 @@ export function createRequireSession(auth: Auth): AuthSessionMiddleware {
|
|||||||
c.set("session", session.session)
|
c.set("session", session.session)
|
||||||
await next()
|
await next()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a function to get session from headers. Useful for WebSocket upgrade validation.
|
* Get session from headers. Useful for WebSocket upgrade validation.
|
||||||
*/
|
*/
|
||||||
export function createGetSessionFromHeaders(auth: Auth) {
|
export async function getSessionFromHeaders(
|
||||||
return async (headers: Headers): Promise<{ user: AuthUser; session: AuthSession } | null> => {
|
headers: Headers,
|
||||||
|
): Promise<{ user: AuthUser; session: AuthSession } | null> {
|
||||||
const session = await auth.api.getSession({ headers })
|
const session = await auth.api.getSession({ headers })
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dev/test middleware that injects a fake user and session.
|
* Dev/test middleware that injects a fake user and session.
|
||||||
@@ -84,10 +81,6 @@ export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddlewar
|
|||||||
image: null,
|
image: null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
role: "admin",
|
|
||||||
banned: false,
|
|
||||||
banReason: null,
|
|
||||||
banExpires: null,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const session: AuthSession = {
|
const session: AuthSession = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Auth } from "./index.ts"
|
import type { auth } from "./index.ts"
|
||||||
|
|
||||||
export type AuthUser = Auth["$Infer"]["Session"]["user"]
|
export type AuthUser = typeof auth.$Infer.Session.user
|
||||||
export type AuthSession = Auth["$Infer"]["Session"]["session"]
|
export type AuthSession = typeof auth.$Infer.Session.session
|
||||||
|
|||||||
5
apps/aelis-backend/src/db.ts
Normal file
5
apps/aelis-backend/src/db.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { Pool } from "pg"
|
||||||
|
|
||||||
|
export const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
})
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { relations } from "drizzle-orm"
|
|
||||||
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core"
|
|
||||||
|
|
||||||
export const user = pgTable("user", {
|
|
||||||
id: text("id").primaryKey(),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
email: text("email").notNull().unique(),
|
|
||||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
|
||||||
image: text("image"),
|
|
||||||
createdAt: timestamp("created_at").notNull(),
|
|
||||||
updatedAt: timestamp("updated_at")
|
|
||||||
.$onUpdate(() => new Date())
|
|
||||||
.notNull(),
|
|
||||||
role: text("role"),
|
|
||||||
banned: boolean("banned").default(false),
|
|
||||||
banReason: text("ban_reason"),
|
|
||||||
banExpires: timestamp("ban_expires"),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const session = pgTable(
|
|
||||||
"session",
|
|
||||||
{
|
|
||||||
id: text("id").primaryKey(),
|
|
||||||
expiresAt: timestamp("expires_at").notNull(),
|
|
||||||
token: text("token").notNull().unique(),
|
|
||||||
createdAt: timestamp("created_at").notNull(),
|
|
||||||
updatedAt: timestamp("updated_at")
|
|
||||||
.$onUpdate(() => new Date())
|
|
||||||
.notNull(),
|
|
||||||
ipAddress: text("ip_address"),
|
|
||||||
userAgent: text("user_agent"),
|
|
||||||
userId: text("user_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
|
||||||
impersonatedBy: text("impersonated_by"),
|
|
||||||
},
|
|
||||||
(table) => [index("session_userId_idx").on(table.userId)],
|
|
||||||
)
|
|
||||||
|
|
||||||
export const account = pgTable(
|
|
||||||
"account",
|
|
||||||
{
|
|
||||||
id: text("id").primaryKey(),
|
|
||||||
accountId: text("account_id").notNull(),
|
|
||||||
providerId: text("provider_id").notNull(),
|
|
||||||
userId: text("user_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
|
||||||
accessToken: text("access_token"),
|
|
||||||
refreshToken: text("refresh_token"),
|
|
||||||
idToken: text("id_token"),
|
|
||||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
|
||||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
|
||||||
scope: text("scope"),
|
|
||||||
password: text("password"),
|
|
||||||
createdAt: timestamp("created_at").notNull(),
|
|
||||||
updatedAt: timestamp("updated_at")
|
|
||||||
.$onUpdate(() => new Date())
|
|
||||||
.notNull(),
|
|
||||||
},
|
|
||||||
(table) => [index("account_userId_idx").on(table.userId)],
|
|
||||||
)
|
|
||||||
|
|
||||||
export const verification = pgTable(
|
|
||||||
"verification",
|
|
||||||
{
|
|
||||||
id: text("id").primaryKey(),
|
|
||||||
identifier: text("identifier").notNull(),
|
|
||||||
value: text("value").notNull(),
|
|
||||||
expiresAt: timestamp("expires_at").notNull(),
|
|
||||||
createdAt: timestamp("created_at").notNull(),
|
|
||||||
updatedAt: timestamp("updated_at")
|
|
||||||
.$onUpdate(() => new Date())
|
|
||||||
.notNull(),
|
|
||||||
},
|
|
||||||
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
|
||||||
)
|
|
||||||
|
|
||||||
export const userRelations = relations(user, ({ many }) => ({
|
|
||||||
sessions: many(session),
|
|
||||||
accounts: many(account),
|
|
||||||
}))
|
|
||||||
|
|
||||||
export const sessionRelations = relations(session, ({ one }) => ({
|
|
||||||
user: one(user, {
|
|
||||||
fields: [session.userId],
|
|
||||||
references: [user.id],
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
export const accountRelations = relations(account, ({ one }) => ({
|
|
||||||
user: one(user, {
|
|
||||||
fields: [account.userId],
|
|
||||||
references: [user.id],
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { SQL } from "bun"
|
|
||||||
import { drizzle, type BunSQLDatabase } from "drizzle-orm/bun-sql"
|
|
||||||
|
|
||||||
import * as schema from "./schema.ts"
|
|
||||||
|
|
||||||
export type Database = BunSQLDatabase<typeof schema>
|
|
||||||
|
|
||||||
export interface DatabaseConnection {
|
|
||||||
db: Database
|
|
||||||
close: () => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDatabase(url: string): DatabaseConnection {
|
|
||||||
if (!url) {
|
|
||||||
throw new Error("DATABASE_URL is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new SQL({ url })
|
|
||||||
return {
|
|
||||||
db: drizzle({ client, schema }),
|
|
||||||
close: () => client.close(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import {
|
|
||||||
boolean,
|
|
||||||
customType,
|
|
||||||
index,
|
|
||||||
jsonb,
|
|
||||||
pgTable,
|
|
||||||
text,
|
|
||||||
timestamp,
|
|
||||||
unique,
|
|
||||||
uuid,
|
|
||||||
} from "drizzle-orm/pg-core"
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Better Auth core tables
|
|
||||||
// Re-exported from CLI-generated schema.
|
|
||||||
// Regenerate with: bunx --bun auth@latest generate --config auth.ts --output src/db/auth-schema.ts
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export {
|
|
||||||
user,
|
|
||||||
session,
|
|
||||||
account,
|
|
||||||
verification,
|
|
||||||
userRelations,
|
|
||||||
sessionRelations,
|
|
||||||
accountRelations,
|
|
||||||
} from "./auth-schema.ts"
|
|
||||||
|
|
||||||
import { user } from "./auth-schema.ts"
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// AELIS — per-user source configuration
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const bytea = customType<{ data: Buffer }>({
|
|
||||||
dataType() {
|
|
||||||
return "bytea"
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const userSources = pgTable(
|
|
||||||
"user_sources",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
userId: text("user_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
|
||||||
sourceId: text("source_id").notNull(),
|
|
||||||
enabled: boolean("enabled").notNull().default(true),
|
|
||||||
config: jsonb("config").default({}),
|
|
||||||
credentials: bytea("credentials"),
|
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
||||||
updatedAt: timestamp("updated_at")
|
|
||||||
.notNull()
|
|
||||||
.defaultNow()
|
|
||||||
.$onUpdate(() => new Date()),
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
unique("user_sources_user_id_source_id_unique").on(t.userId, t.sourceId),
|
|
||||||
index("user_sources_user_id_enabled_idx").on(t.userId, t.enabled),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
|
||||||
|
|
||||||
import { contextKey } from "@aelis/core"
|
|
||||||
import { describe, expect, spyOn, test } from "bun:test"
|
|
||||||
import { Hono } from "hono"
|
|
||||||
|
|
||||||
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
|
|
||||||
import { UserSessionManager } from "../session/index.ts"
|
|
||||||
import { registerFeedHttpHandlers } from "./http.ts"
|
|
||||||
|
|
||||||
interface FeedResponse {
|
|
||||||
items: Array<{
|
|
||||||
id: string
|
|
||||||
type: string
|
|
||||||
priority: number
|
|
||||||
timestamp: string
|
|
||||||
data: Record<string, unknown>
|
|
||||||
}>
|
|
||||||
errors: Array<{ sourceId: string; error: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
function createStubSource(
|
|
||||||
id: string,
|
|
||||||
items: FeedItem[] = [],
|
|
||||||
contextEntries: readonly ContextEntry[] | null = null,
|
|
||||||
): FeedSource {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
async listActions(): Promise<Record<string, ActionDefinition>> {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
async executeAction(): Promise<unknown> {
|
|
||||||
return undefined
|
|
||||||
},
|
|
||||||
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
|
||||||
return contextEntries
|
|
||||||
},
|
|
||||||
async fetchItems() {
|
|
||||||
return items
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTestApp(sessionManager: UserSessionManager, userId?: string) {
|
|
||||||
const app = new Hono()
|
|
||||||
registerFeedHttpHandlers(app, {
|
|
||||||
sessionManager,
|
|
||||||
authSessionMiddleware: mockAuthSessionMiddleware(userId),
|
|
||||||
})
|
|
||||||
return app
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("GET /api/feed", () => {
|
|
||||||
test("returns 401 without auth", async () => {
|
|
||||||
const manager = new UserSessionManager({ providers: [] })
|
|
||||||
const app = buildTestApp(manager)
|
|
||||||
|
|
||||||
const res = await app.request("/api/feed")
|
|
||||||
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns cached feed when available", async () => {
|
|
||||||
const items: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "item-1",
|
|
||||||
sourceId: "test",
|
|
||||||
type: "test",
|
|
||||||
priority: 0.8,
|
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
|
||||||
data: { value: 42 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const manager = new UserSessionManager({
|
|
||||||
providers: [async () => createStubSource("test", items)],
|
|
||||||
})
|
|
||||||
const app = buildTestApp(manager, "user-1")
|
|
||||||
|
|
||||||
// Prime the cache
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
|
||||||
await session.engine.refresh()
|
|
||||||
expect(session.engine.lastFeed()).not.toBeNull()
|
|
||||||
|
|
||||||
const res = await app.request("/api/feed")
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as FeedResponse
|
|
||||||
expect(body.items).toHaveLength(1)
|
|
||||||
expect(body.items[0]!.id).toBe("item-1")
|
|
||||||
expect(body.items[0]!.type).toBe("test")
|
|
||||||
expect(body.items[0]!.priority).toBe(0.8)
|
|
||||||
expect(body.items[0]!.timestamp).toBe("2025-01-01T00:00:00.000Z")
|
|
||||||
expect(body.errors).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("forces refresh when no cached feed", async () => {
|
|
||||||
const items: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "fresh-1",
|
|
||||||
sourceId: "test",
|
|
||||||
type: "test",
|
|
||||||
priority: 0.5,
|
|
||||||
timestamp: new Date("2025-06-01T12:00:00.000Z"),
|
|
||||||
data: { fresh: true },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const manager = new UserSessionManager({
|
|
||||||
providers: [async () => createStubSource("test", items)],
|
|
||||||
})
|
|
||||||
const app = buildTestApp(manager, "user-1")
|
|
||||||
|
|
||||||
// No prior refresh — lastFeed() returns null, handler should call refresh()
|
|
||||||
const res = await app.request("/api/feed")
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as FeedResponse
|
|
||||||
expect(body.items).toHaveLength(1)
|
|
||||||
expect(body.items[0]!.id).toBe("fresh-1")
|
|
||||||
expect(body.items[0]!.data.fresh).toBe(true)
|
|
||||||
expect(body.errors).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("serializes source errors as message strings", async () => {
|
|
||||||
const failingSource: FeedSource = {
|
|
||||||
id: "failing",
|
|
||||||
async listActions() {
|
|
||||||
return {}
|
|
||||||
},
|
|
||||||
async executeAction() {
|
|
||||||
return undefined
|
|
||||||
},
|
|
||||||
async fetchContext() {
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
async fetchItems() {
|
|
||||||
throw new Error("connection timeout")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const manager = new UserSessionManager({ providers: [async () => failingSource] })
|
|
||||||
const app = buildTestApp(manager, "user-1")
|
|
||||||
|
|
||||||
const res = await app.request("/api/feed")
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as FeedResponse
|
|
||||||
expect(body.items).toHaveLength(0)
|
|
||||||
expect(body.errors).toHaveLength(1)
|
|
||||||
expect(body.errors[0]!.sourceId).toBe("failing")
|
|
||||||
expect(body.errors[0]!.error).toBe("connection timeout")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns 503 when all providers fail", async () => {
|
|
||||||
const manager = new UserSessionManager({
|
|
||||||
providers: [
|
|
||||||
async () => {
|
|
||||||
throw new Error("provider down")
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
const app = buildTestApp(manager, "user-1")
|
|
||||||
|
|
||||||
const spy = spyOn(console, "error").mockImplementation(() => {})
|
|
||||||
|
|
||||||
const res = await app.request("/api/feed")
|
|
||||||
|
|
||||||
expect(res.status).toBe(503)
|
|
||||||
const body = (await res.json()) as { error: string }
|
|
||||||
expect(body.error).toBe("Service unavailable")
|
|
||||||
|
|
||||||
spy.mockRestore()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("GET /api/context", () => {
|
|
||||||
const weatherKey = contextKey("aelis.weather", "weather")
|
|
||||||
const weatherData = { temperature: 20, condition: "Clear" }
|
|
||||||
const contextEntries: readonly ContextEntry[] = [[weatherKey, weatherData]]
|
|
||||||
|
|
||||||
// The mock auth middleware always injects this hardcoded user ID
|
|
||||||
const mockUserId = "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn"
|
|
||||||
|
|
||||||
async function buildContextApp(userId?: string) {
|
|
||||||
const manager = new UserSessionManager({
|
|
||||||
providers: [async () => createStubSource("weather", [], contextEntries)],
|
|
||||||
})
|
|
||||||
const app = buildTestApp(manager, userId)
|
|
||||||
const session = await manager.getOrCreate(mockUserId)
|
|
||||||
return { app, session }
|
|
||||||
}
|
|
||||||
|
|
||||||
test("returns 401 without auth", async () => {
|
|
||||||
const manager = new UserSessionManager({ providers: [] })
|
|
||||||
const app = buildTestApp(manager)
|
|
||||||
|
|
||||||
const res = await app.request('/api/context?key=["aelis.weather","weather"]')
|
|
||||||
|
|
||||||
expect(res.status).toBe(401)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns 400 when key param is missing", async () => {
|
|
||||||
const { app } = await buildContextApp("user-1")
|
|
||||||
|
|
||||||
const res = await app.request("/api/context")
|
|
||||||
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
const body = (await res.json()) as { error: string }
|
|
||||||
expect(body.error).toContain("key")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns 400 when key is invalid JSON", async () => {
|
|
||||||
const { app } = await buildContextApp("user-1")
|
|
||||||
|
|
||||||
const res = await app.request("/api/context?key=notjson")
|
|
||||||
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
const body = (await res.json()) as { error: string }
|
|
||||||
expect(body.error).toContain("key")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns 400 when key is not an array", async () => {
|
|
||||||
const { app } = await buildContextApp("user-1")
|
|
||||||
|
|
||||||
const res = await app.request('/api/context?key="string"')
|
|
||||||
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
const body = (await res.json()) as { error: string }
|
|
||||||
expect(body.error).toContain("key")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns 400 when key contains invalid element types", async () => {
|
|
||||||
const { app } = await buildContextApp("user-1")
|
|
||||||
|
|
||||||
const res = await app.request("/api/context?key=[true,null,[1,2]]")
|
|
||||||
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
const body = (await res.json()) as { error: string }
|
|
||||||
expect(body.error).toContain("key")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns 400 when key is an empty array", async () => {
|
|
||||||
const { app } = await buildContextApp("user-1")
|
|
||||||
|
|
||||||
const res = await app.request("/api/context?key=[]")
|
|
||||||
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
const body = (await res.json()) as { error: string }
|
|
||||||
expect(body.error).toContain("key")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns 400 when match param is invalid", async () => {
|
|
||||||
const { app } = await buildContextApp("user-1")
|
|
||||||
|
|
||||||
const res = await app.request('/api/context?key=["aelis.weather"]&match=invalid')
|
|
||||||
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
const body = (await res.json()) as { error: string }
|
|
||||||
expect(body.error).toContain("match")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns exact match with match=exact", async () => {
|
|
||||||
const { app, session } = await buildContextApp("user-1")
|
|
||||||
await session.engine.refresh()
|
|
||||||
|
|
||||||
const res = await app.request('/api/context?key=["aelis.weather","weather"]&match=exact')
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as { match: string; value: unknown }
|
|
||||||
expect(body.match).toBe("exact")
|
|
||||||
expect(body.value).toEqual(weatherData)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns 404 with match=exact when only prefix would match", async () => {
|
|
||||||
const { app, session } = await buildContextApp("user-1")
|
|
||||||
await session.engine.refresh()
|
|
||||||
|
|
||||||
const res = await app.request('/api/context?key=["aelis.weather"]&match=exact')
|
|
||||||
|
|
||||||
expect(res.status).toBe(404)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns prefix match with match=prefix", async () => {
|
|
||||||
const { app, session } = await buildContextApp("user-1")
|
|
||||||
await session.engine.refresh()
|
|
||||||
|
|
||||||
const res = await app.request('/api/context?key=["aelis.weather"]&match=prefix')
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as {
|
|
||||||
match: string
|
|
||||||
entries: Array<{ key: unknown[]; value: unknown }>
|
|
||||||
}
|
|
||||||
expect(body.match).toBe("prefix")
|
|
||||||
expect(body.entries).toHaveLength(1)
|
|
||||||
expect(body.entries[0]!.key).toEqual(["aelis.weather", "weather"])
|
|
||||||
expect(body.entries[0]!.value).toEqual(weatherData)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("default mode returns exact match when available", async () => {
|
|
||||||
const { app, session } = await buildContextApp("user-1")
|
|
||||||
await session.engine.refresh()
|
|
||||||
|
|
||||||
const res = await app.request('/api/context?key=["aelis.weather","weather"]')
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as { match: string; value: unknown }
|
|
||||||
expect(body.match).toBe("exact")
|
|
||||||
expect(body.value).toEqual(weatherData)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("default mode falls back to prefix when no exact match", async () => {
|
|
||||||
const { app, session } = await buildContextApp("user-1")
|
|
||||||
await session.engine.refresh()
|
|
||||||
|
|
||||||
const res = await app.request('/api/context?key=["aelis.weather"]')
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
const body = (await res.json()) as {
|
|
||||||
match: string
|
|
||||||
entries: Array<{ key: unknown[]; value: unknown }>
|
|
||||||
}
|
|
||||||
expect(body.match).toBe("prefix")
|
|
||||||
expect(body.entries).toHaveLength(1)
|
|
||||||
expect(body.entries[0]!.value).toEqual(weatherData)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns 404 when neither exact nor prefix matches", async () => {
|
|
||||||
const { app, session } = await buildContextApp("user-1")
|
|
||||||
await session.engine.refresh()
|
|
||||||
|
|
||||||
const res = await app.request('/api/context?key=["nonexistent"]')
|
|
||||||
|
|
||||||
expect(res.status).toBe(404)
|
|
||||||
const body = (await res.json()) as { error: string }
|
|
||||||
expect(body.error).toBe("Context key not found")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import type { Context, Hono } from "hono"
|
|
||||||
|
|
||||||
import { contextKey } from "@aelis/core"
|
|
||||||
import { createMiddleware } from "hono/factory"
|
|
||||||
|
|
||||||
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
|
||||||
import type { UserSessionManager } from "../session/index.ts"
|
|
||||||
|
|
||||||
type Env = {
|
|
||||||
Variables: {
|
|
||||||
sessionManager: UserSessionManager
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FeedHttpHandlersDeps {
|
|
||||||
sessionManager: UserSessionManager
|
|
||||||
authSessionMiddleware: AuthSessionMiddleware
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerFeedHttpHandlers(
|
|
||||||
app: Hono,
|
|
||||||
{ sessionManager, authSessionMiddleware }: FeedHttpHandlersDeps,
|
|
||||||
) {
|
|
||||||
const inject = createMiddleware<Env>(async (c, next) => {
|
|
||||||
c.set("sessionManager", sessionManager)
|
|
||||||
await next()
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get("/api/feed", inject, authSessionMiddleware, handleGetFeed)
|
|
||||||
app.get("/api/context", inject, authSessionMiddleware, handleGetContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleGetFeed(c: Context<Env>) {
|
|
||||||
const user = c.get("user")!
|
|
||||||
const sessionManager = c.get("sessionManager")
|
|
||||||
|
|
||||||
let session
|
|
||||||
try {
|
|
||||||
session = await sessionManager.getOrCreate(user.id)
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[handleGetFeed] Failed to create session:", err)
|
|
||||||
return c.json({ error: "Service unavailable" }, 503)
|
|
||||||
}
|
|
||||||
|
|
||||||
const feed = await session.feed()
|
|
||||||
|
|
||||||
return c.json({
|
|
||||||
items: feed.items,
|
|
||||||
errors: feed.errors.map((e) => ({
|
|
||||||
sourceId: e.sourceId,
|
|
||||||
error: e.error.message,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleGetContext(c: Context<Env>) {
|
|
||||||
const keyParam = c.req.query("key")
|
|
||||||
if (!keyParam) {
|
|
||||||
return c.json({ error: 'Invalid or missing "key" parameter: must be a JSON array' }, 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed: unknown
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(keyParam)
|
|
||||||
} catch {
|
|
||||||
return c.json({ error: 'Invalid or missing "key" parameter: must be a JSON array' }, 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isContextKeyPart)) {
|
|
||||||
return c.json({ error: 'Invalid or missing "key" parameter: must be a JSON array' }, 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchParam = c.req.query("match")
|
|
||||||
if (matchParam !== undefined && matchParam !== "exact" && matchParam !== "prefix") {
|
|
||||||
return c.json({ error: 'Invalid "match" parameter: must be "exact" or "prefix"' }, 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = c.get("user")!
|
|
||||||
const sessionManager = c.get("sessionManager")
|
|
||||||
|
|
||||||
let session
|
|
||||||
try {
|
|
||||||
session = await sessionManager.getOrCreate(user.id)
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[handleGetContext] Failed to create session:", err)
|
|
||||||
return c.json({ error: "Service unavailable" }, 503)
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = session.engine.currentContext()
|
|
||||||
const key = contextKey(...parsed)
|
|
||||||
|
|
||||||
if (matchParam === "exact") {
|
|
||||||
const value = context.get(key)
|
|
||||||
if (value === undefined) {
|
|
||||||
return c.json({ error: "Context key not found" }, 404)
|
|
||||||
}
|
|
||||||
return c.json({ match: "exact", value })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchParam === "prefix") {
|
|
||||||
const entries = context.find(key)
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return c.json({ error: "Context key not found" }, 404)
|
|
||||||
}
|
|
||||||
return c.json({ match: "prefix", entries })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: single find() covers both exact and prefix matches
|
|
||||||
const entries = context.find(key)
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return c.json({ error: "Context key not found" }, 404)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If exactly one result with the same key length, treat as exact match
|
|
||||||
if (entries.length === 1 && entries[0]!.key.length === parsed.length) {
|
|
||||||
return c.json({ match: "exact", value: entries[0]!.value })
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({ match: "prefix", entries })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Validates that a value is a valid ContextKeyPart (string, number, or plain object of primitives). */
|
|
||||||
function isContextKeyPart(value: unknown): boolean {
|
|
||||||
if (typeof value === "string" || typeof value === "number") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
||||||
return Object.values(value).every(
|
|
||||||
(v) => typeof v === "string" || typeof v === "number" || typeof v === "boolean",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import { mergeEnhancement } from "./merge.ts"
|
|||||||
function makeItem(overrides: Partial<FeedItem> = {}): FeedItem {
|
function makeItem(overrides: Partial<FeedItem> = {}): FeedItem {
|
||||||
return {
|
return {
|
||||||
id: "item-1",
|
id: "item-1",
|
||||||
sourceId: "test",
|
|
||||||
type: "test",
|
type: "test",
|
||||||
timestamp: new Date("2025-01-01T00:00:00Z"),
|
timestamp: new Date("2025-01-01T00:00:00Z"),
|
||||||
data: { value: 42 },
|
data: { value: 42 },
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import type { FeedItem } from "@aelis/core"
|
|||||||
|
|
||||||
import type { EnhancementResult } from "./schema.ts"
|
import type { EnhancementResult } from "./schema.ts"
|
||||||
|
|
||||||
const ENHANCEMENT_SOURCE_ID = "aelis.enhancement"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges an EnhancementResult into feed items.
|
* Merges an EnhancementResult into feed items.
|
||||||
*
|
*
|
||||||
@@ -12,11 +10,7 @@ const ENHANCEMENT_SOURCE_ID = "aelis.enhancement"
|
|||||||
* - Returns a new array (no mutation)
|
* - Returns a new array (no mutation)
|
||||||
* - Ignores fills for items/slots that don't exist
|
* - Ignores fills for items/slots that don't exist
|
||||||
*/
|
*/
|
||||||
export function mergeEnhancement(
|
export function mergeEnhancement(items: FeedItem[], result: EnhancementResult, currentTime: Date): FeedItem[] {
|
||||||
items: FeedItem[],
|
|
||||||
result: EnhancementResult,
|
|
||||||
currentTime: Date,
|
|
||||||
): FeedItem[] {
|
|
||||||
const merged = items.map((item) => {
|
const merged = 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
|
||||||
@@ -37,7 +31,6 @@ export function mergeEnhancement(
|
|||||||
for (const synthetic of result.syntheticItems) {
|
for (const synthetic of result.syntheticItems) {
|
||||||
merged.push({
|
merged.push({
|
||||||
id: synthetic.id,
|
id: synthetic.id,
|
||||||
sourceId: ENHANCEMENT_SOURCE_ID,
|
|
||||||
type: synthetic.type,
|
type: synthetic.type,
|
||||||
timestamp: currentTime,
|
timestamp: currentTime,
|
||||||
data: { text: synthetic.text },
|
data: { text: synthetic.text },
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { buildPrompt, hasUnfilledSlots } from "./prompt-builder.ts"
|
|||||||
function makeItem(overrides: Partial<FeedItem> = {}): FeedItem {
|
function makeItem(overrides: Partial<FeedItem> = {}): FeedItem {
|
||||||
return {
|
return {
|
||||||
id: "item-1",
|
id: "item-1",
|
||||||
sourceId: "test",
|
|
||||||
type: "test",
|
type: "test",
|
||||||
timestamp: new Date("2025-01-01T00:00:00Z"),
|
timestamp: new Date("2025-01-01T00:00:00Z"),
|
||||||
data: { value: 42 },
|
data: { value: 42 },
|
||||||
@@ -61,9 +60,7 @@ describe("buildPrompt", () => {
|
|||||||
|
|
||||||
expect(parsed.items).toHaveLength(1)
|
expect(parsed.items).toHaveLength(1)
|
||||||
expect((parsed.items as Array<Record<string, unknown>>)[0]!.id).toBe("item-1")
|
expect((parsed.items as Array<Record<string, unknown>>)[0]!.id).toBe("item-1")
|
||||||
expect((parsed.items as Array<Record<string, unknown>>)[0]!.slots).toEqual({
|
expect((parsed.items as Array<Record<string, unknown>>)[0]!.slots).toEqual({ insight: "Weather insight" })
|
||||||
insight: "Weather insight",
|
|
||||||
})
|
|
||||||
expect((parsed.items as Array<Record<string, unknown>>)[0]!.type).toBeUndefined()
|
expect((parsed.items as Array<Record<string, unknown>>)[0]!.type).toBeUndefined()
|
||||||
expect(parsed.context).toHaveLength(0)
|
expect(parsed.context).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|||||||
144
apps/aelis-backend/src/feed/http.test.ts
Normal file
144
apps/aelis-backend/src/feed/http.test.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
||||||
|
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { Hono } from "hono"
|
||||||
|
|
||||||
|
import { mockAuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||||
|
import { UserSessionManager } from "../session/index.ts"
|
||||||
|
import { registerFeedHttpHandlers } from "./http.ts"
|
||||||
|
|
||||||
|
interface FeedResponse {
|
||||||
|
items: Array<{
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
priority: number
|
||||||
|
timestamp: string
|
||||||
|
data: Record<string, unknown>
|
||||||
|
}>
|
||||||
|
errors: Array<{ sourceId: string; error: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStubSource(id: string, items: FeedItem[] = []): FeedSource {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
async listActions(): Promise<Record<string, ActionDefinition>> {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
async executeAction(): Promise<unknown> {
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
async fetchContext(): Promise<readonly ContextEntry[] | null> {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
async fetchItems() {
|
||||||
|
return items
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTestApp(sessionManager: UserSessionManager, userId?: string) {
|
||||||
|
const app = new Hono()
|
||||||
|
registerFeedHttpHandlers(app, {
|
||||||
|
sessionManager,
|
||||||
|
authSessionMiddleware: mockAuthSessionMiddleware(userId),
|
||||||
|
})
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GET /api/feed", () => {
|
||||||
|
test("returns 401 without auth", async () => {
|
||||||
|
const manager = new UserSessionManager({ providers: [] })
|
||||||
|
const app = buildTestApp(manager)
|
||||||
|
|
||||||
|
const res = await app.request("/api/feed")
|
||||||
|
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns cached feed when available", async () => {
|
||||||
|
const items: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "item-1",
|
||||||
|
type: "test",
|
||||||
|
priority: 0.8,
|
||||||
|
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||||
|
data: { value: 42 },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const manager = new UserSessionManager({
|
||||||
|
providers: [() => createStubSource("test", items)],
|
||||||
|
})
|
||||||
|
const app = buildTestApp(manager, "user-1")
|
||||||
|
|
||||||
|
// Prime the cache
|
||||||
|
const session = manager.getOrCreate("user-1")
|
||||||
|
await session.engine.refresh()
|
||||||
|
expect(session.engine.lastFeed()).not.toBeNull()
|
||||||
|
|
||||||
|
const res = await app.request("/api/feed")
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as FeedResponse
|
||||||
|
expect(body.items).toHaveLength(1)
|
||||||
|
expect(body.items[0]!.id).toBe("item-1")
|
||||||
|
expect(body.items[0]!.type).toBe("test")
|
||||||
|
expect(body.items[0]!.priority).toBe(0.8)
|
||||||
|
expect(body.items[0]!.timestamp).toBe("2025-01-01T00:00:00.000Z")
|
||||||
|
expect(body.errors).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("forces refresh when no cached feed", async () => {
|
||||||
|
const items: FeedItem[] = [
|
||||||
|
{
|
||||||
|
id: "fresh-1",
|
||||||
|
type: "test",
|
||||||
|
priority: 0.5,
|
||||||
|
timestamp: new Date("2025-06-01T12:00:00.000Z"),
|
||||||
|
data: { fresh: true },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const manager = new UserSessionManager({
|
||||||
|
providers: [() => createStubSource("test", items)],
|
||||||
|
})
|
||||||
|
const app = buildTestApp(manager, "user-1")
|
||||||
|
|
||||||
|
// No prior refresh — lastFeed() returns null, handler should call refresh()
|
||||||
|
const res = await app.request("/api/feed")
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as FeedResponse
|
||||||
|
expect(body.items).toHaveLength(1)
|
||||||
|
expect(body.items[0]!.id).toBe("fresh-1")
|
||||||
|
expect(body.items[0]!.data.fresh).toBe(true)
|
||||||
|
expect(body.errors).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("serializes source errors as message strings", async () => {
|
||||||
|
const failingSource: FeedSource = {
|
||||||
|
id: "failing",
|
||||||
|
async listActions() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
async executeAction() {
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
async fetchContext() {
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
async fetchItems() {
|
||||||
|
throw new Error("connection timeout")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const manager = new UserSessionManager({ providers: [() => failingSource] })
|
||||||
|
const app = buildTestApp(manager, "user-1")
|
||||||
|
|
||||||
|
const res = await app.request("/api/feed")
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as FeedResponse
|
||||||
|
expect(body.items).toHaveLength(0)
|
||||||
|
expect(body.errors).toHaveLength(1)
|
||||||
|
expect(body.errors[0]!.sourceId).toBe("failing")
|
||||||
|
expect(body.errors[0]!.error).toBe("connection timeout")
|
||||||
|
})
|
||||||
|
})
|
||||||
45
apps/aelis-backend/src/feed/http.ts
Normal file
45
apps/aelis-backend/src/feed/http.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { Context, Hono } from "hono"
|
||||||
|
|
||||||
|
import { createMiddleware } from "hono/factory"
|
||||||
|
|
||||||
|
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
||||||
|
import type { UserSessionManager } from "../session/index.ts"
|
||||||
|
|
||||||
|
type Env = {
|
||||||
|
Variables: {
|
||||||
|
sessionManager: UserSessionManager
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeedHttpHandlersDeps {
|
||||||
|
sessionManager: UserSessionManager
|
||||||
|
authSessionMiddleware: AuthSessionMiddleware
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerFeedHttpHandlers(
|
||||||
|
app: Hono,
|
||||||
|
{ sessionManager, authSessionMiddleware }: FeedHttpHandlersDeps,
|
||||||
|
) {
|
||||||
|
const inject = createMiddleware<Env>(async (c, next) => {
|
||||||
|
c.set("sessionManager", sessionManager)
|
||||||
|
await next()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get("/api/feed", inject, authSessionMiddleware, handleGetFeed)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGetFeed(c: Context<Env>) {
|
||||||
|
const user = c.get("user")!
|
||||||
|
const sessionManager = c.get("sessionManager")
|
||||||
|
const session = sessionManager.getOrCreate(user.id)
|
||||||
|
|
||||||
|
const feed = await session.feed()
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
items: feed.items,
|
||||||
|
errors: feed.errors.map((e) => ({
|
||||||
|
sourceId: e.sourceId,
|
||||||
|
error: e.error.message,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { randomBytes } from "node:crypto"
|
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import { CredentialEncryptor } from "./crypto.ts"
|
|
||||||
|
|
||||||
const TEST_KEY = randomBytes(32).toString("base64")
|
|
||||||
|
|
||||||
describe("CredentialEncryptor", () => {
|
|
||||||
const encryptor = new CredentialEncryptor(TEST_KEY)
|
|
||||||
|
|
||||||
test("round-trip with simple string", () => {
|
|
||||||
const plaintext = "hello world"
|
|
||||||
const encrypted = encryptor.encrypt(plaintext)
|
|
||||||
expect(encryptor.decrypt(encrypted)).toBe(plaintext)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("round-trip with JSON credentials", () => {
|
|
||||||
const credentials = JSON.stringify({
|
|
||||||
accessToken: "ya29.a0AfH6SMB...",
|
|
||||||
refreshToken: "1//0dx...",
|
|
||||||
expiresAt: "2025-12-01T00:00:00Z",
|
|
||||||
})
|
|
||||||
const encrypted = encryptor.encrypt(credentials)
|
|
||||||
expect(encryptor.decrypt(encrypted)).toBe(credentials)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("round-trip with empty string", () => {
|
|
||||||
const encrypted = encryptor.encrypt("")
|
|
||||||
expect(encryptor.decrypt(encrypted)).toBe("")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("round-trip with unicode", () => {
|
|
||||||
const plaintext = "日本語テスト 🔐"
|
|
||||||
const encrypted = encryptor.encrypt(plaintext)
|
|
||||||
expect(encryptor.decrypt(encrypted)).toBe(plaintext)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("each encryption produces different ciphertext (unique IV)", () => {
|
|
||||||
const plaintext = "same input"
|
|
||||||
const a = encryptor.encrypt(plaintext)
|
|
||||||
const b = encryptor.encrypt(plaintext)
|
|
||||||
expect(a).not.toEqual(b)
|
|
||||||
expect(encryptor.decrypt(a)).toBe(plaintext)
|
|
||||||
expect(encryptor.decrypt(b)).toBe(plaintext)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("tampered ciphertext throws", () => {
|
|
||||||
const encrypted = encryptor.encrypt("secret")
|
|
||||||
encrypted[13]! ^= 0xff
|
|
||||||
expect(() => encryptor.decrypt(encrypted)).toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("truncated data throws", () => {
|
|
||||||
expect(() => encryptor.decrypt(Buffer.alloc(10))).toThrow("Encrypted data is too short")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("throws when key is wrong length", () => {
|
|
||||||
expect(() => new CredentialEncryptor(Buffer.from("too-short").toString("base64"))).toThrow(
|
|
||||||
"must be 32 bytes",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
|
|
||||||
|
|
||||||
const ALGORITHM = "aes-256-gcm"
|
|
||||||
const IV_LENGTH = 12
|
|
||||||
const AUTH_TAG_LENGTH = 16
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AES-256-GCM encryption for credential storage.
|
|
||||||
*
|
|
||||||
* Caches the parsed key on construction to avoid repeated
|
|
||||||
* env reads and Buffer allocations.
|
|
||||||
*/
|
|
||||||
export class CredentialEncryptor {
|
|
||||||
private readonly key: Buffer
|
|
||||||
|
|
||||||
constructor(base64Key: string) {
|
|
||||||
const key = Buffer.from(base64Key, "base64")
|
|
||||||
if (key.length !== 32) {
|
|
||||||
throw new Error(
|
|
||||||
`Encryption key must be 32 bytes (got ${key.length}). Generate with: openssl rand -base64 32`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
this.key = key
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypts plaintext using AES-256-GCM.
|
|
||||||
*
|
|
||||||
* Output format: [12-byte IV][ciphertext][16-byte auth tag]
|
|
||||||
*/
|
|
||||||
encrypt(plaintext: string): Buffer {
|
|
||||||
const iv = randomBytes(IV_LENGTH)
|
|
||||||
const cipher = createCipheriv(ALGORITHM, this.key, iv)
|
|
||||||
|
|
||||||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()])
|
|
||||||
const authTag = cipher.getAuthTag()
|
|
||||||
|
|
||||||
return Buffer.concat([iv, encrypted, authTag])
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypts a buffer produced by `encrypt`.
|
|
||||||
*
|
|
||||||
* Expects format: [12-byte IV][ciphertext][16-byte auth tag]
|
|
||||||
*/
|
|
||||||
decrypt(data: Buffer): string {
|
|
||||||
if (data.length < IV_LENGTH + AUTH_TAG_LENGTH) {
|
|
||||||
throw new Error("Encrypted data is too short")
|
|
||||||
}
|
|
||||||
|
|
||||||
const iv = data.subarray(0, IV_LENGTH)
|
|
||||||
const authTag = data.subarray(data.length - AUTH_TAG_LENGTH)
|
|
||||||
const ciphertext = data.subarray(IV_LENGTH, data.length - AUTH_TAG_LENGTH)
|
|
||||||
|
|
||||||
const decipher = createDecipheriv(ALGORITHM, this.key, iv)
|
|
||||||
decipher.setAuthTag(authTag)
|
|
||||||
|
|
||||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,9 +3,10 @@ import type { Context, Hono } from "hono"
|
|||||||
import { type } from "arktype"
|
import { type } from "arktype"
|
||||||
import { createMiddleware } from "hono/factory"
|
import { createMiddleware } from "hono/factory"
|
||||||
|
|
||||||
import type { AuthSessionMiddleware } from "../auth/session-middleware.ts"
|
|
||||||
import type { UserSessionManager } from "../session/index.ts"
|
import type { UserSessionManager } from "../session/index.ts"
|
||||||
|
|
||||||
|
import { requireSession } from "../auth/session-middleware.ts"
|
||||||
|
|
||||||
type Env = { Variables: { sessionManager: UserSessionManager } }
|
type Env = { Variables: { sessionManager: UserSessionManager } }
|
||||||
|
|
||||||
const locationInput = type({
|
const locationInput = type({
|
||||||
@@ -15,21 +16,16 @@ const locationInput = type({
|
|||||||
timestamp: "string.date.iso",
|
timestamp: "string.date.iso",
|
||||||
})
|
})
|
||||||
|
|
||||||
interface LocationHttpHandlersDeps {
|
|
||||||
sessionManager: UserSessionManager
|
|
||||||
authSessionMiddleware: AuthSessionMiddleware
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerLocationHttpHandlers(
|
export function registerLocationHttpHandlers(
|
||||||
app: Hono,
|
app: Hono,
|
||||||
{ sessionManager, authSessionMiddleware }: LocationHttpHandlersDeps,
|
{ sessionManager }: { sessionManager: UserSessionManager },
|
||||||
) {
|
) {
|
||||||
const inject = createMiddleware<Env>(async (c, next) => {
|
const inject = createMiddleware<Env>(async (c, next) => {
|
||||||
c.set("sessionManager", sessionManager)
|
c.set("sessionManager", sessionManager)
|
||||||
await next()
|
await next()
|
||||||
})
|
})
|
||||||
|
|
||||||
app.post("/api/location", inject, authSessionMiddleware, handleUpdateLocation)
|
app.post("/api/location", inject, requireSession, handleUpdateLocation)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpdateLocation(c: Context<Env>) {
|
async function handleUpdateLocation(c: Context<Env>) {
|
||||||
@@ -48,15 +44,7 @@ async function handleUpdateLocation(c: Context<Env>) {
|
|||||||
|
|
||||||
const user = c.get("user")!
|
const user = c.get("user")!
|
||||||
const sessionManager = c.get("sessionManager")
|
const sessionManager = c.get("sessionManager")
|
||||||
|
const session = sessionManager.getOrCreate(user.id)
|
||||||
let session
|
|
||||||
try {
|
|
||||||
session = await sessionManager.getOrCreate(user.id)
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[handleUpdateLocation] Failed to create session:", err)
|
|
||||||
return c.json({ error: "Service unavailable" }, 503)
|
|
||||||
}
|
|
||||||
|
|
||||||
await session.engine.executeAction("aelis.location", "update-location", {
|
await session.engine.executeAction("aelis.location", "update-location", {
|
||||||
lat: result.lat,
|
lat: result.lat,
|
||||||
lng: result.lng,
|
lng: result.lng,
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { LocationSource } from "@aelis/source-location"
|
|
||||||
|
|
||||||
import type { Database } from "../db/index.ts"
|
|
||||||
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
|
||||||
|
|
||||||
import { SourceDisabledError } from "../sources/errors.ts"
|
|
||||||
import { sources } from "../sources/user-sources.ts"
|
|
||||||
|
|
||||||
export class LocationSourceProvider implements FeedSourceProvider {
|
|
||||||
private readonly db: Database
|
|
||||||
|
|
||||||
constructor(db: Database) {
|
|
||||||
this.db = db
|
|
||||||
}
|
|
||||||
|
|
||||||
async feedSourceForUser(userId: string): Promise<LocationSource> {
|
|
||||||
const row = await sources(this.db, userId).find("aelis.location")
|
|
||||||
|
|
||||||
if (!row || !row.enabled) {
|
|
||||||
throw new SourceDisabledError("aelis.location", userId)
|
|
||||||
}
|
|
||||||
|
|
||||||
return new LocationSource()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
/**
|
|
||||||
* Creates an admin user account via Better Auth's server-side API.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* bun run src/scripts/create-admin.ts --name "Admin" --email admin@example.com --password secret123
|
|
||||||
*
|
|
||||||
* Requires DATABASE_URL and BETTER_AUTH_SECRET to be set (reads .env automatically).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { parseArgs } from "util"
|
|
||||||
|
|
||||||
import { createAuth } from "../auth/index.ts"
|
|
||||||
import { createDatabase } from "../db/index.ts"
|
|
||||||
|
|
||||||
function parseCliArgs(): { name: string; email: string; password: string } {
|
|
||||||
const { values } = parseArgs({
|
|
||||||
args: Bun.argv.slice(2),
|
|
||||||
options: {
|
|
||||||
name: { type: "string" },
|
|
||||||
email: { type: "string" },
|
|
||||||
password: { type: "string" },
|
|
||||||
},
|
|
||||||
strict: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!values.name || !values.email || !values.password) {
|
|
||||||
console.error(
|
|
||||||
"Usage: bun run src/scripts/create-admin.ts --name <name> --email <email> --password <password>",
|
|
||||||
)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { name: values.name, email: values.email, password: values.password }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const { name, email, password } = parseCliArgs()
|
|
||||||
|
|
||||||
const databaseUrl = process.env.DATABASE_URL
|
|
||||||
if (!databaseUrl) {
|
|
||||||
console.error("DATABASE_URL is not set")
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { db, close } = createDatabase(databaseUrl)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const auth = createAuth(db)
|
|
||||||
|
|
||||||
const result = await auth.api.createUser({
|
|
||||||
body: { name, email, password, role: "admin" },
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log(`Admin account created: ${result.user.id} (${result.user.email})`)
|
|
||||||
} finally {
|
|
||||||
await close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error("Failed to create admin account:", err)
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
@@ -1,21 +1,16 @@
|
|||||||
|
import { LocationSource } from "@aelis/source-location"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { registerAuthHandlers } from "./auth/http.ts"
|
import { registerAuthHandlers } from "./auth/http.ts"
|
||||||
import { createAuth } from "./auth/index.ts"
|
import { mockAuthSessionMiddleware, requireSession } from "./auth/session-middleware.ts"
|
||||||
import { createRequireSession } from "./auth/session-middleware.ts"
|
|
||||||
import { createDatabase } from "./db/index.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 { registerFeedHttpHandlers } from "./feed/http.ts"
|
||||||
import { registerLocationHttpHandlers } from "./location/http.ts"
|
import { registerLocationHttpHandlers } from "./location/http.ts"
|
||||||
import { LocationSourceProvider } from "./location/provider.ts"
|
|
||||||
import { UserSessionManager } from "./session/index.ts"
|
import { UserSessionManager } from "./session/index.ts"
|
||||||
import { WeatherSourceProvider } from "./weather/provider.ts"
|
import { WeatherSourceProvider } from "./weather/provider.ts"
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const { db, close: closeDb } = createDatabase(process.env.DATABASE_URL!)
|
|
||||||
const auth = createAuth(db)
|
|
||||||
|
|
||||||
const openrouterApiKey = process.env.OPENROUTER_API_KEY
|
const openrouterApiKey = process.env.OPENROUTER_API_KEY
|
||||||
const feedEnhancer = openrouterApiKey
|
const feedEnhancer = openrouterApiKey
|
||||||
? createFeedEnhancer({
|
? createFeedEnhancer({
|
||||||
@@ -31,9 +26,8 @@ function main() {
|
|||||||
|
|
||||||
const sessionManager = new UserSessionManager({
|
const sessionManager = new UserSessionManager({
|
||||||
providers: [
|
providers: [
|
||||||
new LocationSourceProvider(db),
|
() => new LocationSource(),
|
||||||
new WeatherSourceProvider({
|
new WeatherSourceProvider({
|
||||||
db,
|
|
||||||
credentials: {
|
credentials: {
|
||||||
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
|
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
|
||||||
keyId: process.env.WEATHERKIT_KEY_ID!,
|
keyId: process.env.WEATHERKIT_KEY_ID!,
|
||||||
@@ -49,20 +43,18 @@ function main() {
|
|||||||
|
|
||||||
app.get("/health", (c) => c.json({ status: "ok" }))
|
app.get("/health", (c) => c.json({ status: "ok" }))
|
||||||
|
|
||||||
const authSessionMiddleware = createRequireSession(auth)
|
const isDev = process.env.NODE_ENV !== "production"
|
||||||
|
const authSessionMiddleware = isDev ? mockAuthSessionMiddleware("dev-user") : requireSession
|
||||||
|
|
||||||
registerAuthHandlers(app, auth)
|
if (!isDev) {
|
||||||
|
registerAuthHandlers(app)
|
||||||
|
}
|
||||||
|
|
||||||
registerFeedHttpHandlers(app, {
|
registerFeedHttpHandlers(app, {
|
||||||
sessionManager,
|
sessionManager,
|
||||||
authSessionMiddleware,
|
authSessionMiddleware,
|
||||||
})
|
})
|
||||||
registerLocationHttpHandlers(app, { sessionManager, authSessionMiddleware })
|
registerLocationHttpHandlers(app, { sessionManager })
|
||||||
|
|
||||||
process.on("SIGTERM", async () => {
|
|
||||||
await closeDb()
|
|
||||||
process.exit(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { FeedSource } from "@aelis/core"
|
import type { FeedSource } from "@aelis/core"
|
||||||
|
|
||||||
export interface FeedSourceProvider {
|
export interface FeedSourceProvider {
|
||||||
feedSourceForUser(userId: string): Promise<FeedSource>
|
feedSourceForUser(userId: string): FeedSource
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeedSourceProviderFn = (userId: string) => Promise<FeedSource>
|
export type FeedSourceProviderFn = (userId: string) => FeedSource
|
||||||
|
|
||||||
export type FeedSourceProviderInput = FeedSourceProvider | FeedSourceProviderFn
|
export type FeedSourceProviderInput = FeedSourceProvider | FeedSourceProviderFn
|
||||||
|
|||||||
@@ -1,45 +1,48 @@
|
|||||||
import { LocationSource } from "@aelis/source-location"
|
import type { WeatherKitClient, WeatherKitResponse } from "@aelis/source-weatherkit"
|
||||||
import { WeatherSource } from "@aelis/source-weatherkit"
|
|
||||||
import { describe, expect, mock, spyOn, test } from "bun:test"
|
|
||||||
|
|
||||||
|
import { LocationSource } from "@aelis/source-location"
|
||||||
|
import { describe, expect, mock, test } from "bun:test"
|
||||||
|
|
||||||
|
import { WeatherSourceProvider } from "../weather/provider.ts"
|
||||||
import { UserSessionManager } from "./user-session-manager.ts"
|
import { UserSessionManager } from "./user-session-manager.ts"
|
||||||
|
|
||||||
const mockWeatherProvider = async () =>
|
const mockWeatherClient: WeatherKitClient = {
|
||||||
new WeatherSource({ client: { fetch: async () => ({}) as never } })
|
fetch: async () => ({}) as WeatherKitResponse,
|
||||||
|
}
|
||||||
|
|
||||||
describe("UserSessionManager", () => {
|
describe("UserSessionManager", () => {
|
||||||
test("getOrCreate creates session on first call", async () => {
|
test("getOrCreate creates session on first call", () => {
|
||||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
expect(session).toBeDefined()
|
expect(session).toBeDefined()
|
||||||
expect(session.engine).toBeDefined()
|
expect(session.engine).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("getOrCreate returns same session for same user", async () => {
|
test("getOrCreate returns same session for same user", () => {
|
||||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
const session1 = await manager.getOrCreate("user-1")
|
const session1 = manager.getOrCreate("user-1")
|
||||||
const session2 = await manager.getOrCreate("user-1")
|
const session2 = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
expect(session1).toBe(session2)
|
expect(session1).toBe(session2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("getOrCreate returns different sessions for different users", async () => {
|
test("getOrCreate returns different sessions for different users", () => {
|
||||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
const session1 = await manager.getOrCreate("user-1")
|
const session1 = manager.getOrCreate("user-1")
|
||||||
const session2 = await manager.getOrCreate("user-2")
|
const session2 = manager.getOrCreate("user-2")
|
||||||
|
|
||||||
expect(session1).not.toBe(session2)
|
expect(session1).not.toBe(session2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("each user gets independent source instances", async () => {
|
test("each user gets independent source instances", () => {
|
||||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
const session1 = await manager.getOrCreate("user-1")
|
const session1 = manager.getOrCreate("user-1")
|
||||||
const session2 = await manager.getOrCreate("user-2")
|
const session2 = manager.getOrCreate("user-2")
|
||||||
|
|
||||||
const source1 = session1.getSource<LocationSource>("aelis.location")
|
const source1 = session1.getSource<LocationSource>("aelis.location")
|
||||||
const source2 = session2.getSource<LocationSource>("aelis.location")
|
const source2 = session2.getSource<LocationSource>("aelis.location")
|
||||||
@@ -47,56 +50,58 @@ describe("UserSessionManager", () => {
|
|||||||
expect(source1).not.toBe(source2)
|
expect(source1).not.toBe(source2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("remove destroys session and allows re-creation", async () => {
|
test("remove destroys session and allows re-creation", () => {
|
||||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
const session1 = await manager.getOrCreate("user-1")
|
const session1 = manager.getOrCreate("user-1")
|
||||||
manager.remove("user-1")
|
manager.remove("user-1")
|
||||||
const session2 = await manager.getOrCreate("user-1")
|
const session2 = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
expect(session1).not.toBe(session2)
|
expect(session1).not.toBe(session2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("remove is no-op for unknown user", () => {
|
test("remove is no-op for unknown user", () => {
|
||||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
expect(() => manager.remove("unknown")).not.toThrow()
|
expect(() => manager.remove("unknown")).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("accepts function providers", async () => {
|
test("accepts function providers", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
const result = await session.engine.refresh()
|
const result = await session.engine.refresh()
|
||||||
|
|
||||||
expect(result.errors).toHaveLength(0)
|
expect(result.errors).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("accepts object providers", async () => {
|
test("accepts object providers", () => {
|
||||||
|
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
||||||
const manager = new UserSessionManager({
|
const manager = new UserSessionManager({
|
||||||
providers: [async () => new LocationSource(), mockWeatherProvider],
|
providers: [() => new LocationSource(), provider],
|
||||||
})
|
})
|
||||||
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
expect(session.getSource("aelis.weather")).toBeDefined()
|
expect(session.getSource("aelis.weather")).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("accepts mixed providers", async () => {
|
test("accepts mixed providers", () => {
|
||||||
|
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
||||||
const manager = new UserSessionManager({
|
const manager = new UserSessionManager({
|
||||||
providers: [async () => new LocationSource(), mockWeatherProvider],
|
providers: [() => new LocationSource(), provider],
|
||||||
})
|
})
|
||||||
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
expect(session.getSource("aelis.location")).toBeDefined()
|
expect(session.getSource("aelis.location")).toBeDefined()
|
||||||
expect(session.getSource("aelis.weather")).toBeDefined()
|
expect(session.getSource("aelis.weather")).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("refresh returns feed result through session", async () => {
|
test("refresh returns feed result through session", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
const result = await session.engine.refresh()
|
const result = await session.engine.refresh()
|
||||||
|
|
||||||
expect(result).toHaveProperty("context")
|
expect(result).toHaveProperty("context")
|
||||||
@@ -106,9 +111,9 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("location update via executeAction works", async () => {
|
test("location update via executeAction works", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
await session.engine.executeAction("aelis.location", "update-location", {
|
await session.engine.executeAction("aelis.location", "update-location", {
|
||||||
lat: 51.5074,
|
lat: 51.5074,
|
||||||
lng: -0.1278,
|
lng: -0.1278,
|
||||||
@@ -121,10 +126,10 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("subscribe receives updates after location push", async () => {
|
test("subscribe receives updates after location push", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
const callback = mock()
|
const callback = mock()
|
||||||
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
session.engine.subscribe(callback)
|
session.engine.subscribe(callback)
|
||||||
|
|
||||||
await session.engine.executeAction("aelis.location", "update-location", {
|
await session.engine.executeAction("aelis.location", "update-location", {
|
||||||
@@ -141,16 +146,16 @@ describe("UserSessionManager", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("remove stops reactive updates", async () => {
|
test("remove stops reactive updates", async () => {
|
||||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
const callback = mock()
|
const callback = mock()
|
||||||
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
const session = manager.getOrCreate("user-1")
|
||||||
session.engine.subscribe(callback)
|
session.engine.subscribe(callback)
|
||||||
|
|
||||||
manager.remove("user-1")
|
manager.remove("user-1")
|
||||||
|
|
||||||
// Create new session and push location — old callback should not fire
|
// Create new session and push location — old callback should not fire
|
||||||
const session2 = await manager.getOrCreate("user-1")
|
const session2 = manager.getOrCreate("user-1")
|
||||||
await session2.engine.executeAction("aelis.location", "update-location", {
|
await session2.engine.executeAction("aelis.location", "update-location", {
|
||||||
lat: 51.5074,
|
lat: 51.5074,
|
||||||
lng: -0.1278,
|
lng: -0.1278,
|
||||||
@@ -162,93 +167,4 @@ describe("UserSessionManager", () => {
|
|||||||
|
|
||||||
expect(callback).not.toHaveBeenCalled()
|
expect(callback).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("creates session with successful providers when some fail", async () => {
|
|
||||||
const manager = new UserSessionManager({
|
|
||||||
providers: [
|
|
||||||
async () => new LocationSource(),
|
|
||||||
async () => {
|
|
||||||
throw new Error("provider failed")
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const spy = spyOn(console, "error").mockImplementation(() => {})
|
|
||||||
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
|
||||||
|
|
||||||
expect(session).toBeDefined()
|
|
||||||
expect(session.getSource("aelis.location")).toBeDefined()
|
|
||||||
expect(spy).toHaveBeenCalled()
|
|
||||||
|
|
||||||
spy.mockRestore()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("throws AggregateError when all providers fail", async () => {
|
|
||||||
const manager = new UserSessionManager({
|
|
||||||
providers: [
|
|
||||||
async () => {
|
|
||||||
throw new Error("first failed")
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
throw new Error("second failed")
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect(manager.getOrCreate("user-1")).rejects.toBeInstanceOf(AggregateError)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("concurrent getOrCreate for same user returns same session", async () => {
|
|
||||||
let callCount = 0
|
|
||||||
const manager = new UserSessionManager({
|
|
||||||
providers: [
|
|
||||||
async () => {
|
|
||||||
callCount++
|
|
||||||
// Simulate async work to widen the race window
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
||||||
return new LocationSource()
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const [session1, session2] = await Promise.all([
|
|
||||||
manager.getOrCreate("user-1"),
|
|
||||||
manager.getOrCreate("user-1"),
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(session1).toBe(session2)
|
|
||||||
expect(callCount).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("remove during in-flight getOrCreate prevents session from being stored", async () => {
|
|
||||||
let resolveProvider: () => void
|
|
||||||
const providerGate = new Promise<void>((r) => {
|
|
||||||
resolveProvider = r
|
|
||||||
})
|
|
||||||
|
|
||||||
const manager = new UserSessionManager({
|
|
||||||
providers: [
|
|
||||||
async () => {
|
|
||||||
await providerGate
|
|
||||||
return new LocationSource()
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const sessionPromise = manager.getOrCreate("user-1")
|
|
||||||
|
|
||||||
// remove() while provider is still resolving
|
|
||||||
manager.remove("user-1")
|
|
||||||
|
|
||||||
// Let the provider finish
|
|
||||||
resolveProvider!()
|
|
||||||
|
|
||||||
await expect(sessionPromise).rejects.toThrow("removed during creation")
|
|
||||||
|
|
||||||
// A fresh getOrCreate should produce a new session, not the cancelled one
|
|
||||||
const freshSession = await manager.getOrCreate("user-1")
|
|
||||||
expect(freshSession).toBeDefined()
|
|
||||||
expect(freshSession.engine).toBeDefined()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { FeedSource } from "@aelis/core"
|
|
||||||
|
|
||||||
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
||||||
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
|
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
|
||||||
|
|
||||||
@@ -12,7 +10,6 @@ export interface UserSessionManagerConfig {
|
|||||||
|
|
||||||
export class UserSessionManager {
|
export class UserSessionManager {
|
||||||
private sessions = new Map<string, UserSession>()
|
private sessions = new Map<string, UserSession>()
|
||||||
private pending = new Map<string, Promise<UserSession>>()
|
|
||||||
private readonly providers: FeedSourceProviderInput[]
|
private readonly providers: FeedSourceProviderInput[]
|
||||||
private readonly feedEnhancer: FeedEnhancer | null
|
private readonly feedEnhancer: FeedEnhancer | null
|
||||||
|
|
||||||
@@ -21,28 +18,16 @@ export class UserSessionManager {
|
|||||||
this.feedEnhancer = config.feedEnhancer ?? null
|
this.feedEnhancer = config.feedEnhancer ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOrCreate(userId: string): Promise<UserSession> {
|
getOrCreate(userId: string): UserSession {
|
||||||
const existing = this.sessions.get(userId)
|
let session = this.sessions.get(userId)
|
||||||
if (existing) return existing
|
if (!session) {
|
||||||
|
const sources = this.providers.map((p) =>
|
||||||
const inflight = this.pending.get(userId)
|
typeof p === "function" ? p(userId) : p.feedSourceForUser(userId),
|
||||||
if (inflight) return inflight
|
)
|
||||||
|
session = new UserSession(sources, this.feedEnhancer)
|
||||||
const promise = this.createSession(userId)
|
|
||||||
this.pending.set(userId, promise)
|
|
||||||
try {
|
|
||||||
const session = await promise
|
|
||||||
// If remove() was called while we were awaiting, it clears the
|
|
||||||
// pending entry. Detect that and destroy the session immediately.
|
|
||||||
if (!this.pending.has(userId)) {
|
|
||||||
session.destroy()
|
|
||||||
throw new Error(`Session for user ${userId} was removed during creation`)
|
|
||||||
}
|
|
||||||
this.sessions.set(userId, session)
|
this.sessions.set(userId, session)
|
||||||
return session
|
|
||||||
} finally {
|
|
||||||
this.pending.delete(userId)
|
|
||||||
}
|
}
|
||||||
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(userId: string): void {
|
remove(userId: string): void {
|
||||||
@@ -51,36 +36,5 @@ export class UserSessionManager {
|
|||||||
session.destroy()
|
session.destroy()
|
||||||
this.sessions.delete(userId)
|
this.sessions.delete(userId)
|
||||||
}
|
}
|
||||||
// Cancel any in-flight creation so getOrCreate won't store the session
|
|
||||||
this.pending.delete(userId)
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createSession(userId: string): Promise<UserSession> {
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
this.providers.map((p) =>
|
|
||||||
typeof p === "function" ? p(userId) : p.feedSourceForUser(userId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const sources: FeedSource[] = []
|
|
||||||
const errors: unknown[] = []
|
|
||||||
|
|
||||||
for (const result of results) {
|
|
||||||
if (result.status === "fulfilled") {
|
|
||||||
sources.push(result.value)
|
|
||||||
} else {
|
|
||||||
errors.push(result.reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sources.length === 0 && errors.length > 0) {
|
|
||||||
throw new AggregateError(errors, "All feed source providers failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const error of errors) {
|
|
||||||
console.error("[UserSessionManager] Feed source provider failed:", error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return new UserSession(sources, this.feedEnhancer)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ describe("UserSession.feed", () => {
|
|||||||
const items: FeedItem[] = [
|
const items: FeedItem[] = [
|
||||||
{
|
{
|
||||||
id: "item-1",
|
id: "item-1",
|
||||||
sourceId: "test",
|
|
||||||
type: "test",
|
type: "test",
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||||
data: { value: 42 },
|
data: { value: 42 },
|
||||||
@@ -94,7 +93,6 @@ describe("UserSession.feed", () => {
|
|||||||
const items: FeedItem[] = [
|
const items: FeedItem[] = [
|
||||||
{
|
{
|
||||||
id: "item-1",
|
id: "item-1",
|
||||||
sourceId: "test",
|
|
||||||
type: "test",
|
type: "test",
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||||
data: { value: 42 },
|
data: { value: 42 },
|
||||||
@@ -115,7 +113,6 @@ describe("UserSession.feed", () => {
|
|||||||
const items: FeedItem[] = [
|
const items: FeedItem[] = [
|
||||||
{
|
{
|
||||||
id: "item-1",
|
id: "item-1",
|
||||||
sourceId: "test",
|
|
||||||
type: "test",
|
type: "test",
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||||
data: { value: 42 },
|
data: { value: 42 },
|
||||||
@@ -142,7 +139,6 @@ describe("UserSession.feed", () => {
|
|||||||
let currentItems: FeedItem[] = [
|
let currentItems: FeedItem[] = [
|
||||||
{
|
{
|
||||||
id: "item-1",
|
id: "item-1",
|
||||||
sourceId: "test",
|
|
||||||
type: "test",
|
type: "test",
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||||
data: { version: 1 },
|
data: { version: 1 },
|
||||||
@@ -173,7 +169,6 @@ describe("UserSession.feed", () => {
|
|||||||
currentItems = [
|
currentItems = [
|
||||||
{
|
{
|
||||||
id: "item-1",
|
id: "item-1",
|
||||||
sourceId: "test",
|
|
||||||
type: "test",
|
type: "test",
|
||||||
timestamp: new Date("2025-01-02T00:00:00.000Z"),
|
timestamp: new Date("2025-01-02T00:00:00.000Z"),
|
||||||
data: { version: 2 },
|
data: { version: 2 },
|
||||||
@@ -195,7 +190,6 @@ describe("UserSession.feed", () => {
|
|||||||
const items: FeedItem[] = [
|
const items: FeedItem[] = [
|
||||||
{
|
{
|
||||||
id: "item-1",
|
id: "item-1",
|
||||||
sourceId: "test",
|
|
||||||
type: "test",
|
type: "test",
|
||||||
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
timestamp: new Date("2025-01-01T00:00:00.000Z"),
|
||||||
data: { value: 42 },
|
data: { value: 42 },
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* Thrown by a FeedSourceProvider when the source is not enabled for a user.
|
|
||||||
*
|
|
||||||
* UserSessionManager's Promise.allSettled handles this gracefully —
|
|
||||||
* the source is excluded from the session without crashing.
|
|
||||||
*/
|
|
||||||
export class SourceDisabledError extends Error {
|
|
||||||
readonly sourceId: string
|
|
||||||
readonly userId: string
|
|
||||||
|
|
||||||
constructor(sourceId: string, userId: string) {
|
|
||||||
super(`Source "${sourceId}" is not enabled for user "${userId}"`)
|
|
||||||
this.name = "SourceDisabledError"
|
|
||||||
this.sourceId = sourceId
|
|
||||||
this.userId = userId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thrown when an operation targets a user source that doesn't exist.
|
|
||||||
*/
|
|
||||||
export class SourceNotFoundError extends Error {
|
|
||||||
readonly sourceId: string
|
|
||||||
readonly userId: string
|
|
||||||
|
|
||||||
constructor(sourceId: string, userId: string) {
|
|
||||||
super(`Source "${sourceId}" not found for user "${userId}"`)
|
|
||||||
this.name = "SourceNotFoundError"
|
|
||||||
this.sourceId = sourceId
|
|
||||||
this.userId = userId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { and, eq } from "drizzle-orm"
|
|
||||||
|
|
||||||
import type { Database } from "../db/index.ts"
|
|
||||||
|
|
||||||
import { userSources } from "../db/schema.ts"
|
|
||||||
import { SourceNotFoundError } from "./errors.ts"
|
|
||||||
|
|
||||||
export function sources(db: Database, userId: string) {
|
|
||||||
return {
|
|
||||||
/** Returns all enabled sources for the user. */
|
|
||||||
async enabled() {
|
|
||||||
return db
|
|
||||||
.select()
|
|
||||||
.from(userSources)
|
|
||||||
.where(and(eq(userSources.userId, userId), eq(userSources.enabled, true)))
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Returns a specific source by ID, or undefined. */
|
|
||||||
async find(sourceId: string) {
|
|
||||||
const rows = await db
|
|
||||||
.select()
|
|
||||||
.from(userSources)
|
|
||||||
.where(and(eq(userSources.userId, userId), eq(userSources.sourceId, sourceId)))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
return rows[0]
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Enables a source for the user. Throws if the source row doesn't exist. */
|
|
||||||
async enableSource(sourceId: string) {
|
|
||||||
const rows = await db
|
|
||||||
.update(userSources)
|
|
||||||
.set({ enabled: true, updatedAt: new Date() })
|
|
||||||
.where(and(eq(userSources.userId, userId), eq(userSources.sourceId, sourceId)))
|
|
||||||
.returning({ id: userSources.id })
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
throw new SourceNotFoundError(sourceId, userId)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Disables a source for the user. Throws if the source row doesn't exist. */
|
|
||||||
async disableSource(sourceId: string) {
|
|
||||||
const rows = await db
|
|
||||||
.update(userSources)
|
|
||||||
.set({ enabled: false, updatedAt: new Date() })
|
|
||||||
.where(and(eq(userSources.userId, userId), eq(userSources.sourceId, sourceId)))
|
|
||||||
.returning({ id: userSources.id })
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
throw new SourceNotFoundError(sourceId, userId)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Creates or updates the config for a source. */
|
|
||||||
async upsertConfig(sourceId: string, config: Record<string, unknown>) {
|
|
||||||
await db
|
|
||||||
.insert(userSources)
|
|
||||||
.values({ userId, sourceId, config })
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [userSources.userId, userSources.sourceId],
|
|
||||||
set: { config, updatedAt: new Date() },
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Updates the encrypted credentials for a source. Throws if the source row doesn't exist. */
|
|
||||||
async updateCredentials(sourceId: string, credentials: Buffer) {
|
|
||||||
const rows = await db
|
|
||||||
.update(userSources)
|
|
||||||
.set({ credentials, updatedAt: new Date() })
|
|
||||||
.where(and(eq(userSources.userId, userId), eq(userSources.sourceId, sourceId)))
|
|
||||||
.returning({ id: userSources.id })
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
throw new SourceNotFoundError(sourceId, userId)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +1,19 @@
|
|||||||
import { TflSource, type ITflApi, type TflLineId } from "@aelis/source-tfl"
|
import { TflSource, type ITflApi } from "@aelis/source-tfl"
|
||||||
import { type } from "arktype"
|
|
||||||
|
|
||||||
import type { Database } from "../db/index.ts"
|
|
||||||
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||||
|
|
||||||
import { SourceDisabledError } from "../sources/errors.ts"
|
|
||||||
import { sources } from "../sources/user-sources.ts"
|
|
||||||
|
|
||||||
export type TflSourceProviderOptions =
|
export type TflSourceProviderOptions =
|
||||||
| { db: Database; apiKey: string; client?: never }
|
| { apiKey: string; client?: never }
|
||||||
| { db: Database; apiKey?: never; client: ITflApi }
|
| { apiKey?: never; client: ITflApi }
|
||||||
|
|
||||||
const tflConfig = type({
|
|
||||||
"lines?": "string[]",
|
|
||||||
})
|
|
||||||
|
|
||||||
export class TflSourceProvider implements FeedSourceProvider {
|
export class TflSourceProvider implements FeedSourceProvider {
|
||||||
private readonly db: Database
|
private readonly options: TflSourceProviderOptions
|
||||||
private readonly apiKey: string | undefined
|
|
||||||
private readonly client: ITflApi | undefined
|
|
||||||
|
|
||||||
constructor(options: TflSourceProviderOptions) {
|
constructor(options: TflSourceProviderOptions) {
|
||||||
this.db = options.db
|
this.options = options
|
||||||
this.apiKey = "apiKey" in options ? options.apiKey : undefined
|
|
||||||
this.client = "client" in options ? options.client : undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async feedSourceForUser(userId: string): Promise<TflSource> {
|
feedSourceForUser(_userId: string): TflSource {
|
||||||
const row = await sources(this.db, userId).find("aelis.tfl")
|
return new TflSource(this.options)
|
||||||
|
|
||||||
if (!row || !row.enabled) {
|
|
||||||
throw new SourceDisabledError("aelis.tfl", userId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = tflConfig(row.config ?? {})
|
|
||||||
if (parsed instanceof type.errors) {
|
|
||||||
throw new Error(`Invalid TFL config for user ${userId}: ${parsed.summary}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TflSource({
|
|
||||||
apiKey: this.apiKey,
|
|
||||||
client: this.client,
|
|
||||||
lines: parsed.lines as TflLineId[] | undefined,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,15 @@
|
|||||||
import { WeatherSource, type WeatherSourceOptions } from "@aelis/source-weatherkit"
|
import { WeatherSource, type WeatherSourceOptions } from "@aelis/source-weatherkit"
|
||||||
import { type } from "arktype"
|
|
||||||
|
|
||||||
import type { Database } from "../db/index.ts"
|
|
||||||
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||||
|
|
||||||
import { SourceDisabledError } from "../sources/errors.ts"
|
|
||||||
import { sources } from "../sources/user-sources.ts"
|
|
||||||
|
|
||||||
export interface WeatherSourceProviderOptions {
|
|
||||||
db: Database
|
|
||||||
credentials: WeatherSourceOptions["credentials"]
|
|
||||||
client?: WeatherSourceOptions["client"]
|
|
||||||
}
|
|
||||||
|
|
||||||
const weatherConfig = type({
|
|
||||||
"units?": "'metric' | 'imperial'",
|
|
||||||
"hourlyLimit?": "number",
|
|
||||||
"dailyLimit?": "number",
|
|
||||||
})
|
|
||||||
|
|
||||||
export class WeatherSourceProvider implements FeedSourceProvider {
|
export class WeatherSourceProvider implements FeedSourceProvider {
|
||||||
private readonly db: Database
|
private readonly options: WeatherSourceOptions
|
||||||
private readonly credentials: WeatherSourceOptions["credentials"]
|
|
||||||
private readonly client: WeatherSourceOptions["client"]
|
|
||||||
|
|
||||||
constructor(options: WeatherSourceProviderOptions) {
|
constructor(options: WeatherSourceOptions) {
|
||||||
this.db = options.db
|
this.options = options
|
||||||
this.credentials = options.credentials
|
|
||||||
this.client = options.client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async feedSourceForUser(userId: string): Promise<WeatherSource> {
|
feedSourceForUser(_userId: string): WeatherSource {
|
||||||
const row = await sources(this.db, userId).find("aelis.weather")
|
return new WeatherSource(this.options)
|
||||||
|
|
||||||
if (!row || !row.enabled) {
|
|
||||||
throw new SourceDisabledError("aelis.weather", userId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = weatherConfig(row.config ?? {})
|
|
||||||
if (parsed instanceof type.errors) {
|
|
||||||
throw new Error(`Invalid weather config for user ${userId}: ${parsed.summary}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return new WeatherSource({
|
|
||||||
credentials: this.credentials,
|
|
||||||
client: this.client,
|
|
||||||
units: parsed.units,
|
|
||||||
hourlyLimit: parsed.hourlyLimit,
|
|
||||||
dailyLimit: parsed.dailyLimit,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,9 @@
|
|||||||
"@expo-google-fonts/inter": "^0.4.2",
|
"@expo-google-fonts/inter": "^0.4.2",
|
||||||
"@expo-google-fonts/source-serif-4": "^0.4.1",
|
"@expo-google-fonts/source-serif-4": "^0.4.1",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@json-render/react-native": "^0.13.0",
|
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.13",
|
||||||
"expo-dev-client": "~6.0.20",
|
"expo-dev-client": "~6.0.20",
|
||||||
@@ -47,8 +45,7 @@
|
|||||||
"react-native-svg": "15.12.1",
|
"react-native-svg": "15.12.1",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-worklets": "0.5.1",
|
"react-native-worklets": "0.5.1",
|
||||||
"twrnc": "^4.16.0",
|
"twrnc": "^4.16.0"
|
||||||
"zod": "^4.3.6"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { ApiRequestMiddleware } from "./client"
|
|
||||||
|
|
||||||
export const authMiddleware: ApiRequestMiddleware = (_url, init) => {
|
|
||||||
// TODO: placeholder auth middleware
|
|
||||||
return init
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { createContext, useContext } from "react"
|
|
||||||
|
|
||||||
export type ApiRequestMiddleware = (
|
|
||||||
url: Parameters<typeof fetch>[0],
|
|
||||||
init: RequestInit,
|
|
||||||
) => RequestInit
|
|
||||||
|
|
||||||
export class ApiClient {
|
|
||||||
private readonly baseUrl: string
|
|
||||||
private readonly middlewares: readonly ApiRequestMiddleware[]
|
|
||||||
|
|
||||||
static noop = new ApiClient({ baseUrl: "" })
|
|
||||||
|
|
||||||
constructor({
|
|
||||||
baseUrl,
|
|
||||||
middlewares = [],
|
|
||||||
}: {
|
|
||||||
baseUrl: string
|
|
||||||
middlewares?: ApiRequestMiddleware[]
|
|
||||||
}) {
|
|
||||||
this.baseUrl = baseUrl
|
|
||||||
this.middlewares = middlewares
|
|
||||||
}
|
|
||||||
|
|
||||||
async request<T>(...[url, init = {}]: Parameters<typeof fetch>): Promise<[Response, T]> {
|
|
||||||
const finalInit = this.middlewares.reduce(
|
|
||||||
(prevInit, middleware) => middleware(url, prevInit),
|
|
||||||
init,
|
|
||||||
)
|
|
||||||
return fetch(this.baseUrl ? new URL(url.toString(), this.baseUrl) : url, finalInit).then((res) =>
|
|
||||||
Promise.all([Promise.resolve(res), res.json()]),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ApiClientContext = createContext(ApiClient.noop)
|
|
||||||
export function useApiClient() {
|
|
||||||
return useContext(ApiClientContext)
|
|
||||||
}
|
|
||||||
@@ -1,29 +1,17 @@
|
|||||||
import "react-native-reanimated"
|
import "react-native-reanimated"
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
|
||||||
import { Stack } from "expo-router"
|
import { Stack } from "expo-router"
|
||||||
import { StatusBar } from "expo-status-bar"
|
import { StatusBar } from "expo-status-bar"
|
||||||
import React from "react"
|
|
||||||
import { useColorScheme } from "react-native"
|
import { useColorScheme } from "react-native"
|
||||||
import tw, { useDeviceContext } from "twrnc"
|
import tw, { useDeviceContext } from "twrnc"
|
||||||
|
|
||||||
import { authMiddleware } from "@/api/auth-middleware"
|
|
||||||
import { ApiClient, ApiClientContext } from "@/api/client"
|
|
||||||
|
|
||||||
const queryClient = new QueryClient()
|
|
||||||
const apiClient = new ApiClient({
|
|
||||||
baseUrl: process.env.EXPO_PUBLIC_API_BASE_URL ?? "",
|
|
||||||
middlewares: [authMiddleware],
|
|
||||||
})
|
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
useDeviceContext(tw)
|
useDeviceContext(tw)
|
||||||
|
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
const headerBg = colorScheme === "dark" ? "#1c1917" : "#f5f5f4"
|
const headerBg = colorScheme === "dark" ? "#1c1917" : "#f5f5f4"
|
||||||
const headerTint = colorScheme === "dark" ? "#e7e5e4" : "#1c1917"
|
const headerTint = colorScheme === "dark" ? "#e7e5e4" : "#1c1917"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextProvider>
|
<>
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
@@ -52,14 +40,6 @@ export default function RootLayout() {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<StatusBar style="auto" />
|
<StatusBar style="auto" />
|
||||||
</ContextProvider>
|
</>
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextProvider({ children }: React.PropsWithChildren) {
|
|
||||||
return (
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<ApiClientContext value={apiClient}>{children}</ApiClientContext>
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Feather from "@expo/vector-icons/Feather"
|
||||||
import { View } from "react-native"
|
import { View } from "react-native"
|
||||||
import tw from "twrnc"
|
import tw from "twrnc"
|
||||||
|
|
||||||
@@ -14,22 +15,22 @@ function ButtonShowcase() {
|
|||||||
<Button
|
<Button
|
||||||
style={tw`self-start`}
|
style={tw`self-start`}
|
||||||
label="Add item"
|
label="Add item"
|
||||||
leadingIcon={<Button.Icon name="plus" />}
|
leadingIcon={<Feather name="plus" size={18} color="#e7e5e4" />}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
<Section title="Trailing icon">
|
<Section title="Trailing icon">
|
||||||
<Button
|
<Button
|
||||||
style={tw`self-start`}
|
style={tw`self-start`}
|
||||||
label="Next"
|
label="Next"
|
||||||
trailingIcon={<Button.Icon name="arrow-right" />}
|
trailingIcon={<Feather name="arrow-right" size={18} color="#e7e5e4" />}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
<Section title="Both icons">
|
<Section title="Both icons">
|
||||||
<Button
|
<Button
|
||||||
style={tw`self-start`}
|
style={tw`self-start`}
|
||||||
label="Download"
|
label="Download"
|
||||||
leadingIcon={<Button.Icon name="download" />}
|
leadingIcon={<Feather name="download" size={18} color="#e7e5e4" />}
|
||||||
trailingIcon={<Button.Icon name="chevron-down" />}
|
trailingIcon={<Feather name="chevron-down" size={18} color="#e7e5e4" />}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,19 +1,8 @@
|
|||||||
import Feather from "@expo/vector-icons/Feather"
|
|
||||||
import { type PressableProps, Pressable, View } from "react-native"
|
import { type PressableProps, Pressable, View } from "react-native"
|
||||||
import tw from "twrnc"
|
import tw from "twrnc"
|
||||||
|
|
||||||
import { SansSerifText } from "./sans-serif-text"
|
import { SansSerifText } from "./sans-serif-text"
|
||||||
|
|
||||||
type FeatherIconName = React.ComponentProps<typeof Feather>["name"]
|
|
||||||
|
|
||||||
type ButtonIconProps = {
|
|
||||||
name: FeatherIconName
|
|
||||||
}
|
|
||||||
|
|
||||||
function ButtonIcon({ name }: ButtonIconProps) {
|
|
||||||
return <Feather name={name} size={18} color={tw.color("text-stone-100 dark:text-stone-200")} />
|
|
||||||
}
|
|
||||||
|
|
||||||
type ButtonProps = Omit<PressableProps, "children"> & {
|
type ButtonProps = Omit<PressableProps, "children"> & {
|
||||||
label: string
|
label: string
|
||||||
leadingIcon?: React.ReactNode
|
leadingIcon?: React.ReactNode
|
||||||
@@ -23,7 +12,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 = <SansSerifText style={tw`text-stone-100 dark:text-stone-200 font-medium`}>{label}</SansSerifText>
|
const textElement = <SansSerifText style={tw`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}>
|
||||||
@@ -39,5 +28,3 @@ export function Button({ style, label, leadingIcon, trailingIcon, ...props }: Bu
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button.Icon = ButtonIcon
|
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { queryOptions } from "@tanstack/react-query"
|
|
||||||
|
|
||||||
import { useApiClient } from "@/api/client"
|
|
||||||
|
|
||||||
import { FeedItem } from "./types"
|
|
||||||
|
|
||||||
export function useFeedQuery() {
|
|
||||||
const api = useApiClient()
|
|
||||||
return queryOptions({
|
|
||||||
queryKey: ["feed"],
|
|
||||||
queryFn: async () => api.request<{ items: FeedItem[] }>("/feed?render=json-render"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { Spec } from "@json-render/core"
|
|
||||||
|
|
||||||
export interface FeedItem {
|
|
||||||
ui: Spec
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { defineCatalog } from "@json-render/core"
|
|
||||||
import { schema } from "@json-render/react-native/schema"
|
|
||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
export const catalog = defineCatalog(schema, {
|
|
||||||
components: {
|
|
||||||
View: {
|
|
||||||
props: z.object({
|
|
||||||
style: z.string().nullable(),
|
|
||||||
}),
|
|
||||||
slots: ["default"],
|
|
||||||
description:
|
|
||||||
"Generic layout container. The style prop accepts a twrnc class string (e.g. 'flex-row gap-2 p-4 items-center').",
|
|
||||||
example: { style: "flex-row gap-2 p-4" },
|
|
||||||
},
|
|
||||||
Button: {
|
|
||||||
props: z.object({
|
|
||||||
label: z.string(),
|
|
||||||
leadingIcon: z.string().nullable(),
|
|
||||||
trailingIcon: z.string().nullable(),
|
|
||||||
}),
|
|
||||||
events: ["press"],
|
|
||||||
slots: [],
|
|
||||||
description:
|
|
||||||
"Pressable button with a label and optional Feather icons. Icon values are Feather icon names (e.g. 'plus', 'arrow-right'). Bind on.press to trigger an action.",
|
|
||||||
example: { label: "Add item", leadingIcon: "plus", trailingIcon: null },
|
|
||||||
},
|
|
||||||
FeedCard: {
|
|
||||||
props: z.object({
|
|
||||||
style: z.string().nullable(),
|
|
||||||
}),
|
|
||||||
slots: ["default"],
|
|
||||||
description: "Bordered card container for feed content. The style prop accepts a twrnc class string.",
|
|
||||||
example: { style: "p-4 gap-2" },
|
|
||||||
},
|
|
||||||
SansSerifText: {
|
|
||||||
props: z.object({
|
|
||||||
text: z.string(),
|
|
||||||
style: z.string().nullable(),
|
|
||||||
}),
|
|
||||||
slots: [],
|
|
||||||
description:
|
|
||||||
"Sans-serif text (Inter font). The style prop accepts a twrnc class string for size, weight, color, etc.",
|
|
||||||
example: { text: "Hello world", style: "text-base font-medium" },
|
|
||||||
},
|
|
||||||
SerifText: {
|
|
||||||
props: z.object({
|
|
||||||
text: z.string(),
|
|
||||||
style: z.string().nullable(),
|
|
||||||
}),
|
|
||||||
slots: [],
|
|
||||||
description:
|
|
||||||
"Serif text (Source Serif 4 font). The style prop accepts a twrnc class string for size, color, etc.",
|
|
||||||
example: { text: "Heading", style: "text-xl" },
|
|
||||||
},
|
|
||||||
MonospaceText: {
|
|
||||||
props: z.object({
|
|
||||||
text: z.string(),
|
|
||||||
style: z.string().nullable(),
|
|
||||||
}),
|
|
||||||
slots: [],
|
|
||||||
description:
|
|
||||||
"Monospace text (Menlo font). The style prop accepts a twrnc class string for size, color, etc.",
|
|
||||||
example: { text: "const x = 42", style: "text-sm" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
actions: {},
|
|
||||||
})
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { catalog } from "./catalog"
|
|
||||||
export { registry } from "./registry"
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { defineRegistry } from "@json-render/react-native"
|
|
||||||
import { View } from "react-native"
|
|
||||||
import tw from "twrnc"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { FeedCard } from "@/components/ui/feed-card"
|
|
||||||
import { MonospaceText } from "@/components/ui/monospace-text"
|
|
||||||
import { SansSerifText } from "@/components/ui/sans-serif-text"
|
|
||||||
import { SerifText } from "@/components/ui/serif-text"
|
|
||||||
|
|
||||||
import { catalog } from "./catalog"
|
|
||||||
|
|
||||||
type ButtonIconName = React.ComponentProps<typeof Button.Icon>["name"]
|
|
||||||
|
|
||||||
export const { registry } = defineRegistry(catalog, {
|
|
||||||
components: {
|
|
||||||
View: ({ props, children }) => <View style={props.style ? tw`${props.style}` : undefined}>{children}</View>,
|
|
||||||
Button: ({ props, emit }) => (
|
|
||||||
<Button
|
|
||||||
label={props.label}
|
|
||||||
leadingIcon={props.leadingIcon ? <Button.Icon name={props.leadingIcon as ButtonIconName} /> : undefined}
|
|
||||||
trailingIcon={props.trailingIcon ? <Button.Icon name={props.trailingIcon as ButtonIconName} /> : undefined}
|
|
||||||
onPress={() => emit("press")}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
FeedCard: ({ props, children }) => (
|
|
||||||
<FeedCard style={props.style ? tw`${props.style}` : undefined}>{children}</FeedCard>
|
|
||||||
),
|
|
||||||
SansSerifText: ({ props }) => (
|
|
||||||
<SansSerifText style={props.style ? tw`${props.style}` : undefined}>{props.text}</SansSerifText>
|
|
||||||
),
|
|
||||||
SerifText: ({ props }) => (
|
|
||||||
<SerifText style={props.style ? tw`${props.style}` : undefined}>{props.text}</SerifText>
|
|
||||||
),
|
|
||||||
MonospaceText: ({ props }) => (
|
|
||||||
<MonospaceText style={props.style ? tw`${props.style}` : undefined}>{props.text}</MonospaceText>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
199
bun.lock
199
bun.lock
@@ -6,7 +6,7 @@
|
|||||||
"name": "aelis",
|
"name": "aelis",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@json-render/core": "^0.12.1",
|
"@json-render/core": "^0.12.1",
|
||||||
"@nym.sh/jrx": "^0.2.0",
|
"@nym.sh/jrx": "^0.1.0",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"oxfmt": "^0.24.0",
|
"oxfmt": "^0.24.0",
|
||||||
"oxlint": "^1.39.0",
|
"oxlint": "^1.39.0",
|
||||||
@@ -28,11 +28,11 @@
|
|||||||
"@openrouter/sdk": "^0.9.11",
|
"@openrouter/sdk": "^0.9.11",
|
||||||
"arktype": "^2.1.29",
|
"arktype": "^2.1.29",
|
||||||
"better-auth": "^1",
|
"better-auth": "^1",
|
||||||
"drizzle-orm": "^0.45.1",
|
|
||||||
"hono": "^4",
|
"hono": "^4",
|
||||||
|
"pg": "^8",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"drizzle-kit": "^0.31.9",
|
"@types/pg": "^8",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"apps/aelis-client": {
|
"apps/aelis-client": {
|
||||||
@@ -42,11 +42,9 @@
|
|||||||
"@expo-google-fonts/inter": "^0.4.2",
|
"@expo-google-fonts/inter": "^0.4.2",
|
||||||
"@expo-google-fonts/source-serif-4": "^0.4.1",
|
"@expo-google-fonts/source-serif-4": "^0.4.1",
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
"@json-render/react-native": "^0.13.0",
|
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.13",
|
||||||
"expo-dev-client": "~6.0.20",
|
"expo-dev-client": "~6.0.20",
|
||||||
@@ -72,7 +70,6 @@
|
|||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-worklets": "0.5.1",
|
"react-native-worklets": "0.5.1",
|
||||||
"twrnc": "^4.16.0",
|
"twrnc": "^4.16.0",
|
||||||
"zod": "^4.3.6",
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
@@ -111,14 +108,6 @@
|
|||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/aelis-components": {
|
|
||||||
"name": "@aelis/components",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@json-render/core": "*",
|
|
||||||
"@nym.sh/jrx": "*",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"packages/aelis-core": {
|
"packages/aelis-core": {
|
||||||
"name": "@aelis/core",
|
"name": "@aelis/core",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
@@ -153,7 +142,6 @@
|
|||||||
"name": "@aelis/source-caldav",
|
"name": "@aelis/source-caldav",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aelis/components": "workspace:*",
|
|
||||||
"@aelis/core": "workspace:*",
|
"@aelis/core": "workspace:*",
|
||||||
"ical.js": "^2.1.0",
|
"ical.js": "^2.1.0",
|
||||||
"tsdav": "^2.1.7",
|
"tsdav": "^2.1.7",
|
||||||
@@ -179,15 +167,10 @@
|
|||||||
"name": "@aelis/source-tfl",
|
"name": "@aelis/source-tfl",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aelis/components": "workspace:*",
|
|
||||||
"@aelis/core": "workspace:*",
|
"@aelis/core": "workspace:*",
|
||||||
"@aelis/source-location": "workspace:*",
|
"@aelis/source-location": "workspace:*",
|
||||||
"arktype": "^2.1.0",
|
"arktype": "^2.1.0",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
|
||||||
"@json-render/core": "*",
|
|
||||||
"@nym.sh/jrx": "*",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"packages/aelis-source-weatherkit": {
|
"packages/aelis-source-weatherkit": {
|
||||||
"name": "@aelis/source-weatherkit",
|
"name": "@aelis/source-weatherkit",
|
||||||
@@ -204,8 +187,6 @@
|
|||||||
|
|
||||||
"@aelis/backend": ["@aelis/backend@workspace:apps/aelis-backend"],
|
"@aelis/backend": ["@aelis/backend@workspace:apps/aelis-backend"],
|
||||||
|
|
||||||
"@aelis/components": ["@aelis/components@workspace:packages/aelis-components"],
|
|
||||||
|
|
||||||
"@aelis/core": ["@aelis/core@workspace:packages/aelis-core"],
|
"@aelis/core": ["@aelis/core@workspace:packages/aelis-core"],
|
||||||
|
|
||||||
"@aelis/data-source-weatherkit": ["@aelis/data-source-weatherkit@workspace:packages/aelis-data-source-weatherkit"],
|
"@aelis/data-source-weatherkit": ["@aelis/data-source-weatherkit@workspace:packages/aelis-data-source-weatherkit"],
|
||||||
@@ -444,8 +425,6 @@
|
|||||||
|
|
||||||
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
||||||
|
|
||||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
|
||||||
|
|
||||||
"@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="],
|
"@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="],
|
||||||
|
|
||||||
"@electric-sql/pglite": ["@electric-sql/pglite@0.3.15", "", {}, "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ=="],
|
"@electric-sql/pglite": ["@electric-sql/pglite@0.3.15", "", {}, "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ=="],
|
||||||
@@ -460,61 +439,57 @@
|
|||||||
|
|
||||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||||
|
|
||||||
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
|
||||||
|
|
||||||
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
|
||||||
|
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
|
||||||
|
|
||||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
|
||||||
|
|
||||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
|
||||||
|
|
||||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
|
||||||
|
|
||||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
|
||||||
|
|
||||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
|
||||||
|
|
||||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
|
||||||
|
|
||||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
|
||||||
|
|
||||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
|
||||||
|
|
||||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
|
||||||
|
|
||||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
|
||||||
|
|
||||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
|
||||||
|
|
||||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
|
||||||
|
|
||||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
|
||||||
|
|
||||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
|
||||||
|
|
||||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
|
||||||
|
|
||||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
|
||||||
|
|
||||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
|
||||||
|
|
||||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
|
||||||
|
|
||||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
|
||||||
|
|
||||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
|
||||||
|
|
||||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
|
||||||
|
|
||||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
|
||||||
|
|
||||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
|
||||||
|
|
||||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
|
||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
|
||||||
|
|
||||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||||
|
|
||||||
@@ -682,8 +657,6 @@
|
|||||||
|
|
||||||
"@json-render/core": ["@json-render/core@0.12.1", "", { "dependencies": { "zod": "^4.3.6" } }, "sha512-1tV/481GPHmIRd6lXfWcTaIslQusmDg5lzcSBzWLkSXjF9sjjyOQL090in7uHT4tOMWkdmlEJOW5H9C72PsUEQ=="],
|
"@json-render/core": ["@json-render/core@0.12.1", "", { "dependencies": { "zod": "^4.3.6" } }, "sha512-1tV/481GPHmIRd6lXfWcTaIslQusmDg5lzcSBzWLkSXjF9sjjyOQL090in7uHT4tOMWkdmlEJOW5H9C72PsUEQ=="],
|
||||||
|
|
||||||
"@json-render/react-native": ["@json-render/react-native@0.13.0", "", { "dependencies": { "@json-render/core": "0.13.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-native": ">=0.71.0", "zod": "^4.0.0" } }, "sha512-uUrK28xPb7LuyYsi9cTnvrnXnVBG0OwU5Up35aaXwcWMLrfqxJ7oWfF97HlDvZIckQtm0VEngAXhHMW97qBEkg=="],
|
|
||||||
|
|
||||||
"@mjackson/node-fetch-server": ["@mjackson/node-fetch-server@0.2.0", "", {}, "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng=="],
|
"@mjackson/node-fetch-server": ["@mjackson/node-fetch-server@0.2.0", "", {}, "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng=="],
|
||||||
|
|
||||||
"@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.6", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g=="],
|
"@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.6", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g=="],
|
||||||
@@ -704,7 +677,7 @@
|
|||||||
|
|
||||||
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
|
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
|
||||||
|
|
||||||
"@nym.sh/jrx": ["@nym.sh/jrx@0.2.0", "", { "peerDependencies": { "@json-render/core": ">=0.10.0" } }, "sha512-jd7Z1Q6T21366MtSUnwCFiu6Yl1AdNc9s5m6HxeUg265P+0enZCiyyxOuHsFwvpUcSEs/2DVBsqfMptdca44lA=="],
|
"@nym.sh/jrx": ["@nym.sh/jrx@0.1.0", "", { "peerDependencies": { "@json-render/core": ">=0.10.0" } }, "sha512-mu6fkAP/TI9FuP8A4WMCrcucpUtWF5xBTcETnrjOtvEED9i+7sQKuoOyhJeF6QaSuUkAA/8t3Xx3kYUjcAPFbw=="],
|
||||||
|
|
||||||
"@oclif/core": ["@oclif/core@4.8.4", "", { "dependencies": { "ansi-escapes": "^4.3.2", "ansis": "^3.17.0", "clean-stack": "^3.0.1", "cli-spinners": "^2.9.2", "debug": "^4.4.3", "ejs": "^3.1.10", "get-package-type": "^0.1.0", "indent-string": "^4.0.0", "is-wsl": "^2.2.0", "lilconfig": "^3.1.3", "minimatch": "^10.2.4", "semver": "^7.7.3", "string-width": "^4.2.3", "supports-color": "^8", "tinyglobby": "^0.2.14", "widest-line": "^3.1.0", "wordwrap": "^1.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-UTAqwXJJyRvLBvosL+1uPZYSpr8lEHgUb/EVGbPXo5WZqUIBHfJ0sR2bkBEsrj00/ar4IegKxx4YK0wn2c8SQg=="],
|
"@oclif/core": ["@oclif/core@4.8.4", "", { "dependencies": { "ansi-escapes": "^4.3.2", "ansis": "^3.17.0", "clean-stack": "^3.0.1", "cli-spinners": "^2.9.2", "debug": "^4.4.3", "ejs": "^3.1.10", "get-package-type": "^0.1.0", "indent-string": "^4.0.0", "is-wsl": "^2.2.0", "lilconfig": "^3.1.3", "minimatch": "^10.2.4", "semver": "^7.7.3", "string-width": "^4.2.3", "supports-color": "^8", "tinyglobby": "^0.2.14", "widest-line": "^3.1.0", "wordwrap": "^1.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-UTAqwXJJyRvLBvosL+1uPZYSpr8lEHgUb/EVGbPXo5WZqUIBHfJ0sR2bkBEsrj00/ar4IegKxx4YK0wn2c8SQg=="],
|
||||||
|
|
||||||
@@ -1196,10 +1169,6 @@
|
|||||||
|
|
||||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="],
|
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="],
|
||||||
|
|
||||||
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
|
|
||||||
|
|
||||||
"@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=="],
|
|
||||||
|
|
||||||
"@tsconfig/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="],
|
"@tsconfig/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="],
|
||||||
|
|
||||||
"@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="],
|
"@tsconfig/node12": ["@tsconfig/node12@1.0.11", "", {}, "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="],
|
||||||
@@ -1706,8 +1675,6 @@
|
|||||||
|
|
||||||
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
|
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
|
||||||
|
|
||||||
"drizzle-kit": ["drizzle-kit@0.31.9", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg=="],
|
|
||||||
|
|
||||||
"drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="],
|
"drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="],
|
||||||
|
|
||||||
"dtrace-provider": ["dtrace-provider@0.8.8", "", { "dependencies": { "nan": "^2.14.0" } }, "sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg=="],
|
"dtrace-provider": ["dtrace-provider@0.8.8", "", { "dependencies": { "nan": "^2.14.0" } }, "sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg=="],
|
||||||
@@ -1768,9 +1735,7 @@
|
|||||||
|
|
||||||
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
|
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
|
||||||
|
|
||||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
||||||
|
|
||||||
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
|
|
||||||
|
|
||||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
@@ -3322,8 +3287,6 @@
|
|||||||
|
|
||||||
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
|
||||||
|
|
||||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||||
|
|
||||||
"@eslint/config-array/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
"@eslint/config-array/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||||
@@ -3494,8 +3457,6 @@
|
|||||||
|
|
||||||
"@jest/transform/write-file-atomic": ["write-file-atomic@4.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" } }, "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg=="],
|
"@jest/transform/write-file-atomic": ["write-file-atomic@4.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" } }, "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg=="],
|
||||||
|
|
||||||
"@json-render/react-native/@json-render/core": ["@json-render/core@0.13.0", "", { "dependencies": { "zod": "^4.3.6" } }, "sha512-CXmCsc8BHDRq45ScVd+qgvjTbwZHPVpVD05WnTqgDxqfY3LGXu5vxaQRSwYoEodg/DGcZq/4HSj4ipVvrzy3qQ=="],
|
|
||||||
|
|
||||||
"@mrleebo/prisma-ast/lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
|
"@mrleebo/prisma-ast/lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
|
||||||
|
|
||||||
"@oclif/core/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
"@oclif/core/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||||
@@ -3812,8 +3773,6 @@
|
|||||||
|
|
||||||
"twrnc/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
|
"twrnc/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
|
||||||
|
|
||||||
"vite/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
|
||||||
|
|
||||||
"vite-node/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
"vite-node/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||||
|
|
||||||
"waitlist-website/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
"waitlist-website/@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||||
@@ -3836,50 +3795,6 @@
|
|||||||
|
|
||||||
"@babel/highlight/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="],
|
"@babel/highlight/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="],
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
|
||||||
|
|
||||||
"@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
"@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||||
|
|
||||||
"@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
"@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||||
@@ -4146,58 +4061,6 @@
|
|||||||
|
|
||||||
"twrnc/tailwindcss/sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
|
"twrnc/tailwindcss/sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
|
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
|
|
||||||
|
|
||||||
"waitlist-website/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
"waitlist-website/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
"@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
"@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="],
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
# DB Persistence Layer Spec
|
|
||||||
|
|
||||||
## Problem Statement
|
|
||||||
|
|
||||||
AELIS currently hardcodes the same set of feed sources for every user. Source configuration (TFL lines, weather units, calendar IDs, etc.) and credentials (OAuth tokens) are not persisted. Users cannot customize which sources appear in their feed or configure source-specific settings.
|
|
||||||
|
|
||||||
The backend uses a raw `pg` Pool for Better Auth and has no ORM. We need a persistence layer that stores per-user source configuration and credentials, using Drizzle ORM with Bun.sql as the Postgres driver.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### 1. Replace `pg` with `Bun.sql`
|
|
||||||
|
|
||||||
- Remove `pg` and `@types/pg` dependencies
|
|
||||||
- Replace `db.ts` with a Drizzle instance backed by `Bun.sql` (`drizzle-orm/bun-sql`)
|
|
||||||
- All DB access goes through Drizzle — no raw Pool usage
|
|
||||||
|
|
||||||
### 2. Migrate Better Auth to Drizzle adapter
|
|
||||||
|
|
||||||
- Use `better-auth/adapters/drizzle` so auth tables are managed through the same Drizzle instance
|
|
||||||
- Define Better Auth tables (user, session, account, verification) in the Drizzle schema
|
|
||||||
- Better Auth's `database` option switches from `Pool` to the Drizzle adapter
|
|
||||||
|
|
||||||
### 3. User source configuration table
|
|
||||||
|
|
||||||
A `user_sources` table stores per-user source state:
|
|
||||||
|
|
||||||
| Column | Type | Description |
|
|
||||||
| ------------ | ------------------------ | ------------------------------------------------------------ |
|
|
||||||
| `id` | `uuid` PK | Row ID |
|
|
||||||
| `user_id` | `text` FK → `user.id` | Owner |
|
|
||||||
| `source_id` | `text` | Source identifier (e.g., `aelis.tfl`, `aelis.weather`) |
|
|
||||||
| `enabled` | `boolean` | Whether this source is active in the user's feed |
|
|
||||||
| `config` | `jsonb` | Source-specific configuration (validated by source at runtime)|
|
|
||||||
| `credentials`| `bytea` | Encrypted OAuth tokens / secrets (AES-256-GCM) |
|
|
||||||
| `created_at` | `timestamp with tz` | Row creation time |
|
|
||||||
| `updated_at` | `timestamp with tz` | Last modification time |
|
|
||||||
|
|
||||||
- Unique constraint on `(user_id, source_id)` — one config row per source per user.
|
|
||||||
- `config` is a generic `jsonb` column. Each source package exports an arktype schema; the backend provider validates the JSON at source construction time.
|
|
||||||
- `credentials` is stored as encrypted bytes. Only OAuth tokens and secrets go here — non-sensitive config stays in `config`.
|
|
||||||
|
|
||||||
### 4. Credential encryption
|
|
||||||
|
|
||||||
- AES-256-GCM encryption for the `credentials` column
|
|
||||||
- Encryption key sourced from an environment variable (`CREDENTIALS_ENCRYPTION_KEY`)
|
|
||||||
- A `crypto` utility module in the backend provides `encrypt(plaintext)` → `Buffer` and `decrypt(ciphertext)` → `string`
|
|
||||||
- IV is generated per-encryption and stored as a prefix to the ciphertext
|
|
||||||
|
|
||||||
### 5. Default sources on signup
|
|
||||||
|
|
||||||
When a new user is created, seed `user_sources` rows for default sources:
|
|
||||||
|
|
||||||
| Source | Default config |
|
|
||||||
| ------------------ | --------------------------------------------------------------- |
|
|
||||||
| `aelis.location` | `{}` |
|
|
||||||
| `aelis.weather` | `{ "units": "metric", "hourlyLimit": 12, "dailyLimit": 7 }` |
|
|
||||||
| `aelis.tfl` | `{ "lines": <all default lines> }` |
|
|
||||||
|
|
||||||
- Seeding happens via a Better Auth `after` hook on user creation, or via application-level logic after signup.
|
|
||||||
- Sources requiring credentials (Google Calendar, CalDAV) are **not** enabled by default — they require the user to connect an account first.
|
|
||||||
|
|
||||||
### 6. Source providers query DB
|
|
||||||
|
|
||||||
`FeedSourceProvider.feedSourceForUser` is already async (returns `Promise<FeedSource>`). `UserSessionManager.getOrCreate` is already async with in-flight deduplication and `Promise.allSettled`-based graceful degradation — if a provider throws, the source is skipped and the error is logged.
|
|
||||||
|
|
||||||
Each provider receives the Drizzle DB instance and queries `user_sources` internally. If the source is disabled or the row is missing, the provider throws a `SourceDisabledError`. If config validation fails, it throws with a descriptive message. Both cases are handled by `createSession`'s `Promise.allSettled` — the source is excluded from the session and the error is logged.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
class TflSourceProvider implements FeedSourceProvider {
|
|
||||||
constructor(private db: DrizzleDb, private apiKey: string) {}
|
|
||||||
|
|
||||||
async feedSourceForUser(userId: string): Promise<TflSource> {
|
|
||||||
const row = await this.db.select()
|
|
||||||
.from(userSources)
|
|
||||||
.where(and(
|
|
||||||
eq(userSources.userId, userId),
|
|
||||||
eq(userSources.sourceId, "aelis.tfl"),
|
|
||||||
eq(userSources.enabled, true),
|
|
||||||
))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
if (!row[0]) {
|
|
||||||
throw new SourceDisabledError("aelis.tfl", userId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = tflSourceConfig(row[0].config ?? {})
|
|
||||||
if (config instanceof type.errors) {
|
|
||||||
throw new Error(`Invalid TFL config for user ${userId}: ${config.summary}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TflSource({ ...config, apiKey: this.apiKey })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
No interface changes are needed — the existing async `FeedSourceProvider` and `UserSessionManager` signatures are sufficient.
|
|
||||||
|
|
||||||
### 7. Drizzle Kit migrations
|
|
||||||
|
|
||||||
- Use `drizzle-kit` for schema migrations
|
|
||||||
- `drizzle.config.ts` at `apps/aelis-backend/drizzle.config.ts`
|
|
||||||
- Migration files stored in `apps/aelis-backend/drizzle/`
|
|
||||||
- Scripts in `package.json`: `db:generate`, `db:migrate`, `db:studio`
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
1. **Bun.sql driver**
|
|
||||||
- [ ] `pg` and `@types/pg` are removed from `package.json`
|
|
||||||
- [ ] `db.ts` exports a Drizzle instance using `Bun.sql`
|
|
||||||
- [ ] All existing DB usage (Better Auth) works with the new driver
|
|
||||||
|
|
||||||
2. **Better Auth on Drizzle**
|
|
||||||
- [ ] Better Auth uses `drizzle-adapter` with the shared Drizzle instance
|
|
||||||
- [ ] Auth tables (user, session, account, verification) are defined in the Drizzle schema
|
|
||||||
- [ ] Signup, signin, and session validation work as before
|
|
||||||
|
|
||||||
3. **User sources table**
|
|
||||||
- [ ] `user_sources` table exists with the schema described above
|
|
||||||
- [ ] Unique constraint on `(user_id, source_id)` is enforced
|
|
||||||
- [ ] `config` column accepts arbitrary JSON
|
|
||||||
- [ ] `credentials` column stores encrypted bytes
|
|
||||||
|
|
||||||
4. **Credential encryption**
|
|
||||||
- [ ] Encrypt/decrypt utility works with AES-256-GCM
|
|
||||||
- [ ] IV is unique per encryption
|
|
||||||
- [ ] Missing `CREDENTIALS_ENCRYPTION_KEY` fails fast at startup
|
|
||||||
- [ ] Unit tests cover round-trip encrypt → decrypt
|
|
||||||
|
|
||||||
5. **Default source seeding**
|
|
||||||
- [ ] New user signup creates `user_sources` rows for location, weather, and TFL
|
|
||||||
- [ ] Default config values match the table above
|
|
||||||
- [ ] Sources requiring credentials are not auto-enabled
|
|
||||||
|
|
||||||
6. **Provider DB integration**
|
|
||||||
- [ ] Each provider queries `user_sources` for the user's config and credentials
|
|
||||||
- [ ] Disabled sources (enabled=false or missing row) throw `SourceDisabledError`, excluded via `Promise.allSettled`
|
|
||||||
- [ ] Invalid config logs an error and skips the source (graceful degradation)
|
|
||||||
- [ ] `SourceDisabledError` class is created in `src/session/`
|
|
||||||
|
|
||||||
_Note: `FeedSourceProvider` is already async, `UserSessionManager.getOrCreate` is already async with in-flight deduplication and `Promise.allSettled` graceful degradation. No interface changes needed._
|
|
||||||
|
|
||||||
7. **Migrations**
|
|
||||||
- [ ] `drizzle.config.ts` is configured
|
|
||||||
- [ ] Initial migration creates all tables (auth + user_sources)
|
|
||||||
- [ ] `bun run db:generate` and `bun run db:migrate` work
|
|
||||||
|
|
||||||
## Implementation Approach
|
|
||||||
|
|
||||||
### Phase 1: Drizzle + Bun.sql setup
|
|
||||||
|
|
||||||
1. Install `drizzle-orm` and `drizzle-kit`; remove `pg` and `@types/pg`
|
|
||||||
2. Create `src/db/index.ts` — Drizzle instance with `Bun.sql`
|
|
||||||
3. Create `src/db/schema.ts` — Better Auth tables + `user_sources` table
|
|
||||||
4. Create `drizzle.config.ts`
|
|
||||||
5. Add `db:generate`, `db:migrate`, `db:studio` scripts to `package.json`
|
|
||||||
|
|
||||||
### Phase 2: Better Auth migration
|
|
||||||
|
|
||||||
6. Update `src/auth/index.ts` to use `drizzle-adapter` with the Drizzle instance
|
|
||||||
7. Verify signup/signin/session validation still work
|
|
||||||
8. Remove old `src/db.ts` (raw Pool)
|
|
||||||
|
|
||||||
### Phase 3: Credential encryption
|
|
||||||
|
|
||||||
9. Create `src/lib/crypto.ts` with `encrypt` and `decrypt` functions (AES-256-GCM)
|
|
||||||
10. Add `CREDENTIALS_ENCRYPTION_KEY` to `.env.example`
|
|
||||||
11. Write unit tests for encrypt/decrypt round-trip
|
|
||||||
|
|
||||||
### Phase 4: User source config
|
|
||||||
|
|
||||||
12. Create `src/db/user-sources.ts` — query helpers (get sources for user, upsert config, etc.)
|
|
||||||
13. Create `src/session/source-disabled-error.ts` — `SourceDisabledError` class
|
|
||||||
14. Implement default source seeding on user creation
|
|
||||||
15. Update each provider (Weather, TFL, Location) to accept Drizzle DB instance and query `user_sources` for config/credentials
|
|
||||||
|
|
||||||
_`FeedSourceProvider` is already async and `UserSessionManager.getOrCreate` already handles provider failures via `Promise.allSettled`. No interface or caller changes needed._
|
|
||||||
|
|
||||||
### Phase 5: Verification
|
|
||||||
|
|
||||||
16. Generate and run initial migration
|
|
||||||
17. Run existing tests, fix any breakage
|
|
||||||
18. Manual test: signup → default sources created → feed returns data
|
|
||||||
|
|
||||||
## File Structure (new/modified)
|
|
||||||
|
|
||||||
```
|
|
||||||
apps/aelis-backend/
|
|
||||||
├── drizzle.config.ts # NEW
|
|
||||||
├── drizzle/ # NEW — migration files
|
|
||||||
├── src/
|
|
||||||
│ ├── db.ts # REPLACE — Drizzle + Bun.sql
|
|
||||||
│ ├── db/
|
|
||||||
│ │ ├── schema.ts # NEW — all table definitions
|
|
||||||
│ │ └── user-sources.ts # NEW — query helpers
|
|
||||||
│ ├── auth/
|
|
||||||
│ │ └── index.ts # MODIFY — drizzle adapter
|
|
||||||
│ ├── lib/
|
|
||||||
│ │ ├── crypto.ts # NEW — encrypt/decrypt
|
|
||||||
│ │ └── crypto.test.ts # NEW
|
|
||||||
│ ├── session/
|
|
||||||
│ │ └── source-disabled-error.ts # NEW — SourceDisabledError
|
|
||||||
│ ├── weather/
|
|
||||||
│ │ └── provider.ts # MODIFY — query DB
|
|
||||||
│ └── tfl/
|
|
||||||
│ └── provider.ts # MODIFY — query DB
|
|
||||||
```
|
|
||||||
|
|
||||||
_`feed-source-provider.ts`, `user-session-manager.ts`, `engine/http.ts`, and `location/http.ts` are already async-ready on master and do not need changes._
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
**Add:**
|
|
||||||
- `drizzle-orm`
|
|
||||||
- `drizzle-kit` (dev)
|
|
||||||
|
|
||||||
**Remove:**
|
|
||||||
- `pg`
|
|
||||||
- `@types/pg` (dev)
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
**Add to `.env.example`:**
|
|
||||||
- `CREDENTIALS_ENCRYPTION_KEY` — 32-byte hex or base64 key for AES-256-GCM
|
|
||||||
|
|
||||||
## Open Questions (Deferred)
|
|
||||||
|
|
||||||
- HTTP endpoints for CRUD on user source config (settings UI)
|
|
||||||
- OAuth flow for connecting Google Calendar / CalDAV accounts
|
|
||||||
- Source config validation schemas exported from each source package (currently only TFL has one)
|
|
||||||
- Whether to cache DB-loaded config in the UserSession to avoid repeated queries on reconnect
|
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@json-render/core": "^0.12.1",
|
"@json-render/core": "^0.12.1",
|
||||||
"@nym.sh/jrx": "^0.2.0",
|
"@nym.sh/jrx": "^0.1.0",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"oxfmt": "^0.24.0",
|
"oxfmt": "^0.24.0",
|
||||||
"oxlint": "^1.39.0"
|
"oxlint": "^1.39.0"
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@aelis/components",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"main": "src/index.ts",
|
|
||||||
"types": "src/index.ts",
|
|
||||||
"scripts": {
|
|
||||||
"test": "bun test ./src"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@json-render/core": "*",
|
|
||||||
"@nym.sh/jrx": "*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { JrxNode } from "@nym.sh/jrx"
|
|
||||||
|
|
||||||
import { jsx } from "@nym.sh/jrx/jsx-runtime"
|
|
||||||
|
|
||||||
export type ButtonProps = {
|
|
||||||
label: string
|
|
||||||
leadingIcon?: string
|
|
||||||
trailingIcon?: string
|
|
||||||
style?: string
|
|
||||||
children?: JrxNode | JrxNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Button(props: ButtonProps): JrxNode {
|
|
||||||
return jsx("Button", props)
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
/** @jsxImportSource @nym.sh/jrx */
|
|
||||||
|
|
||||||
import { render } from "@nym.sh/jrx"
|
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import { Button } from "./button.ts"
|
|
||||||
import { FeedCard } from "./feed-card.ts"
|
|
||||||
import { MonospaceText } from "./monospace-text.ts"
|
|
||||||
import { SansSerifText } from "./sans-serif-text.ts"
|
|
||||||
import { SerifText } from "./serif-text.ts"
|
|
||||||
|
|
||||||
describe("Button", () => {
|
|
||||||
test("renders with label", () => {
|
|
||||||
const spec = render(<Button label="Press me" />)
|
|
||||||
|
|
||||||
expect(spec.root).toStartWith("button-")
|
|
||||||
const root = spec.elements[spec.root]!
|
|
||||||
expect(root.type).toBe("Button")
|
|
||||||
expect(root.props).toEqual({ label: "Press me" })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("renders with icon props", () => {
|
|
||||||
const spec = render(<Button label="Add" leadingIcon="plus" trailingIcon="arrow-right" />)
|
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
|
||||||
expect(root.type).toBe("Button")
|
|
||||||
expect(root.props).toEqual({
|
|
||||||
label: "Add",
|
|
||||||
leadingIcon: "plus",
|
|
||||||
trailingIcon: "arrow-right",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("passes style as string prop", () => {
|
|
||||||
const spec = render(<Button label="Go" style="px-4 py-2" />)
|
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
|
||||||
expect(root.props.style).toBe("px-4 py-2")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("FeedCard", () => {
|
|
||||||
test("renders as container", () => {
|
|
||||||
const spec = render(<FeedCard />)
|
|
||||||
|
|
||||||
expect(spec.root).toStartWith("feedcard-")
|
|
||||||
const root = spec.elements[spec.root]!
|
|
||||||
expect(root.type).toBe("FeedCard")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("renders with a single child", () => {
|
|
||||||
const spec = render(
|
|
||||||
<FeedCard>
|
|
||||||
<SansSerifText content="Only child" />
|
|
||||||
</FeedCard>,
|
|
||||||
)
|
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
|
||||||
expect(root.children).toHaveLength(1)
|
|
||||||
const child = spec.elements[root.children![0]!]!
|
|
||||||
expect(child.type).toBe("SansSerifText")
|
|
||||||
expect(child.props).toEqual({ content: "Only child" })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("passes style as string prop", () => {
|
|
||||||
const spec = render(<FeedCard style="p-4 border rounded-lg" />)
|
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
|
||||||
expect(root.props.style).toBe("p-4 border rounded-lg")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("SansSerifText", () => {
|
|
||||||
test("renders with content prop", () => {
|
|
||||||
const spec = render(<SansSerifText content="Hello" />)
|
|
||||||
|
|
||||||
expect(spec.root).toStartWith("sansseriftext-")
|
|
||||||
const root = spec.elements[spec.root]!
|
|
||||||
expect(root.type).toBe("SansSerifText")
|
|
||||||
expect(root.props).toEqual({ content: "Hello" })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("passes style as string prop", () => {
|
|
||||||
const spec = render(<SansSerifText content="Hello" style="text-sm text-stone-500" />)
|
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
|
||||||
expect(root.props.style).toBe("text-sm text-stone-500")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("SerifText", () => {
|
|
||||||
test("renders with content prop", () => {
|
|
||||||
const spec = render(<SerifText content="Title" />)
|
|
||||||
|
|
||||||
expect(spec.root).toStartWith("seriftext-")
|
|
||||||
const root = spec.elements[spec.root]!
|
|
||||||
expect(root.type).toBe("SerifText")
|
|
||||||
expect(root.props).toEqual({ content: "Title" })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("passes style as string prop", () => {
|
|
||||||
const spec = render(<SerifText content="Title" style="text-xl" />)
|
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
|
||||||
expect(root.props.style).toBe("text-xl")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("MonospaceText", () => {
|
|
||||||
test("renders with content prop", () => {
|
|
||||||
const spec = render(<MonospaceText content="code()" />)
|
|
||||||
|
|
||||||
expect(spec.root).toStartWith("monospacetext-")
|
|
||||||
const root = spec.elements[spec.root]!
|
|
||||||
expect(root.type).toBe("MonospaceText")
|
|
||||||
expect(root.props).toEqual({ content: "code()" })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("passes style as string prop", () => {
|
|
||||||
const spec = render(<MonospaceText content="code()" style="text-xs" />)
|
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
|
||||||
expect(root.props.style).toBe("text-xs")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("composite", () => {
|
|
||||||
test("FeedCard with nested children", () => {
|
|
||||||
const spec = render(
|
|
||||||
<FeedCard>
|
|
||||||
<SerifText content="Weather" />
|
|
||||||
<SansSerifText content="Sunny, 22C" />
|
|
||||||
<Button label="Details" />
|
|
||||||
</FeedCard>,
|
|
||||||
)
|
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
|
||||||
expect(root.type).toBe("FeedCard")
|
|
||||||
expect(root.children).toHaveLength(3)
|
|
||||||
|
|
||||||
const childKeys = root.children!
|
|
||||||
const child0 = spec.elements[childKeys[0]!]!
|
|
||||||
const child1 = spec.elements[childKeys[1]!]!
|
|
||||||
const child2 = spec.elements[childKeys[2]!]!
|
|
||||||
|
|
||||||
expect(child0.type).toBe("SerifText")
|
|
||||||
expect(child0.props).toEqual({ content: "Weather" })
|
|
||||||
|
|
||||||
expect(child1.type).toBe("SansSerifText")
|
|
||||||
expect(child1.props).toEqual({ content: "Sunny, 22C" })
|
|
||||||
|
|
||||||
expect(child2.type).toBe("Button")
|
|
||||||
expect(child2.props).toEqual({ label: "Details" })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { JrxNode } from "@nym.sh/jrx"
|
|
||||||
|
|
||||||
import { jsx } from "@nym.sh/jrx/jsx-runtime"
|
|
||||||
|
|
||||||
export type FeedCardProps = {
|
|
||||||
style?: string
|
|
||||||
children?: JrxNode | JrxNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FeedCard(props: FeedCardProps): JrxNode {
|
|
||||||
return jsx("FeedCard", props)
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
export type { ButtonProps } from "./button.ts"
|
|
||||||
export { Button } from "./button.ts"
|
|
||||||
|
|
||||||
export type { FeedCardProps } from "./feed-card.ts"
|
|
||||||
export { FeedCard } from "./feed-card.ts"
|
|
||||||
|
|
||||||
export type { SansSerifTextProps } from "./sans-serif-text.ts"
|
|
||||||
export { SansSerifText } from "./sans-serif-text.ts"
|
|
||||||
|
|
||||||
export type { SerifTextProps } from "./serif-text.ts"
|
|
||||||
export { SerifText } from "./serif-text.ts"
|
|
||||||
|
|
||||||
export type { MonospaceTextProps } from "./monospace-text.ts"
|
|
||||||
export { MonospaceText } from "./monospace-text.ts"
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import type { JrxNode } from "@nym.sh/jrx"
|
|
||||||
|
|
||||||
import { jsx } from "@nym.sh/jrx/jsx-runtime"
|
|
||||||
|
|
||||||
export type MonospaceTextProps = {
|
|
||||||
content?: string
|
|
||||||
style?: string
|
|
||||||
children?: JrxNode | JrxNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MonospaceText(props: MonospaceTextProps): JrxNode {
|
|
||||||
return jsx("MonospaceText", props)
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import type { JrxNode } from "@nym.sh/jrx"
|
|
||||||
|
|
||||||
import { jsx } from "@nym.sh/jrx/jsx-runtime"
|
|
||||||
|
|
||||||
export type SansSerifTextProps = {
|
|
||||||
content?: string
|
|
||||||
style?: string
|
|
||||||
children?: JrxNode | JrxNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SansSerifText(props: SansSerifTextProps): JrxNode {
|
|
||||||
return jsx("SansSerifText", props)
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import type { JrxNode } from "@nym.sh/jrx"
|
|
||||||
|
|
||||||
import { jsx } from "@nym.sh/jrx/jsx-runtime"
|
|
||||||
|
|
||||||
export type SerifTextProps = {
|
|
||||||
content?: string
|
|
||||||
style?: string
|
|
||||||
children?: JrxNode | JrxNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SerifText(props: SerifTextProps): JrxNode {
|
|
||||||
return jsx("SerifText", props)
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"jsxImportSource": "@nym.sh/jrx"
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,6 @@ import type { FeedItem } from "./feed"
|
|||||||
* const data = await fetchWeather(location)
|
* const data = await fetchWeather(location)
|
||||||
* return [{
|
* return [{
|
||||||
* id: `weather-${Date.now()}`,
|
* id: `weather-${Date.now()}`,
|
||||||
* sourceId: "aelis.weather",
|
|
||||||
* type: this.type,
|
* type: this.type,
|
||||||
* timestamp: context.time,
|
* timestamp: context.time,
|
||||||
* data: { temp: data.temperature },
|
* data: { temp: data.temperature },
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ function createWeatherSource(
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: `weather-${Date.now()}`,
|
id: `weather-${Date.now()}`,
|
||||||
sourceId: "weather",
|
|
||||||
type: "weather",
|
type: "weather",
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: {
|
data: {
|
||||||
@@ -131,7 +130,6 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "alert-storm",
|
id: "alert-storm",
|
||||||
sourceId: "alert",
|
|
||||||
type: "alert",
|
type: "alert",
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: { message: "Storm warning!" },
|
data: { message: "Storm warning!" },
|
||||||
@@ -425,7 +423,6 @@ describe("FeedEngine", () => {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "item-1",
|
id: "item-1",
|
||||||
sourceId: "working",
|
|
||||||
type: "test",
|
type: "test",
|
||||||
priority: 0.5,
|
priority: 0.5,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
@@ -749,7 +746,6 @@ describe("FeedEngine", () => {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "item-1",
|
id: "item-1",
|
||||||
sourceId: "reactive-items",
|
|
||||||
type: "test",
|
type: "test",
|
||||||
priority: 0.5,
|
priority: 0.5,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
@@ -834,7 +830,6 @@ describe("FeedEngine", () => {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: `item-${fetchCount}`,
|
id: `item-${fetchCount}`,
|
||||||
sourceId: "counter",
|
|
||||||
type: "test",
|
type: "test",
|
||||||
priority: 0.5,
|
priority: 0.5,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
@@ -900,7 +895,6 @@ describe("FeedEngine", () => {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: `weather-${Date.now()}`,
|
id: `weather-${Date.now()}`,
|
||||||
sourceId: "weather",
|
|
||||||
type: "weather",
|
type: "weather",
|
||||||
priority: 0.5,
|
priority: 0.5,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
|
|||||||
@@ -29,17 +29,11 @@ type WeatherItem = FeedItem<"weather", { temp: number }>
|
|||||||
type CalendarItem = FeedItem<"calendar", { title: string }>
|
type CalendarItem = FeedItem<"calendar", { title: string }>
|
||||||
|
|
||||||
function weatherItem(id: string, temp: number): WeatherItem {
|
function weatherItem(id: string, temp: number): WeatherItem {
|
||||||
return { id, sourceId: "aelis.weather", type: "weather", timestamp: new Date(), data: { temp } }
|
return { id, type: "weather", timestamp: new Date(), data: { temp } }
|
||||||
}
|
}
|
||||||
|
|
||||||
function calendarItem(id: string, title: string): CalendarItem {
|
function calendarItem(id: string, title: string): CalendarItem {
|
||||||
return {
|
return { id, type: "calendar", timestamp: new Date(), data: { title } }
|
||||||
id,
|
|
||||||
sourceId: "aelis.calendar",
|
|
||||||
type: "calendar",
|
|
||||||
timestamp: new Date(),
|
|
||||||
data: { title },
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ function createWeatherSource(
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: `weather-${Date.now()}`,
|
id: `weather-${Date.now()}`,
|
||||||
sourceId: "weather",
|
|
||||||
type: "weather",
|
type: "weather",
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: {
|
data: {
|
||||||
@@ -130,7 +129,6 @@ function createAlertSource(): FeedSource<AlertFeedItem> {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "alert-storm",
|
id: "alert-storm",
|
||||||
sourceId: "alert",
|
|
||||||
type: "alert",
|
type: "alert",
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: { message: "Storm warning!" },
|
data: { message: "Storm warning!" },
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ describe("FeedItem slots", () => {
|
|||||||
test("FeedItem without slots is valid", () => {
|
test("FeedItem without slots is valid", () => {
|
||||||
const item: FeedItem<"test", { value: number }> = {
|
const item: FeedItem<"test", { value: number }> = {
|
||||||
id: "test-1",
|
id: "test-1",
|
||||||
sourceId: "test-source",
|
|
||||||
type: "test",
|
type: "test",
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: { value: 42 },
|
data: { value: 42 },
|
||||||
@@ -18,7 +17,6 @@ describe("FeedItem slots", () => {
|
|||||||
test("FeedItem with unfilled slots", () => {
|
test("FeedItem with unfilled slots", () => {
|
||||||
const item: FeedItem<"weather", { temp: number }> = {
|
const item: FeedItem<"weather", { temp: number }> = {
|
||||||
id: "weather-1",
|
id: "weather-1",
|
||||||
sourceId: "aelis.weather",
|
|
||||||
type: "weather",
|
type: "weather",
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: { temp: 18 },
|
data: { temp: 18 },
|
||||||
@@ -43,7 +41,6 @@ describe("FeedItem slots", () => {
|
|||||||
test("FeedItem with filled slots", () => {
|
test("FeedItem with filled slots", () => {
|
||||||
const item: FeedItem<"weather", { temp: number }> = {
|
const item: FeedItem<"weather", { temp: number }> = {
|
||||||
id: "weather-1",
|
id: "weather-1",
|
||||||
sourceId: "aelis.weather",
|
|
||||||
type: "weather",
|
type: "weather",
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: { temp: 18 },
|
data: { temp: 18 },
|
||||||
@@ -78,7 +75,6 @@ describe("FeedItem slots", () => {
|
|||||||
test("FeedItem with empty slots record", () => {
|
test("FeedItem with empty slots record", () => {
|
||||||
const item: FeedItem<"test", { value: number }> = {
|
const item: FeedItem<"test", { value: number }> = {
|
||||||
id: "test-1",
|
id: "test-1",
|
||||||
sourceId: "test-source",
|
|
||||||
type: "test",
|
type: "test",
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: { value: 1 },
|
data: { value: 1 },
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export interface Slot {
|
|||||||
*
|
*
|
||||||
* const item: WeatherItem = {
|
* const item: WeatherItem = {
|
||||||
* id: "weather-123",
|
* id: "weather-123",
|
||||||
* sourceId: "aelis.weatherkit",
|
|
||||||
* type: "weather",
|
* type: "weather",
|
||||||
* timestamp: new Date(),
|
* timestamp: new Date(),
|
||||||
* data: { temp: 18, condition: "cloudy" },
|
* data: { temp: 18, condition: "cloudy" },
|
||||||
@@ -68,8 +67,6 @@ export interface FeedItem<
|
|||||||
> {
|
> {
|
||||||
/** Unique identifier */
|
/** Unique identifier */
|
||||||
id: string
|
id: string
|
||||||
/** ID of the FeedSource that produced this item */
|
|
||||||
sourceId: string
|
|
||||||
/** Item type, matches the data source type */
|
/** Item type, matches the data source type */
|
||||||
type: TType
|
type: TType
|
||||||
/** When this item was generated */
|
/** When this item was generated */
|
||||||
@@ -82,12 +79,6 @@ export interface FeedItem<
|
|||||||
slots?: Record<string, Slot>
|
slots?: Record<string, Slot>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Takes a FeedItem and returns a JRX node tree for rendering. */
|
|
||||||
export type FeedItemRenderer<
|
|
||||||
TType extends string = string,
|
|
||||||
TData extends Record<string, unknown> = Record<string, unknown>,
|
|
||||||
> = (item: FeedItem<TType, TData>) => JrxNode
|
|
||||||
|
|
||||||
/** A FeedItem with a JRX UI tree attached for client-side rendering. */
|
/** A FeedItem with a JRX UI tree attached for client-side rendering. */
|
||||||
export interface RenderedFeedItem<
|
export interface RenderedFeedItem<
|
||||||
TType extends string = string,
|
TType extends string = string,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export type { ActionDefinition } from "./action"
|
|||||||
export { UnknownActionError } from "./action"
|
export { UnknownActionError } from "./action"
|
||||||
|
|
||||||
// Feed
|
// Feed
|
||||||
export type { FeedItem, FeedItemRenderer, FeedItemSignals, RenderedFeedItem, Slot } from "./feed"
|
export type { FeedItem, FeedItemSignals, RenderedFeedItem, Slot } from "./feed"
|
||||||
export { TimeRelevance } from "./feed"
|
export { TimeRelevance } from "./feed"
|
||||||
|
|
||||||
// Feed Source
|
// Feed Source
|
||||||
|
|||||||
@@ -47,8 +47,6 @@ interface LocationData {
|
|||||||
|
|
||||||
const LocationKey: ContextKey<LocationData> = contextKey("aelis.location", "location")
|
const LocationKey: ContextKey<LocationData> = contextKey("aelis.location", "location")
|
||||||
|
|
||||||
const SOURCE_ID = "aelis.weather"
|
|
||||||
|
|
||||||
export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> {
|
export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> {
|
||||||
private readonly DEFAULT_HOURLY_LIMIT = 12
|
private readonly DEFAULT_HOURLY_LIMIT = 12
|
||||||
private readonly DEFAULT_DAILY_LIMIT = 7
|
private readonly DEFAULT_DAILY_LIMIT = 7
|
||||||
@@ -238,7 +236,6 @@ function createCurrentWeatherFeedItem(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-current-${timestamp.getTime()}`,
|
id: `weather-current-${timestamp.getTime()}`,
|
||||||
sourceId: SOURCE_ID,
|
|
||||||
type: WeatherFeedItemType.Current,
|
type: WeatherFeedItemType.Current,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
@@ -273,7 +270,6 @@ function createHourlyWeatherFeedItem(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
||||||
sourceId: SOURCE_ID,
|
|
||||||
type: WeatherFeedItemType.Hourly,
|
type: WeatherFeedItemType.Hourly,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
@@ -308,7 +304,6 @@ function createDailyWeatherFeedItem(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
||||||
sourceId: SOURCE_ID,
|
|
||||||
type: WeatherFeedItemType.Daily,
|
type: WeatherFeedItemType.Daily,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
@@ -336,7 +331,6 @@ function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): Weath
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-alert-${alert.id}`,
|
id: `weather-alert-${alert.id}`,
|
||||||
sourceId: SOURCE_ID,
|
|
||||||
type: WeatherFeedItemType.Alert,
|
type: WeatherFeedItemType.Alert,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ function saturday(hour: number, minute = 0): Date {
|
|||||||
function weatherCurrent(id = "w-current"): FeedItem {
|
function weatherCurrent(id = "w-current"): FeedItem {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
sourceId: "aelis.weather",
|
|
||||||
type: WeatherFeedItemType.Current,
|
type: WeatherFeedItemType.Current,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: { temperature: 18, precipitationIntensity: 0 },
|
data: { temperature: 18, precipitationIntensity: 0 },
|
||||||
@@ -50,7 +49,6 @@ function weatherCurrent(id = "w-current"): FeedItem {
|
|||||||
function weatherCurrentRainy(id = "w-current-rain"): FeedItem {
|
function weatherCurrentRainy(id = "w-current-rain"): FeedItem {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
sourceId: "aelis.weather",
|
|
||||||
type: WeatherFeedItemType.Current,
|
type: WeatherFeedItemType.Current,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: { temperature: 12, precipitationIntensity: 2.5 },
|
data: { temperature: 12, precipitationIntensity: 2.5 },
|
||||||
@@ -60,7 +58,6 @@ function weatherCurrentRainy(id = "w-current-rain"): FeedItem {
|
|||||||
function weatherCurrentExtreme(id = "w-current-extreme"): FeedItem {
|
function weatherCurrentExtreme(id = "w-current-extreme"): FeedItem {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
sourceId: "aelis.weather",
|
|
||||||
type: WeatherFeedItemType.Current,
|
type: WeatherFeedItemType.Current,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: { temperature: -5, precipitationIntensity: 0 },
|
data: { temperature: -5, precipitationIntensity: 0 },
|
||||||
@@ -70,7 +67,6 @@ function weatherCurrentExtreme(id = "w-current-extreme"): FeedItem {
|
|||||||
function weatherHourly(id = "w-hourly"): FeedItem {
|
function weatherHourly(id = "w-hourly"): FeedItem {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
sourceId: "aelis.weather",
|
|
||||||
type: WeatherFeedItemType.Hourly,
|
type: WeatherFeedItemType.Hourly,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: { forecastTime: new Date(), temperature: 20 },
|
data: { forecastTime: new Date(), temperature: 20 },
|
||||||
@@ -80,7 +76,6 @@ function weatherHourly(id = "w-hourly"): FeedItem {
|
|||||||
function weatherDaily(id = "w-daily"): FeedItem {
|
function weatherDaily(id = "w-daily"): FeedItem {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
sourceId: "aelis.weather",
|
|
||||||
type: WeatherFeedItemType.Daily,
|
type: WeatherFeedItemType.Daily,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: { forecastDate: new Date() },
|
data: { forecastDate: new Date() },
|
||||||
@@ -90,7 +85,6 @@ function weatherDaily(id = "w-daily"): FeedItem {
|
|||||||
function weatherAlert(id = "w-alert", urgency = 0.9): FeedItem {
|
function weatherAlert(id = "w-alert", urgency = 0.9): FeedItem {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
sourceId: "aelis.weather",
|
|
||||||
type: WeatherFeedItemType.Alert,
|
type: WeatherFeedItemType.Alert,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: { severity: "extreme" },
|
data: { severity: "extreme" },
|
||||||
@@ -105,7 +99,6 @@ function calendarEvent(
|
|||||||
): FeedItem {
|
): FeedItem {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
sourceId: "aelis.google-calendar",
|
|
||||||
type: CalendarFeedItemType.Event,
|
type: CalendarFeedItemType.Event,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: {
|
data: {
|
||||||
@@ -127,7 +120,6 @@ function calendarEvent(
|
|||||||
function calendarAllDay(id: string): FeedItem {
|
function calendarAllDay(id: string): FeedItem {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
sourceId: "aelis.google-calendar",
|
|
||||||
type: CalendarFeedItemType.AllDay,
|
type: CalendarFeedItemType.AllDay,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: {
|
data: {
|
||||||
@@ -153,7 +145,6 @@ function caldavEvent(
|
|||||||
): FeedItem {
|
): FeedItem {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
sourceId: "aelis.caldav",
|
|
||||||
type: CalDavFeedItemType.Event,
|
type: CalDavFeedItemType.Event,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: {
|
data: {
|
||||||
@@ -179,7 +170,6 @@ function caldavEvent(
|
|||||||
function tflAlert(id = "tfl-1", urgency = 0.8): FeedItem {
|
function tflAlert(id = "tfl-1", urgency = 0.8): FeedItem {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
sourceId: "aelis.tfl",
|
|
||||||
type: TflFeedItemType.Alert,
|
type: TflFeedItemType.Alert,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: {
|
data: {
|
||||||
@@ -195,7 +185,6 @@ function tflAlert(id = "tfl-1", urgency = 0.8): FeedItem {
|
|||||||
function unknownItem(id = "unknown-1"): FeedItem {
|
function unknownItem(id = "unknown-1"): FeedItem {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
sourceId: "unknown",
|
|
||||||
type: "some-future-type",
|
type: "some-future-type",
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
data: { foo: "bar" },
|
data: { foo: "bar" },
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
"test:live": "bun run scripts/test-live.ts"
|
"test:live": "bun run scripts/test-live.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aelis/components": "workspace:*",
|
|
||||||
"@aelis/core": "workspace:*",
|
"@aelis/core": "workspace:*",
|
||||||
"ical.js": "^2.1.0",
|
"ical.js": "^2.1.0",
|
||||||
"tsdav": "^2.1.7"
|
"tsdav": "^2.1.7"
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export class CalDavSource implements FeedSource<CalDavFeedItem> {
|
|||||||
async fetchItems(context: Context): Promise<CalDavFeedItem[]> {
|
async fetchItems(context: Context): Promise<CalDavFeedItem[]> {
|
||||||
const now = context.time
|
const now = context.time
|
||||||
const events = await this.fetchEvents(context)
|
const events = await this.fetchEvents(context)
|
||||||
return events.map((event) => createFeedItem(event, now, this.id, this.timeZone))
|
return events.map((event) => createFeedItem(event, now, this.timeZone))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchEvents(context: Context): Promise<CalDavEventData[]> {
|
private fetchEvents(context: Context): Promise<CalDavEventData[]> {
|
||||||
@@ -351,15 +351,9 @@ function createEventSlots(): Record<string, Slot> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFeedItem(
|
function createFeedItem(event: CalDavEventData, now: Date, timeZone?: string): CalDavFeedItem {
|
||||||
event: CalDavEventData,
|
|
||||||
now: Date,
|
|
||||||
sourceId: string,
|
|
||||||
timeZone?: string,
|
|
||||||
): CalDavFeedItem {
|
|
||||||
return {
|
return {
|
||||||
id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
|
id: `caldav-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
|
||||||
sourceId,
|
|
||||||
type: CalDavFeedItemType.Event,
|
type: CalDavFeedItemType.Event,
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
data: event,
|
data: event,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
export { CalDavCalendarKey, type CalendarContext } from "./calendar-context.ts"
|
||||||
export { CalDavSource, type CalDavSourceOptions } from "./caldav-source.ts"
|
export { CalDavSource, type CalDavSourceOptions } from "./caldav-source.ts"
|
||||||
export { parseICalEvents, type ICalTimeRange } from "./ical-parser.ts"
|
export { parseICalEvents, type ICalTimeRange } from "./ical-parser.ts"
|
||||||
export { renderCalDavFeedItem } from "./renderer.tsx"
|
|
||||||
export {
|
export {
|
||||||
AttendeeRole,
|
AttendeeRole,
|
||||||
AttendeeStatus,
|
AttendeeStatus,
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
/** @jsxImportSource @nym.sh/jrx */
|
|
||||||
|
|
||||||
import type { FeedItemRenderer } from "@aelis/core"
|
|
||||||
|
|
||||||
import { FeedCard, SansSerifText, SerifText } from "@aelis/components"
|
|
||||||
|
|
||||||
import type { CalDavEventData } from "./types.ts"
|
|
||||||
|
|
||||||
import { CalDavEventStatus } from "./types.ts"
|
|
||||||
|
|
||||||
function formatTime(date: Date): string {
|
|
||||||
const hours = date.getHours()
|
|
||||||
const minutes = date.getMinutes()
|
|
||||||
const period = hours >= 12 ? "PM" : "AM"
|
|
||||||
const h = hours % 12 || 12
|
|
||||||
const m = minutes.toString().padStart(2, "0")
|
|
||||||
return `${h}:${m} ${period}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimeRange(data: CalDavEventData): string {
|
|
||||||
if (data.isAllDay) {
|
|
||||||
return "All day"
|
|
||||||
}
|
|
||||||
return `${formatTime(data.startDate)} – ${formatTime(data.endDate)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatStatus(status: CalDavEventData["status"]): string | null {
|
|
||||||
if (status === CalDavEventStatus.Cancelled) return "Cancelled"
|
|
||||||
if (status === CalDavEventStatus.Tentative) return "Tentative"
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export const renderCalDavFeedItem: FeedItemRenderer<"caldav-event", CalDavEventData> = (item) => {
|
|
||||||
const { data, slots } = item
|
|
||||||
const statusLabel = formatStatus(data.status)
|
|
||||||
const attendeeCount = data.attendees.length
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FeedCard>
|
|
||||||
{statusLabel ? <SansSerifText content={statusLabel} style="text-xs uppercase" /> : null}
|
|
||||||
|
|
||||||
<SerifText content={data.title} style="text-lg" />
|
|
||||||
|
|
||||||
<SansSerifText content={formatTimeRange(data)} style="text-sm" />
|
|
||||||
|
|
||||||
{data.calendarName ? (
|
|
||||||
<SansSerifText content={data.calendarName} style="text-sm text-secondary" />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{data.location ? (
|
|
||||||
<SansSerifText content={data.location} style="text-sm text-secondary" />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{attendeeCount > 0 ? (
|
|
||||||
<SansSerifText
|
|
||||||
content={`${attendeeCount} attendee${attendeeCount === 1 ? "" : "s"}`}
|
|
||||||
style="text-sm text-secondary"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{slots?.insight?.content ? (
|
|
||||||
<SansSerifText content={slots.insight.content} style="text-sm" />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{slots?.preparation?.content ? (
|
|
||||||
<SansSerifText content={slots.preparation.content} style="text-sm" />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{slots?.crossSource?.content ? (
|
|
||||||
<SansSerifText content={slots.crossSource.content} style="text-sm" />
|
|
||||||
) : null}
|
|
||||||
</FeedCard>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"jsxImportSource": "@nym.sh/jrx"
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -113,7 +113,7 @@ export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
|
|||||||
const now = context.time.getTime()
|
const now = context.time.getTime()
|
||||||
const lookaheadMs = this.lookaheadHours * 60 * 60 * 1000
|
const lookaheadMs = this.lookaheadHours * 60 * 60 * 1000
|
||||||
|
|
||||||
return events.map((event) => createFeedItem(event, now, lookaheadMs, this.id))
|
return events.map((event) => createFeedItem(event, now, lookaheadMs))
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveCalendarIds(): Promise<string[]> {
|
private async resolveCalendarIds(): Promise<string[]> {
|
||||||
@@ -208,13 +208,11 @@ function createFeedItem(
|
|||||||
event: CalendarEventData,
|
event: CalendarEventData,
|
||||||
nowMs: number,
|
nowMs: number,
|
||||||
lookaheadMs: number,
|
lookaheadMs: number,
|
||||||
sourceId: string,
|
|
||||||
): CalendarFeedItem {
|
): CalendarFeedItem {
|
||||||
const itemType = event.isAllDay ? CalendarFeedItemType.AllDay : CalendarFeedItemType.Event
|
const itemType = event.isAllDay ? CalendarFeedItemType.AllDay : CalendarFeedItemType.Event
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `calendar-${event.calendarId}-${event.eventId}`,
|
id: `calendar-${event.calendarId}-${event.eventId}`,
|
||||||
sourceId,
|
|
||||||
type: itemType,
|
type: itemType,
|
||||||
timestamp: new Date(nowMs),
|
timestamp: new Date(nowMs),
|
||||||
data: event,
|
data: event,
|
||||||
|
|||||||
@@ -10,12 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@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"
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@json-render/core": "*",
|
|
||||||
"@nym.sh/jrx": "*"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,3 @@ export {
|
|||||||
type TflLineStatus,
|
type TflLineStatus,
|
||||||
type TflSourceOptions,
|
type TflSourceOptions,
|
||||||
} from "./types.ts"
|
} from "./types.ts"
|
||||||
export { renderTflAlert } from "./renderer.tsx"
|
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
/** @jsxImportSource @nym.sh/jrx */
|
|
||||||
import { render } from "@nym.sh/jrx"
|
|
||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import type { TflAlertFeedItem } from "./types.ts"
|
|
||||||
|
|
||||||
import { renderTflAlert } from "./renderer.tsx"
|
|
||||||
|
|
||||||
function makeItem(overrides: Partial<TflAlertFeedItem["data"]> = {}): TflAlertFeedItem {
|
|
||||||
return {
|
|
||||||
id: "tfl-alert-northern-minor-delays",
|
|
||||||
type: "tfl-alert",
|
|
||||||
timestamp: new Date("2026-01-15T12:00:00Z"),
|
|
||||||
data: {
|
|
||||||
line: "northern",
|
|
||||||
lineName: "Northern",
|
|
||||||
severity: "minor-delays",
|
|
||||||
description: "Minor delays due to signal failure",
|
|
||||||
closestStationDistance: null,
|
|
||||||
...overrides,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("renderTflAlert", () => {
|
|
||||||
test("renders a FeedCard with title and description", () => {
|
|
||||||
const node = renderTflAlert(makeItem())
|
|
||||||
const spec = render(node)
|
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
|
||||||
expect(root.type).toBe("FeedCard")
|
|
||||||
expect(root.children!.length).toBeGreaterThanOrEqual(2)
|
|
||||||
|
|
||||||
const title = spec.elements[root.children![0]!]!
|
|
||||||
expect(title.type).toBe("SansSerifText")
|
|
||||||
expect(title.props.content).toBe("Northern · Minor delays")
|
|
||||||
|
|
||||||
const body = spec.elements[root.children![1]!]!
|
|
||||||
expect(body.type).toBe("SansSerifText")
|
|
||||||
expect(body.props.content).toBe("Minor delays due to signal failure")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("shows nearest station distance when available", () => {
|
|
||||||
const node = renderTflAlert(makeItem({ closestStationDistance: 0.35 }))
|
|
||||||
const spec = render(node)
|
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
|
||||||
expect(root.children).toHaveLength(3)
|
|
||||||
|
|
||||||
const caption = spec.elements[root.children![2]!]!
|
|
||||||
expect(caption.type).toBe("SansSerifText")
|
|
||||||
expect(caption.props.content).toBe("Nearest station: 350m away")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("formats distance in km when >= 1km", () => {
|
|
||||||
const node = renderTflAlert(makeItem({ closestStationDistance: 2.456 }))
|
|
||||||
const spec = render(node)
|
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
|
||||||
const caption = spec.elements[root.children![2]!]!
|
|
||||||
expect(caption.props.content).toBe("Nearest station: 2.5km away")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("formats near-1km boundary as km not meters", () => {
|
|
||||||
const node = renderTflAlert(makeItem({ closestStationDistance: 0.9999 }))
|
|
||||||
const spec = render(node)
|
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
|
||||||
const caption = spec.elements[root.children![2]!]!
|
|
||||||
expect(caption.props.content).toBe("Nearest station: 1.0km away")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("omits station distance when null", () => {
|
|
||||||
const node = renderTflAlert(makeItem({ closestStationDistance: null }))
|
|
||||||
const spec = render(node)
|
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
|
||||||
// Title + body only, no caption (empty fragment doesn't produce a child)
|
|
||||||
const children = root.children!.filter((key) => {
|
|
||||||
const el = spec.elements[key]
|
|
||||||
return el && el.type !== "Fragment"
|
|
||||||
})
|
|
||||||
expect(children).toHaveLength(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("renders closure severity label", () => {
|
|
||||||
const node = renderTflAlert(makeItem({ severity: "closure", lineName: "Central" }))
|
|
||||||
const spec = render(node)
|
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
|
||||||
const title = spec.elements[root.children![0]!]!
|
|
||||||
expect(title.props.content).toBe("Central · Closed")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("renders major delays severity label", () => {
|
|
||||||
const node = renderTflAlert(makeItem({ severity: "major-delays", lineName: "Jubilee" }))
|
|
||||||
const spec = render(node)
|
|
||||||
|
|
||||||
const root = spec.elements[spec.root]!
|
|
||||||
const title = spec.elements[root.children![0]!]!
|
|
||||||
expect(title.props.content).toBe("Jubilee · Major delays")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
/** @jsxImportSource @nym.sh/jrx */
|
|
||||||
import type { FeedItemRenderer } from "@aelis/core"
|
|
||||||
|
|
||||||
import { FeedCard, SansSerifText } from "@aelis/components"
|
|
||||||
|
|
||||||
import type { TflAlertData } from "./types.ts"
|
|
||||||
|
|
||||||
import { TflAlertSeverity } from "./types.ts"
|
|
||||||
|
|
||||||
const SEVERITY_LABEL: Record<TflAlertSeverity, string> = {
|
|
||||||
[TflAlertSeverity.Closure]: "Closed",
|
|
||||||
[TflAlertSeverity.MajorDelays]: "Major delays",
|
|
||||||
[TflAlertSeverity.MinorDelays]: "Minor delays",
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDistance(km: number): string {
|
|
||||||
const meters = Math.round(km * 1000)
|
|
||||||
if (meters < 1000) {
|
|
||||||
return `${meters}m away`
|
|
||||||
}
|
|
||||||
return `${(meters / 1000).toFixed(1)}km away`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const renderTflAlert: FeedItemRenderer<"tfl-alert", TflAlertData> = (item) => {
|
|
||||||
const { lineName, severity, description, closestStationDistance } = item.data
|
|
||||||
const severityLabel = SEVERITY_LABEL[severity]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FeedCard>
|
|
||||||
<SansSerifText content={`${lineName} · ${severityLabel}`} style="text-base font-semibold" />
|
|
||||||
<SansSerifText content={description} style="text-sm" />
|
|
||||||
{closestStationDistance !== null ? (
|
|
||||||
<SansSerifText
|
|
||||||
content={`Nearest station: ${formatDistance(closestStationDistance)}`}
|
|
||||||
style="text-xs text-stone-500"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</FeedCard>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -151,7 +151,6 @@ export class TflSource implements FeedSource<TflAlertFeedItem> {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `tfl-alert-${status.lineId}-${status.severity}`,
|
id: `tfl-alert-${status.lineId}-${status.severity}`,
|
||||||
sourceId: this.id,
|
|
||||||
type: TflFeedItemType.Alert,
|
type: TflFeedItemType.Alert,
|
||||||
timestamp: context.time,
|
timestamp: context.time,
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"jsxImportSource": "@nym.sh/jrx"
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -167,9 +167,7 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
|
|||||||
const items: WeatherFeedItem[] = []
|
const items: WeatherFeedItem[] = []
|
||||||
|
|
||||||
if (response.currentWeather) {
|
if (response.currentWeather) {
|
||||||
items.push(
|
items.push(createCurrentWeatherFeedItem(response.currentWeather, timestamp, this.units))
|
||||||
createCurrentWeatherFeedItem(response.currentWeather, timestamp, this.units, this.id),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.forecastHourly?.hours) {
|
if (response.forecastHourly?.hours) {
|
||||||
@@ -177,7 +175,7 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
|
|||||||
for (let i = 0; i < hours.length; i++) {
|
for (let i = 0; i < hours.length; i++) {
|
||||||
const hour = hours[i]
|
const hour = hours[i]
|
||||||
if (hour) {
|
if (hour) {
|
||||||
items.push(createHourlyWeatherFeedItem(hour, i, timestamp, this.units, this.id))
|
items.push(createHourlyWeatherFeedItem(hour, i, timestamp, this.units))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,14 +185,14 @@ export class WeatherSource implements FeedSource<WeatherFeedItem> {
|
|||||||
for (let i = 0; i < days.length; i++) {
|
for (let i = 0; i < days.length; i++) {
|
||||||
const day = days[i]
|
const day = days[i]
|
||||||
if (day) {
|
if (day) {
|
||||||
items.push(createDailyWeatherFeedItem(day, i, timestamp, this.units, this.id))
|
items.push(createDailyWeatherFeedItem(day, i, timestamp, this.units))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.weatherAlerts?.alerts) {
|
if (response.weatherAlerts?.alerts) {
|
||||||
for (const alert of response.weatherAlerts.alerts) {
|
for (const alert of response.weatherAlerts.alerts) {
|
||||||
items.push(createWeatherAlertFeedItem(alert, timestamp, this.id))
|
items.push(createWeatherAlertFeedItem(alert, timestamp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +284,6 @@ function createCurrentWeatherFeedItem(
|
|||||||
current: CurrentWeather,
|
current: CurrentWeather,
|
||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
units: Units,
|
units: Units,
|
||||||
sourceId: string,
|
|
||||||
): WeatherFeedItem {
|
): WeatherFeedItem {
|
||||||
const signals: FeedItemSignals = {
|
const signals: FeedItemSignals = {
|
||||||
urgency: adjustUrgencyForCondition(BASE_URGENCY.current, current.conditionCode),
|
urgency: adjustUrgencyForCondition(BASE_URGENCY.current, current.conditionCode),
|
||||||
@@ -295,7 +292,6 @@ function createCurrentWeatherFeedItem(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-current-${timestamp.getTime()}`,
|
id: `weather-current-${timestamp.getTime()}`,
|
||||||
sourceId,
|
|
||||||
type: WeatherFeedItemType.Current,
|
type: WeatherFeedItemType.Current,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
@@ -328,7 +324,6 @@ function createHourlyWeatherFeedItem(
|
|||||||
index: number,
|
index: number,
|
||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
units: Units,
|
units: Units,
|
||||||
sourceId: string,
|
|
||||||
): WeatherFeedItem {
|
): WeatherFeedItem {
|
||||||
const signals: FeedItemSignals = {
|
const signals: FeedItemSignals = {
|
||||||
urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode),
|
urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode),
|
||||||
@@ -337,7 +332,6 @@ function createHourlyWeatherFeedItem(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
||||||
sourceId,
|
|
||||||
type: WeatherFeedItemType.Hourly,
|
type: WeatherFeedItemType.Hourly,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
@@ -364,7 +358,6 @@ function createDailyWeatherFeedItem(
|
|||||||
index: number,
|
index: number,
|
||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
units: Units,
|
units: Units,
|
||||||
sourceId: string,
|
|
||||||
): WeatherFeedItem {
|
): WeatherFeedItem {
|
||||||
const signals: FeedItemSignals = {
|
const signals: FeedItemSignals = {
|
||||||
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
|
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
|
||||||
@@ -373,7 +366,6 @@ function createDailyWeatherFeedItem(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
||||||
sourceId,
|
|
||||||
type: WeatherFeedItemType.Daily,
|
type: WeatherFeedItemType.Daily,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
@@ -393,11 +385,7 @@ function createDailyWeatherFeedItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWeatherAlertFeedItem(
|
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherFeedItem {
|
||||||
alert: WeatherAlert,
|
|
||||||
timestamp: Date,
|
|
||||||
sourceId: string,
|
|
||||||
): WeatherFeedItem {
|
|
||||||
const signals: FeedItemSignals = {
|
const signals: FeedItemSignals = {
|
||||||
urgency: adjustUrgencyForAlertSeverity(alert.severity),
|
urgency: adjustUrgencyForAlertSeverity(alert.severity),
|
||||||
timeRelevance: timeRelevanceForAlertSeverity(alert.severity),
|
timeRelevance: timeRelevanceForAlertSeverity(alert.severity),
|
||||||
@@ -405,7 +393,6 @@ function createWeatherAlertFeedItem(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: `weather-alert-${alert.id}`,
|
id: `weather-alert-${alert.id}`,
|
||||||
sourceId,
|
|
||||||
type: WeatherFeedItemType.Alert,
|
type: WeatherFeedItemType.Alert,
|
||||||
timestamp,
|
timestamp,
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
Reference in New Issue
Block a user