deepspace 0.18.0 → 0.19.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/worker.js CHANGED
@@ -1,4 +1,63 @@
1
+ // src/shared/room-identity-headers.ts
2
+ var BASE64URL = /^[A-Za-z0-9_-]*$/;
3
+ var IDENTITY_QUERY_PARAMS = ["userId", "userName", "userEmail", "userImageUrl", "role"];
4
+ var IDENTITY_HEADERS = [
5
+ "x-user-id",
6
+ "x-user-name",
7
+ "x-user-email",
8
+ "x-user-image-url",
9
+ "x-user-role"
10
+ ];
11
+ function encodeRoomIdentityHeader(value) {
12
+ const bytes = new TextEncoder().encode(value);
13
+ let binary = "";
14
+ for (const byte of bytes) binary += String.fromCharCode(byte);
15
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
16
+ }
17
+ function decodeRoomIdentityHeader(value) {
18
+ if (value === null || !BASE64URL.test(value) || value.length % 4 === 1) return void 0;
19
+ const base64 = value.replaceAll("-", "+").replaceAll("_", "/").padEnd(value.length + (4 - value.length % 4) % 4, "=");
20
+ try {
21
+ const binary = atob(base64);
22
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
23
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
24
+ } catch {
25
+ return void 0;
26
+ }
27
+ }
28
+ function authenticatedRoomRequest(request, auth, identity) {
29
+ const url = new URL(request.url);
30
+ url.searchParams.delete("token");
31
+ for (const key of IDENTITY_QUERY_PARAMS) url.searchParams.delete(key);
32
+ const forwarded = new Request(url.toString(), request);
33
+ for (const header of IDENTITY_HEADERS) forwarded.headers.delete(header);
34
+ if (!auth) return forwarded;
35
+ forwarded.headers.set("x-user-id", encodeRoomIdentityHeader(auth.userId));
36
+ if (auth.claims.name) {
37
+ forwarded.headers.set("x-user-name", encodeRoomIdentityHeader(auth.claims.name));
38
+ }
39
+ if (auth.claims.email) {
40
+ forwarded.headers.set("x-user-email", encodeRoomIdentityHeader(auth.claims.email));
41
+ }
42
+ if (auth.claims.image) {
43
+ forwarded.headers.set("x-user-image-url", encodeRoomIdentityHeader(auth.claims.image));
44
+ }
45
+ if (identity?.role) {
46
+ forwarded.headers.set("x-user-role", encodeRoomIdentityHeader(identity.role));
47
+ }
48
+ return forwarded;
49
+ }
50
+
1
51
  // src/server/rooms/base-room.ts
52
+ function connectionAttachmentFromRequest(request) {
53
+ return {
54
+ userId: decodeRoomIdentityHeader(request.headers.get("x-user-id")) || `anon-${crypto.randomUUID()}`,
55
+ userName: decodeRoomIdentityHeader(request.headers.get("x-user-name")) || "Anonymous",
56
+ userEmail: decodeRoomIdentityHeader(request.headers.get("x-user-email")) || "",
57
+ userImageUrl: decodeRoomIdentityHeader(request.headers.get("x-user-image-url")),
58
+ role: decodeRoomIdentityHeader(request.headers.get("x-user-role"))
59
+ };
60
+ }
2
61
  var BaseRoom = class {
3
62
  state;
4
63
  env;
@@ -7,9 +66,7 @@ var BaseRoom = class {
7
66
  this.state = state;
8
67
  this.env = env ?? {};
9
68
  this.sql = state.storage.sql;
10
- this.state.setWebSocketAutoResponse(
11
- new WebSocketRequestResponsePair("ping", "pong")
12
- );
69
+ this.state.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));
13
70
  }
14
71
  // ==========================================================================
15
72
  // HTTP Entry Point
