aisubs 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,8 +6,8 @@
6
6
  <meta name="color-scheme" content="light dark" />
7
7
  <meta name="theme-color" content="#181818" />
8
8
  <title>AI Subs</title>
9
- <script type="module" crossorigin src="/assets/index-BMoILzPw.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-DHqDdNVe.css">
9
+ <script type="module" crossorigin src="/assets/index-DrnM3oWy.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-CEDww1hA.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
@@ -1,15 +1,18 @@
1
- import { type Server } from "node:http";
1
+ import { type FastifyInstance } from "fastify";
2
+ import type { Server } from "node:http";
2
3
  import type { SubscriptionAuth } from "./auth.js";
3
4
  export interface SubscriptionAuthDashboardOptions {
4
5
  auth: SubscriptionAuth;
5
6
  apiKey?: string;
7
+ regenerateApiKey?: () => Promise<string>;
6
8
  host?: string;
7
9
  port?: number;
8
- /** Maximum buffered proxy request body. Defaults to 10 MiB. */
10
+ /** Maximum request body accepted by the compatibility proxy. Defaults to 10 MiB. */
9
11
  maxProxyBodyBytes?: number;
10
12
  }
11
13
  export interface SubscriptionAuthDashboardServer {
12
14
  server: Server;
15
+ app: FastifyInstance;
13
16
  apiKey: string;
14
17
  url: string;
15
18
  bootstrapUrl: string;
package/dist/dashboard.js CHANGED
@@ -1,10 +1,13 @@
1
+ import cors from "@fastify/cors";
2
+ import websocket from "@fastify/websocket";
3
+ import Fastify from "fastify";
1
4
  import { randomBytes, timingSafeEqual } from "node:crypto";
2
5
  import { readFile } from "node:fs/promises";
3
- import { createServer } from "node:http";
4
6
  import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
5
7
  import { fileURLToPath } from "node:url";
6
- import { handleSubscriptionAuthApi, routeSegments, sendJson } from "./http.js";
7
- import { errorMessage, urlHost } from "./utils.js";
8
+ import { clientAbortSignal, handleSubscriptionAuthApi, routeSegments, sendWebResponse, } from "./http.js";
9
+ import { registerRealtimeProxy } from "./realtime.js";
10
+ import { errorMessage, isRecord, stringValue, urlHost } from "./utils.js";
8
11
  const ASSET_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), "dashboard");
