@mxpicture/print-backlog 0.0.0-stage → 0.0.2

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.
Files changed (63) hide show
  1. package/db/migrations/001_baseline.sql +94 -0
  2. package/dist/public/assets/index-CLP1jBWd.js +1 -0
  3. package/dist/public/assets/index-DxpzozYw.css +1 -0
  4. package/dist/public/favicon.svg +1 -0
  5. package/dist/public/index.html +15 -0
  6. package/dist/server/api/health.d.ts +12 -0
  7. package/dist/server/api/health.d.ts.map +1 -0
  8. package/dist/server/api/health.js +30 -0
  9. package/dist/server/api/health.js.map +1 -0
  10. package/dist/server/api/hooks.d.ts +9 -0
  11. package/dist/server/api/hooks.d.ts.map +1 -0
  12. package/dist/server/api/hooks.js +29 -0
  13. package/dist/server/api/hooks.js.map +1 -0
  14. package/dist/server/api/queues.d.ts +5 -0
  15. package/dist/server/api/queues.d.ts.map +1 -0
  16. package/dist/server/api/queues.js +30 -0
  17. package/dist/server/api/queues.js.map +1 -0
  18. package/dist/server/app.d.ts +10 -0
  19. package/dist/server/app.d.ts.map +1 -0
  20. package/dist/server/app.js +11 -0
  21. package/dist/server/app.js.map +1 -0
  22. package/dist/server/auth.d.ts +11 -0
  23. package/dist/server/auth.d.ts.map +1 -0
  24. package/dist/server/auth.js +31 -0
  25. package/dist/server/auth.js.map +1 -0
  26. package/dist/server/bambuddy.d.ts +15 -0
  27. package/dist/server/bambuddy.d.ts.map +1 -0
  28. package/dist/server/bambuddy.js +31 -0
  29. package/dist/server/bambuddy.js.map +1 -0
  30. package/dist/server/cli.d.ts +3 -0
  31. package/dist/server/cli.d.ts.map +1 -0
  32. package/dist/server/cli.js +123 -0
  33. package/dist/server/cli.js.map +1 -0
  34. package/dist/server/config.d.ts +19 -0
  35. package/dist/server/config.d.ts.map +1 -0
  36. package/dist/server/config.js +63 -0
  37. package/dist/server/config.js.map +1 -0
  38. package/dist/server/db.d.ts +8 -0
  39. package/dist/server/db.d.ts.map +1 -0
  40. package/dist/server/db.js +18 -0
  41. package/dist/server/db.js.map +1 -0
  42. package/dist/server/http.d.ts +90 -0
  43. package/dist/server/http.d.ts.map +1 -0
  44. package/dist/server/http.js +266 -0
  45. package/dist/server/http.js.map +1 -0
  46. package/dist/server/log.d.ts +4 -0
  47. package/dist/server/log.d.ts.map +1 -0
  48. package/dist/server/log.js +4 -0
  49. package/dist/server/log.js.map +1 -0
  50. package/dist/server/schema.d.ts +14 -0
  51. package/dist/server/schema.d.ts.map +1 -0
  52. package/dist/server/schema.js +67 -0
  53. package/dist/server/schema.js.map +1 -0
  54. package/dist/server/version.d.ts +3 -0
  55. package/dist/server/version.d.ts.map +1 -0
  56. package/dist/server/version.js +4 -0
  57. package/dist/server/version.js.map +1 -0
  58. package/dist/shared/types.d.ts +35 -0
  59. package/dist/shared/types.d.ts.map +1 -0
  60. package/dist/shared/types.js +11 -0
  61. package/dist/shared/types.js.map +1 -0
  62. package/package.json +43 -3
  63. package/README.md +0 -3
