@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.
Files changed (38) hide show
  1. package/dist/better-auth/organizations/organization-role.js +13 -0
  2. package/dist/calls/call-hub.js +28 -7
  3. package/dist/calls/call-member.d.ts +5 -1
  4. package/dist/calls/call-reads.d.ts +7 -2
  5. package/dist/calls/call-reads.js +6 -4
  6. package/dist/calls/call-store.d.ts +7 -0
  7. package/dist/calls/call-store.js +35 -24
  8. package/dist/calls/call-sweep.js +8 -6
  9. package/dist/calls/calls-access.js +7 -2
  10. package/dist/calls/calls-runtime.d.ts +26 -5
  11. package/dist/calls/calls-runtime.js +84 -19
  12. package/dist/calls/calls-service.js +4 -2
  13. package/dist/calls/calls-sql.d.ts +1 -1
  14. package/dist/calls/calls-sql.js +56 -5
  15. package/dist/calls/calls-store.js +2 -1
  16. package/dist/calls/client-message.js +27 -2
  17. package/dist/calls/guest-checks-sql.d.ts +14 -0
  18. package/dist/calls/guest-checks-sql.js +43 -0
  19. package/dist/calls/guest-limits.js +49 -0
  20. package/dist/calls/guest-links-sql.d.ts +16 -0
  21. package/dist/calls/guest-links-sql.js +78 -0
  22. package/dist/calls/guest-lobby-sql.d.ts +17 -0
  23. package/dist/calls/guest-lobby-sql.js +81 -0
  24. package/dist/calls/guest-name.js +10 -0
  25. package/dist/calls/guest-routes.js +97 -0
  26. package/dist/calls/guest-socket.js +58 -0
  27. package/dist/calls/guest-store.d.ts +48 -0
  28. package/dist/calls/guest-store.js +20 -0
  29. package/dist/calls/guest-tokens.js +17 -0
  30. package/dist/calls/member-relay.js +63 -5
  31. package/dist/calls/organization-guests-route.js +38 -0
  32. package/dist/calls/record-organization.js +24 -0
  33. package/dist/create-admin.js +22 -2
  34. package/dist/functions/routes.js +1 -1
  35. package/dist/functions/upgrade-caller.js +1 -1
  36. package/dist/index.js +1 -1
  37. package/dist/package.js +1 -1
  38. package/package.json +4 -4
