@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,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 =
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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>;
|
package/dist/calls/calls-sql.js
CHANGED
|
@@ -5,6 +5,52 @@ import { migrationCommand } from "../changes/capture-schema.js";
|
|
|
5
5
|
import { sql } from "kysely";
|
|
6
6
|
//#region src/calls/calls-sql.ts
|
|
7
7
|
/**
|
|
8
|
+
* - `members.name`: the name a guest gave; NULL for the app's members, whom the app names.
|
|
9
|
+
* - `guest_links`: a link that lets guests into one call, by the SHA-256 of its token: the member who made it, the
|
|
10
|
+
* organization of the call's record (NULL in an app without organizations), what a guest may send, until when it
|
|
11
|
+
* lets guests in (NULL: until the call ends) and when it was revoked.
|
|
12
|
+
* - `guests`: someone who came in through a link, by the SHA-256 of their token, with the name they gave; `admitted`
|
|
13
|
+
* NULL while they wait, then whether a member let them in, who and when. They are member `guest:<id>` in the call.
|
|
14
|
+
* - `organization_guests`: whether an organization's admin allows guest links to its calls; no row allows them.
|
|
15
|
+
*/
|
|
16
|
+
const guestsSchemaSql = `alter table "calls"."members" add column if not exists "name" text;
|
|
17
|
+
|
|
18
|
+
create table if not exists "calls"."guest_links" (
|
|
19
|
+
"id" uuid primary key,
|
|
20
|
+
"call_id" uuid not null references "calls"."calls" ("id") on delete cascade,
|
|
21
|
+
"token_hash" bytea not null unique,
|
|
22
|
+
"created_by" text not null,
|
|
23
|
+
"organization_id" text,
|
|
24
|
+
"publish" jsonb not null,
|
|
25
|
+
"created_at" timestamptz not null,
|
|
26
|
+
"expires_at" timestamptz,
|
|
27
|
+
"revoked_at" timestamptz
|
|
28
|
+
);
|
|
29
|
+
create index if not exists "guest_links_call" on "calls"."guest_links" ("call_id");
|
|
30
|
+
create index if not exists "guest_links_organization" on "calls"."guest_links" ("organization_id") where "revoked_at" is null;
|
|
31
|
+
|
|
32
|
+
create table if not exists "calls"."guests" (
|
|
33
|
+
"id" uuid primary key,
|
|
34
|
+
"link_id" uuid not null references "calls"."guest_links" ("id") on delete cascade,
|
|
35
|
+
"call_id" uuid not null references "calls"."calls" ("id") on delete cascade,
|
|
36
|
+
"name" text not null,
|
|
37
|
+
"token_hash" bytea not null unique,
|
|
38
|
+
"entered_at" timestamptz not null,
|
|
39
|
+
"seen_at" timestamptz not null,
|
|
40
|
+
"admitted" boolean,
|
|
41
|
+
"decided_by" text,
|
|
42
|
+
"decided_at" timestamptz
|
|
43
|
+
);
|
|
44
|
+
create index if not exists "guests_waiting" on "calls"."guests" ("call_id") where "admitted" is null;
|
|
45
|
+
create index if not exists "guests_link" on "calls"."guests" ("link_id");
|
|
46
|
+
|
|
47
|
+
create table if not exists "calls"."organization_guests" (
|
|
48
|
+
"organization_id" text primary key,
|
|
49
|
+
"allowed" boolean not null,
|
|
50
|
+
"set_by" text not null,
|
|
51
|
+
"set_at" timestamptz not null
|
|
52
|
+
);`;
|
|
53
|
+
/**
|
|
8
54
|
* - `calls`: a call on a record, by resource and encoded key: who started it, when, the latest it may end, and when it
|
|
9
55
|
* ended (NULL while it goes on). A record has at most one call going on. Its id is random, so it is unique beyond
|
|
10
56
|
* the app too, as the room of a media server other apps share.
|
|
@@ -14,6 +60,7 @@ import { sql } from "kysely";
|
|
|
14
60
|
* taken out by that process as it reads it, and deleted after a minute when no process did.
|
|
15
61
|
* - `rings`: a user a call rang, by their id, with the ring's text, until when it rings, and when it stopped: they
|
|
16
62
|
* joined the call, declined, or the call ended.
|
|
63
|
+
* - The guest tables, see `guestsSchemaSql`.
|
|
17
64
|
*/
|
|
18
65
|
const callsSchemaSql = `create schema if not exists "calls";
|
|
19
66
|
|
|
@@ -67,23 +114,27 @@ create table if not exists "calls"."rings" (
|
|
|
67
114
|
primary key ("call_id", "user_id")
|
|
68
115
|
);
|
|
69
116
|
create index if not exists "rings_user" on "calls"."rings" ("user_id", "until") where "stopped_at" is null;
|
|
70
|
-
create index if not exists "rings_until" on "calls"."rings" ("until") where "stopped_at" is null
|
|
117
|
+
create index if not exists "rings_until" on "calls"."rings" ("until") where "stopped_at" is null;
|
|
118
|
+
|
|
119
|
+
${guestsSchemaSql}`;
|
|
71
120
|
const hasCallsTables = async (db) => (await sql`select to_regclass('calls.calls') is not null and to_regclass('calls.members') is not null and to_regclass('calls.signals') is not null and to_regclass('calls.rings') is not null as found`.execute(db)).rows[0].found;
|
|
121
|
+
const hasGuestTables = async (db) => (await sql`select to_regclass('calls.guest_links') is not null and to_regclass('calls.guests') is not null and to_regclass('calls.organization_guests') is not null
|
|
122
|
+
and exists (select from information_schema.columns where table_schema = 'calls' and table_name = 'members' and column_name = 'name') as found`.execute(db)).rows[0].found;
|
|
72
123
|
const noticesTable = (events) => `${quoteIdent(changesSchema(events))}."notices"`;
|
|
73
124
|
const hasNotices = async (db, events) => (await sql`select to_regclass(${noticesTable(events)}) is not null as found`.execute(db)).rows[0].found;
|
|
74
125
|
/**
|
|
75
|
-
* The SQL that adds the `calls` schema and its tables, and the changes schema's notices table rings reach open pages
|
|
126
|
+
* 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
|
|
76
127
|
* through, `undefined` when they are there. They are never dropped.
|
|
77
128
|
*/
|
|
78
129
|
const callsMigration = async (db, events) => {
|
|
79
|
-
const statements = [...await hasCallsTables(db) ? [] : [callsSchemaSql], ...await hasNotices(db, events) ? [] : [`create schema if not exists ${quoteIdent(changesSchema(events))}`, noticesTableSql(changesSchema(events))]];
|
|
130
|
+
const statements = [...await hasCallsTables(db) ? await hasGuestTables(db) ? [] : [guestsSchemaSql] : [callsSchemaSql], ...await hasNotices(db, events) ? [] : [`create schema if not exists ${quoteIdent(changesSchema(events))}`, noticesTableSql(changesSchema(events))]];
|
|
80
131
|
return statements.length === 0 ? void 0 : `${statements.join(";\n\n")};`;
|
|
81
132
|
};
|
|
82
133
|
/** Refuses to go on while the app has `calls` and the database lacks their tables; the project's migrations add them. */
|
|
83
134
|
const checkCalls = async (db, calls, events) => {
|
|
84
135
|
if (!calls) return;
|
|
85
|
-
if (await hasCallsTables(db) && await hasNotices(db, events)) return;
|
|
136
|
+
if (await hasCallsTables(db) && await hasGuestTables(db) && await hasNotices(db, events)) return;
|
|
86
137
|
throw new Error(`The calls schema or the notices table that rings go through is missing. Write them as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
|
|
87
138
|
};
|
|
88
139
|
//#endregion
|
|
89
|
-
export { callsMigration, callsSchemaSql, checkCalls };
|
|
140
|
+
export { callsMigration, callsSchemaSql, checkCalls, guestsSchemaSql };
|
|
@@ -2,10 +2,11 @@ import { admit, callRecord, heartbeat, leave, remove, setTracks } from "./call-s
|
|
|
2
2
|
import { recordCalls, rosters } from "./call-reads.js";
|
|
3
3
|
import { takeSignals, writeSignal } from "./call-signals-sql.js";
|
|
4
4
|
import { sweepCalls } from "./call-sweep.js";
|
|
5
|
+
import { guestAllowed } from "./guest-checks-sql.js";
|
|
5
6
|
//#region src/calls/calls-store.ts
|
|
6
7
|
/** The `calls` schema of the app's database; writes under the API's statement timeout. */
|
|
7
8
|
const sqlCallStore = (db, timeoutMs) => ({
|
|
8
|
-
admit: (input) => admit(db, timeoutMs, input),
|
|
9
|
+
admit: (input) => admit(db, timeoutMs, input, guestAllowed),
|
|
9
10
|
leave: (row, process) => leave(db, timeoutMs, row, process),
|
|
10
11
|
remove: (call, member) => remove(db, timeoutMs, call, member),
|
|
11
12
|
setTracks: (row, process, tracks) => setTracks(db, row, process, tracks),
|
|
@@ -38,10 +38,17 @@ const description = z.strictObject({
|
|
|
38
38
|
type: z.enum(["offer", "answer"]),
|
|
39
39
|
sdp
|
|
40
40
|
});
|
|
41
|
+
const uuid = z.uuid();
|
|
42
|
+
const guest = z.string().min(1).max(128).optional();
|
|
43
|
+
const ahead = z.iso.datetime({ offset: true }).refine((value) => Date.parse(value) > Date.now(), "A link expires in the future");
|
|
41
44
|
const message = z.discriminatedUnion("type", [
|
|
42
|
-
z.strictObject({
|
|
45
|
+
z.strictObject({
|
|
46
|
+
type: z.literal("join"),
|
|
47
|
+
guest
|
|
48
|
+
}),
|
|
43
49
|
z.strictObject({
|
|
44
50
|
type: z.literal("resume"),
|
|
51
|
+
guest,
|
|
45
52
|
call: id,
|
|
46
53
|
participant: id,
|
|
47
54
|
offer: sdp.optional(),
|
|
@@ -88,7 +95,25 @@ const message = z.discriminatedUnion("type", [
|
|
|
88
95
|
height: size,
|
|
89
96
|
hidden: z.boolean().optional()
|
|
90
97
|
}),
|
|
91
|
-
z.strictObject({ type: z.literal("leave") })
|
|
98
|
+
z.strictObject({ type: z.literal("leave") }),
|
|
99
|
+
z.strictObject({
|
|
100
|
+
type: z.literal("guest-link"),
|
|
101
|
+
publish: z.array(source).max(4),
|
|
102
|
+
expires_at: ahead.optional()
|
|
103
|
+
}),
|
|
104
|
+
z.strictObject({
|
|
105
|
+
type: z.literal("guest-link-revoke"),
|
|
106
|
+
link: uuid
|
|
107
|
+
}),
|
|
108
|
+
z.strictObject({
|
|
109
|
+
type: z.literal("guest-decide"),
|
|
110
|
+
guest: uuid,
|
|
111
|
+
admit: z.boolean()
|
|
112
|
+
}),
|
|
113
|
+
z.strictObject({
|
|
114
|
+
type: z.literal("guest-remove"),
|
|
115
|
+
guest: uuid
|
|
116
|
+
})
|
|
92
117
|
]);
|
|
93
118
|
/** A browser's message, or `undefined` when it is no message of the call protocol. */
|
|
94
119
|
const parseClientMessage = (data) => {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { CallRecord } from "./call-store.js";
|
|
2
|
+
import { CallTrackSource } from "@protobase/schema/internal";
|
|
3
|
+
import { Db } from "@protobase/query";
|
|
4
|
+
//#region src/calls/guest-checks-sql.d.ts
|
|
5
|
+
/** A guest by their token's hash: their call, its record, their name, what they may send and whether they were let in. */
|
|
6
|
+
export type GuestRow = {
|
|
7
|
+
id: string;
|
|
8
|
+
call: string;
|
|
9
|
+
record: CallRecord;
|
|
10
|
+
name: string;
|
|
11
|
+
publish: CallTrackSource[];
|
|
12
|
+
admitted: boolean | null;
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { guestMember } from "./guest-tokens.js";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
//#region src/calls/guest-checks-sql.ts
|
|
4
|
+
/** A link that still lets guests in: not revoked, not expired, and its organization's admin did not turn guests off. */
|
|
5
|
+
const liveLink = (link) => sql.raw(`${link}."revoked_at" is null and (${link}."expires_at" is null or ${link}."expires_at" > now())
|
|
6
|
+
and not exists (select from "calls"."organization_guests" o where o."organization_id" = ${link}."organization_id" and not o."allowed")`);
|
|
7
|
+
/**
|
|
8
|
+
* Whether the guest may be in their call now: let in by a member, through a link that is still live, and not removed
|
|
9
|
+
* from the call. The call's own end the caller checks under its row lock.
|
|
10
|
+
*/
|
|
11
|
+
const guestAllowed = async (trx, guest) => {
|
|
12
|
+
const { rows } = await sql`select g."id"::text as id from "calls"."guests" g join "calls"."guest_links" l on l."id" = g."link_id"
|
|
13
|
+
where g."id" = ${guest}::uuid and g."admitted" and ${liveLink("l")}
|
|
14
|
+
and not exists (select from "calls"."members" m where m."call_id" = g."call_id" and m."member" = ${guestMember(guest)} and m."removed_at" is not null)`.execute(trx);
|
|
15
|
+
return rows.length > 0;
|
|
16
|
+
};
|
|
17
|
+
/** Whether the guest may still be in their call, the call going on: the minute's check of a guest's socket. */
|
|
18
|
+
const guestStillAllowed = async (db, guest) => {
|
|
19
|
+
const { rows } = await sql`select c."ended_at" is null and c."ends_at" > now() as live
|
|
20
|
+
from "calls"."guests" g join "calls"."calls" c on c."id" = g."call_id" where g."id" = ${guest}::uuid`.execute(db);
|
|
21
|
+
return Boolean(rows[0]?.live) && await guestAllowed(db, guest);
|
|
22
|
+
};
|
|
23
|
+
const guestByToken = async (db, hash) => {
|
|
24
|
+
const { rows } = await sql`
|
|
25
|
+
select g."id"::text as id, g."call_id"::text as call, c."resource", c."record_key" as key, g."name", l."publish", g."admitted"
|
|
26
|
+
from "calls"."guests" g join "calls"."guest_links" l on l."id" = g."link_id" join "calls"."calls" c on c."id" = g."call_id"
|
|
27
|
+
where g."token_hash" = ${hash}`.execute(db);
|
|
28
|
+
const row = rows[0];
|
|
29
|
+
if (!row) return;
|
|
30
|
+
return {
|
|
31
|
+
id: row.id,
|
|
32
|
+
call: row.call,
|
|
33
|
+
record: {
|
|
34
|
+
resource: row.resource,
|
|
35
|
+
key: row.key
|
|
36
|
+
},
|
|
37
|
+
name: row.name,
|
|
38
|
+
publish: row.publish,
|
|
39
|
+
admitted: row.admitted
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
export { guestAllowed, guestByToken, guestStillAllowed, liveLink };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
//#region src/calls/guest-limits.ts
|
|
2
|
+
/**
|
|
3
|
+
* Counts per key in a fixed window of `windowMs`, which all keys share: every count starts over when it turns, so
|
|
4
|
+
* memory follows the keys of the current window.
|
|
5
|
+
*/
|
|
6
|
+
const windowCounts = (windowMs, now = () => Date.now()) => {
|
|
7
|
+
let start = -Infinity;
|
|
8
|
+
let counts = /* @__PURE__ */ new Map();
|
|
9
|
+
const turn = () => {
|
|
10
|
+
if (now() - start < windowMs) return;
|
|
11
|
+
start = now();
|
|
12
|
+
counts = /* @__PURE__ */ new Map();
|
|
13
|
+
};
|
|
14
|
+
return {
|
|
15
|
+
count: (key) => {
|
|
16
|
+
turn();
|
|
17
|
+
return counts.get(key) ?? 0;
|
|
18
|
+
},
|
|
19
|
+
add: (key) => {
|
|
20
|
+
turn();
|
|
21
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
/** The lobby's limits, per process: by address, and by guest. */
|
|
26
|
+
const guestLimits = (now = () => Date.now()) => {
|
|
27
|
+
const entries = windowCounts(6e4, now);
|
|
28
|
+
const refusals = windowCounts(6e4, now);
|
|
29
|
+
const polls = windowCounts(6e4, now);
|
|
30
|
+
return {
|
|
31
|
+
/** Whether the address sent too many refused tokens this minute. */
|
|
32
|
+
blocked: (address) => refusals.count(address) >= 10,
|
|
33
|
+
refused: (address) => refusals.add(address),
|
|
34
|
+
/** Takes one entry of the address: whether it fits. */
|
|
35
|
+
enter: (address) => {
|
|
36
|
+
if (entries.count(address) >= 5) return false;
|
|
37
|
+
entries.add(address);
|
|
38
|
+
return true;
|
|
39
|
+
},
|
|
40
|
+
/** Takes one ask of the guest: whether it fits. */
|
|
41
|
+
poll: (guest) => {
|
|
42
|
+
if (polls.count(guest) >= 120) return false;
|
|
43
|
+
polls.add(guest);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
//#endregion
|
|
49
|
+
export { guestLimits, windowCounts };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { CallRecord } from "./call-store.js";
|
|
2
|
+
import { CallGuestLink, CallGuestRefusal, CallTrackSource } from "@protobase/schema/internal";
|
|
3
|
+
import { Db } from "@protobase/query";
|
|
4
|
+
//#region src/calls/guest-links-sql.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* The organization a record is in, as the app keeps it: `undefined` in an app without organizations, `null` for a
|
|
7
|
+
* record of a resource without a tenant in an app with them, which no admin's switch could cover.
|
|
8
|
+
*/
|
|
9
|
+
export type OrganizationOf = (trx: Db, record: CallRecord) => Promise<string | null | undefined>;
|
|
10
|
+
export type CreateLinkInput = {
|
|
11
|
+
call: string;
|
|
12
|
+
member: string;
|
|
13
|
+
publish: CallTrackSource[];
|
|
14
|
+
expiresAt?: Date;
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { writeTransaction } from "../transactions.js";
|
|
2
|
+
import { endIfEmpty, lockCall, removeMember } from "./call-store.js";
|
|
3
|
+
import { guestMember, isGuestMember, linkTokenPrefix, newToken, tokenHash } from "./guest-tokens.js";
|
|
4
|
+
import { sql } from "kysely";
|
|
5
|
+
//#region src/calls/guest-links-sql.ts
|
|
6
|
+
const iso = (column) => sql.raw(`to_char(${column} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`);
|
|
7
|
+
/** Whether `member` is in the call now, as one of the app's members, never a guest. */
|
|
8
|
+
const presentMember = async (trx, call, member) => !isGuestMember(member) && (await sql`select "id"::text as id from "calls"."members" where "call_id" = ${call}::uuid and "member" = ${member} and "left_at" is null`.execute(trx)).rows.length > 0;
|
|
9
|
+
const allowedIn = async (trx, organization) => (await sql`select "allowed" from "calls"."organization_guests" where "organization_id" = ${organization}`.execute(trx)).rows[0]?.allowed !== false;
|
|
10
|
+
/**
|
|
11
|
+
* A new link to the call, made by a member in it, for the organization of the call's own record, read in the
|
|
12
|
+
* transaction that holds the call's row; refused when the call is over, the member is not in it, or the organization
|
|
13
|
+
* turned guests off. Its token is returned once, and only its hash kept.
|
|
14
|
+
*/
|
|
15
|
+
const createLink = (db, timeoutMs, organizationOf, input) => writeTransaction(db, timeoutMs, async (trx) => {
|
|
16
|
+
const call = await lockCall(trx, input.call);
|
|
17
|
+
if (!call || call.ended || call.expired || !await presentMember(trx, call.id, input.member)) return { refused: "gone" };
|
|
18
|
+
const organization = await organizationOf(trx, {
|
|
19
|
+
resource: call.resource,
|
|
20
|
+
key: call.record_key
|
|
21
|
+
});
|
|
22
|
+
if (organization === null) return { refused: "no-organization" };
|
|
23
|
+
if (organization !== void 0 && !await allowedIn(trx, organization)) return { refused: "off" };
|
|
24
|
+
const token = newToken(linkTokenPrefix);
|
|
25
|
+
const { rows } = await sql`insert into "calls"."guest_links" ("id", "call_id", "token_hash", "created_by", "organization_id", "publish", "created_at", "expires_at")
|
|
26
|
+
values (gen_random_uuid(), ${call.id}::uuid, ${tokenHash(linkTokenPrefix, token)}, ${input.member}, ${organization ?? null}, ${JSON.stringify(input.publish)}::jsonb, now(), ${input.expiresAt ?? null})
|
|
27
|
+
returning "id"::text as id, ${iso("\"expires_at\"")} as expires_at`.execute(trx);
|
|
28
|
+
const row = rows[0];
|
|
29
|
+
return {
|
|
30
|
+
link: {
|
|
31
|
+
id: row.id,
|
|
32
|
+
created_by: input.member,
|
|
33
|
+
publish: input.publish,
|
|
34
|
+
expires_at: row.expires_at
|
|
35
|
+
},
|
|
36
|
+
token
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
const revokeIn = async (trx, call, link, by) => {
|
|
40
|
+
await sql`update "calls"."guest_links" set "revoked_at" = now() where "id" = ${link}::uuid and "revoked_at" is null`.execute(trx);
|
|
41
|
+
await sql`update "calls"."guests" set "admitted" = false, "decided_by" = ${by}, "decided_at" = now() where "link_id" = ${link}::uuid and "admitted" is null`.execute(trx);
|
|
42
|
+
const { rows } = await sql`select "id"::text as id from "calls"."guests" where "link_id" = ${link}::uuid and "admitted"`.execute(trx);
|
|
43
|
+
for (const guest of rows) await removeMember(trx, call, guestMember(guest.id));
|
|
44
|
+
await endIfEmpty(trx, call);
|
|
45
|
+
};
|
|
46
|
+
/** A member in the call revokes one of its links: its guests are out. Whether it was the call's and still live. */
|
|
47
|
+
const revokeLink = (db, timeoutMs, input) => writeTransaction(db, timeoutMs, async (trx) => {
|
|
48
|
+
const call = await lockCall(trx, input.call);
|
|
49
|
+
if (!call || call.ended || !await presentMember(trx, call.id, input.member)) return false;
|
|
50
|
+
const { rows } = await sql`select "id"::text as id from "calls"."guest_links" where "id" = ${input.link}::uuid and "call_id" = ${call.id}::uuid and "revoked_at" is null`.execute(trx);
|
|
51
|
+
if (rows.length === 0) return false;
|
|
52
|
+
await revokeIn(trx, call.id, input.link, input.member);
|
|
53
|
+
return true;
|
|
54
|
+
});
|
|
55
|
+
/**
|
|
56
|
+
* Whether the organization's admin allows guest links to its calls. Turning them off revokes every live link of the
|
|
57
|
+
* organization at once, each under its call's row lock in the order of the calls, and takes their guests out: the
|
|
58
|
+
* calls it changed.
|
|
59
|
+
*/
|
|
60
|
+
const setOrganizationGuests = (db, timeoutMs, input) => writeTransaction(db, timeoutMs, async (trx) => {
|
|
61
|
+
await sql`insert into "calls"."organization_guests" ("organization_id", "allowed", "set_by", "set_at") values (${input.organization}, ${input.allowed}, ${input.by}, now())
|
|
62
|
+
on conflict ("organization_id") do update set "allowed" = excluded."allowed", "set_by" = excluded."set_by", "set_at" = excluded."set_at"`.execute(trx);
|
|
63
|
+
if (input.allowed) return [];
|
|
64
|
+
const { rows } = await sql`select "id"::text as id, "call_id"::text as call from "calls"."guest_links"
|
|
65
|
+
where "organization_id" = ${input.organization} and "revoked_at" is null order by "call_id", "id"`.execute(trx);
|
|
66
|
+
const calls = /* @__PURE__ */ new Set();
|
|
67
|
+
for (const link of rows) {
|
|
68
|
+
const call = await lockCall(trx, link.call);
|
|
69
|
+
if (!call || call.ended) continue;
|
|
70
|
+
await revokeIn(trx, call.id, link.id, input.by);
|
|
71
|
+
calls.add(call.id);
|
|
72
|
+
}
|
|
73
|
+
return [...calls];
|
|
74
|
+
});
|
|
75
|
+
/** Whether the organization allows guest links: yes until its admin turned them off. */
|
|
76
|
+
const organizationGuests = (db, organization) => allowedIn(db, organization);
|
|
77
|
+
//#endregion
|
|
78
|
+
export { createLink, organizationGuests, presentMember, revokeLink, setOrganizationGuests };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { CallGuestLink, CallGuestRefusal, CallGuestState, CallLobbyGuest, CallTrackSource } from "@protobase/schema/internal";
|
|
2
|
+
import { Db } from "@protobase/query";
|
|
3
|
+
//#region src/calls/guest-lobby-sql.d.ts
|
|
4
|
+
/** What a guest's page learns of its wait: only its state, and once let in the call's id and what it may send. */
|
|
5
|
+
export type LobbyState = {
|
|
6
|
+
state: CallGuestState;
|
|
7
|
+
call?: string;
|
|
8
|
+
publish?: CallTrackSource[];
|
|
9
|
+
};
|
|
10
|
+
export type DecideInput = {
|
|
11
|
+
call: string;
|
|
12
|
+
guest: string;
|
|
13
|
+
member: string;
|
|
14
|
+
admit: boolean;
|
|
15
|
+
maxMembers: number;
|
|
16
|
+
};
|
|
17
|
+
//#endregion
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { writeTransaction } from "../transactions.js";
|
|
2
|
+
import { lockCall } from "./call-store.js";
|
|
3
|
+
import { liveLink } from "./guest-checks-sql.js";
|
|
4
|
+
import { presentMember } from "./guest-links-sql.js";
|
|
5
|
+
import { sql } from "kysely";
|
|
6
|
+
//#region src/calls/guest-lobby-sql.ts
|
|
7
|
+
const iso = (column) => sql.raw(`to_char(${column} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')`);
|
|
8
|
+
/**
|
|
9
|
+
* A guest waits in the lobby of the link's call, by the name they gave and the hash of the token they were given;
|
|
10
|
+
* refused, the same way for each cause, when the link is unknown or no longer live, its call is over, it turned away
|
|
11
|
+
* `maxDenials` guests, or `maxWaiting` already wait on it. The call's row is locked.
|
|
12
|
+
*/
|
|
13
|
+
const enterLobby = (db, timeoutMs, input) => writeTransaction(db, timeoutMs, async (trx) => {
|
|
14
|
+
const link = (await sql`select l."id"::text as id, l."call_id"::text as call from "calls"."guest_links" l where l."token_hash" = ${input.link} and ${liveLink("l")}`.execute(trx)).rows[0];
|
|
15
|
+
const call = link && await lockCall(trx, link.call);
|
|
16
|
+
if (!link || !call || call.ended || call.expired) return { refused: "gone" };
|
|
17
|
+
const { rows } = await sql`select count(*) filter (where "admitted" is null and "seen_at" > now() - make_interval(secs => ${10}))::int as waiting,
|
|
18
|
+
count(*) filter (where "admitted" = false)::int as denied from "calls"."guests" where "link_id" = ${link.id}::uuid`.execute(trx);
|
|
19
|
+
if (rows[0].waiting >= 10 || rows[0].denied >= 3) return { refused: "gone" };
|
|
20
|
+
return { guest: (await sql`insert into "calls"."guests" ("id", "link_id", "call_id", "name", "token_hash", "entered_at", "seen_at")
|
|
21
|
+
values (gen_random_uuid(), ${link.id}::uuid, ${call.id}::uuid, ${input.name}, ${input.token}, now(), now()) returning "id"::text as id`.execute(trx)).rows[0].id };
|
|
22
|
+
});
|
|
23
|
+
/** The guest's wait, by their token's hash, which tells the lobby they are still there; `undefined` for an unknown token. */
|
|
24
|
+
const lobbyState = async (db, token) => {
|
|
25
|
+
const { rows } = await sql`
|
|
26
|
+
update "calls"."guests" g set "seen_at" = now() from "calls"."guest_links" l, "calls"."calls" c
|
|
27
|
+
where g."token_hash" = ${token} and l."id" = g."link_id" and c."id" = g."call_id"
|
|
28
|
+
returning g."call_id"::text as call, g."admitted", (${liveLink("l")} and c."ended_at" is null and c."ends_at" > now()) as live, l."publish"`.execute(db);
|
|
29
|
+
const row = rows[0];
|
|
30
|
+
if (!row) return;
|
|
31
|
+
if (row.admitted === false) return { state: "denied" };
|
|
32
|
+
if (!row.live) return { state: "ended" };
|
|
33
|
+
if (row.admitted === null) return { state: "waiting" };
|
|
34
|
+
return {
|
|
35
|
+
state: "admitted",
|
|
36
|
+
call: row.call,
|
|
37
|
+
publish: row.publish
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
/** A waiting guest leaves the lobby: their wait is gone. */
|
|
41
|
+
const leaveLobby = async (db, token) => {
|
|
42
|
+
await sql`delete from "calls"."guests" where "token_hash" = ${token} and "admitted" is null`.execute(db);
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* A member in the call lets a guest waiting on one of its live links in, or turns them away; refused when the guest no
|
|
46
|
+
* longer waits there, or, to let them in, when the call has no room now. The call's row is locked, so racing decisions
|
|
47
|
+
* come one after the other; the guest's own join is checked against the room again.
|
|
48
|
+
*/
|
|
49
|
+
const decideGuest = (db, timeoutMs, input) => writeTransaction(db, timeoutMs, async (trx) => {
|
|
50
|
+
const call = await lockCall(trx, input.call);
|
|
51
|
+
if (!call || call.ended || call.expired || !await presentMember(trx, call.id, input.member)) return { refused: "gone" };
|
|
52
|
+
const { rows } = await sql`select g."id"::text as id from "calls"."guests" g join "calls"."guest_links" l on l."id" = g."link_id"
|
|
53
|
+
where g."id" = ${input.guest}::uuid and g."call_id" = ${call.id}::uuid and g."admitted" is null and ${liveLink("l")}`.execute(trx);
|
|
54
|
+
if (rows.length === 0) return { refused: "gone" };
|
|
55
|
+
if (input.admit) {
|
|
56
|
+
const { rows: present } = await sql`select count(*)::int as n from "calls"."members" where "call_id" = ${call.id}::uuid and "left_at" is null`.execute(trx);
|
|
57
|
+
if (present[0].n >= input.maxMembers) return { refused: "full" };
|
|
58
|
+
}
|
|
59
|
+
await sql`update "calls"."guests" set "admitted" = ${input.admit}, "decided_by" = ${input.member}, "decided_at" = now() where "id" = ${input.guest}::uuid`.execute(trx);
|
|
60
|
+
return {};
|
|
61
|
+
});
|
|
62
|
+
/** Each call's lobby, as its members who may invite see it: the guests waiting now, and its live links. */
|
|
63
|
+
const lobbies = async (db, calls) => {
|
|
64
|
+
const found = new Map(calls.map((call) => [call, {
|
|
65
|
+
guests: [],
|
|
66
|
+
links: []
|
|
67
|
+
}]));
|
|
68
|
+
if (calls.length === 0) return found;
|
|
69
|
+
const ids = `{${calls.join(",")}}`;
|
|
70
|
+
const links = await sql`select l."call_id"::text as call, l."id"::text as id, l."created_by", l."publish", ${iso("l.\"expires_at\"")} as expires_at
|
|
71
|
+
from "calls"."guest_links" l where l."call_id" = any(${ids}::uuid[]) and ${liveLink("l")} order by l."created_at", l."id"`.execute(db);
|
|
72
|
+
for (const { call, ...link } of links.rows) found.get(call).links.push(link);
|
|
73
|
+
const guests = await sql`select g."call_id"::text as call, g."id"::text as guest, g."name", ${iso("g.\"entered_at\"")} as since
|
|
74
|
+
from "calls"."guests" g join "calls"."guest_links" l on l."id" = g."link_id"
|
|
75
|
+
where g."call_id" = any(${ids}::uuid[]) and g."admitted" is null and g."seen_at" > now() - make_interval(secs => ${10}) and ${liveLink("l")}
|
|
76
|
+
order by g."entered_at", g."id"`.execute(db);
|
|
77
|
+
for (const { call, ...guest } of guests.rows) found.get(call).guests.push(guest);
|
|
78
|
+
return found;
|
|
79
|
+
};
|
|
80
|
+
//#endregion
|
|
81
|
+
export { decideGuest, enterLobby, leaveLobby, lobbies, lobbyState };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { badRequest } from "../problem.js";
|
|
2
|
+
/** The name a guest gives, trimmed: 1 to 60 characters, none of them a control or format character. */
|
|
3
|
+
const guestName = (value) => {
|
|
4
|
+
const name = typeof value === "string" ? value.trim() : "";
|
|
5
|
+
if (name.length === 0 || [...name].length > 60) throw badRequest("guest-name", `Give a name of 1 to 60 characters`);
|
|
6
|
+
if (/\p{C}/u.test(name)) throw badRequest("guest-name", "A name cannot hold control characters");
|
|
7
|
+
return name;
|
|
8
|
+
};
|
|
9
|
+
//#endregion
|
|
10
|
+
export { guestName };
|