{"version":3,"sources":["../src/spacesMode.ts"],"sourcesContent":["// The spaces mode's route channel and its one navigation verb (R3-708).\n//\n// The spaces mode runs ONE app across six frames — three activity pairs — and they must\n// agree on which space, activity and bundle is selected. A reload or a pasted link has to\n// land every frame on the same state, so the selection is a route the HOST owns and\n// deep-links to (`SPACES_BRIEF` §3), not something the frames negotiate between\n// themselves.\n//\n// Two halves, and the asymmetry is the point:\n//\n//   read   `useSpacesMode()` — the host's parsed route, pushed down.\n//   write  `navigateSpaces(target)` — a request to MOVE, taking coordinates.\n//\n// The write half never takes a path string, now or later. An app that could spell the\n// destination could open anything (`BUNDLE_EMBEDDING_SPEC` §6.1a), so the SDK offers no\n// string overload and the host builds every URL from its own grammar\n// (`SPACES_BRIEF` §7, \"the host spells the URL\"). That is also why an app reads\n// `space.root` for its files rather than assuming a mount path.\nimport { protocolRequest } from './sandboxUtils';\nimport { throwOnRefusal } from './protocolRefusal';\nimport { createPushChannel } from './pushChannel';\nimport { SPACES_MODE, REQUEST_SPACES_MODE, PROTOCOL_SPACES_MODE } from './generated/protocol';\nimport { SCHEMES } from './protocolSchemes';\n\n/** The spaces mode's four top-level activities. */\nexport type SpacesActivity = 'spaces' | 'inbox' | 'people' | 'settings';\n\n/**\n * Where the user is inside the spaces mode, as the host parsed it.\n *\n * `spaceId` is `null` on a route that names no space, which is TWO routes, not one: the\n * launcher (`activity: 'spaces'`) and the **cross-space inbox** (`activity: 'inbox'` —\n * `/spaces/-/inbox`, every room across every space). Branch on the pair, not on `spaceId`\n * alone.\n *\n * The `-` in that URL is the host's reserved segment and never reaches here: it is not an\n * id, and an app must not send `spaceId: '-'` hoping to mean \"all spaces\" — say\n * `{ spaceId: null, activity: 'inbox' }`. The host spells the URL (`SPACES_BRIEF` §7); the\n * SDK never sees that grammar.\n *\n * The optional fields are the per-activity tail: `path` a bundle-relative file, `roomId` a\n * conversation, `member` the uid of `/spaces/<id>/people/<uid>`, `section` a settings pane.\n */\nexport interface SpacesRoute {\n  spaceId: string | null;\n  activity: SpacesActivity;\n  path?: string;\n  roomId?: string;\n  member?: string;\n  section?: string;\n}\n\n/**\n * The selected space, as the host resolved it for THIS frame.\n *\n * `root` is the path the host mounted the space at in this frame, at the reader's role —\n * or `null` in every frame the host did not mount it into. Read files under `root`; never\n * assume a mount path.\n *\n * `name` is untrusted display text supplied by whoever named the space. React escapes it\n * on render and the SDK does not sanitise it — treat it as content, not markup.\n *\n * `kind` (not `mode`) matches site-main's `SpaceKind`: one noun for the personal/shared\n * axis (R-IX-6).\n *\n * **None of this is a grant.** The object describes what the host chose to tell this\n * frame; it confers nothing. `role` is a label for deciding which affordances to *show* —\n * it is not permission to act, and an app that branches on `role === 'owner'` to skip\n * asking has only skipped its own UI, not the check. What IS enforced host-side is the\n * mount: `root` is mounted at the reader's role, so a reader's frame cannot write through\n * it (`site-main` `filesystem/liveRevocation.ts` derives the mount mode from the role, and\n * `editor/spaceHandler.ts` re-checks `role` on the owner-only paths).\n *\n * What is NOT enforced, and must not be read into the above: per-APP authority.\n * `UI_AS_APPS_SPEC` §8's audited gap is explicit that `mount` \"accepts an arbitrary\n * `spaceId` and enforces only user membership … every app currently inherits the user's\n * full authority\". So the host answers \"may the USER do this\", not \"may this app\", and a\n * frame holding this object is no more constrained than the reader is. Treat it as a\n * description of the reader's position, never as a sandbox around the app.\n *\n * A frame the host does not push to reads `null` — an absence of information, not a\n * denial, and equally not a reason to assume access.\n */\nexport interface SpacesModeSpace {\n  id: string;\n  name: string;\n  role: 'owner' | 'writer' | 'reader';\n  kind: 'personal' | 'shared';\n  root: string | null;\n}\n\n/** The host's view of this frame: where the user is, and which space that resolves to. */\nexport interface SpacesModeState {\n  route: SpacesRoute;\n  space: SpacesModeSpace | null;\n}\n\n/**\n * Where to move to.\n *\n * Either a route inside the mode, or the one host destination outside it —\n * `{ destination: 'notifications' }`, which the inventory panel's line pointing at pending\n * invitations uses so the app never spells a mode path itself.\n */\nexport type SpacesTarget = SpacesRoute | { destination: 'notifications' };\n\n/** Why the host refused to navigate.\n *\n *  - `invalid` — the target is not a route this host can build. This is also the code for\n *    a space the reader has no membership-granted view of, **deliberately**: P7 forbids an\n *    existence oracle, and `SPACES_BRIEF` §7 says a doclink into a space the reader is not\n *    a member of \"renders the same not-found as a link to nothing\". A distinct code here\n *    would be exactly the oracle — an app could enumerate spaceIds and read membership off\n *    the difference. Non-member and never-existed are one answer.\n *  - `forbidden` — this frame lacks the CAPABILITY to navigate at all. It is a statement\n *    about the caller, never about the target, so it discloses nothing about what exists.\n *  - `unsupported` — this host has no spaces mode wired.\n *  - `unknown` — the host refused without naming a code.\n *\n *  **What you actually catch.** The SDK throws a plain `Error` with `.code` assigned — a\n *  `CodedRefusalError` from `protocolRefusal.ts` — never a distinct class. These are the\n *  `.code` VALUES the host sends. Nothing enforces the union at runtime — `.code` is\n *  whatever string arrived — so treat an unlisted code as possible and `instanceof Error`\n *  as the only reliable test.\n *\n *  (This note is on the code alias rather than on the interface because the alias is where\n *  the codes are documented. Every doclink in this file names the INTERFACE, so a reader\n *  following one lands a hop away from this — a wart, not a plan. Stated without a\n *  direction on purpose: two earlier revisions of this sentence asserted a positional\n *  claim, and both were wrong.) */\nexport type NavigateSpacesErrorCode = 'invalid' | 'forbidden' | 'unsupported' | 'unknown';\n\nexport interface NavigateSpacesError extends Error {\n  code: NavigateSpacesErrorCode;\n}\n\nconst ACTIVITIES: readonly string[] = ['spaces', 'inbox', 'people', 'settings'];\nconst ROLES: readonly string[] = ['owner', 'writer', 'reader'];\nconst KINDS: readonly string[] = ['personal', 'shared'];\n\nconst isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);\n\n/** A present field must be a string; an absent one is fine. Anything else fails. */\nconst optionalString = (v: unknown): boolean => v === undefined || typeof v === 'string';\n\nconst parseRoute = (v: unknown): SpacesRoute | undefined => {\n  if (!isRecord(v)) return undefined;\n  if (typeof v.activity !== 'string' || !ACTIVITIES.includes(v.activity)) return undefined;\n  if (!(v.spaceId === null || typeof v.spaceId === 'string')) return undefined;\n  if (!optionalString(v.path) || !optionalString(v.roomId)) return undefined;\n  if (!optionalString(v.member) || !optionalString(v.section)) return undefined;\n  const route: SpacesRoute = {\n    spaceId: v.spaceId as string | null,\n    activity: v.activity as SpacesActivity,\n  };\n  // Copied field by field rather than spread: a host that adds a field tomorrow must not\n  // have it silently appear on an object apps treat as this version's shape.\n  if (typeof v.path === 'string') route.path = v.path;\n  if (typeof v.roomId === 'string') route.roomId = v.roomId;\n  if (typeof v.member === 'string') route.member = v.member;\n  if (typeof v.section === 'string') route.section = v.section;\n  return route;\n};\n\nconst parseSpace = (v: unknown): SpacesModeSpace | null | undefined => {\n  if (v === null) return null;\n  if (!isRecord(v)) return undefined;\n  if (typeof v.id !== 'string' || typeof v.name !== 'string') return undefined;\n  if (typeof v.role !== 'string' || !ROLES.includes(v.role)) return undefined;\n  if (typeof v.kind !== 'string' || !KINDS.includes(v.kind)) return undefined;\n  if (!(v.root === null || typeof v.root === 'string')) return undefined;\n  return {\n    id: v.id,\n    name: v.name,\n    role: v.role as SpacesModeSpace['role'],\n    kind: v.kind as SpacesModeSpace['kind'],\n    root: v.root as string | null,\n  };\n};\n\n// Tolerant parse. `undefined` means \"ignore this message\", so a malformed push from a\n// newer or older host leaves the LAST GOOD value standing instead of flipping every frame\n// to a route nobody sent. Nothing in here throws: a throw in the listener would take down\n// the push loop for every channel sharing it.\nconst parseState = (state: unknown): SpacesModeState | null | undefined => {\n  // A pushed `null` is a RETRACTION, not a malformed message, and the wire says so: the\n  // channel's declared value is `SpacesModeState | null` (sandbox-protocol 0.11.0,\n  // `snapshots/sdk.json`, `spaces-mode.value`). Folding it into the `isRecord` guard below\n  // made it mean \"ignore\", which left the host with no way to say \"this frame has left the\n  // mode\" — the last route would stand forever, and an app would keep rendering a space it\n  // is no longer in. `null` here is distinct from `undefined`: it SETS the value.\n  if (state === null) return null;\n  if (!isRecord(state)) return undefined;\n  const route = parseRoute(state.route);\n  if (!route) return undefined;\n  const space = parseSpace(state.space);\n  if (space === undefined) return undefined;\n  return { route, space };\n};\n\n// `initial: null` is the whole compatibility story, and it is deliberately NOT a synthetic\n// `{ activity: 'spaces' }`: an app must be able to tell \"the host has not told me anything\"\n// from \"the host says I am on the launcher\". A frame outside the mode, a standalone tab,\n// `vite dev`, an older host, or a frame lacking the read capability all stay `null`, and an\n// app falls back to its pre-mode UI there.\nconst channel = createPushChannel<SpacesModeState | null>({\n  pushType: SPACES_MODE,\n  requestType: REQUEST_SPACES_MODE,\n  initial: null,\n  // Written inline, reading `msg.state` here rather than inside the helper: the snapshot\n  // gate extracts a push channel's `reads` from THIS expression, and a bare function\n  // reference tells it nothing — it falls back to the parameter type and the wire shape\n  // reads `Record<string, unknown>` instead of `{reads:[\"state\"]}`.\n  parse: (msg) => parseState(msg.state),\n});\n\n/**\n * The host's spaces-mode state for this frame, or `null` when this app is not running in\n * the spaces mode — a standalone tab, `vite dev`, an older host, or a frame the host does\n * not push this to.\n *\n * `null` is a real answer, not a placeholder: fall back to your own UI rather than\n * assuming a route.\n */\nexport const getSpacesMode = (): SpacesModeState | null => channel.get();\n\n/**\n * Subscribe to spaces-mode changes. The listener is invoked immediately with the current\n * value, then on every change. Returns an unsubscribe function.\n */\nexport const onSpacesModeChange = (listener: (state: SpacesModeState | null) => void): (() => void) =>\n  channel.onChange(listener);\n\n/**\n * React hook form of {@link getSpacesMode} — re-renders when the host's route changes.\n *\n * ```tsx\n * const mode = useSpacesMode();\n * if (!mode) return <MyStandaloneUI />;            // not in the spaces mode\n * if (!mode.space) return <Launcher />;            // no space selected\n * if (!mode.space.root) return <NotMountedHere />; // selected, but not mounted into THIS\n * return <Files root={mode.space.root} />;         // never assume a mount path\n * ```\n *\n * The third guard is not defensive padding: `root` is `null` in every frame the host did\n * not mount the space into, which is every frame but the launcher's. Passing it through\n * unchecked is the mistake this example exists to prevent.\n */\nexport const useSpacesMode = (): SpacesModeState | null => channel.use();\n\n/** The host's reply ENVELOPE (site-main's `SpaceResult`). A refusal is a RESOLVED\n *  `{ ok: false, code, message }` frame, not a transport-level rejection — see\n *  `protocolRefusal.ts` for which `ok` this is and why a handler must THROW\n *  `spaceError` rather than return `{ ok: false }`. R3-707's `navigate` handler must\n *  throw. */\ntype NavigateReply = { ok: true; data?: unknown } | { ok: false; code?: string; message?: string };\n\n/**\n * Ask the host to move the spaces mode to `target`.\n *\n * Resolves once the host has navigated. Rejects with a typed {@link NavigateSpacesError}\n * carrying `code` when the host refuses.\n *\n * The target is always structured coordinates — there is no path-string form, because an\n * app that could spell the destination could open anything. To leave the mode for the one\n * host destination outside it, pass `{ destination: 'notifications' }`.\n *\n * ```ts\n * await navigateSpaces({ spaceId: 'abc', activity: 'spaces', path: 'notes/today.mdx' });\n * await navigateSpaces({ destination: 'notifications' });\n * ```\n */\nexport async function navigateSpaces(target: SpacesTarget): Promise<void> {\n  const res = (await protocolRequest(SCHEMES[PROTOCOL_SPACES_MODE], 'navigate', [target])) as NavigateReply;\n  throwOnRefusal(res, 'spaces navigation refused');\n}\n"],"mappings":"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","names":[]}