@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/REPLIT_SETUP.md +265 -196
- package/VERCEL_QUESTIONS.md +118 -90
- package/VERCEL_SETUP.md +1087 -1056
- package/package.json +1 -1
- package/templates/cms-starter/.env.example +4 -0
- package/templates/cms-starter/.replit +3 -0
- package/templates/cms-starter/README.md +12 -0
- package/templates/cms-starter/cms-auth.ts +305 -0
- package/templates/cms-starter/package-lock.json +2462 -605
- package/templates/cms-starter/package.json +2 -2
- package/templates/cms-starter/public/assets/player-download/windows.svg +1 -0
- package/templates/cms-starter/public/index.html +10 -0
- package/templates/cms-starter/public/panel.css +4 -4
- package/templates/cms-starter/server.ts +17 -2
- package/templates/cms-starter/tsconfig.json +1 -1
- package/templates/cms-starter-v0/.env.example +4 -0
- package/templates/cms-starter-v0/README.md +4 -0
- package/templates/cms-starter-v0/cms-app.ts +3 -0
- package/templates/cms-starter-v0/cms-auth.ts +305 -0
- package/templates/cms-starter-v0/cms-panel/assets/player-download/windows.svg +1 -0
- package/templates/cms-starter-v0/cms-panel/index.html +10 -0
- package/templates/cms-starter-v0/cms-panel/panel.css +4 -4
- package/templates/cms-starter-v0/package-lock.json +3772 -0
- package/templates/cms-starter-v0/package.json +2 -2
- package/templates/cms-starter-v0/tsconfig.json +1 -1
package/package.json
CHANGED
|
@@ -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, "&")
|
|
140
|
+
.replace(/</g, "<")
|
|
141
|
+
.replace(/>/g, ">")
|
|
142
|
+
.replace(/"/g, """);
|
|
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
|
+
}
|