@pands/gateway 0.0.0-stage → 0.1.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.
@@ -0,0 +1,65 @@
1
+ import * as _pands_logger from '@pands/logger';
2
+ import * as hono_types from 'hono/types';
3
+ import { PandAuthInstance } from '@pands/auth';
4
+ import { PandConfig } from '@pands/config';
5
+ export { serveService } from '@pands/service';
6
+ import { Hono } from 'hono';
7
+
8
+ interface ServiceOpenApiInput {
9
+ service: string;
10
+ spec: Record<string, any>;
11
+ }
12
+ declare function mergeServiceOpenApiSpecs(serviceSpecs: ServiceOpenApiInput[], info?: {
13
+ title?: string;
14
+ version?: string;
15
+ }): Record<string, any>;
16
+
17
+ interface PublicClientConfig {
18
+ project: string;
19
+ displayName: string;
20
+ env: string;
21
+ apiBaseUrl: string;
22
+ authBaseUrl: string;
23
+ posthog?: {
24
+ publicKey: string;
25
+ host: string;
26
+ };
27
+ links?: {
28
+ grafanaUrl?: string;
29
+ posthogUrl?: string;
30
+ apiDocsUrl: string;
31
+ };
32
+ }
33
+ interface ResolvedActor {
34
+ userId: string;
35
+ email: string;
36
+ role: "user" | "admin";
37
+ }
38
+ interface CreateGatewayAppOptions {
39
+ config: PandConfig;
40
+ serviceUrls?: Record<string, string>;
41
+ serviceApps?: Record<string, {
42
+ fetch: (req: Request) => Response | Promise<Response>;
43
+ }>;
44
+ auth?: PandAuthInstance;
45
+ databaseUrl?: string;
46
+ resolveSession?: (req: Request) => Promise<ResolvedActor | null>;
47
+ authHandler?: (req: Request) => Response | Promise<Response>;
48
+ dbHealth?: () => Promise<string>;
49
+ internalSecret?: string;
50
+ posthogFetch?: typeof fetch;
51
+ staticRootDir?: string;
52
+ }
53
+ declare function resolveGatewayServiceUrls(config: PandConfig, explicitUrls?: Record<string, string>): Record<string, string>;
54
+ declare function createGatewayApp(options: CreateGatewayAppOptions): Hono<{
55
+ Variables: {
56
+ requestId: string;
57
+ };
58
+ }, hono_types.BlankSchema, "/"> & {
59
+ serviceName: string;
60
+ logger: _pands_logger.Logger;
61
+ registerAdminEntity: () => void;
62
+ getAdminEntities: () => never[];
63
+ };
64
+
65
+ export { type CreateGatewayAppOptions, type PublicClientConfig, type ResolvedActor, type ServiceOpenApiInput, createGatewayApp, mergeServiceOpenApiSpecs, resolveGatewayServiceUrls };
package/dist/index.js ADDED
@@ -0,0 +1,465 @@
1
+ // src/index.ts
2
+ import crypto from "crypto";
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import { authTables, createPandAuth, resolveRequestActor } from "@pands/auth";
6
+ import { resolveProjectPorts } from "@pands/config";
7
+ import { createDb } from "@pands/db";
8
+ import { createLogger } from "@pands/logger";
9
+ import { PandError, serveService, signInternalToken } from "@pands/service";
10
+ import { apiReference } from "@scalar/hono-api-reference";
11
+ import { Hono } from "hono";
12
+
13
+ // src/openapi-merge.ts
14
+ function toPascalCase(input) {
15
+ return input.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
16
+ }
17
+ function rewriteSchemaRefs(obj, renameMap) {
18
+ if (Array.isArray(obj)) {
19
+ return obj.map((item) => rewriteSchemaRefs(item, renameMap));
20
+ }
21
+ if (obj && typeof obj === "object") {
22
+ const out = {};
23
+ for (const [k, v] of Object.entries(obj)) {
24
+ if (k === "$ref" && typeof v === "string" && v.startsWith("#/components/schemas/")) {
25
+ const schemaName = v.slice("#/components/schemas/".length);
26
+ const mapped = renameMap[schemaName] ?? schemaName;
27
+ out[k] = `#/components/schemas/${mapped}`;
28
+ } else {
29
+ out[k] = rewriteSchemaRefs(v, renameMap);
30
+ }
31
+ }
32
+ return out;
33
+ }
34
+ return obj;
35
+ }
36
+ function mergeServiceOpenApiSpecs(serviceSpecs, info = {}) {
37
+ const mergedPaths = {};
38
+ const mergedSchemas = {};
39
+ const internalPaths = /* @__PURE__ */ new Set(["/healthz", "/readyz", "/openapi.json", "/_pand/meta"]);
40
+ for (const { service, spec } of serviceSpecs) {
41
+ const pascalService = toPascalCase(service);
42
+ const renameMap = {};
43
+ const schemas = spec?.components?.schemas ?? {};
44
+ for (const [schemaName, schemaDef] of Object.entries(schemas)) {
45
+ if (mergedSchemas[schemaName]) {
46
+ const prefixed = `${pascalService}_${schemaName}`;
47
+ renameMap[schemaName] = prefixed;
48
+ mergedSchemas[prefixed] = schemaDef;
49
+ } else {
50
+ mergedSchemas[schemaName] = schemaDef;
51
+ }
52
+ }
53
+ const rewrittenSpec = Object.keys(renameMap).length > 0 ? rewriteSchemaRefs(spec, renameMap) : spec;
54
+ const rewrittenSchemas = rewrittenSpec?.components?.schemas ?? {};
55
+ for (const [schemaName, schemaDef] of Object.entries(rewrittenSchemas)) {
56
+ const targetName = renameMap[schemaName] ?? schemaName;
57
+ mergedSchemas[targetName] = schemaDef;
58
+ }
59
+ const paths = rewrittenSpec?.paths ?? {};
60
+ for (const [rawPath, pathItem] of Object.entries(paths)) {
61
+ if (internalPaths.has(rawPath)) {
62
+ continue;
63
+ }
64
+ const normalized = rawPath === "/" ? "" : rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
65
+ const prefixedPath = `/api/v1/${service}${normalized}`;
66
+ mergedPaths[prefixedPath] = pathItem;
67
+ }
68
+ }
69
+ return {
70
+ openapi: "3.1.0",
71
+ info: {
72
+ title: info.title ?? "Pand API",
73
+ version: info.version ?? "0.1.0"
74
+ },
75
+ paths: mergedPaths,
76
+ components: {
77
+ schemas: mergedSchemas
78
+ }
79
+ };
80
+ }
81
+
82
+ // src/index.ts
83
+ function defaultResolveSession(req) {
84
+ const testUserId = req.headers.get("x-test-user-id");
85
+ if (testUserId) {
86
+ const roleHeader = req.headers.get("x-test-user-role");
87
+ return Promise.resolve({
88
+ userId: testUserId,
89
+ email: req.headers.get("x-test-user-email") ?? `${testUserId}@mahdins.dev`,
90
+ role: roleHeader === "admin" ? "admin" : "user"
91
+ });
92
+ }
93
+ const authHeader = req.headers.get("authorization");
94
+ if (authHeader?.startsWith("Bearer test-user:")) {
95
+ const parts = authHeader.slice("Bearer test-user:".length).split(":");
96
+ const userId = parts[0] || "user-1";
97
+ const role = parts[1] === "admin" ? "admin" : "user";
98
+ return Promise.resolve({
99
+ userId,
100
+ email: `${userId}@mahdins.dev`,
101
+ role
102
+ });
103
+ }
104
+ return Promise.resolve(null);
105
+ }
106
+ function resolveGatewayServiceUrls(config, explicitUrls) {
107
+ if (explicitUrls) {
108
+ return explicitUrls;
109
+ }
110
+ if (process.env.PAND_SERVICE_URLS) {
111
+ return JSON.parse(process.env.PAND_SERVICE_URLS);
112
+ }
113
+ const ports = resolveProjectPorts(config);
114
+ const urls = {};
115
+ for (const [svc, port] of Object.entries(ports.services)) {
116
+ urls[svc] = `http://localhost:${port}`;
117
+ }
118
+ return urls;
119
+ }
120
+ function createGatewayApp(options) {
121
+ const { config } = options;
122
+ const serviceUrls = resolveGatewayServiceUrls(config, options.serviceUrls);
123
+ let lazyAuth = options.auth ?? null;
124
+ const getAuth = () => {
125
+ if (lazyAuth) return lazyAuth;
126
+ const dbUrl = options.databaseUrl ?? process.env.DATABASE_URL;
127
+ if (!dbUrl) return null;
128
+ const dbInstance = createDb({ databaseUrl: dbUrl, schema: authTables });
129
+ lazyAuth = createPandAuth({ db: dbInstance.db, config });
130
+ return lazyAuth;
131
+ };
132
+ const resolveSession = options.resolveSession ?? (async (req) => {
133
+ const auth = getAuth();
134
+ if (auth) {
135
+ return await resolveRequestActor(auth, req);
136
+ }
137
+ return await defaultResolveSession(req);
138
+ });
139
+ const authHandler = options.authHandler ?? (async (req) => {
140
+ const auth = getAuth();
141
+ if (!auth) {
142
+ throw PandError.notFound("Auth handler is not configured on this gateway");
143
+ }
144
+ return await auth.handler(req);
145
+ });
146
+ const logger = createLogger({
147
+ service: "gateway",
148
+ project: config.name,
149
+ level: process.env.NODE_ENV === "test" ? "silent" : void 0
150
+ });
151
+ const app = new Hono();
152
+ app.use("*", async (c, next) => {
153
+ const reqId = c.req.header("x-request-id") ?? crypto.randomUUID();
154
+ c.set("requestId", reqId);
155
+ c.header("x-request-id", reqId);
156
+ await next();
157
+ });
158
+ app.onError((err, c) => {
159
+ const requestId = c.get("requestId") ?? crypto.randomUUID();
160
+ if (err instanceof PandError) {
161
+ return c.json(
162
+ {
163
+ error: {
164
+ code: err.code,
165
+ message: err.message,
166
+ requestId,
167
+ ...err.details ? { details: err.details } : {}
168
+ }
169
+ },
170
+ err.status
171
+ );
172
+ }
173
+ logger.error({ err, requestId }, "gateway_unhandled_error");
174
+ return c.json(
175
+ {
176
+ error: {
177
+ code: "INTERNAL_ERROR",
178
+ message: err instanceof Error ? err.message : "Internal gateway error",
179
+ requestId
180
+ }
181
+ },
182
+ 500
183
+ );
184
+ });
185
+ const callService = async (serviceName, pathAndQuery, init) => {
186
+ const inProcess = options.serviceApps?.[serviceName];
187
+ if (inProcess) {
188
+ const req = new Request(`http://${serviceName}.internal${pathAndQuery}`, init);
189
+ return await inProcess.fetch(req);
190
+ }
191
+ const baseUrl = serviceUrls[serviceName];
192
+ if (!baseUrl) {
193
+ throw PandError.notFound(`Unknown service "${serviceName}"`);
194
+ }
195
+ return await fetch(`${baseUrl.replace(/\/$/, "")}${pathAndQuery}`, init);
196
+ };
197
+ app.get("/healthz", (c) => {
198
+ return c.json({
199
+ status: "ok",
200
+ service: "gateway",
201
+ version: config.pandVersion
202
+ });
203
+ });
204
+ app.get("/readyz", async (c) => {
205
+ const checks = {};
206
+ let ready = true;
207
+ if (options.dbHealth) {
208
+ const dbStatus = await options.dbHealth();
209
+ checks.db = dbStatus;
210
+ if (dbStatus !== "ok") {
211
+ ready = false;
212
+ }
213
+ } else {
214
+ checks.db = "ok";
215
+ }
216
+ return c.json(
217
+ {
218
+ status: ready ? "ready" : "degraded",
219
+ service: "gateway",
220
+ checks
221
+ },
222
+ ready ? 200 : 503
223
+ );
224
+ });
225
+ app.get("/api/config", (c) => {
226
+ const env = process.env.PAND_ENV ?? "dev";
227
+ const ports = resolveProjectPorts(config);
228
+ const posthogKey = process.env.POSTHOG_PUBLIC_KEY;
229
+ const grafanaUrl = env === "prod" ? `https://grafana.${config.domain}` : `http://localhost:${ports.grafanaPort}`;
230
+ const clientConfig = {
231
+ project: config.name,
232
+ displayName: config.displayName,
233
+ env,
234
+ apiBaseUrl: "/api",
235
+ authBaseUrl: "/api/auth",
236
+ ...posthogKey ? {
237
+ posthog: {
238
+ publicKey: posthogKey,
239
+ host: "/ingest"
240
+ }
241
+ } : {},
242
+ links: {
243
+ grafanaUrl,
244
+ posthogUrl: "https://eu.posthog.com",
245
+ apiDocsUrl: "/api/docs"
246
+ }
247
+ };
248
+ return c.json(clientConfig);
249
+ });
250
+ app.all("/api/auth/*", async (c) => {
251
+ return await authHandler(c.req.raw);
252
+ });
253
+ app.get("/api/openapi.json", async (c) => {
254
+ const specs = await Promise.all(
255
+ Object.keys(config.services).map(async (service) => {
256
+ try {
257
+ const res = await callService(service, "/openapi.json");
258
+ if (!res.ok) {
259
+ return { service, spec: { paths: {}, components: { schemas: {} } } };
260
+ }
261
+ const spec = await res.json();
262
+ return { service, spec };
263
+ } catch {
264
+ return { service, spec: { paths: {}, components: { schemas: {} } } };
265
+ }
266
+ })
267
+ );
268
+ const merged = mergeServiceOpenApiSpecs(specs, {
269
+ title: `${config.displayName} API`,
270
+ version: config.pandVersion
271
+ });
272
+ return c.json(merged);
273
+ });
274
+ app.get(
275
+ "/api/docs",
276
+ apiReference({
277
+ spec: {
278
+ url: "/api/openapi.json"
279
+ }
280
+ })
281
+ );
282
+ app.get("/api/admin/meta", async (c) => {
283
+ const actor = await resolveSession(c.req.raw);
284
+ if (!actor) {
285
+ throw PandError.unauthorized("Authentication required");
286
+ }
287
+ if (actor.role !== "admin") {
288
+ throw PandError.forbidden("Admin role required");
289
+ }
290
+ const reqId = c.get("requestId");
291
+ const internalToken = await signInternalToken(
292
+ {
293
+ sub: actor.userId,
294
+ authenticated: true,
295
+ email: actor.email,
296
+ role: actor.role,
297
+ caller: "gateway",
298
+ reqId
299
+ },
300
+ options.internalSecret
301
+ );
302
+ const allMeta = [];
303
+ for (const service of Object.keys(config.services)) {
304
+ try {
305
+ const res = await callService(service, "/_pand/meta", {
306
+ headers: {
307
+ "x-pand-internal-token": internalToken,
308
+ "x-request-id": reqId
309
+ }
310
+ });
311
+ if (res.ok) {
312
+ const body = await res.json();
313
+ const items = Array.isArray(body) ? body : body.entities ?? [];
314
+ allMeta.push(...items);
315
+ }
316
+ } catch (err) {
317
+ logger.warn({ err, service }, "failed_fetching_service_admin_meta");
318
+ }
319
+ }
320
+ return c.json(allMeta);
321
+ });
322
+ const proxyServiceHandler = async (c) => {
323
+ const serviceName = c.req.param("service");
324
+ if (!config.services[serviceName]) {
325
+ throw PandError.notFound(`Service "${serviceName}" not found`);
326
+ }
327
+ const reqId = c.get("requestId");
328
+ const sessionActor = await resolveSession(c.req.raw);
329
+ const internalToken = await signInternalToken(
330
+ sessionActor ? {
331
+ sub: sessionActor.userId,
332
+ authenticated: true,
333
+ email: sessionActor.email,
334
+ role: sessionActor.role,
335
+ caller: "gateway",
336
+ reqId
337
+ } : {
338
+ sub: "anonymous",
339
+ authenticated: false,
340
+ caller: "gateway",
341
+ reqId
342
+ },
343
+ options.internalSecret
344
+ );
345
+ const url = new URL(c.req.url);
346
+ const prefix = `/api/v1/${serviceName}`;
347
+ const strippedPathname = url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) || "/" : url.pathname;
348
+ const targetPathAndQuery = `${strippedPathname}${url.search}`;
349
+ const headers = new Headers();
350
+ const contentType = c.req.header("content-type");
351
+ if (contentType) {
352
+ headers.set("content-type", contentType);
353
+ }
354
+ const accept = c.req.header("accept");
355
+ if (accept) {
356
+ headers.set("accept", accept);
357
+ }
358
+ const traceparent = c.req.header("traceparent");
359
+ if (traceparent) {
360
+ headers.set("traceparent", traceparent);
361
+ }
362
+ headers.set("x-request-id", reqId);
363
+ headers.set("x-pand-internal-token", internalToken);
364
+ const method = c.req.method;
365
+ const hasBody = method !== "GET" && method !== "HEAD";
366
+ const bodyBuffer = hasBody ? await c.req.arrayBuffer() : void 0;
367
+ let upstreamRes;
368
+ try {
369
+ upstreamRes = await callService(serviceName, targetPathAndQuery, {
370
+ method,
371
+ headers,
372
+ body: bodyBuffer && bodyBuffer.byteLength > 0 ? bodyBuffer : void 0
373
+ });
374
+ } catch (err) {
375
+ throw PandError.upstream(`Failed to reach service "${serviceName}"`, {
376
+ cause: err instanceof Error ? err.message : String(err)
377
+ });
378
+ }
379
+ const resHeaders = new Headers(upstreamRes.headers);
380
+ resHeaders.set("x-request-id", reqId);
381
+ return new Response(upstreamRes.body, {
382
+ status: upstreamRes.status,
383
+ headers: resHeaders
384
+ });
385
+ };
386
+ app.all("/api/v1/:service", proxyServiceHandler);
387
+ app.all("/api/v1/:service/*", proxyServiceHandler);
388
+ app.all("/ingest/*", async (c) => {
389
+ const url = new URL(c.req.url);
390
+ const subpath = url.pathname.replace(/^\/ingest/, "");
391
+ const isStatic = subpath.startsWith("/static/");
392
+ const targetHost = isStatic ? "https://eu-assets.i.posthog.com" : (process.env.POSTHOG_HOST ?? "https://eu.i.posthog.com").replace(/\/$/, "");
393
+ const targetUrl = `${targetHost}${subpath}${url.search}`;
394
+ const fetchFn = options.posthogFetch ?? fetch;
395
+ const method = c.req.method;
396
+ const hasBody = method !== "GET" && method !== "HEAD";
397
+ const body = hasBody ? await c.req.arrayBuffer() : void 0;
398
+ const headers = new Headers();
399
+ const contentType = c.req.header("content-type");
400
+ if (contentType) {
401
+ headers.set("content-type", contentType);
402
+ }
403
+ const upstream = await fetchFn(targetUrl, {
404
+ method,
405
+ headers,
406
+ body: body && body.byteLength > 0 ? body : void 0
407
+ });
408
+ return new Response(upstream.body, {
409
+ status: upstream.status,
410
+ headers: {
411
+ "content-type": upstream.headers.get("content-type") ?? "application/json"
412
+ }
413
+ });
414
+ });
415
+ app.notFound((c) => {
416
+ if (process.env.PAND_SERVE_STATIC === "true" || options.staticRootDir) {
417
+ const host = c.req.header("host") ?? "";
418
+ const isAdminHost = host.startsWith(`${config.name}-admin.`);
419
+ const baseStatic = options.staticRootDir ?? path.resolve(process.cwd(), "public", isAdminHost ? "admin" : "web");
420
+ const reqPath = c.req.path === "/" ? "/index.html" : c.req.path;
421
+ const candidate = path.join(baseStatic, reqPath);
422
+ const fallback = path.join(baseStatic, "index.html");
423
+ const fileToServe = fs.existsSync(candidate) && fs.statSync(candidate).isFile() ? candidate : fallback;
424
+ if (fs.existsSync(fileToServe)) {
425
+ const ext = path.extname(fileToServe);
426
+ const mimeMap = {
427
+ ".html": "text/html; charset=utf-8",
428
+ ".js": "application/javascript; charset=utf-8",
429
+ ".css": "text/css; charset=utf-8",
430
+ ".json": "application/json; charset=utf-8",
431
+ ".svg": "image/svg+xml",
432
+ ".png": "image/png"
433
+ };
434
+ return new Response(fs.readFileSync(fileToServe), {
435
+ status: 200,
436
+ headers: { "content-type": mimeMap[ext] ?? "application/octet-stream" }
437
+ });
438
+ }
439
+ }
440
+ const requestId = c.get("requestId") ?? crypto.randomUUID();
441
+ return c.json(
442
+ {
443
+ error: {
444
+ code: "NOT_FOUND",
445
+ message: `Route ${c.req.method} ${c.req.path} not found`,
446
+ requestId
447
+ }
448
+ },
449
+ 404
450
+ );
451
+ });
452
+ return Object.assign(app, {
453
+ serviceName: "gateway",
454
+ logger,
455
+ registerAdminEntity: () => {
456
+ },
457
+ getAdminEntities: () => []
458
+ });
459
+ }
460
+ export {
461
+ createGatewayApp,
462
+ mergeServiceOpenApiSpecs,
463
+ resolveGatewayServiceUrls,
464
+ serveService
465
+ };
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@pands/gateway",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "dependencies": {
15
+ "@hono/node-server": "^1.13.8",
16
+ "@scalar/hono-api-reference": "^0.5.170",
17
+ "hono": "^4.7.2",
18
+ "@pands/auth": "0.1.0",
19
+ "@pands/config": "0.1.0",
20
+ "@pands/logger": "0.1.0",
21
+ "@pands/db": "0.1.0",
22
+ "@pands/service": "0.1.0",
23
+ "@pands/telemetry": "0.1.0"
24
+ },
25
+ "devDependencies": {
26
+ "@pands/testing": "0.1.0"
27
+ },
28
+ "description": "Reverse proxy, routing, and Better Auth authentication gateway for Pand",
29
+ "license": "MIT",
30
+ "author": "Mehdi Nosrati",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/MehdiNosrati/pand.git"
34
+ },
35
+ "homepage": "https://github.com/MehdiNosrati/pand#readme",
36
+ "bugs": {
37
+ "url": "https://github.com/MehdiNosrati/pand/issues"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "files": [
43
+ "dist"
44
+ ],
45
+ "scripts": {
46
+ "build": "tsup src/index.ts --format esm --dts --clean",
47
+ "typecheck": "tsc --noEmit",
48
+ "test": "vitest run"
49
+ }
6
50
  }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.