@protobase/server 0.49.2 → 0.50.1

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/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.49.2";
2
+ var version = "0.50.1";
3
3
  //#endregion
4
4
  export { version };
@@ -0,0 +1,61 @@
1
+ import { isDatabaseError } from "../error-handler.js";
2
+ import { writeTransaction } from "../transactions.js";
3
+ import { callerSettings } from "../rest/caller-settings.js";
4
+ import { sql } from "kysely";
5
+ import { randomUUID } from "node:crypto";
6
+ //#region src/realtime/channel-access.ts
7
+ const insufficientPrivilege = "42501";
8
+ /**
9
+ * Supabase Realtime Authorization: the row security policies on `realtime.messages` decide what a caller may do on a
10
+ * private channel's topic, as Supabase's Realtime checks them. Each check runs in a transaction of its own, under a
11
+ * savepoint it rolls back, so nothing it inserts stays. It sets the caller's role and claims as `/api/sb/rest/v1` does,
12
+ * and the topic, without its `realtime:` prefix, in `realtime.topic`, which `realtime.topic()` reads. A database
13
+ * without `realtime.messages` allows nothing.
14
+ */
15
+ const channelAccess = ({ db, statementTimeoutMs }) => {
16
+ const probe = (denied, run) => writeTransaction(db, statementTimeoutMs, async (trx) => {
17
+ const { rows } = await sql`select to_regclass('realtime.messages') is not null as found`.execute(trx);
18
+ if (!rows[0].found) return denied;
19
+ await sql`savepoint access_probe`.execute(trx);
20
+ const result = await run(trx);
21
+ await sql`rollback to savepoint access_probe`.execute(trx);
22
+ return result;
23
+ });
24
+ const actAs = (trx, caller, topic) => {
25
+ const settings = {
26
+ role: "none",
27
+ "request.jwt.claim.sub": "",
28
+ ...callerSettings(caller),
29
+ "realtime.topic": topic
30
+ };
31
+ return sql`select ${sql.join(Object.entries(settings).map(([name, value]) => sql`set_config(${name}, ${value}, true)`))}`.execute(trx);
32
+ };
33
+ return {
34
+ /**
35
+ * Whether the caller may receive each extension's messages on the topic: as the connection's own role, which must
36
+ * own `realtime.messages` or bypass its policies, it inserts a message of each, then selects them as the caller.
37
+ */
38
+ read: (caller, topic, extensions) => probe(/* @__PURE__ */ new Map(), async (trx) => {
39
+ const probes = extensions.map((extension) => ({
40
+ id: randomUUID(),
41
+ extension
42
+ }));
43
+ await sql`insert into realtime.messages (id, topic, extension, private)
44
+ select id, ${topic}, extension, true from jsonb_to_recordset(${JSON.stringify(probes)}::jsonb) as p(id uuid, extension text)`.execute(trx);
45
+ await actAs(trx, caller, topic);
46
+ const { rows } = await sql`select id::text as id from realtime.messages where id = any(${probes.map((probe) => probe.id)}::uuid[])`.execute(trx);
47
+ const seen = new Set(rows.map((row) => row.id));
48
+ return new Map(probes.map((probe) => [probe.extension, seen.has(probe.id)]));
49
+ }),
50
+ /** Whether the caller may send the extension's messages on the topic: whether it may insert one. */
51
+ write: (caller, topic, extension) => probe(false, async (trx) => {
52
+ await actAs(trx, caller, topic);
53
+ return sql`insert into realtime.messages (topic, extension, private) values (${topic}, ${extension}, true)`.execute(trx).then(() => true, (error) => {
54
+ if (isDatabaseError(error) && error.code === insufficientPrivilege) return false;
55
+ throw error;
56
+ });
57
+ })
58
+ };
59
+ };
60
+ //#endregion
61
+ export { channelAccess };
@@ -0,0 +1,46 @@
1
+ import { isDatabaseError } from "../error-handler.js";
2
+ //#region src/realtime/channel-gate.ts
3
+ /** A public channel, which may do anything. */
4
+ const publicGate = {
5
+ authorize: async () => void 0,
6
+ hears: () => true,
7
+ mayWrite: async () => true
8
+ };
9
+ /** Supabase's message for a refusal to read the topic, which it names without its `realtime:` prefix. */
10
+ const readRefusalText = (topic, refusal) => refusal === "revoked" ? `You no longer have permission to read from this Channel topic: ${subTopic(topic)}` : `You do not have permissions to read from this Channel topic: ${subTopic(topic)}`;
11
+ const subTopic = (topic) => topic.replace(/^realtime:/, "");
12
+ /**
13
+ * A private channel, as Supabase Realtime Authorization checks one: its reads of broadcasts, and of presence when it
14
+ * listens to presence, are checked by `authorize`, and each write the first time the channel sends it after that, and
15
+ * kept until its next token. A policy that fails, such as one calling a function that raises, allows nothing, and its
16
+ * error goes to `report`.
17
+ */
18
+ const privateGate = (access, topic, presence, report) => {
19
+ let reads = /* @__PURE__ */ new Map();
20
+ let writes = /* @__PURE__ */ new Map();
21
+ const denied = (nothing) => (error) => {
22
+ if (!isDatabaseError(error)) throw error;
23
+ report(error);
24
+ return nothing;
25
+ };
26
+ return {
27
+ authorize: async (caller) => {
28
+ const before = reads;
29
+ reads = await access.read(caller, subTopic(topic), presence ? ["broadcast", "presence"] : ["broadcast"]).catch(denied(/* @__PURE__ */ new Map()));
30
+ writes = /* @__PURE__ */ new Map();
31
+ if (![...reads.values()].some(Boolean)) return "denied";
32
+ if ([...before].some(([extension, allowed]) => allowed && !reads.get(extension))) return "revoked";
33
+ },
34
+ hears: (extension) => reads.get(extension) === true,
35
+ mayWrite: async (caller, extension) => {
36
+ const known = writes.get(extension);
37
+ if (known !== void 0) return known;
38
+ return access.write(caller, subTopic(topic), extension).then((allowed) => {
39
+ writes.set(extension, allowed);
40
+ return allowed;
41
+ }, denied(false));
42
+ }
43
+ };
44
+ };
45
+ //#endregion
46
+ export { privateGate, publicGate, readRefusalText };
@@ -1,10 +1,12 @@
1
1
  import { changeChecker } from "./change-checker.js";
