@protobase/server 0.25.11 → 0.26.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/admin-options.d.ts +7 -0
- package/dist/better-auth/app-tokens.js +2 -2
- package/dist/better-auth/authenticator.js +1 -1
- package/dist/better-auth/create-auth.d.ts +121 -2
- package/dist/better-auth/create-auth.js +70 -51
- package/dist/better-auth/email-confirmed.js +31 -0
- package/dist/better-auth/organizations/accept-invitation.js +1 -1
- package/dist/better-auth/organizations/app-roles.js +1 -1
- package/dist/better-auth/organizations/options.js +1 -1
- package/dist/better-auth/organizations/organization-plugin.js +1 -1
- package/dist/better-auth/organizations/session-organization.js +1 -1
- package/dist/better-auth/passkey-plugin.js +1 -1
- package/dist/better-auth/policy-plugin.js +27 -12
- package/dist/better-auth/roles/active-role.js +1 -1
- package/dist/better-auth/sign-up/sign-up-account.js +26 -0
- package/dist/better-auth/sign-up/sign-up-plugin.js +6 -14
- package/dist/better-auth/staff-plugin.js +1 -1
- package/dist/better-auth/staff-sign-in-endpoints.js +3 -3
- package/dist/better-auth/status-route.js +1 -1
- package/dist/better-auth/user-table.d.ts +1 -0
- package/dist/better-auth/user-table.js +1 -0
- package/dist/better-auth/users/user-endpoints.js +1 -1
- package/dist/create-admin.d.ts +10 -1
- package/dist/create-admin.js +26 -2
- package/dist/gotrue/access-tokens.js +15 -0
- package/dist/gotrue/auth-codes.js +33 -0
- package/dist/gotrue/bearer.js +22 -0
- package/dist/gotrue/deps.js +8 -0
- package/dist/gotrue/email-code-routes.js +121 -0
- package/dist/gotrue/errors.js +52 -0
- package/dist/gotrue/forward.js +45 -0
- package/dist/gotrue/keys.js +14 -0
- package/dist/gotrue/pending-sign-up.js +26 -0
- package/dist/gotrue/provider-routes.js +79 -0
- package/dist/gotrue/request-body.js +22 -0
- package/dist/gotrue/routes.js +32 -0
- package/dist/gotrue/session-json.js +34 -0
- package/dist/gotrue/sessions-plugin.js +83 -0
- package/dist/gotrue/token-route.js +42 -0
- package/dist/gotrue/user-json.js +46 -0
- package/dist/gotrue/user-routes.js +99 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +7 -7
- package/dist/live/notices-sql.js +4 -2
- package/dist/realtime/change-checker.js +102 -0
- package/dist/realtime/change-filter.js +106 -0
- package/dist/realtime/change-hub.js +100 -0
- package/dist/realtime/change-payload.js +31 -0
- package/dist/realtime/phoenix-message.js +63 -0
- package/dist/realtime/postgres-changes.js +62 -0
- package/dist/realtime/realtime-app.js +31 -0
- package/dist/realtime/realtime-routes.js +47 -0
- package/dist/realtime/realtime-socket.js +135 -0
- package/dist/rest/caller-settings.js +27 -0
- package/package.json +10 -5
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { callerSettings } from "../rest/caller-settings.js";
|
|
2
|
+
import { changePayload, eventOf } from "./change-payload.js";
|
|
3
|
+
//#region src/realtime/change-hub.ts
|
|
4
|
+
const unique = (items) => [...new Set(items)];
|
|
5
|
+
const watches = (binding, entry) => binding.entries.includes(entry);
|
|
6
|
+
const groupChecks = (sinks, changes) => unique(changes.map((change) => change.entry)).map((entry) => {
|
|
7
|
+
const bindings = sinks.flatMap((sink) => sink.bindings).filter((binding) => watches(binding, entry));
|
|
8
|
+
const of = changes.filter((change) => change.entry === entry);
|
|
9
|
+
return {
|
|
10
|
+
entry,
|
|
11
|
+
bindings,
|
|
12
|
+
filters: [...new Map(bindings.filter((binding) => binding.conditions.length > 0).map((binding) => [binding.filter, binding.conditions])).entries()],
|
|
13
|
+
keys: unique(of.filter((change) => change.type !== "DELETE").map((change) => change.notice.key)),
|
|
14
|
+
deleted: unique(of.filter((change) => change.type === "DELETE").map((change) => change.notice.key))
|
|
15
|
+
};
|
|
16
|
+
}).filter((check) => check.bindings.length > 0);
|
|
17
|
+
/**
|
|
18
|
+
* Where the open sockets of a process hear of changes: it follows the notice feed while any channel listens, and
|
|
19
|
+
* checks each batch of notices for all channels at once, one batch at a time, so Realtime holds at most one pooled
|
|
20
|
+
* connection per process, and only while a check runs. Channels whose callers have the same role and claims, such as a
|
|
21
|
+
* user's tabs, are checked as one.
|
|
22
|
+
*/
|
|
23
|
+
const changeHub = ({ feed, check, entry, report }) => {
|
|
24
|
+
const sinks = /* @__PURE__ */ new Set();
|
|
25
|
+
let subscription;
|
|
26
|
+
let pending = [];
|
|
27
|
+
let running = false;
|
|
28
|
+
const deliver = async (notices) => {
|
|
29
|
+
const changes = notices.flatMap((notice) => {
|
|
30
|
+
const found = entry(notice.resource);
|
|
31
|
+
return found ? [{
|
|
32
|
+
notice,
|
|
33
|
+
entry: found,
|
|
34
|
+
type: eventOf(found, notice)
|
|
35
|
+
}] : [];
|
|
36
|
+
});
|
|
37
|
+
if (changes.length === 0 || sinks.size === 0) return;
|
|
38
|
+
const groups = /* @__PURE__ */ new Map();
|
|
39
|
+
for (const sink of sinks) {
|
|
40
|
+
const key = JSON.stringify(callerSettings(sink.caller()));
|
|
41
|
+
groups.set(key, [...groups.get(key) ?? [], sink]);
|
|
42
|
+
}
|
|
43
|
+
const planned = [...groups.values()].map((members) => ({
|
|
44
|
+
members,
|
|
45
|
+
checks: groupChecks(members, changes)
|
|
46
|
+
})).filter((group) => group.checks.length > 0);
|
|
47
|
+
const views = await check(planned.map(({ members, checks }) => ({
|
|
48
|
+
caller: members[0].caller(),
|
|
49
|
+
resources: checks.map(({ entry: checked, keys, deleted, filters }) => ({
|
|
50
|
+
entry: checked,
|
|
51
|
+
keys,
|
|
52
|
+
deleted,
|
|
53
|
+
filters: filters.map(([, conditions]) => conditions)
|
|
54
|
+
}))
|
|
55
|
+
})));
|
|
56
|
+
for (const { notice, entry: changed, type } of changes) planned.forEach(({ members, checks }, groupIndex) => {
|
|
57
|
+
const resourceIndex = checks.findIndex((item) => item.entry === changed);
|
|
58
|
+
if (resourceIndex < 0) return;
|
|
59
|
+
const view = views[groupIndex][resourceIndex];
|
|
60
|
+
const row = (type === "DELETE" ? view.deleted : view.rows).get(notice.key);
|
|
61
|
+
if (!row) return;
|
|
62
|
+
const { filters } = checks[resourceIndex];
|
|
63
|
+
for (const sink of members) {
|
|
64
|
+
if (!sinks.has(sink)) continue;
|
|
65
|
+
const ids = sink.bindings.filter((binding) => watches(binding, changed) && (binding.event === "*" || binding.event === type)).filter((binding) => binding.conditions.length === 0 || row.matches[filters.findIndex(([filter]) => filter === binding.filter)]).map((binding) => binding.id);
|
|
66
|
+
if (ids.length > 0) sink.send(changePayload(changed, notice, type, view.columns, row, ids));
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
const run = async () => {
|
|
71
|
+
running = true;
|
|
72
|
+
while (pending.length > 0) {
|
|
73
|
+
const batch = pending;
|
|
74
|
+
pending = [];
|
|
75
|
+
await deliver(batch).catch(report);
|
|
76
|
+
}
|
|
77
|
+
running = false;
|
|
78
|
+
};
|
|
79
|
+
const listen = (notices) => {
|
|
80
|
+
pending.push(...notices);
|
|
81
|
+
if (!running) run();
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
/** Sends the sink the changes it may see from now on, until the returned function removes it. */
|
|
85
|
+
add: (sink) => {
|
|
86
|
+
sinks.add(sink);
|
|
87
|
+
subscription ??= feed.subscribe(listen).catch((error) => {
|
|
88
|
+
report(error);
|
|
89
|
+
subscription = void 0;
|
|
90
|
+
});
|
|
91
|
+
return () => {
|
|
92
|
+
sinks.delete(sink);
|
|
93
|
+
if (sinks.size > 0 || !subscription) return;
|
|
94
|
+
subscription.then((subscribed) => subscribed?.stop());
|
|
95
|
+
subscription = void 0;
|
|
96
|
+
};
|
|
97
|
+
} };
|
|
98
|
+
};
|
|
99
|
+
//#endregion
|
|
100
|
+
export { changeHub };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { schemaOf } from "./postgres-changes.js";
|
|
2
|
+
//#region src/realtime/change-payload.ts
|
|
3
|
+
/**
|
|
4
|
+
* What a notice is to a database client: a record created is an INSERT; one updated, undeleted or soft deleted, whose
|
|
5
|
+
* row is still there, an UPDATE; one whose row is gone a DELETE.
|
|
6
|
+
*/
|
|
7
|
+
const eventOf = (entry, notice) => {
|
|
8
|
+
if (notice.operation === "create") return "INSERT";
|
|
9
|
+
if (notice.operation === "delete" && !entry.model.softDelete) return "DELETE";
|
|
10
|
+
return "UPDATE";
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* The change for bindings `ids`. An insert or update carries the row as it is now, several changes since the last
|
|
14
|
+
* poll showing its latest state each time, and `old_record` its key alone, as Supabase does without replica identity
|
|
15
|
+
* full. A delete carries the key alone. `commit_timestamp` is when the change became a notice.
|
|
16
|
+
*/
|
|
17
|
+
const changePayload = (entry, notice, type, columns, row, ids) => ({
|
|
18
|
+
ids,
|
|
19
|
+
data: {
|
|
20
|
+
schema: schemaOf(entry),
|
|
21
|
+
table: entry.model.table.name,
|
|
22
|
+
commit_timestamp: notice.at,
|
|
23
|
+
type,
|
|
24
|
+
columns,
|
|
25
|
+
...type !== "DELETE" && { record: row.record },
|
|
26
|
+
...type !== "INSERT" && { old_record: row.oldRecord },
|
|
27
|
+
errors: null
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
//#endregion
|
|
31
|
+
export { changePayload, eventOf };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
//#region src/realtime/phoenix-message.ts
|
|
2
|
+
const vsnOf = (value) => {
|
|
3
|
+
if (value === void 0 || value === "2.0.0") return "2.0.0";
|
|
4
|
+
return value === "1.0.0" ? "1.0.0" : void 0;
|
|
5
|
+
};
|
|
6
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
const text = (value) => typeof value === "string" ? value : value === null || value === void 0 ? null : String(value);
|
|
8
|
+
/** A text frame as a message; `undefined` for one that is not a Phoenix message. Binary frames carry broadcasts only. */
|
|
9
|
+
const decodeMessage = (vsn, frame) => {
|
|
10
|
+
let parsed;
|
|
11
|
+
try {
|
|
12
|
+
parsed = JSON.parse(frame);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (error instanceof SyntaxError) return void 0;
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
if (vsn === "2.0.0") {
|
|
18
|
+
if (!Array.isArray(parsed) || parsed.length !== 5) return void 0;
|
|
19
|
+
const [joinRef, ref, topic, event, payload] = parsed;
|
|
20
|
+
if (typeof topic !== "string" || typeof event !== "string" || !isRecord(payload)) return void 0;
|
|
21
|
+
return {
|
|
22
|
+
joinRef: text(joinRef),
|
|
23
|
+
ref: text(ref),
|
|
24
|
+
topic,
|
|
25
|
+
event,
|
|
26
|
+
payload
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
if (!isRecord(parsed) || typeof parsed.topic !== "string" || typeof parsed.event !== "string" || !isRecord(parsed.payload)) return void 0;
|
|
30
|
+
return {
|
|
31
|
+
joinRef: text(parsed.join_ref),
|
|
32
|
+
ref: text(parsed.ref),
|
|
33
|
+
topic: parsed.topic,
|
|
34
|
+
event: parsed.event,
|
|
35
|
+
payload: parsed.payload
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
const encodeMessage = (vsn, message) => JSON.stringify(vsn === "2.0.0" ? [
|
|
39
|
+
message.joinRef,
|
|
40
|
+
message.ref,
|
|
41
|
+
message.topic,
|
|
42
|
+
message.event,
|
|
43
|
+
message.payload
|
|
44
|
+
] : {
|
|
45
|
+
join_ref: message.joinRef,
|
|
46
|
+
ref: message.ref,
|
|
47
|
+
topic: message.topic,
|
|
48
|
+
event: message.event,
|
|
49
|
+
payload: message.payload
|
|
50
|
+
});
|
|
51
|
+
/** The answer to a pushed message: `phx_reply` with its ref. */
|
|
52
|
+
const reply = (to, status, response) => ({
|
|
53
|
+
joinRef: to.joinRef,
|
|
54
|
+
ref: to.ref,
|
|
55
|
+
topic: to.topic,
|
|
56
|
+
event: "phx_reply",
|
|
57
|
+
payload: {
|
|
58
|
+
status,
|
|
59
|
+
response
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
//#endregion
|
|
63
|
+
export { decodeMessage, encodeMessage, reply, vsnOf };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { parseFilter } from "./change-filter.js";
|
|
2
|
+
//#region src/realtime/postgres-changes.ts
|
|
3
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4
|
+
const optionalText = (value) => typeof value === "string" && value !== "" ? value : void 0;
|
|
5
|
+
const eventOf = (value) => {
|
|
6
|
+
const upper = typeof value === "string" ? value.toUpperCase() : "*";
|
|
7
|
+
return upper === "INSERT" || upper === "UPDATE" || upper === "DELETE" ? upper : "*";
|
|
8
|
+
};
|
|
9
|
+
/** The schema a resource's table is in, `public` when it names none. */
|
|
10
|
+
const schemaOf = (entry) => entry.model.table.schema ?? "public";
|
|
11
|
+
const columnsOf = (entry) => new Set(Object.values(entry.model.fields).map((field) => field.column));
|
|
12
|
+
const unavailable = (schema, table, filter) => `Unable to subscribe to changes with given parameters. Please check Realtime is enabled for the given connect parameters: [schema: ${schema}, table: ${table ?? "*"}, filters: ${filter ?? "[]"}]`;
|
|
13
|
+
/**
|
|
14
|
+
* The bindings a join asks for, each over the live resources whose table it names: by schema (`*` for any), table
|
|
15
|
+
* (any when left out or `*`), event (`*` for all) and filter. A binding whose table is not live, a filter that does not
|
|
16
|
+
* parse or names a column the table has not, and a `select` of columns are refused with Supabase's message, as an error
|
|
17
|
+
* text.
|
|
18
|
+
*/
|
|
19
|
+
const parseBindings = (requested, live, nextId) => {
|
|
20
|
+
if (requested === void 0) return {
|
|
21
|
+
bindings: [],
|
|
22
|
+
replies: []
|
|
23
|
+
};
|
|
24
|
+
if (!Array.isArray(requested)) return "postgres_changes must be a list";
|
|
25
|
+
const bindings = [];
|
|
26
|
+
const replies = [];
|
|
27
|
+
for (const item of requested) {
|
|
28
|
+
if (!isRecord(item)) return "Each postgres_changes binding must be an object";
|
|
29
|
+
const schema = optionalText(item.schema) ?? "public";
|
|
30
|
+
const table = optionalText(item.table);
|
|
31
|
+
const filter = optionalText(item.filter);
|
|
32
|
+
if (item.select !== void 0) return "Not implemented: postgres_changes select; leave it out to receive every column";
|
|
33
|
+
const entries = live.filter((entry) => (schema === "*" || schemaOf(entry) === schema) && (table === void 0 || table === "*" || entry.model.table.name === table));
|
|
34
|
+
if (entries.length === 0) return unavailable(schema, table, filter);
|
|
35
|
+
const conditions = parseFilter(filter);
|
|
36
|
+
if (typeof conditions === "string") return conditions;
|
|
37
|
+
const missing = conditions.find((condition) => entries.some((entry) => !columnsOf(entry).has(condition.column)));
|
|
38
|
+
if (missing) return `invalid column for filter ${missing.column}`;
|
|
39
|
+
const id = nextId();
|
|
40
|
+
const event = eventOf(item.event);
|
|
41
|
+
bindings.push({
|
|
42
|
+
id,
|
|
43
|
+
event,
|
|
44
|
+
filter,
|
|
45
|
+
conditions,
|
|
46
|
+
entries
|
|
47
|
+
});
|
|
48
|
+
replies.push({
|
|
49
|
+
id,
|
|
50
|
+
event: typeof item.event === "string" ? item.event : "*",
|
|
51
|
+
schema,
|
|
52
|
+
...table !== void 0 && { table },
|
|
53
|
+
...filter !== void 0 && { filter }
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
bindings,
|
|
58
|
+
replies
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
//#endregion
|
|
62
|
+
export { parseBindings, schemaOf };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { changeChecker } from "./change-checker.js";
|
|
2
|
+
import { changeHub } from "./change-hub.js";
|
|
3
|
+
import { realtimeRoutes } from "./realtime-routes.js";
|
|
4
|
+
//#region src/realtime/realtime-app.ts
|
|
5
|
+
/**
|
|
6
|
+
* `/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.
|
|
8
|
+
*/
|
|
9
|
+
const realtimeApp = ({ deps, feed, upgradeWebSocket, caller, report }) => {
|
|
10
|
+
const live = deps.registry.entries.filter((entry) => entry.model.live);
|
|
11
|
+
const check = changeChecker({
|
|
12
|
+
db: deps.db,
|
|
13
|
+
statementTimeoutMs: deps.statementTimeoutMs,
|
|
14
|
+
report
|
|
15
|
+
});
|
|
16
|
+
const hub = changeHub({
|
|
17
|
+
feed,
|
|
18
|
+
check,
|
|
19
|
+
entry: (resource) => live.find((entry) => entry.name === resource),
|
|
20
|
+
report
|
|
21
|
+
});
|
|
22
|
+
return realtimeRoutes({
|
|
23
|
+
upgradeWebSocket,
|
|
24
|
+
caller,
|
|
25
|
+
live,
|
|
26
|
+
hub,
|
|
27
|
+
report
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
//#endregion
|
|
31
|
+
export { realtimeApp };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { HttpProblem } from "../problem.js";
|
|
2
|
+
import { vsnOf } from "./phoenix-message.js";
|
|
3
|
+
import { realtimeSocket } from "./realtime-socket.js";
|
|
4
|
+
import { Hono } from "hono";
|
|
5
|
+
//#region src/realtime/realtime-routes.ts
|
|
6
|
+
const keyRequest = (apikey, token) => new Request("http://realtime.invalid/", { headers: {
|
|
7
|
+
apikey,
|
|
8
|
+
...token !== void 0 && { authorization: `Bearer ${token}` }
|
|
9
|
+
} });
|
|
10
|
+
/**
|
|
11
|
+
* `GET /realtime/v1/websocket?apikey=…&vsn=…`: Supabase Realtime's socket, for `postgres_changes` of the live
|
|
12
|
+
* resources (see `realtimeSocket`). A missing or wrong key is refused before the upgrade, with 401.
|
|
13
|
+
*/
|
|
14
|
+
const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, report }) => {
|
|
15
|
+
const app = new Hono();
|
|
16
|
+
let lastId = 0;
|
|
17
|
+
const nextId = () => ++lastId;
|
|
18
|
+
app.get("/websocket", async (c, next) => {
|
|
19
|
+
if (!vsnOf(c.req.query("vsn"))) throw new HttpProblem(400, "invalid-request", "Bad Request", "Use vsn 1.0.0 or 2.0.0");
|
|
20
|
+
await caller(keyRequest(c.req.query("apikey") ?? ""));
|
|
21
|
+
await next();
|
|
22
|
+
}, upgradeWebSocket((c) => {
|
|
23
|
+
const apikey = c.req.query("apikey");
|
|
24
|
+
let socket;
|
|
25
|
+
return {
|
|
26
|
+
onOpen: (_event, ws) => {
|
|
27
|
+
socket = realtimeSocket({
|
|
28
|
+
vsn: vsnOf(c.req.query("vsn")),
|
|
29
|
+
send: (frame) => ws.send(frame),
|
|
30
|
+
close: () => ws.close(),
|
|
31
|
+
resolve: (token) => caller(keyRequest(apikey, token)),
|
|
32
|
+
live,
|
|
33
|
+
hub,
|
|
34
|
+
nextId,
|
|
35
|
+
report
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
onMessage: (event) => {
|
|
39
|
+
if (typeof event.data === "string") socket?.message(event.data);
|
|
40
|
+
},
|
|
41
|
+
onClose: () => socket?.closed()
|
|
42
|
+
};
|
|
43
|
+
}));
|
|
44
|
+
return app;
|
|
45
|
+
};
|
|
46
|
+
//#endregion
|
|
47
|
+
export { realtimeRoutes };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { HttpProblem } from "../problem.js";
|
|
2
|
+
import { parseBindings } from "./postgres-changes.js";
|
|
3
|
+
import { decodeMessage, encodeMessage, reply } from "./phoenix-message.js";
|
|
4
|
+
//#region src/realtime/realtime-socket.ts
|
|
5
|
+
const tokenOf = (payload) => typeof payload.access_token === "string" && payload.access_token !== "" ? payload.access_token : void 0;
|
|
6
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
const refusal = (error) => {
|
|
8
|
+
if (error instanceof HttpProblem && error.status === 401) return error.detail;
|
|
9
|
+
throw error;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* One WebSocket of `/realtime/v1`, speaking Phoenix channels as realtime-js expects: heartbeats, channel joins with
|
|
13
|
+
* `postgres_changes` bindings and an access token, token refreshes on an open channel, and leaves. A channel's token
|
|
14
|
+
* ends it when it expires, unless a newer one came first. Messages are handled one at a time, in order. Broadcast,
|
|
15
|
+
* presence and private channels are refused for now.
|
|
16
|
+
*/
|
|
17
|
+
const realtimeSocket = ({ vsn, send, close, resolve, live, hub, nextId, report, idleMs = 6e4, now = Date.now }) => {
|
|
18
|
+
const channels = /* @__PURE__ */ new Map();
|
|
19
|
+
let queue = Promise.resolve();
|
|
20
|
+
let idle;
|
|
21
|
+
const push = (message) => send(encodeMessage(vsn, message));
|
|
22
|
+
const toChannel = (topic, channel, event, payload) => push({
|
|
23
|
+
joinRef: channel.joinRef,
|
|
24
|
+
ref: null,
|
|
25
|
+
topic,
|
|
26
|
+
event,
|
|
27
|
+
payload
|
|
28
|
+
});
|
|
29
|
+
const leave = (topic) => {
|
|
30
|
+
const channel = channels.get(topic);
|
|
31
|
+
if (!channel) return;
|
|
32
|
+
channel.remove();
|
|
33
|
+
clearTimeout(channel.expiry);
|
|
34
|
+
channels.delete(topic);
|
|
35
|
+
};
|
|
36
|
+
const end = (topic, message) => {
|
|
37
|
+
const channel = channels.get(topic);
|
|
38
|
+
if (!channel) return;
|
|
39
|
+
toChannel(topic, channel, "system", {
|
|
40
|
+
extension: "system",
|
|
41
|
+
status: "error",
|
|
42
|
+
message
|
|
43
|
+
});
|
|
44
|
+
toChannel(topic, channel, "phx_close", {});
|
|
45
|
+
leave(topic);
|
|
46
|
+
};
|
|
47
|
+
const expireAt = (topic, channel) => {
|
|
48
|
+
clearTimeout(channel.expiry);
|
|
49
|
+
const exp = channel.caller.role === "authenticated" ? channel.caller.claims.exp : void 0;
|
|
50
|
+
if (exp === void 0) return;
|
|
51
|
+
channel.expiry = setTimeout(() => end(topic, "Token has expired"), Math.max(0, exp * 1e3 - now()));
|
|
52
|
+
};
|
|
53
|
+
const join = async (message) => {
|
|
54
|
+
leave(message.topic);
|
|
55
|
+
const config = isRecord(message.payload.config) ? message.payload.config : {};
|
|
56
|
+
if (config.private === true) return push(reply(message, "error", { reason: "Not implemented: private channels" }));
|
|
57
|
+
if (isRecord(config.presence) && config.presence.enabled === true) return push(reply(message, "error", { reason: "Not implemented: presence" }));
|
|
58
|
+
const caller = await resolve(tokenOf(message.payload)).catch(refusal);
|
|
59
|
+
if (typeof caller === "string") return push(reply(message, "error", { reason: caller }));
|
|
60
|
+
const parsed = parseBindings(config.postgres_changes, live, nextId);
|
|
61
|
+
if (typeof parsed === "string") return push(reply(message, "error", { reason: parsed }));
|
|
62
|
+
const channel = {
|
|
63
|
+
joinRef: message.joinRef,
|
|
64
|
+
caller,
|
|
65
|
+
remove: () => {}
|
|
66
|
+
};
|
|
67
|
+
if (parsed.bindings.length > 0) channel.remove = hub.add({
|
|
68
|
+
caller: () => channel.caller,
|
|
69
|
+
bindings: parsed.bindings,
|
|
70
|
+
send: (payload) => toChannel(message.topic, channel, "postgres_changes", payload)
|
|
71
|
+
});
|
|
72
|
+
channels.set(message.topic, channel);
|
|
73
|
+
expireAt(message.topic, channel);
|
|
74
|
+
push(reply(message, "ok", { postgres_changes: parsed.replies }));
|
|
75
|
+
if (parsed.bindings.length > 0) toChannel(message.topic, channel, "system", {
|
|
76
|
+
extension: "postgres_changes",
|
|
77
|
+
status: "ok",
|
|
78
|
+
message: "Subscribed to PostgreSQL",
|
|
79
|
+
channel: message.topic.replace(/^realtime:/, "")
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
const refresh = async (message) => {
|
|
83
|
+
const caller = await resolve(tokenOf(message.payload)).catch(refusal);
|
|
84
|
+
const channel = channels.get(message.topic);
|
|
85
|
+
if (!channel) return;
|
|
86
|
+
if (typeof caller === "string") return end(message.topic, caller);
|
|
87
|
+
channel.caller = caller;
|
|
88
|
+
expireAt(message.topic, channel);
|
|
89
|
+
};
|
|
90
|
+
const handle = async (message) => {
|
|
91
|
+
if (message.topic === "phoenix") {
|
|
92
|
+
if (message.event === "heartbeat") push(reply(message, "ok", {}));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (message.event === "phx_join") return join(message);
|
|
96
|
+
if (!channels.has(message.topic)) return push(reply(message, "error", { reason: "unmatched topic" }));
|
|
97
|
+
if (message.event === "access_token") return refresh(message);
|
|
98
|
+
if (message.event === "phx_leave") {
|
|
99
|
+
leave(message.topic);
|
|
100
|
+
push(reply(message, "ok", {}));
|
|
101
|
+
return push({
|
|
102
|
+
joinRef: message.joinRef,
|
|
103
|
+
ref: message.ref,
|
|
104
|
+
topic: message.topic,
|
|
105
|
+
event: "phx_close",
|
|
106
|
+
payload: {}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
push(reply(message, "error", { reason: `Not implemented: ${message.event}; this server has postgres_changes only` }));
|
|
110
|
+
};
|
|
111
|
+
const stayAwake = () => {
|
|
112
|
+
clearTimeout(idle);
|
|
113
|
+
idle = setTimeout(close, idleMs);
|
|
114
|
+
};
|
|
115
|
+
stayAwake();
|
|
116
|
+
return {
|
|
117
|
+
/** Handles a text frame after those before it. */
|
|
118
|
+
message: (frame) => {
|
|
119
|
+
stayAwake();
|
|
120
|
+
const message = decodeMessage(vsn, frame);
|
|
121
|
+
if (!message) return;
|
|
122
|
+
queue = queue.then(() => handle(message)).catch((error) => {
|
|
123
|
+
report(error);
|
|
124
|
+
close();
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
/** Ends every channel of a socket that closed. */
|
|
128
|
+
closed: () => {
|
|
129
|
+
clearTimeout(idle);
|
|
130
|
+
for (const topic of [...channels.keys()]) leave(topic);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
//#endregion
|
|
135
|
+
export { realtimeSocket };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
//#region src/rest/caller-settings.ts
|
|
2
|
+
/**
|
|
3
|
+
* The transaction settings a `/rest/v1` request runs with, as Supabase's PostgREST sets them: `role`, which is
|
|
4
|
+
* `SET LOCAL ROLE`, to `anon` or `authenticated`, and the caller's claims in `request.jwt.claims` and its user in
|
|
5
|
+
* `request.jwt.claim.sub`, which `auth.uid()`, `auth.role()` and `auth.jwt()` read. The `role` claim names the
|
|
6
|
+
* database role, as on Supabase, whatever the token's own says. The app's role, of the secret key, sets no role: the
|
|
7
|
+
* connection's own owns the tables. A signed-in user is the actor change capture records, when `actorSetting` is set.
|
|
8
|
+
*/
|
|
9
|
+
const callerSettings = (caller, actorSetting) => {
|
|
10
|
+
const role = caller.role === "app" ? "service_role" : caller.role;
|
|
11
|
+
const sub = caller.role === "authenticated" ? caller.claims.sub : void 0;
|
|
12
|
+
const actor = sub === void 0 ? void 0 : {
|
|
13
|
+
id: sub,
|
|
14
|
+
roles: []
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
...caller.role !== "app" && { role: caller.role },
|
|
18
|
+
"request.jwt.claims": JSON.stringify({
|
|
19
|
+
...caller.claims,
|
|
20
|
+
role
|
|
21
|
+
}),
|
|
22
|
+
...sub !== void 0 && { "request.jwt.claim.sub": sub },
|
|
23
|
+
...actor && actorSetting && { [actorSetting]: JSON.stringify(actor) }
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
//#endregion
|
|
27
|
+
export { callerSettings };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.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": {
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@better-auth/passkey": "^1.7.7",
|
|
34
34
|
"@better-auth/sso": "^1.7.7",
|
|
35
|
-
"@protobase/layout": "0.
|
|
36
|
-
"@protobase/query": "0.
|
|
37
|
-
"@protobase/schema": "0.
|
|
35
|
+
"@protobase/layout": "0.26.0",
|
|
36
|
+
"@protobase/query": "0.26.0",
|
|
37
|
+
"@protobase/schema": "0.26.0",
|
|
38
38
|
"@react-email/render": "^2.1.0",
|
|
39
39
|
"@scalar/hono-api-reference": "^0.12.10",
|
|
40
40
|
"@sentry/core": "^11.7.0",
|
|
@@ -65,6 +65,11 @@
|
|
|
65
65
|
}
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
|
-
"
|
|
68
|
+
"@hono/node-server": "^2.1.3",
|
|
69
|
+
"@supabase/ssr": "^0.12.7",
|
|
70
|
+
"@supabase/supabase-js": "^2.118.0",
|
|
71
|
+
"@types/ws": "^8.18.2",
|
|
72
|
+
"samlify": "^2.13.1",
|
|
73
|
+
"ws": "^8.22.0"
|
|
69
74
|
}
|
|
70
75
|
}
|