visual-comments 0.1.0 → 0.2.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 (55) hide show
  1. package/README.md +19 -2
  2. package/dist/cli/index.d.ts +2 -0
  3. package/dist/cli/index.js +477 -0
  4. package/dist/core/presence.d.ts +13 -0
  5. package/dist/core/presence.js +25 -0
  6. package/dist/core/threads.svelte.d.ts +36 -2
  7. package/dist/core/threads.svelte.js +120 -3
  8. package/dist/index.d.ts +1 -1
  9. package/dist/server/env.d.ts +78 -0
  10. package/dist/server/env.js +94 -0
  11. package/dist/server/index.d.ts +1 -0
  12. package/dist/server/index.js +10 -3
  13. package/dist/server/variables.d.ts +28 -0
  14. package/dist/server/variables.js +73 -0
  15. package/dist/shared/protocol.d.ts +4 -0
  16. package/dist/shared/protocol.js +4 -0
  17. package/dist/shared/types.d.ts +64 -0
  18. package/dist/sveltekit/CommentTools.svelte +98 -0
  19. package/dist/sveltekit/CommentTools.svelte.d.ts +6 -0
  20. package/dist/sveltekit/Comments.svelte +104 -0
  21. package/dist/sveltekit/Comments.svelte.d.ts +25 -0
  22. package/dist/sveltekit/Toolbar.svelte +64 -0
  23. package/dist/sveltekit/Toolbar.svelte.d.ts +13 -0
  24. package/dist/sveltekit/ToolbarButton.svelte +66 -0
  25. package/dist/sveltekit/ToolbarButton.svelte.d.ts +12 -0
  26. package/dist/sveltekit/context.d.ts +16 -0
  27. package/dist/sveltekit/context.js +9 -0
  28. package/dist/sveltekit/index.d.ts +6 -0
  29. package/dist/sveltekit/index.js +6 -0
  30. package/dist/ui/Account.svelte +45 -7
  31. package/dist/ui/App.svelte +12 -3
  32. package/dist/ui/Avatar.svelte +28 -6
  33. package/dist/ui/Avatar.svelte.d.ts +4 -5
  34. package/dist/ui/Connect.svelte +128 -0
  35. package/dist/ui/Connect.svelte.d.ts +6 -0
  36. package/dist/ui/Menu.svelte +5 -1
  37. package/dist/ui/Menu.svelte.d.ts +2 -0
  38. package/dist/ui/Overlay.svelte +15 -1
  39. package/dist/ui/Panel.svelte +141 -127
  40. package/dist/ui/Pin.svelte +22 -2
  41. package/dist/ui/Pin.svelte.d.ts +2 -0
  42. package/dist/ui/Presence.svelte +43 -0
  43. package/dist/ui/Presence.svelte.d.ts +8 -0
  44. package/dist/ui/ThreadCard.svelte +26 -2
  45. package/dist/ui/generated/styles.css +1 -1
  46. package/dist/ui/generated/styles.js +2 -2
  47. package/dist/ui/styles.css +21 -0
  48. package/dist/ui/text.d.ts +3 -1
  49. package/dist/ui/text.js +4 -0
  50. package/dist/visual-comments.svelte.d.ts +53 -4
  51. package/dist/visual-comments.svelte.js +154 -29
  52. package/dist/vite/index.d.ts +20 -0
  53. package/dist/vite/index.js +78 -0
  54. package/dist-widget/visual-comments.js +17 -10
  55. package/package.json +88 -66