9
12
  const CONTENT_TYPES = {
10
13
  ".css": "text/css; charset=utf-8",
@@ -20,6 +23,16 @@ function sameSecret(actual, expected) {
20
23
  const right = Buffer.from(expected);
21
24
  return left.length === right.length && timingSafeEqual(left, right);
22
25
  }
26
+ function requestApiKeys(request) {
27
+ const authorization = request.headers.authorization;
28
+ const bearer = authorization?.startsWith("Bearer ") ? authorization.slice(7) : undefined;
29
+ const header = (name) => {
30
+ const value = request.headers[name];
31
+ return Array.isArray(value) ? value[0] : value;
32
+ };
33
+ const queryKey = new URL(request.url, "http://aisubs.local").searchParams.get("key") ?? undefined;
34
+ return [bearer, header("x-api-key"), header("x-goog-api-key"), queryKey].filter((value) => value != null);
35
+ }
23
36
  function cookie(request, name) {
24
37
  for (const part of request.headers.cookie?.split(";") ?? []) {
25
38
  const [key, ...value] = part.trim().split("=");
@@ -28,14 +41,35 @@ function cookie(request, name) {
28
41
  }
29
42
  return undefined;
30
43
  }
31
- function secure(response) {
32
- response.setHeader("content-security-policy", "default-src 'self'; base-uri 'none'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'");
33
- response.setHeader("referrer-policy", "no-referrer");
34
- response.setHeader("cross-origin-opener-policy", "same-origin");
35
- response.setHeader("cross-origin-resource-policy", "same-origin");
36
- response.setHeader("permissions-policy", "camera=(), geolocation=(), microphone=()");
37
- response.setHeader("x-content-type-options", "nosniff");
38
- response.setHeader("x-frame-options", "DENY");
44
+ function secure(reply) {
45
+ reply.header("content-security-policy", "default-src 'self'; base-uri 'none'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'");
46
+ reply.header("referrer-policy", "no-referrer");
47
+ reply.header("cross-origin-opener-policy", "same-origin");
48
+ reply.header("cross-origin-resource-policy", "same-origin");
49
+ reply.header("permissions-policy", "camera=(), geolocation=(), microphone=()");
50
+ reply.header("x-content-type-options", "nosniff");
51
+ reply.header("x-frame-options", "DENY");
52
+ }
53
+ function hostname(value) {
54
+ if (!value)
55
+ return undefined;
56
+ try {
57
+ return new URL(`http://${value}`).hostname.replace(/^\[|\]$/g, "");
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ }
63
+ async function responseFailure(response) {
64
+ if (response.ok)
65
+ return undefined;
66
+ const body = await response
67
+ .clone()
68
+ .json()
69
+ .catch(() => null);
70
+ const failure = isRecord(body) ? body.error : undefined;
71
+ const message = isRecord(failure) ? stringValue(failure.message) : stringValue(failure);
72
+ return (message ?? `HTTP ${response.status}`).slice(0, 2_000);
39
73
  }
40
74
  export async function createSubscriptionAuthDashboardServer(options) {
41
75
  const host = options.host ?? "127.0.0.1";
@@ -43,58 +77,150 @@ export async function createSubscriptionAuthDashboardServer(options) {
43
77
  throw new Error("The AI Subs dashboard may only bind to localhost");
44
78
  }
45
79
  const origin = `http://${urlHost(host)}`;
46
- const apiKey = options.apiKey ?? randomBytes(32).toString("base64url");
47
- const bootstrapToken = randomBytes(32).toString("base64url");
80
+ let apiKey = options.apiKey ?? `aisubs_${randomBytes(32).toString("base64url")}`;
81
+ let regeneratingApiKey;
48
82
  const sessionToken = randomBytes(32).toString("base64url");
49
- let bootstrapAvailable = true;
50
- const server = createServer(async (request, response) => {
51
- secure(response);
52
- try {
53
- const url = new URL(request.url ?? "/", origin);
54
- if (url.pathname === "/health")
55
- return sendJson(response, 200, { ok: true });
56
- if (request.method === "GET" && url.pathname === "/bootstrap") {
57
- if (!bootstrapAvailable ||
58
- !sameSecret(url.searchParams.get("token") ?? undefined, bootstrapToken)) {
59
- return sendJson(response, 401, { error: "Bootstrap link is invalid or has expired" });
60
- }
61
- bootstrapAvailable = false;
62
- response.writeHead(302, {
63
- location: "/",
64
- "cache-control": "no-store",
65
- "set-cookie": `aisubs_session=${encodeURIComponent(sessionToken)}; HttpOnly; SameSite=Strict; Path=/`,
66
- });
67
- response.end();
83
+ const requestLogs = [];
84
+ const requestErrors = new WeakMap();
85
+ const logStreams = new Set();
86
+ let requestId = 0;
87
+ const app = Fastify({
88
+ bodyLimit: options.maxProxyBodyBytes ?? 10 * 1024 * 1024,
89
+ forceCloseConnections: true,
90
+ });
91
+ app.removeAllContentTypeParsers();
92
+ app.addContentTypeParser("*", { parseAs: "buffer" }, (_request, body, done) => done(null, body));
93
+ await app.register(cors, {
94
+ origin: true,
95
+ credentials: false,
96
+ methods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
97
+ allowedHeaders: [
98
+ "authorization",
99
+ "content-type",
100
+ "x-api-key",
101
+ "x-goog-api-key",
102
+ "anthropic-version",
103
+ "anthropic-beta",
104
+ "openai-beta",
105
+ ],
106
+ exposedHeaders: ["x-request-id", "retry-after"],
107
+ });
108
+ await app.register(websocket);
109
+ await app.register(async (scope) => {
110
+ registerRealtimeProxy(scope, options.auth, (request) => {
111
+ return (requestApiKeys(request).some((value) => sameSecret(value, apiKey)) ||
112
+ sameSecret(cookie(request, "aisubs_session"), sessionToken));
113
+ });
114
+ });
115
+ app.addHook("onRequest", async (request, reply) => {
116
+ secure(reply);
117
+ if (hostname(request.headers.host) !== host) {
118
+ await reply.code(421).send({ error: "Invalid local host" });
119
+ return;
120
+ }
121
+ if (request.url.startsWith("/aisubs/")) {
122
+ const startedAt = performance.now();
123
+ reply.raw.once("finish", () => {
124
+ const path = new URL(request.url, origin).pathname;
125
+ const entry = {
126
+ id: ++requestId,
127
+ timestamp: Date.now(),
128
+ method: request.method,
129
+ path,
130
+ status: reply.statusCode,
131
+ durationMs: Math.round(performance.now() - startedAt),
132
+ error: requestErrors.get(request),
133
+ };
134
+ requestLogs.push(entry);
135
+ if (requestLogs.length > 200)
136
+ requestLogs.shift();
137
+ const event = `data: ${JSON.stringify(entry)}\n\n`;
138
+ for (const stream of logStreams)
139
+ stream.write(event);
140
+ });
141
+ }
142
+ });
143
+ app.route({
144
+ method: ["GET", "POST", "PUT", "PATCH", "DELETE"],
145
+ url: "/*",
146
+ async handler(request, reply) {
147
+ const url = new URL(request.url, origin);
148
+ if (url.pathname === "/health") {
149
+ await reply
150
+ .header("x-aisubs-service", "aisubs")
151
+ .header("x-aisubs-pid", String(process.pid))
152
+ .send({ ok: true });
68
153
  return;
69
154
  }
70
- const bearer = request.headers.authorization?.startsWith("Bearer ")
71
- ? request.headers.authorization.slice(7)
72
- : undefined;
73
- const headerKey = Array.isArray(request.headers["x-api-key"])
74
- ? request.headers["x-api-key"][0]
75
- : request.headers["x-api-key"];
76
- const bearerAuthenticated = sameSecret(bearer, apiKey) || sameSecret(headerKey, apiKey);
155
+ if (request.method === "GET" && url.pathname === "/") {
156
+ reply.header("set-cookie", `aisubs_session=${encodeURIComponent(sessionToken)}; HttpOnly; SameSite=Strict; Path=/`);
157
+ }
158
+ const bearerAuthenticated = requestApiKeys(request).some((value) => sameSecret(value, apiKey));
77
159
  const cookieAuthenticated = sameSecret(cookie(request, "aisubs_session"), sessionToken);
78
- if (!bearerAuthenticated && !cookieAuthenticated) {
79
- return sendJson(response, 401, { error: "Unauthorized" });
160
+ const apiRoute = ["v1", "aisubs"].includes(routeSegments(url.pathname)[0] ?? "");
161
+ if (apiRoute && !bearerAuthenticated && !cookieAuthenticated) {
162
+ await reply.code(401).send({
163
+ error: {
164
+ message: "Unauthorized",
165
+ type: "authentication_error",
166
+ code: "invalid_api_key",
167
+ },
168
+ });
169
+ return;
170
+ }
171
+ if (request.method === "GET" && url.pathname === "/v1/logs/stream") {
172
+ reply.hijack();
173
+ reply.raw.writeHead(200, {
174
+ "content-type": "text/event-stream",
175
+ "cache-control": "no-store",
176
+ connection: "keep-alive",
177
+ });
178
+ reply.raw.flushHeaders();
179
+ for (const entry of requestLogs)
180
+ reply.raw.write(`data: ${JSON.stringify(entry)}\n\n`);
181
+ logStreams.add(reply.raw);
182
+ request.raw.once("close", () => logStreams.delete(reply.raw));
183
+ return;
80
184
  }
81
- if (cookieAuthenticated &&
82
- !bearerAuthenticated &&
83
- !["GET", "HEAD", "OPTIONS"].includes(request.method ?? "GET") &&
84
- request.headers.origin !== `http://${request.headers.host}`) {
85
- return sendJson(response, 403, { error: "Cross-origin mutation blocked" });
185
+ if (apiRoute && cookieAuthenticated && !bearerAuthenticated) {
186
+ if (!["GET", "HEAD", "OPTIONS"].includes(request.method) &&
187
+ request.headers.origin !== `http://${request.headers.host}`) {
188
+ await reply.code(403).send({ error: "Cross-origin mutation blocked" });
189
+ return;
190
+ }
191
+ if (request.method === "GET" && url.pathname === "/v1/api-key") {
192
+ await reply.send({ apiKey });
193
+ return;
194
+ }
195
+ if (request.method === "POST" && url.pathname === "/v1/api-key/regenerate") {
196
+ regeneratingApiKey ??= (options.regenerateApiKey
197
+ ? options.regenerateApiKey()
198
+ : Promise.resolve(`aisubs_${randomBytes(32).toString("base64url")}`)).finally(() => {
199
+ regeneratingApiKey = undefined;
200
+ });
201
+ apiKey = await regeneratingApiKey;
202
+ await reply.send({ apiKey });
203
+ return;
204
+ }
86
205
  }
87
- if (["v1", "aisubs"].includes(routeSegments(url.pathname)[0] ?? "")) {
88
- if (!(await handleSubscriptionAuthApi(options.auth, request, response, url, options.maxProxyBodyBytes))) {
89
- sendJson(response, 404, { error: "Not found" });
206
+ if (apiRoute) {
207
+ const response = await handleSubscriptionAuthApi(options.auth, request, clientAbortSignal(request, reply));
208
+ if (response) {
209
+ const failure = await responseFailure(response);
210
+ if (failure)
211
+ requestErrors.set(request, failure);
212
+ await sendWebResponse(reply, response);
90
213
  }
214
+ else
215
+ await reply.code(404).send({ error: "Not found" });
91
216
  return;
92
217
  }
93
218
  const requested = url.pathname === "/" ? "index.html" : url.pathname;
94
219
  const assetPath = resolve(ASSET_DIRECTORY, `.${requested}`);
95
220
  const assetRelative = relative(ASSET_DIRECTORY, assetPath);
96
221
  if (assetRelative.startsWith("..") || isAbsolute(assetRelative)) {
97
- return sendJson(response, 404, { error: "Not found" });
222
+ await reply.code(404).send({ error: "Not found" });
223
+ return;
98
224
  }
99
225
  let file = assetPath;
100
226
  let body = await readFile(file).catch(() => null);
@@ -102,38 +228,37 @@ export async function createSubscriptionAuthDashboardServer(options) {
102
228
  file = join(ASSET_DIRECTORY, "index.html");
103
229
  body = await readFile(file);
104
230
  }
105
- response.writeHead(200, {
106
- "content-type": CONTENT_TYPES[extname(file)] ?? "application/octet-stream",
107
- "cache-control": relative(ASSET_DIRECTORY, file).startsWith("assets/")
108
- ? "public, max-age=31536000, immutable"
109
- : "no-store",
110
- });
111
- response.end(body);
112
- }
113
- catch (error) {
114
- if (response.headersSent)
115
- response.destroy();
116
- else
117
- sendJson(response, 400, { error: errorMessage(error) });
118
- }
231
+ await reply
232
+ .type(CONTENT_TYPES[extname(file)] ?? "application/octet-stream")
233
+ .header("cache-control", relative(ASSET_DIRECTORY, file).startsWith("assets/")
234
+ ? "public, max-age=31536000, immutable"
235
+ : "no-store")
236
+ .send(body);
237
+ },
119
238
  });
120
- await new Promise((resolve, reject) => {
121
- server.once("error", reject);
122
- server.listen(options.port ?? 0, host, () => {
123
- server.off("error", reject);
124
- resolve();
125
- });
239
+ app.setErrorHandler(async (error, request, reply) => {
240
+ requestErrors.set(request, errorMessage(error));
241
+ await reply.code(400).send({ error: errorMessage(error) });
126
242
  });
127
- const address = server.address();
243
+ await app.listen({ port: options.port ?? 0, host });
244
+ const address = app.server.address();
128
245
  if (!address || typeof address === "string") {
246
+ await app.close();
129
247
  throw new Error("Unable to determine AI Subs dashboard port");
130
248
  }
131
249
  const url = `${origin}:${address.port}`;
132
250
  return {
133
- server,
134
- apiKey,
251
+ server: app.server,
252
+ app,
253
+ get apiKey() {
254
+ return apiKey;
255
+ },
135
256
  url,
136
- bootstrapUrl: `${url}/bootstrap?token=${encodeURIComponent(bootstrapToken)}`,
137
- close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
257
+ bootstrapUrl: url,
258
+ close: async () => {
259
+ for (const stream of logStreams)
260
+ stream.end();
261
+ await app.close();
262
+ },
138
263
  };
139
264
  }
package/dist/http.d.ts CHANGED
@@ -1,22 +1,25 @@
1
- import { type IncomingMessage, type Server, type ServerResponse } from "node:http";
1
+ import { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
2
+ import type { Server } from "node:http";
2
3
  import type { SubscriptionAuth } from "./auth.js";
3
- export declare function readJsonBody(request: IncomingMessage): Promise<unknown>;
4
- export declare function readProxyBody(request: IncomingMessage, maxBytes?: number): Promise<Buffer>;
5
- export declare function sendJson(response: ServerResponse, status: number, body: unknown, headers?: Record<string, string>): void;
6
4
  export declare function routeSegments(pathname: string): string[];
7
- export declare function handleSubscriptionAuthApi(auth: SubscriptionAuth, request: IncomingMessage, response: ServerResponse, url: URL, maxProxyBodyBytes?: number): Promise<boolean>;
5
+ export declare function sendWebResponse(reply: FastifyReply, upstream: Response): Promise<void>;
6
+ export declare function handleSubscriptionAuthApi(auth: SubscriptionAuth, request: FastifyRequest, signal?: AbortSignal): Promise<Response | null>;
7
+ /** Abort upstream work only when the client disconnects before the response finishes. */
8
+ export declare function clientAbortSignal(request: FastifyRequest, reply: FastifyReply): AbortSignal;
8
9
  export interface SubscriptionAuthServerOptions {
9
10
  auth: SubscriptionAuth;
10
11
  apiKey: string;
11
12
  host?: string;
12
13
  port?: number;
13
- /** Maximum buffered proxy request body. Defaults to 10 MiB. */
14
+ /** Maximum request body accepted by the compatibility proxy. Defaults to 10 MiB. */
14
15
  maxProxyBodyBytes?: number;
15
16
  }
16
17
  export interface SubscriptionAuthServer {
17
18
  server: Server;
19
+ app: FastifyInstance;
18
20
  apiKey: string;
19
21
  url: string;
20
22
  close(): Promise<void>;
21
23
  }
24
+ export declare function createApiApp(options: SubscriptionAuthServerOptions): FastifyInstance;
22
25
  export declare function createSubscriptionAuthServer(options: SubscriptionAuthServerOptions): Promise<SubscriptionAuthServer>;