@protobase/server 0.62.5 → 0.63.1

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 (41) hide show
  1. package/dist/better-auth/create-auth.d.ts +2 -2
  2. package/dist/better-auth/create-auth.js +4 -2
  3. package/dist/better-auth/organizations/organization-role.js +13 -0
  4. package/dist/calls/call-hub.js +28 -7
  5. package/dist/calls/call-member.d.ts +5 -1
  6. package/dist/calls/call-reads.d.ts +7 -2
  7. package/dist/calls/call-reads.js +6 -4
  8. package/dist/calls/call-store.d.ts +7 -0
  9. package/dist/calls/call-store.js +35 -24
  10. package/dist/calls/call-sweep.js +8 -6
  11. package/dist/calls/calls-access.js +7 -2
  12. package/dist/calls/calls-runtime.d.ts +26 -5
  13. package/dist/calls/calls-runtime.js +84 -19
  14. package/dist/calls/calls-service.js +4 -2
  15. package/dist/calls/calls-sql.d.ts +1 -1
  16. package/dist/calls/calls-sql.js +56 -5
  17. package/dist/calls/calls-store.js +2 -1
  18. package/dist/calls/client-message.js +27 -2
  19. package/dist/calls/guest-checks-sql.d.ts +14 -0
  20. package/dist/calls/guest-checks-sql.js +43 -0
  21. package/dist/calls/guest-limits.js +49 -0
  22. package/dist/calls/guest-links-sql.d.ts +16 -0
  23. package/dist/calls/guest-links-sql.js +78 -0
  24. package/dist/calls/guest-lobby-sql.d.ts +17 -0
  25. package/dist/calls/guest-lobby-sql.js +81 -0
  26. package/dist/calls/guest-name.js +10 -0
  27. package/dist/calls/guest-routes.js +97 -0
  28. package/dist/calls/guest-socket.js +58 -0
  29. package/dist/calls/guest-store.d.ts +48 -0
  30. package/dist/calls/guest-store.js +20 -0
  31. package/dist/calls/guest-tokens.js +17 -0
  32. package/dist/calls/member-relay.js +63 -5
  33. package/dist/calls/organization-guests-route.js +38 -0
  34. package/dist/calls/record-organization.js +24 -0
  35. package/dist/create-admin.js +22 -2
  36. package/dist/functions/routes.js +1 -1
  37. package/dist/functions/upgrade-caller.js +1 -1
  38. package/dist/index.js +1 -1
  39. package/dist/package.js +1 -1
  40. package/dist/personal-mail/personal-mail-providers.js +11 -7
  41. package/package.json +4 -4
