@casualoffice/sheets 0.11.1 → 0.12.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.
@@ -25,7 +25,7 @@ import type { IWorkbookData } from '@univerjs/core';
25
25
  import type { FUniver } from '@univerjs/core/facade';
26
26
  import type { CasualSheetsAPI } from '../sheets/api';
27
27
  import { startBridge, type BridgeHandle } from './bridge';
28
- import { buildWsUrl } from './ws-url';
28
+ import { buildWsUrl, type WsUrlShare } from './ws-url';
29
29
 
30
30
  /** Either the SDK's imperative API (`onReady`) or the bare FUniver facade.
31
31
  * Collab only needs the facade, so a host that holds the raw FUniver (the
@@ -64,8 +64,14 @@ export interface AttachCollabOptions {
64
64
  * hook keep the connection queued without it. Defaults to `'anon'` (the
65
65
  * reference server's hook only reads the `role` query param). */
66
66
  token?: string;
67
- /** `view` joins read-only. Defaults to `'write'`. */
67
+ /** `view` joins read-only. Defaults to `'write'`. Ignored by the server
68
+ * when a `share` token is supplied — the token is authoritative. */
68
69
  role?: CollabRole;
70
+ /** Secure share-link capability (sharing-model §6.1). When `share.share`
71
+ * is set, the role is omitted from the WS URL and the server resolves it
72
+ * from the token (bound to this room at mint time). `share.sp` carries
73
+ * the optional join password for a password-protected token. */
74
+ share?: WsUrlShare;
69
75
  /**
70
76
  * Called when a peer's compaction snapshot arrives — the host swaps the
71
77
  * workbook (typically `api.loadSnapshot(wb)`). MAY return a promise; the
@@ -103,7 +109,7 @@ export function attachCollab(api: CollabAttachable, opts: AttachCollabOptions):
103
109
  // Match the reference host: drop the reconnect timeout to 10 s so a dropped
104
110
  // socket surfaces as `offline` quickly rather than after the 30 s default.
105
111
  const ws = new HocuspocusProviderWebsocket({
106
- url: buildWsUrl(opts.server, opts.room, role, opts.password),
112
+ url: buildWsUrl(opts.server, opts.room, role, opts.password, opts.share),
107
113
  messageReconnectTimeout: 10_000,
108
114
  });
109
115
  const provider = new HocuspocusProvider({
@@ -18,6 +18,9 @@ export {
18
18
  type CollabRole,
19
19
  type CollabConnectionStatus,
20
20
  } from './attachCollab';
21
+ // Secure share-link capability shape (sharing-model §6.1) — referenced by
22
+ // AttachCollabOptions.share.
23
+ export { type WsUrlShare } from './ws-url';
21
24
 
22
25
  // The mutation bridge — the framework-agnostic core (subscribes to
23
26
  // onMutationExecutedForCollab, replays with fromCollab, guards __splitChunk__).
@@ -4,17 +4,47 @@
4
4
  * `node:test` without dragging in the Univer ESM graph.
5
5
  */
6
6
 
7
- /** Build the room WS URL: `<server>?room=<id>[&p=<pw>]&role=<role>`. */
7
+ /** Extra capability params carried on the WS upgrade for secure share
8
+ * links (sharing-model §6.1). */
9
+ export type WsUrlShare = {
10
+ /** Secure share-link token. When present the server is authoritative
11
+ * for the role — it resolves the token to a role + a bound room, so
12
+ * the client must NOT also assert `role=` (the server ignores a
13
+ * client `?role=` when a token is present; sending one is just
14
+ * spoofable noise). */
15
+ share?: string;
16
+ /** Optional join password paired with a password-protected share
17
+ * token (`?sp=`). Distinct from the anonymous-room `?p=` password. */
18
+ sp?: string;
19
+ };
20
+
21
+ /**
22
+ * Build the room WS URL.
23
+ *
24
+ * - No share token: `<server>?room=<id>[&p=<pw>]&role=<role>` — the
25
+ * anonymous-room path; byte-identical to before.
26
+ * - With a `share` token: `<server>?room=<id>[&p=<pw>]&share=<token>[&sp=<pw>]`.
27
+ * `role=` is deliberately omitted — the server resolves the role
28
+ * from the token, so shipping a spoofable client role is pointless.
29
+ */
8
30
  export function buildWsUrl(
9
31
  server: string,
10
32
  room: string,
11
33
  role: 'view' | 'write',
12
34
  password?: string,
35
+ share?: WsUrlShare,
13
36
  ): string {
14
37
  const sep = server.includes('?') ? '&' : '?';
15
- return (
38
+ const base =
16
39
  `${server}${sep}room=${encodeURIComponent(room)}` +
17
- `${password ? `&p=${encodeURIComponent(password)}` : ''}` +
18
- `&role=${encodeURIComponent(role)}`
19
- );
40
+ `${password ? `&p=${encodeURIComponent(password)}` : ''}`;
41
+ const token = share?.share;
42
+ if (token) {
43
+ return (
44
+ base +
45
+ `&share=${encodeURIComponent(token)}` +
46
+ `${share?.sp ? `&sp=${encodeURIComponent(share.sp)}` : ''}`
47
+ );
48
+ }
49
+ return base + `&role=${encodeURIComponent(role)}`;
20
50
  }
