fauna-api 1.0.686 → 1.0.692

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.
@@ -726,8 +726,9 @@ function frameScopedRule(rule) {
726
726
  return `${text.slice(0, open)}{ ${inner.join("\n")} }`;
727
727
  }
728
728
  /**
729
- * Re-render a `WebSnapshot` into a target document (typically an isolated iframe
730
- * or shadow root on the viewer) and apply incoming `WebMutation`s. The replayer
729
+ * Re-render a shared page into a frame inside `mount` and apply incoming `WebMutation`s.
730
+ * The page is someone else's, so the frame is sandboxed without scripts: the replayer
731
+ * writes into it from this page, and nothing the page carries ever runs. The replayer
731
732
  * keeps its own id->Node map mirroring the recorder's synthetic ids.
732
733
  */
733
734
  export class DomReplayer {
@@ -756,8 +757,8 @@ export class DomReplayer {
756
757
  writable: true,
757
758
  value: null
758
759
  });
759
- // Mutations that arrive while a full document's frame loads. They follow its
760
- // snapshot, so they apply once its ids are bound; a newer snapshot drops them.
760
+ // Mutations that arrive while a frame loads. They follow its snapshot, so they
761
+ // apply once its ids are bound; a newer snapshot drops them.
761
762
  Object.defineProperty(this, "queued", {
762
763
  enumerable: true,
763
764
  configurable: true,
@@ -806,64 +807,65 @@ export class DomReplayer {
806
807
  return this.mount;
807
808
  }
808
809
  /**
809
- * Apply a full cleaned HTML document (or fragment). Uses an iframe when the
810
- * payload looks like a full document so `<html>/<head>/<body>` structure and
811
- * DFS ids match the host.
810
+ * A fresh frame for a replay, loaded with `srcdoc`. Same origin without scripts: this
811
+ * page can write into it, and the shared page can't run anything there. Null when a
812
+ * newer replay started while it loaded.
813
+ */
814
+ async openFrame(srcdoc, load) {
815
+ this.queued = [];
816
+ this.mount.replaceChildren();
817
+ const iframe = this.mount.ownerDocument.createElement("iframe");
818
+ iframe.setAttribute("sandbox", "allow-same-origin");
819
+ iframe.style.cssText = "border:0;width:100%;height:100%;background:#fff;display:block;";
820
+ this.mount.appendChild(iframe);
821
+ this.iframe = iframe;
822
+ try {
823
+ await new Promise((resolve, reject) => {
824
+ iframe.onload = () => resolve();
825
+ iframe.onerror = () => reject(new Error("Failed to load shared page frame."));
826
+ iframe.srcdoc = srcdoc;
827
+ });
828
+ }
829
+ catch (err) {
830
+ if (load === this.loads)
831
+ this.queued = null;
832
+ throw err;
833
+ }
834
+ return load === this.loads ? iframe : null;
835
+ }
836
+ flushQueued() {
837
+ const queued = this.queued ?? [];
838
+ this.queued = null;
839
+ for (const mutation of queued)
840
+ this.mutate(mutation);
841
+ }
842
+ /**
843
+ * Apply a cleaned HTML document, or a fragment, which the frame parses into one.
844
+ * The `<html>/<head>/<body>` structure and DFS ids match the host's.
812
845
  */
813
846
  async applyHtml(html, viewport) {
814
847
  this.nodes.clear();
815
848
  this._viewport = viewport ?? null;
816
- const looksFull = /<html[\s>]/i.test(html);
817
849
  const load = ++this.loads;
818
- if (looksFull) {
819
- this.queued = [];
820
- this.mount.replaceChildren();
821
- const iframe = this.mount.ownerDocument.createElement("iframe");
822
- // Scripts/handlers are stripped on the host; keep the frame inert.
823
- // No allow-scripts — shared pages must not execute host JS in the guest.
824
- iframe.setAttribute("sandbox", "allow-same-origin allow-forms");
825
- iframe.style.cssText = "border:0;width:100%;height:100%;background:#fff;display:block;";
826
- this.mount.appendChild(iframe);
827
- this.iframe = iframe;
828
- // Defense in depth: drop scripts that survived capture so Chrome doesn't spam
829
- // sandbox console errors (no allow-scripts by design). Script preloads stay as
830
- // inert links: the host numbers them, so removing one would shift every id after.
831
- const inert = html
832
- .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
833
- .replace(/<script\b[^>]*\/>/gi, "")
834
- .replace(/<link\b[^>]*rel=["'][^"']*preload[^"']*["'][^>]*>/gi, (tag) => /\bas=["'](script|module)["']/i.test(tag) || /rel=["']modulepreload["']/i.test(tag)
835
- ? tag.replace(/rel=["'][^"']*["']/i, 'rel="fauna-inert"')
836
- : tag);
837
- try {
838
- await new Promise((resolve, reject) => {
839
- iframe.onload = () => resolve();
840
- iframe.onerror = () => reject(new Error("Failed to load shared page frame."));
841
- iframe.srcdoc = inert;
842
- });
843
- }
844
- catch (err) {
845
- if (load === this.loads)
846
- this.queued = null;
847
- throw err;
848
- }
849
- if (load !== this.loads)
850
- return;
851
- this.bindIds();
852
- this.restyle();
853
- // A textarea shows its text, not its value attribute.
854
- for (const field of Array.from(iframe.contentDocument?.querySelectorAll("textarea[value]") ?? [])) {
855
- field.value = field.getAttribute("value") ?? "";
856
- }
857
- const queued = this.queued ?? [];
858
- this.queued = null;
859
- for (const mutation of queued)
860
- this.mutate(mutation);
850
+ // Drop scripts that survived capture so Chrome doesn't log a sandbox error for each.
851
+ // Script preloads stay as inert links: the host numbers them, so removing one would
852
+ // shift every id after.
853
+ const inert = html
854
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "")
855
+ .replace(/<script\b[^>]*\/>/gi, "")
856
+ .replace(/<link\b[^>]*rel=["'][^"']*preload[^"']*["'][^>]*>/gi, (tag) => /\bas=["'](script|module)["']/i.test(tag) || /rel=["']modulepreload["']/i.test(tag)
857
+ ? tag.replace(/rel=["'][^"']*["']/i, 'rel="fauna-inert"')
858
+ : tag);
859
+ const iframe = await this.openFrame(inert, load);
860
+ if (!iframe)
861
861
  return;
862
- }
863
- this.queued = null;
864
- this.iframe = null;
865
- this.mount.innerHTML = html;
866
862
  this.bindIds();
863
+ this.restyle();
864
+ // A textarea shows its text, not its value attribute.
865
+ for (const field of Array.from(iframe.contentDocument?.querySelectorAll("textarea[value]") ?? [])) {
866
+ field.value = field.getAttribute("value") ?? "";
867
+ }
868
+ this.flushQueued();
867
869
  }
868
870
  /** Bind synthetic ids: from the ids a recorded snapshot carries, else in DFS order to
869
871
  * match a recorder that numbers in that order. */
@@ -1056,16 +1058,24 @@ export class DomReplayer {
1056
1058
  ownerDoc() {
1057
1059
  return this.iframe?.contentDocument ?? this.mount.ownerDocument;
1058
1060
  }
1059
- build(id, map) {
1061
+ /** `seen` keeps a subtree that names a node twice (or itself) from building forever. */
1062
+ build(id, map, seen = new Set()) {
1060
1063
  const spec = map[id];
1061
- if (!spec)
1064
+ if (!spec || seen.has(id))
1062
1065
  return null;
1066
+ seen.add(id);
1063
1067
  if (spec.kind === "text") {
1064
1068
  const text = this.ownerDoc().createTextNode(spec.text);
1065
1069
  this.nodes.set(id, text);
1066
1070
  return text;
1067
1071
  }
1068
- const el = this.ownerDoc().createElement(spec.tag);
1072
+ let el;
1073
+ try {
1074
+ el = this.ownerDoc().createElement(spec.tag);
1075
+ }
1076
+ catch {
1077
+ return null;
1078
+ }
1069
1079
  for (const [name, value] of Object.entries(spec.attributes)) {
1070
1080
  try {
1071
1081
  el.setAttribute(name, value);
@@ -1074,7 +1084,7 @@ export class DomReplayer {
1074
1084
  }
1075
1085
  this.nodes.set(id, el);
1076
1086
  for (const childId of spec.children) {
1077
- const child = this.build(childId, map);
1087
+ const child = this.build(childId, map, seen);
1078
1088
  if (child)
1079
1089
  el.appendChild(child);
1080
1090
  }
@@ -1087,16 +1097,20 @@ export class DomReplayer {
1087
1097
  return this.build(node.id, map);
1088
1098
  }
1089
1099
  /** Render a fresh snapshot, replacing whatever the mount currently holds. */
1090
- apply(snapshot) {
1091
- this.loads++;
1092
- this.queued = null;
1100
+ async apply(snapshot) {
1093
1101
  this.nodes.clear();
1094
- this.iframe = null;
1095
- this.mount.innerHTML = "";
1096
1102
  this._viewport = snapshot.viewport ?? null;
1103
+ const load = ++this.loads;
1104
+ const iframe = await this.openFrame("<!doctype html><html><head></head><body></body></html>", load);
1105
+ if (!iframe)
1106
+ return;
1107
+ const doc = iframe.contentDocument;
1097
1108
  const root = this.build(snapshot.root, snapshot.nodes);
1098
- if (root)
1099
- this.mount.appendChild(root);
1109
+ if (root?.nodeName === "HTML")
1110
+ doc.replaceChild(root, doc.documentElement);
1111
+ else if (root)
1112
+ doc.body.replaceChildren(root);
1113
+ this.flushQueued();
1100
1114
  }
1101
1115
  /** Apply one incremental mutation to the rendered tree. */
1102
1116
  mutate(mutation) {
@@ -1370,6 +1384,11 @@ export function pageToHtml(root = document.documentElement) {
1370
1384
  wrap.appendChild(frameClone);
1371
1385
  return wrap;
1372
1386
  }
1387
+ // The host streams this frame's document into the wrapper itself.
1388
+ if (recorder?.remote(el)) {
1389
+ wrap.setAttribute("data-fauna-iframe", "remote");
1390
+ return wrap;
1391
+ }
1373
1392
  wrap.setAttribute("data-fauna-iframe", "blocked");
1374
1393
  wrap.setAttribute("style", "display:flex;align-items:center;justify-content:center;min-height:48px;background:#f3f3f3;color:#666;font:12px/1.4 system-ui,sans-serif;border:1px dashed #bbb;");
1375
1394
  wrap.appendChild(markedText("Embedded frame unavailable in share", recorder?.placeholder(el)));
@@ -1487,10 +1506,18 @@ export function pageToHtml(root = document.documentElement) {
1487
1506
  cssUrls: frozen.urls,
1488
1507
  };
1489
1508
  }
1490
- /** Install a MutationObserver that posts WebMutations to `window[pushName]`.
1509
+ /** Install a MutationObserver that posts WebMutations to `window[pushName]` as
1510
+ * `{ token, mutation }`, so the host knows which recorder sent each one.
1491
1511
  * Framed documents are walked, observed, and re-inlined on navigation, with
1492
- * their html, head, and body addressed as the divs `pageToHtml` emits. */
1493
- export function pageInstallRecorder(pushName) {
1512
+ * their html, head, and body addressed as the divs `pageToHtml` emits.
1513
+ * With `remoteFrames`, a frame whose document this page can't read is left to
1514
+ * the host, which streams it from the frame itself: its wrapper says
1515
+ * `data-fauna-iframe="remote"` and holds nothing. With `framed`, this document
1516
+ * is such a frame, recorded the way a parent would inline it, and
1517
+ * `__faunaRecorder.snapshot()` serializes it. */
1518
+ export function pageInstallRecorder(pushName, options) {
1519
+ const framed = options.framed === true;
1520
+ const remoteFrames = options.remoteFrames === true;
1494
1521
  const BLOCKED_ATTR_PREFIXES = ["on"];
1495
1522
  const BLOCKED_TAGS = new Set(["SCRIPT", "NOSCRIPT"]);
1496
1523
  const FRAME_TAGS = new Set(["IFRAME", "FRAME", "OBJECT", "EMBED"]);
@@ -1664,7 +1691,7 @@ export function pageInstallRecorder(pushName) {
1664
1691
  if (!node)
1665
1692
  return null;
1666
1693
  if (node.nodeType === Node.DOCUMENT_NODE)
1667
- return node === document ? null : node.documentElement;
1694
+ return node === document && !framed ? null : node.documentElement;
1668
1695
  return node.nodeType === Node.ELEMENT_NODE ? node : null;
1669
1696
  };
1670
1697
  const scrollOf = (el) => {
@@ -1715,7 +1742,8 @@ export function pageInstallRecorder(pushName) {
1715
1742
  doc.removeEventListener("input", onInput, true);
1716
1743
  doc.removeEventListener("scroll", onScroll, true);
1717
1744
  };
1718
- const push = window[pushName];
1745
+ const binding = window[pushName];
1746
+ const push = binding ? (mutation) => binding({ token: options.token, mutation }) : undefined;
1719
1747
  const isBlockedAttr = (name) => BLOCKED_ATTR_PREFIXES.some((p) => name.toLowerCase().startsWith(p));
1720
1748
  const idFor = (node) => {
1721
1749
  let id = ids.get(node);
@@ -1735,7 +1763,7 @@ export function pageInstallRecorder(pushName) {
1735
1763
  }
1736
1764
  };
1737
1765
  const frameRootTag = (el) => {
1738
- if (el.ownerDocument === document)
1766
+ if (el.ownerDocument === document && !framed)
1739
1767
  return "";
1740
1768
  const tag = el.tagName.toLowerCase();
1741
1769
  return tag === "html" || tag === "head" || tag === "body" ? tag : "";
@@ -1784,7 +1812,7 @@ export function pageInstallRecorder(pushName) {
1784
1812
  // node holding its rules, like pageToHtml, so both number the same nodes. Viewers fetch
1785
1813
  // nothing cross origin, and the copy can scope it. A sheet still loading sends its
1786
1814
  // rules when it loads.
1787
- const isFrameSheet = (el) => el.tagName === "LINK" && el.ownerDocument !== document && /\bstylesheet\b/i.test(el.getAttribute("rel") ?? "");
1815
+ const isFrameSheet = (el) => el.tagName === "LINK" && (el.ownerDocument !== document || framed) && /\bstylesheet\b/i.test(el.getAttribute("rel") ?? "");
1788
1816
  const sheetCss = (link) => {
1789
1817
  try {
1790
1818
  const sheet = link.sheet;
@@ -1936,6 +1964,10 @@ export function pageInstallRecorder(pushName) {
1936
1964
  attributes["data-fauna-iframe"] = "inlined";
1937
1965
  return serializeNode(frame.doc.documentElement, into);
1938
1966
  }
1967
+ if (remoteFrames) {
1968
+ attributes["data-fauna-iframe"] = "remote";
1969
+ return null;
1970
+ }
1939
1971
  attributes["data-fauna-iframe"] = "blocked";
1940
1972
  attributes.style = PLACEHOLDER_STYLE;
1941
1973
  const textId = frame.placeholder ?? nextId++;
@@ -1971,8 +2003,8 @@ export function pageInstallRecorder(pushName) {
1971
2003
  attributes[attr.name] = absoluteAttr(el, attr.name, attr.value);
1972
2004
  }
1973
2005
  if (isFrame) {
1974
- const children = [serializeFrameContent(watchFrame(el), into, attributes)];
1975
- into[id] = { id, kind: "element", tag: "div", attributes, children };
2006
+ const content = serializeFrameContent(watchFrame(el), into, attributes);
2007
+ into[id] = { id, kind: "element", tag: "div", attributes, children: content === null ? [] : [content] };
1976
2008
  return id;
1977
2009
  }
1978
2010
  const children = [];
@@ -2010,7 +2042,7 @@ export function pageInstallRecorder(pushName) {
2010
2042
  push?.({ kind: "attribute", id: parent, name: "data-fauna-iframe", value: wrapper["data-fauna-iframe"] ?? null });
2011
2043
  const style = el.getAttribute("style");
2012
2044
  push?.({ kind: "attribute", id: parent, name: "style", value: wrapper.style ?? (style === null ? null : absoluteAttr(el, "style", style)) });
2013
- const node = map[childId];
2045
+ const node = childId === null ? undefined : map[childId];
2014
2046
  if (node)
2015
2047
  push?.({ kind: "insert", parent, node, subtree: Object.values(map), before: null });
2016
2048
  };
@@ -2040,11 +2072,11 @@ export function pageInstallRecorder(pushName) {
2040
2072
  if (view?.scrollX || view?.scrollY)
2041
2073
  scrolledAtInstall.push(frame.doc.documentElement);
2042
2074
  }
2043
- else
2075
+ else if (!remoteFrames)
2044
2076
  frame.placeholder = nextId++;
2045
2077
  return;
2046
2078
  }
2047
- if (el !== document.documentElement && (el.scrollTop || el.scrollLeft))
2079
+ if (el === document.documentElement ? framed && (window.scrollX || window.scrollY) : el.scrollTop || el.scrollLeft)
2048
2080
  scrolledAtInstall.push(el);
2049
2081
  for (const child of Array.from(el.childNodes))
2050
2082
  walkBind(child);
@@ -2063,10 +2095,17 @@ export function pageInstallRecorder(pushName) {
2063
2095
  id: (node) => ids.get(node),
2064
2096
  sheetText: (link) => sheetTexts.get(link),
2065
2097
  placeholder: (el) => frames.get(el)?.placeholder ?? undefined,
2098
+ remote: (el) => remoteFrames && frames.has(el) && !frames.get(el).doc,
2099
+ snapshot: () => {
2100
+ const nodes = {};
2101
+ return { root: serializeNode(document.documentElement, nodes), nodes };
2102
+ },
2066
2103
  };
2067
2104
  const observer = new MutationObserver(handle);
2068
2105
  observer.observe(root, { subtree: true, childList: true, attributes: true, characterData: true });
2069
- const onResize = () => push?.({ kind: "viewport", width: window.innerWidth, height: window.innerHeight });
2106
+ // A frame's size is its parent's business.
2107
+ const onResize = () => { if (!framed)
2108
+ push?.({ kind: "viewport", width: window.innerWidth, height: window.innerHeight }); };
2070
2109
  window.addEventListener("resize", onResize);
2071
2110
  document.addEventListener("mouseover", onOver, true);
2072
2111
  document.addEventListener("mouseout", onOut, true);
@@ -47,10 +47,11 @@ export type RoomToolState = {
47
47
  result?: string;
48
48
  /** What a running tool is waiting on, like the machine starting. */
49
49
  progress?: string;
50
- /** The live room the tool opened, to watch it: a browser, or a machine's desktop, by access code. */
50
+ /** The live room the tool opened, to watch it: a browser, joined with its access code, or a machine's desktop, joined by reference by the people who may use the machine. */
51
51
  session?: {
52
52
  kind: "browser" | "desktop";
53
- code: string;
53
+ reference: string;
54
+ code?: string;
54
55
  url?: string;
55
56
  };
56
57
  };
@@ -96,6 +97,7 @@ export declare class ChatRoom extends Room {
96
97
  private _human;
97
98
  private _repository;
98
99
  private _owner;
100
+ private _write;
99
101
  private _reference;
100
102
  private hooks;
101
103
  private target;
@@ -112,6 +114,8 @@ export declare class ChatRoom extends Room {
112
114
  getSystemPrompt(): string;
113
115
  /** True for a people only chat: no model ever replies, so sends never queue. */
114
116
  isHuman(): boolean;
117
+ /** False when you joined with a share link that only lets you read along. */
118
+ canWrite(): boolean;
115
119
  /** The chat's repository, by id. Kept current for everyone in the room. */
116
120
  get repository(): number | null;
117
121
  /** The saved chat this room is bound to, by id, or null for an unbound room. */
@@ -74,6 +74,12 @@ export class ChatRoom extends Room {
74
74
  writable: true,
75
75
  value: ""
76
76
  });
77
+ Object.defineProperty(this, "_write", {
78
+ enumerable: true,
79
+ configurable: true,
80
+ writable: true,
81
+ value: true
82
+ });
77
83
  Object.defineProperty(this, "_reference", {
78
84
  enumerable: true,
79
85
  configurable: true,
@@ -134,6 +140,10 @@ export class ChatRoom extends Room {
134
140
  isHuman() {
135
141
  return this._human;
136
142
  }
143
+ /** False when you joined with a share link that only lets you read along. */
144
+ canWrite() {
145
+ return this._write;
146
+ }
137
147
  /** The chat's repository, by id. Kept current for everyone in the room. */
138
148
  get repository() {
139
149
  return this._repository;
@@ -291,6 +301,7 @@ export class ChatRoom extends Room {
291
301
  this._human = state?.chat?.human === true;
292
302
  this._repository = typeof state?.chat?.repository === "number" && state.chat.repository > 0 ? state.chat.repository : null;
293
303
  this._owner = typeof state?.chat?.owner === "string" ? state.chat.owner : "";
304
+ this._write = state?.chat?.write !== false;
294
305
  this._reference = typeof state?.room?.reference === "string" ? state.room.reference : "";
295
306
  this.messages = Array.isArray(state?.messages) ? [...state.messages] : [];
296
307
  this.queue = Array.isArray(state?.queue) ? [...state.queue] : [];
@@ -21,12 +21,15 @@ export declare function moveChatToGroup(credentials: Credentials, chatId: string
21
21
  * chat (reference becomes `parrot:chat:{chatId}`, billed to the chat's
22
22
  * balance-holder) and seed the newest window from durable; omit it for an
23
23
  * unbound ephemeral room. `messages` seeds only ephemeral rooms. `share: true`
24
- * also mints an access code. Returns the room's `reference` (+ `code` when shared).
24
+ * also mints an access code; people who join with it read along, and with
25
+ * `write: true` they can send too (replies to them run without the chat's
26
+ * repository and memories). Returns the room's `reference` (+ `code` when shared).
25
27
  */
26
28
  export declare function openRoom(authorization: Credentials | ApiKey, options?: {
27
29
  chatId?: string;
28
30
  messages?: Content[];
29
31
  share?: boolean;
32
+ write?: boolean;
30
33
  }): Promise<{
31
34
  reference: string;
32
35
  roomId: string;
@@ -19,7 +19,9 @@ export async function moveChatToGroup(credentials, chatId, groupId) {
19
19
  * chat (reference becomes `parrot:chat:{chatId}`, billed to the chat's
20
20
  * balance-holder) and seed the newest window from durable; omit it for an
21
21
  * unbound ephemeral room. `messages` seeds only ephemeral rooms. `share: true`
22
- * also mints an access code. Returns the room's `reference` (+ `code` when shared).
22
+ * also mints an access code; people who join with it read along, and with
23
+ * `write: true` they can send too (replies to them run without the chat's
24
+ * repository and memories). Returns the room's `reference` (+ `code` when shared).
23
25
  */
24
26
  export async function openRoom(authorization, options = {}) {
25
27
  const messages = options.messages ? await serializeTranscript(options.messages) : undefined;
@@ -29,7 +31,7 @@ export async function openRoom(authorization, options = {}) {
29
31
  "Content-Type": "application/json",
30
32
  "Authorization": "email" in authorization ? `Token ${authorization.token}` : `ApiKey ${authorization.id}`,
31
33
  },
32
- body: JSON.stringify({ chatId: options.chatId, messages, share: options.share }),
34
+ body: JSON.stringify({ chatId: options.chatId, messages, share: options.share, write: options.write }),
33
35
  });
34
36
  if (!response.ok)
35
37
  throw new Error(await response.text());
@@ -1,8 +1,11 @@
1
+ import { Fauna } from "../index.js";
1
2
  import { ApiKey, Credentials, Profile } from "./authentification.js";
2
3
  import { Presence, Room, Hooks as ServerHooks } from "./shared/server.js";
3
4
  /** What a member may do: `read` sees the state, `write` can also set it. */
4
5
  export type Role = 'read' | 'write';
5
- /** Who is in a room: every member of a group, or one person by email. */
6
+ /** Who is in a room: everyone in a group, directly or through a nested group, or
7
+ * one person by email. A person's room takes groups its owner is in; a group's
8
+ * room takes groups nested inside it. */
6
9
  export type Member = ({
7
10
  group: string;
8
11
  } | {
@@ -10,10 +13,12 @@ export type Member = ({
10
13
  }) & {
11
14
  role: Role;
12
15
  };
13
- /** A live room an app (or a person) owns. Everyone in it sees `state` change live. */
16
+ /** A live room a person or a group owns. Everyone in it sees `state` change live. */
14
17
  export type LiveRoom = {
15
18
  id: string;
16
19
  name: string;
20
+ /** The group that owns it; absent when a person does. */
21
+ group?: string;
17
22
  members: Member[];
18
23
  /** Any JSON, up to 256 KB, always set whole. Fauna never reads it. */
19
24
  state: unknown;
@@ -22,39 +27,60 @@ export type LiveRoom = {
22
27
  created: Date;
23
28
  updated: Date;
24
29
  };
30
+ export declare const LiveRoom: {
31
+ fromJSON(json: any): LiveRoom;
32
+ };
25
33
  /** What `create` takes. Every field is optional. */
26
34
  export type Draft = {
27
35
  name?: string;
36
+ /** A group you are a direct admin of, to own the room. Without it, you own it. */
37
+ group?: string;
28
38
  members?: Member[];
29
39
  state?: unknown;
30
40
  };
31
- /** Someone in a room: their profile, and which of the room's groups they're in. */
41
+ /** One of a room's groups someone is in: `through` is the group's own id when
42
+ * they are directly in it, else the nested group they come in through. */
43
+ export type PersonGroup = {
44
+ id: string;
45
+ through: string;
46
+ };
47
+ export declare const PersonGroup: {
48
+ fromJSON(json: any): PersonGroup;
49
+ };
50
+ /** Someone in a room: their profile, and each of the room's groups they're in. */
32
51
  export type Person = {
33
52
  profile: Profile;
34
- groups: string[];
53
+ groups: PersonGroup[];
54
+ };
55
+ export declare const Person: {
56
+ fromJSON(json: any): Person;
35
57
  };
36
58
  /** Room hooks, plus `onState` whenever someone changes the state. */
37
59
  export type Hooks = ServerHooks & Partial<{
38
60
  onState(room: StateRoom, state: unknown, version: number): void;
39
61
  }>;
40
62
  /**
41
- * Make a live room. Signed in with an API key, the key owns it; otherwise you do.
42
- * Only the owner changes who is in it or deletes it.
63
+ * Make a live room. You own it, also when you sign with an API key, or with
64
+ * `draft.group`, that group does: you must be its direct admin. Everyone in an
65
+ * owner group reads its room, and its admins manage it. Only the owner changes
66
+ * who is in it or deletes it.
43
67
  */
44
68
  export declare function create(authorization: Credentials | ApiKey, draft?: Draft): Promise<LiveRoom>;
45
69
  /** A room you own or are a member of, with its state. */
46
70
  export declare function get(authorization: Credentials | ApiKey, id: string): Promise<LiveRoom>;
47
- /** Everyone in a room, each group's members listed. Anyone who can read the room. */
71
+ /** Everyone in a room: everyone in its groups (and its owner group), directly or
72
+ * through a nested group, and its email members. Anyone who can read the room. */
48
73
  export declare function people(authorization: Credentials | ApiKey, id: string): Promise<Person[]>;
49
- /** Replace who is in a room. Owner only. */
74
+ /** Replace who is in a room. Its owner only: the person, or for a group's room, that group's admins. */
50
75
  export declare function setMembers(authorization: Credentials | ApiKey, id: string, members: Member[]): Promise<LiveRoom>;
51
76
  /**
52
- * Replace a room's state; everyone in it sees the change. The owner and writers
53
- * only. With `ifVersion`, it writes only while the room is still at that version.
77
+ * Replace a room's state; everyone in it sees the change. The owner (for a
78
+ * group's room, that group's admins) and writers only. With `ifVersion`, it
79
+ * writes only while the room is still at that version.
54
80
  * Resolves to the new version.
55
81
  */
56
82
  export declare function setState(authorization: Credentials | ApiKey, id: string, state: unknown, ifVersion?: number): Promise<number>;
57
- /** Delete a room; everyone in it is disconnected. Owner only. */
83
+ /** Delete a room; everyone in it is disconnected. Its owner only: the person, or for a group's room, that group's admins. */
58
84
  export declare function remove(authorization: Credentials | ApiKey, id: string): Promise<void>;
59
85
  /** A live room you are in: its state, kept current, and who else is here. */
60
86
  export declare class StateRoom extends Room {
package/dist/src/rooms.js CHANGED
@@ -1,11 +1,41 @@
1
1
  import { Fauna } from "../index.js";
2
2
  import { Room, connect as connectServer } from "./shared/server.js";
3
- function hydrate(raw) {
4
- return { ...raw, created: new Date(raw.created), updated: new Date(raw.updated) };
5
- }
3
+ export const LiveRoom = {
4
+ fromJSON(json) {
5
+ return {
6
+ id: String(json.id),
7
+ name: String(json.name),
8
+ ...(json.group ? { group: String(json.group) } : {}),
9
+ members: json.members,
10
+ state: json.state,
11
+ version: Number(json.version),
12
+ created: new Date(json.created),
13
+ updated: new Date(json.updated),
14
+ };
15
+ },
16
+ };
17
+ export const PersonGroup = {
18
+ fromJSON(json) {
19
+ return { id: String(json.id), through: String(json.through) };
20
+ },
21
+ };
22
+ export const Person = {
23
+ fromJSON(json) {
24
+ return {
25
+ profile: {
26
+ email: String(json.profile.email),
27
+ ...(json.profile.displayName ? { displayName: String(json.profile.displayName) } : {}),
28
+ ...(json.profile.icon ? { icon: String(json.profile.icon) } : {}),
29
+ },
30
+ groups: json.groups.map(PersonGroup.fromJSON),
31
+ };
32
+ },
33
+ };
6
34
  /**
7
- * Make a live room. Signed in with an API key, the key owns it; otherwise you do.
8
- * Only the owner changes who is in it or deletes it.
35
+ * Make a live room. You own it, also when you sign with an API key, or with
36
+ * `draft.group`, that group does: you must be its direct admin. Everyone in an
37
+ * owner group reads its room, and its admins manage it. Only the owner changes
38
+ * who is in it or deletes it.
9
39
  */
10
40
  export async function create(authorization, draft = {}) {
11
41
  const response = await fetch(`${Fauna.getURL()}/rooms`, {
@@ -19,7 +49,7 @@ export async function create(authorization, draft = {}) {
19
49
  if (!response.ok) {
20
50
  throw new Error(await response.text());
21
51
  }
22
- return hydrate(await response.json());
52
+ return LiveRoom.fromJSON(await response.json());
23
53
  }
24
54
  /** A room you own or are a member of, with its state. */
25
55
  export async function get(authorization, id) {
@@ -32,9 +62,10 @@ export async function get(authorization, id) {
32
62
  if (!response.ok) {
33
63
  throw new Error(await response.text());
34
64
  }
35
- return hydrate(await response.json());
65
+ return LiveRoom.fromJSON(await response.json());
36
66
  }
37
- /** Everyone in a room, each group's members listed. Anyone who can read the room. */
67
+ /** Everyone in a room: everyone in its groups (and its owner group), directly or
68
+ * through a nested group, and its email members. Anyone who can read the room. */
38
69
  export async function people(authorization, id) {
39
70
  const response = await fetch(`${Fauna.getURL()}/rooms/${encodeURIComponent(id)}/people`, {
40
71
  method: "GET",
@@ -45,9 +76,9 @@ export async function people(authorization, id) {
45
76
  if (!response.ok) {
46
77
  throw new Error(await response.text());
47
78
  }
48
- return await response.json();
79
+ return (await response.json()).map(Person.fromJSON);
49
80
  }
50
- /** Replace who is in a room. Owner only. */
81
+ /** Replace who is in a room. Its owner only: the person, or for a group's room, that group's admins. */
51
82
  export async function setMembers(authorization, id, members) {
52
83
  const response = await fetch(`${Fauna.getURL()}/rooms/${encodeURIComponent(id)}/members`, {
53
84
  method: "PUT",
@@ -60,11 +91,12 @@ export async function setMembers(authorization, id, members) {
60
91
  if (!response.ok) {
61
92
  throw new Error(await response.text());
62
93
  }
63
- return hydrate(await response.json());
94
+ return LiveRoom.fromJSON(await response.json());
64
95
  }
65
96
  /**
66
- * Replace a room's state; everyone in it sees the change. The owner and writers
67
- * only. With `ifVersion`, it writes only while the room is still at that version.
97
+ * Replace a room's state; everyone in it sees the change. The owner (for a
98
+ * group's room, that group's admins) and writers only. With `ifVersion`, it
99
+ * writes only while the room is still at that version.
68
100
  * Resolves to the new version.
69
101
  */
70
102
  export async function setState(authorization, id, state, ifVersion) {
@@ -81,7 +113,7 @@ export async function setState(authorization, id, state, ifVersion) {
81
113
  }
82
114
  return (await response.json()).version;
83
115
  }
84
- /** Delete a room; everyone in it is disconnected. Owner only. */
116
+ /** Delete a room; everyone in it is disconnected. Its owner only: the person, or for a group's room, that group's admins. */
85
117
  export async function remove(authorization, id) {
86
118
  const response = await fetch(`${Fauna.getURL()}/rooms/${encodeURIComponent(id)}`, {
87
119
  method: "DELETE",