@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.
- package/dist/better-auth/auth-settings.d.ts +4 -1
- package/dist/better-auth/create-auth.d.ts +14 -2
- package/dist/better-auth/create-auth.js +8 -0
- package/dist/better-auth/organizations/organization-role.js +13 -0
- package/dist/better-auth/status-route.js +8 -1
- package/dist/calls/call-hub.js +28 -7
- package/dist/calls/call-member.d.ts +5 -1
- package/dist/calls/call-reads.d.ts +7 -2
- package/dist/calls/call-reads.js +6 -4
- package/dist/calls/call-store.d.ts +7 -0
- package/dist/calls/call-store.js +35 -24
- package/dist/calls/call-sweep.js +8 -6
- package/dist/calls/calls-access.js +7 -2
- package/dist/calls/calls-runtime.d.ts +26 -5
- package/dist/calls/calls-runtime.js +84 -19
- package/dist/calls/calls-service.js +4 -2
- package/dist/calls/calls-sql.d.ts +1 -1
- package/dist/calls/calls-sql.js +56 -5
- package/dist/calls/calls-store.js +2 -1
- package/dist/calls/client-message.js +27 -2
- package/dist/calls/guest-checks-sql.d.ts +14 -0
- package/dist/calls/guest-checks-sql.js +43 -0
- package/dist/calls/guest-limits.js +49 -0
- package/dist/calls/guest-links-sql.d.ts +16 -0
- package/dist/calls/guest-links-sql.js +78 -0
- package/dist/calls/guest-lobby-sql.d.ts +17 -0
- package/dist/calls/guest-lobby-sql.js +81 -0
- package/dist/calls/guest-name.js +10 -0
- package/dist/calls/guest-routes.js +97 -0
- package/dist/calls/guest-socket.js +58 -0
- package/dist/calls/guest-store.d.ts +48 -0
- package/dist/calls/guest-store.js +20 -0
- package/dist/calls/guest-tokens.js +17 -0
- package/dist/calls/member-relay.js +63 -5
- package/dist/calls/organization-guests-route.js +38 -0
- package/dist/calls/record-organization.js +24 -0
- package/dist/create-admin.js +23 -2
- package/dist/functions/define-function.d.ts +10 -0
- package/dist/functions/own-mailbox.js +9 -0
- package/dist/functions/routes.js +5 -2
- package/dist/functions/upgrade-caller.js +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -2
- package/dist/jobs/job-types.d.ts +1 -1
- package/dist/jobs/runtime.d.ts +1 -1
- package/dist/mail/composer-message.js +27 -0
- package/dist/mail/define-email.d.ts +7 -4
- package/dist/mail/define-email.js +3 -2
- package/dist/mail/{smtp-mailer.d.ts → mail-message.d.ts} +9 -1
- package/dist/mail/smtp-mailer.js +2 -8
- package/dist/package.js +1 -1
- package/dist/personal-mail/gmail.js +43 -0
- package/dist/personal-mail/linked-accounts.js +36 -0
- package/dist/personal-mail/personal-mail-error.d.ts +22 -0
- package/dist/personal-mail/personal-mail-error.js +25 -0
- package/dist/personal-mail/personal-mail-provider.d.ts +25 -0
- package/dist/personal-mail/personal-mail-providers.d.ts +8 -0
- package/dist/personal-mail/personal-mail-providers.js +16 -0
- package/dist/personal-mail/personal-mailer.js +26 -0
- package/dist/personal-mail/rfc822-message.js +15 -0
- package/package.json +4 -4
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { UserColumn } from "./user-table.js";
|
|
2
|
-
import { Mailer } from "../mail/
|
|
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/
|
|
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
|
|
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: {
|
package/dist/calls/call-hub.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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;
|
package/dist/calls/call-reads.js
CHANGED
|
@@ -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 = {
|
package/dist/calls/call-store.js
CHANGED
|
@@ -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 (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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 };
|
package/dist/calls/call-sweep.js
CHANGED
|
@@ -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
|
|
11
|
-
* whose calls it
|
|
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 (
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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. */
|