2
2
  import { changeHub } from "./change-hub.js";
3
+ import { channelAccess } from "./channel-access.js";
3
4
  import { realtimeRoutes } from "./realtime-routes.js";
4
5
  //#region src/realtime/realtime-app.ts
5
6
  /**
6
7
  * `/api/sb/realtime/v1`: the `postgres_changes` of the live resources, each checked under the subscriber's role, claims and
7
- * the tables' RLS policies before it is sent, and broadcasts and presence between the channels of the process.
8
+ * the tables' RLS policies before it is sent, and broadcasts and presence between the channels of the process, on a
9
+ * private channel as the policies on `realtime.messages` allow.
8
10
  */
9
11
  const realtimeApp = ({ deps, feed, upgradeWebSocket, caller, report }) => {
10
12
  const live = deps.registry.entries.filter((entry) => entry.model.live);
@@ -19,11 +21,16 @@ const realtimeApp = ({ deps, feed, upgradeWebSocket, caller, report }) => {
19
21
  entry: (resource) => live.find((entry) => entry.name === resource),
20
22
  report
21
23
  });
24
+ const access = channelAccess({
25
+ db: deps.db,
26
+ statementTimeoutMs: deps.statementTimeoutMs
27
+ });
22
28
  return realtimeRoutes({
23
29
  upgradeWebSocket,
24
30
  caller,
25
31
  live,
26
32
  hub,
33
+ access,
27
34
  report
28
35
  });
29
36
  };
@@ -16,7 +16,7 @@ const keyRequest = (apikey, token) => new Request("http://realtime.invalid/", {
16
16
  * key is refused before the upgrade, with 401. A socket whose raw socket is not one of npm `ws`, such as Bun's own `ws`
17
17
  * shim's, is closed at once with 1011, since its unsent bytes cannot be counted (`realtimeLimits.maxUnsentBytes`).
18
18
  */
19
- const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, report }) => {
19
+ const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, access, report }) => {
20
20
  const app = new Hono();
21
21
  const broadcasts = broadcastHub();
22
22
  const presences = presenceHub();
@@ -45,6 +45,7 @@ const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, report }) => {
45
45
  hub,
46
46
  broadcasts,
47
47
  presences,
48
+ access,
48
49
  nextId,
49
50
  report
50
51
  });
@@ -4,6 +4,7 @@ import { fixedWindow, fixedWindows } from "./fixed-window.js";
4
4
  import { realtimeLimits } from "./realtime-limits.js";
5
5
  import { decodeMessage, encodeMessage, reply } from "./phoenix-message.js";
6
6
  import { decodeBroadcastPush, encodeBroadcast, fitsFrame } from "./broadcast-frame.js";