@@ -0,0 +1,63 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Environment -> typed config. The ansible role (ansible-playbook-contabo-vps,
4
+ * roles/print_backlog) renders it into app.env and the compose file. The
5
+ * database comes from the standard PG* variables (db.ts).
6
+ */
7
+ const emptyAsUndefined = (v) => (v === "" ? undefined : v);
8
+ const optionalString = z.preprocess(emptyAsUndefined, z.string().optional());
9
+ const optionalUrl = z.preprocess(emptyAsUndefined, z.url().optional());
10
+ const schema = z.object({
11
+ PORT: z.coerce.number().int().positive().default(8080),
12
+ // The page's own origin (https://backlog.mxpicture.de). Every write from the
13
+ // browser must carry exactly this Origin: the SSO cookie is valid on every
14
+ // sibling subdomain, so SameSite alone does not stop a cross-site write.
15
+ PUBLIC_ORIGIN: z.url(),
16
+ DATA_DIR: z.string().min(1).default("/data"),
17
+ // Set by Caddy's sso_policy (`inject headers with claims`). The container
18
+ // has no published port, so only Caddy can set it.
19
+ TRUST_USER_HEADER: z.string().min(1).default("X-Token-User-Name"),
20
+ // Development only: the user when no SSO proxy is in front. Refused unless
21
+ // PUBLIC_ORIGIN is a loopback origin, so it can never open a deployed app.
22
+ DEV_USER: optionalString,
23
+ // Bambuddy's webhook sends `Authorization: Bearer <token>`.
24
+ WEBHOOK_TOKEN: z
25
+ .string()
26
+ .min(16, "WEBHOOK_TOKEN must be at least 16 characters"),
27
+ // Send to Bambuddy. Empty = off (download only).
28
+ BAMBUDDY_URL: optionalUrl,
29
+ BAMBUDDY_API_KEY: optionalString,
30
+ BAMBUDDY_FOLDER: z.preprocess(emptyAsUndefined, z.string().default("From Backlog")),
31
+ MAX_UPLOAD_MB: z.coerce.number().int().positive().max(4096).default(512),
32
+ DEFAULT_QUEUE: z.preprocess(emptyAsUndefined, z.string().trim().min(1).default("default")),
33
+ TRASH_DAYS: z.coerce.number().int().nonnegative().default(30),
34
+ });
35
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
36
+ export const loadConfig = (env = process.env) => {
37
+ const c = schema.parse(env);
38
+ // URL.origin drops a trailing slash or path someone put in the variable.
39
+ const origin = new URL(c.PUBLIC_ORIGIN);
40
+ if (c.DEV_USER && !LOOPBACK_HOSTS.has(origin.hostname))
41
+ throw new Error("DEV_USER is for development only: PUBLIC_ORIGIN must be a loopback origin");
42
+ if (c.BAMBUDDY_URL && !c.BAMBUDDY_API_KEY)
43
+ throw new Error("BAMBUDDY_API_KEY is required with BAMBUDDY_URL");
44
+ return {
45
+ port: c.PORT,
46
+ origin: origin.origin,
47
+ dataDir: c.DATA_DIR,
48
+ userHeader: c.TRUST_USER_HEADER.toLowerCase(),
49
+ devUser: c.DEV_USER ?? null,
50
+ webhookToken: c.WEBHOOK_TOKEN,
51
+ bambuddy: c.BAMBUDDY_URL
52
+ ? {
53
+ url: c.BAMBUDDY_URL.replace(/\/+$/, ""),
54
+ apiKey: c.BAMBUDDY_API_KEY,
55
+ folder: c.BAMBUDDY_FOLDER,
56
+ }
57
+ : null,
58
+ maxUploadBytes: c.MAX_UPLOAD_MB * 1024 * 1024,
59
+ defaultQueue: c.DEFAULT_QUEUE,
60
+ trashDays: c.TRASH_DAYS,
61
+ };
62
+ };
63
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/server/config.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,8 @@
1
+ import postgres from "postgres";
2
+ export type Sql = ReturnType<typeof postgres>;
3
+ /**
4
+ * The pool, from the standard PG* variables. The app owns its database
5
+ * (print-backlog-db on mxpicture.de), so one role does everything.
6
+ */
7
+ export declare const createSql: (max?: number) => Sql;
8
+ //# sourceMappingURL=db.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"db.d.ts","sourceRoot":"","sources":["../../src/server/db.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,MAAM,UAAU,CAAC;AAEhC,MAAM,MAAM,GAAG,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAE9C;;;GAGG;AACH,eAAO,MAAM,SAAS,GAAI,YAAO,KAAG,GAYhC,CAAC"}
@@ -0,0 +1,18 @@
1
+ import postgres from "postgres";
2
+ /**
3
+ * The pool, from the standard PG* variables. The app owns its database
4
+ * (print-backlog-db on mxpicture.de), so one role does everything.
5
+ */
6
+ export const createSql = (max = 5) => postgres({
7
+ host: process.env.PGHOST,
8
+ port: Number(process.env.PGPORT) || 5432,
9
+ database: process.env.PGDATABASE,
10
+ username: process.env.PGUSER,
11
+ password: process.env.PGPASSWORD,
12
+ max,
13
+ idle_timeout: 60,
14
+ connect_timeout: 10,
15
+ connection: { application_name: "print-backlog" },
16
+ onnotice: () => { },
17
+ });
18
+ //# sourceMappingURL=db.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"db.js","sourceRoot":"","sources":["../../src/server/db.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,MAAM,UAAU,CAAC;AAIhC;;;GAGG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,GAAG,GAAG,CAAC,EAAO,EAAE,CACxC,QAAQ,CAAC;IACP,IAAI,EAAE,OAAO,CAAC,GAAG,CAAC,MAAM;IACxB,IAAI,EAAE,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,IAAI;IACxC,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,UAAU;IAChC,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,MAAM;IAC5B,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,UAAU;IAChC,GAAG;IACH,YAAY,EAAE,EAAE;IAChB,eAAe,EAAE,EAAE;IACnB,UAAU,EAAE,EAAE,gBAAgB,EAAE,eAAe,EAAE;IACjD,QAAQ,EAAE,GAAG,EAAE,GAAE,CAAC;CACnB,CAAC,CAAC"}
@@ -0,0 +1,90 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ /** A handler's way to answer with a status other than 200/500. */
3
+ export declare class HttpError extends Error {
4
+ readonly status: number;
5
+ constructor(status: number, message: string);
6
+ }
7
+ /**
8
+ * Who may call a route:
9
+ * - `sso`: a browser behind Caddy's SSO gate (the default). Writes must carry
10
+ * the page's own Origin.
11
+ * - `webhook`: Bambuddy, with `Authorization: Bearer <WEBHOOK_TOKEN>`. Caddy
12
+ * lets only /hooks/bambuddy through without SSO.
13
+ * - `public`: anyone (the Gatus health probe).
14
+ */
15
+ export type Access = "sso" | "webhook" | "public";
16
+ /**
17
+ * How a write's body is read: `json` (parsed, size-capped) or `stream` (left
18
+ * on ctx.req for the handler, e.g. a multipart upload).
19
+ */
20
+ export type BodyKind = "json" | "stream";
21
+ export interface Ctx {
22
+ req: IncomingMessage;
23
+ res: ServerResponse;
24
+ url: URL;
25
+ /** `:name` segments of the route */
26
+ params: Record<string, string>;
27
+ /** the parsed JSON body of a `json` write; undefined otherwise */
28
+ body: unknown;
29
+ /** the SSO user; "bambuddy" for the webhook, "-" for a public route */
30
+ user: string;
31
+ }
32
+ /** Returned by a handler that wrote the response itself (a download). */
33
+ export declare const HANDLED: unique symbol;
34
+ /** Returned by a handler that answers JSON with a status other than 200 (e.g. a failing health check). */
35
+ export declare class Reply {
36
+ readonly status: number;
37
+ readonly body: unknown;
38
+ constructor(status: number, body: unknown);
39
+ }
40
+ export type Handler = (ctx: Ctx) => unknown;
41
+ export type Method = "GET" | "POST" | "PATCH" | "DELETE";
42
+ export interface RouteOptions {
43
+ access?: Access;
44
+ body?: BodyKind;
45
+ }
46
+ export interface Match {
47
+ handler: Handler;
48
+ params: Record<string, string>;
49
+ access: Access;
50
+ body: BodyKind;
51
+ }
52
+ /** Exact paths with `:name` segments; the first route that matches wins. */
53
+ export declare class Router {
54
+ #private;
55
+ add(method: Method, path: string, handler: Handler, opts?: RouteOptions): this;
56
+ get: (path: string, handler: Handler, opts?: RouteOptions) => this;
57
+ post: (path: string, handler: Handler, opts?: RouteOptions) => this;
58
+ patch: (path: string, handler: Handler, opts?: RouteOptions) => this;
59
+ delete: (path: string, handler: Handler, opts?: RouteOptions) => this;
60
+ /** The route, "method" when only the method is wrong, or null. */
61
+ match(method: string, pathname: string): Match | "method" | null;
62
+ }
63
+ /** Sent with every response. Nothing is loaded from elsewhere. */
64
+ export declare const SECURITY_HEADERS: Record<string, string>;
65
+ export declare const sendJson: (res: ServerResponse, status: number, body: unknown) => void;
66
+ export interface ServerDeps {
67
+ /** the only Origin a browser write may carry (Config.origin) */
68
+ origin: string;
69
+ /** the SSO user header, lower-case (Config.userHeader) */
70
+ userHeader: string;
71
+ /** development only (Config.devUser) */
72
+ devUser: string | null;
73
+ webhookToken: string;
74
+ router: Router;
75
+ /** the built front-end (dist/public); null = API only */
76
+ staticDir: string | null;
77
+ log: (line: string) => void;
78
+ }
79
+ /**
80
+ * One request.
81
+ * - The page and its assets are served without a user check: Caddy's SSO gate
82
+ * covers every path but /health and /hooks/bambuddy.
83
+ * - `sso` API routes need the SSO user header (else 500: fail closed); their
84
+ * writes must carry Origin == deps.origin, so neither a sibling subdomain's
85
+ * script nor a plain HTML form can make one.
86
+ * - `webhook` routes need the bearer token.
87
+ * Each write leaves an audit line with the user.
88
+ */
89
+ export declare const handle: (req: IncomingMessage, res: ServerResponse, deps: ServerDeps) => Promise<void>;
90
+ //# sourceMappingURL=http.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/server/http.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,266 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { extname, join, normalize, sep } from "node:path";
3
+ import { ZodError } from "zod";
4
+ import { bearerMatches, ssoUser } from "./auth.js";
5
+ /** A handler's way to answer with a status other than 200/500. */
6
+ export class HttpError extends Error {
7
+ status;
8
+ constructor(status, message) {
9
+ super(message);
10
+ this.status = status;
11
+ }
12
+ }
13
+ /** Returned by a handler that wrote the response itself (a download). */
14
+ export const HANDLED = Symbol("handled");
15
+ /** Returned by a handler that answers JSON with a status other than 200 (e.g. a failing health check). */
16
+ export class Reply {
17
+ status;
18
+ body;
19
+ constructor(status, body) {
20
+ this.status = status;
21
+ this.body = body;
22
+ }
23
+ }
24
+ /** Exact paths with `:name` segments; the first route that matches wins. */
25
+ export class Router {
26
+ #routes = [];
27
+ add(method, path, handler, opts = {}) {
28
+ this.#routes.push({
29
+ method,
30
+ parts: path.split("/"),
31
+ handler,
32
+ access: opts.access ?? "sso",
33
+ body: opts.body ?? "json",
34
+ });
35
+ return this;
36
+ }
37
+ get = (path, handler, opts) => this.add("GET", path, handler, opts);
38
+ post = (path, handler, opts) => this.add("POST", path, handler, opts);
39
+ patch = (path, handler, opts) => this.add("PATCH", path, handler, opts);
40
+ delete = (path, handler, opts) => this.add("DELETE", path, handler, opts);
41
+ /** The route, "method" when only the method is wrong, or null. */
42
+ match(method, pathname) {
43
+ const parts = pathname.split("/");
44
+ let wrongMethod = false;
45
+ for (const r of this.#routes) {
46
+ if (r.parts.length !== parts.length)
47
+ continue;
48
+ const params = {};
49
+ const ok = r.parts.every((p, i) => {
50
+ if (p.startsWith(":")) {
51
+ try {
52
+ params[p.slice(1)] = decodeURIComponent(parts[i]);
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ return parts[i] !== "";
58
+ }
59
+ return p === parts[i];
60
+ });
61
+ if (!ok)
62
+ continue;
63
+ if (r.method === method || (method === "HEAD" && r.method === "GET"))
64
+ return { handler: r.handler, params, access: r.access, body: r.body };
65
+ wrongMethod = true;
66
+ }
67
+ return wrongMethod ? "method" : null;
68
+ }
69
+ }
70
+ /** Sent with every response. Nothing is loaded from elsewhere. */
71
+ export const SECURITY_HEADERS = {
72
+ "Content-Security-Policy": "default-src 'self'; img-src 'self' data: blob:; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
73
+ "X-Content-Type-Options": "nosniff",
74
+ "Referrer-Policy": "same-origin",
75
+ };
76
+ export const sendJson = (res, status, body) => {
77
+ const data = JSON.stringify(body ?? null);
78
+ res.writeHead(status, {
79
+ ...SECURITY_HEADERS,
80
+ "Content-Type": "application/json; charset=utf-8",
81
+ "Content-Length": Buffer.byteLength(data),
82
+ "Cache-Control": "no-store",
83
+ });
84
+ res.end(data);
85
+ };
86
+ /** Large enough for any JSON body here (a webhook payload included), small enough not to buffer an attack. */
87
+ const MAX_JSON_BYTES = 256 * 1024;
88
+ const readJson = async (req) => {
89
+ const chunks = [];
90
+ let size = 0;
91
+ for await (const chunk of req) {
92
+ size += chunk.length;
93
+ if (size > MAX_JSON_BYTES)
94
+ throw new HttpError(413, "request body too large");
95
+ chunks.push(chunk);
96
+ }
97
+ if (size === 0)
98
+ return {};
99
+ try {
100
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
101
+ }
102
+ catch {
103
+ throw new HttpError(400, "invalid JSON body");
104
+ }
105
+ };
106
+ /** Postgres error codes that are the caller's fault: a readable 4xx, not a 500. */
107
+ const PG_CLIENT_ERRORS = {
108
+ "23505": 409, // unique_violation (a queue name that is taken)
109
+ "23503": 409, // foreign_key_violation
110
+ "23514": 400, // check_violation
111
+ "22P02": 400, // invalid_text_representation
112
+ };
113
+ const errorResponse = (e) => {
114
+ if (e instanceof HttpError)
115
+ return { status: e.status, message: e.message };
116
+ if (e instanceof ZodError)
117
+ return {
118
+ status: 400,
119
+ message: e.issues
120
+ .map((i) => `${i.path.join(".") || "body"}: ${i.message}`)
121
+ .join("; "),
122
+ };
123
+ const code = e?.code;
124
+ if (typeof code === "string" && PG_CLIENT_ERRORS[code])
125
+ return { status: PG_CLIENT_ERRORS[code], message: e.message };
126
+ return { status: 500, message: "internal error" };
127
+ };
128
+ const isWrite = (method) => method !== "GET" && method !== "HEAD";
129
+ const isJson = (req) => /^application\/json\s*(;|$)/i.test(req.headers["content-type"] ?? "");
130
+ /**
131
+ * One request.
132
+ * - The page and its assets are served without a user check: Caddy's SSO gate
133
+ * covers every path but /health and /hooks/bambuddy.
134
+ * - `sso` API routes need the SSO user header (else 500: fail closed); their
135
+ * writes must carry Origin == deps.origin, so neither a sibling subdomain's
136
+ * script nor a plain HTML form can make one.
137
+ * - `webhook` routes need the bearer token.
138
+ * Each write leaves an audit line with the user.
139
+ */
140
+ export const handle = async (req, res, deps) => {
141
+ const started = Date.now();
142
+ const method = req.method ?? "GET";
143
+ const url = new URL(req.url ?? "/", "http://localhost");
144
+ let user = "-";
145
+ const audit = (status, note = "") => deps.log(`user=${user} ${method} ${url.pathname}${url.search} ${status} ${Date.now() - started}ms${note}`);
146
+ try {
147
+ const isApi = url.pathname.startsWith("/api/") ||
148
+ url.pathname === "/health" ||
149
+ url.pathname.startsWith("/hooks/");
150
+ if (!isApi) {
151
+ if (isWrite(method))
152
+ throw new HttpError(405, "method not allowed");
153
+ return await serveStatic(req, res, deps.staticDir, url.pathname);
154
+ }
155
+ const match = deps.router.match(method, url.pathname);
156
+ if (match === null)
157
+ throw new HttpError(404, "not found");
158
+ if (match === "method")
159
+ throw new HttpError(405, "method not allowed");
160
+ if (match.access === "sso") {
161
+ const u = ssoUser(req, deps.userHeader, deps.devUser);
162
+ if (!u) {
163
+ deps.log(`${method} ${url.pathname}: no ${deps.userHeader} header; is the SSO gate in front?`);
164
+ throw new HttpError(500, "no SSO user: the request did not come through the SSO gate");
165
+ }
166
+ user = u;
167
+ if (isWrite(method) && req.headers.origin !== deps.origin) {
168
+ audit(403, ` (Origin ${JSON.stringify(req.headers.origin ?? null)})`);
169
+ return sendJson(res, 403, { error: "cross-origin write refused" });
170
+ }
171
+ }
172
+ else if (match.access === "webhook") {
173
+ if (!bearerMatches(req, deps.webhookToken)) {
174
+ audit(401);
175
+ return sendJson(res, 401, { error: "invalid or missing bearer token" });
176
+ }
177
+ user = "bambuddy";
178
+ }
179
+ let body;
180
+ if (isWrite(method) && match.body === "json") {
181
+ if (!isJson(req))
182
+ throw new HttpError(415, "a write must be Content-Type: application/json");
183
+ body = await readJson(req);
184
+ }
185
+ const result = await match.handler({
186
+ req,
187
+ res,
188
+ url,
189
+ params: match.params,
190
+ body,
191
+ user,
192
+ });
193
+ if (result === HANDLED)
194
+ return;
195
+ const [status, payload] = result instanceof Reply ? [result.status, result.body] : [200, result];
196
+ sendJson(res, status, payload);
197
+ if (isWrite(method))
198
+ audit(status);
199
+ }
200
+ catch (e) {
201
+ const { status, message } = errorResponse(e);
202
+ if (status >= 500)
203
+ deps.log(`${method} ${url.pathname}: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`);
204
+ if (res.headersSent) {
205
+ res.end();
206
+ return;
207
+ }
208
+ sendJson(res, status, { error: message });
209
+ if (isWrite(method) || status >= 500)
210
+ audit(status);
211
+ }
212
+ };
213
+ const CONTENT_TYPES = {
214
+ ".html": "text/html; charset=utf-8",
215
+ ".js": "text/javascript; charset=utf-8",
216
+ ".css": "text/css; charset=utf-8",
217
+ ".svg": "image/svg+xml",
218
+ ".png": "image/png",
219
+ ".ico": "image/x-icon",
220
+ ".json": "application/json",
221
+ ".woff2": "font/woff2",
222
+ ".txt": "text/plain; charset=utf-8",
223
+ };
224
+ const isFile = async (path) => {
225
+ try {
226
+ return (await stat(path)).isFile();
227
+ }
228
+ catch {
229
+ return false;
230
+ }
231
+ };
232
+ /**
233
+ * The built front-end. Vite's hashed files under /assets/ are cached for a
234
+ * year; everything else is revalidated. A path without an extension that is
235
+ * not a file is a page of the app (client-side routing): index.html.
236
+ */
237
+ const serveStatic = async (req, res, dir, pathname) => {
238
+ if (!dir)
239
+ throw new HttpError(404, "not found");
240
+ let rel;
241
+ try {
242
+ rel = normalize(decodeURIComponent(pathname)).replace(/^([/\\])+/, "");
243
+ }
244
+ catch {
245
+ throw new HttpError(400, "bad path");
246
+ }
247
+ if (rel.includes("\0") || rel.split(sep).includes(".."))
248
+ throw new HttpError(400, "bad path");
249
+ let file = join(dir, rel);
250
+ if (rel === "" || !(await isFile(file))) {
251
+ if (extname(rel) !== "")
252
+ throw new HttpError(404, "not found");
253
+ file = join(dir, "index.html");
254
+ }
255
+ const data = await readFile(file);
256
+ res.writeHead(200, {
257
+ ...SECURITY_HEADERS,
258
+ "Content-Type": CONTENT_TYPES[extname(file)] ?? "application/octet-stream",
259
+ "Content-Length": data.length,
260
+ "Cache-Control": rel.startsWith("assets/")
261
+ ? "public, max-age=31536000, immutable"
262
+ : "no-cache",
263
+ });
264
+ res.end(req.method === "HEAD" ? undefined : data);
265
+ };
266
+ //# sourceMappingURL=http.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http.js","sourceRoot":"","sources":["../../src/server/http.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ /** One line per event on stdout (Dozzle), with a timestamp. */
2
+ export declare const log: (line: string) => void;
3
+ export declare const errorMessage: (e: unknown) => string;
4
+ //# sourceMappingURL=log.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"log.d.ts","sourceRoot":"","sources":["../../src/server/log.ts"],"names":[],"mappings":"AAAA,+DAA+D;AAC/D,eAAO,MAAM,GAAG,GAAI,MAAM,MAAM,KAAG,IACiB,CAAC;AAErD,eAAO,MAAM,YAAY,GAAI,GAAG,OAAO,KAAG,MACE,CAAC"}
@@ -0,0 +1,4 @@
1
+ /** One line per event on stdout (Dozzle), with a timestamp. */
2
+ export const log = (line) => console.log(`${new Date().toISOString()} ${line}`);
3
+ export const errorMessage = (e) => e instanceof Error ? e.message : String(e);
4
+ //# sourceMappingURL=log.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"log.js","sourceRoot":"","sources":["../../src/server/log.ts"],"names":[],"mappings":"AAAA,+DAA+D;AAC/D,MAAM,CAAC,MAAM,GAAG,GAAG,CAAC,IAAY,EAAQ,EAAE,CACxC,OAAO,CAAC,GAAG,CAAC,GAAG,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE,IAAI,IAAI,EAAE,CAAC,CAAC;AAErD,MAAM,CAAC,MAAM,YAAY,GAAG,CAAC,CAAU,EAAU,EAAE,CACjD,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC"}
@@ -0,0 +1,14 @@
1
+ import type { Sql } from "./db.js";
2
+ /**
3
+ * dbmate's bookkeeping table, schema-qualified so it never depends on
4
+ * search_path.
5
+ */
6
+ export declare const MIGRATIONS_TABLE = "backlog.schema_migrations";
7
+ /**
8
+ * Bring schema `backlog` up to date and make sure the default queue exists.
9
+ * Safe to re-run on every start; a second run changes nothing.
10
+ */
11
+ export declare const applySchema: (sql: Sql, defaultQueue: string) => Promise<void>;
12
+ /** The first queue, created only while there is none (it can be renamed later). */
13
+ export declare const ensureDefaultQueue: (sql: Sql, name: string) => Promise<void>;
14
+ //# sourceMappingURL=schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/server/schema.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,SAAS,CAAC;AAEnC;;;GAGG;AACH,eAAO,MAAM,gBAAgB,8BAA8B,CAAC;AAqD5D;;;GAGG;AACH,eAAO,MAAM,WAAW,GACtB,KAAK,GAAG,EACR,cAAc,MAAM,KACnB,OAAO,CAAC,IAAI,CAId,CAAC;AAEF,mFAAmF;AACnF,eAAO,MAAM,kBAAkB,GAC7B,KAAK,GAAG,EACR,MAAM,MAAM,KACX,OAAO,CAAC,IAAI,CAMd,CAAC"}
@@ -0,0 +1,67 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ import { fileURLToPath } from "node:url";
4
+ /**
5
+ * dbmate's bookkeeping table, schema-qualified so it never depends on
6
+ * search_path.
7
+ */
8
+ export const MIGRATIONS_TABLE = "backlog.schema_migrations";
9
+ /** The migrations ship inside the package (`files`: db); this module is <pkg>/dist/server/schema.js. */
10
+ const migrationsDir = () => fileURLToPath(new URL("../../db/migrations", import.meta.url));
11
+ /**
12
+ * dbmate's JS launcher, resolved inside the package: `npm install -g` links no
13
+ * `dbmate` onto PATH for a dependency. It picks the platform binary from
14
+ * dbmate's optionalDependencies at run time.
15
+ */
16
+ const dbmateCli = () => createRequire(import.meta.url).resolve("dbmate/dist/cli.js");
17
+ /** DATABASE_URL for dbmate from the same PG* variables postgres.js reads. */
18
+ const databaseUrl = () => {
19
+ const enc = encodeURIComponent;
20
+ const user = enc(process.env.PGUSER ?? "");
21
+ const password = process.env.PGPASSWORD;
22
+ const auth = password ? `${user}:${enc(password)}` : user;
23
+ const host = process.env.PGHOST ?? "localhost";
24
+ const port = process.env.PGPORT ?? "5432";
25
+ const database = enc(process.env.PGDATABASE ?? "");
26
+ const sslmode = process.env.PGSSLMODE ?? "disable";
27
+ return `postgres://${auth}@${host}:${port}/${database}?sslmode=${sslmode}`;
28
+ };
29
+ const dbmateEnv = () => ({
30
+ ...process.env,
31
+ DATABASE_URL: databaseUrl(),
32
+ DBMATE_MIGRATIONS_DIR: migrationsDir(),
33
+ DBMATE_MIGRATIONS_TABLE: MIGRATIONS_TABLE,
34
+ // The image has no pg_dump, and the schema snapshot is not kept.
35
+ DBMATE_NO_DUMP_SCHEMA: "true",
36
+ // Refuse a pending migration that sorts before an applied one.
37
+ DBMATE_STRICT: "true",
38
+ });
39
+ /** One dbmate subcommand. Never `up` (CREATE DATABASE) and never `drop`. */
40
+ const dbmate = (...args) => {
41
+ const { status, error } = spawnSync(process.execPath, [dbmateCli(), ...args], {
42
+ stdio: "inherit",
43
+ env: dbmateEnv(),
44
+ });
45
+ if (error)
46
+ throw error;
47
+ if (status !== 0)
48
+ throw new Error(`dbmate ${args.join(" ")} exited with ${status}`);
49
+ };
50
+ /**
51
+ * Bring schema `backlog` up to date and make sure the default queue exists.
52
+ * Safe to re-run on every start; a second run changes nothing.
53
+ */
54
+ export const applySchema = async (sql, defaultQueue) => {
55
+ await sql `CREATE SCHEMA IF NOT EXISTS backlog`;
56
+ dbmate("migrate");
57
+ await ensureDefaultQueue(sql, defaultQueue);
58
+ };
59
+ /** The first queue, created only while there is none (it can be renamed later). */
60
+ export const ensureDefaultQueue = async (sql, name) => {
61
+ await sql `
62
+ INSERT INTO backlog.queue (name, position)
63
+ SELECT ${name}, 1024
64
+ WHERE NOT EXISTS (SELECT 1 FROM backlog.queue)
65
+ `;
66
+ };
67
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/server/schema.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ /** This package's version, from its package.json (<pkg>/dist/server/version.js -> <pkg>/package.json). */
2
+ export declare const VERSION: string;
3
+ //# sourceMappingURL=version.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../../src/server/version.ts"],"names":[],"mappings":"AAEA,0GAA0G;AAC1G,eAAO,MAAM,OAAO,EAAE,MAEb,CAAC"}
@@ -0,0 +1,4 @@
1
+ import { createRequire } from "node:module";
2
+ /** This package's version, from its package.json (<pkg>/dist/server/version.js -> <pkg>/package.json). */
3
+ export const VERSION = createRequire(import.meta.url)("../../package.json").version;
4
+ //# sourceMappingURL=version.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.js","sourceRoot":"","sources":["../../src/server/version.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAE5C,0GAA0G;AAC1G,MAAM,CAAC,MAAM,OAAO,GAClB,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,oBAAoB,CACpD,CAAC,OAAO,CAAC"}