@@ -0,0 +1,48 @@
1
+ import { GuestRow } from "./guest-checks-sql.js";
2
+ import { CreateLinkInput } from "./guest-links-sql.js";
3
+ import { DecideInput, LobbyState } from "./guest-lobby-sql.js";
4
+ import { CallGuestLink, CallGuestRefusal, CallLobbyGuest } from "@protobase/schema/internal";
5
+ import { Db } from "@protobase/query";
6
+ //#region src/calls/guest-store.d.ts
7
+ /** Where guest links, the lobby's guests and the organizations' switch are kept: the `calls` schema. */
8
+ export type GuestStore = {
9
+ createLink: (input: CreateLinkInput) => Promise<{
10
+ link: CallGuestLink;
11
+ token: string;
12
+ } | {
13
+ refused: CallGuestRefusal;
14
+ }>;
15
+ revokeLink: (input: {
16
+ call: string;
17
+ link: string;
18
+ member: string;
19
+ }) => Promise<boolean>;
20
+ decide: (input: DecideInput) => Promise<{
21
+ refused?: CallGuestRefusal;
22
+ }>;
23
+ lobbies: (calls: readonly string[]) => Promise<Map<string, {
24
+ guests: CallLobbyGuest[];
25
+ links: CallGuestLink[];
26
+ }>>;
27
+ enter: (input: {
28
+ link: Buffer;
29
+ name: string;
30
+ token: Buffer;
31
+ }) => Promise<{
32
+ guest: string;
33
+ } | {
34
+ refused: 'gone';
35
+ }>;
36
+ state: (token: Buffer) => Promise<LobbyState | undefined>;
37
+ leave: (token: Buffer) => Promise<void>;
38
+ byToken: (token: Buffer) => Promise<GuestRow | undefined>;
39
+ stillAllowed: (guest: string) => Promise<boolean>;
40
+ /** The calls whose guests turning guests off took out. */
41
+ setOrganization: (input: {
42
+ organization: string;
43
+ allowed: boolean;
44
+ by: string;
45
+ }) => Promise<string[]>;
46
+ organizationAllowed: (organization: string) => Promise<boolean>;
47
+ };
48
+ //#endregion
@@ -0,0 +1,20 @@
1
+ import { guestByToken, guestStillAllowed } from "./guest-checks-sql.js";
2
+ import { createLink, organizationGuests, revokeLink, setOrganizationGuests } from "./guest-links-sql.js";
3
+ import { decideGuest, enterLobby, leaveLobby, lobbies, lobbyState } from "./guest-lobby-sql.js";
4
+ //#region src/calls/guest-store.ts
5
+ /** The `calls` schema of the app's database; writes under the API's statement timeout. */
6
+ const sqlGuestStore = (db, timeoutMs, organizationOf) => ({
7
+ createLink: (input) => createLink(db, timeoutMs, organizationOf, input),
8
+ revokeLink: (input) => revokeLink(db, timeoutMs, input),
9
+ decide: (input) => decideGuest(db, timeoutMs, input),
10
+ lobbies: (calls) => lobbies(db, calls),
11
+ enter: (input) => enterLobby(db, timeoutMs, input),
12
+ state: (token) => lobbyState(db, token),
13
+ leave: (token) => leaveLobby(db, token),
14
+ byToken: (token) => guestByToken(db, token),
15
+ stillAllowed: (guest) => guestStillAllowed(db, guest),
16
+ setOrganization: (input) => setOrganizationGuests(db, timeoutMs, input),
17
+ organizationAllowed: (organization) => organizationGuests(db, organization)
18
+ });
19
+ //#endregion
20
+ export { sqlGuestStore };
@@ -0,0 +1,17 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ //#region src/calls/guest-tokens.ts
3
+ /** What starts a guest link's token, and a guest's: neither is ever a session token, a JWT or an API key. */
4
+ const linkTokenPrefix = "pbgl_";
5
+ const guestTokenPrefix = "pbg_";
6
+ /** A new token, 32 random bytes in base64url after its prefix. */
7
+ const newToken = (prefix) => `${prefix}${randomBytes(32).toString("base64url")}`;
8
+ /** The SHA-256 a token is stored and looked up by; `undefined` for a value that is no token of this kind. */
9
+ const tokenHash = (prefix, token) => {
10
+ if (typeof token !== "string" || !token.startsWith(prefix) || token.length !== prefix.length + 43) return;
11
+ return createHash("sha256").update(token).digest();
12
+ };
13
+ /** The member a guest is in their call: no `calls.join` takes a member of this form. */
14
+ const guestMember = (guest) => `guest:${guest}`;
15
+ const isGuestMember = (member) => member.startsWith("guest:");
16
+ //#endregion
17
+ export { guestMember, guestTokenPrefix, isGuestMember, linkTokenPrefix, newToken, tokenHash };
@@ -1,9 +1,11 @@
1
- import { memberEnd } from "./close-codes.js";
2
1
  import { parseClientMessage } from "./client-message.js";
2
+ import { memberEnd } from "./close-codes.js";
3
3
  //#region src/calls/member-relay.ts
4
4
  /** The close code of a socket whose caller Protobase refused: the member leaves the call too. */
5
5
  const refusedCloseCode = 1008;
6
6
  const mayPublish = (rights, tracks) => rights.publish === true || Array.isArray(rights.publish) && tracks.every((track) => rights.publish.includes(track.source));
