@tomorrowos/sdk 1.0.1 → 1.1.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomorrowos/sdk",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "Server SDK for building and self-hosting digital signage CMS products with TomorrowOS players.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,3 +17,7 @@
17
17
 
18
18
  # Optional: override the default deterministic pairing-code secret.
19
19
  # PAIRING_CODE_SECRET=
20
+
21
+ # Optional CMS password.
22
+ # Leave unset for open admin (local/dev). Set later via .env or host Secrets, then restart.
23
+ # CMS_PASSWORD=
@@ -7,6 +7,7 @@ run = "npm run start"
7
7
  channel = "stable-24_11"
8
8
 
9
9
  # Expose the CMS HTTP + WebSocket server (TomorrowOS Control Panel at /).
10
+ # Publish readiness: probe GET /status (public). Do not rely on auth redirects from /.
10
11
  [[ports]]
11
12
  localPort = 3000
12
13
  externalPort = 80
@@ -15,3 +16,5 @@ externalPort = 80
15
16
  run = ["sh", "-c", "npm run start"]
16
17
  deploymentTarget = "autoscale"
17
18
  ignorePorts = false
19
+ # Prefer Reserved VM in the Publishing UI for persistent WebSockets.
20
+ # If the UI allows a health/readiness path, set it to /status.
@@ -10,6 +10,18 @@ npm install
10
10
  npm run dev
