mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 09:01:19 +00:00
Compare commits
4 Commits
feat/drizz
...
13de230f05
| Author | SHA1 | Date | |
|---|---|---|---|
|
13de230f05
|
|||
|
64a03b253e
|
|||
|
2b1a50349c
|
|||
|
bb92c9f227
|
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
|
||||||
|
|||||||
@@ -1,19 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
expo:
|
expo:
|
||||||
name: Expo Dev Server
|
name: Expo Dev Server
|
||||||
description: Expo development server for aelis-client
|
description: Expo development server for aris-client
|
||||||
triggeredBy:
|
triggeredBy:
|
||||||
- postDevcontainerStart
|
- postDevcontainerStart
|
||||||
commands:
|
commands:
|
||||||
start: cd apps/aelis-client && ./scripts/run-dev-server.sh
|
start: cd apps/aris-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
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Project
|
## Project
|
||||||
|
|
||||||
AELIS is an AI-powered personal assistant that aggregates data from various sources into a contextual feed. Monorepo with `packages/` (shared libraries) and `apps/` (applications).
|
ARIS is an AI-powered personal assistant that aggregates data from various sources into a contextual feed. Monorepo with `packages/` (shared libraries) and `apps/` (applications).
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# aelis
|
# aris
|
||||||
|
|
||||||
To install dependencies:
|
To install dependencies:
|
||||||
|
|
||||||
@@ -8,14 +8,14 @@ bun install
|
|||||||
|
|
||||||
## Packages
|
## Packages
|
||||||
|
|
||||||
### @aelis/source-tfl
|
### @aris/source-tfl
|
||||||
|
|
||||||
TfL (Transport for London) feed source for tube, overground, and Elizabeth line alerts.
|
TfL (Transport for London) feed source for tube, overground, and Elizabeth line alerts.
|
||||||
|
|
||||||
#### Testing
|
#### Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd packages/aelis-source-tfl
|
cd packages/aris-source-tfl
|
||||||
bun run test
|
bun run test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@aelis/backend",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"main": "src/server.ts",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "bun run --watch src/server.ts",
|
|
||||||
"start": "bun run src/server.ts",
|
|
||||||
"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": {
|
|
||||||
"@aelis/core": "workspace:*",
|
|
||||||
"@aelis/source-caldav": "workspace:*",
|
|
||||||
"@aelis/source-google-calendar": "workspace:*",
|
|
||||||
"@aelis/source-location": "workspace:*",
|
|
||||||
"@aelis/source-tfl": "workspace:*",
|
|
||||||
"@aelis/source-weatherkit": "workspace:*",
|
|
||||||
"@openrouter/sdk": "^0.9.11",
|
|
||||||
"arktype": "^2.1.29",
|
|
||||||
"better-auth": "^1",
|
|
||||||
"drizzle-orm": "^0.45.1",
|
|
||||||
"hono": "^4"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"drizzle-kit": "^0.31.9"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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 * as schema from "../db/schema.ts"
|
|
||||||
|
|
||||||
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: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
plugins: [admin()],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Auth = ReturnType<typeof createAuth>
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import type { Context, MiddlewareHandler, Next } from "hono"
|
|
||||||
|
|
||||||
import type { Auth } from "./index.ts"
|
|
||||||
import type { AuthSession, AuthUser } from "./session.ts"
|
|
||||||
|
|
||||||
export interface SessionVariables {
|
|
||||||
user: AuthUser | null
|
|
||||||
session: AuthSession | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AuthSessionEnv = { Variables: SessionVariables }
|
|
||||||
|
|
||||||
export type AuthSessionMiddleware = MiddlewareHandler<AuthSessionEnv>
|
|
||||||
|
|
||||||
declare module "hono" {
|
|
||||||
interface ContextVariableMap extends SessionVariables {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a middleware that attaches session and user to the context.
|
|
||||||
* Does not reject unauthenticated requests - use createRequireSession for that.
|
|
||||||
*/
|
|
||||||
export function createSessionMiddleware(auth: Auth): AuthSessionMiddleware {
|
|
||||||
return async (c: Context, next: Next): Promise<void> => {
|
|
||||||
const session = await auth.api.getSession({ headers: c.req.raw.headers })
|
|
||||||
|
|
||||||
if (session) {
|
|
||||||
c.set("user", session.user)
|
|
||||||
c.set("session", session.session)
|
|
||||||
} else {
|
|
||||||
c.set("user", null)
|
|
||||||
c.set("session", null)
|
|
||||||
}
|
|
||||||
|
|
||||||
await next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a middleware that requires a valid session. Returns 401 if not authenticated.
|
|
||||||
*/
|
|
||||||
export function createRequireSession(auth: Auth): AuthSessionMiddleware {
|
|
||||||
return async (c: Context, next: Next): Promise<Response | void> => {
|
|
||||||
const session = await auth.api.getSession({ headers: c.req.raw.headers })
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.set("user", session.user)
|
|
||||||
c.set("session", session.session)
|
|
||||||
await next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a function to get session from headers. Useful for WebSocket upgrade validation.
|
|
||||||
*/
|
|
||||||
export function createGetSessionFromHeaders(auth: Auth) {
|
|
||||||
return async (headers: Headers): Promise<{ user: AuthUser; session: AuthSession } | null> => {
|
|
||||||
const session = await auth.api.getSession({ headers })
|
|
||||||
return session
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dev/test middleware that injects a fake user and session.
|
|
||||||
* Pass userId to simulate an authenticated request, or omit to get 401.
|
|
||||||
*/
|
|
||||||
export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddleware {
|
|
||||||
return async (c: Context, next: Next): Promise<Response | void> => {
|
|
||||||
if (!userId) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401)
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date()
|
|
||||||
const expiresAt = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
|
|
||||||
|
|
||||||
const user: AuthUser = {
|
|
||||||
id: "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn",
|
|
||||||
name: "Dev User",
|
|
||||||
email: "dev@aelis.local",
|
|
||||||
emailVerified: true,
|
|
||||||
image: null,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
role: "admin",
|
|
||||||
banned: false,
|
|
||||||
banReason: null,
|
|
||||||
banExpires: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const session: AuthSession = {
|
|
||||||
id: "Wt3FvBpXaQrMhD8sKjE6LcYn0gUz5iRo",
|
|
||||||
userId: "k7Gx2mPqRvNwYs9TdLfA4bHcJeUo1iZn",
|
|
||||||
token: "Vb9CxNfRm2KwQs7TjPeA5dLhYg0UoZi4",
|
|
||||||
expiresAt,
|
|
||||||
ipAddress: "127.0.0.1",
|
|
||||||
userAgent: "aelis-dev",
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
c.set("user", user)
|
|
||||||
c.set("session", session)
|
|
||||||
|
|
||||||
await next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import type { Auth } from "./index.ts"
|
|
||||||
|
|
||||||
export type AuthUser = Auth["$Infer"]["Session"]["user"]
|
|
||||||
export type AuthSession = Auth["$Infer"]["Session"]["session"]
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,9 +0,0 @@
|
|||||||
import type { FeedSource } from "@aelis/core"
|
|
||||||
|
|
||||||
export interface FeedSourceProvider {
|
|
||||||
feedSourceForUser(userId: string): Promise<FeedSource>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FeedSourceProviderFn = (userId: string) => Promise<FeedSource>
|
|
||||||
|
|
||||||
export type FeedSourceProviderInput = FeedSourceProvider | FeedSourceProviderFn
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
import { LocationSource } from "@aelis/source-location"
|
|
||||||
import { WeatherSource } from "@aelis/source-weatherkit"
|
|
||||||
import { describe, expect, mock, spyOn, test } from "bun:test"
|
|
||||||
|
|
||||||
import { UserSessionManager } from "./user-session-manager.ts"
|
|
||||||
|
|
||||||
const mockWeatherProvider = async () =>
|
|
||||||
new WeatherSource({ client: { fetch: async () => ({}) as never } })
|
|
||||||
|
|
||||||
describe("UserSessionManager", () => {
|
|
||||||
test("getOrCreate creates session on first call", async () => {
|
|
||||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
|
||||||
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
|
||||||
|
|
||||||
expect(session).toBeDefined()
|
|
||||||
expect(session.engine).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("getOrCreate returns same session for same user", async () => {
|
|
||||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
|
||||||
|
|
||||||
const session1 = await manager.getOrCreate("user-1")
|
|
||||||
const session2 = await manager.getOrCreate("user-1")
|
|
||||||
|
|
||||||
expect(session1).toBe(session2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("getOrCreate returns different sessions for different users", async () => {
|
|
||||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
|
||||||
|
|
||||||
const session1 = await manager.getOrCreate("user-1")
|
|
||||||
const session2 = await manager.getOrCreate("user-2")
|
|
||||||
|
|
||||||
expect(session1).not.toBe(session2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("each user gets independent source instances", async () => {
|
|
||||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
|
||||||
|
|
||||||
const session1 = await manager.getOrCreate("user-1")
|
|
||||||
const session2 = await manager.getOrCreate("user-2")
|
|
||||||
|
|
||||||
const source1 = session1.getSource<LocationSource>("aelis.location")
|
|
||||||
const source2 = session2.getSource<LocationSource>("aelis.location")
|
|
||||||
|
|
||||||
expect(source1).not.toBe(source2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("remove destroys session and allows re-creation", async () => {
|
|
||||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
|
||||||
|
|
||||||
const session1 = await manager.getOrCreate("user-1")
|
|
||||||
manager.remove("user-1")
|
|
||||||
const session2 = await manager.getOrCreate("user-1")
|
|
||||||
|
|
||||||
expect(session1).not.toBe(session2)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("remove is no-op for unknown user", () => {
|
|
||||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
|
||||||
|
|
||||||
expect(() => manager.remove("unknown")).not.toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("accepts function providers", async () => {
|
|
||||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
|
||||||
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
|
||||||
const result = await session.engine.refresh()
|
|
||||||
|
|
||||||
expect(result.errors).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("accepts object providers", async () => {
|
|
||||||
const manager = new UserSessionManager({
|
|
||||||
providers: [async () => new LocationSource(), mockWeatherProvider],
|
|
||||||
})
|
|
||||||
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
|
||||||
|
|
||||||
expect(session.getSource("aelis.weather")).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("accepts mixed providers", async () => {
|
|
||||||
const manager = new UserSessionManager({
|
|
||||||
providers: [async () => new LocationSource(), mockWeatherProvider],
|
|
||||||
})
|
|
||||||
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
|
||||||
|
|
||||||
expect(session.getSource("aelis.location")).toBeDefined()
|
|
||||||
expect(session.getSource("aelis.weather")).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("refresh returns feed result through session", async () => {
|
|
||||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
|
||||||
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
|
||||||
const result = await session.engine.refresh()
|
|
||||||
|
|
||||||
expect(result).toHaveProperty("context")
|
|
||||||
expect(result).toHaveProperty("items")
|
|
||||||
expect(result).toHaveProperty("errors")
|
|
||||||
expect(result.context.time).toBeInstanceOf(Date)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("location update via executeAction works", async () => {
|
|
||||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
|
||||||
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
|
||||||
await session.engine.executeAction("aelis.location", "update-location", {
|
|
||||||
lat: 51.5074,
|
|
||||||
lng: -0.1278,
|
|
||||||
accuracy: 10,
|
|
||||||
timestamp: new Date(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const source = session.getSource<LocationSource>("aelis.location")
|
|
||||||
expect(source?.lastLocation?.lat).toBe(51.5074)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("subscribe receives updates after location push", async () => {
|
|
||||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
|
||||||
const callback = mock()
|
|
||||||
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
|
||||||
session.engine.subscribe(callback)
|
|
||||||
|
|
||||||
await session.engine.executeAction("aelis.location", "update-location", {
|
|
||||||
lat: 51.5074,
|
|
||||||
lng: -0.1278,
|
|
||||||
accuracy: 10,
|
|
||||||
timestamp: new Date(),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wait for async update propagation
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
||||||
|
|
||||||
expect(callback).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("remove stops reactive updates", async () => {
|
|
||||||
const manager = new UserSessionManager({ providers: [async () => new LocationSource()] })
|
|
||||||
const callback = mock()
|
|
||||||
|
|
||||||
const session = await manager.getOrCreate("user-1")
|
|
||||||
session.engine.subscribe(callback)
|
|
||||||
|
|
||||||
manager.remove("user-1")
|
|
||||||
|
|
||||||
// Create new session and push location — old callback should not fire
|
|
||||||
const session2 = await manager.getOrCreate("user-1")
|
|
||||||
await session2.engine.executeAction("aelis.location", "update-location", {
|
|
||||||
lat: 51.5074,
|
|
||||||
lng: -0.1278,
|
|
||||||
accuracy: 10,
|
|
||||||
timestamp: new Date(),
|
|
||||||
})
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
||||||
|
|
||||||
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,86 +0,0 @@
|
|||||||
import type { FeedSource } from "@aelis/core"
|
|
||||||
|
|
||||||
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
|
||||||
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
|
|
||||||
|
|
||||||
import { UserSession } from "./user-session.ts"
|
|
||||||
|
|
||||||
export interface UserSessionManagerConfig {
|
|
||||||
providers: FeedSourceProviderInput[]
|
|
||||||
feedEnhancer?: FeedEnhancer | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserSessionManager {
|
|
||||||
private sessions = new Map<string, UserSession>()
|
|
||||||
private pending = new Map<string, Promise<UserSession>>()
|
|
||||||
private readonly providers: FeedSourceProviderInput[]
|
|
||||||
private readonly feedEnhancer: FeedEnhancer | null
|
|
||||||
|
|
||||||
constructor(config: UserSessionManagerConfig) {
|
|
||||||
this.providers = config.providers
|
|
||||||
this.feedEnhancer = config.feedEnhancer ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
async getOrCreate(userId: string): Promise<UserSession> {
|
|
||||||
const existing = this.sessions.get(userId)
|
|
||||||
if (existing) return existing
|
|
||||||
|
|
||||||
const inflight = this.pending.get(userId)
|
|
||||||
if (inflight) return inflight
|
|
||||||
|
|
||||||
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)
|
|
||||||
return session
|
|
||||||
} finally {
|
|
||||||
this.pending.delete(userId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(userId: string): void {
|
|
||||||
const session = this.sessions.get(userId)
|
|
||||||
if (session) {
|
|
||||||
session.destroy()
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
import { TflSource, type ITflApi, type TflLineId } 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 { SourceDisabledError } from "../sources/errors.ts"
|
|
||||||
import { sources } from "../sources/user-sources.ts"
|
|
||||||
|
|
||||||
export type TflSourceProviderOptions =
|
|
||||||
| { db: Database; apiKey: string; client?: never }
|
|
||||||
| { db: Database; apiKey?: never; client: ITflApi }
|
|
||||||
|
|
||||||
const tflConfig = type({
|
|
||||||
"lines?": "string[]",
|
|
||||||
})
|
|
||||||
|
|
||||||
export class TflSourceProvider implements FeedSourceProvider {
|
|
||||||
private readonly db: Database
|
|
||||||
private readonly apiKey: string | undefined
|
|
||||||
private readonly client: ITflApi | undefined
|
|
||||||
|
|
||||||
constructor(options: TflSourceProviderOptions) {
|
|
||||||
this.db = options.db
|
|
||||||
this.apiKey = "apiKey" in options ? options.apiKey : undefined
|
|
||||||
this.client = "client" in options ? options.client : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
async feedSourceForUser(userId: string): Promise<TflSource> {
|
|
||||||
const row = await sources(this.db, userId).find("aelis.tfl")
|
|
||||||
|
|
||||||
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 +0,0 @@
|
|||||||
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 { 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 {
|
|
||||||
private readonly db: Database
|
|
||||||
private readonly credentials: WeatherSourceOptions["credentials"]
|
|
||||||
private readonly client: WeatherSourceOptions["client"]
|
|
||||||
|
|
||||||
constructor(options: WeatherSourceProviderOptions) {
|
|
||||||
this.db = options.db
|
|
||||||
this.credentials = options.credentials
|
|
||||||
this.client = options.client
|
|
||||||
}
|
|
||||||
|
|
||||||
async feedSourceForUser(userId: string): Promise<WeatherSource> {
|
|
||||||
const row = await sources(this.db, userId).find("aelis.weather")
|
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,65 +0,0 @@
|
|||||||
import "react-native-reanimated"
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
|
||||||
import { Stack } from "expo-router"
|
|
||||||
import { StatusBar } from "expo-status-bar"
|
|
||||||
import React from "react"
|
|
||||||
import { useColorScheme } from "react-native"
|
|
||||||
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() {
|
|
||||||
useDeviceContext(tw)
|
|
||||||
|
|
||||||
const colorScheme = useColorScheme()
|
|
||||||
const headerBg = colorScheme === "dark" ? "#1c1917" : "#f5f5f4"
|
|
||||||
const headerTint = colorScheme === "dark" ? "#e7e5e4" : "#1c1917"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextProvider>
|
|
||||||
<Stack
|
|
||||||
screenOptions={{
|
|
||||||
headerShown: false,
|
|
||||||
contentStyle: { backgroundColor: headerBg },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack.Screen
|
|
||||||
name="components/index"
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: "Components",
|
|
||||||
headerStyle: { backgroundColor: headerBg },
|
|
||||||
headerTintColor: headerTint,
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="components/[name]"
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: "",
|
|
||||||
headerStyle: { backgroundColor: headerBg },
|
|
||||||
headerTintColor: headerTint,
|
|
||||||
headerShadowVisible: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<StatusBar style="auto" />
|
|
||||||
</ContextProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextProvider({ children }: React.PropsWithChildren) {
|
|
||||||
return (
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<ApiClientContext value={apiClient}>{children}</ApiClientContext>
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { useLocalSearchParams, useNavigation } from "expo-router"
|
|
||||||
import { useEffect } from "react"
|
|
||||||
import { ScrollView, View } from "react-native"
|
|
||||||
import tw from "twrnc"
|
|
||||||
|
|
||||||
import { buttonShowcase } from "@/components/ui/button.showcase"
|
|
||||||
import { feedCardShowcase } from "@/components/ui/feed-card.showcase"
|
|
||||||
import { monospaceTextShowcase } from "@/components/ui/monospace-text.showcase"
|
|
||||||
import { sansSerifTextShowcase } from "@/components/ui/sans-serif-text.showcase"
|
|
||||||
import { serifTextShowcase } from "@/components/ui/serif-text.showcase"
|
|
||||||
import { type Showcase } from "@/components/showcase"
|
|
||||||
import { SansSerifText } from "@/components/ui/sans-serif-text"
|
|
||||||
|
|
||||||
const showcases: Record<string, Showcase> = {
|
|
||||||
button: buttonShowcase,
|
|
||||||
"feed-card": feedCardShowcase,
|
|
||||||
"serif-text": serifTextShowcase,
|
|
||||||
"sans-serif-text": sansSerifTextShowcase,
|
|
||||||
"monospace-text": monospaceTextShowcase,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ComponentDetailScreen() {
|
|
||||||
const { name } = useLocalSearchParams<{ name: string }>()
|
|
||||||
const navigation = useNavigation()
|
|
||||||
const showcase = showcases[name]
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (showcase) {
|
|
||||||
navigation.setOptions({ title: showcase.title })
|
|
||||||
}
|
|
||||||
}, [navigation, showcase])
|
|
||||||
|
|
||||||
if (!showcase) {
|
|
||||||
return (
|
|
||||||
<View style={tw`bg-stone-100 dark:bg-stone-900 flex-1 items-center justify-center`}>
|
|
||||||
<SansSerifText>Component not found</SansSerifText>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ShowcaseComponent = showcase.component
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView style={tw`bg-stone-100 dark:bg-stone-900 flex-1`} contentContainerStyle={tw`px-5 pb-10 pt-4 gap-6`}>
|
|
||||||
<ShowcaseComponent />
|
|
||||||
</ScrollView>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { Link } from "expo-router"
|
|
||||||
import { FlatList, Pressable, View } from "react-native"
|
|
||||||
import tw from "twrnc"
|
|
||||||
|
|
||||||
import { SansSerifText } from "@/components/ui/sans-serif-text"
|
|
||||||
|
|
||||||
const components = [
|
|
||||||
{ name: "button", label: "Button" },
|
|
||||||
{ name: "feed-card", label: "FeedCard" },
|
|
||||||
{ name: "serif-text", label: "SerifText" },
|
|
||||||
{ name: "sans-serif-text", label: "SansSerifText" },
|
|
||||||
{ name: "monospace-text", label: "MonospaceText" },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export default function ComponentsScreen() {
|
|
||||||
return (
|
|
||||||
<View style={tw`flex-1`}>
|
|
||||||
<View style={tw`mx-4 mt-4 rounded-xl border border-stone-200 dark:border-stone-800 overflow-hidden`}>
|
|
||||||
<FlatList
|
|
||||||
data={components}
|
|
||||||
keyExtractor={(item) => item.name}
|
|
||||||
scrollEnabled={false}
|
|
||||||
ItemSeparatorComponent={() => (
|
|
||||||
<View style={tw`border-b border-stone-200 dark:border-stone-800`} />
|
|
||||||
)}
|
|
||||||
renderItem={({ item }) => (
|
|
||||||
<Link href={`/components/${item.name}`} asChild>
|
|
||||||
<Pressable style={tw`px-4 py-3`}>
|
|
||||||
<SansSerifText style={tw`text-base`}>{item.label}</SansSerifText>
|
|
||||||
</Pressable>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { Link } from "expo-router"
|
|
||||||
import { Pressable } from "react-native"
|
|
||||||
import { SafeAreaView } from "react-native-safe-area-context"
|
|
||||||
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"
|
|
||||||
|
|
||||||
export default function HomeScreen() {
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={tw`bg-stone-100 dark:bg-stone-900 flex-1 px-5 pt-6 gap-4`}>
|
|
||||||
<FeedCard>
|
|
||||||
<SerifText style={tw`text-4xl`}>Hello world asdsadsa</SerifText>
|
|
||||||
<SansSerifText style={tw`text-4xl font-bold`}>Hello world</SansSerifText>
|
|
||||||
<MonospaceText style={tw`text-4xl`}>asdjsakljdl</MonospaceText>
|
|
||||||
<Button style={tw`self-start`} label="Test" />
|
|
||||||
</FeedCard>
|
|
||||||
<Link href="/components" asChild>
|
|
||||||
<Pressable>
|
|
||||||
<SansSerifText style={tw`text-teal-600`}>View component library</SansSerifText>
|
|
||||||
</Pressable>
|
|
||||||
</Link>
|
|
||||||
</SafeAreaView>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { View } from "react-native"
|
|
||||||
import tw from "twrnc"
|
|
||||||
|
|
||||||
import { SansSerifText } from "./ui/sans-serif-text"
|
|
||||||
|
|
||||||
export type Showcase = {
|
|
||||||
title: string
|
|
||||||
component: React.ComponentType
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<View style={tw`gap-3`}>
|
|
||||||
<SansSerifText style={tw`text-sm text-stone-500 dark:text-stone-400`}>{title}</SansSerifText>
|
|
||||||
{children}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { View } from "react-native"
|
|
||||||
import tw from "twrnc"
|
|
||||||
|
|
||||||
import { Button } from "./button"
|
|
||||||
import { type Showcase, Section } from "../showcase"
|
|
||||||
|
|
||||||
function ButtonShowcase() {
|
|
||||||
return (
|
|
||||||
<View style={tw`gap-6`}>
|
|
||||||
<Section title="Default">
|
|
||||||
<Button style={tw`self-start`} label="Press me" />
|
|
||||||
</Section>
|
|
||||||
<Section title="Leading icon">
|
|
||||||
<Button
|
|
||||||
style={tw`self-start`}
|
|
||||||
label="Add item"
|
|
||||||
leadingIcon={<Button.Icon name="plus" />}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
<Section title="Trailing icon">
|
|
||||||
<Button
|
|
||||||
style={tw`self-start`}
|
|
||||||
label="Next"
|
|
||||||
trailingIcon={<Button.Icon name="arrow-right" />}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
<Section title="Both icons">
|
|
||||||
<Button
|
|
||||||
style={tw`self-start`}
|
|
||||||
label="Download"
|
|
||||||
leadingIcon={<Button.Icon name="download" />}
|
|
||||||
trailingIcon={<Button.Icon name="chevron-down" />}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const buttonShowcase: Showcase = {
|
|
||||||
title: "Button",
|
|
||||||
component: ButtonShowcase,
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import Feather from "@expo/vector-icons/Feather"
|
|
||||||
import { type PressableProps, Pressable, View } from "react-native"
|
|
||||||
import tw from "twrnc"
|
|
||||||
|
|
||||||
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"> & {
|
|
||||||
label: string
|
|
||||||
leadingIcon?: React.ReactNode
|
|
||||||
trailingIcon?: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Button({ style, label, leadingIcon, trailingIcon, ...props }: ButtonProps) {
|
|
||||||
const hasIcons = leadingIcon != null || trailingIcon != null
|
|
||||||
|
|
||||||
const textElement = <SansSerifText style={tw`text-stone-100 dark:text-stone-200 font-medium`}>{label}</SansSerifText>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable style={[tw`rounded-full bg-teal-600 px-4 py-3 w-fit`, style]} {...props}>
|
|
||||||
{hasIcons ? (
|
|
||||||
<View style={tw`flex-row items-center gap-1.5`}>
|
|
||||||
{leadingIcon}
|
|
||||||
{textElement}
|
|
||||||
{trailingIcon}
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
textElement
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button.Icon = ButtonIcon
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { View } from "react-native"
|
|
||||||
import tw from "twrnc"
|
|
||||||
|
|
||||||
import { Button } from "./button"
|
|
||||||
import { FeedCard } from "./feed-card"
|
|
||||||
import { SansSerifText } from "./sans-serif-text"
|
|
||||||
import { SerifText } from "./serif-text"
|
|
||||||
import { type Showcase, Section } from "../showcase"
|
|
||||||
|
|
||||||
function FeedCardShowcase() {
|
|
||||||
return (
|
|
||||||
<View style={tw`gap-6`}>
|
|
||||||
<Section title="Default">
|
|
||||||
<FeedCard style={tw`p-4`}>
|
|
||||||
<SansSerifText>Card content goes here</SansSerifText>
|
|
||||||
</FeedCard>
|
|
||||||
</Section>
|
|
||||||
<Section title="With mixed content">
|
|
||||||
<FeedCard style={tw`p-4 gap-2`}>
|
|
||||||
<SerifText style={tw`text-xl`}>Title</SerifText>
|
|
||||||
<SansSerifText>Body text inside a feed card.</SansSerifText>
|
|
||||||
<Button style={tw`self-start mt-2`} label="Action" />
|
|
||||||
</FeedCard>
|
|
||||||
</Section>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const feedCardShowcase: Showcase = {
|
|
||||||
title: "FeedCard",
|
|
||||||
component: FeedCardShowcase,
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { View, type ViewProps } from "react-native"
|
|
||||||
import tw from "twrnc"
|
|
||||||
|
|
||||||
export function FeedCard({ style, ...props }: ViewProps) {
|
|
||||||
return <View style={[tw`border border-stone-200 dark:border-stone-800 rounded-lg`, style]} {...props} />
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { View } from "react-native"
|
|
||||||
import tw from "twrnc"
|
|
||||||
|
|
||||||
import { MonospaceText } from "./monospace-text"
|
|
||||||
import { type Showcase, Section } from "../showcase"
|
|
||||||
|
|
||||||
function MonospaceTextShowcase() {
|
|
||||||
return (
|
|
||||||
<View style={tw`gap-6`}>
|
|
||||||
<Section title="Sizes">
|
|
||||||
<View style={tw`gap-2`}>
|
|
||||||
<MonospaceText style={tw`text-sm`}>Small monospace text</MonospaceText>
|
|
||||||
<MonospaceText style={tw`text-base`}>Base monospace text</MonospaceText>
|
|
||||||
<MonospaceText style={tw`text-xl`}>Extra large monospace text</MonospaceText>
|
|
||||||
<MonospaceText style={tw`text-3xl`}>3XL monospace text</MonospaceText>
|
|
||||||
</View>
|
|
||||||
</Section>
|
|
||||||
<Section title="Code-like usage">
|
|
||||||
<View style={tw`bg-stone-200 dark:bg-stone-800 rounded-lg p-3`}>
|
|
||||||
<MonospaceText style={tw`text-sm`}>{"const x = 42;"}</MonospaceText>
|
|
||||||
<MonospaceText style={tw`text-sm`}>{"console.log(x);"}</MonospaceText>
|
|
||||||
</View>
|
|
||||||
</Section>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const monospaceTextShowcase: Showcase = {
|
|
||||||
title: "MonospaceText",
|
|
||||||
component: MonospaceTextShowcase,
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Text, type TextProps } from "react-native"
|
|
||||||
import tw from "twrnc"
|
|
||||||
|
|
||||||
export function MonospaceText({ children, style, ...props }: TextProps) {
|
|
||||||
return (
|
|
||||||
<Text style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Menlo" }, style]} {...props}>
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { View } from "react-native"
|
|
||||||
import tw from "twrnc"
|
|
||||||
|
|
||||||
import { SansSerifText } from "./sans-serif-text"
|
|
||||||
import { type Showcase, Section } from "../showcase"
|
|
||||||
|
|
||||||
function SansSerifTextShowcase() {
|
|
||||||
return (
|
|
||||||
<View style={tw`gap-6`}>
|
|
||||||
<Section title="Sizes">
|
|
||||||
<View style={tw`gap-2`}>
|
|
||||||
<SansSerifText style={tw`text-sm`}>Small sans-serif text</SansSerifText>
|
|
||||||
<SansSerifText style={tw`text-base`}>Base sans-serif text</SansSerifText>
|
|
||||||
<SansSerifText style={tw`text-xl`}>Extra large sans-serif text</SansSerifText>
|
|
||||||
<SansSerifText style={tw`text-3xl`}>3XL sans-serif text</SansSerifText>
|
|
||||||
</View>
|
|
||||||
</Section>
|
|
||||||
<Section title="Weights">
|
|
||||||
<View style={tw`gap-2`}>
|
|
||||||
<SansSerifText style={tw`font-light`}>Light weight</SansSerifText>
|
|
||||||
<SansSerifText style={tw`font-normal`}>Normal weight</SansSerifText>
|
|
||||||
<SansSerifText style={tw`font-medium`}>Medium weight</SansSerifText>
|
|
||||||
<SansSerifText style={tw`font-semibold`}>Semibold weight</SansSerifText>
|
|
||||||
<SansSerifText style={tw`font-bold`}>Bold weight</SansSerifText>
|
|
||||||
</View>
|
|
||||||
</Section>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const sansSerifTextShowcase: Showcase = {
|
|
||||||
title: "SansSerifText",
|
|
||||||
component: SansSerifTextShowcase,
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Text, type TextProps } from "react-native"
|
|
||||||
import tw from "twrnc"
|
|
||||||
|
|
||||||
export function SansSerifText({ children, style, ...props }: TextProps) {
|
|
||||||
return (
|
|
||||||
<Text style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Inter" }, style]} {...props}>
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { View } from "react-native"
|
|
||||||
import tw from "twrnc"
|
|
||||||
|
|
||||||
import { SerifText } from "./serif-text"
|
|
||||||
import { type Showcase, Section } from "../showcase"
|
|
||||||
|
|
||||||
function SerifTextShowcase() {
|
|
||||||
return (
|
|
||||||
<View style={tw`gap-6`}>
|
|
||||||
<Section title="Sizes">
|
|
||||||
<View style={tw`gap-2`}>
|
|
||||||
<SerifText style={tw`text-sm`}>Small serif text</SerifText>
|
|
||||||
<SerifText style={tw`text-base`}>Base serif text</SerifText>
|
|
||||||
<SerifText style={tw`text-xl`}>Extra large serif text</SerifText>
|
|
||||||
<SerifText style={tw`text-3xl`}>3XL serif text</SerifText>
|
|
||||||
</View>
|
|
||||||
</Section>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const serifTextShowcase: Showcase = {
|
|
||||||
title: "SerifText",
|
|
||||||
component: SerifTextShowcase,
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Text, type TextProps } from "react-native"
|
|
||||||
import tw from "twrnc"
|
|
||||||
|
|
||||||
export function SerifText({ children, style, ...props }: TextProps) {
|
|
||||||
return (
|
|
||||||
<Text style={[tw`text-stone-800 dark:text-stone-200`, { fontFamily: "Source Serif 4" }, style]} {...props}>
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -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
|
||||||
|
|
||||||
27
apps/aris-backend/package.json
Normal file
27
apps/aris-backend/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@aris/backend",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/server.ts",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run --watch src/server.ts",
|
||||||
|
"start": "bun run src/server.ts",
|
||||||
|
"test": "bun test src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aris/core": "workspace:*",
|
||||||
|
"@aris/source-caldav": "workspace:*",
|
||||||
|
"@aris/source-google-calendar": "workspace:*",
|
||||||
|
"@aris/source-location": "workspace:*",
|
||||||
|
"@aris/source-tfl": "workspace:*",
|
||||||
|
"@aris/source-weatherkit": "workspace:*",
|
||||||
|
"@openrouter/sdk": "^0.9.11",
|
||||||
|
"arktype": "^2.1.29",
|
||||||
|
"better-auth": "^1",
|
||||||
|
"hono": "^4",
|
||||||
|
"pg": "^8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@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))
|
||||||
}
|
}
|
||||||
10
apps/aris-backend/src/auth/index.ts
Normal file
10
apps/aris-backend/src/auth/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { betterAuth } from "better-auth"
|
||||||
|
|
||||||
|
import { pool } from "../db.ts"
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
database: pool,
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
76
apps/aris-backend/src/auth/session-middleware.ts
Normal file
76
apps/aris-backend/src/auth/session-middleware.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import type { Context, MiddlewareHandler, Next } from "hono"
|
||||||
|
|
||||||
|
import type { AuthSession, AuthUser } from "./session.ts"
|
||||||
|
|
||||||
|
import { auth } from "./index.ts"
|
||||||
|
|
||||||
|
export interface SessionVariables {
|
||||||
|
user: AuthUser | null
|
||||||
|
session: AuthSession | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthSessionEnv = { Variables: SessionVariables }
|
||||||
|
|
||||||
|
export type AuthSessionMiddleware = MiddlewareHandler<AuthSessionEnv>
|
||||||
|
|
||||||
|
declare module "hono" {
|
||||||
|
interface ContextVariableMap extends SessionVariables {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware that attaches session and user to the context.
|
||||||
|
* Does not reject unauthenticated requests - use requireSession for that.
|
||||||
|
*/
|
||||||
|
export async function sessionMiddleware(c: Context, next: Next): Promise<void> {
|
||||||
|
const session = await auth.api.getSession({ headers: c.req.raw.headers })
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
c.set("user", session.user)
|
||||||
|
c.set("session", session.session)
|
||||||
|
} else {
|
||||||
|
c.set("user", null)
|
||||||
|
c.set("session", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware that requires a valid session. Returns 401 if not authenticated.
|
||||||
|
*/
|
||||||
|
export async function requireSession(c: Context, next: Next): Promise<Response | void> {
|
||||||
|
const session = await auth.api.getSession({ headers: c.req.raw.headers })
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.set("user", session.user)
|
||||||
|
c.set("session", session.session)
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get session from headers. Useful for WebSocket upgrade validation.
|
||||||
|
*/
|
||||||
|
export async function getSessionFromHeaders(
|
||||||
|
headers: Headers,
|
||||||
|
): Promise<{ user: AuthUser; session: AuthSession } | null> {
|
||||||
|
const session = await auth.api.getSession({ headers })
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only middleware that injects a fake user and session.
|
||||||
|
* Pass userId to simulate an authenticated request, or omit to get 401.
|
||||||
|
*/
|
||||||
|
export function mockAuthSessionMiddleware(userId?: string): AuthSessionMiddleware {
|
||||||
|
return async (c: Context, next: Next): Promise<Response | void> => {
|
||||||
|
if (!userId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401)
|
||||||
|
}
|
||||||
|
c.set("user", { id: userId } as AuthUser)
|
||||||
|
c.set("session", { id: "mock-session" } as AuthSession)
|
||||||
|
await next()
|
||||||
|
}
|
||||||
|
}
|
||||||
4
apps/aris-backend/src/auth/session.ts
Normal file
4
apps/aris-backend/src/auth/session.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import type { auth } from "./index.ts"
|
||||||
|
|
||||||
|
export type AuthUser = typeof auth.$Infer.Session.user
|
||||||
|
export type AuthSession = typeof auth.$Infer.Session.session
|
||||||
5
apps/aris-backend/src/db.ts
Normal file
5
apps/aris-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,4 +1,4 @@
|
|||||||
import type { FeedItem } from "@aelis/core"
|
import type { FeedItem } from "@aris/core"
|
||||||
|
|
||||||
import type { LlmClient } from "./llm-client.ts"
|
import type { LlmClient } from "./llm-client.ts"
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { FeedItem } from "@aelis/core"
|
import type { FeedItem } from "@aris/core"
|
||||||
|
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
@@ -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 },
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { FeedItem } from "@aelis/core"
|
import type { FeedItem } from "@aris/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 },
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { FeedItem } from "@aelis/core"
|
import type { FeedItem } from "@aris/core"
|
||||||
|
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { FeedItem } from "@aelis/core"
|
import type { FeedItem } from "@aris/core"
|
||||||
|
|
||||||
import { CalDavFeedItemType } from "@aelis/source-caldav"
|
import { CalDavFeedItemType } from "@aris/source-caldav"
|
||||||
import { CalendarFeedItemType } from "@aelis/source-google-calendar"
|
import { CalendarFeedItemType } from "@aris/source-google-calendar"
|
||||||
|
|
||||||
import systemPromptBase from "./prompts/system.txt"
|
import systemPromptBase from "./prompts/system.txt"
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
You are AELIS, a personal assistant. You enhance a user's feed by filling slots and optionally generating synthetic items.
|
You are ARIS, a personal assistant. You enhance a user's feed by filling slots and optionally generating synthetic items.
|
||||||
|
|
||||||
The user message is a JSON object with:
|
The user message is a JSON object with:
|
||||||
- "items": feed items with data and named slots to fill. Each slot has a description of what to write.
|
- "items": feed items with data and named slots to fill. Each slot has a description of what to write.
|
||||||
144
apps/aris-backend/src/feed/http.test.ts
Normal file
144
apps/aris-backend/src/feed/http.test.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aris/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/aris-backend/src/feed/http.ts
Normal file
45
apps/aris-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,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,16 +44,8 @@ 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
|
await session.engine.executeAction("aris.location", "update-location", {
|
||||||
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", {
|
|
||||||
lat: result.lat,
|
lat: result.lat,
|
||||||
lng: result.lng,
|
lng: result.lng,
|
||||||
accuracy: result.accuracy,
|
accuracy: result.accuracy,
|
||||||
@@ -1,21 +1,16 @@
|
|||||||
|
import { LocationSource } from "@aris/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 { 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,12 @@ function main() {
|
|||||||
|
|
||||||
app.get("/health", (c) => c.json({ status: "ok" }))
|
app.get("/health", (c) => c.json({ status: "ok" }))
|
||||||
|
|
||||||
const authSessionMiddleware = createRequireSession(auth)
|
registerAuthHandlers(app)
|
||||||
|
|
||||||
registerAuthHandlers(app, auth)
|
|
||||||
|
|
||||||
registerFeedHttpHandlers(app, {
|
registerFeedHttpHandlers(app, {
|
||||||
sessionManager,
|
sessionManager,
|
||||||
authSessionMiddleware,
|
authSessionMiddleware: requireSession,
|
||||||
})
|
|
||||||
registerLocationHttpHandlers(app, { sessionManager, authSessionMiddleware })
|
|
||||||
|
|
||||||
process.on("SIGTERM", async () => {
|
|
||||||
await closeDb()
|
|
||||||
process.exit(0)
|
|
||||||
})
|
})
|
||||||
|
registerLocationHttpHandlers(app, { sessionManager })
|
||||||
|
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
9
apps/aris-backend/src/session/feed-source-provider.ts
Normal file
9
apps/aris-backend/src/session/feed-source-provider.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { FeedSource } from "@aris/core"
|
||||||
|
|
||||||
|
export interface FeedSourceProvider {
|
||||||
|
feedSourceForUser(userId: string): FeedSource
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FeedSourceProviderFn = (userId: string) => FeedSource
|
||||||
|
|
||||||
|
export type FeedSourceProviderInput = FeedSourceProvider | FeedSourceProviderFn
|
||||||
170
apps/aris-backend/src/session/user-session-manager.test.ts
Normal file
170
apps/aris-backend/src/session/user-session-manager.test.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import type { WeatherKitClient, WeatherKitResponse } from "@aris/source-weatherkit"
|
||||||
|
|
||||||
|
import { LocationSource } from "@aris/source-location"
|
||||||
|
import { describe, expect, mock, test } from "bun:test"
|
||||||
|
|
||||||
|
import { WeatherSourceProvider } from "../weather/provider.ts"
|
||||||
|
import { UserSessionManager } from "./user-session-manager.ts"
|
||||||
|
|
||||||
|
const mockWeatherClient: WeatherKitClient = {
|
||||||
|
fetch: async () => ({}) as WeatherKitResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("UserSessionManager", () => {
|
||||||
|
test("getOrCreate creates session on first call", () => {
|
||||||
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
|
const session = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
|
expect(session).toBeDefined()
|
||||||
|
expect(session.engine).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("getOrCreate returns same session for same user", () => {
|
||||||
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
|
const session1 = manager.getOrCreate("user-1")
|
||||||
|
const session2 = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
|
expect(session1).toBe(session2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("getOrCreate returns different sessions for different users", () => {
|
||||||
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
|
const session1 = manager.getOrCreate("user-1")
|
||||||
|
const session2 = manager.getOrCreate("user-2")
|
||||||
|
|
||||||
|
expect(session1).not.toBe(session2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("each user gets independent source instances", () => {
|
||||||
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
|
const session1 = manager.getOrCreate("user-1")
|
||||||
|
const session2 = manager.getOrCreate("user-2")
|
||||||
|
|
||||||
|
const source1 = session1.getSource<LocationSource>("aris.location")
|
||||||
|
const source2 = session2.getSource<LocationSource>("aris.location")
|
||||||
|
|
||||||
|
expect(source1).not.toBe(source2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("remove destroys session and allows re-creation", () => {
|
||||||
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
|
const session1 = manager.getOrCreate("user-1")
|
||||||
|
manager.remove("user-1")
|
||||||
|
const session2 = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
|
expect(session1).not.toBe(session2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("remove is no-op for unknown user", () => {
|
||||||
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
|
expect(() => manager.remove("unknown")).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("accepts function providers", async () => {
|
||||||
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
|
const session = manager.getOrCreate("user-1")
|
||||||
|
const result = await session.engine.refresh()
|
||||||
|
|
||||||
|
expect(result.errors).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("accepts object providers", () => {
|
||||||
|
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
||||||
|
const manager = new UserSessionManager({
|
||||||
|
providers: [() => new LocationSource(), provider],
|
||||||
|
})
|
||||||
|
|
||||||
|
const session = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
|
expect(session.getSource("aris.weather")).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("accepts mixed providers", () => {
|
||||||
|
const provider = new WeatherSourceProvider({ client: mockWeatherClient })
|
||||||
|
const manager = new UserSessionManager({
|
||||||
|
providers: [() => new LocationSource(), provider],
|
||||||
|
})
|
||||||
|
|
||||||
|
const session = manager.getOrCreate("user-1")
|
||||||
|
|
||||||
|
expect(session.getSource("aris.location")).toBeDefined()
|
||||||
|
expect(session.getSource("aris.weather")).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("refresh returns feed result through session", async () => {
|
||||||
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
|
const session = manager.getOrCreate("user-1")
|
||||||
|
const result = await session.engine.refresh()
|
||||||
|
|
||||||
|
expect(result).toHaveProperty("context")
|
||||||
|
expect(result).toHaveProperty("items")
|
||||||
|
expect(result).toHaveProperty("errors")
|
||||||
|
expect(result.context.time).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("location update via executeAction works", async () => {
|
||||||
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
|
||||||
|
const session = manager.getOrCreate("user-1")
|
||||||
|
await session.engine.executeAction("aris.location", "update-location", {
|
||||||
|
lat: 51.5074,
|
||||||
|
lng: -0.1278,
|
||||||
|
accuracy: 10,
|
||||||
|
timestamp: new Date(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const source = session.getSource<LocationSource>("aris.location")
|
||||||
|
expect(source?.lastLocation?.lat).toBe(51.5074)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("subscribe receives updates after location push", async () => {
|
||||||
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
const callback = mock()
|
||||||
|
|
||||||
|
const session = manager.getOrCreate("user-1")
|
||||||
|
session.engine.subscribe(callback)
|
||||||
|
|
||||||
|
await session.engine.executeAction("aris.location", "update-location", {
|
||||||
|
lat: 51.5074,
|
||||||
|
lng: -0.1278,
|
||||||
|
accuracy: 10,
|
||||||
|
timestamp: new Date(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for async update propagation
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("remove stops reactive updates", async () => {
|
||||||
|
const manager = new UserSessionManager({ providers: [() => new LocationSource()] })
|
||||||
|
const callback = mock()
|
||||||
|
|
||||||
|
const session = manager.getOrCreate("user-1")
|
||||||
|
session.engine.subscribe(callback)
|
||||||
|
|
||||||
|
manager.remove("user-1")
|
||||||
|
|
||||||
|
// Create new session and push location — old callback should not fire
|
||||||
|
const session2 = manager.getOrCreate("user-1")
|
||||||
|
await session2.engine.executeAction("aris.location", "update-location", {
|
||||||
|
lat: 51.5074,
|
||||||
|
lng: -0.1278,
|
||||||
|
accuracy: 10,
|
||||||
|
timestamp: new Date(),
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
|
|
||||||
|
expect(callback).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
40
apps/aris-backend/src/session/user-session-manager.ts
Normal file
40
apps/aris-backend/src/session/user-session-manager.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
||||||
|
import type { FeedSourceProviderInput } from "./feed-source-provider.ts"
|
||||||
|
|
||||||
|
import { UserSession } from "./user-session.ts"
|
||||||
|
|
||||||
|
export interface UserSessionManagerConfig {
|
||||||
|
providers: FeedSourceProviderInput[]
|
||||||
|
feedEnhancer?: FeedEnhancer | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserSessionManager {
|
||||||
|
private sessions = new Map<string, UserSession>()
|
||||||
|
private readonly providers: FeedSourceProviderInput[]
|
||||||
|
private readonly feedEnhancer: FeedEnhancer | null
|
||||||
|
|
||||||
|
constructor(config: UserSessionManagerConfig) {
|
||||||
|
this.providers = config.providers
|
||||||
|
this.feedEnhancer = config.feedEnhancer ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
getOrCreate(userId: string): UserSession {
|
||||||
|
let session = this.sessions.get(userId)
|
||||||
|
if (!session) {
|
||||||
|
const sources = this.providers.map((p) =>
|
||||||
|
typeof p === "function" ? p(userId) : p.feedSourceForUser(userId),
|
||||||
|
)
|
||||||
|
session = new UserSession(sources, this.feedEnhancer)
|
||||||
|
this.sessions.set(userId, session)
|
||||||
|
}
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(userId: string): void {
|
||||||
|
const session = this.sessions.get(userId)
|
||||||
|
if (session) {
|
||||||
|
session.destroy()
|
||||||
|
this.sessions.delete(userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aelis/core"
|
import type { ActionDefinition, ContextEntry, FeedItem, FeedSource } from "@aris/core"
|
||||||
|
|
||||||
import { LocationSource } from "@aelis/source-location"
|
import { LocationSource } from "@aris/source-location"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
import { UserSession } from "./user-session.ts"
|
import { UserSession } from "./user-session.ts"
|
||||||
@@ -36,7 +36,7 @@ describe("UserSession", () => {
|
|||||||
const location = new LocationSource()
|
const location = new LocationSource()
|
||||||
const session = new UserSession([location])
|
const session = new UserSession([location])
|
||||||
|
|
||||||
const result = session.getSource<LocationSource>("aelis.location")
|
const result = session.getSource<LocationSource>("aris.location")
|
||||||
|
|
||||||
expect(result).toBe(location)
|
expect(result).toBe(location)
|
||||||
})
|
})
|
||||||
@@ -59,7 +59,7 @@ describe("UserSession", () => {
|
|||||||
const location = new LocationSource()
|
const location = new LocationSource()
|
||||||
const session = new UserSession([location])
|
const session = new UserSession([location])
|
||||||
|
|
||||||
await session.engine.executeAction("aelis.location", "update-location", {
|
await session.engine.executeAction("aris.location", "update-location", {
|
||||||
lat: 51.5,
|
lat: 51.5,
|
||||||
lng: -0.1,
|
lng: -0.1,
|
||||||
accuracy: 10,
|
accuracy: 10,
|
||||||
@@ -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,4 +1,4 @@
|
|||||||
import { FeedEngine, type FeedItem, type FeedResult, type FeedSource } from "@aelis/core"
|
import { FeedEngine, type FeedItem, type FeedResult, type FeedSource } from "@aris/core"
|
||||||
|
|
||||||
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
|
||||||
|
|
||||||
19
apps/aris-backend/src/tfl/provider.ts
Normal file
19
apps/aris-backend/src/tfl/provider.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { TflSource, type ITflApi } from "@aris/source-tfl"
|
||||||
|
|
||||||
|
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||||
|
|
||||||
|
export type TflSourceProviderOptions =
|
||||||
|
| { apiKey: string; client?: never }
|
||||||
|
| { apiKey?: never; client: ITflApi }
|
||||||
|
|
||||||
|
export class TflSourceProvider implements FeedSourceProvider {
|
||||||
|
private readonly options: TflSourceProviderOptions
|
||||||
|
|
||||||
|
constructor(options: TflSourceProviderOptions) {
|
||||||
|
this.options = options
|
||||||
|
}
|
||||||
|
|
||||||
|
feedSourceForUser(_userId: string): TflSource {
|
||||||
|
return new TflSource(this.options)
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/aris-backend/src/weather/provider.ts
Normal file
15
apps/aris-backend/src/weather/provider.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { WeatherSource, type WeatherSourceOptions } from "@aris/source-weatherkit"
|
||||||
|
|
||||||
|
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||||
|
|
||||||
|
export class WeatherSourceProvider implements FeedSourceProvider {
|
||||||
|
private readonly options: WeatherSourceOptions
|
||||||
|
|
||||||
|
constructor(options: WeatherSourceOptions) {
|
||||||
|
this.options = options
|
||||||
|
}
|
||||||
|
|
||||||
|
feedSourceForUser(_userId: string): WeatherSource {
|
||||||
|
return new WeatherSource(this.options)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "Aelis",
|
"name": "Aris",
|
||||||
"slug": "aelis-client",
|
"slug": "aris-client",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "aelis",
|
"scheme": "aris",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"ios": {
|
"ios": {
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
},
|
},
|
||||||
"ITSAppUsesNonExemptEncryption": false
|
"ITSAppUsesNonExemptEncryption": false
|
||||||
},
|
},
|
||||||
"bundleIdentifier": "sh.nym.aelis"
|
"bundleIdentifier": "sh.nym.aris"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
},
|
},
|
||||||
"edgeToEdgeEnabled": true,
|
"edgeToEdgeEnabled": true,
|
||||||
"predictiveBackGestureEnabled": false,
|
"predictiveBackGestureEnabled": false,
|
||||||
"package": "sh.nym.aelis"
|
"package": "sh.nym.aris"
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"output": "static",
|
"output": "static",
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user