@athenaintel/react 0.21.1 → 0.22.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 (65) hide show
  1. package/README.md +63 -0
  2. package/dist/chat/StatewireApprovalCard.cjs +70 -10
  3. package/dist/chat/StatewireApprovalCard.cjs.map +1 -1
  4. package/dist/chat/StatewireApprovalCard.d.ts +12 -1
  5. package/dist/chat/StatewireApprovalCard.js +73 -13
  6. package/dist/chat/StatewireApprovalCard.js.map +1 -1
  7. package/dist/chat/tiptap-text.cjs +28 -23
  8. package/dist/chat/tiptap-text.cjs.map +1 -1
  9. package/dist/chat/tiptap-text.js +28 -23
  10. package/dist/chat/tiptap-text.js.map +1 -1
  11. package/dist/chat/use-statewire-approval-request.cjs +22 -1
  12. package/dist/chat/use-statewire-approval-request.cjs.map +1 -1
  13. package/dist/chat/use-statewire-approval-request.d.ts +41 -0
  14. package/dist/chat/use-statewire-approval-request.js +24 -3
  15. package/dist/chat/use-statewire-approval-request.js.map +1 -1
  16. package/dist/chat-ui/mentions/suggestions/data/sources/asset-type-icons.cjs +0 -1
  17. package/dist/chat-ui/mentions/suggestions/data/sources/asset-type-icons.cjs.map +1 -1
  18. package/dist/chat-ui/mentions/suggestions/data/sources/asset-type-icons.js +0 -1
  19. package/dist/chat-ui/mentions/suggestions/data/sources/asset-type-icons.js.map +1 -1
  20. package/dist/collab/client.cjs +4 -9
  21. package/dist/collab/client.cjs.map +1 -1
  22. package/dist/collab/client.js +2 -7
  23. package/dist/collab/client.js.map +1 -1
  24. package/dist/collab/index.cjs +23 -0
  25. package/dist/collab/index.cjs.map +1 -1
  26. package/dist/collab/index.d.ts +17 -2
  27. package/dist/collab/index.js +24 -1
  28. package/dist/collab/index.js.map +1 -1
  29. package/dist/collab/mint.cjs +18 -13
  30. package/dist/collab/mint.cjs.map +1 -1
  31. package/dist/collab/mint.d.ts +10 -0
  32. package/dist/collab/mint.js +18 -13
  33. package/dist/collab/mint.js.map +1 -1
  34. package/dist/collab/presence-client.cjs +211 -0
  35. package/dist/collab/presence-client.cjs.map +1 -0
  36. package/dist/collab/presence-client.d.ts +60 -0
  37. package/dist/collab/presence-client.js +194 -0
  38. package/dist/collab/presence-client.js.map +1 -0
  39. package/dist/collab/presence-mint.cjs +68 -0
  40. package/dist/collab/presence-mint.cjs.map +1 -0
  41. package/dist/collab/presence-mint.d.ts +87 -0
  42. package/dist/collab/presence-mint.js +68 -0
  43. package/dist/collab/presence-mint.js.map +1 -0
  44. package/dist/collab/presence-react.cjs +114 -0
  45. package/dist/collab/presence-react.cjs.map +1 -0
  46. package/dist/collab/presence-react.d.ts +47 -0
  47. package/dist/collab/presence-react.js +114 -0
  48. package/dist/collab/presence-react.js.map +1 -0
  49. package/dist/collab/presence-slots.cjs +220 -0
  50. package/dist/collab/presence-slots.cjs.map +1 -0
  51. package/dist/collab/presence-slots.d.ts +119 -0
  52. package/dist/collab/presence-slots.js +220 -0
  53. package/dist/collab/presence-slots.js.map +1 -0
  54. package/dist/collab/token-refresh.cjs +13 -0
  55. package/dist/collab/token-refresh.cjs.map +1 -0
  56. package/dist/collab/token-refresh.d.ts +20 -0
  57. package/dist/collab/token-refresh.js +13 -0
  58. package/dist/collab/token-refresh.js.map +1 -0
  59. package/dist/packages/agent-runtime/src/interrupts.cjs +40 -1
  60. package/dist/packages/agent-runtime/src/interrupts.cjs.map +1 -1
  61. package/dist/packages/agent-runtime/src/interrupts.js +40 -1
  62. package/dist/packages/agent-runtime/src/interrupts.js.map +1 -1
  63. package/dist/packages/react/package.json.cjs +1 -1
  64. package/dist/packages/react/package.json.js +1 -1
  65. package/package.json +7 -7