7
+ import { privateGate, publicGate, readRefusalText } from "./channel-gate.js";
7
8
  import { randomUUID } from "node:crypto";
8
9
  import { Buffer } from "node:buffer";
9
10
  //#region src/realtime/realtime-socket.ts
@@ -26,7 +27,8 @@ const refusal = (error) => {
26
27
  * One WebSocket of `/api/sb/realtime/v1`, speaking Phoenix channels as realtime-js expects: heartbeats, channel joins with
27
28
  * `postgres_changes` bindings and an access token, token refreshes on an open channel, broadcasts with their `self`
28
29
  * and `ack` options, presence tracking, and leaves. A channel's token ends it when it expires, unless a newer one came
29
- * first. Messages are handled one at a time, in order. Private channels are refused for now.
30
+ * first. Messages are handled one at a time, in order. A private channel joins, hears, broadcasts and tracks presence
31
+ * only as the policies on `realtime.messages` allow (see `privateGate`), checked again at each new token.
30
32
  *
31
33
  * It holds a client to `realtimeLimits`: a push over a rate, a channel over the cap or a presence meta too big gets an
32
34
  * error reply where Supabase would close the channel, and is not relayed. Rates count per socket, presence per socket
@@ -35,7 +37,7 @@ const refusal = (error) => {
35
37
  * earlier messages wait. A close, the client's or the server's, leaves every channel, a join still checking its token
36
38
  * included, and drops whatever is still queued.
37
39
  */
38
- const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadcasts, presences, nextId, report, idleMs = 6e4, now = Date.now }) => {
40
+ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadcasts, presences, access, nextId, report, idleMs = 6e4, now = Date.now }) => {
39
41
  const channels = /* @__PURE__ */ new Map();
40
42
  let queue = Promise.resolve();
41
43
  let queuedMessages = 0;
@@ -71,6 +73,7 @@ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadc
71
73
  payload
72
74
  });
