@protobase/server 0.62.4 → 0.63.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 (61) hide show
  1. package/dist/better-auth/auth-settings.d.ts +4 -1
  2. package/dist/better-auth/create-auth.d.ts +14 -2
  3. package/dist/better-auth/create-auth.js +8 -0
  4. package/dist/better-auth/organizations/organization-role.js +13 -0
  5. package/dist/better-auth/status-route.js +8 -1
  6. package/dist/calls/call-hub.js +28 -7
  7. package/dist/calls/call-member.d.ts +5 -1
  8. package/dist/calls/call-reads.d.ts +7 -2
  9. package/dist/calls/call-reads.js +6 -4
  10. package/dist/calls/call-store.d.ts +7 -0
  11. package/dist/calls/call-store.js +35 -24
  12. package/dist/calls/call-sweep.js +8 -6
  13. package/dist/calls/calls-access.js +7 -2
  14. package/dist/calls/calls-runtime.d.ts +26 -5
  15. package/dist/calls/calls-runtime.js +84 -19
  16. package/dist/calls/calls-service.js +4 -2
  17. package/dist/calls/calls-sql.d.ts +1 -1
  18. package/dist/calls/calls-sql.js +56 -5
  19. package/dist/calls/calls-store.js +2 -1
  20. package/dist/calls/client-message.js +27 -2
  21. package/dist/calls/guest-checks-sql.d.ts +14 -0
  22. package/dist/calls/guest-checks-sql.js +43 -0
  23. package/dist/calls/guest-limits.js +49 -0
  24. package/dist/calls/guest-links-sql.d.ts +16 -0
  25. package/dist/calls/guest-links-sql.js +78 -0
  26. package/dist/calls/guest-lobby-sql.d.ts +17 -0
  27. package/dist/calls/guest-lobby-sql.js +81 -0
  28. package/dist/calls/guest-name.js +10 -0
  29. package/dist/calls/guest-routes.js +97 -0
  30. package/dist/calls/guest-socket.js +58 -0
  31. package/dist/calls/guest-store.d.ts +48 -0
  32. package/dist/calls/guest-store.js +20 -0
  33. package/dist/calls/guest-tokens.js +17 -0
  34. package/dist/calls/member-relay.js +63 -5
  35. package/dist/calls/organization-guests-route.js +38 -0
  36. package/dist/calls/record-organization.js +24 -0
  37. package/dist/create-admin.js +23 -2
  38. package/dist/functions/define-function.d.ts +10 -0
  39. package/dist/functions/own-mailbox.js +9 -0
  40. package/dist/functions/routes.js +5 -2
  41. package/dist/functions/upgrade-caller.js +1 -1
  42. package/dist/index.d.ts +4 -2
  43. package/dist/index.js +3 -2
  44. package/dist/jobs/job-types.d.ts +1 -1
  45. package/dist/jobs/runtime.d.ts +1 -1
  46. package/dist/mail/composer-message.js +27 -0
  47. package/dist/mail/define-email.d.ts +7 -4
  48. package/dist/mail/define-email.js +3 -2
  49. package/dist/mail/{smtp-mailer.d.ts → mail-message.d.ts} +9 -1
  50. package/dist/mail/smtp-mailer.js +2 -8
  51. package/dist/package.js +1 -1
  52. package/dist/personal-mail/gmail.js +43 -0
  53. package/dist/personal-mail/linked-accounts.js +36 -0
  54. package/dist/personal-mail/personal-mail-error.d.ts +22 -0
  55. package/dist/personal-mail/personal-mail-error.js +25 -0
  56. package/dist/personal-mail/personal-mail-provider.d.ts +25 -0
  57. package/dist/personal-mail/personal-mail-providers.d.ts +8 -0
  58. package/dist/personal-mail/personal-mail-providers.js +16 -0
  59. package/dist/personal-mail/personal-mailer.js +26 -0
  60. package/dist/personal-mail/rfc822-message.js +15 -0
  61. package/package.json +4 -4
@@ -1,5 +1,5 @@
1
1
  import { UserColumn } from "./user-table.js";
2
- import { Mailer } from "../mail/smtp-mailer.js";
2
+ import { Mailer } from "../mail/mail-message.js";
3
3
  import { SignInPolicy } from "./sign-in-policy.js";
4
4
  import { StaffSignIn } from "./staff-log.js";
5
5
  import { Person } from "./organizations/people-endpoint.js";
