@casualoffice/sheets 0.11.1 → 0.13.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';
@@ -131,19 +132,20 @@ function EmbeddedSheets({
131
132
  const [errorMsg, setErrorMsg] = useState<string | null>(null);
132
133
 
133
134
  const [viewMode, setViewMode] = useState<'preview' | 'editor'>(initialViewMode);
134
- // Editor mode flips `header: true` so Univer's formula bar (A1 cell
135
- // ref, fx button) and menubar render at the top of the iframe —
136
- // visually distinct from preview's canvas-only surface. `toolbar` and
137
- // `footer` stay off: Univer's ribbon and sheet-tabs slot resolve
138
- // services (IRPCChannelService, sheet-drawing) at construction that
139
- // the SDK doesn't bundle a worker for, and turning them on lights up
140
- // `[redi]: Cannot find "Kb" registered by any injector` and the
141
- // canvas never paints. Cells stay editable in editor mode via direct
142
- // keyboard input on the focused cell.
143
- const ui =
144
- viewMode === 'editor'
145
- ? { header: true, toolbar: false, footer: false, contextMenu: true }
146
- : { header: false, toolbar: false, footer: false, contextMenu: true };
135
+ // Editor mode renders the SDK's OWN React chrome (`chrome="full"`): the
136
+ // menu bar (Edit/Insert/Format/Data/View), the rich formatting toolbar,
137
+ // the formula bar, sheet tabs and status bar — the full native editor, so
138
+ // hosts embed a complete spreadsheet and only frame/brand it (they do NOT
139
+ // hand-roll a toolbar). We use the SDK chrome, NOT Univer's built-in `ui`
140
+ // toolbar/footer: Univer's ribbon + sheet-tabs slots resolve services
141
+ // (IRPCChannelService, sheet-drawing) the single-file embed doesn't bundle,
142
+ // which throws `[redi]: Cannot find registered by any injector`. The SDK
143
+ // chrome is plain React over the facade, so it has no such dependency.
144
+ //
145
+ // Preview = `chrome="none"`: bare canvas, made read-only by the veto in
146
+ // onReady. Univer's own chrome stays off in both modes.
147
+ const chrome: 'full' | 'none' = viewMode === 'editor' ? 'full' : 'none';
148
+ const ui = { header: false, toolbar: false, footer: false, contextMenu: true };
147
149
 
148
150
  useEffect(() => {
149
151
  transport.on({
@@ -224,6 +226,7 @@ function EmbeddedSheets({
224
226
  <CasualSheets
225
227
  key={viewMode}
226
228
  initialData={data}
229
+ chrome={chrome}
227
230
  ui={ui}
228
231
  // Seed the en-US string bundle. Without `locales`, Univer's
229
232
  // LocaleService throws "Locale not initialized" and the workbench
@@ -247,6 +250,41 @@ function EmbeddedSheets({
247
250
  onSave={(snapshot) => transport.sendSaveNotify({ snapshot, reason: 'shortcut' })}
248
251
  onExit={(snapshot) => transport.sendExit({ snapshot })}
249
252
  onReady={(api) => {
253
+ // Expose the imperative API on the iframe window so hosts can
254
+ // introspect/debug the embedded editor (and e2e can drive it). Mirrors
255
+ // the React component's `window.__univerAPI` seam.
256
+ (globalThis as unknown as { __casualEmbedApi?: unknown }).__casualEmbedApi = api;
257
+ // Debug seam: report the live workbook-editable permission so hosts/e2e
258
+ // can confirm preview is genuinely read-only (not just chromeless).
259
+ (
260
+ globalThis as unknown as { __casualEmbedEditable?: () => boolean | undefined }
261
+ ).__casualEmbedEditable = () => {
262
+ const uid = api.getSnapshot()?.id;
263
+ return uid ? getEditable(api.univer, uid) : undefined;
264
+ };
265
+ // Preview = genuinely READ-ONLY. Hiding the chrome (above) isn't
266
+ // enough — Univer's cell editor still opens on dbl-click / F2. Flip the
267
+ // workbook's edit permission off so preview can't be typed into. The
268
+ // mount remounts on viewMode change (key={viewMode}), so editor mode
269
+ // simply never applies this.
270
+ //
271
+ // Defer to a rAF: applied synchronously in onReady the flag loses a
272
+ // race — sheets-ui initialises WorkbookEditablePermission to `true`
273
+ // during unit setup AFTER onReady fires, clobbering our `false` (a
274
+ // behavioural embed test caught preview still accepting keystrokes).
275
+ // The app's collab view-only path waits a rAF for the same reason
276
+ // (CollabDriver.tsx), so the permission point exists and we
277
+ // updatePermissionPoint(false) over the settled default.
278
+ if (viewMode === 'preview') {
279
+ requestAnimationFrame(() => {
280
+ const unitId = api.getSnapshot()?.id;
281
+ if (unitId)
282
+ applyReadOnly(api.univer, unitId, () => {
283
+ const g = globalThis as unknown as { __casualEmbedBlocked?: number };
284
+ g.__casualEmbedBlocked = (g.__casualEmbedBlocked ?? 0) + 1;
285
+ });
286
+ });
287
+ }
250
288
  // Wire host → editor command.execute (Drive's custom toolbar
251
289
  // calls this for bold / italic / undo / …). Maps the small
252
290
  // 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
+ }