@protobase/server 0.62.5 → 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/organizations/organization-role.js +13 -0
- 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 +22 -2
- package/dist/functions/routes.js +1 -1
- package/dist/functions/upgrade-caller.js +1 -1
- package/dist/index.js +1 -1
- package/dist/package.js +1 -1
- package/package.json +4 -4
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 };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { HttpProblem, forbidden, notFound, unauthorized } from "../problem.js";
|
|
2
|
+
import { socketAcceptor } from "../functions/accept-socket.js";
|
|
3
|
+
import { allowedOrigin } from "../functions/upgrade-caller.js";
|
|
4
|
+
import { guestLimits } from "./guest-limits.js";
|
|
5
|
+
import { guestName } from "./guest-name.js";
|
|
6
|
+
import { guestTokenPrefix, linkTokenPrefix, newToken, tokenHash } from "./guest-tokens.js";
|
|
7
|
+
import { guestSocket } from "./guest-socket.js";
|
|
8
|
+
import { Hono } from "hono";
|
|
9
|
+
//#region src/calls/guest-routes.ts
|
|
10
|
+
const noStore = { "Cache-Control": "no-store" };
|
|
11
|
+
const tooMany = () => new HttpProblem(429, "too-many-requests", "Too Many Requests", "Too many tries: wait a minute", {}, { "retry-after": "60" });
|
|
12
|
+
const noLobby = () => notFound("This link lets nobody into a call");
|
|
13
|
+
const bearer = (c) => c.req.header("authorization")?.match(/^Bearer (.+)$/)?.[1];
|
|
14
|
+
/**
|
|
15
|
+
* The guests' routes, mounted at `guestCallsPath` before the API's authenticator, which takes none of their tokens,
|
|
16
|
+
* as no route but these takes them:
|
|
17
|
+
* - `POST /lobby` with `{ link, name }`: the guest waits in the lobby of the link's call; `201 { guest, token }`.
|
|
18
|
+
* - `GET /lobby` with `Authorization: Bearer <token>`: `{ state }`, with `call` and `publish` once let in.
|
|
19
|
+
* - `DELETE /lobby`: the waiting guest leaves.
|
|
20
|
+
* - `GET /socket`: the guest's call socket (see `guestSocket`).
|
|
21
|
+
* Each address may enter `entriesPerMinute` times a minute, and is answered 429 for the rest of the minute after
|
|
22
|
+
* `refusalsPerMinute` unknown or refused tokens; each guest asks after their wait `pollsPerMinute` times at most.
|
|
23
|
+
*/
|
|
24
|
+
const guestRoutes = ({ calls, addressOf, origins, upgradeWebSocket, onError, report }) => {
|
|
25
|
+
const app = new Hono();
|
|
26
|
+
app.onError(onError);
|
|
27
|
+
const limits = guestLimits();
|
|
28
|
+
const address = (c) => addressOf(c.req.raw) ?? "no-trusted-address";
|
|
29
|
+
const open = (c) => {
|
|
30
|
+
const at = address(c);
|
|
31
|
+
if (limits.blocked(at)) throw tooMany();
|
|
32
|
+
return at;
|
|
33
|
+
};
|
|
34
|
+
const guestToken = (c, at) => {
|
|
35
|
+
const hash = tokenHash(guestTokenPrefix, bearer(c));
|
|
36
|
+
if (!hash) {
|
|
37
|
+
limits.refused(at);
|
|
38
|
+
throw unauthorized("Send the token the lobby gave you");
|
|
39
|
+
}
|
|
40
|
+
if (!limits.poll(hash.toString("hex"))) throw tooMany();
|
|
41
|
+
return hash;
|
|
42
|
+
};
|
|
43
|
+
app.post("/lobby", async (c) => {
|
|
44
|
+
const at = open(c);
|
|
45
|
+
const body = await c.req.json().catch(() => void 0);
|
|
46
|
+
if (!limits.enter(at)) throw tooMany();
|
|
47
|
+
const name = guestName(body?.name);
|
|
48
|
+
const link = tokenHash(linkTokenPrefix, body?.link);
|
|
49
|
+
if (!link) {
|
|
50
|
+
limits.refused(at);
|
|
51
|
+
throw noLobby();
|
|
52
|
+
}
|
|
53
|
+
const token = newToken(guestTokenPrefix);
|
|
54
|
+
const entered = await (await calls.runtime()).guests().enter({
|
|
55
|
+
link,
|
|
56
|
+
name,
|
|
57
|
+
token: tokenHash(guestTokenPrefix, token)
|
|
58
|
+
});
|
|
59
|
+
if ("refused" in entered) {
|
|
60
|
+
limits.refused(at);
|
|
61
|
+
throw noLobby();
|
|
62
|
+
}
|
|
63
|
+
return c.json({
|
|
64
|
+
guest: entered.guest,
|
|
65
|
+
token
|
|
66
|
+
}, 201, noStore);
|
|
67
|
+
});
|
|
68
|
+
app.get("/lobby", async (c) => {
|
|
69
|
+
const at = open(c);
|
|
70
|
+
const hash = guestToken(c, at);
|
|
71
|
+
const state = await (await calls.runtime()).guests().state(hash);
|
|
72
|
+
if (!state) {
|
|
73
|
+
limits.refused(at);
|
|
74
|
+
throw unauthorized("This token lets nobody into a call");
|
|
75
|
+
}
|
|
76
|
+
return c.json(state, 200, noStore);
|
|
77
|
+
});
|
|
78
|
+
app.delete("/lobby", async (c) => {
|
|
79
|
+
const at = open(c);
|
|
80
|
+
await (await calls.runtime()).guests().leave(guestToken(c, at));
|
|
81
|
+
return c.body(null, 204);
|
|
82
|
+
});
|
|
83
|
+
app.get("/socket", async (c) => {
|
|
84
|
+
const at = open(c);
|
|
85
|
+
if (!allowedOrigin(c.req.raw, origins)) throw forbidden("foreign-origin", "This page’s origin may not open a socket to this app");
|
|
86
|
+
const runtime = await calls.runtime();
|
|
87
|
+
const acceptor = socketAcceptor({
|
|
88
|
+
c,
|
|
89
|
+
upgradeWebSocket,
|
|
90
|
+
report
|
|
91
|
+
});
|
|
92
|
+
return acceptor.answer("guest call", acceptor.accept(guestSocket(runtime, () => limits.refused(at))));
|
|
93
|
+
});
|
|
94
|
+
return app;
|
|
95
|
+
};
|
|
96
|
+
//#endregion
|
|
97
|
+
export { guestRoutes };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { forbidden } from "../problem.js";
|
|
2
|
+
import { parseClientMessage } from "./client-message.js";
|
|
3
|
+
import { guestTokenPrefix, tokenHash } from "./guest-tokens.js";
|
|
4
|
+
//#region src/calls/guest-socket.ts
|
|
5
|
+
/** How long a guest's socket may stay open before its first message names the guest, in milliseconds. */
|
|
6
|
+
const guestFirstMessageMs = 1e4;
|
|
7
|
+
const refusedCloseCode = 1008;
|
|
8
|
+
const maxMessageBytes = 307200;
|
|
9
|
+
/**
|
|
10
|
+
* A guest's call socket: open to anyone, it lets in only a guest whose first message, their `join` or `resume`, carries
|
|
11
|
+
* the token of a guest a member let in who may still be in the call. From then on it is a member's socket, as `guest:<id>` in their link's call,
|
|
12
|
+
* checked every minute: removed, their link revoked or expired, guests turned off or the call ended closes it. A
|
|
13
|
+
* socket that names no such guest, or none in time, closes with 1008 and `refused` (the address is counted).
|
|
14
|
+
*/
|
|
15
|
+
const guestSocket = (runtime, refused) => {
|
|
16
|
+
let socket;
|
|
17
|
+
let relay;
|
|
18
|
+
let guest;
|
|
19
|
+
let timer;
|
|
20
|
+
const refuse = () => {
|
|
21
|
+
refused();
|
|
22
|
+
socket.send(JSON.stringify({
|
|
23
|
+
type: "left",
|
|
24
|
+
reason: "refused"
|
|
25
|
+
}));
|
|
26
|
+
socket.close(refusedCloseCode, "Not let into this call");
|
|
27
|
+
};
|
|
28
|
+
const first = async (data) => {
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
const message = parseClientMessage(data);
|
|
31
|
+
const hash = message && (message.type === "join" || message.type === "resume") ? tokenHash(guestTokenPrefix, message.guest) : void 0;
|
|
32
|
+
const row = hash && await runtime.guests().byToken(hash);
|
|
33
|
+
if (!row || row.admitted !== true || !await runtime.guests().stillAllowed(row.id)) return refuse();
|
|
34
|
+
guest = row.id;
|
|
35
|
+
relay = await runtime.relayGuest(row);
|
|
36
|
+
await relay.onOpen?.(socket);
|
|
37
|
+
await relay.onMessage?.(data, socket);
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
maxMessageBytes,
|
|
41
|
+
onOpen: (opened) => {
|
|
42
|
+
socket = opened;
|
|
43
|
+
timer = setTimeout(refuse, guestFirstMessageMs);
|
|
44
|
+
timer.unref?.();
|
|
45
|
+
},
|
|
46
|
+
onMessage: (data) => relay ? relay.onMessage?.(data, socket) : first(data),
|
|
47
|
+
onClose: (code, reason) => {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
return relay?.onClose?.(code, reason);
|
|
50
|
+
},
|
|
51
|
+
recheck: async () => {
|
|
52
|
+
if (guest === void 0) return;
|
|
53
|
+
if (!await runtime.guests().stillAllowed(guest)) throw forbidden("guest-not-allowed", "This guest may no longer be in the call");
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
//#endregion
|
|
58
|
+
export { guestFirstMessageMs, guestSocket };
|