@@ -10,6 +10,7 @@ import { VersionedSecret } from "./secret-versions.js";
10
10
  import { UserDeletionListener } from "./user-deletion.js";
11
11
  import { OrganizationRole, StoredMember, StoredOrganization } from "./organizations/store.js";
12
12
  import { OrganizationsOptions, ResolvedOrganizations } from "./organizations/options.js";
13
+ import { PersonalMailProvider } from "../personal-mail/personal-mail-provider.js";
13
14
  import { McpAuthOptions } from "./mcp/mcp-plugins.js";
14
15
  import "../index.js";
15
16
  import { CreateAuthOptions, UserCreatedHook } from "./create-auth.js";
@@ -8836,6 +8837,8 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
8836
8837
  id: string;
8837
8838
  name: string;
8838
8839
  }[];
8840
+ personalMail: PersonalMailProvider[];
8841
+ personalMailer: (userId: string) => Mailer;
8839
8842
  mcp: {
8840
8843
  resource: string;
8841
8844
  path: string;
@@ -1,5 +1,5 @@
1
1
  import { UserColumn, UserTableOptions } from "./user-table.js";
2
- import { Mailer } from "../mail/smtp-mailer.js";
2
+ import { Mailer } from "../mail/mail-message.js";
3
3
  import { SignInPolicy } from "./sign-in-policy.js";
4
4
  import { StaffSignIn } from "./staff-log.js";
5
5
  import { Person } from "./organizations/people-endpoint.js";
@@ -10,9 +10,11 @@ import { VersionedSecret } from "./secret-versions.js";
10
10
  import { UserDeletionListener } from "./user-deletion.js";
11
11
  import { OrganizationRole } from "./organizations/store.js";
12
12
  import { OrganizationsOptions, ResolvedOrganizations } from "./organizations/options.js";
13
+ import { PersonalMailProvider } from "../personal-mail/personal-mail-provider.js";
13
14
  import { McpAuthOptions } from "./mcp/mcp-plugins.js";
14
15
  import { OperatorProvider } from "./operator-provider.js";
15
16
  import { PlatformSignIn } from "./platform-sign-in.js";
17
+ import { PersonalMailProviderId } from "../personal-mail/personal-mail-providers.js";
16
18
  import "../index.js";
17
19
  import "./auth-base-path.js";
18
20
  import { mcpAuthPlugins } from "./mcp/mcp-auth-plugins.js";
@@ -108,6 +110,12 @@ export type CreateAuthOptions = {
108
110
  * One with the id of `signInProvider` takes its place. Default none.
109
111
  */
110
112
  socialProviders?: BetterAuthOptions['socialProviders'];
113
+ /**
114
+ * The mail providers people may link their own mailbox with, from the account page, for the app's functions to send
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.
117
+ */
118
+ personalMail?: PersonalMailProviderId[];
111
119
  /** Encrypt the OAuth access, refresh and ID tokens Better Auth stores for social sign-ins, with `secret`. Default off. */
112
120
  encryptOAuthTokens?: boolean;
113
121
  /** Runs after a user is created, by any route: `createUser`, an admin, or a social sign-up. Default none. */
@@ -1794,7 +1802,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
1794
1802
  email: string;
1795
1803
  emailVerified: boolean;
1796
1804
  name: string;
1797
- image? /** Runs after a user is created, by any route: `createUser`, an admin, or a social sign-up. Default none. */ : string | null | undefined;
1805
+ image?: string | null | undefined;
1798
1806
  }) | import("better-auth/plugins").UserWithTwoFactor;
1799
1807
  }>;
1800
1808
  generateBackupCodes: import("better-call").StrictEndpoint<"/two-factor/generate-backup-codes", {
@@ -8977,6 +8985,10 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
8977
8985
  id: string;
8978
8986
  name: string;
8979
8987
  }[];
8988
+ /** The mail providers people link their own mailbox with (`personalMail`). */
8989
+ personalMail: PersonalMailProvider[];
8990
+ /** Mail from `userId`'s own mailbox, through the mail provider they linked (see `personalMailer`). */
8991
+ personalMailer: (userId: string) => Mailer;
8980
8992
  /** The OAuth provider for MCP clients, when on: the endpoint's resource (its URL) and path, and the scope its tokens carry. */