11
11
  ```
12
12
 
13
+ ### Optional CMS password
14
+
15
+ Set `CMS_PASSWORD` in `.env` (or host Secrets) to lock the admin UI:
16
+
17
+ ```env
18
+ CMS_PASSWORD=your-strong-password
19
+ ```
20
+
21
+ - **Optional:** leave unset → Control Panel stays open (dev mode).
22
+ - **Set later:** add the env var and restart the server.
23
+ - Sign in at `/login` when the password is enabled.
24
+
13
25
  By default, the starter uses SQLite at `data/tomorrowos.db`. The database is
14
26
  created by `tomorrowos init` and the server will also create missing tables on
15
27
  startup.
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Optional Control Panel auth for CMS starters.
3
+ *
4
+ * - CMS_PASSWORD unset → admin UI stays open (dev mode).
5
+ * - CMS_PASSWORD set → browser admin requires login; device paths stay public.
6
+ *
7
+ * Device-safe (always open): WebSocket upgrades (not handled here),
8
+ * GET /brand.json, GET /devices, GET /status, GET/HEAD /uploads/*.
9
+ */
10
+
11
+ import { createHmac, timingSafeEqual } from "crypto";
12
+ import type { IncomingMessage, Server, ServerResponse } from "http";
13
+
14
+ const COOKIE_NAME = "tomorrowos_panel";
15
+ const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
16
+
17
+ export function getCmsPassword(): string {
18
+ return String(process.env.CMS_PASSWORD || "").trim();
19
+ }
20
+
21
+ export function isCmsAuthEnabled(): boolean {
22
+ return getCmsPassword().length > 0;
23
+ }
24
+
25
+ export function isDevicePublicPath(method: string, pathname: string): boolean {
26
+ const m = method.toUpperCase();
27
+ if (m !== "GET" && m !== "HEAD") return false;
28
+ if (pathname === "/brand.json") return true;
29
+ if (pathname === "/devices") return true;
30
+ if (pathname === "/status") return true;
31
+ if (pathname === "/uploads" || pathname.startsWith("/uploads/")) return true;
32
+ return false;
33
+ }
34
+
35
+ function safeEqualString(a: string, b: string): boolean {
36
+ const left = Buffer.from(a, "utf8");
37
+ const right = Buffer.from(b, "utf8");
38
+ if (left.length !== right.length) return false;
39
+ return timingSafeEqual(left, right);
40
+ }
41
+
42
+ function signSession(password: string, exp: number): string {
43
+ const payload = `exp=${exp}`;
44
+ const sig = createHmac("sha256", password).update(payload).digest("base64url");
45
+ return `${payload}.${sig}`;
46
+ }
47
+
48
+ function verifySession(password: string, token: string | undefined): boolean {
49
+ if (!token) return false;
50
+ const parts = token.split(".");
51
+ if (parts.length !== 2) return false;
52
+ const [payload, sig] = parts;
53
+ const expected = createHmac("sha256", password).update(payload).digest("base64url");
54
+ if (!safeEqualString(sig, expected)) return false;
55
+ const expMatch = /^exp=(\d+)$/.exec(payload);
56
+ if (!expMatch) return false;
57
+ const exp = Number(expMatch[1]);
58
+ return Number.isFinite(exp) && Date.now() <= exp;
59
+ }
60
+
61
+ function parseCookies(req: IncomingMessage): Record<string, string> {
62
+ const header = req.headers.cookie;
63
+ if (!header) return {};
64
+ const out: Record<string, string> = {};
65
+ for (const part of header.split(";")) {
66
+ const idx = part.indexOf("=");
67
+ if (idx <= 0) continue;
68
+ const key = part.slice(0, idx).trim();
69
+ const value = part.slice(idx + 1).trim();
70
+ out[key] = decodeURIComponent(value);
71
+ }
72
+ return out;
73
+ }
74
+
75
+ function wantsHtml(req: IncomingMessage): boolean {
76
+ const accept = String(req.headers.accept || "");
77
+ if (accept.includes("application/json") && !accept.includes("text/html")) {
78
+ return false;
79
+ }
80
+ if (accept.includes("text/html")) return true;
81
+ if (!accept || accept.includes("*/*")) return true;
82
+ return false;
83
+ }
84
+
85
+ function isHttpsRequest(req: IncomingMessage): boolean {
86
+ const xf = String(req.headers["x-forwarded-proto"] || "")
87
+ .split(",")[0]
88
+ .trim()
89
+ .toLowerCase();
90
+ if (xf === "https") return true;
91
+ return Boolean((req.socket as { encrypted?: boolean }).encrypted);
92
+ }
93
+
94
+ function sendJson(res: ServerResponse, status: number, body: Record<string, unknown>) {
95
+ const payload = JSON.stringify(body);
96
+ res.writeHead(status, {
97
+ "Content-Type": "application/json; charset=utf-8",
98
+ "Cache-Control": "no-store"
99
+ });
100
+ res.end(payload);
101
+ }
102
+
103
+ function loginPageHtml(error?: string): string {
104
+ const err = error
105
+ ? `<p style="color:#b91c1c;margin:0 0 1rem;font-size:0.95rem">${escapeHtml(error)}</p>`
106
+ : "";
107
+ return `<!DOCTYPE html>
108
+ <html lang="en">
109
+ <head>
110
+ <meta charset="utf-8" />
111
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
112
+ <title>TomorrowOS Control Panel — Login</title>
113
+ <style>
114
+ body { font-family: system-ui, sans-serif; background: #f4f4f5; margin: 0; min-height: 100vh; display: grid; place-items: center; }
115
+ .card { background: #fff; border: 1px solid #e4e4e7; border-radius: 12px; padding: 2rem; width: min(420px, 92vw); box-shadow: 0 8px 24px rgba(0,0,0,.04); }
116
+ h1 { font-size: 1.25rem; margin: 0 0 .35rem; }
117
+ p.hint { color: #71717a; font-size: .9rem; margin: 0 0 1.25rem; line-height: 1.4; }
118
+ label { display: block; font-size: .85rem; font-weight: 600; margin-bottom: .4rem; }
119
+ input[type=password] { width: 100%; box-sizing: border-box; padding: .7rem .8rem; border: 1px solid #d4d4d8; border-radius: 8px; font-size: 1rem; }
120
+ button { margin-top: 1rem; width: 100%; padding: .75rem 1rem; border: 0; border-radius: 8px; background: #111; color: #fff; font-weight: 600; cursor: pointer; }
121
+ button:hover { background: #27272a; }
122
+ </style>
123
+ </head>
124
+ <body>
125
+ <form class="card" method="POST" action="/login" autocomplete="current-password">
126
+ <h1>CMS login</h1>
127
+ <p class="hint">Enter your CMS password to login.</p>
128
+ ${err}
129
+ <label for="password">Password</label>
130
+ <input id="password" name="password" type="password" required autofocus />
131
+ <button type="submit">Sign in</button>
132
+ </form>
133
+ </body>
134
+ </html>`;
135
+ }
136
+
137
+ function escapeHtml(value: string): string {
138
+ return value
139
+ .replace(/&/g, "&amp;")
140
+ .replace(/</g, "&lt;")
141
+ .replace(/>/g, "&gt;")
142
+ .replace(/"/g, "&quot;");
143
+ }
144
+
145
+ async function readBody(req: IncomingMessage): Promise<string> {
146
+ const chunks: Buffer[] = [];
147
+ for await (const chunk of req) {
148
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
149
+ }
150
+ return Buffer.concat(chunks).toString("utf8");
151
+ }
152
+
153
+ function parseFormPassword(raw: string, contentType: string | undefined): string {
154
+ if ((contentType || "").includes("application/json")) {
155
+ try {
156
+ const data = JSON.parse(raw) as { password?: unknown };
157
+ return String(data.password || "");
158
+ } catch {
159
+ return "";
160
+ }
161
+ }
162
+ const params = new URLSearchParams(raw);
163
+ return String(params.get("password") || "");
164
+ }
165
+
166
+ function setSessionCookie(res: ServerResponse, token: string, secure: boolean) {
167
+ const maxAge = Math.floor(SESSION_TTL_MS / 1000);
168
+ const securePart = secure ? "; Secure" : "";
169
+ res.setHeader(
170
+ "Set-Cookie",
171
+ `${COOKIE_NAME}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${securePart}`
172
+ );
173
+ }
174
+
175
+ function clearSessionCookie(res: ServerResponse, secure: boolean) {
176
+ const securePart = secure ? "; Secure" : "";
177
+ res.setHeader(
178
+ "Set-Cookie",
179
+ `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${securePart}`
180
+ );
181
+ }
182
+
183
+ /**
184
+ * Returns true if the request was fully handled (caller must not forward to SDK).
185
+ * Returns false if the request should continue to TomorrowOS HTTP handling.
186
+ */
187
+ export async function handleCmsAuth(
188
+ req: IncomingMessage,
189
+ res: ServerResponse
190
+ ): Promise<boolean> {
191
+ if (!isCmsAuthEnabled()) return false;
192
+
193
+ const password = getCmsPassword();
194
+ const method = String(req.method || "GET").toUpperCase();
195
+ const url = new URL(req.url || "/", "http://localhost");
196
+ let pathname = url.pathname || "/";
197
+ if (pathname.length > 1 && pathname.endsWith("/")) {
198
+ pathname = pathname.slice(0, -1);
199
+ }
200
+
201
+ if (isDevicePublicPath(method, pathname)) return false;
202
+
203
+ if ((method === "GET" || method === "HEAD") && pathname === "/login") {
204
+ res.writeHead(200, {
205
+ "Content-Type": "text/html; charset=utf-8",
206
+ "Cache-Control": "no-store"
207
+ });
208
+ if (method === "HEAD") {
209
+ res.end();
210
+ } else {
211
+ res.end(loginPageHtml());
212
+ }
213
+ return true;
214
+ }
215
+
216
+ if (method === "POST" && pathname === "/logout") {
217
+ clearSessionCookie(res, isHttpsRequest(req));
218
+ res.writeHead(303, { Location: "/login" });
219
+ res.end();
220
+ return true;
221
+ }
222
+
223
+ if (method === "POST" && pathname === "/login") {
224
+ const raw = await readBody(req);
225
+ const submitted = parseFormPassword(raw, req.headers["content-type"]);
226
+ if (!safeEqualString(submitted, password)) {
227
+ res.writeHead(401, {
228
+ "Content-Type": "text/html; charset=utf-8",
229
+ "Cache-Control": "no-store"
230
+ });
231
+ res.end(loginPageHtml("Incorrect password."));
232
+ return true;
233
+ }
234
+ const token = signSession(password, Date.now() + SESSION_TTL_MS);
235
+ setSessionCookie(res, token, isHttpsRequest(req));
236
+ const acceptJson =
237
+ String(req.headers.accept || "").includes("application/json") ||
238
+ String(req.headers["content-type"] || "").includes("application/json");
239
+ if (acceptJson) {
240
+ sendJson(res, 200, { status: "success" });
241
+ return true;
242
+ }
243
+ res.writeHead(303, { Location: "/" });
244
+ res.end();
245
+ return true;
246
+ }
247
+
248
+ const cookies = parseCookies(req);
249
+ if (verifySession(password, cookies[COOKIE_NAME])) return false;
250
+
251
+ // Serve login with HTTP 200 (no 303). Replit Publish readiness probes `/` and
252
+ // fails if it follows a redirect chain to /login.
253
+ if (wantsHtml(req) && (method === "GET" || method === "HEAD")) {
254
+ res.writeHead(200, {
255
+ "Content-Type": "text/html; charset=utf-8",
256
+ "Cache-Control": "no-store"
257
+ });
258
+ if (method === "HEAD") {
259
+ res.end();
260
+ } else {
261
+ res.end(loginPageHtml());
262
+ }
263
+ return true;
264
+ }
265
+
266
+ sendJson(res, 401, {
267
+ status: "failed",
268
+ error:
269
+ "CMS authentication required. Sign in at /login or set cookie after POST /login."
270
+ });
271
+ return true;
272
+ }
273
+
274
+ /** Wrap an http.Server created by TomorrowOS.listen so panel routes can require CMS_PASSWORD. */
275
+ export function attachCmsAuth(server: Server): void {
276
+ const sdkRequestListeners = server.listeners("request").slice() as Array<
277
+ (req: IncomingMessage, res: ServerResponse) => void
278
+ >;
279
+ server.removeAllListeners("request");
280
+ server.on("request", (req, res) => {
281
+ void (async () => {
282
+ try {
283
+ if (await handleCmsAuth(req, res)) return;
284
+ for (const listener of sdkRequestListeners) {
285
+ listener.call(server, req, res);
286
+ }
287
+ } catch (err) {
288
+ console.error("[TomorrowOS] cms auth / request failed:", err);
289
+ if (!res.headersSent) {
290
+ res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
291
+ res.end(JSON.stringify({ status: "failed", error: "Internal server error" }));
292
+ }
293
+ }
294
+ })();
295
+ });
296
+
297
+ if (isCmsAuthEnabled()) {
298
+ console.log("[TomorrowOS] CMS_PASSWORD is set — Control Panel requires /login");
299
+ console.log(
300
+ "[TomorrowOS] Device paths remain open: WebSocket, /brand.json, /devices, /uploads/*"
301
+ );
302
+ } else {
303
+ console.log("[TomorrowOS] CMS_PASSWORD unset — Control Panel is open (dev mode)");
304
+ }
305
+ }