@@ -7,17 +7,35 @@ export class Threads {
7
7
  threads = $state([]);
8
8
  spaceName = $state('');
9
9
  loading = $state(true);
10
+ /** Whether `viewer` is known: from the server's answer, or given when the page loaded. */
11
+ viewerKnown = $state(false);
10
12
  /** A failed action, shown until dismissed. */
11
13
  error = $state(null);
12
14
  /** Ticks every 30 seconds so relative times stay current. */
13
15
  now = $state(Date.now());
16
+ /** This tab's event stream, as the server named it, while it is open. */
17
+ connection = $state();
18
+ /** Everyone present in the space, this tab included, while the event stream is open. */
19
+ participants = $state.raw([]);
20
+ /** The space's agents once listed; undefined until then, or when the space has them turned off. */
21
+ agents = $state.raw();
14
22
  api;
15
23
  refreshing;
16
24
  queued = false;
17
25
  listening;
18
26
  nextPending = -1;
19
- constructor(api) {
27
+ presence;
28
+ /** The last presence sent, with its connection, so an unchanged one is not sent again. */
29
+ posted = '';
30
+ /** The participants as last received, so an unchanged list does not wake every listener. */
31
+ received = '[]';
32
+ /** `viewer`: who is signed in, when the page already knows it; undefined to wait for the server. */
33
+ constructor(api, viewer) {
20
34
  this.api = api;
35
+ if (viewer !== undefined) {
36
+ this.viewer = viewer;
37
+ this.viewerKnown = true;
38
+ }
21
39
  }
22
40
  get unreadCount() {
23
41
  return this.threads.filter((thread) => thread.unread && !thread.resolvedAt).length;
@@ -46,6 +64,7 @@ export class Threads {
46
64
  throw new Error();
47
65
  const snapshot = (await response.json());
48
66
  this.viewer = snapshot.viewer;
67
+ this.viewerKnown = true;
49
68
  this.spaceName = snapshot.space.name;
50
69
  // Keep replies that are still on their way to the server.
51
70
  const pending = new Map(this.threads.map((t) => [t.id, t.comments.filter((c) => c.pending)]));
@@ -75,6 +94,19 @@ export class Threads {
75
94
  stopListening() {
76
95
  this.listening?.abort();
77
96
  this.listening = undefined;
97
+ this.disconnected();
98
+ }
99
+ /** Without the stream, who is present is unknown, and the server has already let this tab go. */
100
+ disconnected() {
101
+ this.connection = undefined;
102
+ this.setParticipants([]);
103
+ }
104
+ setParticipants(participants) {
105
+ const json = JSON.stringify(participants);
106
+ if (json === this.received)
107
+ return;
108
+ this.received = json;
109
+ this.participants = participants;
78
110
  }
79
111
  /** Follows the event stream; after a dropped connection, catches up on anything missed. */
80
112
  listen() {
@@ -101,8 +133,7 @@ export class Threads {
101
133
  buffer += value;
102
134
  let end;
103
135
  while ((end = buffer.indexOf('\n\n')) !== -1) {
104
- if (/^event: change$/m.test(buffer.slice(0, end)))
105
- void this.refresh();
136
+ this.receive(buffer.slice(0, end));
106
137
  buffer = buffer.slice(end + 2);
107
138
  }
108
139
  }
@@ -112,12 +143,65 @@ export class Threads {
112
143
  }
113
144
  if (controller.signal.aborted)
114
145
  break;
146
+ this.disconnected();
115
147
  await new Promise((done) => setTimeout(done, 3000));
116
148
  }
117
149
  if (this.listening === controller)
118
150
  this.listening = undefined;
119
151
  })();
120
152
  }
153
+ /** One message of the event stream. Servers without presence send only `change`. */
154
+ receive(message) {
155
+ const event = /^event: (.*)$/m.exec(message)?.[1];
156
+ if (event === 'change')
157
+ return void this.refresh();
158
+ if (event !== 'hello' && event !== 'presence')
159
+ return;
160
+ let data;
161
+ try {
162
+ data = JSON.parse(message.match(/^data: ?(.*)$/gm)?.map((line) => line.replace(/^data: ?/, '')).join('\n') ?? '');
163
+ }
164
+ catch {
165
+ return;
166
+ }
167
+ if (event === 'hello' && typeof data?.connection === 'string') {
168
+ this.connection = data.connection;
169
+ // A new connection is a new participant: tell the server what this tab is doing.
170
+ void this.postPresence();
171
+ }
172
+ else if (event === 'presence' && Array.isArray(data?.participants)) {
173
+ this.setParticipants(data.participants);
174
+ }
175
+ }
176
+ /**
177
+ * Says what this tab is doing, unless it said so already. `again` sends it
178
+ * even so, as the heartbeat that keeps `typing` from lapsing.
179
+ */
180
+ setPresence(presence, again = false) {
181
+ this.presence = presence;
182
+ void this.postPresence(again);
183
+ }
184
+ async postPresence(again = false) {
185
+ const connection = this.connection;
186
+ if (!connection || !this.presence)
187
+ return;
188
+ const body = JSON.stringify({ connection, ...this.presence });
189
+ if (body === this.posted && !again)
190
+ return;
191
+ this.posted = body;
192
+ try {
193
+ const response = await this.api.fetch('/presence', {
194
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body
195
+ });
196
+ if (!response.ok && this.posted === body)
197
+ this.posted = '';
198
+ }
199
+ catch {
200
+ // The next change, or the next connection, sends it again.
201
+ if (this.posted === body)
202
+ this.posted = '';
203
+ }
204
+ }
121
205
  async request(method, path, body) {
122
206
  const response = await this.api.fetch(path, {
123
207
  method,
@@ -216,4 +300,37 @@ export class Threads {
216
300
  async markAllRead() {
217
301
  await Promise.all(this.threads.filter((t) => t.unread).map((t) => this.markRead(t.id, true)));
218
302
  }
303
+ // Agents.
304
+ async loadAgents() {
305
+ try {
306
+ this.agents = (await this.request('GET', '/agents')).agents;
307
+ }
308
+ catch {
309
+ this.agents = undefined;
310
+ }
311
+ }
312
+ /** Takes an agent out of the space at once: its token stops working. */
313
+ async removeAgent(id) {
314
+ this.agents = this.agents?.filter((agent) => agent.id !== id);
315
+ try {
316
+ await this.request('DELETE', `/agents/${encodeURIComponent(id)}`);
317
+ }
318
+ catch (error) {
319
+ this.error = error.message;
320
+ void this.loadAgents();
321
+ }
322
+ }
323
+ /** An agent asking to join, for the person approving it. Throws when it has expired or was answered. */
324
+ async agentRequest(id) {
325
+ return (await this.request('GET', `/agents/requests/${encodeURIComponent(id)}`)).request;
326
+ }
327
+ async approveAgent(id) {
328
+ const { agent } = await this.request('POST', `/agents/requests/${encodeURIComponent(id)}/approve`);
329
+ if (this.agents)
330
+ this.agents = [...this.agents.filter((a) => a.id !== agent.id), agent];
331
+ return agent;
332
+ }
333
+ async denyAgent(id) {
334
+ await this.request('DELETE', `/agents/requests/${encodeURIComponent(id)}`);
335
+ }
219
336
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { VisualComments, pageOf, type VisualCommentsOptions, type SurfaceInfo, type CommentFilters } from './visual-comments.svelte.js';
1
+ export { VisualComments, pageOf, type VisualCommentsOptions, type SurfaceInfo, type CommentFilters, type PresenceLocation } from './visual-comments.svelte.js';
2
2
  export type * from './shared/types.js';
3
3
  import { VisualComments, type VisualCommentsOptions } from './visual-comments.svelte.js';
4
4
  /** Adds comments to the page. Call once, in the browser. */
@@ -0,0 +1,78 @@
1
+ import type { Viewer } from '../shared/types.js';
2
+ import { type CommentsUser, type Handle } from './index.js';
3
+ import { type CommentsEnv } from './variables.js';
4
+ export { commentsVariables, type CommentsEnv, type CommentsEnvName } from './variables.js';
5
+ export interface CommentsSignInOptions {
6
+ /**
7
+ * More host names the development sign-in works on, besides localhost: e.g.
8
+ * the machine's address on a private network, to open the dev server from
9
+ * another machine. Whoever reaches the site there can sign in as anyone.
10
+ */
11
+ devHosts?: string[];
12
+ }
13
+ export interface CommentsEnvOptions {
14
+ /**
15
+ * Whether the dev server is running: `dev` from `$app/env` (`$app/environment`
16
+ * before SvelteKit 3). Needed for `VISUAL_COMMENTS_ENABLED=dev`, and for the
17
+ * development sign-in.
18
+ */
19
+ dev?: boolean;
20
+ /**
21
+ * Sign people in to the comments apart from the site's own accounts, with
22
+ * `@cnvx/sign-in` (install it beside this package): at the provider of
23
+ * the `VISUAL_COMMENTS_OIDC_*` variables, or, while the dev server runs without
24
+ * them, with any name on localhost. Its routes are at `VISUAL_COMMENTS_AUTH`;
25
+ * its session is the `visual_comments_session` cookie. Without it, the site's
26
+ * own sign-in says who comments (`sveltekit(user)`).
27
+ */
28
+ signIn?: boolean | CommentsSignInOptions;
29
+ }
30
+ /** What the browser needs to reach the comments, for `<Comments config>` of `visual-comments/sveltekit`. */
31
+ export interface CommentsClientConfig {
32
+ /** Where this site answers comment requests, e.g. `/_comments`. */
33
+ endpoint: string;
34
+ /** Where this site's sign-in routes are, e.g. `/auth`. */
35
+ auth: string;
36
+ /**
37
+ * Who is signed in to the comments, as the server knows it when the page
38
+ * loads (`load`): null for nobody, so the toolbar shows them at once.
39
+ * Undefined: the browser asks.
40
+ */
41
+ viewer?: Viewer | null;
42
+ }
43
+ export interface CommentsFromEnv {
44
+ /** Whether comments are on. */
45
+ readonly enabled: boolean;
46
+ /** For the browser, e.g. returned by the root layout's `load`. Null while comments are off. */
47
+ readonly client: CommentsClientConfig | null;
48
+ /**
49
+ * `client` with who is signed in to the comments' own sign-in, for the root
50
+ * layout's `load`: `load: (event) => comments.load(event)`. Without the own
51
+ * sign-in, or while off, it is `client`.
52
+ */
53
+ load(event: {
54
+ request: Request;
55
+ }): Promise<CommentsClientConfig | null>;
56
+ /** Answers comment requests as `user`; undefined for any other request, and for every request while off. */
57
+ handle(request: Request, user: CommentsUser): Promise<Response | undefined>;
58
+ /**
59
+ * SvelteKit's `handle` hook. With the comments' own sign-in, it answers its
60
+ * routes and comments as the person signed in there; put it first. Otherwise
61
+ * put it after the site's sign-in: by default the person is
62
+ * `event.locals.user`. While off it passes every request on.
63
+ */
64
+ sveltekit<E extends {
65
+ request: Request;
66
+ locals: object;
67
+ }>(user?: (event: E) => CommentsUser | Promise<CommentsUser>): Handle<E>;
68
+ }
69
+ /**
70
+ * Sets up comments from `VISUAL_COMMENTS_*` variables. They are off unless
71
+ * `VISUAL_COMMENTS_ENABLED` is `true`, or `dev` while the dev server runs;
72
+ * on, `VISUAL_COMMENTS_SERVER`, `VISUAL_COMMENTS_SPACE` and
73
+ * `VISUAL_COMMENTS_SECRET` must be set, and with `signIn` outside the dev
74
+ * server the `VISUAL_COMMENTS_OIDC_*` ones and `VISUAL_COMMENTS_SESSION_SECRET`.
75
+ * Throws on a missing or invalid value, so a deployment fails at start rather
76
+ * than when someone comments.
77
+ */
78
+ export declare function commentsFromEnv(env: CommentsEnv, options?: CommentsEnvOptions): CommentsFromEnv;
@@ -0,0 +1,94 @@
1
+ // Comments set up from environment variables, so each deployment turns them on
2
+ // or off without a new build: on in development and staging, say, and absent
3
+ // in production. Off, the site answers no comment requests and the browser
4
+ // never loads the comments' code. (To leave them out of a build altogether, see
5
+ // `visual-comments/vite`.)
6
+ import { commentsProxy } from './index.js';
7
+ import { invalid, read } from './variables.js';
8
+ export { commentsVariables } from './variables.js';
9
+ /** The comments' own sign-in's cookie. */
10
+ const COOKIE = 'visual_comments_session';
11
+ const OIDC = ['VISUAL_COMMENTS_OIDC_ISSUER', 'VISUAL_COMMENTS_OIDC_CLIENT_ID', 'VISUAL_COMMENTS_OIDC_CLIENT_SECRET'];
12
+ /**
13
+ * Sets up comments from `VISUAL_COMMENTS_*` variables. They are off unless
14
+ * `VISUAL_COMMENTS_ENABLED` is `true`, or `dev` while the dev server runs;
15
+ * on, `VISUAL_COMMENTS_SERVER`, `VISUAL_COMMENTS_SPACE` and
16
+ * `VISUAL_COMMENTS_SECRET` must be set, and with `signIn` outside the dev
17
+ * server the `VISUAL_COMMENTS_OIDC_*` ones and `VISUAL_COMMENTS_SESSION_SECRET`.
18
+ * Throws on a missing or invalid value, so a deployment fails at start rather
19
+ * than when someone comments.
20
+ */
21
+ export function commentsFromEnv(env, options = {}) {
22
+ const flag = read(env, 'VISUAL_COMMENTS_ENABLED') ?? 'false';
23
+ if (flag === 'dev' && options.dev === undefined) {
24
+ throw invalid('VISUAL_COMMENTS_ENABLED', 'is dev, so commentsFromEnv needs { dev }: `dev` from $app/env');
25
+ }
26
+ const enabled = flag === 'true' || (flag === 'dev' && options.dev === true);
27
+ if (!enabled) {
28
+ return {
29
+ enabled: false,
30
+ client: null,
31
+ load: async () => null,
32
+ handle: async () => undefined,
33
+ sveltekit: () => ({ event, resolve }) => Promise.resolve(resolve(event))
34
+ };
35
+ }
36
+ const required = (name) => read(env, name) ?? (() => { throw invalid(name, 'must be set while comments are on'); })();
37
+ const path = (read(env, 'VISUAL_COMMENTS_PATH') ?? '/_comments').replace(/(.)\/+$/, '$1');
38
+ const auth = (read(env, 'VISUAL_COMMENTS_AUTH') ?? '/auth').replace(/(.)\/+$/, '$1');
39
+ const proxy = commentsProxy({
40
+ server: required('VISUAL_COMMENTS_SERVER'),
41
+ space: required('VISUAL_COMMENTS_SPACE'),
42
+ secret: required('VISUAL_COMMENTS_SECRET'),
43
+ path
44
+ });
45
+ const client = { endpoint: path, auth };
46
+ if (!options.signIn)
47
+ return { enabled: true, client, load: async () => client, handle: proxy.handle, sveltekit: proxy.sveltekit };
48
+ // The comments' own sign-in: at the provider, or in development with any name.
49
+ const provider = OIDC.some((name) => read(env, name)) || !options.dev;
50
+ const [issuer, clientId, clientSecret] = OIDC.map((name) => (provider ? required(name) : undefined));
51
+ // In development the space's secret makes the key when there is no session secret, so the
52
+ // sessions outlast restarts of the dev server.
53
+ const secret = provider
54
+ ? required('VISUAL_COMMENTS_SESSION_SECRET')
55
+ : read(env, 'VISUAL_COMMENTS_SESSION_SECRET') ?? `visual-comments dev sign-in:${required('VISUAL_COMMENTS_SECRET')}`;
56
+ const devHosts = typeof options.signIn === 'object' ? options.signIn.devHosts : undefined;
57
+ let module;
58
+ // Loaded on the first request, so sites without it can use the rest of this module.
59
+ const load = () => (module ??= import('@cnvx/sign-in/server').then(({ createSignIn, withCookies }) => ({
60
+ signIn: createSignIn({ issuer, clientId, clientSecret, secret, path: auth, cookie: COOKIE, devLogin: !provider, devHosts }),
61
+ withCookies
62
+ }), (error) => { throw new Error('The comments’ own sign-in needs @cnvx/sign-in: install it beside visual-comments.', { cause: error }); }));
63
+ // One check per request: the layout's `load` and the hook share it, and the hook sets the
64
+ // cookies it renews.
65
+ const sessions = new WeakMap();
66
+ const check = (request) => {
67
+ let session = sessions.get(request);
68
+ if (!session)
69
+ sessions.set(request, (session = load().then(({ signIn }) => signIn.check(request))));
70
+ return session;
71
+ };
72
+ return {
73
+ enabled: true,
74
+ client,
75
+ load: async ({ request }) => ({ ...client, viewer: (await check(request)).user ?? null }),
76
+ handle: proxy.handle,
77
+ sveltekit: () => async ({ event, resolve }) => {
78
+ const { signIn, withCookies } = await load();
79
+ const answered = await signIn.handle(event.request);
80
+ if (answered)
81
+ return answered;
82
+ const { pathname } = new URL(event.request.url);
83
+ if (pathname === path || pathname.startsWith(`${path}/`)) {
84
+ const session = await check(event.request);
85
+ const response = await proxy.handle(event.request, session.user);
86
+ if (response)
87
+ return withCookies(response, session.cookies);
88
+ }
89
+ const response = await resolve(event);
90
+ const session = sessions.get(event.request);
91
+ return session ? withCookies(response, (await session).cookies) : response;
92
+ }
93
+ };
94
+ }
@@ -31,3 +31,4 @@ export declare function commentsProxy(options: CommentsProxyOptions): {
31
31
  locals: object;
32
32
  }>(user?: (event: E) => CommentsUser | Promise<CommentsUser>) => Handle<E>;
33
33
  };
34
+ export { commentsFromEnv, commentsVariables, type CommentsClientConfig, type CommentsEnv, type CommentsEnvName, type CommentsEnvOptions, type CommentsFromEnv, type CommentsSignInOptions } from './env.js';
@@ -1,7 +1,8 @@
1
1
  // For the site's server: passes the browser's comment requests on to the
2
- // comment server, with the space's secret and the person signed in on the site.
2
+ // comment server, with the space's secret and the person signed in on the site,
3
+ // or the token of an agent of the space.
3
4
  // Uses only the Fetch API, so it runs in Node, Bun, Deno and Workers.
4
- import { USER_HEADER } from '../shared/protocol.js';
5
+ import { AGENT_HEADER, AGENT_TOKEN_PREFIX, USER_HEADER } from '../shared/protocol.js';
5
6
  const REQUEST_HEADERS = ['content-type', 'accept', 'last-event-id'];
6
7
  const RESPONSE_HEADERS = ['content-type', 'cache-control', 'content-security-policy', 'x-content-type-options', 'x-accel-buffering'];
7
8
  export function commentsProxy(options) {
@@ -19,7 +20,12 @@ export function commentsProxy(options) {
19
20
  if (value)
20
21
  headers.set(name, value);
21
22
  }
22
- if (user) {
23
+ // An agent's token speaks for the agent, even from a browser where someone is signed in.
24
+ // Any other bearer token is the site's business and stays here.
25
+ const token = /^Bearer (\S+)$/.exec(request.headers.get('authorization') ?? '')?.[1];
26
+ if (token?.startsWith(AGENT_TOKEN_PREFIX))
27
+ headers.set(AGENT_HEADER, token);
28
+ else if (user) {
23
29
  const { sub, name, email, picture } = user;
24
30
  headers.set(USER_HEADER, base64url(JSON.stringify({ sub, name, email, picture })));
25
31
  }
@@ -65,3 +71,4 @@ function base64url(text) {
65
71
  binary += String.fromCharCode(byte);
66
72
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
67
73
  }
74
+ export { commentsFromEnv, commentsVariables } from './env.js';
@@ -0,0 +1,28 @@
1
+ /** The variables `commentsFromEnv` reads. */
2
+ export type CommentsEnvName = 'VISUAL_COMMENTS_ENABLED' | 'VISUAL_COMMENTS_SERVER' | 'VISUAL_COMMENTS_SPACE' | 'VISUAL_COMMENTS_SECRET' | 'VISUAL_COMMENTS_PATH' | 'VISUAL_COMMENTS_AUTH' | 'VISUAL_COMMENTS_OIDC_ISSUER' | 'VISUAL_COMMENTS_OIDC_CLIENT_ID' | 'VISUAL_COMMENTS_OIDC_CLIENT_SECRET' | 'VISUAL_COMMENTS_SESSION_SECRET';
3
+ /**
4
+ * Where the variables come from: `process.env`, SvelteKit's `$app/env/private`
5
+ * (`import * as env from '$app/env/private'`) or `$env/dynamic/private` before
6
+ * SvelteKit 3. Values that are not strings count as unset.
7
+ */
8
+ export type CommentsEnv = {
9
+ readonly [K in CommentsEnvName]?: unknown;
10
+ };
11
+ export declare function invalid(name: CommentsEnvName, message: string): Error;
12
+ /** A variable's value, checked; undefined when unset or empty. */
13
+ export declare function read(env: CommentsEnv, name: CommentsEnvName): string | undefined;
14
+ /**
15
+ * The variables for SvelteKit's `src/env.ts`, all private and read when the
16
+ * app starts, so one build serves every deployment:
17
+ *
18
+ * ```ts
19
+ * import { defineEnvVars } from '@sveltejs/kit/env';
20
+ * import { commentsVariables } from 'visual-comments/server';
21
+ *
22
+ * export const variables = defineEnvVars({ ...commentsVariables });
23
+ * ```
24
+ */
25
+ export declare const commentsVariables: { readonly [K in CommentsEnvName]: {
26
+ description: string;
27
+ schema: (value: string | undefined) => string | undefined;
28
+ }; };
@@ -0,0 +1,73 @@
1
+ // The variables `commentsFromEnv` reads, with their checks. Kept apart from it, so a build
2
+ // without the comments (`visual-comments/vite`) still declares them.
3
+ const isPath = (value) => (value.startsWith('/') ? undefined : 'must be a path that starts with /, e.g. /_comments');
4
+ const isUrl = (value) => (URL.canParse(value) && /^https?:$/.test(new URL(value).protocol) ? undefined : 'must be an http(s) URL');
5
+ const isSecret = (value) => (value.length >= 32 ? undefined : 'must be at least 32 characters');
6
+ const CHECKS = {
7
+ VISUAL_COMMENTS_ENABLED: {
8
+ description: 'Whether comments are on: `true`, `false` or `dev` (only while the dev server runs). Default `false`.',
9
+ check: (value) => (['true', 'false', 'dev'].includes(value) ? undefined : 'must be true, false or dev')
10
+ },
11
+ VISUAL_COMMENTS_SERVER: {
12
+ description: 'The comment server, e.g. `https://comments.convex.works`. Required while comments are on.',
13
+ check: isUrl
14
+ },
15
+ VISUAL_COMMENTS_SPACE: {
16
+ description: "The site's space on the comment server. Required while comments are on."
17
+ },
18
+ VISUAL_COMMENTS_SECRET: {
19
+ description: "The space's secret, at least 32 characters. Required while comments are on. Never sent to browsers.",
20
+ check: isSecret
21
+ },
22
+ VISUAL_COMMENTS_PATH: {
23
+ description: 'Where the site answers comment requests, base path included. Default `/_comments`.',
24
+ check: isPath
25
+ },
26
+ VISUAL_COMMENTS_AUTH: {
27
+ description: "Where the site's sign-in routes are, base path included. Default `/auth`.",
28
+ check: isPath
29
+ },
30
+ VISUAL_COMMENTS_OIDC_ISSUER: {
31
+ description: "With the comments' own sign-in: the OpenID Connect provider, e.g. `https://id.convex.works`.",
32
+ check: isUrl
33
+ },
34
+ VISUAL_COMMENTS_OIDC_CLIENT_ID: {
35
+ description: "With the comments' own sign-in: their confidential client at the provider."
36
+ },
37
+ VISUAL_COMMENTS_OIDC_CLIENT_SECRET: {
38
+ description: "With the comments' own sign-in: that client's secret."
39
+ },
40
+ VISUAL_COMMENTS_SESSION_SECRET: {
41
+ description: "With the comments' own sign-in: encrypts their session cookie. At least 32 characters. Optional under the dev server.",
42
+ check: isSecret
43
+ }
44
+ };
45
+ export function invalid(name, message) {
46
+ return new Error(`${name} ${message}.`);
47
+ }
48
+ /** A variable's value, checked; undefined when unset or empty. */
49
+ export function read(env, name) {
50
+ const value = env[name];
51
+ if (typeof value !== 'string' || value === '')
52
+ return undefined;
53
+ const problem = CHECKS[name].check?.(value);
54
+ if (problem)
55
+ throw invalid(name, problem);
56
+ return value;
57
+ }
58
+ /**
59
+ * The variables for SvelteKit's `src/env.ts`, all private and read when the
60
+ * app starts, so one build serves every deployment:
61
+ *
62
+ * ```ts
63
+ * import { defineEnvVars } from '@sveltejs/kit/env';
64
+ * import { commentsVariables } from 'visual-comments/server';
65
+ *
66
+ * export const variables = defineEnvVars({ ...commentsVariables });
67
+ * ```
68
+ */
69
+ export const commentsVariables = Object.fromEntries(Object.entries(CHECKS).map(([name, { description }]) => [name, {
70
+ description,
71
+ // Optional, and checked when the app starts.
72
+ schema: (value) => read({ [name]: value }, name)
73
+ }]));
@@ -1,2 +1,6 @@
1
1
  /** The header with who is signed in on the site: their `Viewer` as base64url-encoded JSON. */
2
2
  export declare const USER_HEADER = "visual-comments-user";
3
+ /** The header with an agent's token, passed on by the site's server instead of `USER_HEADER`. */
4
+ export declare const AGENT_HEADER = "visual-comments-agent";
5
+ /** Agent tokens start with this, so the site's server can tell them from other bearer tokens. */
6
+ export declare const AGENT_TOKEN_PREFIX = "vca_";
@@ -1,3 +1,7 @@
1
1
  // How a site's server talks to the comment server.
2
2
  /** The header with who is signed in on the site: their `Viewer` as base64url-encoded JSON. */
3
3
  export const USER_HEADER = 'visual-comments-user';
4
+ /** The header with an agent's token, passed on by the site's server instead of `USER_HEADER`. */
5
+ export const AGENT_HEADER = 'visual-comments-agent';
6
+ /** Agent tokens start with this, so the site's server can tell them from other bearer tokens. */
7
+ export const AGENT_TOKEN_PREFIX = 'vca_';
@@ -3,6 +3,13 @@ export interface Author {
3
3
  sub: string;
4
4
  name: string;
5
5
  picture?: string;
6
+ /** Set when the author is an agent: it joined the space when a person approved it. */
7
+ agent?: {
8
+ addedBy: {
9
+ sub: string;
10
+ name: string;
11
+ };
12
+ };
6
13
  }
7
14
  /** The person signed in on the site. */
8
15
  export interface Viewer extends Author {
@@ -91,3 +98,60 @@ export interface NewThreadInput {
91
98
  body: string;
92
99
  images?: number[];
93
100
  }
101
+ /** What someone present is doing. */
102
+ export type PresenceState = 'viewing' | 'typing' | 'working';
103
+ /** Someone in the space right now: one per browser tab, or one per task of an agent. */
104
+ export interface Participant {
105
+ /** Stable while they stay. */
106
+ id: string;
107
+ author: Author;
108
+ state: PresenceState;
109
+ page?: string;
110
+ surface?: string;
111
+ thread?: number;
112
+ /** What they are doing, in their words, e.g. `Tightening the table`. */
113
+ note?: string;
114
+ /** When the state last changed, in milliseconds since the epoch. */
115
+ since: number;
116
+ }
117
+ /** What a client sends to `POST /presence`. */
118
+ export interface PresenceUpdate {
119
+ /** A browser's connection, from the event stream's `hello` event. Agents leave it out. */
120
+ connection?: string;
121
+ /** Agents only: one participant per key, so one agent can work on several things at once. Default `''`. */
122
+ key?: string;
123
+ /** `gone` removes the participant. */
124
+ state: PresenceState | 'gone';
125
+ page?: string;
126
+ surface?: string;
127
+ thread?: number;
128
+ note?: string;
129
+ }
130
+ /** An agent of the space. */
131
+ export interface Agent {
132
+ id: string;
133
+ name: string;
134
+ addedBy: {
135
+ sub: string;
136
+ name: string;
137
+ };
138
+ createdAt: number;
139
+ lastSeenAt: number | null;
140
+ }
141
+ /** An agent asking to join the space, waiting for a person to approve it. */
142
+ export interface AgentRequest {
143
+ id: string;
144
+ name: string;
145
+ /** Shown to the agent and to the person approving, so both know it is the same request. */
146
+ code: string;
147
+ expiresAt: number;
148
+ }
149
+ /** The request as the agent that asked gets it: with the secret it collects its token with. */
150
+ export interface NewAgentRequest extends AgentRequest {
151
+ secret: string;
152
+ }
153
+ /** What `GET /agent/next` answers: the threads with something new for the agent. */
154
+ export interface AgentInbox {
155
+ threads: CommentThread[];
156
+ participants: Participant[];
157
+ }