@@ -59,22 +116,11 @@ var BaseRoom = class {
59
116
  // ==========================================================================
60
117
  // WebSocket Connection
61
118
  // ==========================================================================
62
- async handleWebSocketUpgrade(_request, url) {
63
- const userId = url.searchParams.get("userId") || void 0;
64
- const userName = url.searchParams.get("userName") || "Anonymous";
65
- const userEmail = url.searchParams.get("userEmail") || "";
66
- const userImageUrl = url.searchParams.get("userImageUrl") || void 0;
119
+ async handleWebSocketUpgrade(request, _url) {
67
120
  const pair = new WebSocketPair();
68
121
  const [client, server] = Object.values(pair);
69
122
  this.state.acceptWebSocket(server);
70
- const role = url.searchParams.get("role") || void 0;
71
- const attachment = {
72
- userId: userId ?? `anon-${crypto.randomUUID()}`,
73
- userName,
74
- userEmail,
75
- userImageUrl,
76
- role
77
- };
123
+ const attachment = connectionAttachmentFromRequest(request);
78
124
  const augmented = await this.onConnect(server, attachment) ?? attachment;
79
125
  server.serializeAttachment(augmented);
80
126
  return new Response(null, { status: 101, webSocket: client });
@@ -374,7 +420,7 @@ function dataToColumnValues(data, columns) {
374
420
  const values = {};
375
421
  for (const col of columns) {
376
422
  if (col.expression || col.readonly) continue;
377
- const val = data[col.id] ?? data[col.name];
423
+ const val = data[col.name];
378
424
  if (val !== void 0) {
379
425
  values[col.id] = coerceValue(val, col.storage, col.interpretation);
380
426
  }
@@ -523,6 +569,15 @@ function checkFieldPermissions(schema, role, newData, existingData) {
523
569
  }
524
570
  return null;
525
571
  }
572
+ function checkUnclaimedOwnerTransition(schema, role, nextData, userId) {
573
+ const permissions = getRolePermissions(schema, role);
574
+ if (permissions.update !== "unclaimed-or-own" || !schema.ownerField) return null;
575
+ const nextOwner = nextData[schema.ownerField];
576
+ if (nextOwner === void 0 || nextOwner === null || nextOwner === "" || nextOwner === userId) {
577
+ return null;
578
+ }
579
+ return `Role '${role}' can set '${schema.ownerField}' only to their own user id or leave it unclaimed`;
580
+ }
526
581
  var SYSTEM_MANAGED_COLUMNS = /* @__PURE__ */ new Set([
527
582
  "email",
528
583
  "name",
@@ -566,7 +621,7 @@ function lintSchema(schema) {
566
621
  if (schema.ownerField) {
567
622
  const col = schema.columns.find((c) => c.name === schema.ownerField);
568
623
  const spoofableRoles = Object.entries(schema.permissions).filter(
569
- ([role, permissions]) => role !== "owner" && role !== "admin" && permissions.create === true
624
+ ([role, permissions]) => role !== "owner" && role !== "admin" && permissions.create === true && permissions.update !== "unclaimed-or-own"
570
625
  ).map(([role]) => role);
571
626
  if (col && !col.userBound && spoofableRoles.length > 0) {
572
627
  warnings.push(
@@ -1193,6 +1248,14 @@ function putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRb
1193
1248
  return { success: false, error: `Schema not registered for collection: ${collection}` };
1194
1249
  }
1195
1250
  const columns = getResolvedColumns(schema);
1251
+ for (const col of columns) {
1252
+ if (col.id !== col.name && Object.hasOwn(data, col.id)) {
1253
+ return {
1254
+ success: false,
1255
+ error: `FIELD ERROR: physical column id '${col.id}' is not a writable field; use '${col.name}'`
1256
+ };
1257
+ }
1258
+ }
1196
1259
  const existing = getRecord(ctx.sql, collection, recordId, schema);
1197
1260
  const isUpdate = existing !== null;
1198
1261
  const mergedData = isUpdate ? { ...existing.data, ...data } : { ...data };
@@ -1267,6 +1330,10 @@ function putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRb
1267
1330
  };
1268
1331
  }
1269
1332
  }
1333
+ const ownerError = checkUnclaimedOwnerTransition(schema, userRole, finalData, userId);
1334
+ if (ownerError) {
1335
+ return { success: false, error: `FIELD ERROR: ${ownerError}` };
1336
+ }
1270
1337
  }
1271
1338
  const colValues = dataToColumnValues(finalData, columns);
1272
1339
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -1519,11 +1586,17 @@ function handleYjsJoin(ctx, ws, attachment, payload) {
1519
1586
  }
1520
1587
  function handleYjsLeave(ctx, ws, attachment, payload) {
1521
1588
  const { collection, recordId, fieldName } = payload;
1589
+ const docKey = getYjsDocKey(collection, recordId, fieldName);
1590
+ const clientId = attachment.awarenessClientIds?.[docKey];
1591
+ if (clientId !== void 0) {
1592
+ broadcastAwarenessClientRemoval(ctx, docKey, clientId);
1593
+ delete attachment.awarenessClientIds[docKey];
1594
+ }
1522
1595
  attachment.yjsSubscriptions = attachment.yjsSubscriptions.filter(
1523
1596
  (s) => !(s.collection === collection && s.recordId === recordId && s.fieldName === fieldName)
1524
1597
  );
1525
1598
  ws.serializeAttachment(attachment);
1526
- unloadYjsDocIfUnused(ctx, getYjsDocKey(collection, recordId, fieldName), ws);
1599
+ unloadYjsDocIfUnused(ctx, docKey, ws);
1527
1600
  }
1528
1601
  function handleYjsBinaryMessage(ctx, ws, attachment, data) {
1529
1602
  const decoder = createDecoder(data);
@@ -1535,12 +1608,13 @@ function handleYjsBinaryMessage(ctx, ws, attachment, data) {
1535
1608
  const parsed2 = parseYjsDocKey(docKey2);
1536
1609
  if (!parsed2) return;
1537
1610
  if (!hasYjsSubscription(attachment, parsed2)) return;
1538
- if (attachment.awarenessClientId == null) {
1611
+ if (attachment.awarenessClientIds?.[docKey2] === void 0) {
1539
1612
  try {
1540
1613
  const pd = createDecoder(payload);
1541
1614
  const count = readVarUint(pd);
1542
1615
  if (count > 0) {
1543
- attachment.awarenessClientId = readVarUint(pd);
1616
+ attachment.awarenessClientIds ??= {};
1617
+ attachment.awarenessClientIds[docKey2] = readVarUint(pd);
1544
1618
  ws.serializeAttachment(attachment);
1545
1619
  }
1546
1620
  } catch {
@@ -1622,18 +1696,21 @@ function broadcastAwareness(ctx, docKey, payload, excludeWs) {
1622
1696
  }
1623
1697
  }
1624
1698
  function broadcastAwarenessRemoval(ctx, attachment) {
1625
- const clientId = attachment.awarenessClientId;
1626
- if (clientId == null || attachment.yjsSubscriptions.length === 0) return;
1699
+ if (!attachment.awarenessClientIds) return;
1700
+ for (const sub of attachment.yjsSubscriptions) {
1701
+ const docKey = getYjsDocKey(sub.collection, sub.recordId, sub.fieldName);
1702
+ const clientId = attachment.awarenessClientIds[docKey];
1703
+ if (clientId !== void 0) broadcastAwarenessClientRemoval(ctx, docKey, clientId);
1704
+ }
1705
+ }
1706
+ function broadcastAwarenessClientRemoval(ctx, docKey, clientId) {
1627
1707
  const payloadEncoder = createEncoder();
1628
1708
  writeVarUint(payloadEncoder, 1);
1629
1709
  writeVarUint(payloadEncoder, clientId);
1630
1710
  writeVarUint(payloadEncoder, 16777215);
1631
1711
  writeVarUint8Array(payloadEncoder, new TextEncoder().encode("null"));
1632
1712
  const removalPayload = toUint8Array(payloadEncoder);
1633
- for (const sub of attachment.yjsSubscriptions) {
1634
- const docKey = getYjsDocKey(sub.collection, sub.recordId, sub.fieldName);
1635
- broadcastAwareness(ctx, docKey, removalPayload, null);
1636
- }
1713
+ broadcastAwareness(ctx, docKey, removalPayload, null);
1637
1714
  }
1638
1715
  function handleYjsDisconnect(ctx, ws, attachment) {
1639
1716
  broadcastAwarenessRemoval(ctx, attachment);
@@ -2005,10 +2082,6 @@ async function handleSetRole(ctx, ws, attachment, payload) {
2005
2082
  now,
2006
2083
  payload.userId
2007
2084
  );
2008
- if (attachment.userId === payload.userId) {
2009
- attachment.role = payload.role;
2010
- ws.serializeAttachment(attachment);
2011
- }
2012
2085
  const userRecords = getAllUserRecords(ctx.sql, ctx.schemaRegistry);
2013
2086
  const users = userRecords.map((r) => ({
2014
2087
  id: r.recordId,
@@ -2017,9 +2090,12 @@ async function handleSetRole(ctx, ws, attachment, payload) {
2017
2090
  for (const otherWs of ctx.state.getWebSockets()) {
2018
2091
  const otherAttachment = otherWs.deserializeAttachment();
2019
2092
  if (!otherAttachment) continue;
2020
- if (otherAttachment.userId === payload.userId && otherWs !== ws) {
2021
- otherAttachment.role = payload.role;
2022
- otherWs.serializeAttachment(otherAttachment);
2093
+ if (otherAttachment.userId === payload.userId) {
2094
+ try {
2095
+ otherWs.close(1008, "role-changed");
2096
+ } catch {
2097
+ }
2098
+ continue;
2023
2099
  }
2024
2100
  if (otherAttachment.role === "admin") {
2025
2101
  ctx.send(otherWs, { type: MSG.USER_LIST, payload: { users } });
@@ -3551,6 +3627,13 @@ var CanvasRoom = class extends BaseRoom {
3551
3627
  };
3552
3628
 
3553
3629
  // src/server/rooms/presence-room.ts
3630
+ var MAX_PRESENCE_STATE_BYTES = 16 * 1024;
3631
+ function isPlainObject(value) {
3632
+ return value !== null && typeof value === "object" && !Array.isArray(value);
3633
+ }
3634
+ function stateSize(state) {
3635
+ return new TextEncoder().encode(JSON.stringify(state)).byteLength;
3636
+ }
3554
3637
  var PresenceRoom = class extends BaseRoom {
3555
3638
  peers = /* @__PURE__ */ new Map();
3556
3639
  peerSockets = /* @__PURE__ */ new Map();
@@ -3572,8 +3655,6 @@ var PresenceRoom = class extends BaseRoom {
3572
3655
  const recovered = {
3573
3656
  userId: att.userId,
3574
3657
  userName: att.userName,
3575
- userEmail: att.userEmail,
3576
- userImageUrl: att.userImageUrl,
3577
3658
  joinedAt,
3578
3659
  state: {}
3579
3660
  };
@@ -3590,8 +3671,6 @@ var PresenceRoom = class extends BaseRoom {
3590
3671
  const peer = {
3591
3672
  userId: user.userId,
3592
3673
  userName: user.userName,
3593
- userEmail: user.userEmail,
3594
- userImageUrl: user.userImageUrl,
3595
3674
  joinedAt: now,
3596
3675
  state: {}
3597
3676
  };
@@ -3619,15 +3698,28 @@ var PresenceRoom = class extends BaseRoom {
3619
3698
  peer = {
3620
3699
  userId: user.userId,
3621
3700
  userName: user.userName,
3622
- userEmail: user.userEmail,
3623
- userImageUrl: user.userImageUrl,
3624
3701
  joinedAt,
3625
3702
  state: {}
3626
3703
  };
3627
3704
  this.peers.set(user.userId, peer);
3628
3705
  this.peerSockets.set(user.userId, ws);
3629
3706
  }
3630
- peer.state = { ...peer.state, ...payload };
3707
+ if (!isPlainObject(payload)) {
3708
+ this.sendTo(ws, {
3709
+ type: MSG.ERROR,
3710
+ payload: { error: "Presence state must be an object" }
3711
+ });
3712
+ return;
3713
+ }
3714
+ const nextState = { ...peer.state, ...payload };
3715
+ if (stateSize(nextState) > MAX_PRESENCE_STATE_BYTES) {
3716
+ this.sendTo(ws, {
3717
+ type: MSG.ERROR,
3718
+ payload: { error: "Presence state exceeds 16 KiB" }
3719
+ });
3720
+ return;
3721
+ }
3722
+ peer.state = nextState;
3631
3723
  this.peers.set(user.userId, peer);
3632
3724
  this.broadcast(
3633
3725
  {
@@ -3639,7 +3731,10 @@ var PresenceRoom = class extends BaseRoom {
3639
3731
  break;
3640
3732
  }
3641
3733
  default:
3642
- this.sendTo(ws, { type: MSG.ERROR, payload: { error: `Unknown presence message type: ${type}` } });
3734
+ this.sendTo(ws, {
3735
+ type: MSG.ERROR,
3736
+ payload: { error: `Unknown presence message type: ${type}` }
3737
+ });
3643
3738
  }
3644
3739
  }
3645
3740
  onDisconnect(ws, user) {
@@ -4083,6 +4178,7 @@ var JobRoom = class extends BaseRoom {
4083
4178
  retentionMs;
4084
4179
  snapshotLimit;
4085
4180
  retryBackoffMs;
4181
+ authorizeWrite;
4086
4182
  /** In-flight jobs in this isolate; lets same-isolate JOB_CANCEL fire the signal. */
4087
4183
  inFlight = /* @__PURE__ */ new Map();
4088
4184
  /** Set by `ctx.continue` inside `executeJob`; reset before every run. */
@@ -4093,6 +4189,7 @@ var JobRoom = class extends BaseRoom {
4093
4189
  this.retentionMs = Math.max(0, config.retentionMs ?? DEFAULT_RETENTION_MS);
4094
4190
  this.snapshotLimit = Math.max(1, config.snapshotLimit ?? DEFAULT_SNAPSHOT_LIMIT);
4095
4191
  this.retryBackoffMs = Math.max(0, config.retryBackoffMs ?? 1e3);
4192
+ this.authorizeWrite = config.authorizeWrite ?? ((user) => user.role === ROLES.MEMBER || user.role === ROLES.ADMIN);
4096
4193
  }
4097
4194
  ensureInitialized() {
4098
4195
  if (this.initialized) return;
@@ -4133,14 +4230,31 @@ var JobRoom = class extends BaseRoom {
4133
4230
  this.ensureInitialized();
4134
4231
  return super.fetch(request);
4135
4232
  }
4136
- onConnect(ws, user) {
4233
+ async onConnect(ws, user) {
4137
4234
  this.ensureInitialized();
4235
+ const canWrite = await this.authorizeWrite(user);
4236
+ this.sendTo(ws, { type: MSG.AUTH, payload: { canWrite } });
4138
4237
  this.sendTo(ws, serverBuild.jobSnapshot(this.getRecentJobs(this.snapshotLimit)));
4139
- return user;
4238
+ return { ...user, canWrite };
4140
4239
  }
4141
4240
  async onMessage(ws, user, message) {
4142
4241
  this.ensureInitialized();
4143
4242
  const { type, payload } = message;
4243
+ if (type === MSG.JOB_ENQUEUE || type === MSG.JOB_CANCEL || type === MSG.JOB_RETRY) {
4244
+ const attachment = user;
4245
+ const canWrite = await this.authorizeWrite(user);
4246
+ if (attachment.canWrite !== canWrite) {
4247
+ attachment.canWrite = canWrite;
4248
+ ws.serializeAttachment(attachment);
4249
+ this.sendTo(ws, { type: MSG.AUTH, payload: { canWrite } });
4250
+ } else if (!canWrite) {
4251
+ this.sendTo(ws, { type: MSG.AUTH, payload: { canWrite: false } });
4252
+ }
4253
+ if (!canWrite) {
4254
+ this.sendTo(ws, serverBuild.error("Write access denied: job queue requires member access"));
4255
+ return;
4256
+ }
4257
+ }
4144
4258
  switch (type) {
4145
4259
  case MSG.JOB_ENQUEUE: {
4146
4260
  const requestId = String(payload.requestId ?? "");
@@ -5606,6 +5720,7 @@ var RESERVED_COLLECTION_NAMES = new Set(
5606
5720
  var APP_MIGRATION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/;
5607
5721
  var MAX_APP_MIGRATION_IDS = 256;
5608
5722
  var WORKER_OWNED_NOT_FOUND_MIGRATION_ID = "2026-08-worker-owned-not-found";
5723
+ var SECURE_ROOM_BOUNDARIES_MIGRATION_ID = "2026-08-secure-room-boundaries";
5609
5724
  function validateAppMigrationIds(value) {
5610
5725
  if (!Array.isArray(value)) {
5611
5726
  return { valid: false, reason: "app migrations must be a JSON string array" };
@@ -6638,6 +6753,33 @@ function buildCronContext(env, ownerUserId, roomId = "default") {
6638
6753
  return { records, integrations, ownerUserId };
6639
6754
  }
6640
6755
 
6756
+ // src/server/utils/app-role.ts
6757
+ async function resolveAppRole(env, userId) {
6758
+ if (userId === env.OWNER_USER_ID) return "admin";
6759
+ const stub = env.RECORD_ROOMS.get(env.RECORD_ROOMS.idFromName(`app:${env.DEEPSPACE_APP_ID}`));
6760
+ try {
6761
+ const res = await stub.fetch(
6762
+ new Request("https://internal/api/tools/execute", {
6763
+ method: "POST",
6764
+ headers: {
6765
+ "Content-Type": "application/json",
6766
+ "X-User-Id": env.OWNER_USER_ID,
6767
+ "X-App-Action": "true"
6768
+ },
6769
+ body: JSON.stringify({
6770
+ tool: "records.get",
6771
+ params: { collection: "users", recordId: userId }
6772
+ })
6773
+ })
6774
+ );
6775
+ const json = await res.json();
6776
+ const role = json.data?.record?.data?.role;
6777
+ return json.success && (role === "admin" || role === "member") ? role : "viewer";
6778
+ } catch {
6779
+ return "viewer";
6780
+ }
6781
+ }
6782
+
6641
6783
  // src/server/utils/ai.ts
6642
6784
  import { createAnthropic } from "@ai-sdk/anthropic";
6643
6785
  import { createOpenAI } from "@ai-sdk/openai";
@@ -9137,6 +9279,7 @@ export {
9137
9279
  RecordRoom,
9138
9280
  RegistryClientError,
9139
9281
  SDK_RUN_WORKER_FIRST,
9282
+ SECURE_ROOM_BOUNDARIES_MIGRATION_ID,
9140
9283
  STRICT_APP_ID_RE,
9141
9284
  SYSTEM_COLLECTIONS,
9142
9285
  SYSTEM_MANAGED_COLUMNS,
@@ -9155,6 +9298,7 @@ export {
9155
9298
  applyAiToolDefaults,
9156
9299
  applySlidingWindow,
9157
9300
  authWorkerFetch,
9301
+ authenticatedRoomRequest,
9158
9302
  bindingManifestFromOutputConfig,
9159
9303
  buildCronContext,
9160
9304
  buildTableSelect,
@@ -9167,6 +9311,7 @@ export {
9167
9311
  capToolResultSize,
9168
9312
  captureScreenshot,
9169
9313
  checkFieldPermissions,
9314
+ checkUnclaimedOwnerTransition,
9170
9315
  clientBuild,
9171
9316
  coerceValue,
9172
9317
  collectionTableName,
@@ -9231,6 +9376,7 @@ export {
9231
9376
  registryErrorJson,
9232
9377
  registryStub,
9233
9378
  resolveAppName,
9379
+ resolveAppRole,
9234
9380
  resolveColumn,
9235
9381
  resolveCompatibilityFlags,
9236
9382
  resolveDeepSpaceAgentModel,