@protobase/server 0.58.7 → 0.59.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 (45) hide show
  1. package/dist/assistant/query-runner/query-runner.d.ts +7 -2
  2. package/dist/assistant/query-runner/query-runner.js +8 -4
  3. package/dist/assistant/query-runner/runner-plan.js +4 -4
  4. package/dist/better-auth/auth-settings.d.ts +1 -0
  5. package/dist/better-auth/auth-tables.d.ts +13 -6
  6. package/dist/better-auth/auth-tables.js +9 -6
  7. package/dist/better-auth/create-auth.d.ts +1 -0
  8. package/dist/better-auth/store/resources-outside.d.ts +4 -1
  9. package/dist/better-auth/store/resources-outside.js +8 -3
  10. package/dist/better-auth/user-table.d.ts +6 -0
  11. package/dist/better-auth/user-table.js +3 -2
  12. package/dist/channels/channel-bounds.js +17 -0
  13. package/dist/channels/channel-name.d.ts +1 -0
  14. package/dist/channels/channel-name.js +30 -0
  15. package/dist/channels/channel-protocol.js +33 -0
  16. package/dist/channels/channel-refusals.js +26 -0
  17. package/dist/channels/channel-routes.js +98 -0
  18. package/dist/channels/channel-sender.d.ts +20 -0
  19. package/dist/channels/channel-sender.js +39 -0
  20. package/dist/channels/channel-socket.js +253 -0
  21. package/dist/channels/presence-entries.js +24 -0
  22. package/dist/channels/record-readable.js +28 -0
  23. package/dist/channels/sent.js +8 -0
  24. package/dist/create-admin.js +22 -2
  25. package/dist/functions/define-function.d.ts +3 -0
  26. package/dist/functions/routes.js +3 -1
  27. package/dist/index.d.ts +2 -1
  28. package/dist/package.js +1 -1
  29. package/dist/realtime/broadcast-hub.js +1 -1
  30. package/dist/realtime/broadcast-request.js +1 -1
  31. package/dist/realtime/database-broadcasts.js +1 -1
  32. package/dist/realtime/realtime-app.js +2 -18
  33. package/dist/realtime/realtime-hubs.js +30 -0
  34. package/dist/realtime/realtime-socket.js +2 -2
  35. package/dist/realtime/relay/realtime-options.d.ts +1 -1
  36. package/dist/realtime/relay/realtime-relay.js +2 -2
  37. package/dist/realtime/relay/relay-frame-sql.js +7 -4
  38. package/dist/realtime/relay/relay-presence-sql.js +13 -13
  39. package/dist/realtime/relay/relay-schema-sql.js +11 -2
  40. package/dist/realtime/relay/relay-schema.d.ts +1 -1
  41. package/dist/realtime/relay/relay-schema.js +4 -2
  42. package/dist/realtime/relay/relayed-hubs.js +63 -40
  43. package/dist/webhooks/attempt.js +1 -0
  44. package/dist/webhooks/webhooks.js +2 -1
  45. package/package.json +4 -4
@@ -6,16 +6,21 @@ export type TableName = {
6
6
  schema?: string;
7
7
  name: string;
8
8
  };
9
+ /** The tables the role must never read: these, and every table in `schemas`, whatever its name. */
10
+ export type GuardedTables = {
11
+ tables: readonly TableName[];
12
+ schemas: readonly string[];
13
+ };
9
14
  /**
10
15
  * The SQL that makes the role the assistant's read-only queries run as, and its runner, and grants it exactly the
11
16
  * resources' tables, never an auth table; `undefined` when the database is current.
12
17
  */
13
- export declare const queryRunnerMigration: (db: Db, models: readonly ResourceModel[], authTables: readonly TableName[]) => Promise<string | undefined>;
18
+ export declare const queryRunnerMigration: (db: Db, models: readonly ResourceModel[], authTables: GuardedTables) => Promise<string | undefined>;
14
19
  /**
15
20
  * Refuses to go on while the runner of the assistant's read-only queries is missing or out of date, or runs as a role
16
21
  * that is not weaker than the app's: the app's role itself or one it is a member of, a superuser, a login role, or one
17
22
  * that can read an auth table, or while the role may call a security-definer function that runs as a role able to
18
23
  * read one. It checks the owner's privileges, not its name, so a database cloned under another name still passes. A resource added since the last migration is not checked: Postgres refuses to read its table until then.
19
24
  */