@@ -33,3 +33,58 @@ test('buildWsUrl percent-encodes the room id', () => {
33
33
  'wss://h/yjs?room=a%20b%2Fc&role=write',
34
34
  );
35
35
  });
36
+
37
+ // ── Secure share-link tokens (sharing-model §6.1) ──────────────────────────
38
+
39
+ test('buildWsUrl with a share token forwards share= and OMITS role=', () => {
40
+ // The server is authoritative for the role when a token is present, so the
41
+ // spoofable client role must not be on the URL — even though `role` is
42
+ // still passed positionally. A `write` here must NOT leak through.
43
+ assert.equal(
44
+ buildWsUrl('wss://h/yjs', 'room1', 'write', undefined, { share: 'tok123' }),
45
+ 'wss://h/yjs?room=room1&share=tok123',
46
+ );
47
+ });
48
+
49
+ test('buildWsUrl with a share token forwards sp= (join password) when set', () => {
50
+ assert.equal(
51
+ buildWsUrl('wss://h/yjs', 'room1', 'view', undefined, { share: 'tok123', sp: 'p@ss/word' }),
52
+ 'wss://h/yjs?room=room1&share=tok123&sp=p%40ss%2Fword',
53
+ );
54
+ });
55
+
56
+ test('buildWsUrl percent-encodes the share token', () => {
57
+ assert.equal(
58
+ buildWsUrl('wss://h/yjs', 'room1', 'view', undefined, { share: 'a b/c' }),
59
+ 'wss://h/yjs?room=room1&share=a%20b%2Fc',
60
+ );
61
+ });
62
+
63
+ test('buildWsUrl keeps a room password alongside a share token', () => {
64
+ assert.equal(
65
+ buildWsUrl('wss://h/yjs', 'room1', 'write', 'roompw', { share: 'tok' }),
66
+ 'wss://h/yjs?room=room1&p=roompw&share=tok',
67
+ );
68
+ });
69
+
70
+ test('buildWsUrl is byte-identical to the no-token form when share is undefined', () => {
71
+ // The explicit-undefined call site (e.g. attachCollab forwarding an absent
72
+ // opts.share) must produce exactly what the old 4-arg call produced.
73
+ assert.equal(
74
+ buildWsUrl('wss://h/yjs', 'room1', 'write', undefined, undefined),
75
+ buildWsUrl('wss://h/yjs', 'room1', 'write'),
76
+ );
77
+ assert.equal(
78
+ buildWsUrl('wss://h/yjs', 'room1', 'view', 'pw', undefined),
79
+ buildWsUrl('wss://h/yjs', 'room1', 'view', 'pw'),
80
+ );
81
+ });
82
+
83
+ test('buildWsUrl ignores an empty share token (falls back to role=)', () => {
84
+ // A defensive case: `{ share: '' }` is not a real token, so we must not
85
+ // emit `share=` with an empty value and drop the role.
86
+ assert.equal(
87
+ buildWsUrl('wss://h/yjs', 'room1', 'write', undefined, { share: '' }),
88
+ 'wss://h/yjs?room=room1&role=write',
89
+ );
90
+ });
@@ -23,6 +23,7 @@ import { createRoot } from 'react-dom/client';
23
23
  import { EmbedTransport } from '../embed/EmbedTransport';
24
24
  import type { CasualApp } from '../embed/protocol';
25
25
  import { CasualSheets } from '../sheets/CasualSheets';
26
+ import { applyReadOnly, getEditable } from '../sheets/read-only';
26
27
  import { xlsxToWorkbookData } from '../xlsx';
27
28
  import { EMBED_LOCALES } from './locale';
28
29
  import type { IWorkbookData } from '@univerjs/core';