@@ -0,0 +1,211 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const websocket = require("@y/websocket");
4
+ const Y = require("@y/y");
5
+ const closePolicy = require("./close-policy.cjs");
6
+ const presenceMint = require("./presence-mint.cjs");
7
+ const presenceSlots = require("./presence-slots.cjs");
8
+ const tokenRefresh = require("./token-refresh.cjs");
9
+ function _interopNamespaceDefault(e) {
10
+ const n = Object.create(null, { [Symbol.toStringTag]: { value: "Module" } });
11
+ if (e) {
12
+ for (const k in e) {
13
+ if (k !== "default") {
14
+ const d = Object.getOwnPropertyDescriptor(e, k);
15
+ Object.defineProperty(n, k, d.get ? d : {
16
+ enumerable: true,
17
+ get: () => e[k]
18
+ });
19
+ }
20
+ }
21
+ }
22
+ n.default = e;
23
+ return Object.freeze(n);
24
+ }
25
+ const Y__namespace = /* @__PURE__ */ _interopNamespaceDefault(Y);
26
+ const PRESENCE_SWEEP_INTERVAL_MS = 15e3;
27
+ const EMPTY_ROSTER = { humans: [], agents: [] };
28
+ function connectWorkspacePresence(args) {
29
+ const doc = new Y__namespace.Doc();
30
+ const listeners = /* @__PURE__ */ new Set();
31
+ let provider = null;
32
+ let refreshTimer = null;
33
+ let destroyed = false;
34
+ let status = "connecting";
35
+ let accessType = null;
36
+ let error = null;
37
+ let version = 0;
38
+ let consecutiveRemints = 0;
39
+ let connectSeq = 0;
40
+ let roster = EMPTY_ROSTER;
41
+ let rosterFingerprint = JSON.stringify(EMPTY_ROSTER);
42
+ const notify = () => {
43
+ version += 1;
44
+ for (const listener of listeners) listener();
45
+ };
46
+ const applyRoster = (next) => {
47
+ const fingerprint = JSON.stringify(next);
48
+ if (fingerprint === rosterFingerprint) return false;
49
+ roster = next;
50
+ rosterFingerprint = fingerprint;
51
+ return true;
52
+ };
53
+ const recomputeRoster = () => {
54
+ if (destroyed) return;
55
+ const next = provider ? presenceSlots.mergePresenceStates(provider.awareness.getStates()) : EMPTY_ROSTER;
56
+ if (applyRoster(next)) notify();
57
+ };
58
+ const setStatus = (next, err = null) => {
59
+ if (destroyed && next !== "closed") return;
60
+ status = next;
61
+ error = err;
62
+ if (next === "closed" || next === "error") applyRoster(EMPTY_ROSTER);
63
+ notify();
64
+ };
65
+ const clearRefreshTimer = () => {
66
+ if (refreshTimer !== null) {
67
+ clearTimeout(refreshTimer);
68
+ refreshTimer = null;
69
+ }
70
+ };
71
+ const teardownProvider = () => {
72
+ clearRefreshTimer();
73
+ if (provider) {
74
+ const stale = provider;
75
+ provider = null;
76
+ stale.destroy();
77
+ stale.awareness.destroy();
78
+ }
79
+ };
80
+ const scheduleRefresh = (grant) => {
81
+ clearRefreshTimer();
82
+ const delay = tokenRefresh.refreshDelayMs({ expiresAtMs: grant.expires_at_ms, now: Date.now() });
83
+ refreshTimer = setTimeout(() => {
84
+ void connect({ scheduledRefresh: true });
85
+ }, delay);
86
+ };
87
+ const connect = async (opts) => {
88
+ if (destroyed) return;
89
+ const attempt = ++connectSeq;
90
+ const scheduledRefresh = (opts == null ? void 0 : opts.scheduledRefresh) === true;
91
+ if (!scheduledRefresh) {
92
+ teardownProvider();
93
+ } else {
94
+ clearRefreshTimer();
95
+ }
96
+ let grant;
97
+ try {
98
+ grant = await presenceMint.mintWorkspacePresenceToken({ auth: args.auth, workspaceId: args.workspaceId });
99
+ } catch (err) {
100
+ if (destroyed || attempt !== connectSeq) return;
101
+ if (scheduledRefresh) {
102
+ refreshTimer = setTimeout(() => {
103
+ void connect({ scheduledRefresh: true });
104
+ }, tokenRefresh.MIN_REFRESH_DELAY_MS);
105
+ return;
106
+ }
107
+ setStatus("error", err instanceof Error ? err : new Error(String(err)));
108
+ return;
109
+ }
110
+ if (destroyed || attempt !== connectSeq) return;
111
+ if (scheduledRefresh) {
112
+ teardownProvider();
113
+ }
114
+ accessType = grant.access_type;
115
+ const nextProvider = new websocket.WebsocketProvider(grant.ws_url, `${grant.org}/${grant.doc_id}`, doc, {
116
+ params: {
117
+ yauth: grant.token,
118
+ branch: grant.branch
119
+ },
120
+ // BroadcastChannel would share this room with same-origin tabs
121
+ // locally, bypassing the server's per-recipient filter. Presence must
122
+ // round-trip the server.
123
+ disableBc: true
124
+ });
125
+ provider = nextProvider;
126
+ nextProvider.awareness.off("update", nextProvider._awarenessUpdateHandler);
127
+ nextProvider.awareness.setLocalState(null);
128
+ nextProvider.on("status", (event) => {
129
+ if (provider !== nextProvider) return;
130
+ if (event.status === "connected") {
131
+ consecutiveRemints = 0;
132
+ }
133
+ setStatus(event.status);
134
+ });
135
+ nextProvider.on("connection-close", (event) => {
136
+ if (provider !== nextProvider) return;
137
+ const action = closePolicy.classifyClose(event == null ? void 0 : event.code);
138
+ if (action === "remint") {
139
+ consecutiveRemints += 1;
140
+ teardownProvider();
141
+ if (consecutiveRemints > tokenRefresh.MAX_CONSECUTIVE_REMINTS) {
142
+ setStatus(
143
+ "closed",
144
+ new Error("Access repeatedly revoked mid-session (4401); giving up.")
145
+ );
146
+ return;
147
+ }
148
+ applyRoster(EMPTY_ROSTER);
149
+ setStatus("connecting");
150
+ void connect();
151
+ } else if (action === "stop") {
152
+ teardownProvider();
153
+ setStatus(
154
+ "closed",
155
+ new Error(`Connection closed permanently (code ${(event == null ? void 0 : event.code) ?? "unknown"})`)
156
+ );
157
+ }
158
+ });
159
+ const onAwareness = () => {
160
+ if (provider !== nextProvider) return;
161
+ recomputeRoster();
162
+ };
163
+ nextProvider.awareness.on("change", onAwareness);
164
+ nextProvider.awareness.on("update", onAwareness);
165
+ scheduleRefresh(grant);
166
+ };
167
+ void connect();
168
+ const sweepTimer = setInterval(recomputeRoster, PRESENCE_SWEEP_INTERVAL_MS);
169
+ return {
170
+ get status() {
171
+ return status;
172
+ },
173
+ get error() {
174
+ return error;
175
+ },
176
+ get accessType() {
177
+ return accessType;
178
+ },
179
+ get version() {
180
+ return version;
181
+ },
182
+ get humans() {
183
+ return roster.humans;
184
+ },
185
+ get agents() {
186
+ return roster.agents;
187
+ },
188
+ refreshNow: () => connect({ scheduledRefresh: true }),
189
+ onChange: (listener) => {
190
+ listeners.add(listener);
191
+ return () => {
192
+ listeners.delete(listener);
193
+ };
194
+ },
195
+ destroy: () => {
196
+ if (destroyed) return;
197
+ destroyed = true;
198
+ clearInterval(sweepTimer);
199
+ teardownProvider();
200
+ status = "closed";
201
+ applyRoster(EMPTY_ROSTER);
202
+ version += 1;
203
+ for (const listener of [...listeners]) listener();
204
+ listeners.clear();
205
+ doc.destroy();
206
+ }
207
+ };
208
+ }
209
+ exports.PRESENCE_SWEEP_INTERVAL_MS = PRESENCE_SWEEP_INTERVAL_MS;
210
+ exports.connectWorkspacePresence = connectWorkspacePresence;
211
+ //# sourceMappingURL=presence-client.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"presence-client.cjs","sources":["../../src/collab/presence-client.ts"],"sourcesContent":["/**\n * Vanilla (framework-agnostic) observer of a workspace's presence feed.\n *\n * `connectWorkspacePresence` mints a read-only token for the workspace\n * awareness room (`<workspaceId>/awareness`), opens the Keryx WebSocket with\n * the v14 provider, and keeps a merged roster of the people and agent runs in\n * the room — the same per-recipient-filtered feed the Olympus People page\n * renders. Token lifecycle matches `connectGenericDoc`: re-mint before expiry,\n * one fresh mint on a 4401 kick, permanent stop on any other 44xx code,\n * provider backoff for everything else.\n *\n * The client is a **pure observer**. It never writes to the Y.Doc, it drops\n * the provider's default local awareness slot before the socket opens so the\n * token holder never announces itself, and it detaches y-websocket's echo of\n * remote awareness updates — the room's read-only grant carries no broadcast\n * permission, so anything sent would only be discarded server-side. React\n * apps use `useWorkspacePresence` from `@athenaintel/react/collab` instead of\n * calling this directly.\n */\n\nimport { WebsocketProvider } from '@y/websocket';\nimport * as Y from '@y/y';\nimport type { GenericDocStatus } from './client';\nimport { classifyClose } from './close-policy';\nimport type { CollabAuth } from './mint';\nimport { mintWorkspacePresenceToken, type WorkspacePresenceTokenResponse } from './presence-mint';\nimport {\n mergePresenceStates,\n type PresenceAgent,\n type PresenceHuman,\n type PresenceRoster,\n} from './presence-slots';\nimport { MAX_CONSECUTIVE_REMINTS, MIN_REFRESH_DELAY_MS, refreshDelayMs } from './token-refresh';\n\n/** Same vocabulary as the Generic Doc client; a presence feed has no sync phase of its own. */\nexport type WorkspacePresenceStatus = GenericDocStatus;\n\n/**\n * How often the roster is re-derived without an awareness event. y-protocols\n * drops a slot whose publisher went silent for its `outdatedTimeout` (30 s)\n * from a timer of its own, and this sweep is the belt to that brace: a stale\n * slot falls out of `humans` even if no event reaches us for it.\n */\nexport const PRESENCE_SWEEP_INTERVAL_MS = 15_000;\n\nexport interface WorkspacePresenceHandle {\n readonly status: WorkspacePresenceStatus;\n /** Terminal error, when status is 'error' or 'closed'. */\n readonly error: Error | null;\n /** Granted access from the last successful mint — always 'r' for this feed. */\n readonly accessType: 'r' | 'rw' | null;\n /** Monotonic change counter — bumps on every status or roster notification. */\n readonly version: number;\n /**\n * People in the room, one entry per person (tabs merged by attested id).\n * Identity-stable between notifications: the array only changes when the\n * derived roster does, so it is safe to key memoisation on.\n */\n readonly humans: PresenceHuman[];\n /** Agent runs in the room with something the token holder may see. */\n readonly agents: PresenceAgent[];\n /**\n * Force an early token refresh. Session-preserving: the live connection\n * stays up until the fresh token is minted; a failed mint retries on a\n * short timer instead of dropping the session.\n */\n refreshNow: () => Promise<void>;\n onChange: (listener: () => void) => () => void;\n destroy: () => void;\n}\n\nconst EMPTY_ROSTER: PresenceRoster = { humans: [], agents: [] };\n\nexport function connectWorkspacePresence(args: {\n workspaceId: string;\n auth: CollabAuth;\n}): WorkspacePresenceHandle {\n // The awareness room is a Keryx doc like any other; the provider needs a\n // Y.Doc to attach to. Nothing here ever writes to it.\n const doc = new Y.Doc();\n const listeners = new Set<() => void>();\n\n let provider: WebsocketProvider | null = null;\n let refreshTimer: ReturnType<typeof setTimeout> | null = null;\n let destroyed = false;\n let status: WorkspacePresenceStatus = 'connecting';\n let accessType: 'r' | 'rw' | null = null;\n let error: Error | null = null;\n let version = 0;\n let consecutiveRemints = 0;\n /**\n * Connection attempts can overlap — the initial mint, `refreshNow`, the\n * refresh timer and a 4401 kick all enter `connect()`. The newest attempt\n * owns the connection; an older one still waiting on its mint drops its\n * result when it lands, so an out-of-order grant can neither replace a newer\n * provider nor leave one connected but orphaned.\n */\n let connectSeq = 0;\n let roster: PresenceRoster = EMPTY_ROSTER;\n let rosterFingerprint = JSON.stringify(EMPTY_ROSTER);\n\n const notify = () => {\n version += 1;\n for (const listener of listeners) listener();\n };\n\n /** Replace the roster only when the derived value changed; returns whether it did. */\n const applyRoster = (next: PresenceRoster): boolean => {\n const fingerprint = JSON.stringify(next);\n if (fingerprint === rosterFingerprint) return false;\n roster = next;\n rosterFingerprint = fingerprint;\n return true;\n };\n\n const recomputeRoster = () => {\n if (destroyed) return;\n const next = provider ? mergePresenceStates(provider.awareness.getStates()) : EMPTY_ROSTER;\n if (applyRoster(next)) notify();\n };\n\n const setStatus = (next: WorkspacePresenceStatus, err: Error | null = null) => {\n if (destroyed && next !== 'closed') return;\n status = next;\n error = err;\n // A feed that stopped must not keep showing the last roster: silence\n // reading as \"these people are still here\" is the failure presence exists\n // to avoid.\n if (next === 'closed' || next === 'error') applyRoster(EMPTY_ROSTER);\n notify();\n };\n\n const clearRefreshTimer = () => {\n if (refreshTimer !== null) {\n clearTimeout(refreshTimer);\n refreshTimer = null;\n }\n };\n\n const teardownProvider = () => {\n clearRefreshTimer();\n if (provider) {\n const stale = provider;\n // Null first so the awareness listeners below see a superseded provider\n // and ignore the events its destruction emits.\n provider = null;\n stale.destroy();\n // The provider does not destroy the awareness it created; without this\n // every superseded instance keeps its outdated-state timer alive until\n // the doc is destroyed.\n stale.awareness.destroy();\n }\n };\n\n const scheduleRefresh = (grant: WorkspacePresenceTokenResponse) => {\n clearRefreshTimer();\n const delay = refreshDelayMs({ expiresAtMs: grant.expires_at_ms, now: Date.now() });\n refreshTimer = setTimeout(() => {\n void connect({ scheduledRefresh: true });\n }, delay);\n };\n\n const connect = async (opts?: { scheduledRefresh?: boolean }): Promise<void> => {\n if (destroyed) return;\n const attempt = ++connectSeq;\n const scheduledRefresh = opts?.scheduledRefresh === true;\n // A scheduled refresh mints FIRST and keeps the live session up: the old\n // token has ~5 minutes of headroom, so a transient mint failure retries\n // on a short timer instead of dropping a healthy connection.\n if (!scheduledRefresh) {\n teardownProvider();\n } else {\n clearRefreshTimer();\n }\n let grant: WorkspacePresenceTokenResponse;\n try {\n grant = await mintWorkspacePresenceToken({ auth: args.auth, workspaceId: args.workspaceId });\n } catch (err) {\n // Superseded while minting: the newer attempt owns the outcome, and a\n // retry timer from this one would only stomp on its schedule.\n if (destroyed || attempt !== connectSeq) return;\n if (scheduledRefresh) {\n refreshTimer = setTimeout(() => {\n void connect({ scheduledRefresh: true });\n }, MIN_REFRESH_DELAY_MS);\n return;\n }\n // A denied mint is authoritative (not an admin / roster not enabled /\n // sandbox credential refused); anything else (network) is worth telling\n // the caller about too — the handle stays usable via a later explicit\n // reconnect-by-recreate.\n setStatus('error', err instanceof Error ? err : new Error(String(err)));\n return;\n }\n if (destroyed || attempt !== connectSeq) return;\n if (scheduledRefresh) {\n teardownProvider();\n }\n accessType = grant.access_type;\n\n const nextProvider = new WebsocketProvider(grant.ws_url, `${grant.org}/${grant.doc_id}`, doc, {\n params: {\n yauth: grant.token,\n branch: grant.branch,\n },\n // BroadcastChannel would share this room with same-origin tabs\n // locally, bypassing the server's per-recipient filter. Presence must\n // round-trip the server.\n disableBc: true,\n });\n provider = nextProvider;\n\n // Pure observer. y-websocket re-broadcasts every awareness update it\n // sees (including remote ones) and announces a default `{}` local slot on\n // connect; drop both before the socket opens. Detach the echo handler\n // first so clearing the local slot does not itself get echoed.\n nextProvider.awareness.off('update', nextProvider._awarenessUpdateHandler);\n nextProvider.awareness.setLocalState(null);\n\n nextProvider.on('status', (event: { status: 'connecting' | 'connected' | 'disconnected' }) => {\n if (provider !== nextProvider) return;\n if (event.status === 'connected') {\n consecutiveRemints = 0;\n }\n setStatus(event.status);\n });\n\n nextProvider.on('connection-close', (event: CloseEvent | null) => {\n if (provider !== nextProvider) return;\n const action = classifyClose(event?.code);\n if (action === 'remint') {\n // Revoked mid-session: a fresh mint re-runs the server-side check. If\n // access is truly gone the mint 403s and we land in 'error'. Bounded:\n // repeated 4401s without an intervening successful connect mean the\n // server keeps rejecting freshly minted tokens — stop rather than\n // loop mint→connect→kick.\n consecutiveRemints += 1;\n teardownProvider();\n if (consecutiveRemints > MAX_CONSECUTIVE_REMINTS) {\n setStatus(\n 'closed',\n new Error('Access repeatedly revoked mid-session (4401); giving up.')\n );\n return;\n }\n // The socket is gone and the fresh grant is a network round-trip\n // away: until it connects there is no live feed, so neither\n // 'connected' nor the last roster may stand.\n applyRoster(EMPTY_ROSTER);\n setStatus('connecting');\n void connect();\n } else if (action === 'stop') {\n teardownProvider();\n setStatus(\n 'closed',\n new Error(`Connection closed permanently (code ${event?.code ?? 'unknown'})`)\n );\n }\n // 'retry': the provider's exponential backoff handles it.\n });\n\n const onAwareness = () => {\n if (provider !== nextProvider) return;\n recomputeRoster();\n };\n // 'change' fires when a slot's content changed; 'update' also on\n // keepalive renewals. Both re-derive; the fingerprint gate keeps a\n // renewal from notifying subscribers.\n nextProvider.awareness.on('change', onAwareness);\n nextProvider.awareness.on('update', onAwareness);\n\n scheduleRefresh(grant);\n };\n\n void connect();\n\n const sweepTimer = setInterval(recomputeRoster, PRESENCE_SWEEP_INTERVAL_MS);\n\n return {\n get status() {\n return status;\n },\n get error() {\n return error;\n },\n get accessType() {\n return accessType;\n },\n get version() {\n return version;\n },\n get humans() {\n return roster.humans;\n },\n get agents() {\n return roster.agents;\n },\n refreshNow: () => connect({ scheduledRefresh: true }),\n onChange: (listener) => {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n destroy: () => {\n if (destroyed) return;\n destroyed = true;\n clearInterval(sweepTimer);\n teardownProvider();\n status = 'closed';\n applyRoster(EMPTY_ROSTER);\n // Final notification so subscribers observe the closed, empty feed\n // before their listeners are dropped.\n version += 1;\n for (const listener of [...listeners]) listener();\n listeners.clear();\n doc.destroy();\n },\n };\n}\n"],"names":["Y","mergePresenceStates","refreshDelayMs","mintWorkspacePresenceToken","MIN_REFRESH_DELAY_MS","WebsocketProvider","classifyClose","MAX_CONSECUTIVE_REMINTS"],"mappings":"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"}
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Vanilla (framework-agnostic) observer of a workspace's presence feed.
3
+ *
4
+ * `connectWorkspacePresence` mints a read-only token for the workspace
5
+ * awareness room (`<workspaceId>/awareness`), opens the Keryx WebSocket with
6
+ * the v14 provider, and keeps a merged roster of the people and agent runs in
7
+ * the room — the same per-recipient-filtered feed the Olympus People page
8
+ * renders. Token lifecycle matches `connectGenericDoc`: re-mint before expiry,
9
+ * one fresh mint on a 4401 kick, permanent stop on any other 44xx code,
10
+ * provider backoff for everything else.
11
+ *
12
+ * The client is a **pure observer**. It never writes to the Y.Doc, it drops
13
+ * the provider's default local awareness slot before the socket opens so the
14
+ * token holder never announces itself, and it detaches y-websocket's echo of
15
+ * remote awareness updates — the room's read-only grant carries no broadcast
16
+ * permission, so anything sent would only be discarded server-side. React
17
+ * apps use `useWorkspacePresence` from `@athenaintel/react/collab` instead of
18
+ * calling this directly.
19
+ */
20
+ import type { GenericDocStatus } from './client';
21
+ import type { CollabAuth } from './mint';
22
+ import { type PresenceAgent, type PresenceHuman } from './presence-slots';
23
+ /** Same vocabulary as the Generic Doc client; a presence feed has no sync phase of its own. */
24
+ export type WorkspacePresenceStatus = GenericDocStatus;
25
+ /**
26
+ * How often the roster is re-derived without an awareness event. y-protocols
27
+ * drops a slot whose publisher went silent for its `outdatedTimeout` (30 s)
28
+ * from a timer of its own, and this sweep is the belt to that brace: a stale
29
+ * slot falls out of `humans` even if no event reaches us for it.
30
+ */
31
+ export declare const PRESENCE_SWEEP_INTERVAL_MS = 15000;
32
+ export interface WorkspacePresenceHandle {
33
+ readonly status: WorkspacePresenceStatus;
34
+ /** Terminal error, when status is 'error' or 'closed'. */
35
+ readonly error: Error | null;
36
+ /** Granted access from the last successful mint — always 'r' for this feed. */
37
+ readonly accessType: 'r' | 'rw' | null;
38
+ /** Monotonic change counter — bumps on every status or roster notification. */
39
+ readonly version: number;
40
+ /**
41
+ * People in the room, one entry per person (tabs merged by attested id).
42
+ * Identity-stable between notifications: the array only changes when the
43
+ * derived roster does, so it is safe to key memoisation on.
44
+ */
45
+ readonly humans: PresenceHuman[];
46
+ /** Agent runs in the room with something the token holder may see. */
47
+ readonly agents: PresenceAgent[];
48
+ /**
49
+ * Force an early token refresh. Session-preserving: the live connection
50
+ * stays up until the fresh token is minted; a failed mint retries on a
51
+ * short timer instead of dropping the session.
52
+ */
53
+ refreshNow: () => Promise<void>;
54
+ onChange: (listener: () => void) => () => void;
55
+ destroy: () => void;
56
+ }
57
+ export declare function connectWorkspacePresence(args: {
58
+ workspaceId: string;
59
+ auth: CollabAuth;
60
+ }): WorkspacePresenceHandle;
@@ -0,0 +1,194 @@
1
+ import { WebsocketProvider } from "@y/websocket";
2
+ import * as Y from "@y/y";
3
+ import { classifyClose } from "./close-policy.js";
4
+ import { mintWorkspacePresenceToken } from "./presence-mint.js";
5
+ import { mergePresenceStates } from "./presence-slots.js";
6
+ import { MIN_REFRESH_DELAY_MS, MAX_CONSECUTIVE_REMINTS, refreshDelayMs } from "./token-refresh.js";
7
+ const PRESENCE_SWEEP_INTERVAL_MS = 15e3;
8
+ const EMPTY_ROSTER = { humans: [], agents: [] };
9
+ function connectWorkspacePresence(args) {
10
+ const doc = new Y.Doc();
11
+ const listeners = /* @__PURE__ */ new Set();
12
+ let provider = null;
13
+ let refreshTimer = null;
14
+ let destroyed = false;
15
+ let status = "connecting";
16
+ let accessType = null;
17
+ let error = null;
18
+ let version = 0;
19
+ let consecutiveRemints = 0;
20
+ let connectSeq = 0;
21
+ let roster = EMPTY_ROSTER;
22
+ let rosterFingerprint = JSON.stringify(EMPTY_ROSTER);
23
+ const notify = () => {
24
+ version += 1;
25
+ for (const listener of listeners) listener();
26
+ };
27
+ const applyRoster = (next) => {
28
+ const fingerprint = JSON.stringify(next);
29
+ if (fingerprint === rosterFingerprint) return false;
30
+ roster = next;
31
+ rosterFingerprint = fingerprint;
32
+ return true;
33
+ };
34
+ const recomputeRoster = () => {
35
+ if (destroyed) return;
36
+ const next = provider ? mergePresenceStates(provider.awareness.getStates()) : EMPTY_ROSTER;
37
+ if (applyRoster(next)) notify();
38
+ };
39
+ const setStatus = (next, err = null) => {
40
+ if (destroyed && next !== "closed") return;
41
+ status = next;
42
+ error = err;
43
+ if (next === "closed" || next === "error") applyRoster(EMPTY_ROSTER);
44
+ notify();
45
+ };
46
+ const clearRefreshTimer = () => {
47
+ if (refreshTimer !== null) {
48
+ clearTimeout(refreshTimer);
49
+ refreshTimer = null;
50
+ }
51
+ };
52
+ const teardownProvider = () => {
53
+ clearRefreshTimer();
54
+ if (provider) {
55
+ const stale = provider;
56
+ provider = null;
57
+ stale.destroy();
58
+ stale.awareness.destroy();
59
+ }
60
+ };
61
+ const scheduleRefresh = (grant) => {
62
+ clearRefreshTimer();
63
+ const delay = refreshDelayMs({ expiresAtMs: grant.expires_at_ms, now: Date.now() });
64
+ refreshTimer = setTimeout(() => {
65
+ void connect({ scheduledRefresh: true });
66
+ }, delay);
67
+ };
68
+ const connect = async (opts) => {
69
+ if (destroyed) return;
70
+ const attempt = ++connectSeq;
71
+ const scheduledRefresh = (opts == null ? void 0 : opts.scheduledRefresh) === true;
72
+ if (!scheduledRefresh) {
73
+ teardownProvider();
74
+ } else {
75
+ clearRefreshTimer();
76
+ }
77
+ let grant;
78
+ try {
79
+ grant = await mintWorkspacePresenceToken({ auth: args.auth, workspaceId: args.workspaceId });
80
+ } catch (err) {
81
+ if (destroyed || attempt !== connectSeq) return;
82
+ if (scheduledRefresh) {
83
+ refreshTimer = setTimeout(() => {
84
+ void connect({ scheduledRefresh: true });
85
+ }, MIN_REFRESH_DELAY_MS);
86
+ return;
87
+ }
88
+ setStatus("error", err instanceof Error ? err : new Error(String(err)));
89
+ return;
90
+ }
91
+ if (destroyed || attempt !== connectSeq) return;
92
+ if (scheduledRefresh) {
93
+ teardownProvider();
94
+ }
95
+ accessType = grant.access_type;
96
+ const nextProvider = new WebsocketProvider(grant.ws_url, `${grant.org}/${grant.doc_id}`, doc, {
97
+ params: {
98
+ yauth: grant.token,
99
+ branch: grant.branch
100
+ },
101
+ // BroadcastChannel would share this room with same-origin tabs
102
+ // locally, bypassing the server's per-recipient filter. Presence must
103
+ // round-trip the server.
104
+ disableBc: true
105
+ });
106
+ provider = nextProvider;
107
+ nextProvider.awareness.off("update", nextProvider._awarenessUpdateHandler);
108
+ nextProvider.awareness.setLocalState(null);
109
+ nextProvider.on("status", (event) => {
110
+ if (provider !== nextProvider) return;
111
+ if (event.status === "connected") {
112
+ consecutiveRemints = 0;
113
+ }
114
+ setStatus(event.status);
115
+ });
116
+ nextProvider.on("connection-close", (event) => {
117
+ if (provider !== nextProvider) return;
118
+ const action = classifyClose(event == null ? void 0 : event.code);
119
+ if (action === "remint") {
120
+ consecutiveRemints += 1;
121
+ teardownProvider();
122
+ if (consecutiveRemints > MAX_CONSECUTIVE_REMINTS) {
123
+ setStatus(
124
+ "closed",
125
+ new Error("Access repeatedly revoked mid-session (4401); giving up.")
126
+ );
127
+ return;
128
+ }
129
+ applyRoster(EMPTY_ROSTER);
130
+ setStatus("connecting");
131
+ void connect();
132
+ } else if (action === "stop") {
133
+ teardownProvider();
134
+ setStatus(
135
+ "closed",
136
+ new Error(`Connection closed permanently (code ${(event == null ? void 0 : event.code) ?? "unknown"})`)
137
+ );
138
+ }
139
+ });
140
+ const onAwareness = () => {
141
+ if (provider !== nextProvider) return;
142
+ recomputeRoster();
143
+ };
144
+ nextProvider.awareness.on("change", onAwareness);
145
+ nextProvider.awareness.on("update", onAwareness);
146
+ scheduleRefresh(grant);
147
+ };
148
+ void connect();
149
+ const sweepTimer = setInterval(recomputeRoster, PRESENCE_SWEEP_INTERVAL_MS);
150
+ return {
151
+ get status() {
152
+ return status;
153
+ },
154
+ get error() {
155
+ return error;
156
+ },
157
+ get accessType() {
158
+ return accessType;
159
+ },
160
+ get version() {
161
+ return version;
162
+ },
163
+ get humans() {
164
+ return roster.humans;
165
+ },
166
+ get agents() {
167
+ return roster.agents;
168
+ },
169
+ refreshNow: () => connect({ scheduledRefresh: true }),
170
+ onChange: (listener) => {
171
+ listeners.add(listener);
172
+ return () => {
173
+ listeners.delete(listener);
174
+ };
175
+ },
176
+ destroy: () => {
177
+ if (destroyed) return;
178
+ destroyed = true;
179
+ clearInterval(sweepTimer);
180
+ teardownProvider();
181
+ status = "closed";
182
+ applyRoster(EMPTY_ROSTER);
183
+ version += 1;
184
+ for (const listener of [...listeners]) listener();
185
+ listeners.clear();
186
+ doc.destroy();
187
+ }
188
+ };
189
+ }
190
+ export {
191
+ PRESENCE_SWEEP_INTERVAL_MS,
192
+ connectWorkspacePresence
193
+ };
194
+ //# sourceMappingURL=presence-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"presence-client.js","sources":["../../src/collab/presence-client.ts"],"sourcesContent":["/**\n * Vanilla (framework-agnostic) observer of a workspace's presence feed.\n *\n * `connectWorkspacePresence` mints a read-only token for the workspace\n * awareness room (`<workspaceId>/awareness`), opens the Keryx WebSocket with\n * the v14 provider, and keeps a merged roster of the people and agent runs in\n * the room — the same per-recipient-filtered feed the Olympus People page\n * renders. Token lifecycle matches `connectGenericDoc`: re-mint before expiry,\n * one fresh mint on a 4401 kick, permanent stop on any other 44xx code,\n * provider backoff for everything else.\n *\n * The client is a **pure observer**. It never writes to the Y.Doc, it drops\n * the provider's default local awareness slot before the socket opens so the\n * token holder never announces itself, and it detaches y-websocket's echo of\n * remote awareness updates — the room's read-only grant carries no broadcast\n * permission, so anything sent would only be discarded server-side. React\n * apps use `useWorkspacePresence` from `@athenaintel/react/collab` instead of\n * calling this directly.\n */\n\nimport { WebsocketProvider } from '@y/websocket';\nimport * as Y from '@y/y';\nimport type { GenericDocStatus } from './client';\nimport { classifyClose } from './close-policy';\nimport type { CollabAuth } from './mint';\nimport { mintWorkspacePresenceToken, type WorkspacePresenceTokenResponse } from './presence-mint';\nimport {\n mergePresenceStates,\n type PresenceAgent,\n type PresenceHuman,\n type PresenceRoster,\n} from './presence-slots';\nimport { MAX_CONSECUTIVE_REMINTS, MIN_REFRESH_DELAY_MS, refreshDelayMs } from './token-refresh';\n\n/** Same vocabulary as the Generic Doc client; a presence feed has no sync phase of its own. */\nexport type WorkspacePresenceStatus = GenericDocStatus;\n\n/**\n * How often the roster is re-derived without an awareness event. y-protocols\n * drops a slot whose publisher went silent for its `outdatedTimeout` (30 s)\n * from a timer of its own, and this sweep is the belt to that brace: a stale\n * slot falls out of `humans` even if no event reaches us for it.\n */\nexport const PRESENCE_SWEEP_INTERVAL_MS = 15_000;\n\nexport interface WorkspacePresenceHandle {\n readonly status: WorkspacePresenceStatus;\n /** Terminal error, when status is 'error' or 'closed'. */\n readonly error: Error | null;\n /** Granted access from the last successful mint — always 'r' for this feed. */\n readonly accessType: 'r' | 'rw' | null;\n /** Monotonic change counter — bumps on every status or roster notification. */\n readonly version: number;\n /**\n * People in the room, one entry per person (tabs merged by attested id).\n * Identity-stable between notifications: the array only changes when the\n * derived roster does, so it is safe to key memoisation on.\n */\n readonly humans: PresenceHuman[];\n /** Agent runs in the room with something the token holder may see. */\n readonly agents: PresenceAgent[];\n /**\n * Force an early token refresh. Session-preserving: the live connection\n * stays up until the fresh token is minted; a failed mint retries on a\n * short timer instead of dropping the session.\n */\n refreshNow: () => Promise<void>;\n onChange: (listener: () => void) => () => void;\n destroy: () => void;\n}\n\nconst EMPTY_ROSTER: PresenceRoster = { humans: [], agents: [] };\n\nexport function connectWorkspacePresence(args: {\n workspaceId: string;\n auth: CollabAuth;\n}): WorkspacePresenceHandle {\n // The awareness room is a Keryx doc like any other; the provider needs a\n // Y.Doc to attach to. Nothing here ever writes to it.\n const doc = new Y.Doc();\n const listeners = new Set<() => void>();\n\n let provider: WebsocketProvider | null = null;\n let refreshTimer: ReturnType<typeof setTimeout> | null = null;\n let destroyed = false;\n let status: WorkspacePresenceStatus = 'connecting';\n let accessType: 'r' | 'rw' | null = null;\n let error: Error | null = null;\n let version = 0;\n let consecutiveRemints = 0;\n /**\n * Connection attempts can overlap — the initial mint, `refreshNow`, the\n * refresh timer and a 4401 kick all enter `connect()`. The newest attempt\n * owns the connection; an older one still waiting on its mint drops its\n * result when it lands, so an out-of-order grant can neither replace a newer\n * provider nor leave one connected but orphaned.\n */\n let connectSeq = 0;\n let roster: PresenceRoster = EMPTY_ROSTER;\n let rosterFingerprint = JSON.stringify(EMPTY_ROSTER);\n\n const notify = () => {\n version += 1;\n for (const listener of listeners) listener();\n };\n\n /** Replace the roster only when the derived value changed; returns whether it did. */\n const applyRoster = (next: PresenceRoster): boolean => {\n const fingerprint = JSON.stringify(next);\n if (fingerprint === rosterFingerprint) return false;\n roster = next;\n rosterFingerprint = fingerprint;\n return true;\n };\n\n const recomputeRoster = () => {\n if (destroyed) return;\n const next = provider ? mergePresenceStates(provider.awareness.getStates()) : EMPTY_ROSTER;\n if (applyRoster(next)) notify();\n };\n\n const setStatus = (next: WorkspacePresenceStatus, err: Error | null = null) => {\n if (destroyed && next !== 'closed') return;\n status = next;\n error = err;\n // A feed that stopped must not keep showing the last roster: silence\n // reading as \"these people are still here\" is the failure presence exists\n // to avoid.\n if (next === 'closed' || next === 'error') applyRoster(EMPTY_ROSTER);\n notify();\n };\n\n const clearRefreshTimer = () => {\n if (refreshTimer !== null) {\n clearTimeout(refreshTimer);\n refreshTimer = null;\n }\n };\n\n const teardownProvider = () => {\n clearRefreshTimer();\n if (provider) {\n const stale = provider;\n // Null first so the awareness listeners below see a superseded provider\n // and ignore the events its destruction emits.\n provider = null;\n stale.destroy();\n // The provider does not destroy the awareness it created; without this\n // every superseded instance keeps its outdated-state timer alive until\n // the doc is destroyed.\n stale.awareness.destroy();\n }\n };\n\n const scheduleRefresh = (grant: WorkspacePresenceTokenResponse) => {\n clearRefreshTimer();\n const delay = refreshDelayMs({ expiresAtMs: grant.expires_at_ms, now: Date.now() });\n refreshTimer = setTimeout(() => {\n void connect({ scheduledRefresh: true });\n }, delay);\n };\n\n const connect = async (opts?: { scheduledRefresh?: boolean }): Promise<void> => {\n if (destroyed) return;\n const attempt = ++connectSeq;\n const scheduledRefresh = opts?.scheduledRefresh === true;\n // A scheduled refresh mints FIRST and keeps the live session up: the old\n // token has ~5 minutes of headroom, so a transient mint failure retries\n // on a short timer instead of dropping a healthy connection.\n if (!scheduledRefresh) {\n teardownProvider();\n } else {\n clearRefreshTimer();\n }\n let grant: WorkspacePresenceTokenResponse;\n try {\n grant = await mintWorkspacePresenceToken({ auth: args.auth, workspaceId: args.workspaceId });\n } catch (err) {\n // Superseded while minting: the newer attempt owns the outcome, and a\n // retry timer from this one would only stomp on its schedule.\n if (destroyed || attempt !== connectSeq) return;\n if (scheduledRefresh) {\n refreshTimer = setTimeout(() => {\n void connect({ scheduledRefresh: true });\n }, MIN_REFRESH_DELAY_MS);\n return;\n }\n // A denied mint is authoritative (not an admin / roster not enabled /\n // sandbox credential refused); anything else (network) is worth telling\n // the caller about too — the handle stays usable via a later explicit\n // reconnect-by-recreate.\n setStatus('error', err instanceof Error ? err : new Error(String(err)));\n return;\n }\n if (destroyed || attempt !== connectSeq) return;\n if (scheduledRefresh) {\n teardownProvider();\n }\n accessType = grant.access_type;\n\n const nextProvider = new WebsocketProvider(grant.ws_url, `${grant.org}/${grant.doc_id}`, doc, {\n params: {\n yauth: grant.token,\n branch: grant.branch,\n },\n // BroadcastChannel would share this room with same-origin tabs\n // locally, bypassing the server's per-recipient filter. Presence must\n // round-trip the server.\n disableBc: true,\n });\n provider = nextProvider;\n\n // Pure observer. y-websocket re-broadcasts every awareness update it\n // sees (including remote ones) and announces a default `{}` local slot on\n // connect; drop both before the socket opens. Detach the echo handler\n // first so clearing the local slot does not itself get echoed.\n nextProvider.awareness.off('update', nextProvider._awarenessUpdateHandler);\n nextProvider.awareness.setLocalState(null);\n\n nextProvider.on('status', (event: { status: 'connecting' | 'connected' | 'disconnected' }) => {\n if (provider !== nextProvider) return;\n if (event.status === 'connected') {\n consecutiveRemints = 0;\n }\n setStatus(event.status);\n });\n\n nextProvider.on('connection-close', (event: CloseEvent | null) => {\n if (provider !== nextProvider) return;\n const action = classifyClose(event?.code);\n if (action === 'remint') {\n // Revoked mid-session: a fresh mint re-runs the server-side check. If\n // access is truly gone the mint 403s and we land in 'error'. Bounded:\n // repeated 4401s without an intervening successful connect mean the\n // server keeps rejecting freshly minted tokens — stop rather than\n // loop mint→connect→kick.\n consecutiveRemints += 1;\n teardownProvider();\n if (consecutiveRemints > MAX_CONSECUTIVE_REMINTS) {\n setStatus(\n 'closed',\n new Error('Access repeatedly revoked mid-session (4401); giving up.')\n );\n return;\n }\n // The socket is gone and the fresh grant is a network round-trip\n // away: until it connects there is no live feed, so neither\n // 'connected' nor the last roster may stand.\n applyRoster(EMPTY_ROSTER);\n setStatus('connecting');\n void connect();\n } else if (action === 'stop') {\n teardownProvider();\n setStatus(\n 'closed',\n new Error(`Connection closed permanently (code ${event?.code ?? 'unknown'})`)\n );\n }\n // 'retry': the provider's exponential backoff handles it.\n });\n\n const onAwareness = () => {\n if (provider !== nextProvider) return;\n recomputeRoster();\n };\n // 'change' fires when a slot's content changed; 'update' also on\n // keepalive renewals. Both re-derive; the fingerprint gate keeps a\n // renewal from notifying subscribers.\n nextProvider.awareness.on('change', onAwareness);\n nextProvider.awareness.on('update', onAwareness);\n\n scheduleRefresh(grant);\n };\n\n void connect();\n\n const sweepTimer = setInterval(recomputeRoster, PRESENCE_SWEEP_INTERVAL_MS);\n\n return {\n get status() {\n return status;\n },\n get error() {\n return error;\n },\n get accessType() {\n return accessType;\n },\n get version() {\n return version;\n },\n get humans() {\n return roster.humans;\n },\n get agents() {\n return roster.agents;\n },\n refreshNow: () => connect({ scheduledRefresh: true }),\n onChange: (listener) => {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n destroy: () => {\n if (destroyed) return;\n destroyed = true;\n clearInterval(sweepTimer);\n teardownProvider();\n status = 'closed';\n applyRoster(EMPTY_ROSTER);\n // Final notification so subscribers observe the closed, empty feed\n // before their listeners are dropped.\n version += 1;\n for (const listener of [...listeners]) listener();\n listeners.clear();\n doc.destroy();\n },\n };\n}\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
+ const mint = require("./mint.cjs");
4
+ function workspaceUrl({
5
+ auth,
6
+ workspaceId,
7
+ path
8
+ }) {
9
+ const base = mint.getAthenaApiBaseUrl(auth.backendUrl);
10
+ return `${base}/api/v0/workspaces/${encodeURIComponent(workspaceId)}/${path}`;
11
+ }
12
+ async function mintWorkspacePresenceToken(args) {
13
+ const response = await fetch(
14
+ workspaceUrl({ auth: args.auth, workspaceId: args.workspaceId, path: "presence-token" }),
15
+ {
16
+ method: "POST",
17
+ headers: {
18
+ "Content-Type": "application/json",
19
+ ...mint.getCollabAuthHeaders(args.auth)
20
+ },
21
+ body: JSON.stringify({}),
22
+ signal: args.signal
23
+ }
24
+ );
25
+ if (!response.ok) {
26
+ throw await mint.collabErrorFromResponse(
27
+ response,
28
+ `Workspace presence token mint failed with status ${response.status}`
29
+ );
30
+ }
31
+ return await response.json();
32
+ }
33
+ function arrayOrEmpty(value) {
34
+ return Array.isArray(value) ? value : [];
35
+ }
36
+ async function resolveWorkspacePresence(args) {
37
+ const response = await fetch(
38
+ workspaceUrl({ auth: args.auth, workspaceId: args.workspaceId, path: "presence/resolve" }),
39
+ {
40
+ method: "POST",
41
+ headers: {
42
+ "Content-Type": "application/json",
43
+ ...mint.getCollabAuthHeaders(args.auth)
44
+ },
45
+ body: JSON.stringify(args.request),
46
+ signal: args.signal
47
+ }
48
+ );
49
+ if (!response.ok) {
50
+ throw await mint.collabErrorFromResponse(
51
+ response,
52
+ `Workspace presence resolve failed with status ${response.status}`
53
+ );
54
+ }
55
+ const body = await response.json();
56
+ if (!body || typeof body !== "object") {
57
+ throw new mint.CollabTokenError(response.status, "Workspace presence resolve returned no body");
58
+ }
59
+ return {
60
+ assets: arrayOrEmpty(body.assets),
61
+ sessions: arrayOrEmpty(body.sessions),
62
+ projects: arrayOrEmpty(body.projects),
63
+ members: arrayOrEmpty(body.members)
64
+ };
65
+ }
66
+ exports.mintWorkspacePresenceToken = mintWorkspacePresenceToken;
67
+ exports.resolveWorkspacePresence = resolveWorkspacePresence;
68
+ //# sourceMappingURL=presence-mint.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"presence-mint.cjs","sources":["../../src/collab/presence-mint.ts"],"sourcesContent":["/**\n * Public-API clients for the workspace presence feed.\n *\n * Two endpoints, both admin-only and both under the workspace:\n *\n * - `POST /api/v0/workspaces/{workspaceId}/presence-token` mints a\n * **read-only** Keryx token for the workspace awareness room\n * (`<workspaceId>/awareness`) — the same per-recipient-filtered presence\n * feed the Olympus People page renders. The holder can receive presence and\n * broadcast nothing; `connectWorkspacePresence` re-mints before expiry.\n * - `POST /api/v0/workspaces/{workspaceId}/presence/resolve` turns the opaque\n * ids carried in awareness contexts (asset ids, session thread ids, project\n * ids) into titles the caller may see. Only things the caller may open come\n * back; missing ids are simply absent.\n *\n * Wire shapes are snake_case, matching `CollabTokenResponse`.\n */\n\nimport {\n type CollabAuth,\n CollabTokenError,\n collabErrorFromResponse,\n getAthenaApiBaseUrl,\n getCollabAuthHeaders,\n} from './mint';\n\n/** Grant for the workspace awareness room. Always read-only (`access_type: 'r'`). */\nexport interface WorkspacePresenceTokenResponse {\n token: string;\n access_type: 'r' | 'rw';\n expires_at_ms: number;\n ws_url: string;\n org: string;\n doc_id: string;\n branch: string;\n}\n\nexport interface WorkspacePresenceResolveRequest {\n asset_ids?: string[];\n /** Thread ids, as published in `session` awareness contexts. */\n session_ids?: string[];\n project_ids?: string[];\n include_projects?: boolean;\n include_members?: boolean;\n}\n\nexport interface WorkspacePresenceResolvedAsset {\n id: string;\n title: string;\n asset_type: string;\n}\n\nexport interface WorkspacePresenceResolvedSession {\n thread_id: string;\n asset_id: string;\n title: string;\n}\n\nexport interface WorkspacePresenceResolvedProject {\n id: string;\n title: string;\n assets: WorkspacePresenceResolvedAsset[];\n}\n\nexport interface WorkspacePresenceResolvedMember {\n id: string;\n name: string;\n email: string;\n avatar_url: string | null;\n}\n\nexport interface WorkspacePresenceResolveResponse {\n assets: WorkspacePresenceResolvedAsset[];\n sessions: WorkspacePresenceResolvedSession[];\n projects: WorkspacePresenceResolvedProject[];\n members: WorkspacePresenceResolvedMember[];\n}\n\nfunction workspaceUrl({\n auth,\n workspaceId,\n path,\n}: {\n auth: CollabAuth;\n workspaceId: string;\n path: string;\n}): string {\n const base = getAthenaApiBaseUrl(auth.backendUrl);\n return `${base}/api/v0/workspaces/${encodeURIComponent(workspaceId)}/${path}`;\n}\n\n/**\n * Mint a read-only token for the workspace awareness room.\n *\n * Rejects with `CollabTokenError` on 401 (bad credential) and 403 (admin\n * required, roster not enabled for this workspace, or a sandbox credential\n * that may not read presence), carrying the server's `detail`.\n */\nexport async function mintWorkspacePresenceToken(args: {\n auth: CollabAuth;\n workspaceId: string;\n signal?: AbortSignal;\n}): Promise<WorkspacePresenceTokenResponse> {\n const response = await fetch(\n workspaceUrl({ auth: args.auth, workspaceId: args.workspaceId, path: 'presence-token' }),\n {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n ...getCollabAuthHeaders(args.auth),\n },\n body: JSON.stringify({}),\n signal: args.signal,\n }\n );\n if (!response.ok) {\n throw await collabErrorFromResponse(\n response,\n `Workspace presence token mint failed with status ${response.status}`\n );\n }\n return (await response.json()) as WorkspacePresenceTokenResponse;\n}\n\nfunction arrayOrEmpty<T>(value: T[] | undefined | null): T[] {\n return Array.isArray(value) ? value : [];\n}\n\n/**\n * Resolve awareness ids to titles. Every section of the response is always\n * present on the returned object (an omitted section reads as empty), so\n * callers can index without guarding. Failures reject with `CollabTokenError`\n * — the same status + detail shape as the mint.\n */\nexport async function resolveWorkspacePresence(args: {\n auth: CollabAuth;\n workspaceId: string;\n request: WorkspacePresenceResolveRequest;\n signal?: AbortSignal;\n}): Promise<WorkspacePresenceResolveResponse> {\n const response = await fetch(\n workspaceUrl({ auth: args.auth, workspaceId: args.workspaceId, path: 'presence/resolve' }),\n {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n ...getCollabAuthHeaders(args.auth),\n },\n body: JSON.stringify(args.request),\n signal: args.signal,\n }\n );\n if (!response.ok) {\n throw await collabErrorFromResponse(\n response,\n `Workspace presence resolve failed with status ${response.status}`\n );\n }\n const body = (await response.json()) as Partial<WorkspacePresenceResolveResponse> | null;\n if (!body || typeof body !== 'object') {\n throw new CollabTokenError(response.status, 'Workspace presence resolve returned no body');\n }\n return {\n assets: arrayOrEmpty(body.assets),\n sessions: arrayOrEmpty(body.sessions),\n projects: arrayOrEmpty(body.projects),\n members: arrayOrEmpty(body.members),\n };\n}\n"],"names":["getAthenaApiBaseUrl","getCollabAuthHeaders","collabErrorFromResponse","CollabTokenError"],"mappings":";;;AA8EA,SAAS,aAAa;AAAA,EACpB;AAAA,EACA;AAAA,EACA;AACF,GAIW;AACT,QAAM,OAAOA,KAAAA,oBAAoB,KAAK,UAAU;AAChD,SAAO,GAAG,IAAI,sBAAsB,mBAAmB,WAAW,CAAC,IAAI,IAAI;AAC7E;AASA,eAAsB,2BAA2B,MAIL;AAC1C,QAAM,WAAW,MAAM;AAAA,IACrB,aAAa,EAAE,MAAM,KAAK,MAAM,aAAa,KAAK,aAAa,MAAM,kBAAkB;AAAA,IACvF;AAAA,MACE,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,gBAAgB;AAAA,QAChB,GAAGC,KAAAA,qBAAqB,KAAK,IAAI;AAAA,MAAA;AAAA,MAEnC,MAAM,KAAK,UAAU,EAAE;AAAA,MACvB,QAAQ,KAAK;AAAA,IAAA;AAAA,EACf;AAEF,MAAI,CAAC,SAAS,IAAI;AAChB,UAAM,MAAMC,KAAAA;AAAAA,MACV;AAAA,MACA,oDAAoD,SAAS,MAAM;AAAA,IAAA;AAAA,EAEvE;AACA,SAAQ,MAAM,SAAS,KAAA;AACzB;AAEA,SAAS,aAAgB,OAAoC;AAC3D,SAAO,MAAM,QAAQ,KAAK,IAAI,QAAQ,CAAA;AACxC;AAQA,eAAsB,yBAAyB,MAKD;AAC5C,QAAM,WAAW,MAAM;AAAA,IACrB,aAAa,EAAE,MAAM,KAAK,MAAM,aAAa,KAAK,aAAa,MAAM,oBAAoB;AAAA,IACzF;AAAA,MACE,QAAQ;AAAA,MACR,SAAS;AAAA,QACP,gBAAgB;AAAA,QAChB,GAAGD,KAAAA,qBAAqB,KAAK,IAAI;AAAA,MAAA;AAAA,MAEnC,MAAM,KAAK,UAAU,KAAK,OAAO;AAAA,MACjC,QAAQ,KAAK;AAAA,IAAA;AAAA,EACf;AAEF,MAAI,CAAC,SAAS,IAAI;AAChB,UAAM,MAAMC,KAAAA;AAAAA,MACV;AAAA,MACA,iDAAiD,SAAS,MAAM;AAAA,IAAA;AAAA,EAEpE;AACA,QAAM,OAAQ,MAAM,SAAS,KAAA;AAC7B,MAAI,CAAC,QAAQ,OAAO,SAAS,UAAU;AACrC,UAAM,IAAIC,KAAAA,iBAAiB,SAAS,QAAQ,6CAA6C;AAAA,EAC3F;AACA,SAAO;AAAA,IACL,QAAQ,aAAa,KAAK,MAAM;AAAA,IAChC,UAAU,aAAa,KAAK,QAAQ;AAAA,IACpC,UAAU,aAAa,KAAK,QAAQ;AAAA,IACpC,SAAS,aAAa,KAAK,OAAO;AAAA,EAAA;AAEtC;;;"}