@book.dev/ui 1.60.0 → 1.64.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.
Files changed (38) hide show
  1. package/dist/{EmojiGrid-xK5mPJPo.js → EmojiGrid-THzuvC4K.js} +75 -84
  2. package/dist/blockeditor/BlockEditor.d.ts +16 -0
  3. package/dist/blockeditor/EmojiMenu.d.ts +17 -0
  4. package/dist/blockeditor/__tests__/EmojiMenu.test.d.ts +1 -0
  5. package/dist/blockeditor/__tests__/readOnly.test.d.ts +1 -0
  6. package/dist/blockeditor/__tests__/titleHandoff.test.d.ts +1 -0
  7. package/dist/blockeditor/__tests__/triggerMenus.test.d.ts +1 -0
  8. package/dist/blockeditor/kit/KitFrame.d.ts +5 -0
  9. package/dist/blockeditor/kit/lock.d.ts +14 -0
  10. package/dist/components/LastEditedBy.d.ts +11 -0
  11. package/dist/components/OnboardingNudge.d.ts +11 -0
  12. package/dist/components/ShareDialog.d.ts +25 -0
  13. package/dist/components/__tests__/lastEditedBy.test.d.ts +1 -0
  14. package/dist/components/__tests__/membersSettings.test.d.ts +1 -0
  15. package/dist/components/__tests__/sharingSettings.test.d.ts +1 -0
  16. package/dist/components/settings/AccountSettings.d.ts +4 -0
  17. package/dist/components/settings/AccountSwitcher.d.ts +12 -0
  18. package/dist/components/settings/MembersSettings.d.ts +1 -0
  19. package/dist/components/settings/SharingSettings.d.ts +9 -0
  20. package/dist/emoji-Bmft6RPl.js +11 -0
  21. package/dist/{format-CLQoRoYP.js → format-D9FO3jG9.js} +114 -114
  22. package/dist/i18n/messages/en.d.ts +169 -0
  23. package/dist/index.js +7238 -5976
  24. package/dist/lib/__tests__/useCanWrite.test.d.ts +1 -0
  25. package/dist/lib/hud.d.ts +2 -2
  26. package/dist/lib/sidebarStyles.d.ts +7 -0
  27. package/dist/lib/useCanWrite.d.ts +45 -0
  28. package/dist/{lucideIcons-B6pmC-WQ.js → lucideIcons-Dv5yzk2L.js} +1916 -1016
  29. package/dist/providers/AccountProvider.d.ts +47 -7
  30. package/dist/providers/ForwardingProvider.d.ts +19 -0
  31. package/dist/providers/PlatformLibraryProvider.d.ts +20 -0
  32. package/dist/providers/__tests__/AccountProvider.test.d.ts +1 -0
  33. package/dist/providers/__tests__/forwardingAudience.test.d.ts +9 -0
  34. package/dist/providers/forwardingAudience.d.ts +165 -0
  35. package/dist/screens/pageChrome.d.ts +11 -0
  36. package/dist/style.css +1 -1
  37. package/dist/{toHtml-BoPr8Ce4.js → toHtml-DeWpCU0o.js} +2 -2
  38. package/package.json +7 -2
@@ -12,23 +12,47 @@ import React, { PropsWithChildren } from 'react';
12
12
  *
13
13
  * Sync: pull on connect / app open (remote wins), then push the
14
14
  * `{preferences, workspaces}` blob on local change (debounced, last-writer-wins).
15
+ *
16
+ * Multi-account (OB-194): the client holds a **list** of connected accounts and
17
+ * an **active** one. Sign-in *adds* an account rather than replacing the current
18
+ * one; the active account is the identity presented to the data server and the
19
+ * one whose settings sync. Each account's device token is stored separately and
20
+ * namespaced (OS keychain on desktop, namespaced `localStorage` on web/dev) —
21
+ * no cross-account leakage. The single-account fields below keep reflecting the
22
+ * ACTIVE account, so a lone account behaves exactly as it did before.
15
23
  */
