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/server.js CHANGED
@@ -915,6 +915,33 @@ function buildCronContext(env, ownerUserId, roomId = "default") {
915
915
  return { records, integrations, ownerUserId };
916
916
  }
917
917
 
918
+ // src/server/utils/app-role.ts
919
+ async function resolveAppRole(env, userId) {
920
+ if (userId === env.OWNER_USER_ID) return "admin";
921
+ const stub = env.RECORD_ROOMS.get(env.RECORD_ROOMS.idFromName(`app:${env.DEEPSPACE_APP_ID}`));
922
+ try {
923
+ const res = await stub.fetch(
924
+ new Request("https://internal/api/tools/execute", {
925
+ method: "POST",
926
+ headers: {
927
+ "Content-Type": "application/json",
928
+ "X-User-Id": env.OWNER_USER_ID,
929
+ "X-App-Action": "true"
930
+ },
931
+ body: JSON.stringify({
932
+ tool: "records.get",
933
+ params: { collection: "users", recordId: userId }
934
+ })
935
+ })
936
+ );
937
+ const json = await res.json();
938
+ const role = json.data?.record?.data?.role;
939
+ return json.success && (role === "admin" || role === "member") ? role : "viewer";
940
+ } catch {
941
+ return "viewer";
942
+ }
943
+ }
944
+
918
945
  // src/server/utils/ai.ts
919
946
  import { createAnthropic } from "@ai-sdk/anthropic";
920
947
  import { createOpenAI } from "@ai-sdk/openai";
@@ -1930,7 +1957,30 @@ function splitStatements(sql) {
1930
1957
  return sql.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
1931
1958
  }
1932
1959
 
1960
+ // src/shared/room-identity-headers.ts
1961
+ var BASE64URL = /^[A-Za-z0-9_-]*$/;
1962
+ function decodeRoomIdentityHeader(value) {
1963
+ if (value === null || !BASE64URL.test(value) || value.length % 4 === 1) return void 0;
1964
+ const base64 = value.replaceAll("-", "+").replaceAll("_", "/").padEnd(value.length + (4 - value.length % 4) % 4, "=");
1965
+ try {
1966
+ const binary = atob(base64);
1967
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
1968
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
1969
+ } catch {
1970
+ return void 0;
1971
+ }
1972
+ }
1973
+
1933
1974
  // src/server/rooms/base-room.ts
1975
+ function connectionAttachmentFromRequest(request) {
1976
+ return {
1977
+ userId: decodeRoomIdentityHeader(request.headers.get("x-user-id")) || `anon-${crypto.randomUUID()}`,
1978
+ userName: decodeRoomIdentityHeader(request.headers.get("x-user-name")) || "Anonymous",
1979
+ userEmail: decodeRoomIdentityHeader(request.headers.get("x-user-email")) || "",
1980
+ userImageUrl: decodeRoomIdentityHeader(request.headers.get("x-user-image-url")),
1981
+ role: decodeRoomIdentityHeader(request.headers.get("x-user-role"))
1982
+ };
1983
+ }
1934
1984
  var BaseRoom = class {
1935
1985
  state;
1936
1986
  env;
@@ -1939,9 +1989,7 @@ var BaseRoom = class {
1939
1989
  this.state = state;
1940
1990
  this.env = env ?? {};
1941
1991
  this.sql = state.storage.sql;
1942
- this.state.setWebSocketAutoResponse(
1943
- new WebSocketRequestResponsePair("ping", "pong")
1944
- );
1992
+ this.state.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));
1945
1993
  }
1946
1994
  // ==========================================================================
1947
1995
  // HTTP Entry Point