73
75
  const hear = (topic, channel, event, payload) => {
76
+ if (!channel.gate.hears("broadcast")) return;
74
77
  if (vsn === "2.0.0") return deliver(encodeBroadcast(topic, event, payload));
75
78
  if (payload instanceof ArrayBuffer) return;
76
79
  toChannel(topic, channel, "broadcast", {
@@ -109,12 +112,16 @@ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadc
109
112
  if (!channels.has(message.topic) && channels.size >= realtimeLimits.channelsPerSocket) return push(reply(message, "error", { reason: "ChannelRateLimitReached: Too many channels" }));
110
113
  leave(message.topic);
111
114
  const config = isRecord(message.payload.config) ? message.payload.config : {};
112
- if (config.private === true) return push(reply(message, "error", { reason: "Not implemented: private channels" }));
113
115
  const broadcast = isRecord(config.broadcast) ? config.broadcast : {};
114
116
  const presence = isRecord(config.presence) ? config.presence : {};
117
+ const listens = presence.enabled === true;
118
+ const gate = config.private === true ? privateGate(access, message.topic, listens, report) : publicGate;
115
119
  const caller = await resolve(tokenOf(message.payload)).catch(refusal);
116
120
  if (!open) return;
117
121
  if (typeof caller === "string") return push(reply(message, "error", { reason: caller }));
122
+ const refused = await gate.authorize(caller);
123
+ if (!open) return;
124
+ if (refused) return push(reply(message, "error", { reason: `Unauthorized: ${readRefusalText(message.topic, refused)}` }));
118
125
  const parsed = parseBindings(config.postgres_changes, live, nextId);
119
126
  if (typeof parsed === "string") return push(reply(message, "error", { reason: parsed }));
120
127
  const { topic } = message;
@@ -123,10 +130,11 @@ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadc
123
130
  receive: (event, payload) => hear(topic, channel, event, payload)
124
131
  };
125
132
  const key = typeof presence.key === "string" && presence.key !== "" ? presence.key : randomUUID();
126
- const listens = presence.enabled === true;
127
133
  const seat = presences.join(topic, {
128
134
  key,
129
- listens,
135
+ get listens() {
136
+ return listens && gate.hears("presence");
137
+ },
130
138
  receive: (diff) => toChannel(topic, channel, "presence_diff", diff)
131
139
  });
132
140
  const offBroadcast = broadcasts.join(topic, member);
@@ -138,6 +146,7 @@ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadc
138
146
  const channel = {
139
147
  joinRef: message.joinRef,
140
148
  caller,
149
+ gate,
141
150
  ack: broadcast.ack === true,
142
151
  member,
143
152
  presence: seat,
@@ -150,7 +159,7 @@ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadc
150
159
  channels.set(topic, channel);
151
160
  expireAt(topic, channel);
152
161
  push(reply(message, "ok", { postgres_changes: parsed.replies }));
153
- if (listens) toChannel(topic, channel, "presence_state", seat.state());
162
+ if (listens && gate.hears("presence")) toChannel(topic, channel, "presence_state", seat.state());
154
163
  if (parsed.bindings.length > 0) toChannel(message.topic, channel, "system", {
155
164
  extension: "postgres_changes",
156
165
  status: "ok",
@@ -163,25 +172,37 @@ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadc
163
172
  const channel = channels.get(message.topic);
164
173
  if (!channel) return;
165
174
  if (typeof caller === "string") return end(message.topic, caller);
175
+ const refused = await channel.gate.authorize(caller);
176
+ if (channels.get(message.topic) !== channel) return;
177
+ if (refused) return end(message.topic, readRefusalText(message.topic, refused));
166
178
  channel.caller = caller;
167
179
  expireAt(message.topic, channel);
168
180
  };
169
- const sendBroadcast = (message, channel) => {
181
+ const sendBroadcast = async (message, channel) => {
170
182
  const { event, payload } = message.payload;
171
183
  if (!events()) return push(reply(message, "error", { reason: tooFast }));
172
184
  if (typeof event !== "string") return push(reply(message, "error", { reason: "A broadcast needs an event" }));
173
185
  if (!fitsFrame(message.topic) || !fitsFrame(event)) return push(reply(message, "error", { reason: "A broadcast topic and event are at most 255 bytes each" }));
186
+ const allowed = await channel.gate.mayWrite(channel.caller, "broadcast");
187
+ if (channels.get(message.topic) !== channel) return;
188
+ if (!allowed) return push(reply(message, "error", { error: "unauthorized" }));
174
189
  if (!broadcasts.send(message.topic, channel.member, event, payload)) return push(reply(message, "error", { reason: `${tooFast} on this topic` }));
175
190
  if (channel.ack) push(reply(message, "ok", {}));
176
191
  };
177
- const updatePresence = (message, channel) => {
192
+ const updatePresence = async (message, channel) => {
178
193
  const { event, payload } = message.payload;
179
194
  if (!events()) return push(reply(message, "error", { reason: tooFast }));
180
195
  if (!presenceRate(message.topic)) return push(reply(message, "error", { reason: "Client presence rate limit exceeded" }));
181
- if (event === "track" && isRecord(payload) && Buffer.byteLength(JSON.stringify(payload)) > realtimeLimits.maxPresenceBytes) return push(reply(message, "error", { reason: "Track message size exceeded" }));
182
- if (event === "track" && isRecord(payload)) channel.presence.track(payload);
183
- else if (event === "untrack") channel.presence.untrack();
184
- else return push(reply(message, "error", { reason: "A presence message tracks an object or untracks" }));
196
+ if (event === "untrack") {
197
+ channel.presence.untrack();
198
+ return push(reply(message, "ok", {}));
199
+ }
200
+ if (event !== "track" || !isRecord(payload)) return push(reply(message, "error", { reason: "A presence message tracks an object or untracks" }));
201
+ if (Buffer.byteLength(JSON.stringify(payload)) > realtimeLimits.maxPresenceBytes) return push(reply(message, "error", { reason: "Track message size exceeded" }));
202
+ const allowed = await channel.gate.mayWrite(channel.caller, "presence");
203
+ if (channels.get(message.topic) !== channel) return;
204
+ if (!allowed) return push(reply(message, "error", {}));
205
+ channel.presence.track(payload);
185
206
  push(reply(message, "ok", {}));
186
207
  };
187
208
  const handle = async (message) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.49.2",
3
+ "version": "0.50.1",
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": {
@@ -40,9 +40,9 @@
40
40
  "@better-auth/passkey": "^1.7.12",
41
41
  "@better-auth/sso": "^1.7.12",
42
42
  "@modelcontextprotocol/server": "^2.3.1",
43
- "@protobase/layout": "0.49.2",
44
- "@protobase/query": "0.49.2",
45
- "@protobase/schema": "0.49.2",
43
+ "@protobase/layout": "0.50.1",
44
+ "@protobase/query": "0.50.1",
45
+ "@protobase/schema": "0.50.1",
46
46
  "@react-email/render": "^2.1.0",
47
47
  "@scalar/hono-api-reference": "^0.12.10",
48
48
  "@sentry/core": "^11.7.0",