16
24
  export type AccountStatus = 'disconnected' | 'connecting' | 'syncing' | 'connected' | 'error';
25
+ /** One connected account in the multi-account list. Carries only non-secret
26
+ * metadata — the device token lives in the per-account secret store. */
27
+ export interface ConnectedAccount {
28
+ /** Stable local id for this account slot (namespaces its token + index row). */
29
+ id: string;
30
+ /** Display label — the active-persona email, else the name/account host. */
31
+ name: string;
32
+ /** The active-persona email (lowercased) when the identity JWS asserts one. */
33
+ email: string | null;
34
+ /** The account service base URL this account signed in to. */
35
+ accountUrl: string;
36
+ /** Connection status. The ACTIVE account tracks the live status; the others are
37
+ * dormant (reported as `connected` until they are made active). */
38
+ status: AccountStatus;
39
+ }
17
40
  interface AccountContextValue {
18
41
  status: AccountStatus;
19
42
  connected: boolean;
20
- /** The device bearer token, for same-app account API calls (e.g. forwarding's
21
- * POST /api/sites). Null when disconnected. Treat as a secret. */
43
+ /** The device bearer token of the ACTIVE account, for same-app account API
44
+ * calls (e.g. forwarding's POST /api/sites). Null when disconnected. Treat as
45
+ * a secret. */
22
46
  token: string | null;
23
47
  /** The label this device registers under (shown in the account dashboard). */
24
48
  deviceName: string;
25
- /** ISO timestamp of the last successful server sync, or null. */
49
+ /** ISO timestamp of the active account's last successful server sync, or null. */
26
50
  lastSyncedAt: string | null;
27
51
  /** A human-readable error from the last failed action, or null. */
28
52
  error: string | null;
29
- /** The account service base URL (for an "open dashboard" link). */
53
+ /** The active account's service base URL (for an "open dashboard" link). */
30
54
  accountUrl: string;
31
- /** Start the deep-link sign-in flow. */
55
+ /** Start the deep-link sign-in flow (additive — see {@link addAccount}). */
32
56
  signIn: () => void;
33
57
  /** Complete sign-in from a manually pasted code — the dev/fallback path for when
34
58
  * the `openbook://` deep link can't fire (the user dismisses the "open app?"
@@ -37,10 +61,26 @@ interface AccountContextValue {
37
61
  submitCode: (raw: string) => void;
38
62
  /** Abandon a pending sign-in (returns to disconnected when not yet connected). */
39
63
  cancel: () => void;
40
- /** Forget the local token (does not revoke it server-side — do that in the dashboard). */
64
+ /** Forget the ACTIVE account's token (does not revoke it server-side — do that in
65
+ * the dashboard). If other accounts remain, switches to one of them. */
41
66
  signOut: () => void;
42
- /** Pull-then-push a reconciliation now. */
67
+ /** Pull-then-push a reconciliation now (for the active account). */
43
68
  syncNow: () => void;
69
+ /** Every connected account, in connection order. */
70
+ accounts: ConnectedAccount[];
71
+ /** The id of the active account, or null when none is connected. */
72
+ activeAccountId: string | null;
73
+ /** Make `id` the active account: presents its identity, syncs its settings. */
74
+ setActiveAccount: (id: string) => void;
75
+ /** Start the sign-in flow to ADD an account (alias of {@link signIn}; the flow
76
+ * is additive — a new sign-in never evicts the accounts already connected). */
77
+ addAccount: () => void;
78
+ /** Forget account `id` locally (token + metadata). Switches active away from it. */
79
+ removeAccount: (id: string) => void;
80
+ /** Re-mint the active identity JWS now (e.g. after forwarding on/off changes the
81
+ * required audience, OB-202). Resolves to the audience the issuer scoped the new
82
+ * token to, or null when unscoped / nothing minted / disconnected. */
83
+ remintIdentity: () => Promise<string | null>;
44
84
  }
