deepspace 0.3.10 → 0.4.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.
- package/dist/cli.js +331 -115
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +199 -16
- package/dist/index.js +354 -166
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +242 -73
- package/dist/server.js +985 -374
- package/dist/server.js.map +1 -1
- package/dist/worker.d.ts +322 -73
- package/dist/worker.js +961 -343
- package/dist/worker.js.map +1 -1
- package/features/admin-page/feature.json +29 -0
- package/features/admin-page/src/AdminPage.tsx +292 -0
- package/features/admin-page/src/admin-schema.ts +20 -0
- package/features/ai-chat/feature.json +41 -0
- package/features/ai-chat/src/AiChatPage.tsx +692 -0
- package/features/ai-chat/src/ChatPanel.tsx +1098 -0
- package/features/ai-chat/src/ai-chat-schema.ts +11 -0
- package/features/canvas/feature.json +45 -0
- package/features/canvas/src/CanvasEditorPage.tsx +87 -0
- package/features/canvas/src/CanvasListPage.tsx +202 -0
- package/features/canvas/src/canvas-schema.ts +33 -0
- package/features/canvas/src/components/CanvasToolbar.tsx +170 -0
- package/features/canvas/src/components/CanvasView.tsx +369 -0
- package/features/canvas/src/components/ShapeRenderer.tsx +114 -0
- package/features/cron/feature.json +33 -0
- package/features/cron/src/CronLogPage.tsx +100 -0
- package/features/cron/src/cron.ts +25 -0
- package/features/docs/feature.json +96 -0
- package/features/docs/src/DocsEditorPage.tsx +1012 -0
- package/features/docs/src/DocsListPage.tsx +951 -0
- package/features/docs/src/DocsOutlinePanel.tsx +68 -0
- package/features/docs/src/DocsPresence.tsx +138 -0
- package/features/docs/src/InviteDialog.tsx +336 -0
- package/features/docs/src/LibrarySidebar.tsx +464 -0
- package/features/docs/src/docs-favorites.ts +18 -0
- package/features/docs/src/docs-library-types.ts +15 -0
- package/features/docs/src/docs-schema.ts +80 -0
- package/features/docs/src/docs-ui.css +309 -0
- package/features/docs/src/editor/DocEditorSurface.tsx +257 -0
- package/features/docs/src/editor/DocsTiptapToolbar.tsx +560 -0
- package/features/docs/src/editor/useDocEditor.ts +206 -0
- package/features/docs/src/use-yjs-room-with-awareness.ts +285 -0
- package/features/file-attachments/feature.json +42 -0
- package/features/file-attachments/src/AttachmentsPage.tsx +864 -0
- package/features/file-attachments/src/attachments-constants.ts +9 -0
- package/features/file-attachments/src/attachments-schema.ts +45 -0
- package/features/file-attachments/src/useFilePreview.ts +471 -0
- package/features/file-attachments/src/useMimeTypeDetection.ts +138 -0
- package/features/file-manager/feature.json +24 -0
- package/features/file-manager/src/FileManagerPage.tsx +330 -0
- package/features/integration-test/feature.json +18 -0
- package/features/integration-test/src/IntegrationTestPage.tsx +423 -0
- package/features/integration-test/src/integration.spec.ts +42 -0
- package/features/items/feature.json +33 -0
- package/features/items/src/ItemsPage.tsx +325 -0
- package/features/items/src/items-constants.ts +10 -0
- package/features/items/src/items-schema.ts +27 -0
- package/features/kanban/feature.json +18 -0
- package/features/kanban/src/Kanban.tsx +283 -0
- package/features/landing/feature.json +62 -0
- package/features/landing/src/LandingPage.tsx +627 -0
- package/features/landing/src/primitives.tsx +389 -0
- package/features/landing/src/sections/FAQSection.tsx +110 -0
- package/features/landing/src/sections/FeaturesGridSection.tsx +311 -0
- package/features/landing/src/sections/HowItWorksSection.tsx +62 -0
- package/features/landing/src/sections/LogoCloudSection.tsx +58 -0
- package/features/landing/src/sections/ShowcaseSection.tsx +63 -0
- package/features/landing/src/sections/StatsSection.tsx +31 -0
- package/features/landing/src/sections/TeamSection.tsx +86 -0
- package/features/landing/src/sections/TestimonialsSection.tsx +68 -0
- package/features/landing/src/sections/VideoSection.tsx +71 -0
- package/features/leaderboard/feature.json +33 -0
- package/features/leaderboard/src/LeaderboardPage.tsx +372 -0
- package/features/leaderboard/src/leaderboard-constants.ts +21 -0
- package/features/leaderboard/src/leaderboard-schema.ts +36 -0
- package/features/messaging/feature.json +111 -0
- package/features/messaging/src/ChatMultiPage.tsx +300 -0
- package/features/messaging/src/ChatPage.tsx +75 -0
- package/features/messaging/src/components/chat/ChatHeader.tsx +76 -0
- package/features/messaging/src/components/chat/MessageActionSheet.tsx +168 -0
- package/features/messaging/src/components/chat/MessageInput.tsx +65 -0
- package/features/messaging/src/components/chat/MessageItem.tsx +352 -0
- package/features/messaging/src/components/chat/MessageList.tsx +248 -0
- package/features/messaging/src/components/chat/ReactionPicker.tsx +2 -0
- package/features/messaging/src/components/chat/ThreadPanel.tsx +107 -0
- package/features/messaging/src/components/chat/UserProfilePopover.tsx +158 -0
- package/features/messaging/src/components/chat-multi/AddMemberModal.tsx +188 -0
- package/features/messaging/src/components/chat-multi/BrowseGroupsModal.tsx +163 -0
- package/features/messaging/src/components/chat-multi/ChannelListItem.tsx +147 -0
- package/features/messaging/src/components/chat-multi/ChannelSettingsPanel.tsx +344 -0
- package/features/messaging/src/components/chat-multi/ChannelSidebar.tsx +460 -0
- package/features/messaging/src/components/chat-multi/CreateGroupModal.tsx +190 -0
- package/features/messaging/src/components/chat-multi/InvitationList.tsx +149 -0
- package/features/messaging/src/components/chat-multi/NewMessageModal.tsx +159 -0
- package/features/messaging/src/hooks/useChatChannel.ts +64 -0
- package/features/messaging/src/hooks/useLongPress.ts +74 -0
- package/features/messaging/src/hooks/useMultiChannel.ts +142 -0
- package/features/messaging/src/messaging-schema.ts +25 -0
- package/features/presence-test/feature.json +21 -0
- package/features/presence-test/src/PresenceTestPage.tsx +120 -0
- package/features/sidebar/feature.json +19 -0
- package/features/sidebar/src/AppSidebar.tsx +558 -0
- package/features/tasks/feature.json +33 -0
- package/features/tasks/src/TasksPage.tsx +598 -0
- package/features/tasks/src/tasks-constants.ts +43 -0
- package/features/tasks/src/tasks-schema.ts +56 -0
- package/features/testing/feature.json +49 -0
- package/features/testing/src/TestPage.tsx +145 -0
- package/features/testing/src/cpu-burn-action.ts +51 -0
- package/features/testing/src/subrequest-burn-action.ts +53 -0
- package/features/testing/src/test-schema.ts +23 -0
- package/features/topbar/feature.json +18 -0
- package/features/topbar/src/Topbar.tsx +353 -0
- package/features/tree/feature.json +26 -0
- package/features/tree/src/TreeSidebar.tsx +607 -0
- package/features/tree/src/tree-constants.ts +68 -0
- package/features/tree/src/tree-hooks.ts +258 -0
- package/package.json +4 -2
- package/scripts/add-feature.cjs +654 -0
package/dist/server.js
CHANGED
|
@@ -165,6 +165,11 @@ var BUILT_IN_TOOLS = [
|
|
|
165
165
|
|
|
166
166
|
// src/server/utils/scoped-r2-files.ts
|
|
167
167
|
var CORS_HEADERS = { "Access-Control-Allow-Origin": "*" };
|
|
168
|
+
function contentDisposition(originalName) {
|
|
169
|
+
const safe = originalName.replace(/[\x00-\x1f\x7f"\\]/g, "_") || "download";
|
|
170
|
+
const encoded = encodeURIComponent(originalName);
|
|
171
|
+
return `inline; filename="${safe}"; filename*=UTF-8''${encoded}`;
|
|
172
|
+
}
|
|
168
173
|
function sanitizeSubpath(raw) {
|
|
169
174
|
if (!raw) return "";
|
|
170
175
|
const segments = raw.split("/");
|
|
@@ -174,6 +179,20 @@ function sanitizeSubpath(raw) {
|
|
|
174
179
|
function isKeyWithinPrefix(key, prefix) {
|
|
175
180
|
return key.startsWith(prefix);
|
|
176
181
|
}
|
|
182
|
+
var DANGEROUS_MIME_TYPES = /* @__PURE__ */ new Set([
|
|
183
|
+
"text/html",
|
|
184
|
+
"application/xhtml+xml",
|
|
185
|
+
"image/svg+xml",
|
|
186
|
+
"application/xml",
|
|
187
|
+
"text/xml",
|
|
188
|
+
"application/javascript",
|
|
189
|
+
"text/javascript",
|
|
190
|
+
"application/ecmascript",
|
|
191
|
+
"text/ecmascript"
|
|
192
|
+
]);
|
|
193
|
+
function normalizeMimeType(raw) {
|
|
194
|
+
return raw.split(";")[0].trim().toLowerCase();
|
|
195
|
+
}
|
|
177
196
|
function createScopedR2Handler(config) {
|
|
178
197
|
const requireAuthForMutations = config.requireAuthForMutations ?? true;
|
|
179
198
|
return async (request, url, bucket, auth) => {
|
|
@@ -263,6 +282,13 @@ async function handleUpload(request, url, bucket, prefix, userId) {
|
|
|
263
282
|
fileName = body.name;
|
|
264
283
|
mimeType = body.mimeType || "application/octet-stream";
|
|
265
284
|
}
|
|
285
|
+
mimeType = normalizeMimeType(mimeType) || "application/octet-stream";
|
|
286
|
+
if (DANGEROUS_MIME_TYPES.has(mimeType)) {
|
|
287
|
+
return Response.json(
|
|
288
|
+
{ error: `Unsupported media type: ${mimeType}` },
|
|
289
|
+
{ status: 415, headers: CORS_HEADERS }
|
|
290
|
+
);
|
|
291
|
+
}
|
|
266
292
|
const requestedKey = url.searchParams.get("key");
|
|
267
293
|
let key;
|
|
268
294
|
if (requestedKey !== null) {
|
|
@@ -327,342 +353,15 @@ async function handleDownload(bucket, key) {
|
|
|
327
353
|
}
|
|
328
354
|
const headers = new Headers();
|
|
329
355
|
headers.set("Content-Type", object.httpMetadata?.contentType || "application/octet-stream");
|
|
356
|
+
headers.set("X-Content-Type-Options", "nosniff");
|
|
330
357
|
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
|
331
358
|
headers.set("Access-Control-Allow-Origin", "*");
|
|
332
359
|
if (object.customMetadata?.originalName) {
|
|
333
|
-
headers.set("Content-Disposition",
|
|
360
|
+
headers.set("Content-Disposition", contentDisposition(object.customMetadata.originalName));
|
|
334
361
|
}
|
|
335
362
|
return new Response(object.body, { headers });
|
|
336
363
|
}
|
|
337
364
|
|
|
338
|
-
// src/server/auth/jwtVerifier.ts
|
|
339
|
-
import { importSPKI, jwtVerify } from "jose";
|
|
340
|
-
|
|
341
|
-
// src/server/auth/utils.ts
|
|
342
|
-
var BASE64_URL_REGEX = /^[A-Za-z0-9-_]+$/;
|
|
343
|
-
function bufferToHex(bytes) {
|
|
344
|
-
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
345
|
-
let hex = "";
|
|
346
|
-
for (const byte of view) {
|
|
347
|
-
hex += byte.toString(16).padStart(2, "0");
|
|
348
|
-
}
|
|
349
|
-
return hex;
|
|
350
|
-
}
|
|
351
|
-
function normalizeArray(value) {
|
|
352
|
-
if (value == null) return void 0;
|
|
353
|
-
if (Array.isArray(value)) {
|
|
354
|
-
return value.length ? value : void 0;
|
|
355
|
-
}
|
|
356
|
-
return [value];
|
|
357
|
-
}
|
|
358
|
-
function decodeJwtPayload(token) {
|
|
359
|
-
if (!token) return void 0;
|
|
360
|
-
const parts = token.split(".");
|
|
361
|
-
if (parts.length < 2) return void 0;
|
|
362
|
-
const payloadSegment = parts[1];
|
|
363
|
-
if (!payloadSegment) return void 0;
|
|
364
|
-
if (!BASE64_URL_REGEX.test(payloadSegment)) return void 0;
|
|
365
|
-
try {
|
|
366
|
-
const normalized = payloadSegment.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(payloadSegment.length / 4) * 4, "=");
|
|
367
|
-
const bytes = base64ToUint8Array(normalized);
|
|
368
|
-
const json = new TextDecoder().decode(bytes);
|
|
369
|
-
const payload = JSON.parse(json);
|
|
370
|
-
return {
|
|
371
|
-
iss: payload.iss ?? null,
|
|
372
|
-
aud: payload.aud ?? null,
|
|
373
|
-
azp: payload?.azp ?? null,
|
|
374
|
-
exp: payload.exp ?? null,
|
|
375
|
-
iat: payload.iat ?? null
|
|
376
|
-
};
|
|
377
|
-
} catch {
|
|
378
|
-
return void 0;
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
function nowMs() {
|
|
382
|
-
return Date.now();
|
|
383
|
-
}
|
|
384
|
-
function base64ToUint8Array(value) {
|
|
385
|
-
if (typeof atob === "function") {
|
|
386
|
-
const binary = atob(value);
|
|
387
|
-
const out = new Uint8Array(binary.length);
|
|
388
|
-
for (let i = 0; i < binary.length; i += 1) {
|
|
389
|
-
out[i] = binary.charCodeAt(i);
|
|
390
|
-
}
|
|
391
|
-
return out;
|
|
392
|
-
}
|
|
393
|
-
if (typeof Buffer !== "undefined") {
|
|
394
|
-
return Uint8Array.from(Buffer.from(value, "base64"));
|
|
395
|
-
}
|
|
396
|
-
throw new Error("No base64 decoder available in current environment");
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// src/server/auth/jwtVerifier.ts
|
|
400
|
-
var DEFAULT_CLOCK_SKEW_MS = 5e3;
|
|
401
|
-
function matchesAuthorizedParty(azp, patterns) {
|
|
402
|
-
if (!azp) return true;
|
|
403
|
-
for (const pattern of patterns) {
|
|
404
|
-
if (pattern === azp) return true;
|
|
405
|
-
if (pattern.includes("*")) {
|
|
406
|
-
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
407
|
-
const regexPattern = escaped.replace(/\*/g, "[^/]*");
|
|
408
|
-
const regex = new RegExp(`^${regexPattern}$`);
|
|
409
|
-
if (regex.test(azp)) return true;
|
|
410
|
-
if (pattern.includes("*.")) {
|
|
411
|
-
const rootPattern = pattern.replace("*.", "");
|
|
412
|
-
if (rootPattern === azp) return true;
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
return false;
|
|
417
|
-
}
|
|
418
|
-
var keyCache = /* @__PURE__ */ new Map();
|
|
419
|
-
async function getPublicKey(pem) {
|
|
420
|
-
const cached = keyCache.get(pem);
|
|
421
|
-
if (cached) return cached;
|
|
422
|
-
const normalized = pem.replace(/\\n/g, "\n");
|
|
423
|
-
const key = await importSPKI(normalized, "ES256");
|
|
424
|
-
keyCache.set(pem, key);
|
|
425
|
-
return key;
|
|
426
|
-
}
|
|
427
|
-
async function verifyJwt(config, token) {
|
|
428
|
-
if (!token) {
|
|
429
|
-
return { result: null };
|
|
430
|
-
}
|
|
431
|
-
try {
|
|
432
|
-
const publicKey = await getPublicKey(config.publicKey);
|
|
433
|
-
const audience = normalizeArray(config.audience);
|
|
434
|
-
const clockTolerance = Math.floor(
|
|
435
|
-
(config.clockSkewMs ?? DEFAULT_CLOCK_SKEW_MS) / 1e3
|
|
436
|
-
);
|
|
437
|
-
const { payload } = await jwtVerify(token, publicKey, {
|
|
438
|
-
issuer: config.issuer,
|
|
439
|
-
audience: audience && audience.length === 1 ? audience[0] : audience,
|
|
440
|
-
clockTolerance
|
|
441
|
-
});
|
|
442
|
-
const claims = payload;
|
|
443
|
-
if (!claims.sub) {
|
|
444
|
-
throw new Error("JWT verification succeeded but subject claim is missing");
|
|
445
|
-
}
|
|
446
|
-
const authorizedParties = normalizeArray(config.authorizedParties);
|
|
447
|
-
if (authorizedParties) {
|
|
448
|
-
const azp = claims.azp;
|
|
449
|
-
if (!matchesAuthorizedParty(azp, authorizedParties)) {
|
|
450
|
-
throw new Error(
|
|
451
|
-
`Authorized party '${azp}' does not match any allowed pattern`
|
|
452
|
-
);
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
return {
|
|
456
|
-
result: {
|
|
457
|
-
userId: claims.sub,
|
|
458
|
-
claims
|
|
459
|
-
}
|
|
460
|
-
};
|
|
461
|
-
} catch (error) {
|
|
462
|
-
return {
|
|
463
|
-
result: null,
|
|
464
|
-
error,
|
|
465
|
-
debug: decodeJwtPayload(token)
|
|
466
|
-
};
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// src/server/auth/internalAuth.ts
|
|
471
|
-
var DEFAULT_MAX_SKEW_MS = 5 * 6e4;
|
|
472
|
-
var nodeCryptoPromise = null;
|
|
473
|
-
async function loadNodeCrypto() {
|
|
474
|
-
if (!nodeCryptoPromise) {
|
|
475
|
-
nodeCryptoPromise = import("crypto").then((module) => module).catch(() => null);
|
|
476
|
-
}
|
|
477
|
-
return nodeCryptoPromise;
|
|
478
|
-
}
|
|
479
|
-
function hasSubtleCrypto() {
|
|
480
|
-
return typeof globalThis.crypto?.subtle?.importKey === "function";
|
|
481
|
-
}
|
|
482
|
-
async function computeHmacHex(secret, payload) {
|
|
483
|
-
if (hasSubtleCrypto()) {
|
|
484
|
-
const encoder = new TextEncoder();
|
|
485
|
-
const key = await crypto.subtle.importKey(
|
|
486
|
-
"raw",
|
|
487
|
-
encoder.encode(secret),
|
|
488
|
-
{ name: "HMAC", hash: "SHA-256" },
|
|
489
|
-
false,
|
|
490
|
-
["sign"]
|
|
491
|
-
);
|
|
492
|
-
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
|
|
493
|
-
return bufferToHex(signature);
|
|
494
|
-
}
|
|
495
|
-
const nodeCrypto = await loadNodeCrypto();
|
|
496
|
-
if (!nodeCrypto) {
|
|
497
|
-
throw new Error("Crypto implementation unavailable for HMAC computation");
|
|
498
|
-
}
|
|
499
|
-
return nodeCrypto.createHmac("sha256", secret).update(payload).digest("hex");
|
|
500
|
-
}
|
|
501
|
-
async function timingSafeEqualHex(a, b) {
|
|
502
|
-
if (a.length !== b.length) {
|
|
503
|
-
return false;
|
|
504
|
-
}
|
|
505
|
-
const nodeCrypto = await loadNodeCrypto();
|
|
506
|
-
if (nodeCrypto?.timingSafeEqual) {
|
|
507
|
-
const aBuf = Buffer.from(a, "hex");
|
|
508
|
-
const bBuf = Buffer.from(b, "hex");
|
|
509
|
-
try {
|
|
510
|
-
return nodeCrypto.timingSafeEqual(aBuf, bBuf);
|
|
511
|
-
} catch {
|
|
512
|
-
return false;
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
let diff = 0;
|
|
516
|
-
for (let i = 0; i < a.length; i += 1) {
|
|
517
|
-
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
518
|
-
}
|
|
519
|
-
return diff === 0;
|
|
520
|
-
}
|
|
521
|
-
async function signInternalPayload({
|
|
522
|
-
secret,
|
|
523
|
-
payload,
|
|
524
|
-
timestamp
|
|
525
|
-
}) {
|
|
526
|
-
const ts = timestamp ?? nowMs().toString();
|
|
527
|
-
const signature = await computeHmacHex(secret, `${ts}.${payload}`);
|
|
528
|
-
return { timestamp: ts, signature };
|
|
529
|
-
}
|
|
530
|
-
async function verifyInternalSignature({
|
|
531
|
-
secret,
|
|
532
|
-
timestamp,
|
|
533
|
-
signature,
|
|
534
|
-
payload,
|
|
535
|
-
maxSkewMs = DEFAULT_MAX_SKEW_MS
|
|
536
|
-
}) {
|
|
537
|
-
if (!secret || !timestamp || !signature) {
|
|
538
|
-
return false;
|
|
539
|
-
}
|
|
540
|
-
const tsNumber = Number(timestamp);
|
|
541
|
-
if (!Number.isFinite(tsNumber)) {
|
|
542
|
-
return false;
|
|
543
|
-
}
|
|
544
|
-
const skew = Math.abs(nowMs() - tsNumber);
|
|
545
|
-
if (skew > maxSkewMs) {
|
|
546
|
-
return false;
|
|
547
|
-
}
|
|
548
|
-
const expected = await computeHmacHex(secret, `${timestamp}.${payload}`);
|
|
549
|
-
return timingSafeEqualHex(signature.toLowerCase(), expected.toLowerCase());
|
|
550
|
-
}
|
|
551
|
-
function buildInternalPayload(body) {
|
|
552
|
-
if (body === void 0) return "{}";
|
|
553
|
-
if (typeof body === "string") return body;
|
|
554
|
-
try {
|
|
555
|
-
return JSON.stringify(body);
|
|
556
|
-
} catch {
|
|
557
|
-
return "{}";
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// src/server/auth/betterAuth.ts
|
|
562
|
-
import { betterAuth } from "better-auth";
|
|
563
|
-
import { organization, twoFactor } from "better-auth/plugins";
|
|
564
|
-
function createDeepSpaceAuth(config) {
|
|
565
|
-
const socialProviders = {};
|
|
566
|
-
if (config.google) {
|
|
567
|
-
socialProviders.google = config.google;
|
|
568
|
-
}
|
|
569
|
-
if (config.github) {
|
|
570
|
-
socialProviders.github = config.github;
|
|
571
|
-
}
|
|
572
|
-
return betterAuth({
|
|
573
|
-
database: config.database,
|
|
574
|
-
baseURL: config.baseURL,
|
|
575
|
-
secret: config.secret,
|
|
576
|
-
emailAndPassword: {
|
|
577
|
-
enabled: config.emailAndPassword ?? true
|
|
578
|
-
},
|
|
579
|
-
socialProviders,
|
|
580
|
-
trustedOrigins: config.trustedOrigins ?? [
|
|
581
|
-
"https://deep.space",
|
|
582
|
-
"https://*.deep.space",
|
|
583
|
-
"https://*.app.space",
|
|
584
|
-
"http://localhost:*"
|
|
585
|
-
],
|
|
586
|
-
plugins: [organization(), twoFactor()]
|
|
587
|
-
});
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
// src/server/utils/cron.ts
|
|
591
|
-
var DEFAULT_API_BASE_URL = "https://api-worker.deep.space";
|
|
592
|
-
function buildCronContext(env, ownerUserId, roomId = "default") {
|
|
593
|
-
const hmacSecret = env.INTERNAL_STORAGE_HMAC_SECRET;
|
|
594
|
-
const apiBase = env.API_BASE_URL || DEFAULT_API_BASE_URL;
|
|
595
|
-
const roomIdObj = env.RECORD_ROOMS.idFromName(roomId);
|
|
596
|
-
const room = env.RECORD_ROOMS.get(roomIdObj);
|
|
597
|
-
async function executeTool3(tool, params) {
|
|
598
|
-
const response = await room.fetch(new Request("https://internal/api/tools/execute", {
|
|
599
|
-
method: "POST",
|
|
600
|
-
headers: {
|
|
601
|
-
"Content-Type": "application/json",
|
|
602
|
-
"X-User-Id": ownerUserId,
|
|
603
|
-
"X-App-Action": "true"
|
|
604
|
-
},
|
|
605
|
-
body: JSON.stringify({ tool, params })
|
|
606
|
-
}));
|
|
607
|
-
const result = await response.json();
|
|
608
|
-
if (!result.success) {
|
|
609
|
-
throw new Error(`RecordRoom tool ${tool} failed: ${result.error || "Unknown error"}`);
|
|
610
|
-
}
|
|
611
|
-
return result.data;
|
|
612
|
-
}
|
|
613
|
-
const records = {
|
|
614
|
-
async query(collection, opts) {
|
|
615
|
-
const params = { collection };
|
|
616
|
-
if (opts?.where) params.where = opts.where;
|
|
617
|
-
if (opts?.limit) params.limit = opts.limit;
|
|
618
|
-
const data = await executeTool3("records.query", params);
|
|
619
|
-
return data.records || [];
|
|
620
|
-
},
|
|
621
|
-
async create(collection, data) {
|
|
622
|
-
return executeTool3("records.create", { collection, data });
|
|
623
|
-
},
|
|
624
|
-
async update(collection, recordId, data) {
|
|
625
|
-
return executeTool3("records.update", { collection, recordId, data });
|
|
626
|
-
},
|
|
627
|
-
async delete(collection, recordId) {
|
|
628
|
-
return executeTool3("records.delete", { collection, recordId });
|
|
629
|
-
}
|
|
630
|
-
};
|
|
631
|
-
const integrations = {
|
|
632
|
-
async call(endpoint, params) {
|
|
633
|
-
if (!hmacSecret) {
|
|
634
|
-
throw new Error("INTERNAL_STORAGE_HMAC_SECRET not configured \u2014 cannot call integrations");
|
|
635
|
-
}
|
|
636
|
-
const payload = { userId: ownerUserId, ...params };
|
|
637
|
-
const payloadStr = buildInternalPayload(payload);
|
|
638
|
-
const { timestamp, signature } = await signInternalPayload({
|
|
639
|
-
secret: hmacSecret,
|
|
640
|
-
payload: payloadStr
|
|
641
|
-
});
|
|
642
|
-
const response = await fetch(`${apiBase}/api/internal/integrations/${endpoint}`, {
|
|
643
|
-
method: "POST",
|
|
644
|
-
headers: {
|
|
645
|
-
"Content-Type": "application/json",
|
|
646
|
-
"x-internal-timestamp": timestamp,
|
|
647
|
-
"x-internal-signature": signature
|
|
648
|
-
},
|
|
649
|
-
body: payloadStr
|
|
650
|
-
});
|
|
651
|
-
if (!response.ok) {
|
|
652
|
-
const errText = await response.text();
|
|
653
|
-
throw new Error(`Integration call ${endpoint} failed (${response.status}): ${errText}`);
|
|
654
|
-
}
|
|
655
|
-
return response.json();
|
|
656
|
-
}
|
|
657
|
-
};
|
|
658
|
-
return { records, integrations, ownerUserId };
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
// src/server/utils/ai.ts
|
|
662
|
-
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
663
|
-
import { createOpenAI } from "@ai-sdk/openai";
|
|
664
|
-
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
665
|
-
|
|
666
365
|
// src/server/utils/proxies.ts
|
|
667
366
|
function resolveApiTransport(env) {
|
|
668
367
|
if (env.API_WORKER) return { kind: "binding", fetcher: env.API_WORKER };
|
|
@@ -738,10 +437,82 @@ function authWorkerFetch(env, path, init) {
|
|
|
738
437
|
} else {
|
|
739
438
|
pathAndSearch = "/" + path;
|
|
740
439
|
}
|
|
741
|
-
return fetch(`${baseUrl}${pathAndSearch}`, init);
|
|
440
|
+
return fetch(`${baseUrl}${pathAndSearch}`, init);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// src/server/utils/cron.ts
|
|
444
|
+
function buildCronContext(env, ownerUserId, roomId = "default") {
|
|
445
|
+
const roomIdObj = env.RECORD_ROOMS.idFromName(roomId);
|
|
446
|
+
const room = env.RECORD_ROOMS.get(roomIdObj);
|
|
447
|
+
async function executeTool3(tool, params) {
|
|
448
|
+
const response = await room.fetch(new Request("https://internal/api/tools/execute", {
|
|
449
|
+
method: "POST",
|
|
450
|
+
headers: {
|
|
451
|
+
"Content-Type": "application/json",
|
|
452
|
+
"X-User-Id": ownerUserId,
|
|
453
|
+
"X-App-Action": "true"
|
|
454
|
+
},
|
|
455
|
+
body: JSON.stringify({ tool, params })
|
|
456
|
+
}));
|
|
457
|
+
const result = await response.json();
|
|
458
|
+
if (!result.success) {
|
|
459
|
+
throw new Error(`RecordRoom tool ${tool} failed: ${result.error || "Unknown error"}`);
|
|
460
|
+
}
|
|
461
|
+
return result.data;
|
|
462
|
+
}
|
|
463
|
+
const records = {
|
|
464
|
+
async query(collection, opts) {
|
|
465
|
+
const params = { collection };
|
|
466
|
+
if (opts?.where) params.where = opts.where;
|
|
467
|
+
if (opts?.limit) params.limit = opts.limit;
|
|
468
|
+
const data = await executeTool3("records.query", params);
|
|
469
|
+
return data.records || [];
|
|
470
|
+
},
|
|
471
|
+
async create(collection, data) {
|
|
472
|
+
return executeTool3("records.create", { collection, data });
|
|
473
|
+
},
|
|
474
|
+
async update(collection, recordId, data) {
|
|
475
|
+
return executeTool3("records.update", { collection, recordId, data });
|
|
476
|
+
},
|
|
477
|
+
async delete(collection, recordId) {
|
|
478
|
+
return executeTool3("records.delete", { collection, recordId });
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
const integrations = {
|
|
482
|
+
async call(endpoint, params = {}) {
|
|
483
|
+
if (!env.APP_OWNER_JWT) {
|
|
484
|
+
throw new Error(
|
|
485
|
+
"integrations.call requires env.APP_OWNER_JWT. Deployed apps receive this automatically; in dev, `deepspace dev` writes it into .dev.vars."
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
const response = await apiWorkerFetch(env, `/api/integrations/${endpoint}`, {
|
|
489
|
+
method: "POST",
|
|
490
|
+
headers: {
|
|
491
|
+
"Content-Type": "application/json",
|
|
492
|
+
Authorization: `Bearer ${env.APP_OWNER_JWT}`
|
|
493
|
+
},
|
|
494
|
+
body: JSON.stringify(params)
|
|
495
|
+
});
|
|
496
|
+
const text = await response.text();
|
|
497
|
+
let result = {};
|
|
498
|
+
try {
|
|
499
|
+
result = text ? JSON.parse(text) : {};
|
|
500
|
+
} catch {
|
|
501
|
+
}
|
|
502
|
+
if (!response.ok || !result.success) {
|
|
503
|
+
const detail = result.message || result.error || text || `HTTP ${response.status}`;
|
|
504
|
+
throw new Error(`Integration call ${endpoint} failed: ${detail}`);
|
|
505
|
+
}
|
|
506
|
+
return result.data;
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
return { records, integrations, ownerUserId };
|
|
742
510
|
}
|
|
743
511
|
|
|
744
512
|
// src/server/utils/ai.ts
|
|
513
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
514
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
515
|
+
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
745
516
|
var ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS = 4096;
|
|
746
517
|
function withDefaultAnthropicMaxTokens(body) {
|
|
747
518
|
if (typeof body !== "string") return body;
|
|
@@ -1544,6 +1315,13 @@ var MSG = {
|
|
|
1544
1315
|
USER_LIST: "user.list",
|
|
1545
1316
|
SET_ROLE: "user.set_role",
|
|
1546
1317
|
USER_UPDATE: "user.update",
|
|
1318
|
+
// ---- Auth signal -----------------------------------------------------
|
|
1319
|
+
// Server → client one-shot on connect from rooms that enforce role-based
|
|
1320
|
+
// writes (Canvas, Game, Cron). Tells the client whether this connection
|
|
1321
|
+
// can write so its hook can disable local write APIs and the UI can
|
|
1322
|
+
// render controls accordingly. Without this, a viewer's click would
|
|
1323
|
+
// round-trip to the server and silently come back as ERROR.
|
|
1324
|
+
AUTH: "auth",
|
|
1547
1325
|
// ---- Yjs JSON control frames (binary sync lives in ./yjs.ts) ---------
|
|
1548
1326
|
YJS_JOIN: "yjs.join",
|
|
1549
1327
|
YJS_LEAVE: "yjs.leave",
|
|
@@ -1577,6 +1355,14 @@ var MSG = {
|
|
|
1577
1355
|
CRON_PAUSE: "cron.pause",
|
|
1578
1356
|
CRON_RESUME: "cron.resume",
|
|
1579
1357
|
CRON_STATUS: "cron.status",
|
|
1358
|
+
// ---- JobRoom ---------------------------------------------------------
|
|
1359
|
+
// Background-job queue with durable, alarm-driven execution. See
|
|
1360
|
+
// server/rooms/job-room.ts. Clients enqueue and subscribe to updates;
|
|
1361
|
+
// the room broadcasts JOB_UPDATE for every status transition.
|
|
1362
|
+
JOB_ENQUEUE: "job.enqueue",
|
|
1363
|
+
JOB_CANCEL: "job.cancel",
|
|
1364
|
+
JOB_RETRY: "job.retry",
|
|
1365
|
+
JOB_UPDATE: "job.update",
|
|
1580
1366
|
// ---- PresenceRoom ----------------------------------------------------
|
|
1581
1367
|
PRESENCE_SYNC: "presence.sync",
|
|
1582
1368
|
PRESENCE_JOIN: "presence.join",
|
|
@@ -2069,6 +1855,17 @@ var serverBuild = {
|
|
|
2069
1855
|
cronTasks: (tasks) => ({ type: MSG.CRON_TASKS, payload: { tasks } }),
|
|
2070
1856
|
cronHistory: (history) => ({ type: MSG.CRON_HISTORY, payload: { history } }),
|
|
2071
1857
|
cronStatus: (tasks, recentHistory) => ({ type: MSG.CRON_STATUS, payload: { tasks, recentHistory } }),
|
|
1858
|
+
// Jobs
|
|
1859
|
+
jobSnapshot: (jobs) => ({ type: MSG.JOB_UPDATE, payload: { kind: "snapshot", jobs } }),
|
|
1860
|
+
jobEnqueued: (job, requestId) => ({
|
|
1861
|
+
type: MSG.JOB_UPDATE,
|
|
1862
|
+
payload: { kind: "enqueued", job, requestId }
|
|
1863
|
+
}),
|
|
1864
|
+
jobProgress: (job) => ({ type: MSG.JOB_UPDATE, payload: { kind: "progress", job } }),
|
|
1865
|
+
jobSucceeded: (job) => ({ type: MSG.JOB_UPDATE, payload: { kind: "succeeded", job } }),
|
|
1866
|
+
jobFailed: (job) => ({ type: MSG.JOB_UPDATE, payload: { kind: "failed", job } }),
|
|
1867
|
+
jobCanceled: (job) => ({ type: MSG.JOB_UPDATE, payload: { kind: "canceled", job } }),
|
|
1868
|
+
jobRetried: (job) => ({ type: MSG.JOB_UPDATE, payload: { kind: "retried", job } }),
|
|
2072
1869
|
// Presence
|
|
2073
1870
|
presenceSync: (peers) => ({ type: MSG.PRESENCE_SYNC, payload: { peers } }),
|
|
2074
1871
|
presenceJoin: (peer) => ({ type: MSG.PRESENCE_JOIN, payload: { peer } }),
|
|
@@ -4160,7 +3957,20 @@ var YjsRoom = class extends BaseRoom {
|
|
|
4160
3957
|
}
|
|
4161
3958
|
};
|
|
4162
3959
|
|
|
3960
|
+
// src/shared/roles.ts
|
|
3961
|
+
var ROLES = {
|
|
3962
|
+
VIEWER: "viewer",
|
|
3963
|
+
MEMBER: "member",
|
|
3964
|
+
ADMIN: "admin"
|
|
3965
|
+
};
|
|
3966
|
+
|
|
4163
3967
|
// src/server/rooms/game-room.ts
|
|
3968
|
+
var GAME_WRITE_TYPES = /* @__PURE__ */ new Set([
|
|
3969
|
+
MSG.GAME_INPUT,
|
|
3970
|
+
MSG.GAME_PLAYER_READY,
|
|
3971
|
+
MSG.GAME_START,
|
|
3972
|
+
MSG.GAME_END
|
|
3973
|
+
]);
|
|
4164
3974
|
var GameRoom = class extends BaseRoom {
|
|
4165
3975
|
config;
|
|
4166
3976
|
players = /* @__PURE__ */ new Map();
|
|
@@ -4216,10 +4026,26 @@ var GameRoom = class extends BaseRoom {
|
|
|
4216
4026
|
// ==========================================================================
|
|
4217
4027
|
onConnect(ws, user) {
|
|
4218
4028
|
this.ensureInitialized();
|
|
4029
|
+
const role = user.role ?? ROLES.VIEWER;
|
|
4030
|
+
const canWrite = role === ROLES.MEMBER || role === ROLES.ADMIN;
|
|
4219
4031
|
const attachment = {
|
|
4220
4032
|
...user,
|
|
4221
|
-
joinedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4033
|
+
joinedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4034
|
+
canWrite
|
|
4222
4035
|
};
|
|
4036
|
+
this.sendTo(ws, { type: MSG.AUTH, payload: { canWrite } });
|
|
4037
|
+
if (!canWrite) {
|
|
4038
|
+
this.sendTo(ws, {
|
|
4039
|
+
type: MSG.GAME_STATE,
|
|
4040
|
+
payload: {
|
|
4041
|
+
state: this.gameState,
|
|
4042
|
+
tick: this.currentTick,
|
|
4043
|
+
players: Array.from(this.players.values()),
|
|
4044
|
+
running: this.running
|
|
4045
|
+
}
|
|
4046
|
+
});
|
|
4047
|
+
return attachment;
|
|
4048
|
+
}
|
|
4223
4049
|
const player = {
|
|
4224
4050
|
userId: user.userId,
|
|
4225
4051
|
userName: user.userName,
|
|
@@ -4248,6 +4074,13 @@ var GameRoom = class extends BaseRoom {
|
|
|
4248
4074
|
async onMessage(ws, user, message) {
|
|
4249
4075
|
this.ensureInitialized();
|
|
4250
4076
|
const { type, payload } = message;
|
|
4077
|
+
if (GAME_WRITE_TYPES.has(type) && !user.canWrite) {
|
|
4078
|
+
this.sendTo(ws, {
|
|
4079
|
+
type: MSG.ERROR,
|
|
4080
|
+
payload: { error: "Write access denied: spectators cannot drive the game" }
|
|
4081
|
+
});
|
|
4082
|
+
return;
|
|
4083
|
+
}
|
|
4251
4084
|
switch (type) {
|
|
4252
4085
|
case MSG.GAME_INPUT: {
|
|
4253
4086
|
this.inputBuffer.push({
|
|
@@ -4396,6 +4229,15 @@ var GameRoom = class extends BaseRoom {
|
|
|
4396
4229
|
|
|
4397
4230
|
// src/server/rooms/canvas-room.ts
|
|
4398
4231
|
import * as Y5 from "yjs";
|
|
4232
|
+
var CANVAS_WRITE_TYPES = /* @__PURE__ */ new Set([
|
|
4233
|
+
MSG.CANVAS_ADD,
|
|
4234
|
+
MSG.CANVAS_MOVE,
|
|
4235
|
+
MSG.CANVAS_RESIZE,
|
|
4236
|
+
MSG.CANVAS_DELETE,
|
|
4237
|
+
MSG.CANVAS_UPDATE,
|
|
4238
|
+
MSG.CANVAS_UNDO,
|
|
4239
|
+
MSG.CANVAS_REDO
|
|
4240
|
+
]);
|
|
4399
4241
|
var CanvasRoom = class extends BaseRoom {
|
|
4400
4242
|
doc = null;
|
|
4401
4243
|
initialized = false;
|
|
@@ -4455,10 +4297,14 @@ var CanvasRoom = class extends BaseRoom {
|
|
|
4455
4297
|
}
|
|
4456
4298
|
onConnect(ws, user) {
|
|
4457
4299
|
this.ensureInitialized();
|
|
4300
|
+
const role = user.role ?? ROLES.VIEWER;
|
|
4301
|
+
const canWrite = role === ROLES.MEMBER || role === ROLES.ADMIN;
|
|
4458
4302
|
const attachment = {
|
|
4459
4303
|
...user,
|
|
4460
|
-
viewport: null
|
|
4304
|
+
viewport: null,
|
|
4305
|
+
canWrite
|
|
4461
4306
|
};
|
|
4307
|
+
this.sendTo(ws, { type: MSG.AUTH, payload: { canWrite } });
|
|
4462
4308
|
const shapes = this.getAllShapes();
|
|
4463
4309
|
this.sendTo(ws, {
|
|
4464
4310
|
type: MSG.CANVAS_SHAPES,
|
|
@@ -4472,6 +4318,13 @@ var CanvasRoom = class extends BaseRoom {
|
|
|
4472
4318
|
async onMessage(ws, user, message) {
|
|
4473
4319
|
this.ensureInitialized();
|
|
4474
4320
|
const { type, payload } = message;
|
|
4321
|
+
if (CANVAS_WRITE_TYPES.has(type) && !user.canWrite) {
|
|
4322
|
+
this.sendTo(ws, {
|
|
4323
|
+
type: MSG.ERROR,
|
|
4324
|
+
payload: { error: "Write access denied: viewer role cannot modify the canvas" }
|
|
4325
|
+
});
|
|
4326
|
+
return;
|
|
4327
|
+
}
|
|
4475
4328
|
switch (type) {
|
|
4476
4329
|
case MSG.CANVAS_ADD: {
|
|
4477
4330
|
const shape = payload;
|
|
@@ -4766,6 +4619,11 @@ var PresenceRoom = class extends BaseRoom {
|
|
|
4766
4619
|
};
|
|
4767
4620
|
|
|
4768
4621
|
// src/server/rooms/cron-room.ts
|
|
4622
|
+
var CRON_WRITE_TYPES = /* @__PURE__ */ new Set([
|
|
4623
|
+
MSG.CRON_TRIGGER,
|
|
4624
|
+
MSG.CRON_PAUSE,
|
|
4625
|
+
MSG.CRON_RESUME
|
|
4626
|
+
]);
|
|
4769
4627
|
var CronRoom = class extends BaseRoom {
|
|
4770
4628
|
tasks;
|
|
4771
4629
|
initialized = false;
|
|
@@ -4844,6 +4702,9 @@ var CronRoom = class extends BaseRoom {
|
|
|
4844
4702
|
}
|
|
4845
4703
|
onConnect(ws, user) {
|
|
4846
4704
|
this.ensureInitialized();
|
|
4705
|
+
const role = user.role ?? ROLES.VIEWER;
|
|
4706
|
+
const canWrite = role === ROLES.MEMBER || role === ROLES.ADMIN;
|
|
4707
|
+
this.sendTo(ws, { type: MSG.AUTH, payload: { canWrite } });
|
|
4847
4708
|
this.sendTo(ws, {
|
|
4848
4709
|
type: MSG.CRON_TASKS,
|
|
4849
4710
|
payload: { tasks: this.getTaskStates() }
|
|
@@ -4852,11 +4713,18 @@ var CronRoom = class extends BaseRoom {
|
|
|
4852
4713
|
type: MSG.CRON_HISTORY,
|
|
4853
4714
|
payload: { history: this.getRecentHistory(50) }
|
|
4854
4715
|
});
|
|
4855
|
-
return user;
|
|
4716
|
+
return { ...user, canWrite };
|
|
4856
4717
|
}
|
|
4857
4718
|
async onMessage(ws, user, message) {
|
|
4858
4719
|
this.ensureInitialized();
|
|
4859
4720
|
const { type, payload } = message;
|
|
4721
|
+
if (CRON_WRITE_TYPES.has(type) && !user.canWrite) {
|
|
4722
|
+
this.sendTo(ws, {
|
|
4723
|
+
type: MSG.ERROR,
|
|
4724
|
+
payload: { error: "Write access denied: viewer role cannot modify cron tasks" }
|
|
4725
|
+
});
|
|
4726
|
+
return;
|
|
4727
|
+
}
|
|
4860
4728
|
switch (type) {
|
|
4861
4729
|
case MSG.CRON_TRIGGER: {
|
|
4862
4730
|
const taskName = payload.taskName;
|
|
@@ -5126,45 +4994,582 @@ function computeNextRunAt(task, from) {
|
|
|
5126
4994
|
if (task.interval_minutes) {
|
|
5127
4995
|
return new Date(from.getTime() + task.interval_minutes * 60 * 1e3).toISOString();
|
|
5128
4996
|
}
|
|
5129
|
-
if (task.schedule && task.timezone) {
|
|
5130
|
-
const next = nextCronFire(task.schedule, task.timezone, from);
|
|
5131
|
-
return next ? next.toISOString() : null;
|
|
4997
|
+
if (task.schedule && task.timezone) {
|
|
4998
|
+
const next = nextCronFire(task.schedule, task.timezone, from);
|
|
4999
|
+
return next ? next.toISOString() : null;
|
|
5000
|
+
}
|
|
5001
|
+
return null;
|
|
5002
|
+
}
|
|
5003
|
+
function nextCronFire(expression, timezone, from) {
|
|
5004
|
+
const parsed = parseCronExpression(expression);
|
|
5005
|
+
const start = new Date(Math.floor(from.getTime() / 6e4) * 6e4 + 6e4);
|
|
5006
|
+
const limit = new Date(start.getTime() + 366 * 24 * 60 * 6e4);
|
|
5007
|
+
for (let t = start.getTime(); t < limit.getTime(); t += 6e4) {
|
|
5008
|
+
const candidate = new Date(t);
|
|
5009
|
+
if (cronMatches(parsed, timezone, candidate)) {
|
|
5010
|
+
return candidate;
|
|
5011
|
+
}
|
|
5012
|
+
}
|
|
5013
|
+
return null;
|
|
5014
|
+
}
|
|
5015
|
+
function cronMatches(parsed, timezone, date) {
|
|
5016
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
5017
|
+
timeZone: timezone,
|
|
5018
|
+
year: "numeric",
|
|
5019
|
+
month: "numeric",
|
|
5020
|
+
day: "numeric",
|
|
5021
|
+
hour: "numeric",
|
|
5022
|
+
minute: "numeric",
|
|
5023
|
+
weekday: "short",
|
|
5024
|
+
hour12: false
|
|
5025
|
+
});
|
|
5026
|
+
const parts = Object.fromEntries(
|
|
5027
|
+
formatter.formatToParts(date).map((p) => [p.type, p.value])
|
|
5028
|
+
);
|
|
5029
|
+
const minute = parseInt(parts.minute, 10);
|
|
5030
|
+
const hour = parseInt(parts.hour, 10) % 24;
|
|
5031
|
+
const dayOfMonth = parseInt(parts.day, 10);
|
|
5032
|
+
const month = parseInt(parts.month, 10);
|
|
5033
|
+
const weekdayMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
5034
|
+
const dayOfWeek = weekdayMap[parts.weekday] ?? 0;
|
|
5035
|
+
return parsed.minute.has(minute) && parsed.hour.has(hour) && parsed.dayOfMonth.has(dayOfMonth) && parsed.month.has(month) && parsed.dayOfWeek.has(dayOfWeek);
|
|
5036
|
+
}
|
|
5037
|
+
|
|
5038
|
+
// src/server/rooms/job-room.ts
|
|
5039
|
+
var RUNNING_STALE_MS = 16 * 60 * 1e3;
|
|
5040
|
+
var DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1e3;
|
|
5041
|
+
var DEFAULT_SNAPSHOT_LIMIT = 100;
|
|
5042
|
+
var JobRoom = class extends BaseRoom {
|
|
5043
|
+
initialized = false;
|
|
5044
|
+
defaultMaxAttempts;
|
|
5045
|
+
retentionMs;
|
|
5046
|
+
snapshotLimit;
|
|
5047
|
+
retryBackoffMs;
|
|
5048
|
+
/** In-flight jobs in this isolate; lets same-isolate JOB_CANCEL fire the signal. */
|
|
5049
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
5050
|
+
/** Set by `ctx.continue` inside `executeJob`; reset before every run. */
|
|
5051
|
+
continueState = null;
|
|
5052
|
+
constructor(state, env, config = {}) {
|
|
5053
|
+
super(state, env);
|
|
5054
|
+
this.defaultMaxAttempts = Math.max(1, config.defaultMaxAttempts ?? 1);
|
|
5055
|
+
this.retentionMs = Math.max(0, config.retentionMs ?? DEFAULT_RETENTION_MS);
|
|
5056
|
+
this.snapshotLimit = Math.max(1, config.snapshotLimit ?? DEFAULT_SNAPSHOT_LIMIT);
|
|
5057
|
+
this.retryBackoffMs = Math.max(0, config.retryBackoffMs ?? 1e3);
|
|
5058
|
+
}
|
|
5059
|
+
ensureInitialized() {
|
|
5060
|
+
if (this.initialized) return;
|
|
5061
|
+
this.initialized = true;
|
|
5062
|
+
this.sql.exec(`
|
|
5063
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
5064
|
+
id TEXT PRIMARY KEY,
|
|
5065
|
+
type TEXT NOT NULL,
|
|
5066
|
+
status TEXT NOT NULL,
|
|
5067
|
+
payload TEXT,
|
|
5068
|
+
result TEXT,
|
|
5069
|
+
error TEXT,
|
|
5070
|
+
progress REAL,
|
|
5071
|
+
progress_message TEXT,
|
|
5072
|
+
checkpoint TEXT,
|
|
5073
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
5074
|
+
max_attempts INTEGER NOT NULL DEFAULT 1,
|
|
5075
|
+
enqueued_at TEXT NOT NULL,
|
|
5076
|
+
started_at TEXT,
|
|
5077
|
+
completed_at TEXT,
|
|
5078
|
+
enqueued_by TEXT,
|
|
5079
|
+
next_run_at TEXT
|
|
5080
|
+
)
|
|
5081
|
+
`);
|
|
5082
|
+
this.sql.exec(
|
|
5083
|
+
`CREATE INDEX IF NOT EXISTS jobs_status_next_run ON jobs (status, next_run_at)`
|
|
5084
|
+
);
|
|
5085
|
+
this.sql.exec(
|
|
5086
|
+
`CREATE INDEX IF NOT EXISTS jobs_completed_at ON jobs (completed_at)`
|
|
5087
|
+
);
|
|
5088
|
+
this.recoverStuckRunning();
|
|
5089
|
+
this.scheduleNextAlarm();
|
|
5090
|
+
}
|
|
5091
|
+
// ==========================================================================
|
|
5092
|
+
// BaseRoom Lifecycle
|
|
5093
|
+
// ==========================================================================
|
|
5094
|
+
async fetch(request) {
|
|
5095
|
+
this.ensureInitialized();
|
|
5096
|
+
return super.fetch(request);
|
|
5097
|
+
}
|
|
5098
|
+
onConnect(ws, user) {
|
|
5099
|
+
this.ensureInitialized();
|
|
5100
|
+
this.sendTo(ws, serverBuild.jobSnapshot(this.getRecentJobs(this.snapshotLimit)));
|
|
5101
|
+
return user;
|
|
5102
|
+
}
|
|
5103
|
+
async onMessage(ws, user, message) {
|
|
5104
|
+
this.ensureInitialized();
|
|
5105
|
+
const { type, payload } = message;
|
|
5106
|
+
switch (type) {
|
|
5107
|
+
case MSG.JOB_ENQUEUE: {
|
|
5108
|
+
const requestId = String(payload.requestId ?? "");
|
|
5109
|
+
const jobType = String(payload.type ?? "");
|
|
5110
|
+
if (!jobType) {
|
|
5111
|
+
this.sendTo(ws, serverBuild.error("JOB_ENQUEUE missing type"));
|
|
5112
|
+
return;
|
|
5113
|
+
}
|
|
5114
|
+
const job = this.insertJob({
|
|
5115
|
+
type: jobType,
|
|
5116
|
+
payload: payload.payload,
|
|
5117
|
+
maxAttempts: typeof payload.maxAttempts === "number" ? Math.max(1, Math.floor(payload.maxAttempts)) : void 0,
|
|
5118
|
+
enqueuedBy: user.userId
|
|
5119
|
+
});
|
|
5120
|
+
this.sendTo(ws, serverBuild.jobEnqueued(job, requestId));
|
|
5121
|
+
this.broadcast(serverBuild.jobEnqueued(job), ws);
|
|
5122
|
+
this.scheduleNextAlarm();
|
|
5123
|
+
break;
|
|
5124
|
+
}
|
|
5125
|
+
case MSG.JOB_CANCEL: {
|
|
5126
|
+
const jobId = String(payload.jobId ?? "");
|
|
5127
|
+
if (!jobId) {
|
|
5128
|
+
this.sendTo(ws, serverBuild.error("JOB_CANCEL missing jobId"));
|
|
5129
|
+
return;
|
|
5130
|
+
}
|
|
5131
|
+
this.cancelJob(jobId);
|
|
5132
|
+
break;
|
|
5133
|
+
}
|
|
5134
|
+
case MSG.JOB_RETRY: {
|
|
5135
|
+
const jobId = String(payload.jobId ?? "");
|
|
5136
|
+
if (!jobId) {
|
|
5137
|
+
this.sendTo(ws, serverBuild.error("JOB_RETRY missing jobId"));
|
|
5138
|
+
return;
|
|
5139
|
+
}
|
|
5140
|
+
this.retryJob(jobId);
|
|
5141
|
+
break;
|
|
5142
|
+
}
|
|
5143
|
+
default:
|
|
5144
|
+
this.sendTo(ws, serverBuild.error(`Unknown job message type: ${type}`));
|
|
5145
|
+
}
|
|
5146
|
+
}
|
|
5147
|
+
async onAlarm() {
|
|
5148
|
+
this.ensureInitialized();
|
|
5149
|
+
await this.drainDueJobs();
|
|
5150
|
+
this.pruneExpired();
|
|
5151
|
+
this.scheduleNextAlarm();
|
|
5152
|
+
}
|
|
5153
|
+
// ==========================================================================
|
|
5154
|
+
// HTTP — internal enqueue path for cross-isolate callers
|
|
5155
|
+
// ==========================================================================
|
|
5156
|
+
/**
|
|
5157
|
+
* One internal endpoint, `POST /enqueue`, used by the `enqueueJob`
|
|
5158
|
+
* helper at the bottom of this file. The room is not meant to be
|
|
5159
|
+
* reachable from the public internet; callers route through the app
|
|
5160
|
+
* worker (or call `this.enqueue(...)` directly from a subclass).
|
|
5161
|
+
*
|
|
5162
|
+
* Subclasses overriding `onRequest` should call `super.onRequest(req)`
|
|
5163
|
+
* for paths they don't handle.
|
|
5164
|
+
*/
|
|
5165
|
+
async onRequest(request) {
|
|
5166
|
+
this.ensureInitialized();
|
|
5167
|
+
const url = new URL(request.url);
|
|
5168
|
+
if (request.method !== "POST" || !url.pathname.endsWith("/enqueue")) {
|
|
5169
|
+
return new Response("Not Found", { status: 404 });
|
|
5170
|
+
}
|
|
5171
|
+
const body = await request.json().catch(() => null);
|
|
5172
|
+
if (!body || typeof body.type !== "string" || !body.type) {
|
|
5173
|
+
return new Response(JSON.stringify({ error: "type required" }), {
|
|
5174
|
+
status: 400,
|
|
5175
|
+
headers: { "Content-Type": "application/json" }
|
|
5176
|
+
});
|
|
5177
|
+
}
|
|
5178
|
+
const job = this.insertJob({
|
|
5179
|
+
type: body.type,
|
|
5180
|
+
payload: body.payload,
|
|
5181
|
+
maxAttempts: typeof body.maxAttempts === "number" ? Math.max(1, Math.floor(body.maxAttempts)) : void 0,
|
|
5182
|
+
enqueuedBy: typeof body.enqueuedBy === "string" ? body.enqueuedBy : void 0
|
|
5183
|
+
});
|
|
5184
|
+
this.broadcast(serverBuild.jobEnqueued(job));
|
|
5185
|
+
this.scheduleNextAlarm();
|
|
5186
|
+
return new Response(JSON.stringify({ job }), {
|
|
5187
|
+
status: 200,
|
|
5188
|
+
headers: { "Content-Type": "application/json" }
|
|
5189
|
+
});
|
|
5190
|
+
}
|
|
5191
|
+
// ==========================================================================
|
|
5192
|
+
// Public enqueue surface (server-side callers in the same isolate)
|
|
5193
|
+
// ==========================================================================
|
|
5194
|
+
/**
|
|
5195
|
+
* Synchronously persist a new job and arm the alarm. Returns the new
|
|
5196
|
+
* job. This is the in-isolate path — useful for a subclass `onJob`
|
|
5197
|
+
* that wants to chain follow-up work without paying a fetch hop:
|
|
5198
|
+
*
|
|
5199
|
+
* protected async onJob(job: Job, ctx: JobContext) {
|
|
5200
|
+
* // ... do work ...
|
|
5201
|
+
* if (needsFollowup) this.enqueue('followup', { parent: job.id })
|
|
5202
|
+
* }
|
|
5203
|
+
*
|
|
5204
|
+
* Workers code that lives outside the DO isolate (HTTP routes, cron
|
|
5205
|
+
* tasks, server actions) cannot call this directly — they must go
|
|
5206
|
+
* through the `enqueueJob` helper at the bottom of this file, which
|
|
5207
|
+
* routes via the DO's `/enqueue` HTTP endpoint.
|
|
5208
|
+
*/
|
|
5209
|
+
enqueue(type, payload, options) {
|
|
5210
|
+
this.ensureInitialized();
|
|
5211
|
+
const job = this.insertJob({
|
|
5212
|
+
type,
|
|
5213
|
+
payload,
|
|
5214
|
+
maxAttempts: options?.maxAttempts,
|
|
5215
|
+
enqueuedBy: options?.enqueuedBy
|
|
5216
|
+
});
|
|
5217
|
+
this.broadcast(serverBuild.jobEnqueued(job));
|
|
5218
|
+
this.scheduleNextAlarm();
|
|
5219
|
+
return job;
|
|
5220
|
+
}
|
|
5221
|
+
// ==========================================================================
|
|
5222
|
+
// Mutation helpers
|
|
5223
|
+
// ==========================================================================
|
|
5224
|
+
insertJob(input) {
|
|
5225
|
+
const id = crypto.randomUUID();
|
|
5226
|
+
const enqueuedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5227
|
+
const maxAttempts = Math.max(1, input.maxAttempts ?? this.defaultMaxAttempts);
|
|
5228
|
+
const payloadJson = input.payload === void 0 ? null : safeStringify(input.payload);
|
|
5229
|
+
this.sql.exec(
|
|
5230
|
+
`INSERT INTO jobs (id, type, status, payload, attempts, max_attempts, enqueued_at, enqueued_by, next_run_at)
|
|
5231
|
+
VALUES (?, ?, 'queued', ?, 0, ?, ?, ?, ?)`,
|
|
5232
|
+
id,
|
|
5233
|
+
input.type,
|
|
5234
|
+
payloadJson,
|
|
5235
|
+
maxAttempts,
|
|
5236
|
+
enqueuedAt,
|
|
5237
|
+
input.enqueuedBy ?? null,
|
|
5238
|
+
enqueuedAt
|
|
5239
|
+
);
|
|
5240
|
+
return this.getJobById(id);
|
|
5241
|
+
}
|
|
5242
|
+
cancelJob(jobId) {
|
|
5243
|
+
const row = this.sql.exec(`SELECT status FROM jobs WHERE id = ?`, jobId).toArray()[0];
|
|
5244
|
+
if (!row) return;
|
|
5245
|
+
if (row.status === "succeeded" || row.status === "failed" || row.status === "canceled") {
|
|
5246
|
+
return;
|
|
5247
|
+
}
|
|
5248
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5249
|
+
this.sql.exec(
|
|
5250
|
+
`UPDATE jobs
|
|
5251
|
+
SET status = 'canceled',
|
|
5252
|
+
completed_at = ?,
|
|
5253
|
+
checkpoint = NULL,
|
|
5254
|
+
next_run_at = NULL
|
|
5255
|
+
WHERE id = ?`,
|
|
5256
|
+
completedAt,
|
|
5257
|
+
jobId
|
|
5258
|
+
);
|
|
5259
|
+
const controller = this.inFlight.get(jobId);
|
|
5260
|
+
if (controller) controller.abort();
|
|
5261
|
+
const job = this.getJobById(jobId);
|
|
5262
|
+
if (job) this.broadcast(serverBuild.jobCanceled(job));
|
|
5263
|
+
}
|
|
5264
|
+
retryJob(jobId) {
|
|
5265
|
+
const row = this.sql.exec(`SELECT status, attempts, max_attempts FROM jobs WHERE id = ?`, jobId).toArray()[0];
|
|
5266
|
+
if (!row) return;
|
|
5267
|
+
if (row.status !== "failed" && row.status !== "canceled") return;
|
|
5268
|
+
const newMax = Math.max(row.max_attempts, row.attempts + 1);
|
|
5269
|
+
this.sql.exec(
|
|
5270
|
+
`UPDATE jobs
|
|
5271
|
+
SET status = 'queued',
|
|
5272
|
+
max_attempts = ?,
|
|
5273
|
+
error = NULL,
|
|
5274
|
+
result = NULL,
|
|
5275
|
+
completed_at = NULL,
|
|
5276
|
+
next_run_at = ?
|
|
5277
|
+
WHERE id = ?`,
|
|
5278
|
+
newMax,
|
|
5279
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
5280
|
+
jobId
|
|
5281
|
+
);
|
|
5282
|
+
const job = this.getJobById(jobId);
|
|
5283
|
+
if (job) this.broadcast(serverBuild.jobRetried(job));
|
|
5284
|
+
this.scheduleNextAlarm();
|
|
5285
|
+
}
|
|
5286
|
+
// ==========================================================================
|
|
5287
|
+
// Alarm / execution
|
|
5288
|
+
// ==========================================================================
|
|
5289
|
+
/**
|
|
5290
|
+
* Drain due jobs in FIFO order. All due jobs share the alarm's
|
|
5291
|
+
* 15-min wall budget (a CF property, not a per-job allocation).
|
|
5292
|
+
*/
|
|
5293
|
+
async drainDueJobs() {
|
|
5294
|
+
const cutoff = (/* @__PURE__ */ new Date()).toISOString();
|
|
5295
|
+
while (true) {
|
|
5296
|
+
const row = this.sql.exec(
|
|
5297
|
+
`SELECT * FROM jobs
|
|
5298
|
+
WHERE status = 'queued'
|
|
5299
|
+
AND (next_run_at IS NULL OR next_run_at <= ?)
|
|
5300
|
+
ORDER BY enqueued_at ASC
|
|
5301
|
+
LIMIT 1`,
|
|
5302
|
+
cutoff
|
|
5303
|
+
).toArray()[0];
|
|
5304
|
+
if (!row) return;
|
|
5305
|
+
await this.executeJob(row);
|
|
5306
|
+
}
|
|
5307
|
+
}
|
|
5308
|
+
async executeJob(row) {
|
|
5309
|
+
const startedAt = row.started_at ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
5310
|
+
const attemptsBefore = row.attempts;
|
|
5311
|
+
const attempt = attemptsBefore + 1;
|
|
5312
|
+
this.sql.exec(
|
|
5313
|
+
`UPDATE jobs SET status = 'running', started_at = ?, attempts = ? WHERE id = ?`,
|
|
5314
|
+
startedAt,
|
|
5315
|
+
attempt,
|
|
5316
|
+
row.id
|
|
5317
|
+
);
|
|
5318
|
+
const controller = new AbortController();
|
|
5319
|
+
this.inFlight.set(row.id, controller);
|
|
5320
|
+
this.continueState = null;
|
|
5321
|
+
const job = {
|
|
5322
|
+
...rowToJobView(row),
|
|
5323
|
+
status: "running",
|
|
5324
|
+
attempts: attempt,
|
|
5325
|
+
startedAt
|
|
5326
|
+
};
|
|
5327
|
+
let outcome = "succeeded";
|
|
5328
|
+
let result;
|
|
5329
|
+
let errorMessage;
|
|
5330
|
+
try {
|
|
5331
|
+
const ctx = this.makeContext(row.id, controller.signal);
|
|
5332
|
+
result = await this.onJob(job, ctx);
|
|
5333
|
+
if (this.continueState) outcome = "continued";
|
|
5334
|
+
} catch (e) {
|
|
5335
|
+
outcome = "failed";
|
|
5336
|
+
errorMessage = e instanceof Error ? e.message : String(e);
|
|
5337
|
+
console.error(`[JobRoom] Job "${row.type}" (${row.id}) failed:`, e);
|
|
5338
|
+
} finally {
|
|
5339
|
+
this.inFlight.delete(row.id);
|
|
5340
|
+
}
|
|
5341
|
+
const currentStatus = this.readStatus(row.id);
|
|
5342
|
+
if (currentStatus === "canceled") {
|
|
5343
|
+
this.continueState = null;
|
|
5344
|
+
return;
|
|
5345
|
+
}
|
|
5346
|
+
if (outcome === "continued") {
|
|
5347
|
+
const state = this.continueState;
|
|
5348
|
+
this.continueState = null;
|
|
5349
|
+
const nextRunAt = new Date(Date.now() + Math.max(0, state.afterMs)).toISOString();
|
|
5350
|
+
this.sql.exec(
|
|
5351
|
+
`UPDATE jobs
|
|
5352
|
+
SET status = 'queued',
|
|
5353
|
+
checkpoint = ?,
|
|
5354
|
+
next_run_at = ?
|
|
5355
|
+
WHERE id = ?`,
|
|
5356
|
+
safeStringify(state.checkpoint),
|
|
5357
|
+
nextRunAt,
|
|
5358
|
+
row.id
|
|
5359
|
+
);
|
|
5360
|
+
const updated2 = this.getJobById(row.id);
|
|
5361
|
+
if (updated2) this.broadcast(serverBuild.jobProgress(updated2));
|
|
5362
|
+
return;
|
|
5363
|
+
}
|
|
5364
|
+
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5365
|
+
if (outcome === "succeeded") {
|
|
5366
|
+
this.sql.exec(
|
|
5367
|
+
`UPDATE jobs
|
|
5368
|
+
SET status = 'succeeded',
|
|
5369
|
+
result = ?,
|
|
5370
|
+
completed_at = ?,
|
|
5371
|
+
error = NULL,
|
|
5372
|
+
checkpoint = NULL,
|
|
5373
|
+
next_run_at = NULL,
|
|
5374
|
+
progress = NULL,
|
|
5375
|
+
progress_message = NULL
|
|
5376
|
+
WHERE id = ?`,
|
|
5377
|
+
result === void 0 ? null : safeStringify(result),
|
|
5378
|
+
completedAt,
|
|
5379
|
+
row.id
|
|
5380
|
+
);
|
|
5381
|
+
const updated2 = this.getJobById(row.id);
|
|
5382
|
+
if (updated2) this.broadcast(serverBuild.jobSucceeded(updated2));
|
|
5383
|
+
return;
|
|
5384
|
+
}
|
|
5385
|
+
if (attempt < row.max_attempts) {
|
|
5386
|
+
const nextRunAt = new Date(Date.now() + this.retryBackoffMs).toISOString();
|
|
5387
|
+
this.sql.exec(
|
|
5388
|
+
`UPDATE jobs
|
|
5389
|
+
SET status = 'queued',
|
|
5390
|
+
error = ?,
|
|
5391
|
+
next_run_at = ?,
|
|
5392
|
+
started_at = NULL,
|
|
5393
|
+
progress = NULL,
|
|
5394
|
+
progress_message = NULL
|
|
5395
|
+
WHERE id = ?`,
|
|
5396
|
+
errorMessage ?? "unknown error",
|
|
5397
|
+
nextRunAt,
|
|
5398
|
+
row.id
|
|
5399
|
+
);
|
|
5400
|
+
const updated2 = this.getJobById(row.id);
|
|
5401
|
+
if (updated2) this.broadcast(serverBuild.jobRetried(updated2));
|
|
5402
|
+
return;
|
|
5403
|
+
}
|
|
5404
|
+
this.sql.exec(
|
|
5405
|
+
`UPDATE jobs
|
|
5406
|
+
SET status = 'failed',
|
|
5407
|
+
error = ?,
|
|
5408
|
+
completed_at = ?,
|
|
5409
|
+
checkpoint = NULL,
|
|
5410
|
+
next_run_at = NULL,
|
|
5411
|
+
progress = NULL,
|
|
5412
|
+
progress_message = NULL
|
|
5413
|
+
WHERE id = ?`,
|
|
5414
|
+
errorMessage ?? "unknown error",
|
|
5415
|
+
completedAt,
|
|
5416
|
+
row.id
|
|
5417
|
+
);
|
|
5418
|
+
const updated = this.getJobById(row.id);
|
|
5419
|
+
if (updated) this.broadcast(serverBuild.jobFailed(updated));
|
|
5420
|
+
}
|
|
5421
|
+
makeContext(jobId, signal) {
|
|
5422
|
+
return {
|
|
5423
|
+
progress: (value, message) => {
|
|
5424
|
+
const clamped = Math.max(0, Math.min(1, value));
|
|
5425
|
+
this.sql.exec(
|
|
5426
|
+
`UPDATE jobs SET progress = ?, progress_message = ? WHERE id = ?`,
|
|
5427
|
+
clamped,
|
|
5428
|
+
message ?? null,
|
|
5429
|
+
jobId
|
|
5430
|
+
);
|
|
5431
|
+
const updated = this.getJobById(jobId);
|
|
5432
|
+
if (updated) this.broadcast(serverBuild.jobProgress(updated));
|
|
5433
|
+
},
|
|
5434
|
+
continue: (state, options) => {
|
|
5435
|
+
this.continueState = {
|
|
5436
|
+
checkpoint: state,
|
|
5437
|
+
afterMs: Math.max(0, options?.afterMs ?? 0)
|
|
5438
|
+
};
|
|
5439
|
+
},
|
|
5440
|
+
signal
|
|
5441
|
+
};
|
|
5442
|
+
}
|
|
5443
|
+
readStatus(jobId) {
|
|
5444
|
+
const row = this.sql.exec(`SELECT status FROM jobs WHERE id = ?`, jobId).toArray()[0];
|
|
5445
|
+
return row?.status ?? null;
|
|
5446
|
+
}
|
|
5447
|
+
// ==========================================================================
|
|
5448
|
+
// Scheduling / recovery / pruning
|
|
5449
|
+
// ==========================================================================
|
|
5450
|
+
scheduleNextAlarm() {
|
|
5451
|
+
const row = this.sql.exec(
|
|
5452
|
+
`SELECT next_run_at FROM jobs
|
|
5453
|
+
WHERE status = 'queued'
|
|
5454
|
+
ORDER BY (next_run_at IS NULL) ASC, next_run_at ASC
|
|
5455
|
+
LIMIT 1`
|
|
5456
|
+
).toArray()[0];
|
|
5457
|
+
if (!row) return;
|
|
5458
|
+
const now = Date.now();
|
|
5459
|
+
const target = row.next_run_at ? Math.max(now, new Date(row.next_run_at).getTime()) : now;
|
|
5460
|
+
this.state.storage.setAlarm(Math.max(target, now + 50));
|
|
5461
|
+
}
|
|
5462
|
+
recoverStuckRunning() {
|
|
5463
|
+
const cutoff = new Date(Date.now() - RUNNING_STALE_MS).toISOString();
|
|
5464
|
+
const stuck = this.sql.exec(
|
|
5465
|
+
`SELECT id, attempts, max_attempts FROM jobs
|
|
5466
|
+
WHERE status = 'running' AND started_at < ?`,
|
|
5467
|
+
cutoff
|
|
5468
|
+
).toArray();
|
|
5469
|
+
for (const r of stuck) {
|
|
5470
|
+
if (r.attempts >= r.max_attempts) {
|
|
5471
|
+
this.sql.exec(
|
|
5472
|
+
`UPDATE jobs
|
|
5473
|
+
SET status = 'failed',
|
|
5474
|
+
error = 'Isolate recycled or wall time exceeded',
|
|
5475
|
+
completed_at = ?,
|
|
5476
|
+
checkpoint = NULL,
|
|
5477
|
+
next_run_at = NULL
|
|
5478
|
+
WHERE id = ?`,
|
|
5479
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
5480
|
+
r.id
|
|
5481
|
+
);
|
|
5482
|
+
} else {
|
|
5483
|
+
this.sql.exec(
|
|
5484
|
+
`UPDATE jobs
|
|
5485
|
+
SET status = 'queued',
|
|
5486
|
+
started_at = NULL,
|
|
5487
|
+
next_run_at = ?,
|
|
5488
|
+
error = 'Previous attempt killed mid-run; retrying',
|
|
5489
|
+
progress = NULL,
|
|
5490
|
+
progress_message = NULL
|
|
5491
|
+
WHERE id = ?`,
|
|
5492
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
5493
|
+
r.id
|
|
5494
|
+
);
|
|
5495
|
+
}
|
|
5496
|
+
}
|
|
5497
|
+
}
|
|
5498
|
+
pruneExpired() {
|
|
5499
|
+
if (this.retentionMs === 0) return;
|
|
5500
|
+
const cutoff = new Date(Date.now() - this.retentionMs).toISOString();
|
|
5501
|
+
this.sql.exec(
|
|
5502
|
+
`DELETE FROM jobs
|
|
5503
|
+
WHERE status IN ('succeeded', 'failed', 'canceled')
|
|
5504
|
+
AND completed_at IS NOT NULL
|
|
5505
|
+
AND completed_at < ?`,
|
|
5506
|
+
cutoff
|
|
5507
|
+
);
|
|
5508
|
+
}
|
|
5509
|
+
// ==========================================================================
|
|
5510
|
+
// Read helpers
|
|
5511
|
+
// ==========================================================================
|
|
5512
|
+
getRecentJobs(limit) {
|
|
5513
|
+
const rows = this.sql.exec(
|
|
5514
|
+
`SELECT * FROM jobs
|
|
5515
|
+
ORDER BY (status IN ('queued','running')) DESC, enqueued_at DESC
|
|
5516
|
+
LIMIT ?`,
|
|
5517
|
+
limit
|
|
5518
|
+
).toArray();
|
|
5519
|
+
return rows.map(rowToJobView);
|
|
5520
|
+
}
|
|
5521
|
+
getJobById(id) {
|
|
5522
|
+
const row = this.sql.exec(`SELECT * FROM jobs WHERE id = ?`, id).toArray()[0];
|
|
5523
|
+
return row ? rowToJobView(row) : null;
|
|
5132
5524
|
}
|
|
5133
|
-
|
|
5134
|
-
|
|
5135
|
-
|
|
5136
|
-
const
|
|
5137
|
-
|
|
5138
|
-
|
|
5139
|
-
|
|
5140
|
-
|
|
5141
|
-
|
|
5142
|
-
|
|
5143
|
-
|
|
5525
|
+
};
|
|
5526
|
+
async function enqueueJob(namespace, roomId, type, payload, options) {
|
|
5527
|
+
const stub = namespace.get(namespace.idFromName(roomId));
|
|
5528
|
+
const res = await stub.fetch(
|
|
5529
|
+
new Request("https://job-room.internal/enqueue", {
|
|
5530
|
+
method: "POST",
|
|
5531
|
+
headers: { "Content-Type": "application/json" },
|
|
5532
|
+
body: JSON.stringify({
|
|
5533
|
+
type,
|
|
5534
|
+
payload,
|
|
5535
|
+
maxAttempts: options?.maxAttempts,
|
|
5536
|
+
enqueuedBy: options?.enqueuedBy
|
|
5537
|
+
})
|
|
5538
|
+
})
|
|
5539
|
+
);
|
|
5540
|
+
if (!res.ok) {
|
|
5541
|
+
const text = await res.text().catch(() => "");
|
|
5542
|
+
throw new Error(`enqueueJob failed: ${res.status} ${text}`);
|
|
5144
5543
|
}
|
|
5145
|
-
|
|
5544
|
+
const data = await res.json();
|
|
5545
|
+
if (!data.job?.id) throw new Error("enqueueJob: malformed response");
|
|
5546
|
+
return data.job.id;
|
|
5146
5547
|
}
|
|
5147
|
-
function
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5154
|
-
|
|
5155
|
-
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
|
|
5160
|
-
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5548
|
+
function rowToJobView(row) {
|
|
5549
|
+
return {
|
|
5550
|
+
id: row.id,
|
|
5551
|
+
type: row.type,
|
|
5552
|
+
status: row.status,
|
|
5553
|
+
payload: row.payload == null ? void 0 : JSON.parse(row.payload),
|
|
5554
|
+
result: row.result == null ? void 0 : JSON.parse(row.result),
|
|
5555
|
+
error: row.error ?? void 0,
|
|
5556
|
+
progress: row.progress ?? void 0,
|
|
5557
|
+
progressMessage: row.progress_message ?? void 0,
|
|
5558
|
+
attempts: row.attempts,
|
|
5559
|
+
maxAttempts: row.max_attempts,
|
|
5560
|
+
enqueuedAt: row.enqueued_at,
|
|
5561
|
+
startedAt: row.started_at,
|
|
5562
|
+
completedAt: row.completed_at,
|
|
5563
|
+
enqueuedBy: row.enqueued_by,
|
|
5564
|
+
resumeFrom: row.checkpoint == null ? void 0 : JSON.parse(row.checkpoint)
|
|
5565
|
+
};
|
|
5566
|
+
}
|
|
5567
|
+
function safeStringify(value) {
|
|
5568
|
+
try {
|
|
5569
|
+
return JSON.stringify(value);
|
|
5570
|
+
} catch {
|
|
5571
|
+
return JSON.stringify({ __unserializable__: String(value) });
|
|
5572
|
+
}
|
|
5168
5573
|
}
|
|
5169
5574
|
|
|
5170
5575
|
// src/server/rooms/do-manifest.ts
|
|
@@ -5247,7 +5652,6 @@ var RESERVED_BINDING_NAMES = /* @__PURE__ */ new Set([
|
|
|
5247
5652
|
"AUTH_WORKER_URL",
|
|
5248
5653
|
"APP_IDENTITY_TOKEN",
|
|
5249
5654
|
"APP_OWNER_JWT",
|
|
5250
|
-
"INTERNAL_STORAGE_HMAC_SECRET",
|
|
5251
5655
|
"USAGE_EVENTS"
|
|
5252
5656
|
]);
|
|
5253
5657
|
function validateBindingManifest(manifest) {
|
|
@@ -6079,6 +6483,215 @@ var RESERVED_COLLECTION_NAMES = new Set(
|
|
|
6079
6483
|
GLOBAL_DO_TYPES.flatMap((t) => t.schemas.map((s) => s.name))
|
|
6080
6484
|
);
|
|
6081
6485
|
|
|
6486
|
+
// src/server/auth/jwtVerifier.ts
|
|
6487
|
+
import { importSPKI, jwtVerify } from "jose";
|
|
6488
|
+
|
|
6489
|
+
// src/server/auth/utils.ts
|
|
6490
|
+
var BASE64_URL_REGEX = /^[A-Za-z0-9-_]+$/;
|
|
6491
|
+
function bufferToHex(bytes) {
|
|
6492
|
+
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
6493
|
+
let hex = "";
|
|
6494
|
+
for (const byte of view) {
|
|
6495
|
+
hex += byte.toString(16).padStart(2, "0");
|
|
6496
|
+
}
|
|
6497
|
+
return hex;
|
|
6498
|
+
}
|
|
6499
|
+
function normalizeArray(value) {
|
|
6500
|
+
if (value == null) return void 0;
|
|
6501
|
+
if (Array.isArray(value)) {
|
|
6502
|
+
return value.length ? value : void 0;
|
|
6503
|
+
}
|
|
6504
|
+
return [value];
|
|
6505
|
+
}
|
|
6506
|
+
function decodeJwtPayload(token) {
|
|
6507
|
+
if (!token) return void 0;
|
|
6508
|
+
const parts = token.split(".");
|
|
6509
|
+
if (parts.length < 2) return void 0;
|
|
6510
|
+
const payloadSegment = parts[1];
|
|
6511
|
+
if (!payloadSegment) return void 0;
|
|
6512
|
+
if (!BASE64_URL_REGEX.test(payloadSegment)) return void 0;
|
|
6513
|
+
try {
|
|
6514
|
+
const normalized = payloadSegment.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(payloadSegment.length / 4) * 4, "=");
|
|
6515
|
+
const bytes = base64ToUint8Array(normalized);
|
|
6516
|
+
const json = new TextDecoder().decode(bytes);
|
|
6517
|
+
const payload = JSON.parse(json);
|
|
6518
|
+
return {
|
|
6519
|
+
iss: payload.iss ?? null,
|
|
6520
|
+
aud: payload.aud ?? null,
|
|
6521
|
+
azp: payload?.azp ?? null,
|
|
6522
|
+
exp: payload.exp ?? null,
|
|
6523
|
+
iat: payload.iat ?? null
|
|
6524
|
+
};
|
|
6525
|
+
} catch {
|
|
6526
|
+
return void 0;
|
|
6527
|
+
}
|
|
6528
|
+
}
|
|
6529
|
+
function base64ToUint8Array(value) {
|
|
6530
|
+
if (typeof atob === "function") {
|
|
6531
|
+
const binary = atob(value);
|
|
6532
|
+
const out = new Uint8Array(binary.length);
|
|
6533
|
+
for (let i = 0; i < binary.length; i += 1) {
|
|
6534
|
+
out[i] = binary.charCodeAt(i);
|
|
6535
|
+
}
|
|
6536
|
+
return out;
|
|
6537
|
+
}
|
|
6538
|
+
if (typeof Buffer !== "undefined") {
|
|
6539
|
+
return Uint8Array.from(Buffer.from(value, "base64"));
|
|
6540
|
+
}
|
|
6541
|
+
throw new Error("No base64 decoder available in current environment");
|
|
6542
|
+
}
|
|
6543
|
+
|
|
6544
|
+
// src/server/auth/jwtVerifier.ts
|
|
6545
|
+
var DEFAULT_CLOCK_SKEW_MS = 5e3;
|
|
6546
|
+
function matchesAuthorizedParty(azp, patterns) {
|
|
6547
|
+
if (!azp) return true;
|
|
6548
|
+
for (const pattern of patterns) {
|
|
6549
|
+
if (pattern === azp) return true;
|
|
6550
|
+
if (pattern.includes("*")) {
|
|
6551
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
6552
|
+
const regexPattern = escaped.replace(/\*/g, "[^/]*");
|
|
6553
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
6554
|
+
if (regex.test(azp)) return true;
|
|
6555
|
+
if (pattern.includes("*.")) {
|
|
6556
|
+
const rootPattern = pattern.replace("*.", "");
|
|
6557
|
+
if (rootPattern === azp) return true;
|
|
6558
|
+
}
|
|
6559
|
+
}
|
|
6560
|
+
}
|
|
6561
|
+
return false;
|
|
6562
|
+
}
|
|
6563
|
+
var keyCache = /* @__PURE__ */ new Map();
|
|
6564
|
+
async function getPublicKey(pem) {
|
|
6565
|
+
const cached = keyCache.get(pem);
|
|
6566
|
+
if (cached) return cached;
|
|
6567
|
+
const normalized = pem.replace(/\\n/g, "\n");
|
|
6568
|
+
const key = await importSPKI(normalized, "ES256");
|
|
6569
|
+
keyCache.set(pem, key);
|
|
6570
|
+
return key;
|
|
6571
|
+
}
|
|
6572
|
+
async function verifyJwt(config, token) {
|
|
6573
|
+
if (!token) {
|
|
6574
|
+
return { result: null };
|
|
6575
|
+
}
|
|
6576
|
+
try {
|
|
6577
|
+
const publicKey = await getPublicKey(config.publicKey);
|
|
6578
|
+
const audience = normalizeArray(config.audience);
|
|
6579
|
+
const clockTolerance = Math.floor(
|
|
6580
|
+
(config.clockSkewMs ?? DEFAULT_CLOCK_SKEW_MS) / 1e3
|
|
6581
|
+
);
|
|
6582
|
+
const { payload } = await jwtVerify(token, publicKey, {
|
|
6583
|
+
issuer: config.issuer,
|
|
6584
|
+
audience: audience && audience.length === 1 ? audience[0] : audience,
|
|
6585
|
+
clockTolerance
|
|
6586
|
+
});
|
|
6587
|
+
const claims = payload;
|
|
6588
|
+
if (!claims.sub) {
|
|
6589
|
+
throw new Error("JWT verification succeeded but subject claim is missing");
|
|
6590
|
+
}
|
|
6591
|
+
const authorizedParties = normalizeArray(config.authorizedParties);
|
|
6592
|
+
if (authorizedParties) {
|
|
6593
|
+
const azp = claims.azp;
|
|
6594
|
+
if (!matchesAuthorizedParty(azp, authorizedParties)) {
|
|
6595
|
+
throw new Error(
|
|
6596
|
+
`Authorized party '${azp}' does not match any allowed pattern`
|
|
6597
|
+
);
|
|
6598
|
+
}
|
|
6599
|
+
}
|
|
6600
|
+
return {
|
|
6601
|
+
result: {
|
|
6602
|
+
userId: claims.sub,
|
|
6603
|
+
claims
|
|
6604
|
+
}
|
|
6605
|
+
};
|
|
6606
|
+
} catch (error) {
|
|
6607
|
+
return {
|
|
6608
|
+
result: null,
|
|
6609
|
+
error,
|
|
6610
|
+
debug: decodeJwtPayload(token)
|
|
6611
|
+
};
|
|
6612
|
+
}
|
|
6613
|
+
}
|
|
6614
|
+
|
|
6615
|
+
// src/server/auth/internalAuth.ts
|
|
6616
|
+
var nodeCryptoPromise = null;
|
|
6617
|
+
async function loadNodeCrypto() {
|
|
6618
|
+
if (!nodeCryptoPromise) {
|
|
6619
|
+
nodeCryptoPromise = import("crypto").then((module) => module).catch(() => null);
|
|
6620
|
+
}
|
|
6621
|
+
return nodeCryptoPromise;
|
|
6622
|
+
}
|
|
6623
|
+
function hasSubtleCrypto() {
|
|
6624
|
+
return typeof globalThis.crypto?.subtle?.importKey === "function";
|
|
6625
|
+
}
|
|
6626
|
+
async function computeHmacHex(secret, payload) {
|
|
6627
|
+
if (hasSubtleCrypto()) {
|
|
6628
|
+
const encoder = new TextEncoder();
|
|
6629
|
+
const key = await crypto.subtle.importKey(
|
|
6630
|
+
"raw",
|
|
6631
|
+
encoder.encode(secret),
|
|
6632
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
6633
|
+
false,
|
|
6634
|
+
["sign"]
|
|
6635
|
+
);
|
|
6636
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
|
|
6637
|
+
return bufferToHex(signature);
|
|
6638
|
+
}
|
|
6639
|
+
const nodeCrypto = await loadNodeCrypto();
|
|
6640
|
+
if (!nodeCrypto) {
|
|
6641
|
+
throw new Error("Crypto implementation unavailable for HMAC computation");
|
|
6642
|
+
}
|
|
6643
|
+
return nodeCrypto.createHmac("sha256", secret).update(payload).digest("hex");
|
|
6644
|
+
}
|
|
6645
|
+
async function timingSafeEqualHex(a, b) {
|
|
6646
|
+
if (a.length !== b.length) {
|
|
6647
|
+
return false;
|
|
6648
|
+
}
|
|
6649
|
+
const nodeCrypto = await loadNodeCrypto();
|
|
6650
|
+
if (nodeCrypto?.timingSafeEqual) {
|
|
6651
|
+
const aBuf = Buffer.from(a, "hex");
|
|
6652
|
+
const bBuf = Buffer.from(b, "hex");
|
|
6653
|
+
try {
|
|
6654
|
+
return nodeCrypto.timingSafeEqual(aBuf, bBuf);
|
|
6655
|
+
} catch {
|
|
6656
|
+
return false;
|
|
6657
|
+
}
|
|
6658
|
+
}
|
|
6659
|
+
let diff = 0;
|
|
6660
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
6661
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
6662
|
+
}
|
|
6663
|
+
return diff === 0;
|
|
6664
|
+
}
|
|
6665
|
+
|
|
6666
|
+
// src/server/auth/betterAuth.ts
|
|
6667
|
+
import { betterAuth } from "better-auth";
|
|
6668
|
+
import { organization, twoFactor } from "better-auth/plugins";
|
|
6669
|
+
function createDeepSpaceAuth(config) {
|
|
6670
|
+
const socialProviders = {};
|
|
6671
|
+
if (config.google) {
|
|
6672
|
+
socialProviders.google = config.google;
|
|
6673
|
+
}
|
|
6674
|
+
if (config.github) {
|
|
6675
|
+
socialProviders.github = config.github;
|
|
6676
|
+
}
|
|
6677
|
+
return betterAuth({
|
|
6678
|
+
database: config.database,
|
|
6679
|
+
baseURL: config.baseURL,
|
|
6680
|
+
secret: config.secret,
|
|
6681
|
+
emailAndPassword: {
|
|
6682
|
+
enabled: config.emailAndPassword ?? true
|
|
6683
|
+
},
|
|
6684
|
+
socialProviders,
|
|
6685
|
+
trustedOrigins: config.trustedOrigins ?? [
|
|
6686
|
+
"https://deep.space",
|
|
6687
|
+
"https://*.deep.space",
|
|
6688
|
+
"https://*.app.space",
|
|
6689
|
+
"http://localhost:*"
|
|
6690
|
+
],
|
|
6691
|
+
plugins: [organization(), twoFactor()]
|
|
6692
|
+
});
|
|
6693
|
+
}
|
|
6694
|
+
|
|
6082
6695
|
// src/shared/subscription.ts
|
|
6083
6696
|
var ENTITLED_STATUSES = /* @__PURE__ */ new Set([
|
|
6084
6697
|
"active",
|
|
@@ -6243,11 +6856,11 @@ export {
|
|
|
6243
6856
|
CronRoom,
|
|
6244
6857
|
DEFAULT_CONTEXT_CONFIG,
|
|
6245
6858
|
DEFAULT_DO_MANIFEST,
|
|
6246
|
-
DEFAULT_MAX_SKEW_MS,
|
|
6247
6859
|
DIRECTORY_SCHEMAS,
|
|
6248
6860
|
GLOBAL_DO_TYPES,
|
|
6249
6861
|
GLOBAL_DO_TYPE_NAMES,
|
|
6250
6862
|
GameRoom,
|
|
6863
|
+
JobRoom,
|
|
6251
6864
|
MESSAGES_SCHEMA,
|
|
6252
6865
|
PresenceRoom,
|
|
6253
6866
|
REACTIONS_SCHEMA,
|
|
@@ -6274,7 +6887,6 @@ export {
|
|
|
6274
6887
|
broadcastChange,
|
|
6275
6888
|
broadcastYjsUpdate,
|
|
6276
6889
|
buildCronContext,
|
|
6277
|
-
buildInternalPayload,
|
|
6278
6890
|
buildTableSelect,
|
|
6279
6891
|
buildUiParts,
|
|
6280
6892
|
canCreate,
|
|
@@ -6298,6 +6910,7 @@ export {
|
|
|
6298
6910
|
decodeJwtPayload,
|
|
6299
6911
|
deleteChatCascade,
|
|
6300
6912
|
deleteRecord,
|
|
6913
|
+
enqueueJob,
|
|
6301
6914
|
executeQuery,
|
|
6302
6915
|
getAllUsers,
|
|
6303
6916
|
getChat,
|
|
@@ -6344,7 +6957,6 @@ export {
|
|
|
6344
6957
|
rowToData,
|
|
6345
6958
|
runMigrations,
|
|
6346
6959
|
saveYjsDoc,
|
|
6347
|
-
signInternalPayload,
|
|
6348
6960
|
timingSafeEqualHex,
|
|
6349
6961
|
totalChars,
|
|
6350
6962
|
truncateOldToolResults,
|
|
@@ -6354,7 +6966,6 @@ export {
|
|
|
6354
6966
|
validateAppName,
|
|
6355
6967
|
validateBindingManifest,
|
|
6356
6968
|
validateDoManifest,
|
|
6357
|
-
verifyInternalSignature,
|
|
6358
6969
|
verifyJwt,
|
|
6359
6970
|
workspaceAccountsSchema,
|
|
6360
6971
|
workspaceContentSharesSchema,
|