aibroker 0.31.0 → 0.31.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.
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "aibroker-ota",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "commonjs",
6
+ "main": "dist/server.js",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "dev": "ts-node src/server.ts"
10
+ },
11
+ "dependencies": {
12
+ "express": "^4.19.2",
13
+ "multer": "^1.4.5-lts.1",
14
+ "zod": "^3.23.8"
15
+ },
16
+ "devDependencies": {
17
+ "@types/express": "^4.17.21",
18
+ "@types/multer": "^1.4.11",
19
+ "@types/node": "^22.0.0",
20
+ "typescript": "^5.5.4"
21
+ }
22
+ }
@@ -0,0 +1,44 @@
1
+ import { RequestHandler } from "express";
2
+
3
+ // TRUST_HEADER defaults true only when the bind is loopback (127.0.0.1).
4
+ // If the bind ever changes to 0.0.0.0, set TRUST_HEADER=true explicitly.
5
+ // WARNING: on loopback, local processes on the Mac can spoof Tailscale-User-Login
6
+ // because the header is set by Tailscale Serve, not verified by the kernel.
7
+ // This is an accepted assumption: the threat model is remote attackers, not local root.
8
+
9
+ const TRUST = process.env.TRUST_HEADER !== "false";
10
+
11
+ if (!TRUST) {
12
+ console.warn("[auth] TRUST_HEADER=false — all write operations will be rejected");
13
+ }
14
+
15
+ function publishers(): string[] {
16
+ return (process.env.AIBROKER_OTA_PUBLISHERS ?? "")
17
+ .split(",")
18
+ .map((s) => s.trim())
19
+ .filter(Boolean);
20
+ }
21
+
22
+ /** Allow any tailnet user to read. Gate writes to the publisher allowlist. */
23
+ export const requirePublisher: RequestHandler = (req, res, next) => {
24
+ if (!TRUST) {
25
+ res.status(403).json({ error: "Write operations disabled (TRUST_HEADER=false)" });
26
+ return;
27
+ }
28
+ const login = req.headers["tailscale-user-login"] as string | undefined;
29
+ const allowed = publishers();
30
+ if (allowed.length === 0) {
31
+ // No allowlist configured — accept any authenticated tailnet user.
32
+ next();
33
+ return;
34
+ }
35
+ if (!login || !allowed.some((p) => login.includes(p))) {
36
+ res.status(403).json({ error: "Not in publisher allowlist" });
37
+ return;
38
+ }
39
+ next();
40
+ };
41
+
42
+ export function callerLogin(req: import("express").Request): string {
43
+ return (req.headers["tailscale-user-login"] as string | undefined) ?? "unknown";
44
+ }
@@ -0,0 +1,52 @@
1
+ import { Request } from "express";
2
+ import { AppMeta } from "./paths.js";
3
+
4
+ export function renderLanding(req: Request, slug: string, meta: AppMeta): string {
5
+ const proto = (req.headers["x-forwarded-proto"] as string | undefined) ?? req.protocol;
6
+ const host = req.headers.host ?? "localhost";
7
+ const base = `${proto}://${host}`;
8
+
9
+ if (meta.platform === "ios") {
10
+ const manifestUrl = `${base}/install/${slug}/manifest.plist`;
11
+ const itmsUrl = `itms-services://?action=download-manifest&url=${encodeURIComponent(manifestUrl)}`;
12
+ return `<!DOCTYPE html>
13
+ <html>
14
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
15
+ <title>Install ${meta.name}</title>
16
+ <style>body{font-family:system-ui;max-width:480px;margin:60px auto;padding:0 24px;text-align:center}
17
+ a.btn{display:inline-block;margin-top:24px;padding:14px 32px;background:#007aff;color:#fff;border-radius:12px;text-decoration:none;font-size:18px}</style>
18
+ </head>
19
+ <body>
20
+ <h1>${meta.name}</h1>
21
+ <p>Version ${meta.version}</p>
22
+ ${meta.icon ? `<img src="/install/${slug}/icon.png" width="120" height="120" style="border-radius:22px">` : ""}
23
+ <br>
24
+ <a class="btn" href="${itmsUrl}">Install on iOS</a>
25
+ <p style="margin-top:32px;font-size:13px;color:#888">
26
+ Ad-hoc build — your device UDID must be in the provisioning profile.
27
+ </p>
28
+ </body>
29
+ </html>`;
30
+ }
31
+
32
+ // Android
33
+ const apkUrl = `${base}/install/${slug}/${meta.apkFile ?? ""}`;
34
+ return `<!DOCTYPE html>
35
+ <html>
36
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
37
+ <title>Install ${meta.name}</title>
38
+ <style>body{font-family:system-ui;max-width:480px;margin:60px auto;padding:0 24px;text-align:center}
39
+ a.btn{display:inline-block;margin-top:24px;padding:14px 32px;background:#3ddc84;color:#000;border-radius:12px;text-decoration:none;font-size:18px}</style>
40
+ </head>
41
+ <body>
42
+ <h1>${meta.name}</h1>
43
+ <p>Version ${meta.version}</p>
44
+ ${meta.icon ? `<img src="/install/${slug}/icon.png" width="120" height="120" style="border-radius:22px">` : ""}
45
+ <br>
46
+ <a class="btn" href="${apkUrl}">Download APK</a>
47
+ <p style="margin-top:32px;font-size:13px;color:#888">
48
+ Enable "Install unknown apps" in Android settings before installing.
49
+ </p>
50
+ </body>
51
+ </html>`;
52
+ }
@@ -0,0 +1,44 @@
1
+ import { Request } from "express";
2
+ import { AppMeta } from "./paths.js";
3
+
4
+ // manifest URL must be derived from req — iOS fails silently on host mismatch
5
+ export function renderManifest(req: Request, slug: string, meta: AppMeta): string {
6
+ const proto = (req.headers["x-forwarded-proto"] as string | undefined) ?? req.protocol;
7
+ const host = req.headers.host ?? "localhost";
8
+ const base = `${proto}://${host}`;
9
+ const ipaFile = meta.ipaFile ?? "";
10
+ const assetUrl = `${base}/install/${slug}/${ipaFile}`;
11
+
12
+ return `<?xml version="1.0" encoding="UTF-8"?>
13
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
14
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
15
+ <plist version="1.0">
16
+ <dict>
17
+ <key>items</key>
18
+ <array>
19
+ <dict>
20
+ <key>assets</key>
21
+ <array>
22
+ <dict>
23
+ <key>kind</key>
24
+ <string>software-package</string>
25
+ <key>url</key>
26
+ <string>${assetUrl}</string>
27
+ </dict>
28
+ </array>
29
+ <key>metadata</key>
30
+ <dict>
31
+ <key>bundle-identifier</key>
32
+ <string>${meta.bundleId}</string>
33
+ <key>bundle-version</key>
34
+ <string>${meta.version}</string>
35
+ <key>kind</key>
36
+ <string>software</string>
37
+ <key>title</key>
38
+ <string>${meta.name}</string>
39
+ </dict>
40
+ </dict>
41
+ </array>
42
+ </dict>
43
+ </plist>`;
44
+ }
@@ -0,0 +1,54 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ const DATA_DIR = process.env.DATA_DIR ?? "/data";
5
+ export const APPS_DIR = join(DATA_DIR, "apps");
6
+ export const TMP_DIR = join(DATA_DIR, "uploads-tmp");
7
+
8
+ export function ensureDirs(): void {
9
+ mkdirSync(APPS_DIR, { recursive: true });
10
+ mkdirSync(TMP_DIR, { recursive: true });
11
+ }
12
+
13
+ export function slugDir(slug: string): string {
14
+ return join(APPS_DIR, slug);
15
+ }
16
+
17
+ export function metaPath(slug: string): string {
18
+ return join(slugDir(slug), "meta.json");
19
+ }
20
+
21
+ export interface AppMeta {
22
+ name: string;
23
+ bundleId: string;
24
+ version: string;
25
+ platform: "ios" | "android";
26
+ ipaFile?: string;
27
+ apkFile?: string;
28
+ icon?: string;
29
+ updatedAt: string;
30
+ updatedBy: string;
31
+ }
32
+
33
+ export function readMeta(slug: string): AppMeta | null {
34
+ const p = metaPath(slug);
35
+ if (!existsSync(p)) return null;
36
+ try {
37
+ return JSON.parse(readFileSync(p, "utf-8")) as AppMeta;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ export function writeMeta(slug: string, meta: AppMeta): void {
44
+ mkdirSync(slugDir(slug), { recursive: true });
45
+ writeFileSync(metaPath(slug), JSON.stringify(meta, null, 2), "utf-8");
46
+ }
47
+
48
+ export function listSlugs(): string[] {
49
+ if (!existsSync(APPS_DIR)) return [];
50
+ const { readdirSync, statSync } = require("node:fs") as typeof import("node:fs");
51
+ return readdirSync(APPS_DIR).filter((e: string) =>
52
+ statSync(join(APPS_DIR, e)).isDirectory()
53
+ );
54
+ }
@@ -0,0 +1,177 @@
1
+ import express, { Request, Response } from "express";
2
+ import multer from "multer";
3
+ import { z } from "zod";
4
+ import { existsSync, createReadStream, readdirSync } from "node:fs";
5
+ import { join, extname, basename } from "node:path";
6
+ import { rename } from "node:fs/promises";
7
+ import {
8
+ ensureDirs, slugDir, listSlugs, readMeta, writeMeta,
9
+ APPS_DIR, TMP_DIR, AppMeta,
10
+ } from "./paths.js";
11
+ import { requirePublisher, callerLogin } from "./auth.js";
12
+ import { renderManifest } from "./manifest.js";
13
+ import { renderLanding } from "./landing.js";
14
+
15
+ ensureDirs();
16
+
17
+ const app = express();
18
+ app.use(express.json());
19
+
20
+ // Multer stores to TMP_DIR (same volume as APPS_DIR — avoids cross-device rename on big IPAs)
21
+ const upload = multer({ dest: TMP_DIR });
22
+
23
+ const UploadSchema = z.object({
24
+ slug: z.string().min(1).regex(/^[a-z0-9-]+$/, "slug must be lowercase alphanumeric + hyphens"),
25
+ name: z.string().min(1),
26
+ bundleId: z.string().min(1),
27
+ version: z.string().min(1),
28
+ platform: z.enum(["ios", "android"]),
29
+ });
30
+
31
+ // ── Health ──────────────────────────────────────────────────────────────────
32
+
33
+ app.get("/healthz", (_req, res) => {
34
+ res.json({ ok: true });
35
+ });
36
+
37
+ // ── Apps API ────────────────────────────────────────────────────────────────
38
+
39
+ app.get("/api/apps", (_req, res) => {
40
+ const slugs = listSlugs();
41
+ const apps = slugs.map((slug) => ({ slug, ...readMeta(slug) })).filter((a) => a.name);
42
+ res.json(apps);
43
+ });
44
+
45
+ app.post("/api/apps", requirePublisher, upload.single("file"), async (req: Request, res: Response) => {
46
+ const parsed = UploadSchema.safeParse(req.body);
47
+ if (!parsed.success) {
48
+ res.status(400).json({ error: parsed.error.flatten() });
49
+ return;
50
+ }
51
+ if (!req.file) {
52
+ res.status(400).json({ error: "Missing file field" });
53
+ return;
54
+ }
55
+
56
+ const { slug, name, bundleId, version, platform } = parsed.data;
57
+ const ext = extname(req.file.originalname).toLowerCase();
58
+
59
+ if (platform === "ios" && ext !== ".ipa") {
60
+ res.status(400).json({ error: "iOS platform requires .ipa file" });
61
+ return;
62
+ }
63
+ if (platform === "android" && ext !== ".apk") {
64
+ // .aab is not installable on devices — only .apk
65
+ res.status(400).json({ error: "Android platform requires .apk file (.aab is not device-installable)" });
66
+ return;
67
+ }
68
+
69
+ const dir = slugDir(slug);
70
+ const fileName = `${name}-${version}${ext}`;
71
+ const dest = join(dir, fileName);
72
+
73
+ // ensureDirs for this slug
74
+ const { mkdirSync } = await import("node:fs");
75
+ mkdirSync(dir, { recursive: true });
76
+
77
+ await rename(req.file.path, dest);
78
+
79
+ const meta: AppMeta = {
80
+ name, bundleId, version, platform,
81
+ updatedAt: new Date().toISOString(),
82
+ updatedBy: callerLogin(req),
83
+ ...(platform === "ios" ? { ipaFile: fileName } : { apkFile: fileName }),
84
+ };
85
+ writeMeta(slug, meta);
86
+
87
+ const proto = (req.headers["x-forwarded-proto"] as string | undefined) ?? req.protocol;
88
+ const host = req.headers.host ?? "localhost";
89
+ res.status(201).json({
90
+ ok: true,
91
+ installUrl: `${proto}://${host}/install/${slug}/`,
92
+ });
93
+ });
94
+
95
+ app.delete("/api/apps/:slug", requirePublisher, (req: Request, res: Response) => {
96
+ const { slug } = req.params;
97
+ const dir = slugDir(slug);
98
+ if (!existsSync(dir)) {
99
+ res.status(404).json({ error: "App not found" });
100
+ return;
101
+ }
102
+ const { rmSync } = require("node:fs") as typeof import("node:fs");
103
+ rmSync(dir, { recursive: true, force: true });
104
+ res.json({ ok: true });
105
+ });
106
+
107
+ // ── Install routes ──────────────────────────────────────────────────────────
108
+
109
+ app.get("/install/:slug/", (req: Request, res: Response) => {
110
+ const { slug } = req.params;
111
+ const meta = readMeta(slug);
112
+ if (!meta) {
113
+ res.status(404).send("App not found");
114
+ return;
115
+ }
116
+ res.send(renderLanding(req, slug, meta));
117
+ });
118
+
119
+ app.get("/install/:slug/manifest.plist", (req: Request, res: Response) => {
120
+ const { slug } = req.params;
121
+ const meta = readMeta(slug);
122
+ if (!meta || meta.platform !== "ios") {
123
+ res.status(404).send("Not found");
124
+ return;
125
+ }
126
+ // manifest URL must be derived from req — iOS fails silently on host mismatch
127
+ res.set({
128
+ "Content-Type": "application/xml",
129
+ "Cache-Control": "no-store",
130
+ });
131
+ res.send(renderManifest(req, slug, meta));
132
+ });
133
+
134
+ // Stream IPA / APK / icon files
135
+ app.get("/install/:slug/:file", (req: Request, res: Response) => {
136
+ const { slug, file } = req.params;
137
+ const filePath = join(slugDir(slug), file);
138
+ if (!existsSync(filePath)) {
139
+ res.status(404).send("Not found");
140
+ return;
141
+ }
142
+ // Prevent path traversal
143
+ if (!filePath.startsWith(APPS_DIR)) {
144
+ res.status(403).send("Forbidden");
145
+ return;
146
+ }
147
+ const ext = extname(file).toLowerCase();
148
+ if (ext === ".ipa") {
149
+ res.set({ "Content-Type": "application/octet-stream", "Cache-Control": "no-store" });
150
+ } else if (ext === ".apk") {
151
+ res.set({ "Content-Type": "application/vnd.android.package-archive", "Cache-Control": "no-store" });
152
+ } else if (ext === ".png") {
153
+ res.set("Content-Type", "image/png");
154
+ }
155
+ createReadStream(filePath).pipe(res);
156
+ });
157
+
158
+ // 8767, not 8765, and not 8766 either.
159
+ //
160
+ // This server used to claim 8765, which the AIBroker daemon's PAILot MQTT
161
+ // broker binds. The daemon is launchd-managed, so it always won the race: the
162
+ // container could not bind, `ota_publish` POSTed to a port that speaks MQTT and
163
+ // got nothing back, and the Tailscale Serve mappings for /install/ and /api/
164
+ // forwarded to the broker. Nothing surfaced any of it, because there was never
165
+ // a working case to compare against.
166
+ //
167
+ // 8766 is not the fix — that is the daemon's Todoist webhook, and moving here
168
+ // only swaps one silent collision for another (it answers 405 to a GET, which
169
+ // looks enough like a live server to fool a quick check). The daemon owns 8765
170
+ // and 8766; 8767 was confirmed unused before being chosen.
171
+ //
172
+ // PORT stays overridable so the container, compose and a bare
173
+ // `node dist/server.js` cannot drift apart.
174
+ const PORT = Number(process.env.PORT ?? 8767);
175
+ app.listen(PORT, "0.0.0.0", () => {
176
+ console.log(`aibroker-ota listening on :${PORT}`);
177
+ });
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "commonjs",
5
+ "moduleResolution": "node",
6
+ "lib": ["ES2022"],
7
+ "outDir": "./dist",
8
+ "rootDir": "./src",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "resolveJsonModule": true
13
+ },
14
+ "include": ["src/**/*"],
15
+ "exclude": ["node_modules", "dist"]
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aibroker",
3
- "version": "0.31.0",
3
+ "version": "0.31.2",
4
4
  "description": "Platform-agnostic AI message broker — routes between user channels and AI backends",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -10,7 +10,11 @@
10
10
  "templates",
11
11
  "README.md",
12
12
  "LICENSE",
13
- "hooks"
13
+ "hooks",
14
+ "docker",
15
+ "!docker/**/node_modules/**",
16
+ "!docker/**/dist/**",
17
+ "!docker/.env"
14
18
  ],
15
19
  "bin": {
16
20
  "aibroker": "dist/daemon/cli.js",