@@ -247,6 +248,41 @@ function EmbeddedSheets({
247
248
  onSave={(snapshot) => transport.sendSaveNotify({ snapshot, reason: 'shortcut' })}
248
249
  onExit={(snapshot) => transport.sendExit({ snapshot })}
249
250
  onReady={(api) => {
251
+ // Expose the imperative API on the iframe window so hosts can
252
+ // introspect/debug the embedded editor (and e2e can drive it). Mirrors
253
+ // the React component's `window.__univerAPI` seam.
254
+ (globalThis as unknown as { __casualEmbedApi?: unknown }).__casualEmbedApi = api;
255
+ // Debug seam: report the live workbook-editable permission so hosts/e2e
256
+ // can confirm preview is genuinely read-only (not just chromeless).
257
+ (
258
+ globalThis as unknown as { __casualEmbedEditable?: () => boolean | undefined }
259
+ ).__casualEmbedEditable = () => {
260
+ const uid = api.getSnapshot()?.id;
261
+ return uid ? getEditable(api.univer, uid) : undefined;
262
+ };
263
+ // Preview = genuinely READ-ONLY. Hiding the chrome (above) isn't
264
+ // enough — Univer's cell editor still opens on dbl-click / F2. Flip the
265
+ // workbook's edit permission off so preview can't be typed into. The
266
+ // mount remounts on viewMode change (key={viewMode}), so editor mode
267
+ // simply never applies this.
268
+ //
269
+ // Defer to a rAF: applied synchronously in onReady the flag loses a
270
+ // race — sheets-ui initialises WorkbookEditablePermission to `true`
271
+ // during unit setup AFTER onReady fires, clobbering our `false` (a
272
+ // behavioural embed test caught preview still accepting keystrokes).
273
+ // The app's collab view-only path waits a rAF for the same reason
274
+ // (CollabDriver.tsx), so the permission point exists and we
275
+ // updatePermissionPoint(false) over the settled default.
276
+ if (viewMode === 'preview') {
277
+ requestAnimationFrame(() => {
278
+ const unitId = api.getSnapshot()?.id;
279
+ if (unitId)
280
+ applyReadOnly(api.univer, unitId, () => {
281
+ const g = globalThis as unknown as { __casualEmbedBlocked?: number };
282
+ g.__casualEmbedBlocked = (g.__casualEmbedBlocked ?? 0) + 1;
283
+ });
284
+ });
285
+ }
250
286
  // Wire host → editor command.execute (Drive's custom toolbar
251
287
  // calls this for bold / italic / undo / …). Maps the small
252
288
  // protocol union to the Univer command id the FUniver facade
@@ -44,8 +44,9 @@ export interface CasualSheetsIframeRef {
44
44
  setViewMode(mode: 'preview' | 'editor'): void;
45
45
  iframe(): HTMLIFrameElement | null;
46
46
  /** Dispatch a formatting / navigation command (bold, italic, undo, …)
47
- * against the iframe's active selection. v0.6+. */
48
- executeCommand(command: CommandExecuteData['command']): void;
47
+ * against the iframe's active selection. `args` carries command payloads
48
+ * (e.g. font family/size, colour) — forwarded over the protocol. v0.6+. */
49
+ executeCommand(command: CommandExecuteData['command'], args?: CommandExecuteData['args']): void;
49
50
  }
50
51
 
51
52
  export interface CasualSheetsIframeProps {
@@ -194,7 +195,8 @@ export const CasualSheetsIframe = forwardRef<CasualSheetsIframeRef, CasualSheets
194
195
  apiRef.current = {
195
196
  setViewMode: (mode) => transportRef.current?.sendSetViewMode({ viewMode: mode }),
196
197
  iframe: () => iframeRef.current,
197
- executeCommand: (command) => transportRef.current?.sendCommandExecute({ command }),
198
+ executeCommand: (command, args) =>
199
+ transportRef.current?.sendCommandExecute({ command, args }),
198
200
  };
199
201
  }
200
202
 
@@ -3,6 +3,7 @@
3
3
  */
4
4
  export { CasualSheets, type CasualSheetsProps } from './CasualSheets';
5
5
  export { createCasualSheetsAPI, type CasualSheetsAPI, type RangeRef } from './api';
6
+ export { applyReadOnly, getEditable } from './read-only';
6
7
  export {
7
8
  CasualSheetsIframe,
8
9
  type CasualSheetsIframeProps,
@@ -0,0 +1,106 @@
1
+ import { CustomCommandExecutionError, ICommandService, IPermissionService } from '@univerjs/core';
2
+ import type { FUniver } from '@univerjs/core/facade';
3
+ import { WorkbookEditablePermission } from '@univerjs/sheets';
4
+
5
+ /**
6
+ * Command ids that MUTATE a sheet — opening the cell editor, writing values,
7
+ * styling, structural edits, clipboard paste. The read-only veto cancels any
8
+ * command whose id matches. Navigation (selection, scroll, zoom, sheet switch),
9
+ * copy, and undo/redo deliberately fall through so preview stays usable.
10
+ *
11
+ * `set-cell-edit-visible` / `set-activate-cell-edit` are the editor-open
12
+ * operations — blocking them is what actually stops keyboard typing, since the
13
+ * cell editor never opens. The rest stop programmatic / paste / menu mutations.
14
+ */
15
+ const READONLY_BLOCK =
16
+ /(set-cell-edit-visible|set-activate-cell-edit|set-range-values|set-style|set-bold|set-italic|set-underline|set-strike|set-font|set-background|set-text|set-horizontal|set-vertical|set-wrap|set-rotation|set-border|set-number-format|insert-|delete-|remove-|clear-selection|cut-content|paste|move-range|move-rows|move-cols|merge|split|add-worksheet|set-worksheet-name|set-worksheet-row|set-worksheet-col|auto-fill|reorder|set-defined-name|set-tab-color|set-frozen-cancel)/;
17
+
18
+ /**
19
+ * Make a workbook genuinely READ-ONLY.
20
+ *
21
+ * Two layers, because they cover different host setups:
22
+ *
23
+ * 1. **Command veto** (`beforeCommandExecuted` → throw
24
+ * `CustomCommandExecutionError`) — cancels every mutating command. This is
25
+ * the load-bearing layer for the iframe embed, whose minimal plugin set does
26
+ * NOT enforce `WorkbookEditablePermission` (verified: the editor still
27
+ * accepts edits with the permission flipped off).
28
+ * 2. **Permission flip** (`WorkbookEditablePermission` → false) — on the full
29
+ * `<CasualSheets>` host path this also greys out mutating menu items and
30
+ * stops the editor opening; harmless where unenforced.
31
+ *
32
+ * Returns a disposer that removes the veto and restores the prior editable
33
+ * state (for callers that toggle a live unit between preview/editor).
34
+ */
35
+ export function applyReadOnly(
36
+ univerApi: FUniver,
37
+ unitId: string,
38
+ onBlock?: (commandId: string) => void,
39
+ ): () => void {
40
+ const injector = (univerApi as unknown as { _injector?: { get(t: unknown): unknown } })._injector;
41
+
42
+ // Layer 1: veto mutating commands — the only layer the minimal embed enforces.
43
+ const cmd = injector?.get(ICommandService) as
44
+ | { beforeCommandExecuted(l: (info: { id: string }) => void): { dispose(): void } }
45
+ | undefined;
46
+ const vetoDisposable = cmd?.beforeCommandExecuted((info) => {
47
+ if (READONLY_BLOCK.test(info.id)) {
48
+ onBlock?.(info.id);
49
+ throw new CustomCommandExecutionError(`read-only: blocked ${info.id}`);
50
+ }
51
+ });
52
+
53
+ // Layer 2: permission flip (best-effort; load-bearing only on full hosts).
54
+ const svc = injector?.get(IPermissionService) as
55
+ | {
56
+ addPermissionPoint(p: unknown): boolean;
57
+ updatePermissionPoint(id: string, value: unknown): void;
58
+ getPermissionPoint(id: string): { value: unknown } | undefined;
59
+ }
60
+ | undefined;
61
+
62
+ // Constructing the point yields the deterministic id Univer derives for the
63
+ // workbook-edit permission; we don't keep the instance otherwise.
64
+ const id = new WorkbookEditablePermission(unitId).id;
65
+ let prev: unknown;
66
+ if (svc) {
67
+ try {
68
+ const existing = svc.getPermissionPoint(id);
69
+ if (existing) {
70
+ prev = existing.value;
71
+ svc.updatePermissionPoint(id, false);
72
+ } else {
73
+ const point = new WorkbookEditablePermission(unitId);
74
+ point.value = false;
75
+ svc.addPermissionPoint(point);
76
+ }
77
+ } catch {
78
+ /* best-effort — the veto above is the load-bearing layer */
79
+ }
80
+ }
81
+
82
+ return () => {
83
+ vetoDisposable?.dispose();
84
+ try {
85
+ svc?.updatePermissionPoint(id, prev === undefined ? true : prev);
86
+ } catch {
87
+ /* swallow */
88
+ }
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Read the current `WorkbookEditablePermission` value for a unit — `true`
94
+ * (editable), `false` (read-only), or `undefined` if the point isn't
95
+ * registered yet. Lets hosts/tests confirm {@link applyReadOnly} took.
96
+ */
97
+ export function getEditable(univerApi: FUniver, unitId: string): boolean | undefined {
98
+ const injector = (univerApi as unknown as { _injector?: { get(t: unknown): unknown } })._injector;
99
+ const svc = injector?.get(IPermissionService) as
100
+ | { getPermissionPoint(id: string): { value: unknown } | undefined }
101
+ | undefined;
102
+ if (!svc) return undefined;
103
+ const id = new WorkbookEditablePermission(unitId).id;
104
+ const point = svc.getPermissionPoint(id);
105
+ return point ? (point.value as boolean) : undefined;
106
+ }