@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
|
@@ -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.
|
|
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 === "
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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.
|
|
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.
|
|
44
|
-
"@protobase/query": "0.
|
|
45
|
-
"@protobase/schema": "0.
|
|
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",
|