aisubs 0.2.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-V-AoOW2F.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-DYe3pr2a.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,4 +1,5 @@
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;
@@ -6,11 +7,12 @@ export interface SubscriptionAuthDashboardOptions {
6
7
  regenerateApiKey?: () => Promise<string>;
7
8
  host?: string;
8
9
  port?: number;
9
- /** Maximum buffered proxy request body. Defaults to 10 MiB. */
10
+ /** Maximum request body accepted by the compatibility proxy. Defaults to 10 MiB. */
10
11
  maxProxyBodyBytes?: number;
11
12
  }
12
13
  export interface SubscriptionAuthDashboardServer {
13
14
  server: Server;
15
+ app: FastifyInstance;
14
16
  apiKey: string;
15
17
  url: string;
16
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,14 @@ 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");
39
52
  }
40
53
  function hostname(value) {
41
54
  if (!value)
@@ -47,6 +60,17 @@ function hostname(value) {
47
60
  return undefined;
48
61
  }
49
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);
73
+ }
50
74
  export async function createSubscriptionAuthDashboardServer(options) {
51
75
  const host = options.host ?? "127.0.0.1";
52
76
  if (!["127.0.0.1", "::1", "localhost"].includes(host)) {
@@ -57,75 +81,116 @@ export async function createSubscriptionAuthDashboardServer(options) {
57
81
  let regeneratingApiKey;
58
82
  const sessionToken = randomBytes(32).toString("base64url");
59
83
  const requestLogs = [];
84
+ const requestErrors = new WeakMap();
60
85
  const logStreams = new Set();
61
86
  let requestId = 0;
62
- const server = createServer(async (request, response) => {
63
- secure(response);
64
- try {
65
- if (hostname(request.headers.host) !== host) {
66
- return sendJson(response, 421, { error: "Invalid local host" });
67
- }
68
- const url = new URL(request.url ?? "/", origin);
69
- if (url.pathname.startsWith("/aisubs/")) {
70
- const startedAt = performance.now();
71
- response.once("finish", () => {
72
- const entry = {
73
- id: ++requestId,
74
- timestamp: Date.now(),
75
- method: request.method ?? "GET",
76
- path: url.pathname,
77
- status: response.statusCode,
78
- durationMs: Math.round(performance.now() - startedAt),
79
- };
80
- requestLogs.push(entry);
81
- if (requestLogs.length > 200)
82
- requestLogs.shift();
83
- const event = `data: ${JSON.stringify(entry)}\n\n`;
84
- for (const stream of logStreams)
85
- stream.write(event);
86
- });
87
- }
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);
88
148
  if (url.pathname === "/health") {
89
- return sendJson(response, 200, { ok: true }, {
90
- "x-aisubs-service": "aisubs",
91
- "x-aisubs-pid": String(process.pid),
92
- });
149
+ await reply
150
+ .header("x-aisubs-service", "aisubs")
151
+ .header("x-aisubs-pid", String(process.pid))
152
+ .send({ ok: true });
153
+ return;
93
154
  }
94
155
  if (request.method === "GET" && url.pathname === "/") {
95
- response.setHeader("set-cookie", `aisubs_session=${encodeURIComponent(sessionToken)}; HttpOnly; SameSite=Strict; Path=/`);
156
+ reply.header("set-cookie", `aisubs_session=${encodeURIComponent(sessionToken)}; HttpOnly; SameSite=Strict; Path=/`);
96
157
  }
97
- const bearer = request.headers.authorization?.startsWith("Bearer ")
98
- ? request.headers.authorization.slice(7)
99
- : undefined;
100
- const headerKey = Array.isArray(request.headers["x-api-key"])
101
- ? request.headers["x-api-key"][0]
102
- : request.headers["x-api-key"];
103
- const bearerAuthenticated = sameSecret(bearer, apiKey) || sameSecret(headerKey, apiKey);
158
+ const bearerAuthenticated = requestApiKeys(request).some((value) => sameSecret(value, apiKey));
104
159
  const cookieAuthenticated = sameSecret(cookie(request, "aisubs_session"), sessionToken);
105
160
  const apiRoute = ["v1", "aisubs"].includes(routeSegments(url.pathname)[0] ?? "");
106
161
  if (apiRoute && !bearerAuthenticated && !cookieAuthenticated) {
107
- return sendJson(response, 401, { error: "Unauthorized" });
162
+ await reply.code(401).send({
163
+ error: {
164
+ message: "Unauthorized",
165
+ type: "authentication_error",
166
+ code: "invalid_api_key",
167
+ },
168
+ });
169
+ return;
108
170
  }
109
171
  if (request.method === "GET" && url.pathname === "/v1/logs/stream") {
110
- response.writeHead(200, {
172
+ reply.hijack();
173
+ reply.raw.writeHead(200, {
111
174
  "content-type": "text/event-stream",
112
175
  "cache-control": "no-store",
113
176
  connection: "keep-alive",
114
177
  });
115
- response.flushHeaders();
178
+ reply.raw.flushHeaders();
116
179
  for (const entry of requestLogs)
117
- response.write(`data: ${JSON.stringify(entry)}\n\n`);
118
- logStreams.add(response);
119
- request.once("close", () => logStreams.delete(response));
180
+ reply.raw.write(`data: ${JSON.stringify(entry)}\n\n`);
181
+ logStreams.add(reply.raw);
182
+ request.raw.once("close", () => logStreams.delete(reply.raw));
120
183
  return;
121
184
  }
122
185
  if (apiRoute && cookieAuthenticated && !bearerAuthenticated) {
123
- if (!["GET", "HEAD", "OPTIONS"].includes(request.method ?? "GET") &&
186
+ if (!["GET", "HEAD", "OPTIONS"].includes(request.method) &&
124
187
  request.headers.origin !== `http://${request.headers.host}`) {
125
- return sendJson(response, 403, { error: "Cross-origin mutation blocked" });
188
+ await reply.code(403).send({ error: "Cross-origin mutation blocked" });
189
+ return;
126
190
  }
127
191
  if (request.method === "GET" && url.pathname === "/v1/api-key") {
128
- return sendJson(response, 200, { apiKey });
192
+ await reply.send({ apiKey });
193
+ return;
129
194
  }
130
195
  if (request.method === "POST" && url.pathname === "/v1/api-key/regenerate") {
131
196
  regeneratingApiKey ??= (options.regenerateApiKey
@@ -134,20 +199,28 @@ export async function createSubscriptionAuthDashboardServer(options) {
134
199
  regeneratingApiKey = undefined;
135
200
  });
136
201
  apiKey = await regeneratingApiKey;
137
- return sendJson(response, 200, { apiKey });
202
+ await reply.send({ apiKey });
203
+ return;
138
204
  }
139
205
  }
140
206
  if (apiRoute) {
141
- if (!(await handleSubscriptionAuthApi(options.auth, request, response, url, options.maxProxyBodyBytes))) {
142
- sendJson(response, 404, { error: "Not found" });
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);
143
213
  }
214
+ else
215
+ await reply.code(404).send({ error: "Not found" });
144
216
  return;
145
217
  }
146
218
  const requested = url.pathname === "/" ? "index.html" : url.pathname;
147
219
  const assetPath = resolve(ASSET_DIRECTORY, `.${requested}`);
148
220
  const assetRelative = relative(ASSET_DIRECTORY, assetPath);
149
221
  if (assetRelative.startsWith("..") || isAbsolute(assetRelative)) {
150
- return sendJson(response, 404, { error: "Not found" });
222
+ await reply.code(404).send({ error: "Not found" });
223
+ return;
151
224
  }
152
225
  let file = assetPath;
153
226
  let body = await readFile(file).catch(() => null);
@@ -155,44 +228,37 @@ export async function createSubscriptionAuthDashboardServer(options) {
155
228
  file = join(ASSET_DIRECTORY, "index.html");
156
229
  body = await readFile(file);
157
230
  }
158
- response.writeHead(200, {
159
- "content-type": CONTENT_TYPES[extname(file)] ?? "application/octet-stream",
160
- "cache-control": relative(ASSET_DIRECTORY, file).startsWith("assets/")
161
- ? "public, max-age=31536000, immutable"
162
- : "no-store",
163
- });
164
- response.end(body);
165
- }
166
- catch (error) {
167
- if (response.headersSent)
168
- response.destroy();
169
- else
170
- sendJson(response, 400, { error: errorMessage(error) });
171
- }
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
+ },
172
238
  });
173
- await new Promise((resolve, reject) => {
174
- server.once("error", reject);
175
- server.listen(options.port ?? 0, host, () => {
176
- server.off("error", reject);
177
- resolve();
178
- });
239
+ app.setErrorHandler(async (error, request, reply) => {
240
+ requestErrors.set(request, errorMessage(error));
241
+ await reply.code(400).send({ error: errorMessage(error) });
179
242
  });
180
- const address = server.address();
243
+ await app.listen({ port: options.port ?? 0, host });
244
+ const address = app.server.address();
181
245
  if (!address || typeof address === "string") {
246
+ await app.close();
182
247
  throw new Error("Unable to determine AI Subs dashboard port");
183
248
  }
184
249
  const url = `${origin}:${address.port}`;
185
250
  return {
186
- server,
251
+ server: app.server,
252
+ app,
187
253
  get apiKey() {
188
254
  return apiKey;
189
255
  },
190
256
  url,
191
257
  bootstrapUrl: url,
192
- close: () => {
258
+ close: async () => {
193
259
  for (const stream of logStreams)
194
260
  stream.end();
195
- return new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
261
+ await app.close();
196
262
  },
197
263
  };
198
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>;