45
85
  /** Cross-window handoff (web): the callback page hands the minted token to the
46
86
  * running app over this BroadcastChannel (popup case) or localStorage key
@@ -1,5 +1,6 @@
1
1
  import React, { PropsWithChildren } from 'react';
2
2
  import { type TunnelStatus } from '@book.dev/sdk';
3
+ import { type AudienceNoticeCode } from './forwardingAudience';
3
4
  /** Combined provisioning/tunnel status. `idle` = never started this session. */
4
5
  export type ForwardingStatus = TunnelStatus | 'idle';
5
6
  interface ForwardingContextValue {
@@ -13,6 +14,24 @@ interface ForwardingContextValue {
13
14
  host: string | null;
14
15
  busy: boolean;
15
16
  error: string | null;
17
+ /**
18
+ * A localizable audience-bind/unbind notice (OB-202), shown when the tunnel is up
19
+ * but the audience hardening is incomplete (`partialUnscoped`/`ensureRescope`), a
20
+ * bind step threw (`bindFailed`), or a disable couldn't confirm the relax
21
+ * (`unbindHeld`). The view maps `code` to a `forwarding.*` string; `detail` is the
22
+ * raw error for the `{error}` codes. `null` when there's nothing to show.
23
+ */
24
+ audienceNotice: {
25
+ code: AudienceNoticeCode;
26
+ detail?: string;
27
+ } | null;
28
+ /**
29
+ * Why a publish was refused before the tunnel opened, for localized + severity-aware
30
+ * display: `unverified` is a precondition (the signed-in owner just needs a verified
31
+ * identity — render it muted, like {@link signInHint}); `claim-failed` is a genuine
32
+ * failure (render it as an error). `null` when there's nothing to show.
33
+ */
34
+ claimRefusal: 'unverified' | 'claim-failed' | null;
16
35
  /** Turn forwarding on: claim the address (sign-in first if needed) + dial out. */
17
36
  enable: () => Promise<void>;
18
37
  /** Turn forwarding off: drop the tunnel but keep the site key (stable address). */
@@ -30,6 +30,23 @@ export interface WindowControls {
30
30
  */
31
31
  watchMaximized?: (cb: (maximized: boolean) => void) => () => void;
32
32
  }
33
+ /**
34
+ * Per-account device-token storage, namespaced by a local account id (OB-194).
35
+ * The client can hold several account.book.pub accounts at once (work + personal),
36
+ * so each account's bearer token gets its own slot — no shared key, no
37
+ * cross-account leakage. The desktop backs this with the OS keychain (one entry
38
+ * per account id, like {@link ForwardingPlatform.keyStore}); the web shell — and
39
+ * unsigned desktop *dev* builds, whose per-relink cdhash loses keychain access —
40
+ * leave it undefined and the UI falls back to a namespaced-`localStorage` store.
41
+ */
42
+ export interface AccountSecretStore {
43
+ /** Read account `id`'s device token, or `null` when none is stored. */
44
+ get(id: string): Promise<string | null>;
45
+ /** Store (overwrite) account `id`'s device token. */
46
+ set(id: string, token: string): Promise<void>;
47
+ /** Forget account `id`'s device token. */
48
+ delete(id: string): Promise<void>;
49
+ }
33
50
  /**
34
51
  * How the host completes account.book.pub's deep-link sign-in. The desktop sets
35
52
  * a custom-scheme `redirectUri` (`openbook://auth-callback`), opens the browser
@@ -49,6 +66,9 @@ export interface AccountPlatform {
49
66
  token: string;
50
67
  state: string;
51
68
  }) => void) => () => void;
69
+ /** Secure, per-account device-token storage (OB-194). Omit on web / desktop dev;
70
+ * the UI then falls back to a namespaced-`localStorage` store. */
71
+ secretStore?: AccountSecretStore;
52
72
  }
53
73
  /**
54
74
  * How the host reads/writes an on-disk book folder (the human-readable
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Audience-bind orchestration (OB-202) — the failure-safe choreography that turns
3
+ * forwarding on/off without ever stranding the loopback owner. Exercised entirely
4
+ * through the `setInstancePolicy` / `getInstanceInfo` seams (i.e. `PUT/GET
5
+ * /api/instance`) over a fake instance, NOT by poking the store: these prove the
6
+ * three-phase ORDER, the mid-sequence rollback, the disable cleanup, and the
7
+ * relaunch ensure-scoped short-circuit.
8
+ */
9
+ export {};
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Audience-bind orchestration for forwarding (OB-202).
3
+ *
4
+ * Enabling the tunnel binds the instance's identity `audience` to its canonical
5
+ * `<prefix>.book.cloud` host with `requireAudience` (OB-177), so the edge's
6
+ * aud-scoped viewer tokens verify and a token minted for a *different* site is
7
+ * rejected. The catch: the local owner reaches the SAME server over loopback with
8
+ * their OWN token, so requiring the audience while that token is still unscoped
9
+ * would lock the owner out — yet the owner's token can't be scoped to the host
10
+ * until the server already accepts it.
11
+ *
12
+ * This module is the failure-safe choreography that resolves that chicken-and-egg
13
+ * WITHOUT ever stranding the owner. It is pure (all side effects injected) so the
14
+ * three-phase sequence, the mid-sequence rollback, the disable cleanup, and the
15
+ * relaunch short-circuit are all exercised against `setInstancePolicy` /
16
+ * `PUT /api/instance` in tests — never by poking the store directly.
17
+ */
18
+ import { type InstanceConfig, type InstanceInfo } from '@book.dev/sdk';
19
+ /** Side effects the audience-bind drives, injected so the logic stays testable. */
20
+ export interface AudienceBindDeps {
21
+ /** `PUT /api/instance` — the audience-policy write (gated server-side). */
22
+ setInstancePolicy(patch: Partial<InstanceConfig>): Promise<InstanceConfig>;
23
+ /** `GET /api/instance` — to read the persisted `audience` + `requireAudience`. */
24
+ getInstanceInfo(): Promise<InstanceInfo>;
25
+ /**
26
+ * Re-mint the owner's own identity token, scoped to whatever
27
+ * {@link setLocalAudience} last recorded. Resolves to the audience the issuer
28
+ * ACTUALLY scoped the minted token to — `null` when it returned an unscoped
29
+ * token (the issuer scopes only when it runs an audience allowlist) or when
30
+ * nothing could be minted. The bind decision turns on this REAL value, never on
31
+ * the audience we merely *asked* for.
32
+ */
33
+ remintIdentity(): Promise<string | null>;
34
+ /** Record (or clear, with `null`) the host the owner's token is scoped to. */
35
+ setLocalAudience(host: string | null): void;
36
+ }
37
+ /**
38
+ * A stable, localizable code for every non-clean audience outcome (OB-202). The UI
39
+ * maps each to a `forwarding.*` message via `t()`; the English `reason` carried
40
+ * alongside stays for logging and is the `{error}` detail for the failure codes.
41
+ */
42
+ export type AudienceNoticeCode = 'partialUnscoped' | 'ensureRescope' | 'bindFailed' | 'unbindHeld';
43
+ /** The result of binding (or ensuring) the forwarded audience. */
44
+ export type AudienceBindOutcome =
45
+ /** Phase 3 reached: `requireAudience` is on and the owner's token is host-scoped. */
46
+ {
47
+ status: 'bound';
48
+ }
49
+ /**
50
+ * The `audience` is set but `requireAudience` stays OFF, because no host-scoped
51
+ * owner token exists (the issuer doesn't scope, or the mint failed) — `partialUnscoped`
52
+ * — or a resumed session couldn't re-scope this launch (`ensureRescope`). A token for
53
+ * a *different* site is still rejected; only unscoped tokens stay accepted — no
54
+ * strict isolation, but crucially no loopback lockout.
55
+ */
56
+ | {
57
+ status: 'partial';
58
+ code: 'partialUnscoped' | 'ensureRescope';
59
+ reason: string;
60
+ }
61
+ /** A phase threw; the binding was relaxed back so loopback stays open. */
62
+ | {
63
+ status: 'failed';
64
+ code: 'bindFailed';
65
+ reason: string;
66
+ };
67
+ /** User-facing reason for the `partial` outcome (issuer returned an unscoped token). */
68
+ export declare const PARTIAL_UNSCOPED_REASON: string;
69
+ /** User-facing reason when a resumed session can't (yet) re-scope the owner token. */
70
+ export declare const ENSURE_RESCOPE_REASON: string;
71
+ /**
72
+ * Bind the forwarded `host` as this instance's required audience via the seamless
73
+ * three-phase switch — and never leave the instance requiring an audience the
74
+ * owner's own token can't satisfy:
75
+ *
76
+ * 1. **accept** the host audience but don't REQUIRE it (the owner's still-unscoped
77
+ * loopback token keeps verifying);
78
+ * 2. **scope** the owner's own token to the host AND confirm the issuer really
79
+ * scoped it — phase 3 proceeds ONLY on a confirmed host-scoped credential;
80
+ * 3. **require** the audience: owner (scoped) + edge tokens carry it, others fail.
81
+ *
82
+ * If phase 2 can't confirm a host-scoped token, hold at `requireAudience:false`
83
+ * (a `partial` outcome) rather than lock the owner out. If any phase throws, relax
84
+ * back to `requireAudience:false` (best-effort) so loopback stays open — the
85
+ * server's loopback-owner recovery still relaxes it even if that PUT is itself
86
+ * rejected.
87
+ */
88
+ export declare function bindForwardingAudience(host: string, deps: AudienceBindDeps): Promise<AudienceBindOutcome>;
89
+ /**
90
+ * Resume the audience binding on launch WITHOUT relaxing it every time. When the
91
+ * server already persisted `audience==host && requireAudience` (a previous session
92
+ * reached phase 3), only re-scope THIS session's owner token — don't transiently
93
+ * drop `requireAudience` (which would reopen the unscoped-token window on every
94
+ * relaunch). Otherwise (fresh enable, or a prior `partial`), run the full bind.
95
+ */
96
+ export declare function ensureForwardingAudience(host: string, deps: AudienceBindDeps): Promise<AudienceBindOutcome>;
97
+ /** The result of ensuring the instance is claimed before it is exposed. */
98
+ export type ForwardingClaimOutcome =
99
+ /** We atomically claimed ownership to the enabling account's verified subject. */
100
+ {
101
+ status: 'claimed';
102
+ }
103
+ /** Already claimed (by this owner or anyone) — nothing to do; safe to expose. */
104
+ | {
105
+ status: 'already';
106
+ }
107
+ /**
108
+ * Couldn't claim, so we won't expose. `code` is the stable discriminant the surface
109
+ * localizes + styles by severity: `unverified` is a precondition the signed-in owner
110
+ * clears by verifying their identity (NOT a crash); `claim-failed` is a genuine
111
+ * failure. `reason` is the English fallback for logs / non-UI callers — the UI routes
112
+ * `code` through `t()` (`forwarding.claimRefusedUnverified` / `forwarding.claimFailed`).
113
+ */
114
+ | {
115
+ status: 'refused';
116
+ code: 'unverified' | 'claim-failed';
117
+ reason: string;
118
+ };
119
+ /**
120
+ * English fallback when forwarding is refused because the account identity is not
121
+ * JWS-verified yet (on desktop the default owner is `verifiedVia:'local'`). The owner
122
+ * IS already signed in on this path — what's missing is a verified identity, not an
123
+ * account — so this guides verifying, not signing in. UI: `forwarding.claimRefusedUnverified`.
124
+ */
125
+ export declare const UNVERIFIED_CLAIM_REASON = "To publish, your account identity needs to be verified first.";
126
+ /** English fallback when the claim write did not land. UI: `forwarding.claimFailed`. */
127
+ export declare const CLAIM_FAILED_REASON = "Couldn\u2019t claim this device for your account, so it wasn\u2019t published. Try again.";
128
+ /**
129
+ * Publish-implies-claim (OB-209). Forwarding turns the local instance into a public
130
+ * ingress that BYPASSES the boot exposure backstop (`assertExposureSafe` only guards
131
+ * a listener bind; the tunnel reaches the loopback server). An UNCLAIMED instance
132
+ * short-circuits `authorize()` rule-0 to the legacy guest gate (default
133
+ * `guestAccess:'write'`) — so exposing it unclaimed = anonymous world-write. We
134
+ * therefore CLAIM before we expose: atomically bind ownership to the enabling
135
+ * account's OWN verified subject (the server routes this through the OB-191 CAS and
136
+ * only ever binds the verified principal — never a client-supplied value), and refuse
137
+ * to dial out when there is no verified identity to claim with. Idempotent: a
138
+ * re-enable on an already-claimed instance is a no-op.
139
+ */
140
+ export declare function ensureClaimedForForwarding(deps: AudienceBindDeps): Promise<ForwardingClaimOutcome>;
141
+ /** The result of unwinding the audience binding on disable. */
142
+ export type AudienceUnbindOutcome =
143
+ /** `requireAudience` was relaxed and the owner token re-minted unscoped. */
144
+ {
145
+ status: 'relaxed';
146
+ }
147
+ /** The relax was NOT confirmed; scoping was LEFT INTACT to avoid a lockout. */
148
+ | {
149
+ status: 'held';
150
+ code: 'unbindHeld';
151
+ reason: string;
152
+ };
153
+ /**
154
+ * Unwind the binding on disable in the SAFE order: relax `requireAudience` FIRST —
155
+ * while the owner's token is still scoped to the host, so the PUT verifies — and
156
+ * ONLY drop the local scoping + re-mint an unscoped owner token once the relax is
157
+ * CONFIRMED. If the relax fails (the owner is already audience-locked, or the
158
+ * server is unreachable), DON'T unscope: that would strand the owner behind a
159
+ * requirement their token no longer satisfies — a permanent loopback lockout.
160
+ * Leave the scoping intact (`held`) so the owner stays verified and can retry.
161
+ *
162
+ * The instance keeps its `audience` for address stability — a later re-enable just
163
+ * re-asserts `requireAudience`.
164
+ */
165
+ export declare function unbindForwardingAudience(deps: AudienceBindDeps): Promise<AudienceUnbindOutcome>;
@@ -36,11 +36,22 @@ export interface PageDocumentProps {
36
36
  * under the header instead of being pushed down by a big gap. */
37
37
  hasDatabase?: boolean;
38
38
  }
39
+ /** Imperative handle the host uses to hand the caret back from the editor. */
40
+ export interface PageTitleHandle {
41
+ /** Focus the title and place the caret at the end (editor → title hand-off). */
42
+ focusEnd(): void;
43
+ }
39
44
  export declare const PageHeader: React.FC<{
40
45
  title: string;
41
46
  icon: string;
42
47
  pageId?: string;
48
+ /** Read-only (a viewer who can't write the page): the title + icon lock down. */
49
+ readOnly?: boolean;
43
50
  onTitleChange?: (title: string) => void;
44
51
  onIconChange?: (emoji: string) => void;
45
52
  onTitleActiveChange?: (active: boolean) => void;
53
+ /** Hand the caret down to the editor (Enter, or ↓ on the title's last line). */
54
+ onLeaveToEditor?: () => void;
55
+ /** Lets the host (BlockPageDocument) drive focus back here from the editor. */
56
+ focusRef?: React.Ref<PageTitleHandle>;
46
57
  }>;