20
- export declare const checkQueryRunner: (db: Db, authTables: readonly TableName[]) => Promise<void>;
25
+ export declare const checkQueryRunner: (db: Db, authTables: GuardedTables) => Promise<void>;
21
26
  //#endregion
@@ -8,6 +8,8 @@ const quoted = (name) => `"${name.replaceAll("\"", "\"\"")}"`;
8
8
  const regclassText = ({ schema, name }) => schema ? `${quoted(schema)}.${quoted(name)}` : quoted(name);
9
9
  const roleSql = sql.raw(queryRoleSql);
10
10
  const rowsOf = async (db, query) => (await query.execute(db)).rows;
11
+ const guardedRelations = ({ tables, schemas }) => sql`select c.oid, c.relnamespace from pg_class c join pg_namespace n on n.oid = c.relnamespace
12
+ where c.oid in (select to_regclass(t.name) from unnest(${tables.map(regclassText)}::text[]) as t(name)) or n.nspname = any(${[...schemas]}::text[])`;
11
13
  const resolved = (db, tables) => rowsOf(db, sql`select n.nspname as schema, c.relname as name from unnest(${tables.map(regclassText)}::text[]) with ordinality as t(name, n)
12
14
  join pg_class c on c.oid = to_regclass(t.name) join pg_namespace n on n.oid = c.relnamespace order by t.n`);