8981
8993
  mcp: {
8982
8994
  resource: string;
@@ -5,6 +5,9 @@ import { defaultSignInPolicy } from "./sign-in-policy.js";
5
5
  import { roleDefinitions } from "./organizations/role-definitions.js";
6
6
  import { signInPolicyPlugin } from "./policy-plugin.js";
7
7
  import { goTrueSessionsPlugin } from "../gotrue/sessions-plugin.js";
8
+ import { betterAuthLinkedAccounts } from "../personal-mail/linked-accounts.js";
9
+ import { resolvePersonalMail } from "../personal-mail/personal-mail-providers.js";
10
+ import { personalMailer } from "../personal-mail/personal-mailer.js";
8
11
  import { endReplacedSession } from "./end-replaced-session.js";
9
12
  import { authBasePath } from "./auth-base-path.js";
10
13
  import { authLogger } from "./auth-logger.js";
@@ -368,6 +371,7 @@ const createAuth = (options, plugins = {}) => {
368
371
  const socialProviders = Object.keys(options.socialProviders ?? {});
369
372
  const platformSignIn = platformSignInFor(signInSettings && resolvePlatformSignIn(signInSettings), socialProviders);
370
373
  const saml = resolveSamlProviders(options.sso?.saml ?? [], [...socialProviders, ...platformSignIn ? [platformSignIn.provider] : []]);
374
+ const personalMail = resolvePersonalMail(options.personalMail ?? [], socialProviders);
371
375
  const origins = resolveOrigins(options.baseURL, options.origins ?? platformOrigins());
372
376
  const passkeyDomain = resolvePasskeyDomain(options.passkeyDomain ?? platformPasskeyDomain(), [options.baseURL, ...origins]);
373
377
  const organizations = options.organizations && resolveOrganizations(options.organizations, definitions, options.baseURL);
@@ -458,6 +462,10 @@ const createAuth = (options, plugins = {}) => {
458
462
  id,
459
463
  name
460
464
  })),
465
+ /** The mail providers people link their own mailbox with (`personalMail`). */
466
+ personalMail,
467
+ /** Mail from `userId`'s own mailbox, through the mail provider they linked (see `personalMailer`). */
468
+ personalMailer: (userId) => personalMailer(betterAuthLinkedAccounts(auth), personalMail, userId),
461
469
  /** The OAuth provider for MCP clients, when on: the endpoint's resource (its URL) and path, and the scope its tokens carry. */
462
470
  mcp: mcp && {
463
471
  resource: mcp.resource,
@@ -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 };
@@ -14,7 +14,7 @@ import { Hono } from "hono";
14
14
  * operator provider's name, when staff can sign in as people; `passkeyDomains`, when people have passkeys for more than
15
15
  * one of the app's hosts, the RP IDs a passkey sign-in can ask for, the passkey domain first; with organizations, who may create them and the roles
16
16
  * with their labels, `membership` for those a member can hold, and the roles each one's holders may also give; and
17
- * `anonymous` when the project's anonymous role reads a resource, so the admin app opens for visitors before anyone signs in; `mcp` when MCP clients can connect, with the `url` of its endpoint, so the account page shows that address and lists the connected clients; and
17
+ * `anonymous` when the project's anonymous role reads a resource, so the admin app opens for visitors before anyone signs in; `mcp` when MCP clients can connect, with the `url` of its endpoint, so the account page shows that address and lists the connected clients; `personalMail`, the mail providers people may link their mailbox with, each with the sign-in provider it links through, the scopes and the authorization parameters of the link, so the account page offers "Link Gmail"; and
18
18
  * `plugins`, those the app loads before sign-in (see `PluginOption`); and `locale`, the language `options.locale` puts
19
19
  * the screens in, so the sign-in screens and public forms, which come before `/meta`, are in it too.
20
20
  */
@@ -48,6 +48,13 @@ const statusRoute = (auth, { anonymous = false, plugins = [], locale } = {}) =>
48
48
  ...passkeyDomains.length > 1 && { passkeyDomains },
49
49
  ...anonymous && { anonymous },
50
50
  ...auth.mcp && { mcp: { url: auth.mcp.resource } },
51
+ ...auth.personalMail.length > 0 && { personalMail: auth.personalMail.map(({ id, name, signInProvider, scopes, linkParams }) => ({
52
+ id,
53
+ name,
54
+ provider: signInProvider,
55
+ scopes,
56
+ params: linkParams
57
+ })) },
51
58
  ...plugins.length > 0 && { plugins },
52
59
  ...locale && { locale },
53
60
  ...auth.organizations && { organizations: {
@@ -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. */