15 Commits

Author SHA1 Message Date
ramvignesh-b 1d417d672c fix: disable refresh button when provider access token is missing
CI / build (pull_request) Successful in 24s
2026-05-12 04:25:07 +05:30
ramvignesh-b 9d6df8a8df feat: implement secure session management using HttpOnly cookies for API key authentication 2026-05-12 04:20:03 +05:30
me 72357ed9ee refactor: modularize template view (#4)
CI / build (push) Successful in 23s
Co-authored-by: ramvignesh-b <ramvignesh-b@github.com>
Reviewed-on: #4
2026-05-11 22:45:11 +00:00
me 4728eaa578 refactor: re-configure env vars and healthcheck (#3)
CI / build (push) Successful in 22s
Co-authored-by: ramvignesh-b <ramvignesh-b@github.com>
Reviewed-on: #3
2026-05-11 19:58:31 +00:00
ramvignesh-b 7c4ef8a51c chore: update healthcheck to use bun fetch instead of curl
CI / build (push) Successful in 22s
2026-05-12 00:57:23 +05:30
ramvignesh-b 2eab4b92cc test: implement redis unavailibility health check
CI / build (push) Successful in 1m27s
2026-05-11 23:59:01 +05:30
ramvignesh-b 51502055db feat: add healthcheck configuration
CI / build (push) Failing after 24s
2026-05-11 23:44:40 +05:30
ramvignesh-b 553d9647c2 chore: add production start script and update readme
CI / build (push) Successful in 21s
2026-05-11 17:44:19 +05:30
me b954ce5f72 ci: integrate tests workflow
CI / build (push) Successful in 22s
Reviewed-on: #2
2026-05-11 12:01:48 +00:00
ramvignesh-b b258ee0a07 test: centralize redis mock in test setup
CI / build (pull_request) Successful in 22s
2026-05-11 17:28:08 +05:30
ramvignesh-b e6354aae00 chore: centralize test environment configuration via bunfig and setup file
CI / build (pull_request) Successful in 21s
2026-05-11 17:07:23 +05:30
ramvignesh-b 78520b9069 feat: add CI workflow for linting and testing using Bun
CI / build (pull_request) Failing after 1m40s
2026-05-11 16:59:17 +05:30
ramvignesh-b dfff0e913d tests: improve coverage for config and provider manager 2026-05-11 16:59:12 +05:30
ramvignesh-b 21c030fee5 chore: add bun types 2026-05-11 16:58:20 +05:30
me 3716c42668 Merge pull request 'feat: integrate scalar api reference' (#1) from feature/api-reference-integration into main
Reviewed-on: #1
2026-05-11 11:18:39 +00:00
29 changed files with 1428 additions and 703 deletions
+3 -4
View File
@@ -1,6 +1,5 @@
# Core Server Configuration
PORT=3000
APP_PORT=3000
API_KEY=your_secret_api_key_here
# Redis Configuration (Use redis://redis:6379 for Docker)
REDIS_URL=redis://localhost:6379
REDIS_HOST=redis
REDIS_PORT=6379
+28
View File
@@ -0,0 +1,28 @@
name: CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run linting
run: bun run check-all
- name: Run tests
run: bun run test
+4 -1
View File
@@ -11,4 +11,7 @@ ENV NODE_ENV=production
USER bun
EXPOSE 3000
CMD ["bun", "run", "src/index.ts"]
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD bun -e "fetch('http://localhost:3000/health').then(res => res.ok ? process.exit(0) : process.exit(1)).catch(e => process.exit(1))"
CMD ["bun", "run", "start"]
+41 -25
View File
@@ -20,47 +20,63 @@
- **Styling**: Tailwind CSS & DaisyUI
- **Schema & Validation**: Zod
## Quick Start
## Getting Started
toknd can be deployed either as a containerized service or self-hosted directly on your hardware.
### 1. Environment Setup
Clone the repository and create your environment file:
```bash
cp .env.example .env
```
Ensure you define a strong `API_KEY` in your `.env`.
Define a strong `API_KEY` and ensure `REDIS_URL` points to a valid Redis instance.
### 2. Local Development (with Auto-Watch)
We use a Docker Compose override system to enable hot-reloading locally:
```bash
podman compose up --build
```
*Note: This mounts your ./src directory into the container and uses bun --hot to restart on any code changes.*
### 2. Choose Deployment Method
### 3. Production Deployment
For production, only the core docker-compose.yml is used:
```bash
docker compose up -d --build
```
#### Option A: Containerized (Recommended)
This is the easiest way to get up and running, as it bundles the application and a Redis instance together.
- **Development (with Hot-Reload)**:
```bash
podman compose up --build
```
- **Production**:
```bash
docker compose up -d --build
```
#### Option B: Self-Hosting (Bare Metal)
Ideal for lightweight deployments or custom environments where you already have Bun and Redis.
1. **Install Dependencies**:
```bash
bun install
```
2. **Start the Server**:
- **Development**: `bun run dev` (with hot-reload)
- **Production**: `bun run start`
*Note: Ensure your Redis server is running and accessible via the `REDIS_URL` in your `.env`.*
---
## API Reference
All protected endpoints require an Authorization header:
toknd provides a built-in **Scalar API Reference** that allows you to explore and test all endpoints directly from your browser.
- **Interactive UI**: [http://localhost:3000/api](http://localhost:3000/api) (or `/docs`)
- **OpenAPI Spec (JSON)**: [http://localhost:3000/doc](http://localhost:3000/doc)
All protected endpoints require a Bearer token in the `Authorization` header:
`Authorization: Bearer <your_master_api_key>`
### Token Brokerage
- **Get Valid Token**: `GET /api/token/:provider`
- Returns a valid access token. Automatically triggers a refresh if the current one is expired.
- **Registry Status**: `GET /api/status`
- Returns the connectivity and refresh status of all configured providers.
### Authentication Flow
1. **Initiate**: `GET /auth/:provider/login`
2. **Callback**: `GET /auth/callback` (Handled internally by toknd)
### Core Concepts
- **Token Brokerage**: Automated access token retrieval and background refreshes for all configured providers.
- **Provider Management**: Register and manage OAuth2 providers via the Dashboard or the configuration API.
## Dashboard
Access the toknd dashboard at:
Access the **toknd** dashboard at:
`http://localhost:3000/app`
Authenticate the registry using your Master API Key to manage your providers and view live token status.
The dashboard allows you to manage provider configurations, view live token statuses, and manually trigger refreshes. Authenticate using your **Master API Key**.
---
+13 -1
View File
@@ -61,5 +61,17 @@
"organizeImports": "on"
}
}
}
},
"overrides": [
{
"includes": ["tests/**/*"],
"linter": {
"rules": {
"suspicious": {
"noExplicitAny": "off"
}
}
}
}
]
}
+3
View File
@@ -15,6 +15,7 @@
"devDependencies": {
"@biomejs/biome": "2.4.15",
"@types/node": "^22.19.18",
"bun-types": "^1.3.13",
"husky": "^9.1.7",
"lint-staged": "^17.0.4",
"typescript": "^5.9.3",
@@ -66,6 +67,8 @@
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
"cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="],
+2
View File
@@ -0,0 +1,2 @@
[test]
preload = ["./tests/setup.ts"]
+5 -2
View File
@@ -3,9 +3,10 @@ services:
build: .
restart: always
ports:
- "${PORT:-3000}:3000"
- "${APP_PORT:-3000}:3000"
environment:
- REDIS_URL=redis://redis:6379
- REDIS_HOST=redis
- REDIS_PORT=6379
- API_KEY=${API_KEY}
depends_on:
- redis
@@ -13,6 +14,8 @@ services:
redis:
image: redis:alpine
restart: always
ports:
- "${REDIS_PORT:-6379}:6379"
volumes:
- redis-data:/data
+3
View File
@@ -4,8 +4,10 @@
"type": "module",
"scripts": {
"dev": "bun run --hot src/index.ts",
"start": "bun src/index.ts",
"test": "bun test",
"lint": "bunx @biomejs/biome check src",
"check-all": "bunx @biomejs/biome check .",
"format": "bunx @biomejs/biome format --write src",
"prepare": "husky"
},
@@ -20,6 +22,7 @@
"devDependencies": {
"@biomejs/biome": "2.4.15",
"@types/node": "^22.19.18",
"bun-types": "^1.3.13",
"husky": "^9.1.7",
"lint-staged": "^17.0.4",
"typescript": "^5.9.3"
+3 -2
View File
@@ -1,8 +1,9 @@
import { z } from "zod";
const configSchema = z.object({
PORT: z.string().default("3000"),
REDIS_URL: z.string(),
APP_PORT: z.string().default("3000"),
REDIS_HOST: z.string().default("redis"),
REDIS_PORT: z.coerce.number().default(6379),
API_KEY: z.string(),
});
+4 -1
View File
@@ -1,4 +1,7 @@
import { Redis } from "ioredis";
import { config } from "../config";
export const redis = new Redis(config.REDIS_URL);
export const redis = new Redis({
host: config.REDIS_HOST,
port: config.REDIS_PORT,
});
+12 -2
View File
@@ -1,8 +1,10 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { Scalar } from "@scalar/hono-api-reference";
import { serveStatic } from "hono/bun";
import { logger } from "hono/logger";
import { prettyJSON } from "hono/pretty-json";
import { config } from "./config";
import { redis } from "./core/RedisClient";
import { apiRoutes } from "./routes/api";
import { authRoutes } from "./routes/auth";
import { configRoutes } from "./routes/config";
@@ -40,6 +42,7 @@ app.use("*", prettyJSON());
app.get("/", (c) => c.redirect("/app"));
app.get("/app/dashboard.js", serveStatic({ path: "./src/views/dashboard.js" }));
app.route("/auth", authRoutes);
app.route("/api/config", configRoutes);
app.route("/api", apiRoutes);
@@ -63,9 +66,16 @@ app.onError((err, c) => {
return c.json({ error: "Internal Server Error", message: err.message }, 500);
});
app.get("/health", (c) => c.json({ status: "ok" }));
app.get("/health", async (c) => {
if (redis.status !== "ready") {
return c.json({ status: "error", message: "Redis down", redis: redis.status }, 503);
}
return c.json({ status: "ok" });
});
export { app };
export default {
port: Number.parseInt(config.PORT, 10),
port: Number.parseInt(config.APP_PORT, 10),
fetch: app.fetch,
};
+11 -3
View File
@@ -1,14 +1,22 @@
import type { Context, Next } from "hono";
import { getCookie } from "hono/cookie";
import { config } from "../config";
export const authMiddleware = async (c: Context, next: Next) => {
const authHeader = c.req.header("Authorization");
const cookieToken = getCookie(c, "toknd_api_key");
if (!authHeader?.startsWith("Bearer ")) {
return c.json({ error: "Missing or invalid authorization header" }, 401);
let token: string | undefined;
if (authHeader?.startsWith("Bearer ")) {
token = authHeader.split(" ")[1];
} else if (cookieToken) {
token = cookieToken;
}
const token = authHeader.split(" ")[1];
if (!token) {
return c.json({ error: "Missing or invalid authorization" }, 401);
}
if (token !== config.API_KEY) {
return c.json({ error: "Invalid API key" }, 403);
+1 -1
View File
@@ -5,7 +5,7 @@ import type { OAuthProvider, TokenResponse } from "./interface";
const TokenResponseSchema = z.object({
access_token: z.string(),
refresh_token: z.string().optional(),
expires_in: z.number(),
expires_in: z.coerce.number(),
});
export class GenericProvider implements OAuthProvider {
+1 -6
View File
@@ -1,5 +1,3 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
import { ConfigManager } from "../core/ConfigManager";
import { redis } from "../core/RedisClient";
@@ -115,10 +113,7 @@ authRoutes.openapi(callbackRoute, async (c) => {
const tokens = await provider.exchangeCode(code, redirectUri);
await tokenManager.saveTokens(providerName, tokens);
const htmlPath = join(process.cwd(), "src/views/success.html");
let html = await readFile(htmlPath, "utf-8");
html = html.replaceAll("__PROVIDER_NAME__", providerName);
return c.html(html);
return c.redirect(`/app/success?provider=${providerName}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred.";
console.error(`[OAuth Error] ${errorMessage}`);
-17
View File
@@ -1,17 +0,0 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { Hono } from "hono";
const dashboardRoutes = new Hono({ strict: false });
dashboardRoutes.get("/", async (c) => {
try {
const htmlPath = join(process.cwd(), "src/views/dashboard.html");
const html = await readFile(htmlPath, "utf-8");
return c.html(html);
} catch (_error) {
return c.text("Error loading dashboard", 500);
}
});
export { dashboardRoutes };
+526
View File
@@ -0,0 +1,526 @@
/** @jsxImportSource hono/jsx */
import { Hono } from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
import { html } from "hono/html";
import type { Child } from "hono/jsx";
import { config } from "../config";
const dashboardRoutes = new Hono({ strict: false });
export const Layout = (props: { title: string; children: Child; isUnlocked?: boolean }) => (
<>
{html`<!DOCTYPE html>`}
<html lang="en" data-theme="abyss">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{props.title}</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<link
href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css"
rel="stylesheet"
type="text/css"
/>
<script src="https://unpkg.com/@phosphor-icons/web@2.1.1"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap"
rel="stylesheet"
/>
<style>
{`
.font-mono {
font-family: "DM Mono", monospace;
}
`}
</style>
</head>
<body
class="bg-base-200/50 min-h-screen font-['DM_Sans',sans-serif] antialiased text-base-content tracking-tight"
x-data={`dashboard({ initialIsUnlocked: ${props.isUnlocked || false} })`}
>
{props.children}
<script src="/app/dashboard.js"></script>
<script src="//unpkg.com/alpinejs@3" defer></script>
</body>
</html>
</>
);
export const Dashboard = (props: { isUnlocked: boolean }) => (
<Layout title="toknd — Auth Broker Dashboard" isUnlocked={props.isUnlocked}>
<div class="navbar bg-base-100 shadow-sm px-4 md:px-8 border-b border-base-300">
<div class="flex-1">
<div class="flex items-center gap-2">
<div class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center text-primary-content font-semibold text-lg">
<i class="ph-duotone ph-fingerprint"></i>
</div>
<div class="text-xl font-semibold tracking-tight">
toknd <span class="text-xs font-normal opacity-50 ml-1">auth broker</span>
</div>
</div>
</div>
<div class="flex-none hidden sm:flex">
<div class="join border border-base-200/50 bg-base-200/50 rounded-xl overflow-hidden focus-within:border-primary transition-colors">
<div class="join-item flex items-center px-4 bg-base-200">
<i class="ph-duotone ph-key text-secondary text-lg"></i>
</div>
<div class="relative flex-1" x-data="{ show: false }">
<input
x-bind:type="show ? 'text' : 'password'"
id="apiKey"
name="apiKey"
x-model="apiKey"
aria-label="Master API Key"
placeholder="API_KEY"
class="input join-item input-sm bg-transparent border-none focus:outline-none w-48 lg:w-64 text-xs pr-10 font-mono"
/>
<button
type="button"
x-on:click="show = !show"
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-square"
>
<i x-bind:class="show ? 'ph-duotone ph-eye-slash text-base opacity-50' : 'ph-duotone ph-eye text-base opacity-50'"></i>
</button>
</div>
<button
x-on:click="unlock()"
type="submit"
class="btn btn-primary btn-sm join-item px-6"
x-bind:disabled="loading"
>
<i class="ph-duotone ph-lock-key-open text-lg" x-show="!loading"></i>
<span class="loading loading-spinner loading-xs" x-show="loading"></span>
<span class="ml-1 hidden md:inline" x-text="loading ? 'Unlocking...' : 'Unlock'"></span>
</button>
<button
x-show="isUnlocked"
x-on:click="logout()"
type="button"
class="btn btn-ghost btn-sm join-item text-error hover:bg-error/10"
>
<i class="ph-bold ph-power text-lg"></i>
</button>
</div>
</div>
</div>
<div class="container mx-auto p-4 md:p-8 max-w-7xl">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 md:gap-8">
<div class="card bg-base-100 shadow-xl border border-base-300 lg:col-span-4 self-start">
<div class="card-body p-6">
<div class="flex items-center gap-2 mb-4">
<div class="w-2 h-6 bg-primary rounded-full"></div>
<h2 class="card-title text-xl font-semibold">Configure Provider</h2>
</div>
<form x-on:submit="saveConfig" class="space-y-4">
<div class="form-control">
<label htmlFor="providerName" class="label py-1">
<span class="label-text flex items-center gap-2">
Provider ID
<span class="tooltip tooltip-top" data-tip="Internal name for this service.">
<i class="ph ph-info opacity-50 cursor-help"></i>
</span>
</span>
</label>
<input
type="text"
id="providerName"
x-model="form.providerName"
placeholder="e.g. trakt"
required
class="input input-bordered w-full focus:input-primary"
/>
</div>
<div class="divider text-xs opacity-50 my-2 uppercase tracking-widest">
Credentials
</div>
<div class="form-control">
<label htmlFor="clientId" class="label py-1">
<span class="label-text">Client ID</span>
</label>
<input
type="text"
id="clientId"
x-model="form.clientId"
placeholder="OAuth client id"
required
class="input input-bordered w-full focus:input-primary"
/>
</div>
<div class="form-control" x-data="{ show: false }">
<label htmlFor="clientSecret" class="label py-1">
<span class="label-text">Client Secret</span>
</label>
<div class="relative">
<input
x-bind:type="show ? 'text' : 'password'"
id="clientSecret"
x-model="form.clientSecret"
placeholder="OAuth client secret"
required
class="input input-bordered w-full focus:input-primary pr-12"
/>
<button
type="button"
x-on:click="show = !show"
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-square"
>
<i x-bind:class="show ? 'ph-duotone ph-eye-slash text-lg opacity-40' : 'ph-duotone ph-eye text-lg opacity-40'"></i>
</button>
</div>
</div>
<div class="divider text-xs opacity-50 my-2 uppercase tracking-widest">Endpoints</div>
<div class="form-control">
<label htmlFor="authUrl" class="label py-1">
<span class="label-text">Auth URL</span>
</label>
<input
type="url"
id="authUrl"
x-model="form.authUrl"
placeholder="https://trakt.tv/oauth/authorize"
required
class="input input-bordered w-full focus:input-primary"
/>
</div>
<div class="form-control">
<label htmlFor="tokenUrl" class="label py-1">
<span class="label-text">Token URL</span>
</label>
<input
type="url"
id="tokenUrl"
x-model="form.tokenUrl"
placeholder="https://api.trakt.tv/oauth/token"
required
class="input input-bordered w-full focus:input-primary"
/>
</div>
<div class="form-control">
<label htmlFor="redirectUri" class="label py-1">
<span class="label-text">Redirect URI</span>
</label>
<div class="relative group">
<input
type="url"
id="redirectUri"
x-bind:value="getRedirectUri()"
readonly
class="input input-bordered w-full pr-12 focus:outline-none cursor-default opacity-80"
/>
<button
type="button"
x-on:click="copyToClipboard(getRedirectUri())"
class="btn btn-ghost btn-xs absolute right-2 top-1/2 -translate-y-1/2 text-base-content/40 hover:text-primary transition-colors"
>
<i class="ph-duotone ph-copy text-lg"></i>
</button>
</div>
<div class="label py-0.5">
<span class="label-text-alt opacity-40 italic text-xs">
Must match provider's callback URL
</span>
</div>
</div>
<div class="form-control">
<label htmlFor="scope" class="label py-1">
<span class="label-text">Scope</span>
</label>
<input
type="text"
id="scope"
x-model="form.scope"
placeholder="public"
class="input input-bordered w-full focus:input-primary"
/>
</div>
<div class="card-actions pt-4">
<button
type="submit"
class="btn btn-primary w-full shadow-md"
x-bind:disabled="loading"
>
<i class="ph ph-plus-bold mr-1"></i>
Save Configuration
</button>
</div>
</form>
</div>
</div>
<div class="card bg-base-100 shadow-xl border border-base-300 lg:col-span-8 overflow-hidden">
<div class="card-body p-0">
<div class="p-6 pb-4 flex justify-between items-center bg-base-100">
<div class="flex items-center gap-2">
<div class="w-2 h-6 bg-primary rounded-full"></div>
<h2 class="card-title text-xl font-semibold">Provider Registry</h2>
</div>
<button
type="button"
x-on:click="fetchProviders()"
class="btn btn-sm btn-base"
x-bind:disabled="!isUnlocked || loading"
>
<i x-bind:class="loading ? 'ph ph-arrows-clockwise animate-spin mr-1' : 'ph ph-arrows-clockwise mr-1'"></i>
Refresh List
</button>
</div>
<div class="relative min-h-[400px]">
<div
x-show="loading && providers.length > 0"
class="absolute inset-0 bg-base-100/50 backdrop-blur-md z-10 flex items-center justify-center"
>
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
<div x-show="!isUnlocked" class="p-20 text-center opacity-30">
<div class="flex flex-col items-center gap-3">
<i class="ph ph-lock-key text-6xl"></i>
<p class="font-medium">Enter Master API Key to access registry</p>
</div>
</div>
<div
x-show="isUnlocked && providers.length === 0 && !loading"
class="p-20 text-center opacity-30"
>
<div class="flex flex-col items-center gap-3">
<i class="ph ph-folder-open text-6xl"></i>
<p class="font-medium">No providers configured yet</p>
</div>
</div>
<div
x-show="isUnlocked && providers.length > 0"
class="p-6 grid grid-cols-1 md:grid-cols-2 gap-4"
>
<template x-for="provider in providers" x-bind:key="provider.name">
<div class="card bg-base-200/50 border border-base-300 shadow-sm hover:shadow-md transition-all group">
<div class="card-body p-5">
<div class="flex flex-col mb-4">
<span
x-text="provider.name"
class="text-lg font-black text-base-content/90 uppercase"
></span>
<span
x-text="provider.config.clientId"
x-bind:title="provider.config.clientId"
class="text-xs opacity-40 truncate font-mono"
></span>
</div>
<div class="space-y-4">
<div x-data="{ show: false }">
<div class="text-xs uppercase font-semibold opacity-30 block mb-1">
Access Token
</div>
<div
x-show="provider.status.accessToken"
class="flex items-center gap-2 bg-base-100 rounded border border-base-300 p-1 pl-3"
>
<input
x-bind:type="show ? 'text' : 'password'"
x-bind:value="provider.status.accessToken"
readonly
class="bg-transparent border-none outline-none shadow-none focus:ring-0 text-xs flex-1 min-w-0 font-mono"
/>
<div class="flex gap-1">
<button
type="button"
x-on:click="show = !show"
class="btn btn-ghost btn-xs btn-square"
>
<i x-bind:class="show ? 'ph-duotone ph-eye-slash text-base opacity-50' : 'ph-duotone ph-eye text-base opacity-50'"></i>
</button>
<button
type="button"
x-on:click="copyToClipboard(provider.status.accessToken)"
class="btn btn-ghost btn-xs btn-square"
>
<i class="ph-duotone ph-copy-simple text-base opacity-50"></i>
</button>
</div>
</div>
<div
x-show="!provider.status.accessToken"
class="h-8 flex items-center px-3 bg-base-300/30 rounded text-xs italic opacity-40"
>
Not Authenticated
</div>
</div>
<div x-data="{ show: false }">
<div class="text-xs uppercase font-semibold opacity-30 block mb-1">
Refresh Token
</div>
<div
x-show="provider.status.refreshToken"
class="flex items-center gap-2 bg-base-100 rounded border border-base-300 p-1 pl-3"
>
<input
x-bind:type="show ? 'text' : 'password'"
x-bind:value="provider.status.refreshToken"
readonly
class="bg-transparent border-none outline-none shadow-none focus:ring-0 text-xs flex-1 min-w-0 font-mono"
/>
<div class="flex gap-1">
<button
type="button"
x-on:click="show = !show"
class="btn btn-ghost btn-xs btn-square"
>
<i x-bind:class="show ? 'ph-duotone ph-eye-slash text-base opacity-50' : 'ph-duotone ph-eye text-base opacity-50'"></i>
</button>
<button
type="button"
x-on:click="copyToClipboard(provider.status.refreshToken)"
class="btn btn-ghost btn-xs btn-square"
>
<i class="ph-duotone ph-copy-simple text-base opacity-50"></i>
</button>
</div>
</div>
<div
x-show="!provider.status.refreshToken"
class="h-8 flex items-center px-3 bg-base-300/30 rounded text-xs italic opacity-40"
>
Not Authenticated
</div>
</div>
</div>
<div class="divider my-3 opacity-10"></div>
<div class="flex justify-between items-center mb-4">
<span class="text-xs font-semibold opacity-30 uppercase">Last Updated</span>
<span
x-text="formatTime(provider.status.lastUpdated)"
class="text-xs font-medium opacity-60"
></span>
</div>
<div class="flex flex-col gap-2">
<div class="grid grid-cols-2 gap-2">
<button
type="button"
x-on:click="window.open('/auth/' + provider.name + '/login', '_blank')"
class="btn btn-primary btn-sm"
>
<i class="ph-bold ph-link"></i> Connect
</button>
<button
type="button"
x-on:click="editProvider(provider)"
class="btn btn-secondary btn-sm"
>
<i class="ph-bold ph-pencil-simple"></i> Edit
</button>
</div>
<button
type="button"
x-on:click="forceRefresh(provider.name)"
class="btn btn-base w-full"
x-bind:disabled="loading || !provider.status.accessToken"
>
<i class="ph-bold ph-arrows-clockwise text-base mr-1"></i>
<span class="text-xs uppercase font-bold tracking-widest">
Refresh Tokens
</span>
</button>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="toast toast-center toast-top z-100" x-show="notification.show">
<div
class="alert shadow-lg border border-base-300"
x-bind:class="notification.type === 'error' ? 'alert-error' : 'alert-success'"
>
<span x-text="notification.message"></span>
</div>
</div>
</Layout>
);
export const Success = (props: { provider: string }) => (
<Layout title="Authenticated!">
<div class="min-h-[80vh] flex items-center justify-center p-4">
<div class="card bg-base-100 shadow-xl border border-base-300 max-w-md w-full">
<div class="card-body items-center text-center p-8 md:p-12">
<div class="w-20 h-20 bg-success/10 text-success rounded-2xl flex items-center justify-center mb-6 shadow-inner animate-pulse-slow">
<i class="ph-duotone ph-check-circle text-5xl"></i>
</div>
<h2 class="card-title text-3xl font-black tracking-tight mb-2 uppercase">
Authenticated!
</h2>
<p class="text-base-content/60 leading-relaxed">
Successfully connected to{" "}
<span class="font-bold text-base-content uppercase">{props.provider}</span>. You can now
close this window or return to the dashboard.
</p>
<div class="divider my-8 opacity-50"></div>
<div class="card-actions w-full">
<a
href="/app"
class="btn btn-primary btn-block shadow-lg hover:shadow-primary/20 transition-all"
>
<i class="ph-bold ph-house mr-2"></i>
Back to Dashboard
</a>
</div>
</div>
</div>
</div>
</Layout>
);
dashboardRoutes.get("/", async (c) => {
const isUnlocked = getCookie(c, "toknd_api_key") === config.API_KEY;
return c.html(<Dashboard isUnlocked={isUnlocked} />);
});
dashboardRoutes.post("/unlock", async (c) => {
const { apiKey } = await c.req.json();
if (apiKey !== config.API_KEY) {
return c.json({ error: "Invalid API Key" }, 401);
}
setCookie(c, "toknd_api_key", apiKey, {
httpOnly: true,
secure: true,
sameSite: "Strict",
path: "/",
maxAge: 60 * 60 * 24 * 7, // 1 week
});
return c.json({ success: true });
});
dashboardRoutes.post("/logout", async (c) => {
deleteCookie(c, "toknd_api_key", { path: "/" });
return c.json({ success: true });
});
dashboardRoutes.get("/success", async (c) => {
const provider = c.req.query("provider") || "Provider";
return c.html(<Success provider={provider} />);
});
export { dashboardRoutes };
-557
View File
@@ -1,557 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="abyss">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>toknd — Auth Broker Dashboard</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
<script src="https://unpkg.com/@phosphor-icons/web@2.1.1"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap" rel="stylesheet">
<style>
:root {
--font-sans: "DM Sans", sans-serif;
--font-mono: "DM Mono", monospace;
}
body {
font-family: var(--font-sans);
letter-spacing: -0.01em;
}
.font-mono,
.input-mono,
#apiKey,
[type="password"],
[id^="token-"] {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
letter-spacing: 0.05em;
}
/* UI elements prioritization */
.btn, .card-title, .navbar a, .label-text, .tooltip {
font-family: var(--font-sans);
}
</style>
</head>
<body class="bg-base-200/50 min-h-screen font-sans antialiased text-base-content">
<div class="navbar bg-base-100 shadow-sm px-4 md:px-8 border-b border-base-300">
<div class="flex-1">
<div class="flex items-center gap-2">
<div
class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center text-primary-content font-semibold text-lg">
<i class="ph-duotone ph-fingerprint"></i>
</div>
<a class="text-xl font-semibold tracking-tight">toknd <span
class="text-xs font-normal opacity-50 ml-1">auth
broker</span></a>
</div>
</div>
<div class="flex-none hidden sm:flex">
<div class="join border border-base-300 bg-base-200/50 rounded-xl overflow-hidden focus-within:border-primary transition-colors">
<div class="join-item flex items-center px-4 bg-base-100">
<i class="ph-duotone ph-key text-primary text-lg animate-pulse-slow"></i>
</div>
<div class="relative flex-1">
<input type="password" id="apiKey" placeholder="API_KEY"
class="input join-item input-sm bg-transparent border-none focus:outline-none w-48 lg:w-64 text-xs pr-10 font-mono" />
<button type="button" onclick="window.toggleToken('apiKey')"
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-square">
<i class="ph-duotone ph-eye text-base opacity-50" id="eye-apiKey"></i>
</button>
</div>
<button onclick="fetchProviders()" type="button"
class="btn btn-primary btn-sm join-item px-6">
<i class="ph-duotone ph-lock-key-open text-lg"></i>
<span class="ml-1 hidden md:inline">Unlock</span>
</button>
</div>
</div>
</div>
<div class="container mx-auto p-4 md:p-8 max-w-7xl">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 md:gap-8">
<div class="card bg-base-100 shadow-xl border border-base-300 lg:col-span-4 self-start">
<div class="card-body p-6">
<div class="flex items-center gap-2 mb-4">
<div class="w-2 h-6 bg-primary rounded-full"></div>
<h2 class="card-title text-xl font-semibold">Configure Provider</h2>
</div>
<form id="configForm" class="space-y-4">
<div class="form-control">
<label class="label py-1">
<span class="label-text flex items-center gap-2">
Provider ID
<div class="tooltip tooltip-top"
data-tip="Internal name for this service. This will define your login URL (e.g. /auth/trakt/login).">
<i class="ph ph-info opacity-50 cursor-help"></i>
</div>
</span>
</label>
<input type="text" id="providerName" placeholder="e.g. trakt" required
class="input input-bordered w-full focus:input-primary" />
</div>
<div class="divider text-xs opacity-50 my-2 uppercase tracking-widest text-xs">Credentials
</div>
<div class="form-control">
<label class="label py-1">
<span class="label-text flex items-center gap-2">
Client ID
<div class="tooltip tooltip-top"
data-tip="Found in the &quot;API&quot; or &quot;Developer&quot; section of the provider. Sometimes called &quot;App ID&quot; or &quot;Consumer Key&quot;.">
<i class="ph ph-info opacity-50 cursor-help"></i>
</div>
</span>
</label>
<input type="text" id="clientId" placeholder="OAuth client id" required
class="input input-bordered w-full focus:input-primary" />
</div>
<div class="form-control">
<label class="label py-1">
<span class="label-text flex items-center gap-2">
Client Secret
<div class="tooltip tooltip-top"
data-tip="Found next to the Client ID. This is your private key—never share it or put it in client-side code.">
<i class="ph-duotone ph-info opacity-50 cursor-help"></i>
</div>
</span>
</label>
<div class="relative">
<input type="password" id="clientSecret" placeholder="OAuth client secret" required
class="input input-bordered w-full focus:input-primary pr-12" />
<button type="button" onclick="window.toggleToken('clientSecret')"
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-square">
<i class="ph-duotone ph-eye text-lg opacity-40" id="eye-clientSecret"></i>
</button>
</div>
</div>
<div class="divider text-xs opacity-50 my-2 uppercase tracking-widest text-xs">Endpoints
</div>
<div class="form-control">
<label class="label py-1">
<span class="label-text flex items-center gap-2">
Auth URL
<div class="tooltip tooltip-top"
data-tip="The page where users click &quot;Authorize&quot;. Usually found in OAuth2 docs under &quot;Endpoints&quot; or &quot;Authorize&quot;.">
<i class="ph ph-info opacity-50 cursor-help"></i>
</div>
</span>
</label>
<input type="url" id="authUrl" placeholder="https://trakt.tv/oauth/authorize" required
class="input input-bordered w-full focus:input-primary" />
</div>
<div class="form-control">
<label class="label py-1">
<span class="label-text flex items-center gap-2">
Token URL
<div class="tooltip tooltip-top"
data-tip="The background API used to trade the code for a token. Usually ends in &quot;/token&quot; or &quot;/access_token&quot;.">
<i class="ph ph-info opacity-50 cursor-help"></i>
</div>
</span>
</label>
<input type="url" id="tokenUrl" placeholder="https://api.trakt.tv/oauth/token" required
class="input input-bordered w-full focus:input-primary" />
</div>
<div class="form-control">
<label class="label py-1">
<span class="label-text flex items-center gap-2">
Redirect URI
<div class="tooltip tooltip-top"
data-tip="Copy this URL and paste it into the &quot;Redirect URI&quot; or &quot;Callback URL&quot; field in your OAuth provider's settings.">
<i class="ph ph-info opacity-50 cursor-help"></i>
</div>
</span>
</label>
<div class="relative group">
<input type="url" id="redirectUri" readonly
class="input input-bordered w-full pr-12 focus:outline-none cursor-default opacity-80" />
<button type="button" onclick="copyRedirectUri()"
class="btn btn-ghost btn-xs absolute right-2 top-1/2 -translate-y-1/2 text-base-content/40 hover:text-primary transition-colors">
<i class="ph ph-copy text-xs"></i>
</button>
</div>
<label class="label py-0.5">
<span class="label-text-alt opacity-40 italic text-xs">Must match provider&apos;s
callback URL</span>
</label>
</div>
<div class="form-control">
<label class="label py-1">
<span class="label-text flex items-center gap-2">
Scope
<div class="tooltip tooltip-top"
data-tip="Determines what data you're allowed to access. Multiple scopes are usually space-separated.">
<i class="ph ph-info opacity-50 cursor-help"></i>
</div>
</span>
</label>
<input type="text" id="scope" placeholder="public"
class="input input-bordered w-full focus:input-primary" />
</div>
<div class="card-actions pt-4">
<button type="submit" class="btn btn-primary w-full shadow-md">
<i class="ph ph-plus-bold mr-1"></i>
Save Configuration
</button>
</div>
</form>
</div>
</div>
<div class="card bg-base-100 shadow-xl border border-base-300 lg:col-span-8 overflow-hidden">
<div class="card-body p-0">
<div class="p-6 pb-4 flex justify-between items-center bg-base-100">
<div class="flex items-center gap-2">
<div class="w-2 h-6 bg-primary rounded-full"></div>
<h2 class="card-title text-xl font-semibold">Provider Registry</h2>
</div>
<button type="button" onclick="fetchProviders()" class="btn btn-sm btn-ghost border-base-300">
<i id="refreshIcon" class="ph ph-arrows-clockwise mr-1"></i>
Refresh
</button>
</div>
<div class="relative min-h-[400px]">
<!-- Loading Overlay -->
<div id="registryLoading"
class="absolute inset-0 bg-base-100/50 backdrop-blur-[2px] z-10 flex items-center justify-center hidden transition-all duration-300">
<div class="flex flex-col items-center gap-4">
<span class="loading loading-spinner loading-lg text-primary"></span>
<span class="text-xs font-semibold uppercase tracking-widest opacity-40">Syncing
Registry...</span>
</div>
</div>
<div id="providerRegistry" class="p-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Empty State -->
<div class="col-span-full text-center py-20 opacity-30">
<div class="flex flex-col items-center gap-3">
<i class="ph ph-lock-key text-6xl"></i>
<p class="font-medium">Enter Master API Key to access registry</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="toast toast-end" id="notificationToast" style="display: none; z-index: 100;">
<div class="alert shadow-lg border border-base-300">
<span id="notificationMessage"></span>
</div>
</div>
<script>
const apiKeyInput = document.getElementById('apiKey');
const configForm = document.getElementById('configForm');
const redirectUriInput = document.getElementById('redirectUri');
const providerRegistry = document.getElementById('providerRegistry');
const registryLoading = document.getElementById('registryLoading');
function setDefaultRedirectUri() {
if (redirectUriInput) {
redirectUriInput.value = `${window.location.origin}/auth/callback`;
}
}
window.copyRedirectUri = () => {
if (redirectUriInput) {
navigator.clipboard.writeText(redirectUriInput.value).then(() => {
showNotification('Redirect URI copied!');
});
}
};
setDefaultRedirectUri();
let providerData = {};
let tokenStatus = {};
function showNotification(message, type = 'success') {
const toast = document.getElementById('notificationToast');
const alert = toast.querySelector('.alert');
const msgSpan = document.getElementById('notificationMessage');
alert.className = `alert alert-${type} shadow-lg`;
msgSpan.textContent = message;
toast.style.display = 'block';
setTimeout(() => {
toast.style.display = 'none';
}, 3000);
}
async function fetchProviders() {
const apiKey = apiKeyInput.value.trim();
const refreshIcon = document.getElementById('refreshIcon');
if (!apiKey) {
providerRegistry.innerHTML = `
<div class="col-span-full text-center py-20 opacity-30">
<div class="flex flex-col items-center gap-3">
<i class="ph-duotone ph-fingerprint text-6xl"></i>
<p class="font-medium italic">Unlock toknd to access the broker registry</p>
</div>
</div>`;
return;
}
if (refreshIcon) refreshIcon.classList.add('animate-spin');
if (registryLoading) registryLoading.classList.remove('hidden');
try {
const configRes = await fetch('/api/config', {
headers: { 'Authorization': `Bearer ${apiKey}` }
});
if (!configRes.ok) throw new Error('Unauthorized or missing API Key');
providerData = await configRes.json();
const statusRes = await fetch('/api/status', {
headers: { 'Authorization': `Bearer ${apiKey}` }
});
if (statusRes.ok) {
tokenStatus = await statusRes.json();
}
renderRegistry();
localStorage.setItem('toknd_api_key', apiKey);
} catch (error) {
console.error('Error:', error);
providerRegistry.innerHTML = `<div class="col-span-full text-center text-error py-12 font-medium">${error.message}</div>`;
} finally {
if (refreshIcon) refreshIcon.classList.remove('animate-spin');
if (registryLoading) registryLoading.classList.add('hidden');
}
}
function formatTimeAgo(dateString) {
if (!dateString) return '<span class="opacity-30">Never</span>';
const date = new Date(dateString);
const now = new Date();
const diffInSeconds = Math.floor((now - date) / 1000);
if (diffInSeconds < 60) return 'Just now';
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
return date.toLocaleDateString();
}
function renderRegistry() {
providerRegistry.innerHTML = '';
const entries = Object.entries(providerData);
if (entries.length === 0) {
providerRegistry.innerHTML = '<div class="col-span-full text-center py-16 opacity-50 font-medium italic">No providers configured yet.</div>';
return;
}
for (const [name, config] of entries) {
const status = tokenStatus[name] || { accessToken: null, refreshToken: null, lastUpdated: null };
const card = document.createElement('div');
card.className = "card bg-base-200/50 border border-base-300 shadow-sm hover:shadow-md transition-all group";
card.innerHTML = `
<div class="card-body p-5">
<div class="flex flex-col mb-4">
<span class="text-lg font-black text-base-content/90 uppercase">${name}</span>
<span class="text-xs opacity-40 truncate font-mono" title="${config.clientId}">${config.clientId}</span>
</div>
<div class="space-y-4">
<div>
<label class="text-xs uppercase font-semibold opacity-30 block mb-1">Access Token</label>
${renderTokenField(name, 'access', status.accessToken)}
</div>
<div>
<label class="text-xs uppercase font-semibold opacity-30 block mb-1">Refresh Token</label>
${renderTokenField(name, 'refresh', status.refreshToken)}
</div>
</div>
<div class="divider my-3 opacity-10"></div>
<div class="flex justify-between items-center mb-4">
<span class="text-xs font-semibold opacity-30 uppercase">Last Updated</span>
<span class="text-xs font-medium opacity-60">${formatTimeAgo(status.lastUpdated)}</span>
</div>
<div class="flex flex-col gap-2">
<div class="grid grid-cols-2 gap-2">
<a href="/auth/${name}/login" target="_blank" class="btn btn-primary btn-sm shadow-sm">
<i class="ph-bold ph-link"></i> Connect
</a>
<button type="button" onclick="window.editProvider('${name}')" class="btn btn-outline btn-sm">
<i class="ph-bold ph-pencil-simple"></i> Edit
</button>
</div>
<button type="button" onclick="window.forceRefresh('${name}')" id="btn-refresh-${name}"
class="btn btn-ghost btn-sm border border-base-300 w-full">
<i class="ph-duotone ph-arrows-clockwise text-base mr-1" id="icon-refresh-${name}"></i>
<span class="text-[10px] uppercase font-bold tracking-wider">Manual Refresh</span>
</button>
</div>
</div>
`;
providerRegistry.appendChild(card);
}
}
function renderTokenField(provider, type, value) {
if (!value) {
return `<div class="h-8 flex items-center px-3 bg-base-300/30 rounded text-xs italic opacity-40">Not Authenticated</div>`;
}
const id = `token-${provider}-${type}`;
return `
<div class="flex items-center gap-2 bg-base-100 rounded border border-base-300 p-1 pl-3 group/token transition-all focus-within:border-primary/50">
<input type="password" id="${id}" value="${value}" readonly
class="bg-transparent border-none outline-none shadow-none focus:ring-0 text-xs flex-1 min-w-0 font-mono" />
<div class="flex gap-1">
<button onclick="window.toggleToken('${id}')" class="btn btn-ghost btn-xs btn-square">
<i class="ph-duotone ph-eye text-base opacity-50" id="eye-${id}"></i>
</button>
<button onclick="window.copyToken('${value}')" class="btn btn-ghost btn-xs btn-square">
<i class="ph-duotone ph-copy-simple text-base opacity-50"></i>
</button>
</div>
</div>
`;
}
window.toggleToken = (id) => {
const input = document.getElementById(id);
const eye = document.getElementById(`eye-${id}`);
if (input.type === 'password') {
input.type = 'text';
eye.className = 'ph-duotone ph-eye-slash text-base';
} else {
input.type = 'password';
eye.className = 'ph-duotone ph-eye text-base opacity-50';
}
};
window.copyToken = (value) => {
navigator.clipboard.writeText(value).then(() => {
showNotification('Token copied to clipboard!');
}).catch(_err => {
showNotification('Failed to copy token', 'error');
});
};
window.editProvider = (name) => {
const config = providerData[name];
if (!config) return;
document.getElementById('providerName').value = name;
document.getElementById('clientId').value = config.clientId;
document.getElementById('clientSecret').value = config.clientSecret;
document.getElementById('authUrl').value = config.authUrl;
document.getElementById('tokenUrl').value = config.tokenUrl;
setDefaultRedirectUri();
document.getElementById('scope').value = config.scope;
document.getElementById('configForm').scrollIntoView({ behavior: 'smooth' });
document.getElementById('providerName').focus();
};
window.forceRefresh = async (name) => {
const apiKey = apiKeyInput.value.trim();
const btn = document.getElementById(`btn-refresh-${name}`);
const icon = document.getElementById(`icon-refresh-${name}`);
if (btn) btn.classList.add('btn-disabled', 'opacity-50');
if (icon) icon.classList.add('animate-spin');
try {
const response = await fetch(`/api/refresh/${name}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${apiKey}` }
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Refresh failed');
showNotification(`${name} tokens refreshed successfully!`);
// Update local data and re-render
tokenStatus[name] = data.status;
renderRegistry();
} catch (error) {
console.error('Refresh error:', error);
showNotification(error.message, 'error');
} finally {
if (btn) btn.classList.remove('btn-disabled', 'opacity-50');
if (icon) icon.classList.remove('animate-spin');
}
};
configForm.addEventListener('submit', async (e) => {
e.preventDefault();
const apiKey = apiKeyInput.value.trim();
if (!apiKey) {
showNotification('Please enter your Master API Key', 'error');
return;
}
const name = document.getElementById('providerName').value.trim();
const config = {
clientId: document.getElementById('clientId').value.trim(),
clientSecret: document.getElementById('clientSecret').value.trim(),
authUrl: document.getElementById('authUrl').value.trim(),
tokenUrl: document.getElementById('tokenUrl').value.trim(),
redirectUri: document.getElementById('redirectUri').value.trim() || undefined,
scope: document.getElementById('scope').value.trim(),
};
try {
const response = await fetch(`/api/config/${name}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify(config)
});
if (!response.ok) throw new Error(await response.text());
showNotification('Configuration saved successfully!');
fetchProviders();
configForm.reset();
setDefaultRedirectUri();
} catch (error) {
console.error('Error saving config:', error);
showNotification(error.message, 'error');
}
});
document.addEventListener('DOMContentLoaded', () => {
const savedKey = localStorage.getItem('toknd_api_key');
if (savedKey) {
apiKeyInput.value = savedKey;
fetchProviders();
}
setDefaultRedirectUri();
});
apiKeyInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
fetchProviders();
}
});
</script>
</body>
</html>
+203
View File
@@ -0,0 +1,203 @@
document.addEventListener("alpine:init", () => {
const formatTime = (timestamp) => {
if (!timestamp) return "Never";
const date = new Date(timestamp);
const diff = Math.floor((Date.now() - date) / 1000);
if (diff < 60) return "Just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return date.toLocaleDateString();
};
window.Alpine.data("dashboard", ({ initialIsUnlocked }) => ({
apiKey: "",
isUnlocked: initialIsUnlocked,
loading: false,
providers: [],
form: {
providerName: "",
clientId: "",
clientSecret: "",
authUrl: "",
tokenUrl: "",
scope: "public",
},
notification: {
show: false,
message: "",
type: "success",
},
init() {
if (this.isUnlocked) {
this.fetchProviders();
}
},
async unlock() {
if (!this.apiKey) return;
this.loading = true;
try {
const res = await fetch("/app/unlock", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: this.apiKey }),
});
if (!res.ok) throw new Error("Invalid API Key");
this.isUnlocked = true;
await this.fetchProviders();
this.apiKey = ""; // Clear after success
} catch (err) {
this.showNotification(err.message, "error");
this.isUnlocked = false;
} finally {
this.loading = false;
}
},
async logout() {
this.loading = true;
try {
await fetch("/app/logout", { method: "POST" });
this.isUnlocked = false;
this.providers = [];
this.showNotification("Logged out successfully");
} catch (err) {
this.showNotification(`Logout failed: ${err.message}`, "error");
} finally {
this.loading = false;
}
},
async fetchProviders() {
this.loading = true;
try {
const [configRes, statusRes] = await Promise.all([
fetch("/api/config"),
fetch("/api/status"),
]);
if (configRes.status === 401 || statusRes.status === 401) {
return this.handleSessionExpired();
}
if (!configRes.ok || !statusRes.ok) throw new Error("Failed to fetch data");
const [config, status] = await Promise.all([configRes.json(), statusRes.json()]);
this.providers = this.mapProviders(config, status);
} catch (err) {
this.showNotification(err.message, "error");
} finally {
this.loading = false;
}
},
mapProviders(config, status) {
return Object.entries(config).map(([name, cfg]) => ({
name,
config: cfg,
status: status[name] || { accessToken: null, refreshToken: null, lastUpdated: null },
}));
},
handleSessionExpired() {
this.isUnlocked = false;
this.providers = [];
this.showNotification("Session expired", "error");
},
async saveConfig() {
this.loading = true;
try {
const res = await fetch(`/api/config/${this.form.providerName}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.form),
});
if (res.status === 401) {
this.isUnlocked = false;
throw new Error("Session expired");
}
if (!res.ok) throw new Error("Failed to save");
this.showNotification("Saved successfully");
await this.fetchProviders();
this.form = {
providerName: "",
clientId: "",
clientSecret: "",
authUrl: "",
tokenUrl: "",
scope: "public",
};
} catch (err) {
this.showNotification(err.message, "error");
} finally {
this.loading = false;
}
},
async forceRefresh(name) {
this.loading = true;
try {
const res = await fetch(`/api/refresh/${name}`, {
method: "POST",
});
if (res.status === 401) {
this.isUnlocked = false;
throw new Error("Session expired");
}
if (!res.ok) throw new Error("Refresh failed");
this.showNotification(`Refreshed ${name}`);
await this.fetchProviders();
} catch (err) {
this.showNotification(err.message, "error");
} finally {
this.loading = false;
}
},
editProvider(provider) {
this.form = {
providerName: provider.name,
clientId: provider.config.clientId,
clientSecret: provider.config.clientSecret,
authUrl: provider.config.authUrl,
tokenUrl: provider.config.tokenUrl,
scope: provider.config.scope,
};
window.scrollTo({ top: 0, behavior: "smooth" });
},
getRedirectUri() {
return `${window.location.origin}/auth/${this.form.providerName || "{provider}"}/callback`;
},
copyToClipboard(text) {
if (!text) return;
navigator.clipboard.writeText(text).then(() => {
this.showNotification("Copied");
});
},
showNotification(message, type = "success") {
this.notification = { show: true, message, type };
setTimeout(() => {
this.notification.show = false;
}, 3000);
},
formatTime(timestamp) {
return formatTime(timestamp);
},
}));
});
-52
View File
@@ -1,52 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="abyss">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>toknd — Authentication Successful</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css" />
<script src="https://unpkg.com/@phosphor-icons/web@2.1.1"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap" rel="stylesheet">
<style>
body {
font-family: "DM Sans", sans-serif;
letter-spacing: -0.01em;
}
.font-mono {
font-family: "DM Mono", monospace;
}
</style>
</head>
<body class="bg-base-200/50 min-h-screen flex items-center justify-center p-4 antialiased">
<div class="card bg-base-100 shadow-xl border border-base-300 max-w-md w-full">
<div class="card-body items-center text-center p-8 md:p-12">
<div
class="w-20 h-20 bg-success/10 text-success rounded-2xl flex items-center justify-center mb-6 shadow-inner animate-pulse">
<i class="ph ph-check-circle text-5xl"></i>
</div>
<h2 class="card-title text-3xl font-black tracking-tight mb-2">Authenticated!</h2>
<p class="text-base-content/60 leading-relaxed">
Successfully connected to <span class="font-bold text-base-content">__PROVIDER_NAME__</span>.
You can now close this window or return to the dashboard.
</p>
<div class="divider my-8 opacity-50"></div>
<div class="card-actions w-full">
<a href="/app" class="btn btn-primary btn-block shadow-lg hover:shadow-primary/20 transition-all">
<i class="ph ph-house-bold mr-2"></i>
Back to Dashboard
</a>
</div>
</div>
</div>
</body>
</html>
+62
View File
@@ -0,0 +1,62 @@
// @ts-nocheck
import { describe, expect, it, mock } from "bun:test";
import { ConfigManager } from "../../src/core/ConfigManager";
describe("ConfigManager", () => {
it("should save and retrieve provider configuration", async () => {
const storage = {};
const redis = {
set: mock((key, val) => {
storage[key] = val;
return Promise.resolve();
}),
get: mock((key) => Promise.resolve(storage[key] || null)),
};
const manager = new ConfigManager(redis);
const traktConfig = {
clientId: "trakt-client-id",
clientSecret: "trakt-client-secret",
authUrl: "https://trakt.tv/oauth/authorize",
tokenUrl: "https://api.trakt.tv/oauth/token",
scope: "public",
};
await manager.setProviderConfig("trakt", traktConfig);
const retrieved = await manager.getProviderConfig("trakt");
expect(retrieved).toEqual(traktConfig);
expect(redis.set).toHaveBeenCalled();
});
it("should return all providers", async () => {
const redis = {
keys: mock(() => Promise.resolve(["config:trakt", "config:github"])),
get: mock((key) =>
Promise.resolve(
JSON.stringify({
clientId: `${key}-id`,
clientSecret: "secret",
authUrl: "https://auth.com",
tokenUrl: "https://token.com",
scope: "all",
}),
),
),
};
const manager = new ConfigManager(redis);
const providers = await manager.getAllProviders();
expect(Object.keys(providers)).toHaveLength(2);
expect(providers.trakt.clientId).toBe("config:trakt-id");
});
it("should return null for non-existent provider", async () => {
const redis = { get: mock(() => Promise.resolve(null)) };
const manager = new ConfigManager(redis);
const config = await manager.getProviderConfig("missing-provider");
expect(config).toBeNull();
});
});
+38 -27
View File
@@ -1,65 +1,76 @@
// @ts-nocheck
import { describe, expect, it, mock } from "bun:test";
import { TokenManager } from "../../src/core/TokenManager";
describe("TokenManager", () => {
it("should return token from redis if available", async () => {
const redisMock = { get: mock(() => Promise.resolve("valid_token")) };
const manager = new TokenManager(redisMock as any, {} as any);
const redis = { get: mock(() => Promise.resolve("active-access-token")) };
const manager = new TokenManager(redis, {});
const token = await manager.getAccessToken("trakt");
expect(token).toBe("valid_token");
expect(token).toBe("active-access-token");
});
it("should refresh token if access token is missing but refresh token exists", async () => {
const redisMock = {
get: mock((key: string) => Promise.resolve(key.includes("refresh") ? "refresh_token" : null)),
const redis = {
get: mock((key) => Promise.resolve(key.includes("refresh") ? "valid-refresh-token" : null)),
set: mock(() => Promise.resolve()),
};
const providerMock = {
const provider = {
refreshToken: mock(() =>
Promise.resolve({
accessToken: "new_token",
refreshToken: "new_refresh",
accessToken: "newly-refreshed-access-token",
refreshToken: "newly-refreshed-refresh-token",
expiresIn: 3600,
}),
),
};
const manager = new TokenManager(redisMock as any, providerMock as any);
const manager = new TokenManager(redis, provider);
const token = await manager.getAccessToken("trakt");
expect(token).toBe("new_token");
expect(redisMock.set).toHaveBeenCalled();
expect(token).toBe("newly-refreshed-access-token");
expect(redis.set).toHaveBeenCalled();
});
it("should return null if no tokens are found", async () => {
const redisMock = { get: mock(() => Promise.resolve(null)) };
const manager = new TokenManager(redisMock as any, {} as any);
const redis = { get: mock(() => Promise.resolve(null)) };
const manager = new TokenManager(redis, {});
const token = await manager.getAccessToken("trakt");
expect(token).toBeNull();
});
it("should refresh token via forceRefresh", async () => {
const redisMock = {
get: mock(() => Promise.resolve("refresh_token")),
it("should refresh token via refreshAccessToken", async () => {
const redis = {
get: mock(() => Promise.resolve("existing-refresh-token")),
set: mock(() => Promise.resolve()),
};
const providerMock = {
const provider = {
refreshToken: mock(() =>
Promise.resolve({
accessToken: "forced_token",
refreshToken: "new_refresh",
accessToken: "manually-refreshed-access-token",
refreshToken: "manually-refreshed-refresh-token",
expiresIn: 3600,
}),
),
};
const manager = new TokenManager(redisMock as any, providerMock as any);
const token = await manager.forceRefresh("trakt");
expect(token).toBe("forced_token");
expect(providerMock.refreshToken).toHaveBeenCalledWith("refresh_token");
const manager = new TokenManager(redis, provider);
const token = await manager.refreshAccessToken("trakt");
expect(token).toBe("manually-refreshed-access-token");
expect(provider.refreshToken).toHaveBeenCalledWith("existing-refresh-token");
});
it("should return null in forceRefresh if no refresh token is found", async () => {
const redisMock = { get: mock(() => Promise.resolve(null)) };
const manager = new TokenManager(redisMock as any, {} as any);
const token = await manager.forceRefresh("trakt");
it("should return null in refreshAccessToken if no refresh token is found", async () => {
const redis = { get: mock(() => Promise.resolve(null)) };
const manager = new TokenManager(redis, {});
const token = await manager.refreshAccessToken("trakt");
expect(token).toBeNull();
});
});
+165
View File
@@ -0,0 +1,165 @@
// @ts-nocheck
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test";
import { redis } from "../../src/core/RedisClient";
import { app } from "../../src/index";
describe("API Integration", () => {
afterEach(() => {
mock.restore();
redis.get.mockImplementation(() => Promise.resolve(null));
redis.set.mockImplementation(() => Promise.resolve());
redis.keys.mockImplementation(() => Promise.resolve([]));
});
const mockTraktConfig = JSON.stringify({
clientId: "trakt-client-id",
clientSecret: "trakt-client-secret",
authUrl: "https://trakt.tv/oauth/authorize",
tokenUrl: "https://api.trakt.tv/oauth/token",
scope: "public",
});
it("should return 401 if API Key is missing", async () => {
const res = await app.request("/api/status");
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Missing or invalid authorization" });
});
it("should return 200 for health check (no auth needed)", async () => {
const res = await app.request("/health");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe("ok");
});
it("should return 503 for health check if redis is down", async () => {
redis.status = "connecting";
const res = await app.request("/health");
const body = await res.json();
expect(res.status).toBe(503);
expect(body.status).toBe("error");
expect(body.redis).toBe("connecting");
});
it("should return 200 for status with valid API Key", async () => {
redis.keys.mockReturnValue(Promise.resolve(["config:trakt"]));
redis.get.mockImplementation((key) => {
if (key.includes("config")) return Promise.resolve(mockTraktConfig);
if (key.includes("access_token")) return Promise.resolve("current-access-token");
if (key.includes("refresh_token")) return Promise.resolve("current-refresh-token");
return Promise.resolve(null);
});
const res = await app.request("/api/status", {
headers: {
Authorization: "Bearer test-api-key",
},
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.trakt).toBeDefined();
expect(body.trakt.accessToken).toBe("current-access-token");
});
it("should return 200 for status with valid Cookie", async () => {
redis.keys.mockReturnValue(Promise.resolve(["config:trakt"]));
redis.get.mockImplementation((key) => {
if (key.includes("config")) return Promise.resolve(mockTraktConfig);
if (key.includes("access_token")) return Promise.resolve("current-access-token");
return Promise.resolve(null);
});
const res = await app.request("/api/status", {
headers: {
Cookie: "toknd_api_key=test-api-key",
},
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.trakt).toBeDefined();
});
it("should return 404 for unknown provider token", async () => {
const res = await app.request("/api/token/unconfigured-provider", {
headers: {
Authorization: "Bearer test-api-key",
},
});
expect(res.status).toBe(404);
});
it("should return token for a configured provider", async () => {
redis.get.mockImplementation((key) => {
if (key.includes("config:trakt")) return Promise.resolve(mockTraktConfig);
if (key.includes("access_token")) return Promise.resolve("trakt-active-token");
return Promise.resolve(null);
});
const res = await app.request("/api/token/trakt", {
headers: {
Authorization: "Bearer test-api-key",
},
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.access_token).toBe("trakt-active-token");
});
it("should return 404 if no access token is in redis for a valid provider", async () => {
redis.get.mockImplementation((key) => {
if (key.includes("config:trakt")) return Promise.resolve(mockTraktConfig);
return Promise.resolve(null);
});
const res = await app.request("/api/token/trakt", {
headers: {
Authorization: "Bearer test-api-key",
},
});
expect(res.status).toBe(404);
const body = await res.json();
expect(body.error).toContain("No tokens found");
});
it("should successfully refresh a token", async () => {
redis.get.mockImplementation((key) => {
if (key.includes("config:trakt")) return Promise.resolve(mockTraktConfig);
if (key.includes("refresh_token")) return Promise.resolve("old-refresh-token");
return Promise.resolve("new-access-token-from-refresh");
});
spyOn(globalThis, "fetch").mockImplementation(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
access_token: "new-access-token-from-fetch",
refresh_token: "new-refresh-token-from-fetch",
expires_in: 3600,
}),
}),
);
const res = await app.request("/api/refresh/trakt", {
method: "POST",
headers: {
Authorization: "Bearer test-api-key",
},
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.success).toBe(true);
expect(body.status.accessToken).toBe("new-access-token-from-refresh");
});
});
+70
View File
@@ -0,0 +1,70 @@
// @ts-nocheck
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test";
import { redis } from "../../src/core/RedisClient";
import { app } from "../../src/index";
describe("Auth Integration", () => {
afterEach(() => {
mock.restore();
redis.get.mockImplementation(() => Promise.resolve(null));
});
const mockProviderConfig = JSON.stringify({
clientId: "trakt-client-id",
clientSecret: "trakt-client-secret",
authUrl: "https://trakt.tv/oauth/authorize",
tokenUrl: "https://api.trakt.tv/oauth/token",
scope: "public",
});
it("should redirect to provider login", async () => {
redis.get.mockImplementation((key) => {
if (key.includes("config:trakt")) return Promise.resolve(mockProviderConfig);
return Promise.resolve(null);
});
const res = await app.request("/auth/trakt/login");
expect(res.status).toBe(302);
expect(res.headers.get("Location")).toContain("trakt.tv/oauth/authorize");
expect(res.headers.get("Location")).toContain("client_id=trakt-client-id");
});
it("should handle callback and exchange code", async () => {
redis.get.mockImplementation((key) => {
if (key.includes("config:trakt")) return Promise.resolve(mockProviderConfig);
return Promise.resolve(null);
});
const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
access_token: "exchange-access-token",
refresh_token: "exchange-refresh-token",
expires_in: 3600,
}),
}),
);
const res = await app.request("/auth/callback?state=trakt&code=temporary-auth-code");
expect(res.status).toBe(302);
expect(res.headers.get("Location")).toBe("/app/success?provider=trakt");
expect(redis.set).toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalled();
});
it("should return 404 if provider not configured during login", async () => {
const res = await app.request("/auth/unknown-provider/login");
expect(res.status).toBe(404);
});
it("should return 400 if callback is missing state or code", async () => {
const res = await app.request("/auth/callback?code=some-code");
expect(res.status).toBe(400);
});
});
+81
View File
@@ -0,0 +1,81 @@
// @ts-nocheck
import { afterEach, describe, expect, it, mock } from "bun:test";
import { redis } from "../../src/core/RedisClient";
import { app } from "../../src/index";
describe("Config Integration", () => {
afterEach(() => {
mock.restore();
redis.get.mockImplementation(() => Promise.resolve(null));
redis.set.mockImplementation(() => Promise.resolve());
redis.keys.mockImplementation(() => Promise.resolve([]));
});
it("should list all configured providers", async () => {
redis.keys.mockReturnValue(Promise.resolve(["config:trakt"]));
redis.get.mockImplementation(() =>
Promise.resolve(
JSON.stringify({
clientId: "trakt-client-id",
clientSecret: "trakt-client-secret",
authUrl: "https://trakt.tv/oauth/authorize",
tokenUrl: "https://api.trakt.tv/oauth/token",
scope: "public",
}),
),
);
const res = await app.request("/api/config", {
headers: {
Authorization: "Bearer test-api-key",
},
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.trakt).toBeDefined();
expect(body.trakt.clientId).toBe("trakt-client-id");
});
it("should set a new provider config", async () => {
const newProviderConfig = {
clientId: "github-client-id",
clientSecret: "github-client-secret",
authUrl: "https://github.com/login/oauth/authorize",
tokenUrl: "https://github.com/login/oauth/access_token",
scope: "user:email",
};
const res = await app.request("/api/config/github", {
method: "POST",
headers: {
Authorization: "Bearer test-api-key",
"Content-Type": "application/json",
},
body: JSON.stringify(newProviderConfig),
});
expect(res.status).toBe(200);
expect(redis.set).toHaveBeenCalledWith(
"config:github",
expect.stringContaining("github-client-id"),
);
});
it("should return 400 for invalid config body", async () => {
const invalidConfig = {
clientId: "missing-other-required-fields",
};
const res = await app.request("/api/config/invalid", {
method: "POST",
headers: {
Authorization: "Bearer test-api-key",
"Content-Type": "application/json",
},
body: JSON.stringify(invalidConfig),
});
expect(res.status).toBe(400);
});
});
+58
View File
@@ -0,0 +1,58 @@
// @ts-nocheck
import { describe, expect, it } from "bun:test";
import { redis } from "../../src/core/RedisClient";
import { app } from "../../src/index";
describe("Dashboard & Common Integration", () => {
it("should serve the dashboard HTML", async () => {
const res = await app.request("/app");
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toContain("text/html");
const html = await res.text();
expect(html).toContain("<!DOCTYPE html>");
});
it("should return 404 with custom handler for unknown routes", async () => {
const res = await app.request("/unknown-route");
expect(res.status).toBe(404);
const body = await res.json();
expect(body.error).toBe("Not Found");
});
it("should return 500 for internal errors", async () => {
redis.keys.mockImplementationOnce(() => {
throw new Error("Redis Crash");
});
const res = await app.request("/api/status", {
headers: { Authorization: "Bearer test-api-key" },
});
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toBe("Internal Server Error");
});
it("should set a cookie on successful unlock", async () => {
const res = await app.request("/app/unlock", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: "test-api-key" }),
});
expect(res.status).toBe(200);
expect(res.headers.get("Set-Cookie")).toContain("toknd_api_key=test-api-key");
expect(res.headers.get("Set-Cookie")).toContain("HttpOnly");
});
it("should clear the cookie on logout", async () => {
const res = await app.request("/app/logout", {
method: "POST",
});
expect(res.status).toBe(200);
expect(res.headers.get("Set-Cookie")).toContain("toknd_api_key=;");
});
});
+69
View File
@@ -0,0 +1,69 @@
// @ts-nocheck
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test";
import { GenericProvider } from "../../src/providers/GenericProvider";
describe("GenericProvider", () => {
const traktConfig = {
clientId: "trakt-client-id",
clientSecret: "trakt-client-secret",
authUrl: "https://trakt.tv/oauth/authorize",
tokenUrl: "https://api.trakt.tv/oauth/token",
scope: "public",
};
afterEach(() => {
mock.restore();
});
it("should generate correct auth URL", () => {
const provider = new GenericProvider("trakt", traktConfig);
const url = provider.getAuthUrl("random-state-123", "https://callback.com");
expect(url).toContain("client_id=trakt-client-id");
expect(url).toContain("redirect_uri=https%3A%2F%2Fcallback.com");
expect(url).toContain("state=random-state-123");
});
it("should handle token response with string expiry", async () => {
const provider = new GenericProvider("trakt", traktConfig);
const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
access_token: "new-access-token",
refresh_token: "new-refresh-token",
expires_in: "7200",
}),
text: () => Promise.resolve(""),
}),
);
const tokens = await provider.refreshToken("old-refresh-token");
expect(tokens.accessToken).toBe("new-access-token");
expect(tokens.expiresIn).toBe(7200);
expect(fetchSpy).toHaveBeenCalled();
});
it("should handle token response without new refresh token", async () => {
const provider = new GenericProvider("trakt", traktConfig);
spyOn(globalThis, "fetch").mockImplementation(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
access_token: "new-access-token-only",
expires_in: 3600,
}),
text: () => Promise.resolve(""),
}),
);
const tokens = await provider.refreshToken("existing-refresh-token");
expect(tokens.accessToken).toBe("new-access-token-only");
expect(tokens.refreshToken).toBe("existing-refresh-token");
});
});
+18
View File
@@ -0,0 +1,18 @@
import { mock } from "bun:test";
// Global test setup to stub environment variables
process.env.API_KEY = "test-api-key";
process.env.REDIS_HOST = "localhost";
process.env.REDIS_PORT = "6379";
process.env.APP_PORT = "3000";
// Global Redis mock
mock.module("../src/core/RedisClient", () => ({
redis: {
status: "ready",
get: mock(() => Promise.resolve(null)),
set: mock(() => Promise.resolve()),
keys: mock(() => Promise.resolve([])),
on: mock(() => {}),
},
}));
+4 -2
View File
@@ -5,8 +5,10 @@
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"lib": ["ESNext"],
"types": ["node"]
"types": ["node", "bun-types"]
},
"include": ["src/**/*"]
"include": ["src/**/*", "tests/**/*"]
}