@@ -113,10 +113,10 @@ export type CreateAuthOptions = {
113
113
  /**
114
114
  * The mail providers people may link their own mailbox with, from the account page, for the app's functions to send
115
115
  * mail from it (`context.personalMail`), such as `['gmail']`. Each links through its sign-in provider, which
116
- * `socialProviders` must have: Gmail through `google`. Default none.
116
+ * `socialProviders` must have: Gmail through `google`. Needs `encryptOAuthTokens`. Default none.
117
117
  */
118
118
  personalMail?: PersonalMailProviderId[];
119
- /** Encrypt the OAuth access, refresh and ID tokens Better Auth stores for social sign-ins, with `secret`. Default off. */
119
+ /** Encrypt the OAuth access and refresh tokens Better Auth stores for social sign-ins, with `secret`; `personalMail` needs it. Default off. */
120
120
  encryptOAuthTokens?: boolean;
121
121
  /** Runs after a user is created, by any route: `createUser`, an admin, or a social sign-up. Default none. */
122
122
  onUserCreated?: UserCreatedHook;
@@ -78,6 +78,7 @@ const tokenLifetime = "15m";
78
78
  /** The configured roles with `admin` first; `admin` is added when missing. */
79
79
  const normalizeRoles = (roles) => roleDefinitions(roles).names;
80
80
  const adminImpersonationPaths = ["/admin/impersonate-user", "/admin/stop-impersonating"];
81
+ const providerTokenPaths = ["/get-access-token", "/refresh-token"];
81
82
  /** The optional plugins `options` turns on, imported: an app without SAML or MCP loads neither. */
82
83
  const loadAuthPlugins = async (options) => ({
83
84
  ...(options.sso?.saml.length ?? 0) > 0 && { samlPlugin: (await import("./saml-plugin.js")).samlPlugin },
@@ -268,6 +269,7 @@ const betterAuthOptions = (options, { origins = [], passkeyDomain, roles, defini
268
269
  ...accountDeletion ? [] : [selfDeletionPath],
269
270
  ...unusedEmailCodePaths,
270
271
  ...adminImpersonationPaths,
272
+ ...providerTokenPaths,
271
273
  ...organizations ? organizationPaths : [],
272
274
  ...saml.length > 0 ? samlDisabledPaths : [],
273
275
  ...mcp ? mcpUnusedPaths : []
@@ -371,7 +373,7 @@ const createAuth = (options, plugins = {}) => {
371
373
  const socialProviders = Object.keys(options.socialProviders ?? {});
372
374
  const platformSignIn = platformSignInFor(signInSettings && resolvePlatformSignIn(signInSettings), socialProviders);
373
375
  const saml = resolveSamlProviders(options.sso?.saml ?? [], [...socialProviders, ...platformSignIn ? [platformSignIn.provider] : []]);
374
- const personalMail = resolvePersonalMail(options.personalMail ?? [], socialProviders);
376
+ const personalMail = resolvePersonalMail(options.personalMail ?? [], socialProviders, options.encryptOAuthTokens === true);
375
377
  const origins = resolveOrigins(options.baseURL, options.origins ?? platformOrigins());
376
378
  const passkeyDomain = resolvePasskeyDomain(options.passkeyDomain ?? platformPasskeyDomain(), [options.baseURL, ...origins]);
377
379
  const organizations = options.organizations && resolveOrganizations(options.organizations, definitions, options.baseURL);
@@ -477,4 +479,4 @@ const createAuth = (options, plugins = {}) => {
477
479
  /** `createAuth` with the optional plugins its options turn on loaded first (see `loadAuthPlugins`). */
478
480
  const buildAuth = async (options) => createAuth(options, await loadAuthPlugins(options));
479
481
  //#endregion
480
- export { authBasePath, betterAuthOptions, buildAuth, createAuth, loadAuthPlugins, normalizeRoles };
482
+ export { authBasePath, betterAuthOptions, buildAuth, createAuth, loadAuthPlugins, normalizeRoles, providerTokenPaths };
@@ -0,0 +1,13 @@
1
+ import { findMember, organizationRoleOf } from "./store.js";
2
+ //#region src/better-auth/organizations/organization-role.ts
3
+ /** The user's role in the organization, `undefined` when they are no member of it. */
4
+ const organizationRole = (auth) => async (organization, user) => {
5
+ const { adapter } = await auth.$context;
6
+ const member = await findMember(adapter, {
7
+ organizationId: organization,
8
+ userId: user
9
+ });
10
+ return member && organizationRoleOf(member);
11
+ };
12
+ //#endregion
13
+ export { organizationRole };
@@ -5,9 +5,10 @@ import { randomUUID } from "node:crypto";
5
5
  const signalsPerSecond = 100;
6
6
  /** How often the signals for this process are read, with several processes. */
7
7
  const signalsEveryMs = 100;
8
- const rosterOf = (members) => members.map(({ member, joined_at }) => ({
8
+ const rosterOf = (members) => members.map(({ member, joined_at, name }) => ({
9
9
  member,
10
- joined_at
10
+ joined_at,
11
+ ...name !== null && { name }
11
12
  }));
12
13
  /**
13
14
  * The members this process serves, in their calls, whose records the `calls` schema keeps. A member is in only while
@@ -16,7 +17,7 @@ const rosterOf = (members) => members.map(({ member, joined_at }) => ({
16
17
  * member of the same call only, straight to their socket here, or through the `calls` schema to the process that
17
18
  * serves them.
18
19
  */
19
- const callHub = ({ store, manyProcesses, report }) => {
20
+ const callHub = ({ store, manyProcesses, report, lobbies }) => {
20
21
  const process = randomUUID();
21
22
  const locals = /* @__PURE__ */ new Map();
22
23
  const known = /* @__PURE__ */ new Map();
@@ -50,10 +51,29 @@ const callHub = ({ store, manyProcesses, report }) => {
50
51
  local.listener?.(members);
51
52
  }
52
53
  };
53
- /** Reads who is in these calls and tells their members here. */
54
+ const tellLobbies = async (calls) => {
55
+ const asked = calls.filter((call) => ofCall(call).some((local) => local.invites && local.listener));
56
+ if (!lobbies || asked.length === 0) return;
57
+ const found = await lobbies(asked);
58
+ for (const call of asked) {
59
+ const lobby = found.get(call);
60
+ if (!lobby) continue;
61
+ const body = JSON.stringify(lobby);
62
+ for (const local of ofCall(call)) {
63
+ if (!local.invites || !local.listener || local.lobby === body) continue;
64
+ local.lobby = body;
65
+ local.link.send({
66
+ type: "lobby",
67
+ ...lobby
68
+ });
69
+ }
70
+ }
71
+ };
72
+ /** Reads who is in these calls, and their lobbies, and tells their members here. */
54
73
  const refresh = async (calls) => {
55
74
  const found = await store.rosters(calls);
56
75
  for (const call of calls) apply(call, found.get(call));
76
+ await tellLobbies(calls);
57
77
  };
58
78
  const deliver = (call, to, from, body) => {
59
79
  ofCall(call).find((local) => local.member === to)?.link.send({
@@ -113,9 +133,9 @@ const callHub = ({ store, manyProcesses, report }) => {
113
133
  /**
114
134
  * Lets the member in, or says why not. Once in, `listen` gets who is in the call; `leave` ends their time in it,
115
135
  * `detach` only stops this process hearing of them, as when their socket was lost: they are out `graceSeconds`
116
- * later unless their browser resumes.
136
+ * later unless their browser resumes. A member who `invites` is told of the call's lobby too.
117
137
  */
118
- admit: async (input, link) => {
138
+ admit: async ({ invites = false, ...input }, link) => {
119
139
  const admitted = await store.admit({
120
140
  ...input,
121
141
  process
@@ -126,7 +146,8 @@ const callHub = ({ store, manyProcesses, report }) => {
126
146
  member: input.member,
127
147
  row: admitted.row,
128
148
  link,
129
- signals: fixedWindow(signalsPerSecond, 1e3)
149
+ signals: fixedWindow(signalsPerSecond, 1e3),
150
+ invites: invites && lobbies !== void 0
130
151
  };
131
152
  for (const before of ofCall(admitted.call)) if (before.member === input.member && before.row === admitted.row) drop(before, "removed");
132
153
  locals.set(local.row, local);
@@ -19,12 +19,16 @@ export type CallMember = CallRights & {
19
19
  };
20
20
  /**
21
21
  * Who joins, unique in the call, such as the signed-in user's id: take it from the caller, never from the request,
22
- * since the other members know them by it.
22
+ * since the other members know them by it. Never `guest:…`, which guests are.
23
23
  */
24
24
  member: string;
25
25
  /** The function's own checks, repeated every minute after the record's, such as whether the caller is still invited. */
26
26
  recheck?: () => unknown;
27
27
  /** Whom the call rings, on any page of the app they have open, when this join starts it. */
28
28
  ring?: CallRingInput;
29
+ /** Whether the member may share guest links to the call and let its guests in; `false` by default. */
30
+ invites?: boolean;
31
+ /** The name the others in the call see, such as the user's name; guests see no other. */
32
+ name?: string;
29
33
  };
30
34
  //#endregion
@@ -2,15 +2,19 @@ import "./call-store.js";
2
2
  import { CallRemoteTrack } from "@protobase/schema/internal";
3
3
  import { Db } from "@protobase/query";
4
4
  //#region src/calls/call-reads.d.ts
5
- /** Someone in a call now: their members row, the process that serves them and the tracks they send. */
5
+ /** Someone in a call now: their members row, the process that serves them, the tracks they send and the name they show. */
6
6
  export type RosterMember = {
7
7
  row: string;
8
8
  member: string;
9
9
  process: string;
10
10
  joined_at: string;
11
11
  tracks: CallRemoteTrack[];
12
+ name: string | null;
12
13
  };
13
- /** A call as the record's readers see it: who started it, when, when it ended, who is in it and who was removed. */
14
+ /**
15
+ * A call as the record's readers see it: who started it, when, when it ended, who is in it, with the name they show
16
+ * when they have one, and who was removed.
17
+ */
14
18
  export type CallItem = {
15
19
  id: string;
16
20
  started_by: string;
@@ -20,6 +24,7 @@ export type CallItem = {
20
24
  members: {
21
25
  member: string;
22
26
  joined_at: string;
27
+ name?: string;
23
28
  }[];
24
29
  removed: {
25
30
  member: string;
@@ -7,7 +7,7 @@ const rosters = async (db, calls) => {
7
7
  const found = /* @__PURE__ */ new Map();
8
8
  if (calls.length === 0) return found;
9
9
  const { rows } = await sql`
10
- select c."id"::text as call, c."ended_at" is not null as ended, m."id"::text as row, m."member", m."process"::text as process, ${iso("m.\"joined_at\"")} as joined_at, m."tracks"
10
+ select c."id"::text as call, c."ended_at" is not null as ended, m."id"::text as row, m."member", m."process"::text as process, ${iso("m.\"joined_at\"")} as joined_at, m."tracks", m."name"
11
11
  from "calls"."calls" c left join "calls"."members" m on m."call_id" = c."id" and m."left_at" is null
12
12
  where c."id" = any(${ids(calls)}::uuid[]) order by c."id", m."joined_at", m."id"`.execute(db);
13
13
  for (const row of rows) {
@@ -21,7 +21,8 @@ const rosters = async (db, calls) => {
21
21
  member: row.member,
22
22
  process: row.process,
23
23
  joined_at: row.joined_at,
24
- tracks: row.tracks
24
+ tracks: row.tracks,
25
+ name: row.name
25
26
  });
26
27
  }
27
28
  return found;
@@ -41,7 +42,7 @@ const recordCalls = async (db, records) => {
41
42
  order by c."ended_at" is not null, c."started_at" desc, c."id"`.execute(db);
42
43
  if (calls.rows.length === 0) return found;
43
44
  const members = await sql`
44
- select "call_id"::text as call, "member", ${iso("\"joined_at\"")} as joined_at, ${iso("\"removed_at\"")} as removed_at
45
+ select "call_id"::text as call, "member", ${iso("\"joined_at\"")} as joined_at, ${iso("\"removed_at\"")} as removed_at, "name"
45
46
  from "calls"."members" where "call_id" = any(${ids(calls.rows.map((call) => call.id))}::uuid[]) and ("left_at" is null or "removed_at" is not null)
46
47
  order by "joined_at", "id"`.execute(db);
47
48
  const items = /* @__PURE__ */ new Map();
@@ -64,7 +65,8 @@ const recordCalls = async (db, records) => {
64
65
  const item = items.get(row.call);
65
66
  if (row.removed_at === null) item.members.push({
66
67
  member: row.member,
67
- joined_at: row.joined_at
68
+ joined_at: row.joined_at,
69
+ ...row.name !== null && { name: row.name }
68
70
  });
69
71
  else item.removed.push({
70
72
  member: row.member,
@@ -19,6 +19,13 @@ export type AdmitInput = {
19
19
  };
20
20
  maxMembers: number;
21
21
  maxMinutes: number;
22
+ /** The name the member shows the others: a guest's own, or the one the app gave. */
23
+ name?: string;
24
+ /** A guest, who comes only into the call their link is for, by their id; never starts one. */
25
+ guest?: {
26
+ id: string;
27
+ call: string;
28
+ };
22
29
  };
23
30
  /** The member in the call `call`, as the members row `row`, with whether their join started it; or why not. */
24
31
  export type Admission = {
@@ -32,31 +32,39 @@ const liveCall = async (trx, record, member, maxMinutes) => {
32
32
  }
33
33
  throw new Error(`The call on ${record.resource} ${record.key} could not be started`);
34
34
  };
35
- const enter = async (trx, call, member, process, tracks) => {
35
+ const enter = async (trx, call, member, process, tracks, name) => {
36
36
  const body = JSON.stringify(tracks);
37
- const { rows } = await sql`update "calls"."members" set "process" = ${process}::uuid, "seen_at" = now(), "tracks" = ${body}::jsonb
37
+ const { rows } = await sql`update "calls"."members" set "process" = ${process}::uuid, "seen_at" = now(), "tracks" = ${body}::jsonb, "name" = ${name}
38
38
  where "call_id" = ${call}::uuid and "member" = ${member} and "left_at" is null returning "id"::text as id`.execute(trx);
39
39
  if (rows[0]) return rows[0].id;
40
- return (await sql`insert into "calls"."members" ("call_id", "member", "process", "tracks", "joined_at", "seen_at")
41
- values (${call}::uuid, ${member}, ${process}::uuid, ${body}::jsonb, now(), now()) returning "id"::text as id`.execute(trx)).rows[0].id;
40
+ return (await sql`insert into "calls"."members" ("call_id", "member", "process", "tracks", "joined_at", "seen_at", "name")
41
+ values (${call}::uuid, ${member}, ${process}::uuid, ${body}::jsonb, now(), now(), ${name}) returning "id"::text as id`.execute(trx)).rows[0].id;
42
+ };
43
+ const resumed = async (trx, record, call) => {
44
+ const found = isCallId(call) ? await lockCall(trx, call) : void 0;
45
+ if (!found || found.ended || found.resource !== record.resource || found.record_key !== record.key) return;
46
+ if (!found.expired) return found.id;
47
+ await endCall(trx, found.id);
42
48
  };
43
49
  /**
44
50
  * Lets the member into the record's call, or the call they resume, when they may: one transaction, the call's row
45
51
  * locked. The members whose process stopped hearing of them are out first. A resume of a call that ended, or that is
46
- * on another record, is refused as ended; a fresh join to a record without a call going on starts one.
52
+ * on another record, is refused as ended; a fresh join to a record without a call going on starts one. A guest comes
53
+ * only into their link's call, while `guestAllowed` lets them, and never starts one.
47
54
  */
48
- const admit = (db, timeoutMs, input) => writeTransaction(db, timeoutMs, async (trx) => {
49
- const { record, member, resume } = input;
55
+ const admit = (db, timeoutMs, input, guestAllowed) => writeTransaction(db, timeoutMs, async (trx) => {
56
+ const { record, member, resume, guest } = input;
50
57
  let call;
51
58
  let started = false;
52
- if (resume) {
53
- const resumed = isCallId(resume.call) ? await lockCall(trx, resume.call) : void 0;
54
- if (!resumed || resumed.ended || resumed.resource !== record.resource || resumed.record_key !== record.key) return { refused: "ended" };
55
- if (resumed.expired) {
56
- await endCall(trx, resumed.id);
57
- return { refused: "ended" };
58
- }
59
- call = resumed.id;
59
+ if (guest) {
60
+ if (resume && resume.call !== guest.call) return { refused: "ended" };
61
+ const going = await resumed(trx, record, guest.call);
62
+ if (!going || !guestAllowed || !await guestAllowed(trx, guest.id)) return { refused: "ended" };
63
+ call = going;
64
+ } else if (resume) {
65
+ const going = await resumed(trx, record, resume.call);
66
+ if (!going) return { refused: "ended" };
67
+ call = going;
60
68
  } else {
61
69
  const live = await liveCall(trx, record, member, input.maxMinutes);
62
70
  call = live.id;
@@ -75,7 +83,7 @@ const admit = (db, timeoutMs, input) => writeTransaction(db, timeoutMs, async (t
75
83
  if (refused) return { refused };
76
84
  return {
77
85
  call,
78
- row: await enter(trx, call, member, input.process, resume?.tracks ?? []),
86
+ row: await enter(trx, call, member, input.process, resume?.tracks ?? [], input.name ?? null),
79
87
  started
80
88
  };
81
89
  });
@@ -96,16 +104,19 @@ const leave = (db, timeoutMs, row, process) => writeTransaction(db, timeoutMs, a
96
104
  const remove = (db, timeoutMs, callId, member) => writeTransaction(db, timeoutMs, async (trx) => {
97
105
  const call = isCallId(callId) ? await lockCall(trx, callId) : void 0;
98
106
  if (!call || call.ended) return false;
99
- const { rows } = await sql`update "calls"."members" set "left_at" = now(), "removed_at" = now()
100
- where "call_id" = ${call.id}::uuid and "member" = ${member} and "left_at" is null returning "id"::text as id`.execute(trx);
101
- if (rows.length === 0) {
102
- await sql`insert into "calls"."members" ("call_id", "member", "process", "tracks", "joined_at", "seen_at", "left_at", "removed_at")
103
- values (${call.id}::uuid, ${member}, ${nobody}::uuid, '[]'::jsonb, now(), now(), now(), now())`.execute(trx);
104
- return false;
105
- }
107
+ if (!await removeMember(trx, call.id, member)) return false;
106
108
  await endIfEmpty(trx, call.id);
107
109
  return true;
108
110
  });
111
+ /** `remove`'s change, in a transaction that holds the call's row: whether the member was in the call. */
112
+ const removeMember = async (trx, call, member) => {
113
+ const { rows } = await sql`update "calls"."members" set "left_at" = now(), "removed_at" = now()
114
+ where "call_id" = ${call}::uuid and "member" = ${member} and "left_at" is null returning "id"::text as id`.execute(trx);
115
+ if (rows.length > 0) return true;
116
+ await sql`insert into "calls"."members" ("call_id", "member", "process", "tracks", "joined_at", "seen_at", "left_at", "removed_at")
117
+ values (${call}::uuid, ${member}, ${nobody}::uuid, '[]'::jsonb, now(), now(), now(), now())`.execute(trx);
118
+ return false;
119
+ };
109
120
  /** The tracks the member of row `row` sends now, while `process` serves them. */
110
121
  const setTracks = async (db, row, process, tracks) => {
111
122
  await sql`update "calls"."members" set "tracks" = ${JSON.stringify(tracks)}::jsonb
@@ -124,4 +135,4 @@ const callRecord = async (db, call) => {
124
135
  return rows[0];
125
136
  };
126
137
  //#endregion
127
- export { admit, callRecord, endCall, heartbeat, isCallId, leave, remove, setTracks };
138
+ export { admit, callRecord, endCall, endIfEmpty, heartbeat, isCallId, leave, lockCall, remove, removeMember, setTracks };
@@ -4,22 +4,24 @@ import { sql } from "kysely";
4
4
  //#region src/calls/call-sweep.ts
5
5
  /** How long a signal between processes is kept: long after it was read. */
6
6
  const signalSeconds = 60;
7
+ const toEnd = (seconds) => sql`("ends_at" <= now() or not exists (select from "calls"."members" m where m."call_id" = c."id" and m."left_at" is null)
8
+ or not exists (select from "calls"."members" m where m."call_id" = c."id" and m."member" not like 'guest:%' and (m."left_at" is null or m."left_at" > now() - make_interval(secs => ${seconds}))))`;
7
9
  /**
8
10
  * Ends what should have ended, over every call, whichever process served it: the members no process heard of within
9
11
  * `graceSeconds`, as after their socket was lost or their process stopped; the calls past their last minute; the
10
- * calls nobody is in any more. A call an admission holds is left to the next sweep. Old signals go too. The records
11
- * whose calls it changed.
12
+ * calls nobody is in any more, and those with only guests in them `guestOnly` seconds after the last of the app's
13
+ * members left. A call an admission holds is left to the next sweep. Old signals go too. The records whose calls it
14
+ * changed.
12
15
  */
13
- const sweepCalls = (db, timeoutMs) => writeTransaction(db, timeoutMs, async (trx) => {
16
+ const sweepCalls = (db, timeoutMs, guestOnly = 120) => writeTransaction(db, timeoutMs, async (trx) => {
14
17
  const left = await sql`update "calls"."members" set "left_at" = now()
15
18
  where "left_at" is null and "seen_at" < now() - make_interval(secs => ${30}) returning "call_id"::text as id`.execute(trx);
16
19
  const changed = new Set(left.rows.map((row) => row.id));
17
- const { rows } = await sql`select "id"::text as id from "calls"."calls" c where "ended_at" is null
18
- and ("ends_at" <= now() or not exists (select from "calls"."members" m where m."call_id" = c."id" and m."left_at" is null))
20
+ const { rows } = await sql`select "id"::text as id from "calls"."calls" c where "ended_at" is null and ${toEnd(guestOnly)}
19
21
  for update skip locked`.execute(trx);
20
22
  if (rows.length > 0) {
21
23
  const ended = await sql`select "id"::text as id from "calls"."calls" c where "id" = any(${`{${rows.map((row) => row.id).join(",")}}`}::uuid[])
22
- and ("ends_at" <= now() or not exists (select from "calls"."members" m where m."call_id" = c."id" and m."left_at" is null))`.execute(trx);
24
+ and ${toEnd(guestOnly)}`.execute(trx);
23
25
  for (const { id } of ended.rows) {
24
26
  await endCall(trx, id);
25
27
  changed.add(id);
@@ -1,11 +1,14 @@
1
1
  import { forbidden } from "../problem.js";
2
+ import { isGuestMember } from "./guest-tokens.js";
2
3
  import { checkRing } from "./call-ring.js";
3
4
  //#region src/calls/calls-access.ts
4
5
  /** The largest message of a member's socket: a description, with room to spare. */
5
6
  const maxMessageBytes = 307200;
6
- const checkMember = ({ record, member }) => {
7
+ const checkMember = ({ record, member, name }) => {
7
8
  if (typeof record?.resource !== "string" || record.resource.length === 0) throw new TypeError("calls.join needs the record, { resource, key }");
8
9
  if (typeof member !== "string" || member.length === 0) throw new TypeError("calls.join needs the member, a non-empty string");
10
+ if (isGuestMember(member)) throw new TypeError("calls.join takes no member that starts with guest:, which only guests are");
11
+ if (name !== void 0 && (typeof name !== "string" || name.length > 200)) throw new TypeError("calls.join takes a name of at most 200 characters");
9
12
  };
10
13
  const withoutCalls = () => Promise.reject(/* @__PURE__ */ new Error("This app has no calls: configure calls: { backend } in protobase.config.ts"));
11
14
  /**
@@ -30,7 +33,9 @@ const callsAccess = (service, caller) => {
30
33
  const ring = member.ring && checkRing(member.ring);
31
34
  const by = {
32
35
  user: String(session.user.id),
33
- ...ring && { ring }
36
+ ...ring && { ring },
37
+ ...member.invites === true && { invites: true },
38
+ ...member.name !== void 0 && { name: member.name }
34
39
  };
35
40
  return {
36
41
  ...await (await service.runtime()).relay(record, member.member, {
@@ -4,10 +4,20 @@ import { CallRingInput } from "./call-ring.js";
4
4
  import { CallRights } from "./call-member.js";
5
5
  import { CallRecord } from "./call-store.js";
6
6
  import { CallItem } from "./call-reads.js";
7
+ import { GuestRow } from "./guest-checks-sql.js";
7
8
  import { CallStore } from "./calls-store.js";
9
+ import { OrganizationOf } from "./guest-links-sql.js";
10
+ import { GuestStore } from "./guest-store.js";
8
11
  import { Ringer } from "./ringer.js";
9
12
  import { Db } from "@protobase/query";
10
13
  //#region src/calls/calls-runtime.d.ts
14
+ /** Who a member is beyond their id: the signed-in user, whom the call rings, whether they invite guests, and their name. */
15
+ export type RelayBy = {
16
+ user?: string;
17
+ ring?: CallRingInput;
18
+ invites?: boolean;
19
+ name?: string;
20
+ };
11
21
  export type CallsRuntimeInput = {
12
22
  db: Db;
13
23
  timeoutMs: number;
@@ -15,6 +25,10 @@ export type CallsRuntimeInput = {
15
25
  report: (error: unknown) => void;
16
26
  /** Memory in tests; the `calls` schema otherwise. */
17
27
  store?: CallStore;
28
+ /** Guest links and the lobby: the `calls` schema, without `store`; none with a `store` of a test's unless given. */
29
+ guests?: GuestStore;
30
+ /** The organization of a call's record, which its guest links are for; none by default. */
31
+ organizationOf?: OrganizationOf;
18
32
  /** The rings of calls, which joins with a `ring` start. */
19
33
  rings?: Ringer;
20
34
  /** Tells the record's readers its calls changed, such as on the record's channel. */
@@ -25,12 +39,19 @@ export type CallsRuntimeInput = {
25
39
  * its pages follow. While it serves a member or a page, it reads them every second and, every five, marks its members
26
40
  * as served and sweeps every call, whichever process served it; with neither, it runs no timer.
27
41
  */
28
- export declare const callsRuntime: ({ db, timeoutMs, options, report, store, rings, announce }: CallsRuntimeInput) => {
42
+ export declare const callsRuntime: ({ db, timeoutMs, options, report, store: given, guests: givenGuests, organizationOf, rings, announce }: CallsRuntimeInput) => {
29
43
  /** The events of the member's socket, relayed to the plugin: they come into the record's call at their `join`. */
30
- relay: (record: CallRecord, member: string, rights: CallRights, by?: {
31
- user?: string;
32
- ring?: CallRingInput;
33
- }) => Promise<SocketEvents>;
44
+ relay: (record: CallRecord, member: string, rights: CallRights, by?: RelayBy) => Promise<SocketEvents>;
45
+ /** The guest's socket, relayed like a member's, as member `guest:<id>` in their link's call only, sending what it allows. */
46
+ relayGuest: (guest: GuestRow) => Promise<SocketEvents>;
47
+ /** Guest links and the lobby, for the guests' routes. */
48
+ guests: () => GuestStore;
49
+ /** The organization's admin set whether its calls take guests: the calls this process serves hear of it at once. */
50
+ setOrganizationGuests: (input: {
51
+ organization: string;
52
+ allowed: boolean;
53
+ by: string;
54
+ }) => Promise<void>;
34
55
  /** Removes the member from the call, and only from it: whether they were in it. */
35
56
  remove: (call: string, member: string) => Promise<boolean>;
36
57
  /** The calls that ring the user now, and declining one. */
@@ -1,8 +1,10 @@
1
+ import { guestMember } from "./guest-tokens.js";
1
2
  import { maxMembersOf } from "./calls-options.js";
2
3
  import { callHub } from "./call-hub.js";
3
4
  import { recordId } from "./call-reads.js";
4
5
  import { callWatch } from "./call-watch.js";
5
6
  import { sqlCallStore } from "./calls-store.js";
7
+ import { sqlGuestStore } from "./guest-store.js";
6
8
  import { memberRelay } from "./member-relay.js";
7
9
  //#region src/calls/calls-runtime.ts
8
10
  /** How often the calls this process serves, and the records its pages follow, are read. */
@@ -18,11 +20,14 @@ const loadBackend = async (settings) => {
18
20
  * its pages follow. While it serves a member or a page, it reads them every second and, every five, marks its members
19
21
  * as served and sweeps every call, whichever process served it; with neither, it runs no timer.
20
22
  */
21
- const callsRuntime = ({ db, timeoutMs, options, report, store = sqlCallStore(db, timeoutMs), rings, announce }) => {
23
+ const callsRuntime = ({ db, timeoutMs, options, report, store: given, guests: givenGuests, organizationOf = async () => void 0, rings, announce }) => {
24
+ const store = given ?? sqlCallStore(db, timeoutMs);
25
+ const guests = given ? givenGuests : givenGuests ?? sqlGuestStore(db, timeoutMs, organizationOf);
22
26
  const hub = callHub({
23
27
  store,
24
28
  manyProcesses: options.processes === "many",
25
- report
29
+ report,
30
+ ...guests && { lobbies: guests.lobbies }
26
31
  });
27
32
  const watch = callWatch(store);
28
33
  const maxMembers = maxMembersOf(options.backend);
@@ -58,24 +63,59 @@ const callsRuntime = ({ db, timeoutMs, options, report, store = sqlCallStore(db,
58
63
  }, readEveryMs);
59
64
  timer.unref?.();
60
65
  };
61
- const door = { admit: async ({ user, ring, ...input }, link) => {
62
- const admitted = await hub.admit(input, link);
63
- schedule();
64
- if ("refused" in admitted) return admitted;
65
- changed([input.record]);
66
- if (rings && user !== void 0) {
67
- if (ring && admitted.started) await rings.start(admitted.call, input.record, user, ring).catch(report);
68
- await rings.stop(admitted.call, user).catch(report);
69
- }
70
- return {
71
- ...admitted,
72
- leave: async () => {
73
- await admitted.leave();
74
- changed([input.record]);
75
- await rings?.settle();
66
+ const guestsChanged = async (call) => {
67
+ await hub.refresh([call]);
68
+ const record = await store.recordOf(call);
69
+ if (record) changed([record]);
70
+ };
71
+ const guestsOf = () => {
72
+ if (!guests) throw new Error("Not implemented: guests with a call store of a test");
73
+ return guests;
74
+ };
75
+ const door = {
76
+ admit: async ({ user, ring, ...input }, link) => {
77
+ const admitted = await hub.admit(input, link);
78
+ schedule();
79
+ if ("refused" in admitted) return admitted;
80
+ changed([input.record]);
81
+ if (rings && user !== void 0) {
82
+ if (ring && admitted.started) await rings.start(admitted.call, input.record, user, ring).catch(report);
83
+ await rings.stop(admitted.call, user).catch(report);
76
84
  }
77
- };
78
- } };
85
+ return {
86
+ ...admitted,
87
+ leave: async () => {
88
+ await admitted.leave();
89
+ changed([input.record]);
90
+ await rings?.settle();
91
+ }
92
+ };
93
+ },
94
+ ...guests && { guests: {
95
+ link: async (input) => {
96
+ const made = await guests.createLink(input);
97
+ await hub.refresh([input.call]);
98
+ return made;
99
+ },
100
+ revoke: async (input) => {
101
+ const revoked = await guests.revokeLink(input);
102
+ await guestsChanged(input.call);
103
+ return revoked;
104
+ },
105
+ decide: async (input) => {
106
+ const decided = await guests.decide({
107
+ ...input,
108
+ maxMembers
109
+ });
110
+ await hub.refresh([input.call]);
111
+ return decided;
112
+ },
113
+ remove: async (call, guest) => {
114
+ await store.remove(call, guestMember(guest));
115
+ await guestsChanged(call);
116
+ }
117
+ } }
118
+ };
79
119
  return {
80
120
  /** The events of the member's socket, relayed to the plugin: they come into the record's call at their `join`. */
81
121
  relay: async (record, member, rights, by = {}) => {
@@ -92,6 +132,31 @@ const callsRuntime = ({ db, timeoutMs, options, report, store = sqlCallStore(db,
92
132
  ...by
93
133
  });
94
134
  },
135
+ /** The guest's socket, relayed like a member's, as member `guest:<id>` in their link's call only, sending what it allows. */
136
+ relayGuest: async (guest) => {
137
+ backend ??= loadBackend(options.backend);
138
+ return memberRelay({
139
+ door,
140
+ backend: await backend,
141
+ record: guest.record,
142
+ member: guestMember(guest.id),
143
+ rights: { publish: guest.publish },
144
+ maxMembers,
145
+ maxMinutes,
146
+ report,
147
+ name: guest.name,
148
+ guest: {
149
+ id: guest.id,
150
+ call: guest.call
151
+ }
152
+ });
153
+ },
154
+ /** Guest links and the lobby, for the guests' routes. */
155
+ guests: guestsOf,
156
+ /** The organization's admin set whether its calls take guests: the calls this process serves hear of it at once. */
157
+ setOrganizationGuests: async (input) => {
158
+ for (const call of await guestsOf().setOrganization(input)) await guestsChanged(call);
159
+ },
95
160
  /** Removes the member from the call, and only from it: whether they were in it. */
96
161
  remove: async (call, member) => {
97
162
  const record = await store.recordOf(call);
@@ -7,10 +7,11 @@ import { appPath } from "@protobase/schema";
7
7
  const callsService = (deps, options, wiring) => {
8
8
  let runtime;
9
9
  const load = async () => {
10
- const [{ callsRuntime }, { ringer, ringMessage }, { sqlRingStore }] = await Promise.all([
10
+ const [{ callsRuntime }, { ringer, ringMessage }, { sqlRingStore }, { recordOrganization }] = await Promise.all([
11
11
  import("./calls-runtime.js"),
12
12
  import("./ringer.js"),
13
- import("./ring-store.js")
13
+ import("./ring-store.js"),
14
+ import("./record-organization.js")
14
15
  ]);
15
16
  const { db, statementTimeoutMs, report, registry } = deps();
16
17
  const recordUrl = ({ resource, key }) => wiring.origin === void 0 ? void 0 : new URL(appPath({
@@ -32,6 +33,7 @@ const callsService = (deps, options, wiring) => {
32
33
  inbox: true
33
34
  }, ringMessage(notification)) }
34
35
  }),
36
+ ...wiring.organizations && { organizationOf: recordOrganization(registry) },
35
37
  announce: (record, items) => wiring.channels().broadcast({
36
38
  resource: record.resource,
37
39
  key: record.key,
@@ -2,7 +2,7 @@ import { EventsOptions } from "../changes/events-options.js";
2
2
  import { Db } from "@protobase/query";
3
3
  //#region src/calls/calls-sql.d.ts
4
4
  /**
5
- * The SQL that adds the `calls` schema and its tables, and the changes schema's notices table rings reach open pages
5
+ * The SQL that adds the `calls` schema and its tables, or the guest tables to a calls schema without them, and the changes schema's notices table rings reach open pages
6
6
  * through, `undefined` when they are there. They are never dropped.
7
7
  */
8
8
  export declare const callsMigration: (db: Db, events?: EventsOptions) => Promise<string | undefined>;