25 Commits

Author SHA1 Message Date
ramvignesh-b a004322970 test: refactor test setup for dynamic redis client imports
CI / build (pull_request) Successful in 22s
2026-05-12 05:46:31 +05:30
ramvignesh-b 282618bc1c test: migrate global test prefixes to explicit imports
CI / build (pull_request) Failing after 20s
2026-05-12 05:42:27 +05:30
ramvignesh-b dcf5cd3551 refactor: modularize OpenAPI configuration and extract version constants into separate files
CI / build (pull_request) Failing after 22s
2026-05-12 05:27:29 +05:30
ramvignesh-b 9edb7fd989 refactor: update OpenAPI documentation metadata 2026-05-12 05:20:32 +05:30
ramvignesh-b 0a276b9a63 refactor: centralize versioning and path constants and standardize routes across the application and test suites 2026-05-12 05:17:07 +05:30
ramvignesh-b 5d8a9ccb3e refactor: update API documentation routes and paths to v1 naming convention 2026-05-12 05:01:58 +05:30
ramvignesh-b cf37904083 feat: version API and auth endpoints under /v1 prefix and update documentation labels 2026-05-12 04:57:19 +05:30
ramvignesh-b f3349fced4 feat: improve OpenAPI documentation by adding tags and route summaries 2026-05-12 04:52:03 +05:30
ramvignesh-b 4554b2e734 feat: add API reference link to dashboard navigation bar 2026-05-12 04:46:42 +05:30
ramvignesh-b 106edb8bb7 feat: hide api key input when authenticated 2026-05-12 04:42:33 +05:30
ramvignesh-b f89b5b4437 feat: add functionality to delete provider configurations and associated tokens via UI and API 2026-05-12 04:40:55 +05:30
me 558bc9e034 feat: implement secure session management using HttpOnly cookies for API key authentication (#5)
CI / build (push) Successful in 27s
Co-authored-by: ramvignesh-b <ramvignesh-b@github.com>
Reviewed-on: #5
2026-05-11 22:56:21 +00:00
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
35 changed files with 1619 additions and 722 deletions
+3 -4
View File
@@ -1,6 +1,5 @@
# Core Server Configuration APP_PORT=3000
PORT=3000
API_KEY=your_secret_api_key_here API_KEY=your_secret_api_key_here
# Redis Configuration (Use redis://redis:6379 for Docker) REDIS_HOST=redis
REDIS_URL=redis://localhost:6379 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 USER bun
EXPOSE 3000 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"]
+43 -27
View File
@@ -20,47 +20,63 @@
- **Styling**: Tailwind CSS & DaisyUI - **Styling**: Tailwind CSS & DaisyUI
- **Schema & Validation**: Zod - **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 ### 1. Environment Setup
Clone the repository and create your environment file: Clone the repository and create your environment file:
```bash ```bash
cp .env.example .env 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) ### 2. Choose Deployment Method
We use a Docker Compose override system to enable hot-reloading locally:
#### 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 ```bash
podman compose up --build podman compose up --build
``` ```
*Note: This mounts your ./src directory into the container and uses bun --hot to restart on any code changes.* - **Production**:
### 3. Production Deployment
For production, only the core docker-compose.yml is used:
```bash ```bash
docker compose up -d --build docker compose up -d --build
``` ```
## API Reference #### Option B: Self-Hosting (Bare Metal)
Ideal for lightweight deployments or custom environments where you already have Bun and Redis.
All protected endpoints require an Authorization header: 1. **Install Dependencies**:
`Authorization: Bearer <your_master_api_key>` ```bash
bun install
### Token Brokerage ```
- **Get Valid Token**: `GET /api/token/:provider` 2. **Start the Server**:
- Returns a valid access token. Automatically triggers a refresh if the current one is expired. - **Development**: `bun run dev` (with hot-reload)
- **Registry Status**: `GET /api/status` - **Production**: `bun run start`
- Returns the connectivity and refresh status of all configured providers. *Note: Ensure your Redis server is running and accessible via the `REDIS_URL` in your `.env`.*
### Authentication Flow ---
1. **Initiate**: `GET /auth/:provider/login`
2. **Callback**: `GET /auth/callback` (Handled internally by toknd) ## API Reference
## Dashboard toknd provides a built-in **Scalar API Reference** that allows you to explore and test all endpoints directly from your browser.
Access the toknd dashboard at:
`http://localhost:3000/app` - **Interactive UI**: [http://localhost:3000/api](http://localhost:3000/api) (or `/docs`)
- **OpenAPI Spec (JSON)**: [http://localhost:3000/doc](http://localhost:3000/doc)
Authenticate the registry using your Master API Key to manage your providers and view live token status.
All protected endpoints require a Bearer token in the `Authorization` header:
`Authorization: Bearer <your_master_api_key>`
### 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:
`http://localhost:3000/app`
The dashboard allows you to manage provider configurations, view live token statuses, and manually trigger refreshes. Authenticate using your **Master API Key**.
--- ---
+12
View File
@@ -61,5 +61,17 @@
"organizeImports": "on" "organizeImports": "on"
} }
} }
},
"overrides": [
{
"includes": ["tests/**/*"],
"linter": {
"rules": {
"suspicious": {
"noExplicitAny": "off"
} }
} }
}
}
]
}
+3
View File
@@ -15,6 +15,7 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.15", "@biomejs/biome": "2.4.15",
"@types/node": "^22.19.18", "@types/node": "^22.19.18",
"bun-types": "^1.3.13",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^17.0.4", "lint-staged": "^17.0.4",
"typescript": "^5.9.3", "typescript": "^5.9.3",
@@ -66,6 +67,8 @@
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "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-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=="], "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: . build: .
restart: always restart: always
ports: ports:
- "${PORT:-3000}:3000" - "${APP_PORT:-3000}:3000"
environment: environment:
- REDIS_URL=redis://redis:6379 - REDIS_HOST=redis
- REDIS_PORT=6379
- API_KEY=${API_KEY} - API_KEY=${API_KEY}
depends_on: depends_on:
- redis - redis
@@ -13,6 +14,8 @@ services:
redis: redis:
image: redis:alpine image: redis:alpine
restart: always restart: always
ports:
- "${REDIS_PORT:-6379}:6379"
volumes: volumes:
- redis-data:/data - redis-data:/data
+3
View File
@@ -4,8 +4,10 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "bun run --hot src/index.ts", "dev": "bun run --hot src/index.ts",
"start": "bun src/index.ts",
"test": "bun test", "test": "bun test",
"lint": "bunx @biomejs/biome check src", "lint": "bunx @biomejs/biome check src",
"check-all": "bunx @biomejs/biome check .",
"format": "bunx @biomejs/biome format --write src", "format": "bunx @biomejs/biome format --write src",
"prepare": "husky" "prepare": "husky"
}, },
@@ -20,6 +22,7 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.15", "@biomejs/biome": "2.4.15",
"@types/node": "^22.19.18", "@types/node": "^22.19.18",
"bun-types": "^1.3.13",
"husky": "^9.1.7", "husky": "^9.1.7",
"lint-staged": "^17.0.4", "lint-staged": "^17.0.4",
"typescript": "^5.9.3" "typescript": "^5.9.3"
+3 -2
View File
@@ -1,8 +1,9 @@
import { z } from "zod"; import { z } from "zod";
const configSchema = z.object({ const configSchema = z.object({
PORT: z.string().default("3000"), APP_PORT: z.string().default("3000"),
REDIS_URL: z.string(), REDIS_HOST: z.string().default("redis"),
REDIS_PORT: z.coerce.number().default(6379),
API_KEY: z.string(), API_KEY: z.string(),
}); });
+6
View File
@@ -0,0 +1,6 @@
import { API_VERSION, APP_VERSION } from "./version";
export { API_VERSION, APP_VERSION };
export const API_PREFIX = `/api/${API_VERSION}`;
export const AUTH_PREFIX = `/${API_VERSION}/auth`;
export const DOCS_PREFIX = `/docs/${API_VERSION}`;
+9
View File
@@ -40,4 +40,13 @@ export class ConfigManager {
return result; return result;
} }
async deleteProviderConfig(provider: string): Promise<void> {
await this.redis.del(`config:${provider}`);
// Also clean up tokens
const tokenKeys = await this.redis.keys(`provider:${provider}:*`);
if (tokenKeys.length > 0) {
await this.redis.del(...tokenKeys);
}
}
} }
+4 -1
View File
@@ -1,4 +1,7 @@
import { Redis } from "ioredis"; import { Redis } from "ioredis";
import { config } from "../config"; 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,
});
+23 -21
View File
@@ -1,8 +1,12 @@
import { OpenAPIHono } from "@hono/zod-openapi"; import { OpenAPIHono } from "@hono/zod-openapi";
import { Scalar } from "@scalar/hono-api-reference"; import { Scalar } from "@scalar/hono-api-reference";
import { serveStatic } from "hono/bun";
import { logger } from "hono/logger"; import { logger } from "hono/logger";
import { prettyJSON } from "hono/pretty-json"; import { prettyJSON } from "hono/pretty-json";
import { config } from "./config"; import { config } from "./config";
import { API_PREFIX, AUTH_PREFIX, DOCS_PREFIX } from "./constants";
import { redis } from "./core/RedisClient";
import { openApiSpec, securityScheme } from "./openapi";
import { apiRoutes } from "./routes/api"; import { apiRoutes } from "./routes/api";
import { authRoutes } from "./routes/auth"; import { authRoutes } from "./routes/auth";
import { configRoutes } from "./routes/config"; import { configRoutes } from "./routes/config";
@@ -11,38 +15,29 @@ import { dashboardRoutes } from "./routes/dashboard";
const app = new OpenAPIHono({ strict: false }); const app = new OpenAPIHono({ strict: false });
// OpenAPI specs // OpenAPI specs
app.doc("/doc", { app.doc(`${DOCS_PREFIX}/openapi.json`, openApiSpec);
openapi: "3.0.0", app.openAPIRegistry.registerComponent("securitySchemes", "API_KEY", securityScheme);
info: {
version: "1.0.0",
title: "toknd — Auth Broker API",
description: "Centralized token management and OAuth2 broker service.",
},
});
app.openAPIRegistry.registerComponent("securitySchemes", "API_KEY", {
type: "http",
scheme: "bearer",
});
// Scalar API Reference // Scalar API Reference
app.get( app.get(
"/api", DOCS_PREFIX,
Scalar({ Scalar({
theme: "solarized", theme: "solarized",
url: "/doc", url: `${DOCS_PREFIX}/openapi.json`,
}), }),
); );
app.get("/docs", (c) => c.redirect("/api")); app.get("/docs", (c) => c.redirect(DOCS_PREFIX));
app.get("/api", (c) => c.redirect(DOCS_PREFIX));
app.use("*", logger()); app.use("*", logger());
app.use("*", prettyJSON()); app.use("*", prettyJSON());
app.get("/", (c) => c.redirect("/app")); app.get("/", (c) => c.redirect("/app"));
app.route("/auth", authRoutes); app.get("/app/dashboard.js", serveStatic({ path: "./src/views/dashboard.js" }));
app.route("/api/config", configRoutes); app.route(AUTH_PREFIX, authRoutes);
app.route("/api", apiRoutes); app.route(`${API_PREFIX}/config`, configRoutes);
app.route(API_PREFIX, apiRoutes);
app.route("/app", dashboardRoutes); app.route("/app", dashboardRoutes);
app.notFound((c) => { app.notFound((c) => {
@@ -63,9 +58,16 @@ app.onError((err, c) => {
return c.json({ error: "Internal Server Error", message: err.message }, 500); 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 { export default {
port: Number.parseInt(config.PORT, 10), port: Number.parseInt(config.APP_PORT, 10),
fetch: app.fetch, fetch: app.fetch,
}; };
+11 -3
View File
@@ -1,14 +1,22 @@
import type { Context, Next } from "hono"; import type { Context, Next } from "hono";
import { getCookie } from "hono/cookie";
import { config } from "../config"; import { config } from "../config";
export const authMiddleware = async (c: Context, next: Next) => { export const authMiddleware = async (c: Context, next: Next) => {
const authHeader = c.req.header("Authorization"); const authHeader = c.req.header("Authorization");
const cookieToken = getCookie(c, "toknd_api_key");
if (!authHeader?.startsWith("Bearer ")) { let token: string | undefined;
return c.json({ error: "Missing or invalid authorization header" }, 401);
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) { if (token !== config.API_KEY) {
return c.json({ error: "Invalid API key" }, 403); return c.json({ error: "Invalid API key" }, 403);
+31
View File
@@ -0,0 +1,31 @@
import { API_VERSION, APP_VERSION } from "./constants";
export const openApiSpec = {
openapi: "3.0.0",
info: {
version: `${API_VERSION}.${APP_VERSION}`,
title: "toknd Auth Broker API",
description:
"A high-performance OAuth2 broker and token management service. Designed to centralize provider configurations and automate token lifecycle management across distributed systems.",
},
tags: [
{
name: "Tokens",
description: "Endpoint operations for accessing and force-refreshing active provider tokens.",
},
{
name: "Management",
description: "Administrative operations for provider lifecycle and configuration.",
},
{
name: "Auth (Internal)",
description: "System-level OAuth2 handshake and callback processing.",
},
],
security: [{ API_KEY: [] }],
};
export const securityScheme = {
type: "http",
scheme: "bearer",
} as const;
+1 -1
View File
@@ -5,7 +5,7 @@ import type { OAuthProvider, TokenResponse } from "./interface";
const TokenResponseSchema = z.object({ const TokenResponseSchema = z.object({
access_token: z.string(), access_token: z.string(),
refresh_token: z.string().optional(), refresh_token: z.string().optional(),
expires_in: z.number(), expires_in: z.coerce.number(),
}); });
export class GenericProvider implements OAuthProvider { export class GenericProvider implements OAuthProvider {
+3
View File
@@ -47,6 +47,7 @@ const statusRoute = createRoute({
method: "get", method: "get",
path: "/status", path: "/status",
security: [{ API_KEY: [] }], security: [{ API_KEY: [] }],
tags: ["Tokens"],
responses: { responses: {
200: { 200: {
content: { "application/json": { schema: StatusResponseSchema } }, content: { "application/json": { schema: StatusResponseSchema } },
@@ -59,6 +60,7 @@ const tokenRoute = createRoute({
method: "get", method: "get",
path: "/token/{provider}", path: "/token/{provider}",
security: [{ API_KEY: [] }], security: [{ API_KEY: [] }],
tags: ["Tokens"],
request: { request: {
params: z.object({ params: z.object({
provider: z.string().openapi({ example: "trakt" }), provider: z.string().openapi({ example: "trakt" }),
@@ -80,6 +82,7 @@ const refreshRoute = createRoute({
method: "post", method: "post",
path: "/refresh/{provider}", path: "/refresh/{provider}",
security: [{ API_KEY: [] }], security: [{ API_KEY: [] }],
tags: ["Tokens"],
request: { request: {
params: z.object({ params: z.object({
provider: z.string().openapi({ example: "trakt" }), provider: z.string().openapi({ example: "trakt" }),
+5 -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 { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
import { ConfigManager } from "../core/ConfigManager"; import { ConfigManager } from "../core/ConfigManager";
import { redis } from "../core/RedisClient"; import { redis } from "../core/RedisClient";
@@ -19,6 +17,8 @@ const AuthErrorResponse = z
const loginRoute = createRoute({ const loginRoute = createRoute({
method: "get", method: "get",
path: "/{provider}/login", path: "/{provider}/login",
tags: ["Auth (Internal)"],
summary: "Start OAuth2 flow (Managed by System)",
request: { request: {
params: z.object({ params: z.object({
provider: z.string().openapi({ example: "trakt" }), provider: z.string().openapi({ example: "trakt" }),
@@ -38,6 +38,8 @@ const loginRoute = createRoute({
const callbackRoute = createRoute({ const callbackRoute = createRoute({
method: "get", method: "get",
path: "/callback", path: "/callback",
tags: ["Auth (Internal)"],
summary: "OAuth2 callback handler (Managed by System)",
request: { request: {
query: z.object({ query: z.object({
state: z state: z
@@ -115,10 +117,7 @@ authRoutes.openapi(callbackRoute, async (c) => {
const tokens = await provider.exchangeCode(code, redirectUri); const tokens = await provider.exchangeCode(code, redirectUri);
await tokenManager.saveTokens(providerName, tokens); await tokenManager.saveTokens(providerName, tokens);
const htmlPath = join(process.cwd(), "src/views/success.html"); return c.redirect(`/app/success?provider=${providerName}`);
let html = await readFile(htmlPath, "utf-8");
html = html.replaceAll("__PROVIDER_NAME__", providerName);
return c.html(html);
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred."; const errorMessage = error instanceof Error ? error.message : "An unexpected error occurred.";
console.error(`[OAuth Error] ${errorMessage}`); console.error(`[OAuth Error] ${errorMessage}`);
+28
View File
@@ -38,6 +38,7 @@ const listConfigRoute = createRoute({
method: "get", method: "get",
path: "/", path: "/",
security: [{ API_KEY: [] }], security: [{ API_KEY: [] }],
tags: ["Management"],
responses: { responses: {
200: { 200: {
content: { "application/json": { schema: AllProvidersResponse } }, content: { "application/json": { schema: AllProvidersResponse } },
@@ -50,6 +51,7 @@ const setConfigRoute = createRoute({
method: "post", method: "post",
path: "/{provider}", path: "/{provider}",
security: [{ API_KEY: [] }], security: [{ API_KEY: [] }],
tags: ["Management"],
request: { request: {
params: z.object({ params: z.object({
provider: z.string().openapi({ example: "trakt" }), provider: z.string().openapi({ example: "trakt" }),
@@ -70,6 +72,24 @@ const setConfigRoute = createRoute({
}, },
}); });
const deleteConfigRoute = createRoute({
method: "delete",
path: "/{provider}",
security: [{ API_KEY: [] }],
tags: ["Management"],
request: {
params: z.object({
provider: z.string().openapi({ example: "trakt" }),
}),
},
responses: {
200: {
content: { "application/json": { schema: SuccessMessage } },
description: "Delete a provider configuration and its tokens",
},
},
});
// Implementations // Implementations
configRoutes.use("*", authMiddleware); configRoutes.use("*", authMiddleware);
@@ -88,4 +108,12 @@ configRoutes.openapi(setConfigRoute, async (c) => {
return c.json({ message: `Config for ${provider} saved successfully` }, 200); return c.json({ message: `Config for ${provider} saved successfully` }, 200);
}); });
configRoutes.openapi(deleteConfigRoute, async (c) => {
const provider = c.req.valid("param").provider;
const configManager = new ConfigManager(redis);
await configManager.deleteProviderConfig(provider);
return c.json({ message: `Config for ${provider} deleted successfully` }, 200);
});
export { configRoutes }; export { configRoutes };
-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 };
+570
View File
@@ -0,0 +1,570 @@
/** @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";
import { API_PREFIX, API_VERSION, APP_VERSION, AUTH_PREFIX, DOCS_PREFIX } from "../constants";
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},
apiVersion: '${API_VERSION}',
appVersion: '${APP_VERSION}',
apiPrefix: '${API_PREFIX}',
authPrefix: '${AUTH_PREFIX}',
docsPrefix: '${DOCS_PREFIX}'
})`}
>
{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 flex items-center gap-6">
<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>
<nav class="hidden md:flex items-center gap-1">
<a
href={DOCS_PREFIX}
target="_blank"
class="btn btn-ghost btn-sm text-base-content/60 hover:text-primary gap-2 px-3"
rel="noopener"
>
<i class="ph-duotone ph-book-open text-lg"></i>
<span class="font-bold uppercase tracking-widest text-xs">
API Reference{" "}
<sup class="text-[8px] opacity-50 ml-0.5">
{API_VERSION}.{APP_VERSION}
</sup>
</span>
</a>
</nav>
</div>
<div class="flex-none hidden sm:flex">
<template x-if="!isUnlocked">
<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>
</div>
</template>
<template x-if="isUnlocked">
<button
x-on:click="logout()"
type="button"
class="btn btn-ghost btn-sm text-error hover:bg-error/10 gap-2 px-4"
x-bind:disabled="loading"
>
<i class="ph-bold ph-power text-lg"></i>
<span class="font-bold uppercase tracking-wider text-xs">Logout</span>
</button>
</template>
</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">
<div class="flex justify-between items-start">
<span
x-text="provider.name"
class="text-lg font-black text-base-content/90 uppercase"
></span>
<button
type="button"
x-on:click="deleteProvider(provider.name)"
class="btn btn-error btn-xs mt-1 opacity-0 group-hover:opacity-100 transition-all duration-300"
title="Delete Provider"
>
<i class="ph-bold ph-trash text-lg"></i>
</button>
</div>
<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_PREFIX}/' + 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 };
+2
View File
@@ -0,0 +1,2 @@
export const API_VERSION = "v1";
export const APP_VERSION = "1.0";
-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>
+239
View File
@@ -0,0 +1,239 @@
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, apiPrefix, authPrefix, docsPrefix, apiVersion, appVersion }) => ({
apiKey: "",
isUnlocked: initialIsUnlocked,
apiPrefix,
authPrefix,
docsPrefix,
apiVersion,
appVersion,
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(`${this.apiPrefix}/config`),
fetch(`${this.apiPrefix}/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(`${this.apiPrefix}/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(`${this.apiPrefix}/refresh/${name}`, {
method: "POST",
});
if (res.status === 401) {
return this.handleSessionExpired();
}
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;
}
},
async deleteProvider(name) {
if (
!confirm(
`Are you sure you want to delete ${name}? This will also remove all associated tokens.`,
)
)
return;
this.loading = true;
try {
const res = await fetch(`${this.apiPrefix}/config/${name}`, {
method: "DELETE",
});
if (res.status === 401) {
return this.handleSessionExpired();
}
if (!res.ok) throw new Error("Delete failed");
this.showNotification(`Deleted ${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}${this.authPrefix}/${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 { describe, expect, it, mock } from "bun:test";
import { TokenManager } from "../../src/core/TokenManager"; import { TokenManager } from "../../src/core/TokenManager";
describe("TokenManager", () => { describe("TokenManager", () => {
it("should return token from redis if available", async () => { it("should return token from redis if available", async () => {
const redisMock = { get: mock(() => Promise.resolve("valid_token")) }; const redis = { get: mock(() => Promise.resolve("active-access-token")) };
const manager = new TokenManager(redisMock as any, {} as any); const manager = new TokenManager(redis, {});
const token = await manager.getAccessToken("trakt"); 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 () => { it("should refresh token if access token is missing but refresh token exists", async () => {
const redisMock = { const redis = {
get: mock((key: string) => Promise.resolve(key.includes("refresh") ? "refresh_token" : null)), get: mock((key) => Promise.resolve(key.includes("refresh") ? "valid-refresh-token" : null)),
set: mock(() => Promise.resolve()), set: mock(() => Promise.resolve()),
}; };
const providerMock = { const provider = {
refreshToken: mock(() => refreshToken: mock(() =>
Promise.resolve({ Promise.resolve({
accessToken: "new_token", accessToken: "newly-refreshed-access-token",
refreshToken: "new_refresh", refreshToken: "newly-refreshed-refresh-token",
expiresIn: 3600, expiresIn: 3600,
}), }),
), ),
}; };
const manager = new TokenManager(redisMock as any, providerMock as any); const manager = new TokenManager(redis, provider);
const token = await manager.getAccessToken("trakt"); 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 () => { it("should return null if no tokens are found", async () => {
const redisMock = { get: mock(() => Promise.resolve(null)) }; const redis = { get: mock(() => Promise.resolve(null)) };
const manager = new TokenManager(redisMock as any, {} as any); const manager = new TokenManager(redis, {});
const token = await manager.getAccessToken("trakt"); const token = await manager.getAccessToken("trakt");
expect(token).toBeNull(); expect(token).toBeNull();
}); });
it("should refresh token via forceRefresh", async () => { it("should refresh token via refreshAccessToken", async () => {
const redisMock = { const redis = {
get: mock(() => Promise.resolve("refresh_token")), get: mock(() => Promise.resolve("existing-refresh-token")),
set: mock(() => Promise.resolve()), set: mock(() => Promise.resolve()),
}; };
const providerMock = { const provider = {
refreshToken: mock(() => refreshToken: mock(() =>
Promise.resolve({ Promise.resolve({
accessToken: "forced_token", accessToken: "manually-refreshed-access-token",
refreshToken: "new_refresh", refreshToken: "manually-refreshed-refresh-token",
expiresIn: 3600, expiresIn: 3600,
}), }),
), ),
}; };
const manager = new TokenManager(redisMock as any, providerMock as any); const manager = new TokenManager(redis, provider);
const token = await manager.forceRefresh("trakt");
expect(token).toBe("forced_token"); const token = await manager.refreshAccessToken("trakt");
expect(providerMock.refreshToken).toHaveBeenCalledWith("refresh_token");
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 () => { it("should return null in refreshAccessToken if no refresh token is found", async () => {
const redisMock = { get: mock(() => Promise.resolve(null)) }; const redis = { get: mock(() => Promise.resolve(null)) };
const manager = new TokenManager(redisMock as any, {} as any); const manager = new TokenManager(redis, {});
const token = await manager.forceRefresh("trakt");
const token = await manager.refreshAccessToken("trakt");
expect(token).toBeNull(); expect(token).toBeNull();
}); });
}); });
+159
View File
@@ -0,0 +1,159 @@
// @ts-nocheck
import { describe, expect, it, spyOn } from "bun:test";
import { API_PREFIX } from "../../src/constants";
import { redis } from "../../src/core/RedisClient";
import { app } from "../../src/index";
describe("API Integration", () => {
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_PREFIX}/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_PREFIX}/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_PREFIX}/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_PREFIX}/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_PREFIX}/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_PREFIX}/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_PREFIX}/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");
});
});
+66
View File
@@ -0,0 +1,66 @@
// @ts-nocheck
import { describe, expect, it, spyOn } from "bun:test";
import { AUTH_PREFIX } from "../../src/constants";
import { redis } from "../../src/core/RedisClient";
import { app } from "../../src/index";
describe("Auth Integration", () => {
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_PREFIX}/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_PREFIX}/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_PREFIX}/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_PREFIX}/callback?code=some-code`);
expect(res.status).toBe(400);
});
});
+87
View File
@@ -0,0 +1,87 @@
// @ts-nocheck
import { describe, expect, it } from "bun:test";
import { API_PREFIX } from "../../src/constants";
import { redis } from "../../src/core/RedisClient";
import { app } from "../../src/index";
describe("Config Integration", () => {
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_PREFIX}/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_PREFIX}/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_PREFIX}/config/invalid`, {
method: "POST",
headers: {
Authorization: "Bearer test-api-key",
"Content-Type": "application/json",
},
body: JSON.stringify(invalidConfig),
});
expect(res.status).toBe(400);
});
it("should delete a provider configuration", async () => {
const res = await app.request(`${API_PREFIX}/config/trakt`, {
method: "DELETE",
headers: {
Authorization: "Bearer test-api-key",
},
});
expect(res.status).toBe(200);
expect(redis.del).toHaveBeenCalled();
});
});
+59
View File
@@ -0,0 +1,59 @@
// @ts-nocheck
import { describe, expect, it } from "bun:test";
import { API_PREFIX } from "../../src/constants";
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_PREFIX}/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");
});
});
+38
View File
@@ -0,0 +1,38 @@
// @ts-nocheck
process.env.API_KEY = "test-api-key";
process.env.REDIS_HOST = "localhost";
process.env.REDIS_PORT = "6379";
process.env.APP_PORT = "3000";
import { afterEach, mock } from "bun:test";
// Global config mock
mock.module("../src/config", () => ({
config: {
API_KEY: "test-api-key",
REDIS_HOST: "localhost",
REDIS_PORT: 6379,
APP_PORT: "3000",
},
}));
// Global Redis mock
mock.module("../src/core/RedisClient", () => ({
redis: {
status: "ready",
get: mock(() => Promise.resolve(null)),
set: mock(() => Promise.resolve()),
del: mock(() => Promise.resolve(1)),
keys: mock(() => Promise.resolve([])),
on: mock(() => {}),
},
}));
afterEach(async () => {
const { redis } = await import("../src/core/RedisClient");
mock.restore();
redis.get.mockImplementation(() => Promise.resolve(null));
redis.set.mockImplementation(() => Promise.resolve());
redis.del.mockImplementation(() => Promise.resolve(1));
redis.keys.mockImplementation(() => Promise.resolve([]));
});
+4 -2
View File
@@ -5,8 +5,10 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"lib": ["ESNext"], "lib": ["ESNext"],
"types": ["node"] "types": ["node", "bun-types"]
}, },
"include": ["src/**/*"] "include": ["src/**/*", "tests/**/*"]
} }