13
15
  const readRunner = async (db) => {
@@ -27,7 +29,8 @@ const readQueryRunner = async (db, models, authTables) => {
27
29
  role,
28
30
  runner: await readRunner(db),
29
31
  resources: await resolved(db, models.map((model) => model.table)),
30
- authTables: await resolved(db, authTables),
32
+ authTables: await resolved(db, authTables.tables),
33
+ guardedSchemas: [...authTables.schemas],
31
34
  granted: {
32
35
  tables,
33
36
  schemas: schemas.map((found) => found.schema)
@@ -39,10 +42,11 @@ const readQueryRunner = async (db, models, authTables) => {
39
42
  * resources' tables, never an auth table; `undefined` when the database is current.
40
43
  */
41
44
  const queryRunnerMigration = async (db, models, authTables) => {
42
- const { role, runner, resources, authTables: auth, granted } = await readQueryRunner(db, models, authTables);
45
+ const { role, runner, resources, authTables: auth, guardedSchemas, granted } = await readQueryRunner(db, models, authTables);
43
46
  const plan = plannedGrants({
44
47
  resources,
45
48
  authTables: auth,
49
+ guardedSchemas,
46
50
  granted
47
51
  });
48
52
  return role.exists && runner?.source === runnerBody && runner.definer && runner.owner === role.name && !runner.executable && plan.grants.length === 0 && plan.revokes.length === 0 ? void 0 : queryRunnerSql(plan);
@@ -53,7 +57,7 @@ const leakingFunctions = (db, authTables) => rowsOf(db, sql`with runner as (sele
53
57
  from pg_proc p join pg_namespace n on n.oid = p.pronamespace, runner
54
58
  where p.prosecdef and p.prorettype <> 'trigger'::regtype and p.proowner <> runner.role
55
59
  and has_schema_privilege(runner.role, p.pronamespace, 'USAGE') and has_function_privilege(runner.role, p.oid, 'EXECUTE')
56
- and exists (select from unnest(${authTables.map(regclassText)}::text[]) as t(name) join pg_class c on c.oid = to_regclass(t.name)
60
+ and exists (select from (${guardedRelations(authTables)}) c
57
61
  where has_schema_privilege(p.proowner, c.relnamespace, 'USAGE') and has_any_column_privilege(p.proowner, c.oid, 'SELECT'))
58
62
  order by 1`);
59
63
  /**
@@ -65,7 +69,7 @@ const leakingFunctions = (db, authTables) => rowsOf(db, sql`with runner as (sele
65
69
  const checkQueryRunner = async (db, authTables) => {
66
70
  const [runner] = await rowsOf(db, sql`select p.prosrc as source, p.prosecdef as definer,
67
71
  (o.rolsuper or o.rolcanlogin or pg_has_role(p.proowner, current_user, 'MEMBER')) as strong,
68
- exists (select from unnest(${authTables.map(regclassText)}::text[]) as t(name) join pg_class c on c.oid = to_regclass(t.name)
72
+ exists (select from (${guardedRelations(authTables)}) c
69
73
  where has_schema_privilege(p.proowner, c.relnamespace, 'USAGE') and has_any_column_privilege(p.proowner, c.oid, 'SELECT')) as reads_auth
70
74
  from pg_proc p join pg_roles o on o.oid = p.proowner where p.oid = to_regprocedure(${queryRunnerSignature})`);
71
75
  if (!runner || !runner.definer || runner.source !== runnerBody) throw refusal("is missing");
@@ -3,11 +3,11 @@ const same = (a, b) => a.schema === b.schema && a.name === b.name;
3
3
  const unique = (items, equal) => items.filter((item, index) => items.findIndex((other) => equal(item, other)) === index);
4
4
  /**
5
5
  * What the query role must be given and what taken back so that it reads exactly the resources' tables, never an auth
6
- * table, with usage on the schemas those are in. `resources` and `authTables` are the tables that exist, `granted`
7
- * what the role holds now.
6
+ * table nor any table of a guarded schema, with usage on the schemas those are in. `resources` and `authTables` are the
7
+ * tables that exist, `granted` what the role holds now.
8
8
  */
9
- const plannedGrants = ({ resources, authTables, granted }) => {
10
- const tables = unique(resources.filter((table) => !authTables.some((auth) => same(table, auth))), same);
9
+ const plannedGrants = ({ resources, authTables, guardedSchemas, granted }) => {
10
+ const tables = unique(resources.filter((table) => !guardedSchemas.includes(table.schema) && !authTables.some((auth) => same(table, auth))), same);
11
11
  const schemas = [...new Set(tables.map((table) => table.schema))];
12
12
  return {
13
13
  grants: [...schemas.filter((schema) => !granted.schemas.includes(schema)).map((schema) => ({
@@ -8823,6 +8823,7 @@ export declare const buildAppAuth: (settings: AuthSettings, input: AppAuthInput)
8823
8823
  schema: string;
8824
8824
  table: string;
8825
8825
  } | undefined;
8826
+ guardedSchemas: string[];
8826
8827
  };
8827
8828
  socialProviders: string[];
8828
8829
  appName: string;
@@ -1,12 +1,19 @@
1
1
  import { AdminAuth } from "./create-auth.js";
2
2
  //#region src/better-auth/auth-tables.d.ts
3
+ /** A table as config names it: the search path decides when it names no schema. */
4
+ export type TableName = {
5
+ schema?: string;
6
+ name: string;
7
+ };
8
+ /** The tables the assistant's queries and the app's resources must never read: these, and every table in `schemas`. */
9
+ export type AuthStoreTables = {
10
+ tables: TableName[];
11
+ schemas: string[];
12
+ };
3
13
  /**
4
14
  * The admin store's tables, which the assistant's queries and the app's resources must never read: the user table and
5
- * `storeTableNames`, and with `users.keyTable` the table under the user view too, such as Supabase's `auth.users`,
6
- * whose password hashes the view leaves out but the table holds.
15
+ * `storeTableNames`, with `users.keyTable` the table under the user view too, such as Supabase's `auth.users`, whose
16
+ * password hashes the view leaves out but the table holds, and every table of `users.guardedSchemas`.
7
17
  */
8
- export declare const authStoreTables: (auth: AdminAuth) => {
9
- schema: string | undefined;
10
- name: string;
11
- }[];
18
+ export declare const authStoreTables: (auth: AdminAuth) => AuthStoreTables;
12
19
  //#endregion
@@ -3,8 +3,8 @@ import { storeTableNames } from "./store/table-names.js";
3
3
  //#region src/better-auth/auth-tables.ts
4
4
  /**
5
5
  * The admin store's tables, which the assistant's queries and the app's resources must never read: the user table and
6
- * `storeTableNames`, and with `users.keyTable` the table under the user view too, such as Supabase's `auth.users`,
7
- * whose password hashes the view leaves out but the table holds.
6
+ * `storeTableNames`, with `users.keyTable` the table under the user view too, such as Supabase's `auth.users`, whose
7
+ * password hashes the view leaves out but the table holds, and every table of `users.guardedSchemas`.
8
8
  */
9
9
  const authStoreTables = (auth) => {
10
10
  const { schema, users } = storeNames(auth);
@@ -12,10 +12,13 @@ const authStoreTables = (auth) => {
12
12
  schema,
13
13
  name
14
14
  }));
15
- return users.keyTable ? [...own, {
16
- schema: users.keyTable.schema,
17
- name: users.keyTable.table
18
- }] : own;
15
+ return {
16
+ tables: users.keyTable ? [...own, {
17
+ schema: users.keyTable.schema,
18
+ name: users.keyTable.table
19
+ }] : own,
20
+ schemas: users.guardedSchemas
21
+ };
19
22
  };
20
23
  //#endregion
21
24
  export { authStoreTables };
@@ -8962,6 +8962,7 @@ export declare const createAuth: (options: CreateAuthOptions, plugins?: AuthPlug
8962
8962
  schema: string;
8963
8963
  table: string;
8964
8964
  } | undefined;
8965
+ guardedSchemas: string[];
8965
8966
  };
8966
8967
  socialProviders: string[];
8967
8968
  /** The app's name it signs in and mails as: `PROTOBASE_APP_NAME`, else `appName`, else `Protobase`. */
@@ -2,6 +2,9 @@ import { AdminAuth } from "../create-auth.js";
2
2
  import { ResourceModel } from "@protobase/schema";
3
3
  import { Kysely } from "kysely";
4
4
  //#region src/better-auth/store/resources-outside.d.ts
5
- /** Refuses a resource over a table of the admin store: the user table, a feature's table or the record of migrations. */
5
+ /**
6
+ * Refuses a resource over a table of the admin store: the user table, a feature's table or the record of migrations,
7
+ * or any table of a guarded schema (`users.guardedSchemas`), by the schema the catalogue finds the table in.
8
+ */
6
9
  export declare const checkResourcesOutsideAuthStore: (db: Kysely<any>, models: readonly ResourceModel[], auth: AdminAuth) => Promise<void>;
7
10
  //#endregion
@@ -6,12 +6,17 @@ const quoted = (name) => `"${name.replaceAll("\"", "\"\"")}"`;
6
6
  const resolve = async (db, tables) => (await sql`select t.n::int as n, ns.nspname as schema, c.relname as name, current_database() as database
7
7
  from unnest(${tables.map(({ schema, name }) => schema ? `${quoted(schema)}.${quoted(name)}` : quoted(name))}::text[]) with ordinality as t(name, n)
8
8
  join pg_class c on c.oid = to_regclass(t.name) join pg_namespace ns on ns.oid = c.relnamespace order by t.n`.execute(db)).rows;
9
- /** Refuses a resource over a table of the admin store: the user table, a feature's table or the record of migrations. */
9
+ /**
10
+ * Refuses a resource over a table of the admin store: the user table, a feature's table or the record of migrations,
11
+ * or any table of a guarded schema (`users.guardedSchemas`), by the schema the catalogue finds the table in.
12
+ */
10
13
  const checkResourcesOutsideAuthStore = async (db, models, auth) => {
11
14
  if (models.length === 0) return;
12
- const store = await resolve(storeDb(auth), authStoreTables(auth));
15
+ const { tables, schemas } = authStoreTables(auth);
16
+ const store = await resolve(storeDb(auth), tables);
13
17
  const same = (a, b) => a.database === b.database && a.schema === b.schema && a.name === b.name;
14
- const over = (await resolve(db, models.map((model) => model.table))).filter((table) => store.some((own) => same(own, table)));
18
+ const guarded = (table) => schemas.includes(table.schema) || store.some((own) => same(own, table));
19
+ const over = (await resolve(db, models.map((model) => model.table))).filter(guarded);
15
20
  if (over.length === 0) return;
16
21
  const named = over.map((table) => `${models[table.n - 1].name} (${table.schema}.${table.name})`).join(", ");
17
22
  throw new Error(`Resources over the auth store's tables, which hold password hashes, sessions and tokens: ${named}. Remove them from the config; the auth store is read through Better Auth alone.`);
@@ -37,5 +37,11 @@ export type UserTableOptions = {
37
37
  schema: string;
38
38
  table: string;
39
39
  };
40
+ /**
41
+ * Schemas the other system that made the user table keeps its sign-in data in, such as `['auth']` for Supabase Auth's
42
+ * identities, sessions, refresh tokens and two-factor secrets: every table in them, today's or one added later,
43
+ * counts among the store's tables. Name only a schema that holds none of the app's own tables.
44
+ */
45
+ guardedSchemas?: string[];
40
46
  };
41
47
  //#endregion
@@ -17,7 +17,7 @@ const userFields = {
17
17
  last_organization_id: "lastOrganizationId"
18
18
  };
19
19
  /** The user table's names, from `auth.users` option; unknown columns are an error. */
20
- const userTable = ({ table = "user", columns = {}, metadata, keyTable } = {}) => {
20
+ const userTable = ({ table = "user", columns = {}, metadata, keyTable, guardedSchemas = [] } = {}) => {
21
21
  const unknown = Object.keys(columns).find((name) => !(name in userFields));
22
22
  if (unknown !== void 0) throw new Error(`users.columns names "${unknown}", which is not a column of the user table: ${Object.keys(userFields).join(", ")}`);
23
23
  const column = (name) => columns[name] ?? name;
@@ -26,7 +26,8 @@ const userTable = ({ table = "user", columns = {}, metadata, keyTable } = {}) =>
26
26
  column,
27
27
  fieldNames: Object.fromEntries(Object.entries(userFields).map(([name, field]) => [field, column(name)])),
28
28
  metadata,
29
- keyTable
29
+ keyTable,
30
+ guardedSchemas
30
31
  };
31
32
  };
32
33
  /** Better Auth's `user` option: the table and its core columns. */
@@ -0,0 +1,17 @@
1
+ //#region src/channels/channel-bounds.ts
2
+ /**
3
+ * The bounds of `/api/channels` that its limits (`realtimeLimits`, the same as `/api/sb/realtime/v1`'s) do not give,
4
+ * each no looser than its counterpart there.
5
+ */
6
+ const channelBounds = {
7
+ /** How often the client says it is there; `@protobase/client` sends a ping this often. */
8
+ pingMs: 5e3,
9
+ /** How long a connection may stay silent before it is closed and its presences leave: 60 s on `/api/sb/realtime/v1`. */
10
+ idleMs: 12e3,
11
+ /** How often a connection's session, and its read access to each channel's record, are checked again. */
12
+ recheckMs: 1e4,
13
+ /** How long a send trusts the last check that the caller may read the record. */
14
+ readCacheMs: 5e3
15
+ };
16
+ //#endregion
17
+ export { channelBounds };
@@ -0,0 +1 @@
1
+ import "../registry.js";
@@ -0,0 +1,30 @@
1
+ import { fitsFrame } from "../realtime/broadcast-frame.js";
2
+ import { decodeKey, encodeKey } from "@protobase/schema/internal";
3
+ //#region src/channels/channel-name.ts
4
+ /**
5
+ * The record `resource` and `key` name, with the key's own text in the name, so `01` and `1` of an integer key name
6
+ * one channel. `undefined` for a resource the app does not have or a key that is not one of its keys, which a join
7
+ * refuses as it refuses a record the caller may not read; `'too-long'` for a name over 255 bytes.
8
+ */
9
+ const channelRecord = (registry, resource, key) => {
10
+ const entry = registry.find(resource);
11
+ if (!entry) return;
12
+ const parsed = decodedKey(entry, key);
13
+ if (!parsed) return;
14
+ const name = `${resource}/${encodeKey(parsed)}`;
15
+ return fitsFrame(name) ? {
16
+ entry,
17
+ key: parsed,
18
+ name
19
+ } : "too-long";
20
+ };
21
+ const decodedKey = (entry, key) => {
22
+ try {
23
+ const decoded = decodeKey(key, entry.keyTypes);
24
+ return Array.isArray(decoded) ? decoded : [decoded];
25
+ } catch {
26
+ return;
27
+ }
28
+ };
29
+ //#endregion
30
+ export { channelRecord };
@@ -0,0 +1,33 @@
1
+ //#region src/channels/channel-protocol.ts
2
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3
+ const text = (value) => typeof value === "string";
4
+ /** The message a text frame carries, or `undefined` for one that is not JSON or not a message. */
5
+ const decodeClientMessage = (frame) => {
6
+ const message = parsed(frame);
7
+ if (!isRecord(message)) return;
8
+ if (message.ref !== void 0 && !text(message.ref)) return;
9
+ const { type } = message;
10
+ if (type === "ping") return { type };
11
+ if (type === "token") return text(message.token) ? {
12
+ type,
13
+ token: message.token
14
+ } : void 0;
15
+ if (type === "join") return text(message.resource) && text(message.key) ? message : void 0;
16
+ if (!text(message.channel)) return;
17
+ if (type === "leave" || type === "untrack") return message;
18
+ if (type === "track") return "state" in message ? message : void 0;
19
+ if (type !== "send" || !text(message.event) || !("payload" in message)) return;
20
+ if (message.self !== void 0 && typeof message.self !== "boolean") return;
21
+ if (message.ack !== void 0 && typeof message.ack !== "boolean") return;
22
+ return message;
23
+ };
24
+ const parsed = (frame) => {
25
+ try {
26
+ return JSON.parse(frame);
27
+ } catch (error) {
28
+ if (error instanceof SyntaxError) return;
29
+ throw error;
30
+ }
31
+ };
32
+ //#endregion
33
+ export { decodeClientMessage };
@@ -0,0 +1,26 @@
1
+ import { realtimeLimits } from "../realtime/realtime-limits.js";
2
+ //#region src/channels/channel-refusals.ts
3
+ const refusal = (code, message) => ({
4
+ code,
5
+ message
6
+ });
7
+ /**
8
+ * Every refusal of `/api/channels`. A record the caller may not read is refused as one that does not exist, so a
9
+ * channel never tells which records there are.
10
+ */
11
+ const channelRefusals = {
12
+ notFound: refusal("not-found", "There is no such record"),
13
+ invalidMessage: refusal("invalid-message", "Send a JSON text frame of a ping, token, join, leave, track, untrack or send"),
14
+ notJoined: refusal("not-joined", "Join the channel first"),
15
+ alreadyJoined: refusal("already-joined", "This connection has joined the channel already"),
16
+ nameTooLong: refusal("name-too-long", "A channel name, its resource, a slash and its key, is at most 255 bytes"),
17
+ eventTooLong: refusal("event-too-long", "An event name is at most 255 bytes"),
18
+ tooManyJoins: refusal("too-many-joins", `Too many joins: at most ${realtimeLimits.joinsPerSecond} a second`),
19
+ tooManyChannels: refusal("too-many-channels", `Too many channels: at most ${realtimeLimits.channelsPerSocket} at once on a connection`),
20
+ tooManyMessages: refusal("too-many-messages", `Too many messages: at most ${realtimeLimits.eventsPerSecond} broadcasts and presence updates a second`),
21
+ channelTooBusy: refusal("channel-too-busy", `Too many broadcasts on this channel: at most ${realtimeLimits.topicBroadcastsPerSecond} a second, every sender's together`),
22
+ tooManyPresenceUpdates: refusal("too-many-presence-updates", `Too many presence updates: at most ${realtimeLimits.presencePerWindow} every ${realtimeLimits.presenceWindowMs / 1e3} seconds on a channel`),
23
+ presenceTooLarge: refusal("presence-too-large", `A tracked state is at most ${realtimeLimits.maxPresenceBytes} bytes of JSON`)
24
+ };
25
+ //#endregion
26
+ export { channelRefusals };
@@ -0,0 +1,98 @@
1
+ import { HttpProblem, notFound } from "../problem.js";
2
+ import { realtimeLimits } from "../realtime/realtime-limits.js";
3
+ import { bufferedOf } from "../realtime/buffered-amount.js";
4
+ import { channelRecord } from "./channel-name.js";
5
+ import { broadcastRecord, publishTo } from "./channel-sender.js";
6
+ import { channelSocket } from "./channel-socket.js";
7
+ import { recordReadable } from "./record-readable.js";
8
+ import { Hono } from "hono";
9
+ import { bodyLimit } from "hono/body-limit";
10
+ //#region src/channels/channel-routes.ts
11
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
12
+ const bearerOf = (request) => request.headers.get("sec-websocket-protocol")?.split(",").map((part) => part.trim()).find((part) => part.startsWith("bearer."))?.slice(7);
13
+ const withBearer = (request, token) => {
14
+ if (token === void 0 || request.headers.has("authorization")) return request;
15
+ const headers = new Headers(request.headers);
16
+ headers.set("authorization", `Bearer ${token}`);
17
+ return new Request(request, { headers });
18
+ };
19
+ const invalidBody = () => new HttpProblem(422, "invalid-broadcast", "Unprocessable Content", "Send { \"event\": \"…\", \"payload\": … } as application/json");
20
+ /**
21
+ * `GET /channels`, a WebSocket of channels named by records (see `channelSocket`), refused before the upgrade like
22
+ * any request its caller cannot make. `POST /channels/:resource/:key/broadcast` sends one broadcast without a socket,
23
+ * as the caller, when they may read the record.
24
+ */
25
+ const channelRoutes = ({ deps, upgradeWebSocket, authenticate, authenticateUpgrade, onError, hubs, follow, report }) => {
26
+ const app = new Hono();
27
+ app.onError(onError);
28
+ const readable = (session, record) => recordReadable(deps, session, record);
29
+ app.get("/channels", async (c, next) => {
30
+ c.set("session", await authenticateUpgrade(withBearer(c.req.raw, bearerOf(c.req.raw))));
31
+ await next();
32
+ }, upgradeWebSocket((c) => {
33
+ const request = c.req.raw;
34
+ const opened = bearerOf(request);
35
+ let socket;
36
+ let unfollow = () => {};
37
+ const closed = () => {
38
+ socket?.closed();
39
+ unfollow();
40
+ };
41
+ return {
42
+ onOpen: (_event, ws) => {
43
+ if (bufferedOf(ws.raw) === void 0) {
44
+ report(/* @__PURE__ */ new Error("Not implemented: channels need WebSockets of npm ws, which count unsent bytes; on Bun, load npm ws by its path rather than Bun's ws shim, or run on Node"));
45
+ return ws.close(1011);
46
+ }
47
+ unfollow = follow();
48
+ socket = channelSocket({
49
+ send: (frame) => ws.send(frame),
50
+ buffered: () => bufferedOf(ws.raw),
51
+ close: (code) => ws.close(code),
52
+ session: c.get("session"),
53
+ reauthenticate: async (token) => authenticateUpgrade(withBearer(request, token ?? opened)),
54
+ record: (resource, key) => channelRecord(deps.registry, resource, key),
55
+ readable,
56
+ broadcasts: hubs.broadcasts,
57
+ presences: hubs.presences,
58
+ report
59
+ });
60
+ },
61
+ onMessage: (event) => socket?.message(event.data),
62
+ onError: closed,
63
+ onClose: closed
64
+ };
65
+ }));
66
+ app.post("/channels/:resource/:key/broadcast", async (c, next) => {
67
+ c.set("session", await authenticate(c.req.raw));
68
+ await next();
69
+ }, bodyLimit({
70
+ maxSize: realtimeLimits.maxFrameBytes,
71
+ onError: () => {
72
+ throw new HttpProblem(413, "too-large", "Payload Too Large", `A broadcast request is at most ${realtimeLimits.maxFrameBytes} bytes`);
73
+ }
74
+ }), async (c) => {
75
+ const body = await c.req.json().catch((error) => {
76
+ if (error instanceof SyntaxError) throw invalidBody();
77
+ throw error;
78
+ });
79
+ if (!isRecord(body) || typeof body.event !== "string" || !("payload" in body)) throw invalidBody();
80
+ const message = {
81
+ resource: c.req.param("resource"),
82
+ key: c.req.param("key"),
83
+ event: body.event,
84
+ payload: body.payload
85
+ };
86
+ const record = broadcastRecord(deps.registry, message);
87
+ const session = c.get("session");
88
+ if (!await recordReadable(deps, session, record)) throw notFound("There is no such record");
89
+ await publishTo(hubs.broadcasts, record, message.event, {
90
+ user: session.anonymous ? null : session.user.id,
91
+ payload: message.payload
92
+ });
93
+ return c.body(null, 202);
94
+ });
95
+ return app;
96
+ };
97
+ //#endregion
98
+ export { channelRoutes };
@@ -0,0 +1,20 @@
1
+ import "../registry.js";
2
+ import "./channel-name.js";
3
+ //#region src/channels/channel-sender.d.ts
4
+ /** A broadcast to the channel of a record, from outside a connection. */
5
+ export type ChannelBroadcast = {
6
+ resource: string;
7
+ key: string;
8
+ event: string;
9
+ payload: unknown;
10
+ };
11
+ /** What the app's own code broadcasts to record channels with, such as a function's `channels`. */
12
+ export type ChannelSender = {
13
+ /**
14
+ * Sends to every connection on the record's channel, of every process, as from the server (`server: true`, no
15
+ * user); nothing is stored, so with nobody on it, it reaches nobody. Rejects with a 404 problem for a record name
16
+ * the app does not have, 422 for an event over 255 bytes or a payload over a frame, 429 over the channel's rate.
17
+ */
18
+ broadcast: (message: ChannelBroadcast) => Promise<void>;
19
+ };
20
+ //#endregion
@@ -0,0 +1,39 @@
1
+ import { HttpProblem, notFound } from "../problem.js";
2
+ import { fitsFrame } from "../realtime/broadcast-frame.js";
3
+ import { realtimeLimits } from "../realtime/realtime-limits.js";
4
+ import { channelRecord } from "./channel-name.js";
5
+ import { channelRefusals } from "./channel-refusals.js";
6
+ import { Buffer } from "node:buffer";
7
+ //#region src/channels/channel-sender.ts
8
+ const invalid = (refusal) => new HttpProblem(422, refusal.code, "Unprocessable Content", refusal.message);
9
+ /** The record a broadcast names, its event and payload checked, or a problem. */
10
+ const broadcastRecord = (registry, { resource, key, event, payload }) => {
11
+ const found = channelRecord(registry, resource, key);
12
+ if (found === "too-long") throw invalid(channelRefusals.nameTooLong);
13
+ if (!found) throw notFound(channelRefusals.notFound.message);
14
+ if (!fitsFrame(event)) throw invalid(channelRefusals.eventTooLong);
15
+ const json = JSON.stringify(payload);
16
+ if (json === void 0 || Buffer.byteLength(json) > realtimeLimits.maxFrameBytes) throw invalid({
17
+ code: "invalid-payload",
18
+ message: `A payload is JSON of at most ${realtimeLimits.maxFrameBytes} bytes`
19
+ });
20
+ return found;
21
+ };
22
+ /** Publishes a checked broadcast, refused with 429 when the channel took its broadcasts for this second. */
23
+ const publishTo = async (broadcasts, record, event, sent) => {
24
+ if (await broadcasts.publish([{
25
+ topic: record.name,
26
+ event,
27
+ payload: sent,
28
+ private: false
29
+ }])) return;
30
+ throw new HttpProblem(429, channelRefusals.channelTooBusy.code, "Too Many Requests", channelRefusals.channelTooBusy.message);
31
+ };
32
+ /** The server's own sender over the record channels' hub. */
33
+ const channelSender = (registry, broadcasts) => ({ broadcast: async (message) => publishTo(broadcasts, broadcastRecord(registry, message), message.event, {
34
+ user: null,
35
+ server: true,
36
+ payload: message.payload
37
+ }) });
38
+ //#endregion
39
+ export { broadcastRecord, channelSender, publishTo };