deepspace 0.3.11 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -396,6 +396,7 @@ declare const MSG: {
396
396
  readonly USER_LIST: "user.list";
397
397
  readonly SET_ROLE: "user.set_role";
398
398
  readonly USER_UPDATE: "user.update";
399
+ readonly AUTH: "auth";
399
400
  readonly YJS_JOIN: "yjs.join";
400
401
  readonly YJS_LEAVE: "yjs.leave";
401
402
  readonly ACK: "records.ack";
@@ -639,6 +640,8 @@ type ServerMessage = BaseMessage<typeof MSG.QUERY_RESULT, {
639
640
  error: string;
640
641
  }> | BaseMessage<typeof MSG.RESUBSCRIBE, EmptyPayload> | BaseMessage<typeof MSG.LIST_SCHEMAS, {
641
642
  schemas: unknown;
643
+ }> | BaseMessage<typeof MSG.AUTH, {
644
+ canWrite: boolean;
642
645
  }> | BaseMessage<typeof MSG.USER_INFO, unknown> | BaseMessage<typeof MSG.USER_LIST, {
643
646
  users: unknown[];
644
647
  }> | BaseMessage<typeof MSG.YJS_JOIN, {
@@ -5972,13 +5975,20 @@ interface UseGameRoomResult {
5972
5975
  running: boolean;
5973
5976
  /** Whether WebSocket is connected */
5974
5977
  connected: boolean;
5975
- /** Send a game input */
5978
+ /**
5979
+ * Whether this connection has write access. False for viewers /
5980
+ * unauthenticated spectators — they receive state broadcasts but the
5981
+ * send callbacks below no-op. UIs should disable input controls when
5982
+ * false so spectators see why their actions don't take effect.
5983
+ */
5984
+ canWrite: boolean;
5985
+ /** Send a game input (no-op for spectators) */
5976
5986
  sendInput: (action: string, data?: Record<string, unknown>) => void;
5977
- /** Mark self as ready */
5987
+ /** Mark self as ready (no-op for spectators) */
5978
5988
  setReady: () => void;
5979
- /** Request game start */
5989
+ /** Request game start (no-op for spectators) */
5980
5990
  startGame: () => void;
5981
- /** Request game end */
5991
+ /** Request game end (no-op for spectators) */
5982
5992
  endGame: () => void;
5983
5993
  }
5984
5994
  declare function useGameRoom(roomId: string): UseGameRoomResult;
@@ -6019,21 +6029,28 @@ interface UseCanvasResult {
6019
6029
  viewports: ViewportClient[];
6020
6030
  /** Whether WebSocket is connected */
6021
6031
  connected: boolean;
6022
- /** Add a shape */
6032
+ /**
6033
+ * Whether this connection has write access (member/admin role). The
6034
+ * write callbacks below no-op when this is false; UIs should also use
6035
+ * it to disable controls so viewers see why their clicks do nothing.
6036
+ * Viewport reporting stays available regardless — it's presence info.
6037
+ */
6038
+ canWrite: boolean;
6039
+ /** Add a shape (no-op when canWrite is false) */
6023
6040
  addShape: (shape: Partial<CanvasShapeClient>) => void;
6024
- /** Move a shape */
6041
+ /** Move a shape (no-op when canWrite is false) */
6025
6042
  moveShape: (shapeId: string, x: number, y: number) => void;
6026
- /** Resize a shape */
6043
+ /** Resize a shape (no-op when canWrite is false) */
6027
6044
  resizeShape: (shapeId: string, width: number, height: number, x?: number, y?: number) => void;
6028
- /** Delete a shape */
6045
+ /** Delete a shape (no-op when canWrite is false) */
6029
6046
  deleteShape: (shapeId: string) => void;
6030
- /** Update shape properties */
6047
+ /** Update shape properties (no-op when canWrite is false) */
6031
6048
  updateShape: (shapeId: string, props: Record<string, unknown>) => void;
6032
- /** Report local viewport */
6049
+ /** Report local viewport (allowed for viewers — presence-like) */
6033
6050
  setViewport: (viewport: Omit<ViewportClient, 'userId'>) => void;
6034
- /** Undo last action */
6051
+ /** Undo last action (no-op when canWrite is false) */
6035
6052
  undo: () => void;
6036
- /** Redo last undone action */
6053
+ /** Redo last undone action (no-op when canWrite is false) */
6037
6054
  redo: () => void;
6038
6055
  }
6039
6056
  declare function useCanvas(roomId: string): UseCanvasResult;
@@ -6070,11 +6087,18 @@ interface UseCronMonitorResult {
6070
6087
  history: CronHistoryEntry[];
6071
6088
  /** Whether WebSocket is connected */
6072
6089
  connected: boolean;
6073
- /** Manually trigger a task */
6090
+ /**
6091
+ * Whether this connection can mutate cron state (trigger / pause /
6092
+ * resume). False for viewers and unauthenticated connections — the
6093
+ * mutation callbacks below no-op and UIs should disable those
6094
+ * controls. Reading tasks/history stays available either way.
6095
+ */
6096
+ canWrite: boolean;
6097
+ /** Manually trigger a task (no-op when canWrite is false) */
6074
6098
  trigger: (taskName: string) => void;
6075
- /** Pause a task */
6099
+ /** Pause a task (no-op when canWrite is false) */
6076
6100
  pause: (taskName: string) => void;
6077
- /** Resume a paused task */
6101
+ /** Resume a paused task (no-op when canWrite is false) */
6078
6102
  resume: (taskName: string) => void;
6079
6103
  }
6080
6104
  declare function useCronMonitor(roomId: string): UseCronMonitorResult;
@@ -6206,17 +6230,23 @@ declare function toConnectionStatus(queryStatus: string): ConnectionStatus;
6206
6230
  /**
6207
6231
  * useR2Files — Scoped file storage hook for DeepSpace apps.
6208
6232
  *
6209
- * Provides upload, download, list, and delete for files in R2, scoped to
6210
- * the current app ("self" — uses hostname-based scoping on the server).
6233
+ * Provides upload, download, list, and delete for files in R2.
6234
+ *
6235
+ * Two scopes are supported via the `scope` option:
6236
+ * - `'self'` (default): per-user storage. Reads require auth.
6237
+ * - `'app'`: app-wide storage. Reads are public — the upload URL works
6238
+ * directly as an `<img src>`. Use for avatars, logos, etc.
6211
6239
  *
6212
6240
  * @example
6213
6241
  * ```tsx
6214
- * const { upload, downloadFile, deleteFile, list } = useR2Files()
6242
+ * // Per-user files (default):
6243
+ * const { upload, downloadFile } = useR2Files()
6215
6244
  * await upload(myFile, 'photo.png')
6216
6245
  *
6217
- * const files = await list()
6218
- * await downloadFile(files[0]) // uses originalName automatically
6219
- * await deleteFile(files[0])
6246
+ * // App-shared files (public reads — usable in <img src>):
6247
+ * const { upload } = useR2Files({ scope: 'app' })
6248
+ * const r = await upload(file, `avatars/${userId}.png`)
6249
+ * // r.url is a plain, anon-readable URL.
6220
6250
  * ```
6221
6251
  */
6222
6252
  interface R2UploadResult {
@@ -6290,17 +6320,29 @@ interface UseR2FilesReturn {
6290
6320
  /**
6291
6321
  * Build a plain URL for a file. Accepts an R2FileInfo or a raw key string.
6292
6322
  *
6293
- * **Note**: This URL has no auth token attached. It only works for
6294
- * unauthenticated reads on deployed sites where the prefix is derived
6295
- * from the hostname. For authenticated access, use `downloadFile` or
6296
- * `readFile` instead.
6323
+ * **Note**: This URL has no auth token attached. It works for `'app'`-scope
6324
+ * files (which are publicly readable) but not for `'self'`-scope files —
6325
+ * those require an Authorization header, so use `readFile` / `downloadFile`.
6297
6326
  */
6298
6327
  getUrl: (fileOrKey: R2FileInfo | string) => string;
6299
6328
  /** Whether an upload is currently in progress. */
6300
6329
  isUploading: boolean;
6301
6330
  }
6331
+ /**
6332
+ * Scope controls where files live and who can read them:
6333
+ *
6334
+ * - `'self'` (default) — `apps/<app>/users/<userId>/…`. Per-user storage.
6335
+ * Reads require the caller's auth token; not usable from a plain `<img>`
6336
+ * or unauthenticated browser request.
6337
+ * - `'app'` — `apps/<app>/…`. Shared by everyone in the app. Reads are
6338
+ * public (no auth header needed), so the upload URL works directly as
6339
+ * an `<img src>`. Use this for avatars, logos, attachments, and other
6340
+ * assets meant to be embedded in pages.
6341
+ *
6342
+ * Uploads under `'app'` still require a signed-in user.
6343
+ */
6302
6344
  type R2Scope = {
6303
- scope?: 'self';
6345
+ scope?: 'self' | 'app';
6304
6346
  };
6305
6347
  declare function useR2Files(options?: R2Scope): UseR2FilesReturn;
6306
6348
 
package/dist/index.js CHANGED
@@ -407,6 +407,13 @@ var MSG = {
407
407
  USER_LIST: "user.list",
408
408
  SET_ROLE: "user.set_role",
409
409
  USER_UPDATE: "user.update",
410
+ // ---- Auth signal -----------------------------------------------------
411
+ // Server → client one-shot on connect from rooms that enforce role-based
412
+ // writes (Canvas, Game, Cron). Tells the client whether this connection
413
+ // can write so its hook can disable local write APIs and the UI can
414
+ // render controls accordingly. Without this, a viewer's click would
415
+ // round-trip to the server and silently come back as ERROR.
416
+ AUTH: "auth",
410
417
  // ---- Yjs JSON control frames (binary sync lives in ./yjs.ts) ---------
411
418
  YJS_JOIN: "yjs.join",
412
419
  YJS_LEAVE: "yjs.leave",
@@ -717,12 +724,17 @@ function DeepSpaceAuthProvider({ children }) {
717
724
  }
718
725
 
719
726
  // src/client/auth/hooks.ts
727
+ var sessionHasResolved = false;
728
+ function stickyIsLoaded(isPending) {
729
+ if (!isPending) sessionHasResolved = true;
730
+ return sessionHasResolved;
731
+ }
720
732
  function useAuth() {
721
733
  const session = useSession();
722
734
  const isSignedIn = !!session.data?.user;
723
735
  const userId = session.data?.user?.id ?? null;
724
736
  return {
725
- isLoaded: !session.isPending,
737
+ isLoaded: stickyIsLoaded(session.isPending),
726
738
  isSignedIn,
727
739
  userId,
728
740
  sessionId: session.data?.session?.id ?? null
@@ -732,7 +744,7 @@ function useAuthUser() {
732
744
  const session = useSession();
733
745
  const user = session.data?.user ?? null;
734
746
  return {
735
- isLoaded: !session.isPending,
747
+ isLoaded: stickyIsLoaded(session.isPending),
736
748
  isSignedIn: !!user,
737
749
  user: user ? {
738
750
  id: user.id,
@@ -880,7 +892,35 @@ function AuthOverlay({
880
892
  }
881
893
  )
882
894
  ] }),
883
- /* @__PURE__ */ jsxs2("div", { className: "mt-5 flex items-center justify-center gap-1.5 text-[11px] text-muted-foreground", children: [
895
+ /* @__PURE__ */ jsxs2("p", { className: "mt-5 text-xs leading-snug text-muted-foreground", children: [
896
+ "By continuing, you agree to the DeepSpace",
897
+ " ",
898
+ /* @__PURE__ */ jsx2(
899
+ "a",
900
+ {
901
+ href: "https://deep.space/terms",
902
+ target: "_blank",
903
+ rel: "noopener noreferrer",
904
+ className: "underline hover:text-foreground",
905
+ children: "Terms of Service"
906
+ }
907
+ ),
908
+ " ",
909
+ "and",
910
+ " ",
911
+ /* @__PURE__ */ jsx2(
912
+ "a",
913
+ {
914
+ href: "https://deep.space/privacy",
915
+ target: "_blank",
916
+ rel: "noopener noreferrer",
917
+ className: "underline hover:text-foreground",
918
+ children: "Privacy Policy"
919
+ }
920
+ ),
921
+ "."
922
+ ] }),
923
+ /* @__PURE__ */ jsxs2("div", { className: "mt-4 flex items-center justify-center gap-1.5 text-[11px] text-muted-foreground", children: [
884
924
  /* @__PURE__ */ jsx2("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "currentColor", opacity: "0.5", "aria-hidden": true, children: /* @__PURE__ */ jsx2("circle", { cx: "12", cy: "12", r: "10" }) }),
885
925
  "Powered by DeepSpace"
886
926
  ] })
@@ -2848,6 +2888,7 @@ function useGameRoom(roomId) {
2848
2888
  const [players, setPlayers] = useState8([]);
2849
2889
  const [running, setRunning] = useState8(false);
2850
2890
  const [connected, setConnected] = useState8(false);
2891
+ const [canWrite, setCanWrite] = useState8(false);
2851
2892
  const wsRef = useRef9(null);
2852
2893
  useEffect11(() => {
2853
2894
  let ws = null;
@@ -2869,6 +2910,9 @@ function useGameRoom(roomId) {
2869
2910
  };
2870
2911
  ws.onmessage = (event) => {
2871
2912
  dispatch(event.data, {
2913
+ [MSG.AUTH]: (p) => {
2914
+ setCanWrite(p.canWrite);
2915
+ },
2872
2916
  [MSG.GAME_STATE]: (p) => {
2873
2917
  setGameState(p.state);
2874
2918
  setTick(p.tick);
@@ -2906,6 +2950,7 @@ function useGameRoom(roomId) {
2906
2950
  wsLog("disconnected", `game:${roomId}`);
2907
2951
  wsRef.current = null;
2908
2952
  setConnected(false);
2953
+ setCanWrite(false);
2909
2954
  if (alive) reconnectTimer = setTimeout(connect, 1e3);
2910
2955
  };
2911
2956
  ws.onerror = () => ws?.close();
@@ -2924,27 +2969,23 @@ function useGameRoom(roomId) {
2924
2969
  wsRef.current = null;
2925
2970
  };
2926
2971
  }, [roomId]);
2927
- const sendInput = useCallback10((action, data = {}) => {
2928
- const ws = wsRef.current;
2929
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
2930
- ws.send(encode(clientBuild.gameInput(action, data)));
2931
- }, []);
2932
- const setReady = useCallback10(() => {
2933
- const ws = wsRef.current;
2934
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
2935
- ws.send(encode(clientBuild.gamePlayerReady()));
2936
- }, []);
2937
- const startGame = useCallback10(() => {
2938
- const ws = wsRef.current;
2939
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
2940
- ws.send(encode(clientBuild.gameStart()));
2941
- }, []);
2942
- const endGame = useCallback10(() => {
2943
- const ws = wsRef.current;
2944
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
2945
- ws.send(encode(clientBuild.gameEnd()));
2946
- }, []);
2947
- return { state: gameState, tick, players, running, connected, sendInput, setReady, startGame, endGame };
2972
+ const send = useCallback10(
2973
+ (message) => {
2974
+ if (!canWrite) return;
2975
+ const ws = wsRef.current;
2976
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
2977
+ ws.send(encode(message));
2978
+ },
2979
+ [canWrite]
2980
+ );
2981
+ const sendInput = useCallback10(
2982
+ (action, data = {}) => send(clientBuild.gameInput(action, data)),
2983
+ [send]
2984
+ );
2985
+ const setReady = useCallback10(() => send(clientBuild.gamePlayerReady()), [send]);
2986
+ const startGame = useCallback10(() => send(clientBuild.gameStart()), [send]);
2987
+ const endGame = useCallback10(() => send(clientBuild.gameEnd()), [send]);
2988
+ return { state: gameState, tick, players, running, connected, canWrite, sendInput, setReady, startGame, endGame };
2948
2989
  }
2949
2990
 
2950
2991
  // src/client/storage/hooks/useCanvas.ts
@@ -2953,6 +2994,7 @@ function useCanvas(roomId) {
2953
2994
  const [shapes, setShapes] = useState9([]);
2954
2995
  const [viewports, setViewports] = useState9([]);
2955
2996
  const [connected, setConnected] = useState9(false);
2997
+ const [canWrite, setCanWrite] = useState9(false);
2956
2998
  const wsRef = useRef10(null);
2957
2999
  useEffect12(() => {
2958
3000
  let ws = null;
@@ -2974,6 +3016,9 @@ function useCanvas(roomId) {
2974
3016
  };
2975
3017
  ws.onmessage = (event) => {
2976
3018
  dispatch(event.data, {
3019
+ [MSG.AUTH]: (p) => {
3020
+ setCanWrite(p.canWrite);
3021
+ },
2977
3022
  [MSG.CANVAS_SHAPES]: (p) => {
2978
3023
  setShapes(p.shapes);
2979
3024
  setViewports(p.viewports);
@@ -3026,6 +3071,7 @@ function useCanvas(roomId) {
3026
3071
  wsLog("disconnected", `canvas:${roomId}`);
3027
3072
  wsRef.current = null;
3028
3073
  setConnected(false);
3074
+ setCanWrite(false);
3029
3075
  if (alive) reconnectTimer = setTimeout(connect, 1e3);
3030
3076
  };
3031
3077
  ws.onerror = () => ws?.close();
@@ -3052,33 +3098,40 @@ function useCanvas(roomId) {
3052
3098
  },
3053
3099
  []
3054
3100
  );
3101
+ const sendWrite = useCallback11(
3102
+ (message) => {
3103
+ if (!canWrite) return;
3104
+ sendBuilt(message);
3105
+ },
3106
+ [sendBuilt, canWrite]
3107
+ );
3055
3108
  const addShape = useCallback11(
3056
- (shape) => sendBuilt(clientBuild.canvasAdd(shape)),
3057
- [sendBuilt]
3109
+ (shape) => sendWrite(clientBuild.canvasAdd(shape)),
3110
+ [sendWrite]
3058
3111
  );
3059
3112
  const moveShape = useCallback11(
3060
- (shapeId, x, y) => sendBuilt(clientBuild.canvasMove(shapeId, x, y)),
3061
- [sendBuilt]
3113
+ (shapeId, x, y) => sendWrite(clientBuild.canvasMove(shapeId, x, y)),
3114
+ [sendWrite]
3062
3115
  );
3063
3116
  const resizeShape = useCallback11(
3064
- (shapeId, width, height, x, y) => sendBuilt(clientBuild.canvasResize(shapeId, width, height, x, y)),
3065
- [sendBuilt]
3117
+ (shapeId, width, height, x, y) => sendWrite(clientBuild.canvasResize(shapeId, width, height, x, y)),
3118
+ [sendWrite]
3066
3119
  );
3067
3120
  const deleteShape = useCallback11(
3068
- (shapeId) => sendBuilt(clientBuild.canvasDelete(shapeId)),
3069
- [sendBuilt]
3121
+ (shapeId) => sendWrite(clientBuild.canvasDelete(shapeId)),
3122
+ [sendWrite]
3070
3123
  );
3071
3124
  const updateShape = useCallback11(
3072
- (shapeId, props) => sendBuilt(clientBuild.canvasUpdate(shapeId, props)),
3073
- [sendBuilt]
3125
+ (shapeId, props) => sendWrite(clientBuild.canvasUpdate(shapeId, props)),
3126
+ [sendWrite]
3074
3127
  );
3075
3128
  const setViewport = useCallback11(
3076
3129
  (viewport) => sendBuilt(clientBuild.canvasViewport(viewport)),
3077
3130
  [sendBuilt]
3078
3131
  );
3079
- const undo = useCallback11(() => sendBuilt(clientBuild.canvasUndo()), [sendBuilt]);
3080
- const redo = useCallback11(() => sendBuilt(clientBuild.canvasRedo()), [sendBuilt]);
3081
- return { shapes, viewports, connected, addShape, moveShape, resizeShape, deleteShape, updateShape, setViewport, undo, redo };
3132
+ const undo = useCallback11(() => sendWrite(clientBuild.canvasUndo()), [sendWrite]);
3133
+ const redo = useCallback11(() => sendWrite(clientBuild.canvasRedo()), [sendWrite]);
3134
+ return { shapes, viewports, connected, canWrite, addShape, moveShape, resizeShape, deleteShape, updateShape, setViewport, undo, redo };
3082
3135
  }
3083
3136
 
3084
3137
  // src/client/storage/hooks/useCronMonitor.ts
@@ -3087,6 +3140,7 @@ function useCronMonitor(roomId) {
3087
3140
  const [tasks, setTasks] = useState10([]);
3088
3141
  const [history, setHistory] = useState10([]);
3089
3142
  const [connected, setConnected] = useState10(false);
3143
+ const [canWrite, setCanWrite] = useState10(false);
3090
3144
  const wsRef = useRef11(null);
3091
3145
  useEffect13(() => {
3092
3146
  let ws = null;
@@ -3104,6 +3158,9 @@ function useCronMonitor(roomId) {
3104
3158
  ws.onopen = () => setConnected(true);
3105
3159
  ws.onmessage = (event) => {
3106
3160
  dispatch(event.data, {
3161
+ [MSG.AUTH]: (p) => {
3162
+ setCanWrite(p.canWrite);
3163
+ },
3107
3164
  [MSG.CRON_TASKS]: (p) => {
3108
3165
  setTasks(p.tasks);
3109
3166
  },
@@ -3119,6 +3176,7 @@ function useCronMonitor(roomId) {
3119
3176
  ws.onclose = () => {
3120
3177
  wsRef.current = null;
3121
3178
  setConnected(false);
3179
+ setCanWrite(false);
3122
3180
  if (alive) reconnectTimer = setTimeout(connect, 1e3);
3123
3181
  };
3124
3182
  ws.onerror = () => ws?.close();
@@ -3131,27 +3189,28 @@ function useCronMonitor(roomId) {
3131
3189
  wsRef.current = null;
3132
3190
  };
3133
3191
  }, [roomId]);
3134
- const sendBuilt = useCallback12(
3192
+ const sendWrite = useCallback12(
3135
3193
  (message) => {
3194
+ if (!canWrite) return;
3136
3195
  const ws = wsRef.current;
3137
3196
  if (!ws || ws.readyState !== WebSocket.OPEN) return;
3138
3197
  ws.send(encode(message));
3139
3198
  },
3140
- []
3199
+ [canWrite]
3141
3200
  );
3142
3201
  const trigger = useCallback12(
3143
- (taskName) => sendBuilt(clientBuild.cronTrigger(taskName)),
3144
- [sendBuilt]
3202
+ (taskName) => sendWrite(clientBuild.cronTrigger(taskName)),
3203
+ [sendWrite]
3145
3204
  );
3146
3205
  const pause = useCallback12(
3147
- (taskName) => sendBuilt(clientBuild.cronPause(taskName)),
3148
- [sendBuilt]
3206
+ (taskName) => sendWrite(clientBuild.cronPause(taskName)),
3207
+ [sendWrite]
3149
3208
  );
3150
3209
  const resume = useCallback12(
3151
- (taskName) => sendBuilt(clientBuild.cronResume(taskName)),
3152
- [sendBuilt]
3210
+ (taskName) => sendWrite(clientBuild.cronResume(taskName)),
3211
+ [sendWrite]
3153
3212
  );
3154
- return { tasks, history, connected, trigger, pause, resume };
3213
+ return { tasks, history, connected, canWrite, trigger, pause, resume };
3155
3214
  }
3156
3215
 
3157
3216
  // src/client/storage/hooks/useJobs.ts
@@ -3376,11 +3435,12 @@ function resolveFile(fileOrKey) {
3376
3435
  }
3377
3436
  function useR2Files(options) {
3378
3437
  const [isUploading, setIsUploading] = useState13(false);
3438
+ const scope = options?.scope ?? "self";
3379
3439
  const scopeParams = useMemo10(() => {
3380
3440
  const params = new URLSearchParams();
3381
- params.set("scope", "self");
3441
+ params.set("scope", scope);
3382
3442
  return params.toString();
3383
- }, []);
3443
+ }, [scope]);
3384
3444
  const authHeaders = useCallback15(async () => {
3385
3445
  const token = await getAuthToken();
3386
3446
  if (token) {