7
+ const guestSources = (rights, asked) => [...new Set(asked)].filter((source) => rights.publish === true || Array.isArray(rights.publish) && rights.publish.includes(source));
8
+ const isGuestMessage = (message) => message.type.startsWith("guest-");
7
9
  const refusals = {
8
10
  removed: "You were removed from this call",
9
11
  full: "This call is full",
@@ -15,7 +17,7 @@ const refusals = {
15
17
  * may not make, and tells the call and the plugin how the socket ended. The plugin only ever sees well-formed messages
16
18
  * of a member Protobase let in.
17
19
  */
18
- const memberRelay = ({ door, backend, record, member, rights, maxMembers, maxMinutes, report, user, ring }) => {
20
+ const memberRelay = ({ door, backend, record, member, rights, maxMembers, maxMinutes, report, user, ring, invites = false, name, guest }) => {
19
21
  let browser;
20
22
  let session;
21
23
  let inCall;
@@ -49,14 +51,20 @@ const memberRelay = ({ door, backend, record, member, rights, maxMembers, maxMin
49
51
  muted: false
50
52
  }))
51
53
  } : void 0;
54
+ const more = {
55
+ ...user !== void 0 && { user },
56
+ ...ring && !resume && { ring },
57
+ ...invites && { invites },
58
+ ...name !== void 0 && { name },
59
+ ...guest && { guest }
60
+ };
52
61
  const admitted = await door.admit({
53
62
  record,
54
63
  member,
55
64
  maxMembers,
56
65
  maxMinutes,
57
66
  ...resume && { resume },
58
- ...user !== void 0 && { user },
59
- ...ring && !resume && { ring }
67
+ ...more
60
68
  }, {
61
69
  send,
62
70
  close
@@ -80,9 +88,59 @@ const memberRelay = ({ door, backend, record, member, rights, maxMembers, maxMin
80
88
  await session.receive(message);
81
89
  await admitted.listen(session.roster ?? (() => {}));
82
90
  };
91
+ const guests = async (message, call) => {
92
+ const actions = door.guests;
93
+ if (!invites || !actions) return send({
94
+ type: "guest-refused",
95
+ reason: "not-allowed"
96
+ });
97
+ switch (message.type) {
98
+ case "guest-link": {
99
+ const made = await actions.link({
100
+ call,
101
+ member,
102
+ publish: guestSources(rights, message.publish),
103
+ ...message.expires_at && { expiresAt: new Date(message.expires_at) }
104
+ });
105
+ if ("refused" in made) return send({
106
+ type: "guest-refused",
107
+ reason: made.refused
108
+ });
109
+ return send({
110
+ type: "guest-link",
111
+ link: made.link,
112
+ token: made.token
113
+ });
114
+ }
115
+ case "guest-link-revoke": return send(await actions.revoke({
116
+ call,
117
+ link: message.link,
118
+ member
119
+ }) ? { type: "guest-done" } : {
120
+ type: "guest-refused",
121
+ reason: "gone"
122
+ });
123
+ case "guest-decide": {
124
+ const decided = await actions.decide({
125
+ call,
126
+ guest: message.guest,
127
+ member,
128
+ admit: message.admit
129
+ });
130
+ return send(decided.refused ? {
131
+ type: "guest-refused",
132
+ reason: decided.refused
133
+ } : { type: "guest-done" });
134
+ }
135
+ case "guest-remove":
136
+ await actions.remove(call, message.guest);
137
+ return send({ type: "guest-done" });
138
+ }
139
+ };
83
140
  const relay = async (message) => {
84
141
  if (message.type === "join" || message.type === "resume") return enter(message);
85
- if (!session) return close(refusedCloseCode, "Join or resume the call first");
142
+ if (!session || !inCall) return close(refusedCloseCode, "Join or resume the call first");
143
+ if (isGuestMessage(message)) return guests(message, inCall.call);
86
144
  if (message.type === "publish" && !mayPublish(rights, message.tracks)) return send({
87
145
  type: "publish-refused",
88
146
  reason: "The member may not publish these tracks"
@@ -0,0 +1,38 @@
1
+ import { badRequest, forbidden } from "../problem.js";
2
+ import { Hono } from "hono";
3
+ //#region src/calls/organization-guests-route.ts
4
+ const organizationOf = (session) => {
5
+ if (session.anonymous || session.client || session.organization === void 0) throw forbidden("organization-required", "Sign in and work in an organization first");
6
+ return String(session.organization.id);
7
+ };
8
+ /**
9
+ * Whether the calls of the organization the user works in take guests, mounted behind the API's authenticator: `GET /`
10
+ * `{ allowed }` for any member, `PUT /` with `{ allowed }` for its owner or an admin. Turning guests off revokes every
11
+ * guest link of the organization's calls and takes their guests out at once.
12
+ */
13
+ const organizationGuestsRoute = ({ calls, roleIn }) => {
14
+ const app = new Hono();
15
+ app.get("/", async (c) => {
16
+ const session = c.get("session");
17
+ const organization = organizationOf(session);
18
+ if (!await roleIn(organization, String(session.user.id))) throw forbidden("not-a-member", "You are not a member of this organization");
19
+ return c.json({ allowed: await (await calls.runtime()).guests().organizationAllowed(organization) }, 200, { "Cache-Control": "no-store" });
20
+ });
21
+ app.put("/", async (c) => {
22
+ const session = c.get("session");
23
+ const organization = organizationOf(session);
24
+ const role = await roleIn(organization, String(session.user.id));
25
+ if (role !== "owner" && role !== "admin") throw forbidden("organization-admin-required", "Only an owner or admin of the organization turns guest links on or off");
26
+ const body = await c.req.json().catch(() => void 0);
27
+ if (typeof body?.allowed !== "boolean") throw badRequest("allowed-required", "Send { allowed: true } or { allowed: false }");
28
+ await (await calls.runtime()).setOrganizationGuests({
29
+ organization,
30
+ allowed: body.allowed,
31
+ by: String(session.user.id)
32
+ });
33
+ return c.json({ allowed: body.allowed });
34
+ });
35
+ return app;
36
+ };
37
+ //#endregion
38
+ export { organizationGuestsRoute };
@@ -0,0 +1,24 @@
1
+ import { decodeKey } from "@protobase/schema/internal";
2
+ import { sql } from "kysely";
3
+ //#region src/calls/record-organization.ts
4
+ /**
5
+ * The organization a call's record is in, read from the record's own tenant field with no access check: `null` for a
6
+ * record of a resource without a tenant, or one that is gone, which no organization's switch covers. A key of a relation
7
+ * to a resource with a key of several fields is read as none either, so its calls take no guests.
8
+ */
9
+ const recordOrganization = (registry) => async (trx, record) => {
10
+ const entry = registry.find(record.resource);
11
+ const tenant = entry?.model.tenant;
12
+ if (!entry || !tenant) return null;
13
+ const { model } = entry;
14
+ const decoded = decodeKey(record.key, entry.keyTypes);
15
+ const values = Array.isArray(decoded) ? decoded : [decoded];
16
+ if (values.length !== model.primaryKey.length) return null;
17
+ const table = model.table.schema ? sql.id(model.table.schema, model.table.name) : sql.id(model.table.name);
18
+ const where = sql.join(model.primaryKey.map((name, index) => sql`${sql.id(model.fields[name].column)} = ${values[index]}`), sql` and `);
19
+ const { rows } = await sql`select ${sql.id(model.fields[tenant].column)} as tenant from ${table} where ${where}`.execute(trx);
20
+ const found = rows[0]?.tenant;
21
+ return found === void 0 || found === null ? null : String(found);
22
+ };
23
+ //#endregion
24
+ export { recordOrganization };
@@ -61,6 +61,10 @@ import { webhookTable } from "./webhooks/table/webhook-table.js";
61
61
  import { subscriptionUrlCheck, subscriptionWriteCheck } from "./webhooks/table/validate-hook.js";
62
62
  import { callsService } from "./calls/calls-service.js";
63
63
  import { ringRoutes } from "./calls/ring-routes.js";
64
+ import { upgradeAuthenticator } from "./functions/upgrade-caller.js";
65
+ import { guestRoutes } from "./calls/guest-routes.js";
66
+ import { organizationGuestsRoute } from "./calls/organization-guests-route.js";
67
+ import { organizationRole } from "./better-auth/organizations/organization-role.js";
64
68
  import { deviceStore, removeDevicesWithUsers } from "./notifications/device-store-of.js";
65
69
  import { historyStore, removeHistoryWithUsers } from "./notifications/history-store-of.js";
66
70
  import { devicesPath, historyPath } from "./notifications/paths.js";
@@ -91,7 +95,6 @@ import { fieldDecrypter } from "./encryption/decrypt-field.js";
91
95
  import { createEncryptionRuntime } from "./encryption/runtime.js";
92
96
  import { functionRoutes } from "./functions/routes.js";
93
97
  import { supabaseFunctionRoutes } from "./functions/supabase-routes.js";
94
- import { upgradeAuthenticator } from "./functions/upgrade-caller.js";
95
98
  import { localeSetting } from "./locale/locale-option.js";
96
99
  import { requestLocale } from "./locale/request-locale.js";
97
100
  import { mapSettings } from "./maps/map-settings.js";
@@ -112,7 +115,8 @@ import { createScanGuard } from "./scan-guard.js";
112
115
  import { failedJobs, failedJobsView } from "./jobs/failed-jobs.js";
113
116
  import { Hono } from "hono";
114
117
  import { accountPage, accountPageName, checkPages, settingsResources, withSettingsFields } from "@protobase/layout";
115
- import { ringsPath, statusPath } from "@protobase/schema/internal";
118
+ import { guestCallsPath, ringsPath, statusPath } from "@protobase/schema/internal";
119
+ import { getIP } from "better-auth/api";
116
120
  //#region src/create-admin.ts
117
121
  const declaredRoles = (auth, roles) => {
118
122
  const names = [...auth?.roles ?? [], ...Object.keys(roles?.definitions ?? {})];
@@ -270,6 +274,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
270
274
  notices: changes,
271
275
  channels: () => channels,
272
276
  notifications: Boolean(notifications),
277
+ organizations: Boolean(auth?.organizations),
273
278
  ...auth?.origins[0] && { origin: auth.origins[0] },
274
279
  ...statusTables && { doNotDisturb: (ringDb, userIds) => onDoNotDisturb(ringDb, statusTables.statuses, userIds) }
275
280
  }) }
@@ -470,6 +475,21 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
470
475
  }
471
476
  if (statusTables) app.route(statusPath(systemPath), guarded((routes) => void routes.route("/", statusRoutes(db, statusTables))));
472
477
  if (deps.calls) app.route(ringsPath(systemPath), guarded((routes) => void routes.route("/", ringRoutes(deps.calls))));
478
+ if (deps.calls && auth) {
479
+ const addressOf = (request) => getIP(request, auth.options);
480
+ app.route(guestCallsPath(systemPath), guestRoutes({
481
+ calls: deps.calls,
482
+ addressOf,
483
+ origins: auth.origins,
484
+ onError,
485
+ ...upgradeWebSocket && { upgradeWebSocket },
486
+ report
487
+ }));
488
+ if (auth.organizations) app.route(guestCallsPath(systemPath, "organization"), guarded((routes) => void routes.route("/", organizationGuestsRoute({
489
+ calls: deps.calls,
490
+ roleIn: organizationRole(auth)
491
+ }))));
492
+ }
473
493
  if (forms) app.route(`${systemPath}/forms`, formRoutes(deps, forms));
474
494
  const feed = tableFeed({
475
495
  db,
@@ -2,9 +2,9 @@ import { forbidden, notFound } from "../problem.js";
2
2
  import { namingTimeouts } from "../statement-timeout.js";
3
3
  import { auditOrigin } from "../audit/origin.js";
4
4
  import { invokeFunction } from "./define-function.js";
5
+ import { socketAcceptor } from "./accept-socket.js";
5
6
  import { runInBackground } from "../background-work.js";
6
7
  import { callsAccess } from "../calls/calls-access.js";
7
- import { socketAcceptor } from "./accept-socket.js";
8
8
  import { ownMailbox } from "./own-mailbox.js";
9
9
  import { functionGate, requireGate } from "./function-gate.js";
10
10
  import { sameCaller } from "./same-caller.js";
@@ -22,4 +22,4 @@ const upgradeAuthenticator = ({ authenticate, origins, sessionToken }) => async
22
22
  return authenticate(new Request(request, { headers }));
23
23
  };
24
24
  //#endregion
25
- export { upgradeAuthenticator };
25
+ export { allowedOrigin, upgradeAuthenticator };
package/dist/index.js CHANGED
@@ -68,6 +68,7 @@ import { historyIdPattern, historyLimits } from "./notifications/history.js";
68
68
  import { PushMessageError, maxTtlSeconds, pushMessage, urgencies } from "./notifications/message.js";
69
69
  import { NotificationSendError, maxMessageBytes, maxRecipients } from "./notifications/send-input.js";
70
70
  import { sendNotification } from "./notifications/send-notification.js";
71
+ import { capSocketMessages, honorSocketMessageCaps, socketMessageCapOf } from "./functions/socket-message-cap.js";
71
72
  import { maxHistoryPage, maxUnreadCount } from "./notifications/history-store.js";
72
73
  import { generateVapidKeys } from "./notifications/web-push/keys.js";
73
74
  import { DeviceStoreError, postgresDeviceStore } from "./notifications/postgres-store.js";
@@ -89,7 +90,6 @@ import { localFileStore } from "./files/local-files.js";
89
90
  import { s3FileStore } from "./files/s3-files.js";
90
91
  import { storageProvider } from "./files/storage-provider.js";
91
92
  import { isBackgroundWork, runInBackground, wantsLongQueries } from "./background-work.js";
92
- import { capSocketMessages, honorSocketMessageCaps, socketMessageCapOf } from "./functions/socket-message-cap.js";
93
93
  import { mapSettings, mapStyleUrl, mapStyleVariable } from "./maps/map-settings.js";
94
94
  import { beforeSignInPluginsVariable, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable } from "./plugins/plugin-settings.js";
95
95
  import { runtimeUrl, runtimeVariable, seesRuntime } from "./runtime/runtime-settings.js";
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.62.5";
2
+ var version = "0.63.0";
3
3
  //#endregion
4
4
  export { version };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.62.5",
3
+ "version": "0.63.0",
4
4
  "description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -42,9 +42,9 @@
42
42
  "@better-auth/sso": "^1.7.12",
43
43
  "@livekit/protocol": "1.53.1",
44
44
  "@modelcontextprotocol/server": "^2.3.1",
45
- "@protobase/layout": "0.62.5",
46
- "@protobase/query": "0.62.5",
47
- "@protobase/schema": "0.62.5",
45
+ "@protobase/layout": "0.63.0",
46
+ "@protobase/query": "0.63.0",
47
+ "@protobase/schema": "0.63.0",
48
48
  "@react-email/render": "^2.1.0",
49
49
  "@scalar/hono-api-reference": "^0.12.10",
50
50
  "@sentry/core": "^11.7.0",