@tomorrowos/sdk 1.0.1 → 1.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,292 @@
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. Players do not use this password.</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" && pathname === "/login") {
204
+ res.writeHead(200, {
205
+ "Content-Type": "text/html; charset=utf-8",
206
+ "Cache-Control": "no-store"
207
+ });
208
+ res.end(loginPageHtml());
209
+ return true;
210
+ }
211
+
212
+ if (method === "POST" && pathname === "/logout") {
213
+ clearSessionCookie(res, isHttpsRequest(req));
214
+ res.writeHead(303, { Location: "/login" });
215
+ res.end();
216
+ return true;
217
+ }
218
+
219
+ if (method === "POST" && pathname === "/login") {
220
+ const raw = await readBody(req);
221
+ const submitted = parseFormPassword(raw, req.headers["content-type"]);
222
+ if (!safeEqualString(submitted, password)) {
223
+ res.writeHead(401, {
224
+ "Content-Type": "text/html; charset=utf-8",
225
+ "Cache-Control": "no-store"
226
+ });
227
+ res.end(loginPageHtml("Incorrect password."));
228
+ return true;
229
+ }
230
+ const token = signSession(password, Date.now() + SESSION_TTL_MS);
231
+ setSessionCookie(res, token, isHttpsRequest(req));
232
+ const acceptJson =
233
+ String(req.headers.accept || "").includes("application/json") ||
234
+ String(req.headers["content-type"] || "").includes("application/json");
235
+ if (acceptJson) {
236
+ sendJson(res, 200, { status: "success" });
237
+ return true;
238
+ }
239
+ res.writeHead(303, { Location: "/" });
240
+ res.end();
241
+ return true;
242
+ }
243
+
244
+ const cookies = parseCookies(req);
245
+ if (verifySession(password, cookies[COOKIE_NAME])) return false;
246
+
247
+ if (wantsHtml(req) && method === "GET") {
248
+ res.writeHead(303, { Location: "/login" });
249
+ res.end();
250
+ return true;
251
+ }
252
+
253
+ sendJson(res, 401, {
254
+ status: "failed",
255
+ error:
256
+ "Control Panel authentication required. Sign in at /login or set cookie after POST /login."
257
+ });
258
+ return true;
259
+ }
260
+
261
+ /** Wrap an http.Server created by TomorrowOS.listen so panel routes can require CMS_PASSWORD. */
262
+ export function attachCmsAuth(server: Server): void {
263
+ const sdkRequestListeners = server.listeners("request").slice() as Array<
264
+ (req: IncomingMessage, res: ServerResponse) => void
265
+ >;
266
+ server.removeAllListeners("request");
267
+ server.on("request", (req, res) => {
268
+ void (async () => {
269
+ try {
270
+ if (await handleCmsAuth(req, res)) return;
271
+ for (const listener of sdkRequestListeners) {
272
+ listener.call(server, req, res);
273
+ }
274
+ } catch (err) {
275
+ console.error("[TomorrowOS] cms auth / request failed:", err);
276
+ if (!res.headersSent) {
277
+ res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
278
+ res.end(JSON.stringify({ status: "failed", error: "Internal server error" }));
279
+ }
280
+ }
281
+ })();
282
+ });
283
+
284
+ if (isCmsAuthEnabled()) {
285
+ console.log("[TomorrowOS] CMS_PASSWORD is set — Control Panel requires /login");
286
+ console.log(
287
+ "[TomorrowOS] Device paths remain open: WebSocket, /brand.json, /devices, /uploads/*"
288
+ );
289
+ } else {
290
+ console.log("[TomorrowOS] CMS_PASSWORD unset — Control Panel is open (dev mode)");
291
+ }
292
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" height="110" width="480" fill="#0078d6" viewBox="0 0 480 110"><path d="M217.562 22.397l-17.55 62.132h-8.533l-12.783-45.407c-.547-1.935-.882-4.044-.996-6.326h-.172a32.143 32.143 0 01-1.126 6.24l-12.867 45.493h-8.45l-18.198-62.132h8.014l13.216 47.66a31.354 31.354 0 011.04 6.24h.218c.144-1.532.588-3.61 1.341-6.24l13.736-47.66h6.975l13.17 48.008c.462 1.645.807 3.582 1.04 5.805h.172c.115-1.5.505-3.493 1.172-5.978L209.675 22.4h7.887v-.003zm9.786 6.498c-1.272 0-2.355-.434-3.25-1.301s-1.341-1.962-1.341-3.293c0-1.326.449-2.434 1.341-3.317.898-.88 1.978-1.32 3.25-1.32 1.3 0 2.404.443 3.316 1.32.908.883 1.364 1.988 1.364 3.317 0 1.272-.456 2.355-1.364 3.25-.912.897-2.015 1.344-3.316 1.344zm3.466 55.634h-7.106V40.16h7.106zm47.754 0h-7.106V59.227c0-9.416-3.438-14.124-10.31-14.124-3.553 0-6.494 1.336-8.819 4.008-2.327 2.672-3.488 6.044-3.488 10.116v25.302h-7.107V40.16h7.107v7.365h.172c3.35-5.603 8.202-8.404 14.558-8.404 4.852 0 8.564 1.567 11.135 4.7 2.573 3.134 3.856 7.662 3.856 13.582v27.125h.003zm50.549 0h-7.107v-7.54h-.172c-3.293 5.72-8.38 8.58-15.252 8.58-5.575 0-10.03-1.986-13.366-5.959-3.334-3.97-5.005-9.38-5.005-16.226 0-7.337 1.848-13.216 5.547-17.632 3.697-4.422 8.62-6.63 14.776-6.63 6.092 0 10.526 2.398 13.3 7.192h.172v-27.47h7.107zm-7.104-20.06v-6.543c0-3.58-1.187-6.612-3.552-9.1-2.37-2.481-5.372-3.726-9.013-3.726-4.333 0-7.743 1.59-10.225 4.766-2.484 3.18-3.727 7.568-3.727 13.171 0 5.114 1.192 9.15 3.575 12.109 2.38 2.96 5.58 4.441 9.596 4.441 3.958 0 7.17-1.427 9.642-4.29 2.467-2.857 3.704-6.47 3.704-10.827zm38.166 21.099c-6.559 0-11.792-2.071-15.706-6.216-3.915-4.146-5.87-9.642-5.87-16.487 0-7.452 2.037-13.273 6.11-17.46 4.07-4.19 9.574-6.286 16.508-6.286 6.612 0 11.779 2.036 15.488 6.11 3.712 4.07 5.567 9.72 5.567 16.941 0 7.078-2 12.745-6 17.007-4.001 4.261-9.366 6.391-16.097 6.391zm.52-40.468c-4.566 0-8.177 1.552-10.831 4.657-2.66 3.109-3.986 7.388-3.986 12.847 0 5.258 1.342 9.4 4.032 12.435 2.684 3.03 6.282 4.548 10.787 4.548 4.594 0 8.123-1.488 10.595-4.464 2.47-2.974 3.704-7.206 3.704-12.694 0-5.548-1.235-9.82-3.704-12.826-2.472-3-6.003-4.503-10.597-4.503zm85.611-4.939l-13.3 44.368h-7.368l-9.14-31.76c-.347-1.214-.58-2.583-.692-4.114h-.172c-.086 1.039-.39 2.38-.91 4.03l-9.92 31.846H397.7l-13.432-44.367h7.451l9.183 33.359c.287 1.012.49 2.34.606 3.985h.347c.087-1.27.348-2.626.781-4.071l10.225-33.276h6.498l9.185 33.448c.287 1.07.505 2.399.652 3.986h.347c.056-1.126.302-2.454.735-3.986l9.013-33.448zm3.293 42.763v-7.626c3.87 2.862 8.13 4.29 12.78 4.29 6.24 0 9.358-2.08 9.358-6.24 0-1.184-.266-2.188-.798-3.012-.533-.824-1.258-1.551-2.166-2.188-.91-.634-1.98-1.207-3.204-1.711a231.663 231.663 0 00-3.965-1.582c-1.965-.781-3.692-1.567-5.177-2.363-1.488-.794-2.728-1.689-3.727-2.687a10.01 10.01 0 01-2.254-3.403c-.505-1.27-.758-2.756-.758-4.462 0-2.079.477-3.922 1.427-5.524.956-1.605 2.226-2.946 3.813-4.031 1.59-1.083 3.403-1.9 5.441-2.45a24.143 24.143 0 016.3-.82c3.841 0 7.279.666 10.314 1.992v7.192c-3.268-2.137-7.023-3.207-11.267-3.207-1.329 0-2.525.152-3.595.457-1.07.301-1.985.73-2.75 1.277-.766.55-1.357 1.207-1.776 1.97a5.185 5.185 0 00-.628 2.536c0 1.156.208 2.122.628 2.905.419.778 1.032 1.47 1.841 2.079.811.603 1.793 1.154 2.946 1.643 1.159.492 2.472 1.027 3.948 1.605 1.96.753 3.724 1.526 5.283 2.317 1.56.794 2.888 1.691 3.985 2.687a10.576 10.576 0 012.536 3.443c.59 1.303.887 2.847.887 4.637 0 2.196-.48 4.102-1.45 5.72a12.403 12.403 0 01-3.877 4.028c-1.62 1.07-3.48 1.864-5.59 2.384-2.104.52-4.315.78-6.627.78-4.571.008-8.529-.874-11.878-2.636zM105.79 0L46.907 8.592v42.669h58.881zM0 15.432v35.829h42.757V9.195zm0 76.261l42.757 6.308V55.408H0zM105.79 107.3V55.408H46.907v43.207z"/></svg>
@@ -270,6 +270,16 @@
270
270
  </a>
271
271
  <p class="player-platform-caption">Supported Tizen 6.5 and 7.0</p>
272
272
  </div>
273
+ <div class="player-platform-cell">
274
+ <a
275
+ href="https://tmr.sh/app/windows/TomorrowOS-Windows-Setup.exe"
276
+ class="player-platform-link"
277
+ rel="noopener noreferrer"
278
+ >
279
+ <img src="./assets/player-download/windows.svg" alt="Windows" class="player-platform-logo player-platform-logo--wide" />
280
+ </a>
281
+ <p class="player-platform-caption">Supported Windows 11</p>
282
+ </div>
273
283
  <div class="player-platform-cell">
274
284
  <img src="./assets/player-download/android.svg" alt="Android" class="player-platform-logo player-platform-logo--wide" />
275
285
  <p class="player-platform-caption player-platform-caption--muted">Planned</p>
@@ -1068,7 +1068,7 @@ button.danger {
1068
1068
 
1069
1069
  .player-platform-grid {
1070
1070
  display: grid;
1071
- padding:1.5rem;
1071
+ padding: 1.5rem 1.5rem 0.5rem;
1072
1072
 
1073
1073
  grid-template-columns: 1fr 1fr;
1074
1074
  gap: 1.75rem 2rem;
@@ -1080,7 +1080,7 @@ button.danger {
1080
1080
  flex-direction: column;
1081
1081
  align-items: center;
1082
1082
  gap: 0.45rem;
1083
- min-height: 5.5rem;
1083
+ min-height: 4.25rem;
1084
1084
  }
1085
1085
 
1086
1086
  .player-platform-link {
@@ -1124,8 +1124,8 @@ button.danger {
1124
1124
  }
1125
1125
 
1126
1126
  .player-platform-diy {
1127
- margin-top: 1.35rem;
1128
- padding-top: 1.35rem;
1127
+ margin-top: 0.55rem;
1128
+ padding-top: 1.15rem;
1129
1129
  border-top: 1px solid #e4e1dc;
1130
1130
  display: flex;
1131
1131
  flex-direction: column;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "my-cms",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "TomorrowOS CMS for Vercel / v0 Publish (Fluid Function + WebSockets + optional Next Preview).",
5
5
  "private": true,
6
6
  "type": "module",
@@ -16,7 +16,7 @@
16
16
  "build": "node -e \"process.exit(0)\""
17
17
  },
18
18
  "dependencies": {
19
- "@tomorrowos/sdk": "^1.0.1",
19
+ "@tomorrowos/sdk": "^1.1.0",
20
20
  "dotenv": "^17.2.3",
21
21
  "tsx": "^4.19.0"
22
22
  },