@@ -1991,22 +2039,11 @@ var BaseRoom = class {
1991
2039
  // ==========================================================================
1992
2040
  // WebSocket Connection
1993
2041
  // ==========================================================================
1994
- async handleWebSocketUpgrade(_request, url) {
1995
- const userId = url.searchParams.get("userId") || void 0;
1996
- const userName = url.searchParams.get("userName") || "Anonymous";
1997
- const userEmail = url.searchParams.get("userEmail") || "";
1998
- const userImageUrl = url.searchParams.get("userImageUrl") || void 0;
2042
+ async handleWebSocketUpgrade(request, _url) {
1999
2043
  const pair = new WebSocketPair();
2000
2044
  const [client, server] = Object.values(pair);
2001
2045
  this.state.acceptWebSocket(server);
2002
- const role = url.searchParams.get("role") || void 0;
2003
- const attachment = {
2004
- userId: userId ?? `anon-${crypto.randomUUID()}`,
2005
- userName,
2006
- userEmail,
2007
- userImageUrl,
2008
- role
2009
- };
2046
+ const attachment = connectionAttachmentFromRequest(request);
2010
2047
  const augmented = await this.onConnect(server, attachment) ?? attachment;
2011
2048
  server.serializeAttachment(augmented);
2012
2049
  return new Response(null, { status: 101, webSocket: client });
@@ -2304,7 +2341,7 @@ function dataToColumnValues(data, columns) {
2304
2341
  const values = {};
2305
2342
  for (const col of columns) {
2306
2343
  if (col.expression || col.readonly) continue;
2307
- const val = data[col.id] ?? data[col.name];
2344
+ const val = data[col.name];
2308
2345
  if (val !== void 0) {
2309
2346
  values[col.id] = coerceValue(val, col.storage, col.interpretation);
2310
2347
  }
@@ -2453,6 +2490,15 @@ function checkFieldPermissions(schema, role, newData, existingData) {
2453
2490
  }
2454
2491
  return null;
2455
2492
  }
2493
+ function checkUnclaimedOwnerTransition(schema, role, nextData, userId) {
2494
+ const permissions = getRolePermissions(schema, role);
2495
+ if (permissions.update !== "unclaimed-or-own" || !schema.ownerField) return null;
2496
+ const nextOwner = nextData[schema.ownerField];
2497
+ if (nextOwner === void 0 || nextOwner === null || nextOwner === "" || nextOwner === userId) {
2498
+ return null;
2499
+ }
2500
+ return `Role '${role}' can set '${schema.ownerField}' only to their own user id or leave it unclaimed`;
2501
+ }
2456
2502
  var SYSTEM_MANAGED_COLUMNS = /* @__PURE__ */ new Set([
2457
2503
  "email",
2458
2504
  "name",
@@ -2496,7 +2542,7 @@ function lintSchema(schema) {
2496
2542
  if (schema.ownerField) {
2497
2543
  const col = schema.columns.find((c) => c.name === schema.ownerField);
2498
2544
  const spoofableRoles = Object.entries(schema.permissions).filter(
2499
- ([role, permissions]) => role !== "owner" && role !== "admin" && permissions.create === true
2545
+ ([role, permissions]) => role !== "owner" && role !== "admin" && permissions.create === true && permissions.update !== "unclaimed-or-own"
2500
2546
  ).map(([role]) => role);
2501
2547
  if (col && !col.userBound && spoofableRoles.length > 0) {
2502
2548
  warnings.push(
@@ -2836,6 +2882,14 @@ function putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRb
2836
2882
  return { success: false, error: `Schema not registered for collection: ${collection}` };
2837
2883
  }
2838
2884
  const columns = getResolvedColumns(schema);
2885
+ for (const col of columns) {
2886
+ if (col.id !== col.name && Object.hasOwn(data, col.id)) {
2887
+ return {
2888
+ success: false,
2889
+ error: `FIELD ERROR: physical column id '${col.id}' is not a writable field; use '${col.name}'`
2890
+ };
2891
+ }
2892
+ }
2839
2893
  const existing = getRecord(ctx.sql, collection, recordId, schema);
2840
2894
  const isUpdate = existing !== null;
2841
2895
  const mergedData = isUpdate ? { ...existing.data, ...data } : { ...data };
@@ -2910,6 +2964,10 @@ function putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRb
2910
2964
  };
2911
2965
  }
2912
2966
  }
2967
+ const ownerError = checkUnclaimedOwnerTransition(schema, userRole, finalData, userId);
2968
+ if (ownerError) {
2969
+ return { success: false, error: `FIELD ERROR: ${ownerError}` };
2970
+ }
2913
2971
  }
2914
2972
  const colValues = dataToColumnValues(finalData, columns);
2915
2973
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -3162,11 +3220,17 @@ function handleYjsJoin(ctx, ws, attachment, payload) {
3162
3220
  }
3163
3221
  function handleYjsLeave(ctx, ws, attachment, payload) {
3164
3222
  const { collection, recordId, fieldName } = payload;
3223
+ const docKey = getYjsDocKey(collection, recordId, fieldName);
3224
+ const clientId = attachment.awarenessClientIds?.[docKey];
3225
+ if (clientId !== void 0) {
3226
+ broadcastAwarenessClientRemoval(ctx, docKey, clientId);
3227
+ delete attachment.awarenessClientIds[docKey];
3228
+ }
3165
3229
  attachment.yjsSubscriptions = attachment.yjsSubscriptions.filter(
3166
3230
  (s) => !(s.collection === collection && s.recordId === recordId && s.fieldName === fieldName)
3167
3231
  );
3168
3232
  ws.serializeAttachment(attachment);
3169
- unloadYjsDocIfUnused(ctx, getYjsDocKey(collection, recordId, fieldName), ws);
3233
+ unloadYjsDocIfUnused(ctx, docKey, ws);
3170
3234
  }
3171
3235
  function handleYjsBinaryMessage(ctx, ws, attachment, data) {
3172
3236
  const decoder = createDecoder(data);
@@ -3178,12 +3242,13 @@ function handleYjsBinaryMessage(ctx, ws, attachment, data) {
3178
3242
  const parsed2 = parseYjsDocKey(docKey2);
3179
3243
  if (!parsed2) return;
3180
3244
  if (!hasYjsSubscription(attachment, parsed2)) return;
3181
- if (attachment.awarenessClientId == null) {
3245
+ if (attachment.awarenessClientIds?.[docKey2] === void 0) {
3182
3246
  try {
3183
3247
  const pd = createDecoder(payload);
3184
3248
  const count = readVarUint(pd);
3185
3249
  if (count > 0) {
3186
- attachment.awarenessClientId = readVarUint(pd);
3250
+ attachment.awarenessClientIds ??= {};
3251
+ attachment.awarenessClientIds[docKey2] = readVarUint(pd);
3187
3252
  ws.serializeAttachment(attachment);
3188
3253
  }
3189
3254
  } catch {
@@ -3265,18 +3330,21 @@ function broadcastAwareness(ctx, docKey, payload, excludeWs) {
3265
3330
  }
3266
3331
  }
3267
3332
  function broadcastAwarenessRemoval(ctx, attachment) {
3268
- const clientId = attachment.awarenessClientId;
3269
- if (clientId == null || attachment.yjsSubscriptions.length === 0) return;
3333
+ if (!attachment.awarenessClientIds) return;
3334
+ for (const sub of attachment.yjsSubscriptions) {
3335
+ const docKey = getYjsDocKey(sub.collection, sub.recordId, sub.fieldName);
3336
+ const clientId = attachment.awarenessClientIds[docKey];
3337
+ if (clientId !== void 0) broadcastAwarenessClientRemoval(ctx, docKey, clientId);
3338
+ }
3339
+ }
3340
+ function broadcastAwarenessClientRemoval(ctx, docKey, clientId) {
3270
3341
  const payloadEncoder = createEncoder();
3271
3342
  writeVarUint(payloadEncoder, 1);
3272
3343
  writeVarUint(payloadEncoder, clientId);
3273
3344
  writeVarUint(payloadEncoder, 16777215);
3274
3345
  writeVarUint8Array(payloadEncoder, new TextEncoder().encode("null"));
3275
3346
  const removalPayload = toUint8Array(payloadEncoder);
3276
- for (const sub of attachment.yjsSubscriptions) {
3277
- const docKey = getYjsDocKey(sub.collection, sub.recordId, sub.fieldName);
3278
- broadcastAwareness(ctx, docKey, removalPayload, null);
3279
- }
3347
+ broadcastAwareness(ctx, docKey, removalPayload, null);
3280
3348
  }
3281
3349
  function handleYjsDisconnect(ctx, ws, attachment) {
3282
3350
  broadcastAwarenessRemoval(ctx, attachment);
@@ -3648,10 +3716,6 @@ async function handleSetRole(ctx, ws, attachment, payload) {
3648
3716
  now,
3649
3717
  payload.userId
3650
3718
  );
3651
- if (attachment.userId === payload.userId) {
3652
- attachment.role = payload.role;
3653
- ws.serializeAttachment(attachment);
3654
- }
3655
3719
  const userRecords = getAllUserRecords(ctx.sql, ctx.schemaRegistry);
3656
3720
  const users = userRecords.map((r) => ({
3657
3721
  id: r.recordId,
@@ -3660,9 +3724,12 @@ async function handleSetRole(ctx, ws, attachment, payload) {
3660
3724
  for (const otherWs of ctx.state.getWebSockets()) {
3661
3725
  const otherAttachment = otherWs.deserializeAttachment();
3662
3726
  if (!otherAttachment) continue;
3663
- if (otherAttachment.userId === payload.userId && otherWs !== ws) {
3664
- otherAttachment.role = payload.role;
3665
- otherWs.serializeAttachment(otherAttachment);
3727
+ if (otherAttachment.userId === payload.userId) {
3728
+ try {
3729
+ otherWs.close(1008, "role-changed");
3730
+ } catch {
3731
+ }
3732
+ continue;
3666
3733
  }
3667
3734
  if (otherAttachment.role === "admin") {
3668
3735
  ctx.send(otherWs, { type: MSG.USER_LIST, payload: { users } });
@@ -5086,6 +5153,13 @@ var CanvasRoom = class extends BaseRoom {
5086
5153
  };
5087
5154
 
5088
5155
  // src/server/rooms/presence-room.ts
5156
+ var MAX_PRESENCE_STATE_BYTES = 16 * 1024;
5157
+ function isPlainObject(value) {
5158
+ return value !== null && typeof value === "object" && !Array.isArray(value);
5159
+ }
5160
+ function stateSize(state) {
5161
+ return new TextEncoder().encode(JSON.stringify(state)).byteLength;
5162
+ }
5089
5163
  var PresenceRoom = class extends BaseRoom {
5090
5164
  peers = /* @__PURE__ */ new Map();
5091
5165
  peerSockets = /* @__PURE__ */ new Map();
@@ -5107,8 +5181,6 @@ var PresenceRoom = class extends BaseRoom {
5107
5181
  const recovered = {
5108
5182
  userId: att.userId,
5109
5183
  userName: att.userName,
5110
- userEmail: att.userEmail,
5111
- userImageUrl: att.userImageUrl,
5112
5184
  joinedAt,
5113
5185
  state: {}
5114
5186
  };
@@ -5125,8 +5197,6 @@ var PresenceRoom = class extends BaseRoom {
5125
5197
  const peer = {
5126
5198
  userId: user.userId,
5127
5199
  userName: user.userName,
5128
- userEmail: user.userEmail,
5129
- userImageUrl: user.userImageUrl,
5130
5200
  joinedAt: now,
5131
5201
  state: {}
5132
5202
  };
@@ -5154,15 +5224,28 @@ var PresenceRoom = class extends BaseRoom {
5154
5224
  peer = {
5155
5225
  userId: user.userId,
5156
5226
  userName: user.userName,
5157
- userEmail: user.userEmail,
5158
- userImageUrl: user.userImageUrl,
5159
5227
  joinedAt,
5160
5228
  state: {}
5161
5229
  };
5162
5230
  this.peers.set(user.userId, peer);
5163
5231
  this.peerSockets.set(user.userId, ws);
5164
5232
  }
5165
- peer.state = { ...peer.state, ...payload };
5233
+ if (!isPlainObject(payload)) {
5234
+ this.sendTo(ws, {
5235
+ type: MSG.ERROR,
5236
+ payload: { error: "Presence state must be an object" }
5237
+ });
5238
+ return;
5239
+ }
5240
+ const nextState = { ...peer.state, ...payload };
5241
+ if (stateSize(nextState) > MAX_PRESENCE_STATE_BYTES) {
5242
+ this.sendTo(ws, {
5243
+ type: MSG.ERROR,
5244
+ payload: { error: "Presence state exceeds 16 KiB" }
5245
+ });
5246
+ return;
5247
+ }
5248
+ peer.state = nextState;
5166
5249
  this.peers.set(user.userId, peer);
5167
5250
  this.broadcast(
5168
5251
  {
@@ -5174,7 +5257,10 @@ var PresenceRoom = class extends BaseRoom {
5174
5257
  break;
5175
5258
  }
5176
5259
  default:
5177
- this.sendTo(ws, { type: MSG.ERROR, payload: { error: `Unknown presence message type: ${type}` } });
5260
+ this.sendTo(ws, {
5261
+ type: MSG.ERROR,
5262
+ payload: { error: `Unknown presence message type: ${type}` }
5263
+ });
5178
5264
  }
5179
5265
  }
5180
5266
  onDisconnect(ws, user) {
@@ -5618,6 +5704,7 @@ var JobRoom = class extends BaseRoom {
5618
5704
  retentionMs;
5619
5705
  snapshotLimit;
5620
5706
  retryBackoffMs;
5707
+ authorizeWrite;
5621
5708
  /** In-flight jobs in this isolate; lets same-isolate JOB_CANCEL fire the signal. */
5622
5709
  inFlight = /* @__PURE__ */ new Map();
5623
5710
  /** Set by `ctx.continue` inside `executeJob`; reset before every run. */
@@ -5628,6 +5715,7 @@ var JobRoom = class extends BaseRoom {
5628
5715
  this.retentionMs = Math.max(0, config.retentionMs ?? DEFAULT_RETENTION_MS);
5629
5716
  this.snapshotLimit = Math.max(1, config.snapshotLimit ?? DEFAULT_SNAPSHOT_LIMIT);
5630
5717
  this.retryBackoffMs = Math.max(0, config.retryBackoffMs ?? 1e3);
5718
+ this.authorizeWrite = config.authorizeWrite ?? ((user) => user.role === ROLES.MEMBER || user.role === ROLES.ADMIN);
5631
5719
  }
5632
5720
  ensureInitialized() {
5633
5721
  if (this.initialized) return;
@@ -5668,14 +5756,31 @@ var JobRoom = class extends BaseRoom {
5668
5756
  this.ensureInitialized();
5669
5757
  return super.fetch(request);
5670
5758
  }
5671
- onConnect(ws, user) {
5759
+ async onConnect(ws, user) {
5672
5760
  this.ensureInitialized();
5761
+ const canWrite = await this.authorizeWrite(user);
5762
+ this.sendTo(ws, { type: MSG.AUTH, payload: { canWrite } });
5673
5763
  this.sendTo(ws, serverBuild.jobSnapshot(this.getRecentJobs(this.snapshotLimit)));
5674
- return user;
5764
+ return { ...user, canWrite };
5675
5765
  }
5676
5766
  async onMessage(ws, user, message) {
5677
5767
  this.ensureInitialized();
5678
5768
  const { type, payload } = message;
5769
+ if (type === MSG.JOB_ENQUEUE || type === MSG.JOB_CANCEL || type === MSG.JOB_RETRY) {
5770
+ const attachment = user;
5771
+ const canWrite = await this.authorizeWrite(user);
5772
+ if (attachment.canWrite !== canWrite) {
5773
+ attachment.canWrite = canWrite;
5774
+ ws.serializeAttachment(attachment);
5775
+ this.sendTo(ws, { type: MSG.AUTH, payload: { canWrite } });
5776
+ } else if (!canWrite) {
5777
+ this.sendTo(ws, { type: MSG.AUTH, payload: { canWrite: false } });
5778
+ }
5779
+ if (!canWrite) {
5780
+ this.sendTo(ws, serverBuild.error("Write access denied: job queue requires member access"));
5781
+ return;
5782
+ }
5783
+ }
5679
5784
  switch (type) {
5680
5785
  case MSG.JOB_ENQUEUE: {
5681
5786
  const requestId = String(payload.requestId ?? "");
@@ -7674,6 +7779,7 @@ export {
7674
7779
  capToolResultSize,
7675
7780
  captureScreenshot,
7676
7781
  checkFieldPermissions,
7782
+ checkUnclaimedOwnerTransition,
7677
7783
  coerceValue,
7678
7784
  collectionTableName,
7679
7785
  columnId,
@@ -7742,6 +7848,7 @@ export {
7742
7848
  registryStub,
7743
7849
  requireSubscription,
7744
7850
  resolveAppName,
7851
+ resolveAppRole,
7745
7852
  resolveColumn,
7746
7853
  rowToData,
7747
7854
  runMigrations,