@voidbase-cloud/voidbase 0.1.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 (134) hide show
  1. package/.env.example +9 -0
  2. package/CHANGELOG.md +19 -0
  3. package/COMPAT.md +43 -0
  4. package/LICENSE +21 -0
  5. package/NOTICE +8 -0
  6. package/README.md +124 -0
  7. package/bin/voidbase.ts +158 -0
  8. package/crons/every-minute.ts +13 -0
  9. package/db/migrations/20260905175935_large_swarm.sql +87 -0
  10. package/db/migrations/20260905185720_wild_sunspot.sql +16 -0
  11. package/db/migrations/20260905190723_solid_toro.sql +1 -0
  12. package/db/migrations/20260905213340_remarkable_union_jack.sql +11 -0
  13. package/db/migrations/meta/20260905175935_snapshot.json +599 -0
  14. package/db/migrations/meta/20260905185720_snapshot.json +703 -0
  15. package/db/migrations/meta/20260905190723_snapshot.json +710 -0
  16. package/db/migrations/meta/20260905213340_snapshot.json +781 -0
  17. package/db/migrations/meta/_journal.json +34 -0
  18. package/db/schema.ts +130 -0
  19. package/docs/deploy.md +153 -0
  20. package/docs/differences.md +88 -0
  21. package/docs/hooks.md +84 -0
  22. package/docs/migrating.md +29 -0
  23. package/docs/perf.md +53 -0
  24. package/docs/platform.md +208 -0
  25. package/docs/releasing.md +38 -0
  26. package/env.ts +23 -0
  27. package/hooks-plugin.ts +237 -0
  28. package/package.json +134 -0
  29. package/queues/jobs.ts +13 -0
  30. package/routes/api/[...path].ts +19 -0
  31. package/scripts/bench-realtime.ts +46 -0
  32. package/scripts/bench.ts +39 -0
  33. package/scripts/ci-suites.sh +27 -0
  34. package/scripts/dev.sh +29 -0
  35. package/scripts/export.ts +70 -0
  36. package/scripts/seed-app-user.sh +14 -0
  37. package/scripts/seed-d1.ts +17 -0
  38. package/scripts/seed-reference.sh +29 -0
  39. package/scripts/starter.sh +22 -0
  40. package/scripts/sync-app.ts +22 -0
  41. package/scripts/sync-panel.ts +66 -0
  42. package/src/cloud/rest.ts +297 -0
  43. package/src/node/assets.ts +22 -0
  44. package/src/node/bundle.ts +88 -0
  45. package/src/node/cloud-init.ts +51 -0
  46. package/src/node/d1.ts +44 -0
  47. package/src/node/deploy-cf.ts +179 -0
  48. package/src/node/index.ts +5 -0
  49. package/src/node/panel.ts +21 -0
  50. package/src/node/serve.ts +125 -0
  51. package/src/node/storage.ts +51 -0
  52. package/src/platform/node/env.ts +4 -0
  53. package/src/platform/node/hooks.ts +19 -0
  54. package/src/platform/node/log.ts +7 -0
  55. package/src/platform/node/migrations.ts +5 -0
  56. package/src/platform/node/photon.ts +1 -0
  57. package/src/platform/node/sockets.ts +22 -0
  58. package/src/platform/node/sse.ts +23 -0
  59. package/src/platform/workers/env.ts +3 -0
  60. package/src/platform/workers/hooks.ts +2 -0
  61. package/src/platform/workers/log.ts +1 -0
  62. package/src/platform/workers/migrations.ts +1 -0
  63. package/src/platform/workers/photon.ts +1 -0
  64. package/src/platform/workers/sockets.ts +3 -0
  65. package/src/platform/workers/sse.ts +1 -0
  66. package/src/server/api.ts +27 -0
  67. package/src/server/app.ts +582 -0
  68. package/src/server/auth-extra.ts +113 -0
  69. package/src/server/auth-flows.ts +186 -0
  70. package/src/server/auth-response.ts +111 -0
  71. package/src/server/auth.ts +187 -0
  72. package/src/server/backups.ts +234 -0
  73. package/src/server/batch.ts +123 -0
  74. package/src/server/bootstrap.ts +71 -0
  75. package/src/server/collections/auth-option-shape.json +71 -0
  76. package/src/server/collections/ddl.ts +127 -0
  77. package/src/server/collections/fields.ts +120 -0
  78. package/src/server/collections/model.ts +185 -0
  79. package/src/server/collections/oauth2-providers.json +1 -0
  80. package/src/server/collections/scaffolds.json +210 -0
  81. package/src/server/collections/service.ts +392 -0
  82. package/src/server/collections/system.json +605 -0
  83. package/src/server/collections/system.ts +19 -0
  84. package/src/server/collections/validate.ts +239 -0
  85. package/src/server/crc32.ts +13 -0
  86. package/src/server/crons.ts +100 -0
  87. package/src/server/crypto.ts +26 -0
  88. package/src/server/db.ts +37 -0
  89. package/src/server/errors.ts +53 -0
  90. package/src/server/files-api.ts +52 -0
  91. package/src/server/filter/compile.ts +420 -0
  92. package/src/server/filter/lexer.ts +107 -0
  93. package/src/server/filter/parser.ts +49 -0
  94. package/src/server/hardening.ts +136 -0
  95. package/src/server/hooks/index.ts +147 -0
  96. package/src/server/hooks/migrations.ts +58 -0
  97. package/src/server/hooks/node-async-hooks.d.ts +7 -0
  98. package/src/server/hooks/record.ts +152 -0
  99. package/src/server/hooks/runtime.ts +344 -0
  100. package/src/server/hooks/virtual-migrations.d.ts +4 -0
  101. package/src/server/hooks/virtual.d.ts +7 -0
  102. package/src/server/hub.ts +91 -0
  103. package/src/server/ids.ts +22 -0
  104. package/src/server/jobs.ts +84 -0
  105. package/src/server/jwt.ts +61 -0
  106. package/src/server/logs.ts +144 -0
  107. package/src/server/mail/index.ts +99 -0
  108. package/src/server/mail/message.ts +43 -0
  109. package/src/server/mail/smtp.ts +82 -0
  110. package/src/server/mail/templates.ts +168 -0
  111. package/src/server/oauth2/index.ts +198 -0
  112. package/src/server/oauth2/providers.ts +153 -0
  113. package/src/server/password.ts +17 -0
  114. package/src/server/realtime/hub-client.ts +50 -0
  115. package/src/server/realtime/index.ts +239 -0
  116. package/src/server/records/expand.ts +129 -0
  117. package/src/server/records/files.ts +69 -0
  118. package/src/server/records/json.ts +23 -0
  119. package/src/server/records/picker.ts +80 -0
  120. package/src/server/records/service.ts +598 -0
  121. package/src/server/records/thumbs.ts +148 -0
  122. package/src/server/records/values.ts +295 -0
  123. package/src/server/settings-api.ts +104 -0
  124. package/src/server/settings.ts +215 -0
  125. package/src/server/sql.ts +61 -0
  126. package/src/server/static.ts +17 -0
  127. package/src/server/storage/s3.ts +118 -0
  128. package/src/server/types.ts +25 -0
  129. package/src/server/webauthn.ts +168 -0
  130. package/tsconfig.json +36 -0
  131. package/tsconfig.node.json +27 -0
  132. package/types/pb_data.d.ts +24438 -0
  133. package/vite.config.ts +10 -0
  134. package/void.json +12 -0
@@ -0,0 +1,198 @@
1
+ // OAuth2 (apis/record_auth_with_oauth2*.go): auth-with-oauth2 code exchange with PKCE, external-auth linking,
2
+ // record creation with createData and the collection's mappedFields, and the browser flow's /api/oauth2-redirect
3
+ // which hands {state, code} to the waiting realtime client through the change feed (topic "@oauth2").
4
+ import type { Context, Hono } from "hono";
5
+ import { recordAuthResponse } from "../auth-response";
6
+ import { requestHook } from "../hooks/runtime";
7
+ import { CollectionRef, HookRecord } from "../hooks/record";
8
+ import type { Collection } from "../collections/model";
9
+ import { ident, one, run, stmt } from "../db";
10
+ import { ApiError, badRequest, forbidden } from "../errors";
11
+ import { nowString, randomId, randomString } from "../ids";
12
+ import { hashPassword } from "../password";
13
+ import { createRecord, type RecordContext } from "../records/service";
14
+ import type { AppEnv, Row } from "../types";
15
+ import { fetchProviderUser, PROVIDER_DEFAULTS, type AuthUser, type Token } from "./providers";
16
+ import { hubActive, publishToClient } from "../realtime/hub-client";
17
+
18
+ export interface ProviderConfig { name: string; clientId: string; clientSecret?: string; authURL?: string; tokenURL?: string; userInfoURL?: string; displayName?: string; pkce?: boolean | null; extra?: Record<string, unknown> }
19
+ interface OAuth2Options { enabled?: boolean; providers?: ProviderConfig[]; mappedFields?: { id?: string; name?: string; username?: string; avatarURL?: string } }
20
+
21
+ export const oauth2Options = (c: Collection): OAuth2Options => ((c.options as Record<string, unknown>).oauth2 ?? {}) as OAuth2Options;
22
+ export function providerConfig(c: Collection, name: string): ProviderConfig | null {
23
+ const p = (oauth2Options(c).providers ?? []).find((x) => x.name === name);
24
+ if (!p) return null;
25
+ const d = PROVIDER_DEFAULTS[name];
26
+ return { ...p, displayName: p.displayName || d?.displayName || name, authURL: p.authURL || d?.authURL, tokenURL: p.tokenURL || d?.tokenURL, userInfoURL: p.userInfoURL || d?.userInfoURL, pkce: typeof p.pkce === "boolean" ? p.pkce : (d?.pkce ?? false) };
27
+ }
28
+ // a provider's extra.scopes (array) replaces the catalog default: Cloudflare's resource scopes are chosen per OAuth client
29
+ export const providerScopes = (p: ProviderConfig | string) => {
30
+ const name = typeof p === "string" ? p : p.name;
31
+ const extra = typeof p === "string" ? undefined : (p.extra?.scopes as unknown);
32
+ if (Array.isArray(extra) && extra.every((s) => typeof s === "string")) return extra as string[];
33
+ if (typeof extra === "string" && extra.trim()) return extra.split(/[\s,]+/).filter(Boolean);
34
+ return PROVIDER_DEFAULTS[name]?.scopes ?? [];
35
+ };
36
+
37
+ const b64url = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
38
+ export async function s256Challenge(verifier: string): Promise<string> {
39
+ return b64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
40
+ }
41
+ // golang.org/x/oauth2 AuthCodeURL: the parameters come out of url.Values.Encode(), i.e. sorted by key
42
+ export function buildAuthURL(p: ProviderConfig, state: string, extra: Record<string, string>): string {
43
+ const u = new URL(p.authURL ?? "");
44
+ const params: Record<string, string> = { response_type: "code", client_id: p.clientId, state, ...extra };
45
+ const scopes = providerScopes(p);
46
+ if (scopes.length) params.scope = scopes.join(" ");
47
+ const encoded = Object.keys(params).sort().map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k]!).replace(/%20/g, "+")}`).join("&");
48
+ return `${u.origin}${u.pathname}?${u.search ? u.search.slice(1) + "&" : ""}${encoded}`;
49
+ }
50
+
51
+ // oauth2.Config.Exchange with AuthStyleAutoDetect: client credentials in the Authorization header first, in the body second
52
+ async function fetchToken(p: ProviderConfig, code: string, redirectURL: string, codeVerifier: string): Promise<Token> {
53
+ if (!p.tokenURL) throw new Error("missing tokenURL");
54
+ const form = new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: redirectURL });
55
+ if (p.pkce && codeVerifier) form.set("code_verifier", codeVerifier);
56
+ const attempt = async (style: "header" | "body") => {
57
+ const body = new URLSearchParams(form);
58
+ const headers: Record<string, string> = { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
59
+ if (style === "header") headers.authorization = "Basic " + btoa(`${encodeURIComponent(p.clientId)}:${encodeURIComponent(p.clientSecret ?? "")}`);
60
+ else { body.set("client_id", p.clientId); body.set("client_secret", p.clientSecret ?? ""); }
61
+ return fetch(p.tokenURL!, { method: "POST", headers, body });
62
+ };
63
+ let res = await attempt("header");
64
+ if (res.status === 401 || res.status === 400) res = await attempt("body");
65
+ const text = await res.text();
66
+ if (!res.ok) throw new Error(`oauth2: cannot fetch token: ${res.status}\nResponse: ${text}`);
67
+ let token: Token;
68
+ try { token = JSON.parse(text) as Token; } catch { token = Object.fromEntries(new URLSearchParams(text)) as unknown as Token; if (token.expires_in) token.expires_in = Number(token.expires_in); }
69
+ if (!token.access_token) throw new Error("oauth2: server response missing access_token");
70
+ return token;
71
+ }
72
+
73
+ async function fetchAuthUser(p: ProviderConfig, token: Token): Promise<AuthUser> {
74
+ const user = await fetchProviderUser({ name: p.name, clientId: p.clientId, clientSecret: p.clientSecret ?? "", userInfoURL: p.userInfoURL ?? "", extra: p.extra ?? {} }, token);
75
+ if (p.name.startsWith("oidc") && !p.userInfoURL && token.id_token) { // audience check like PocketBase's id_token parser
76
+ const aud = (user.rawUser as { aud?: unknown }).aud;
77
+ if (!(aud === p.clientId || (Array.isArray(aud) && aud.includes(p.clientId)))) throw new Error("id_token audience mismatch");
78
+ }
79
+ return user;
80
+ }
81
+
82
+ // POST /api/collections/:collection/auth-with-oauth2
83
+ export async function authWithOAuth2(c: Context<AppEnv>, collection: Collection, ctx: RecordContext): Promise<Response> {
84
+ const opts = oauth2Options(collection);
85
+ if (!opts.enabled) throw forbidden("The collection is not configured to allow OAuth2 authentication.");
86
+ let body: Record<string, unknown> = {};
87
+ try { body = (await c.req.json()) ?? {}; } catch { throw badRequest("An error occurred while loading the submitted data."); }
88
+ const providerName = String(body.provider ?? "");
89
+ const code = String(body.code ?? "");
90
+ const errs: Record<string, { code: string; message: string; params?: Record<string, unknown> }> = {};
91
+ if (!code) errs.code = { code: "validation_required", message: "Cannot be blank." };
92
+ if (!providerName) errs.provider = { code: "validation_required", message: "Cannot be blank." };
93
+ else if (!providerConfig(collection, providerName)) errs.provider = { code: "validation_invalid_provider", message: `Provider with name ${providerName} is missing or is not enabled.`, params: { name: providerName } };
94
+ if (Object.keys(errs).length) throw new ApiError(400, "An error occurred while loading the submitted data.", errs);
95
+ const provider = providerConfig(collection, providerName)!;
96
+ const redirectURL = String(body.redirectURL ?? body.redirectUrl ?? "");
97
+ let token: Token;
98
+ try { token = await fetchToken(provider, code, redirectURL, String(body.codeVerifier ?? "")); } catch (err) { console.warn("voidbase: oauth2 token", err); throw badRequest("Failed to fetch OAuth2 token."); }
99
+ let user: AuthUser;
100
+ try { user = await fetchAuthUser(provider, token); } catch (err) { console.warn("voidbase: oauth2 user", err); throw badRequest("Failed to fetch OAuth2 user."); }
101
+
102
+ const db = c.env.DB;
103
+ const table = ident(collection.name);
104
+ const fallback = ctx.auth && ctx.auth.collection.id === collection.id ? ctx.auth.row : null;
105
+ let external = await one<Row>(db, "SELECT * FROM `_externalAuths` WHERE collectionRef = ? AND provider = ? AND providerId = ? LIMIT 1", [collection.id, providerName, user.id]);
106
+ let row: Row | null = null;
107
+ if (external) row = await one<Row>(db, `SELECT * FROM ${table} WHERE id = ? LIMIT 1`, [external.recordRef]);
108
+ else if (fallback) row = fallback;
109
+ else if (user.email) row = await one<Row>(db, `SELECT * FROM ${table} WHERE email = ? LIMIT 1`, [user.email]);
110
+ const isNew = !row;
111
+ return requestHook("onRecordAuthWithOAuth2Request", c, collection.name, { collection: new CollectionRef(collection), providerName, providerClient: null, oAuth2User: user, createData: (body.createData as Record<string, unknown>) ?? {}, record: row ? HookRecord.fromRow(collection, row) : null, isNewRecord: isNew }, async (ev) => {
112
+
113
+ try {
114
+ if (!row) {
115
+ if (collection.name === "_superusers") throw new Error("superusers are not allowed to sign-up with OAuth2");
116
+ const payload: Record<string, unknown> = { ...((ev.createData as Record<string, unknown>) ?? {}) };
117
+ if (!payload.email) payload.email = user.email;
118
+ const mf = opts.mappedFields ?? {};
119
+ if (mf.id && !(mf.id in payload)) payload[mf.id] = user.id;
120
+ if (mf.name && !(mf.name in payload)) payload[mf.name] = user.name;
121
+ if (mf.username && !(mf.username in payload) && user.username && canAssignUsername(collection, user.username)) payload[mf.username] = user.username;
122
+ if (mf.avatarURL && !(mf.avatarURL in payload) && user.avatarURL) {
123
+ const f = collection.fields.find((x) => x.name === mf.avatarURL);
124
+ if (f && f.type !== "file") payload[mf.avatarURL] = user.avatarURL; // file avatars need a fetch through the record form; kept for auth.providers
125
+ }
126
+ if (!payload.id) payload.id = randomId();
127
+ // forms/record_upsert.go: an OAuth2 sign-up without a password gets a random one
128
+ if (!payload.password) { payload.password = randomString(30); payload.passwordConfirm = payload.password; }
129
+ await createRecord({ ...ctx, request: { ...ctx.request, context: "oauth2" } }, collection, payload as never, {} as never);
130
+ row = await one<Row>(db, `SELECT * FROM ${table} WHERE id = ? LIMIT 1`, [payload.id]);
131
+ if (!row) throw new Error("failed to create OAuth2 auth record");
132
+ if (row.email === user.email && !row.verified) { await run(db, `UPDATE ${table} SET verified = 1, updated = ? WHERE id = ?`, [nowString(), row.id]); row.verified = 1; }
133
+ } else {
134
+ const sets: string[] = []; const params: unknown[] = [];
135
+ const isLogged = fallback !== null && fallback.id === row.id;
136
+ const verified = !!row.verified && row.verified !== 0 && row.verified !== "0" && row.verified !== "false";
137
+ if (!isLogged && !verified) { sets.push("password = ?", "tokenKey = ?"); params.push(await hashPassword(randomString(30)), randomString(50)); }
138
+ if (!verified) { await run(db, "DELETE FROM `_externalAuths` WHERE collectionRef = ? AND recordRef = ?", [collection.id, row.id]); external = null; }
139
+ if (!row.email && user.email) { sets.push("email = ?"); params.push(user.email); row.email = user.email; }
140
+ if (!verified && (!row.email || row.email === user.email)) { sets.push("verified = 1"); row.verified = 1; }
141
+ if (sets.length) { sets.push("updated = ?"); params.push(nowString()); await run(db, `UPDATE ${table} SET ${sets.join(", ")} WHERE id = ?`, [...params, row.id]); }
142
+ }
143
+ if (!external) {
144
+ const now = nowString();
145
+ await run(db, "INSERT INTO `_externalAuths` (id, collectionRef, recordRef, provider, providerId, created, updated) VALUES (?, ?, ?, ?, ?, ?, ?)", [randomId(), collection.id, row.id, providerName, user.id, now, now]);
146
+ }
147
+ } catch (err) {
148
+ if (err instanceof ApiError) throw err;
149
+ console.warn("voidbase: oauth2 submit", err);
150
+ throw badRequest("Failed to authenticate.");
151
+ }
152
+ const fresh = (await one<Row>(db, `SELECT * FROM ${table} WHERE id = ? LIMIT 1`, [row.id])) ?? row;
153
+ const meta: Record<string, unknown> = { ...user, avatarUrl: user.avatarURL, isNew }; // avatarUrl: deprecated alias PocketBase still returns
154
+
155
+ return recordAuthResponse(c, ctx, collection, fresh, "oauth2", { meta: sortKeys(meta), body });
156
+ });
157
+ }
158
+
159
+ const sortKeys = (o: Record<string, unknown>) => Object.fromEntries(Object.entries(o).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
160
+ function canAssignUsername(collection: Collection, username: string): boolean {
161
+ const f = collection.fields.find((x) => x.name === oauth2Options(collection).mappedFields?.username);
162
+ if (!f || f.type !== "text") return false;
163
+ const pattern = String((f as { pattern?: string }).pattern ?? "");
164
+ if (pattern) { try { if (!new RegExp(pattern).test(username)) return false; } catch { /* keep */ } }
165
+ const min = Number((f as { min?: number }).min ?? 0), max = Number((f as { max?: number }).max ?? 0);
166
+ if (min && username.length < min) return false;
167
+ if (max && username.length > max) return false;
168
+ return true;
169
+ }
170
+
171
+ // GET|POST /api/oauth2-redirect: the provider sends the browser back here; we forward {state, code, error} to the
172
+ // realtime client whose id is the state (through the change feed, so any isolate can deliver it) and redirect
173
+ // to the panel's "you can close this window" page.
174
+ const FAILURE = "../_/#/auth/oauth2-redirect-failure", SUCCESS = "../_/#/auth/oauth2-redirect-success";
175
+ export function mountOAuth2Redirect(app: Hono<AppEnv>) {
176
+ const handler = async (c: Context<AppEnv>) => {
177
+ const status = c.req.method === "GET" ? 307 : 303;
178
+ let data: { state: string; code: string; error?: string } = { state: "", code: "" };
179
+ if (c.req.method === "POST") {
180
+ const ct = c.req.header("content-type") ?? "";
181
+ const body = (ct.includes("json") ? await c.req.json().catch(() => ({})) : Object.fromEntries((await c.req.formData().catch(() => new FormData())).entries())) as Record<string, unknown>;
182
+ data = { state: String(body.state ?? ""), code: String(body.code ?? ""), error: body.error ? String(body.error) : undefined };
183
+ } else data = { state: c.req.query("state") ?? "", code: c.req.query("code") ?? "", error: c.req.query("error") || undefined };
184
+ if (!data.state) return c.redirect(FAILURE, status);
185
+ const client = await one<{ subscriptions: string }>(c.env.DB, "SELECT subscriptions FROM `_realtime_clients` WHERE id = ?", [data.state]);
186
+ const subs = client ? (JSON.parse(client.subscriptions || "[]") as string[]) : [];
187
+ if (!client || !subs.includes("@oauth2")) return c.redirect(FAILURE, status);
188
+ const payload: Record<string, unknown> = { state: data.state, code: data.code };
189
+ if (data.error) payload.error = data.error;
190
+ // the stream drops its @oauth2 subscription itself once it has delivered the message
191
+ if (hubActive()) await publishToClient(data.state, "@oauth2", payload);
192
+ else await stmt(c.env.DB, "INSERT INTO `_changes` (collection, recordId, action, data, created) VALUES ('@oauth2', ?, 'message', ?, ?)", [data.state, JSON.stringify(payload), nowString()]).run();
193
+ if (data.error || !data.code) return c.redirect(FAILURE, status);
194
+ return c.redirect(SUCCESS, status);
195
+ };
196
+ app.get("/api/oauth2-redirect", handler);
197
+ app.post("/api/oauth2-redirect", handler);
198
+ }
@@ -0,0 +1,153 @@
1
+ // OAuth2 provider catalog (PocketBase tools/auth): endpoints, scopes, PKCE defaults and the user-info fetch and
2
+ // mapping for every provider PocketBase ships. Email is only taken when the provider vouches for it.
3
+ export interface ProviderDefaults { displayName: string; pkce: boolean; scopes: string[]; authURL?: string; tokenURL?: string; userInfoURL?: string }
4
+ export interface AuthUser { expiry: string; rawUser: Record<string, unknown>; id: string; name: string; username: string; avatarURL: string; accessToken: string; refreshToken: string; email: string }
5
+ export interface Token { access_token: string; token_type?: string; refresh_token?: string; expires_in?: number; id_token?: string; [k: string]: unknown }
6
+ export interface ProviderContext { name: string; clientId: string; clientSecret: string; userInfoURL: string; extra: Record<string, unknown> }
7
+ type Raw = Record<string, unknown>;
8
+
9
+ const oidc: ProviderDefaults = { displayName: "OIDC", pkce: true, scopes: ["openid", "email", "profile"] };
10
+ // Cloudflare OAuth (developers.cloudflare.com/fundamentals/oauth): plain OIDC on dash.cloudflare.com whose userinfo
11
+ // carries only `sub`, so identity comes from the API's GET /user. Resource scopes (API token permission names such
12
+ // as workers-platform.write) are added per provider with extra.scopes; extra.apiBase overrides the API for tests.
13
+ export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
14
+ const cloudflare: ProviderDefaults = { displayName: "Cloudflare", pkce: true, scopes: ["openid", "offline_access"], authURL: "https://dash.cloudflare.com/oauth2/auth", tokenURL: "https://dash.cloudflare.com/oauth2/token", userInfoURL: "https://dash.cloudflare.com/oauth2/userinfo" };
15
+ export const PROVIDER_DEFAULTS: Record<string, ProviderDefaults> = {
16
+ oidc, oidc2: oidc, oidc3: oidc, cloudflare,
17
+ apple: { displayName: "Apple", pkce: true, scopes: ["name", "email"], authURL: "https://appleid.apple.com/auth/authorize", tokenURL: "https://appleid.apple.com/auth/token" },
18
+ bitbucket: { displayName: "Bitbucket", pkce: false, scopes: ["account"], authURL: "https://bitbucket.org/site/oauth2/authorize", tokenURL: "https://bitbucket.org/site/oauth2/access_token", userInfoURL: "https://api.bitbucket.org/2.0/user" },
19
+ box: { displayName: "Box", pkce: true, scopes: ["root_readonly"], authURL: "https://account.box.com/api/oauth2/authorize", tokenURL: "https://api.box.com/oauth2/token", userInfoURL: "https://api.box.com/2.0/users/me" },
20
+ discord: { displayName: "Discord", pkce: true, scopes: ["identify", "email"], authURL: "https://discord.com/api/oauth2/authorize", tokenURL: "https://discord.com/api/oauth2/token", userInfoURL: "https://discord.com/api/users/@me" },
21
+ facebook: { displayName: "Facebook", pkce: true, scopes: ["email"], authURL: "https://www.facebook.com/v3.2/dialog/oauth", tokenURL: "https://graph.facebook.com/v3.2/oauth/access_token", userInfoURL: "https://graph.facebook.com/me?fields=name,email,picture.type(large)" },
22
+ gitea: { displayName: "Gitea/Forgejo", pkce: true, scopes: ["read:user", "user:email"], authURL: "https://gitea.com/login/oauth/authorize", tokenURL: "https://gitea.com/login/oauth/access_token", userInfoURL: "https://gitea.com/api/v1/user" },
23
+ gitee: { displayName: "Gitee", pkce: true, scopes: ["user_info", "emails"], authURL: "https://gitee.com/oauth/authorize", tokenURL: "https://gitee.com/oauth/token", userInfoURL: "https://gitee.com/api/v5/user" },
24
+ github: { displayName: "GitHub", pkce: true, scopes: ["read:user", "user:email"], authURL: "https://github.com/login/oauth/authorize", tokenURL: "https://github.com/login/oauth/access_token", userInfoURL: "https://api.github.com/user" },
25
+ gitlab: { displayName: "GitLab", pkce: true, scopes: ["read_user"], authURL: "https://gitlab.com/oauth/authorize", tokenURL: "https://gitlab.com/oauth/token", userInfoURL: "https://gitlab.com/api/v4/user" },
26
+ google: { displayName: "Google", pkce: true, scopes: ["https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.email"], authURL: "https://accounts.google.com/o/oauth2/v2/auth", tokenURL: "https://oauth2.googleapis.com/token", userInfoURL: "https://www.googleapis.com/oauth2/v3/userinfo" },
27
+ instagram: { displayName: "Instagram", pkce: true, scopes: ["instagram_business_basic"], authURL: "https://www.instagram.com/oauth/authorize", tokenURL: "https://api.instagram.com/oauth/access_token", userInfoURL: "https://graph.instagram.com/me?fields=id,username,account_type,user_id,name,profile_picture_url,followers_count,follows_count,media_count" },
28
+ kakao: { displayName: "Kakao", pkce: true, scopes: ["account_email", "profile_nickname", "profile_image"], authURL: "https://kauth.kakao.com/oauth/authorize", tokenURL: "https://kauth.kakao.com/oauth/token", userInfoURL: "https://kapi.kakao.com/v2/user/me" },
29
+ lark: { displayName: "Lark", pkce: true, scopes: [], authURL: "https://accounts.feishu.cn/open-apis/authen/v1/authorize", tokenURL: "https://open.feishu.cn/open-apis/authen/v2/oauth/token", userInfoURL: "https://open.feishu.cn/open-apis/authen/v1/user_info" },
30
+ linear: { displayName: "Linear", pkce: false, scopes: ["read"], authURL: "https://linear.app/oauth/authorize", tokenURL: "https://api.linear.app/oauth/token", userInfoURL: "https://api.linear.app/graphql" },
31
+ livechat: { displayName: "LiveChat", pkce: true, scopes: [], authURL: "https://accounts.livechat.com/", tokenURL: "https://accounts.livechat.com/token", userInfoURL: "https://accounts.livechat.com/v2/accounts/me" },
32
+ mailcow: { displayName: "mailcow", pkce: true, scopes: ["profile"] },
33
+ microsoft: { displayName: "Microsoft", pkce: true, scopes: ["User.Read"], authURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", tokenURL: "https://login.microsoftonline.com/common/oauth2/v2.0/token", userInfoURL: "https://graph.microsoft.com/v1.0/me" },
34
+ monday: { displayName: "monday.com", pkce: true, scopes: ["me:read"], authURL: "https://auth.monday.com/oauth2/authorize", tokenURL: "https://auth.monday.com/oauth2/token", userInfoURL: "https://api.monday.com/v2" },
35
+ notion: { displayName: "Notion", pkce: true, scopes: [], authURL: "https://api.notion.com/v1/oauth/authorize", tokenURL: "https://api.notion.com/v1/oauth/token", userInfoURL: "https://api.notion.com/v1/users/me" },
36
+ patreon: { displayName: "Patreon", pkce: true, scopes: ["identity", "identity[email]"], authURL: "https://www.patreon.com/oauth2/authorize", tokenURL: "https://www.patreon.com/api/oauth2/token", userInfoURL: "https://www.patreon.com/api/oauth2/v2/identity?fields%5Buser%5D=full_name,email,vanity,image_url,is_email_verified" },
37
+ planningcenter: { displayName: "Planning Center", pkce: true, scopes: ["people"], authURL: "https://api.planningcenteronline.com/oauth/authorize", tokenURL: "https://api.planningcenteronline.com/oauth/token", userInfoURL: "https://api.planningcenteronline.com/people/v2/me" },
38
+ spotify: { displayName: "Spotify", pkce: true, scopes: ["user-read-private"], authURL: "https://accounts.spotify.com/authorize", tokenURL: "https://accounts.spotify.com/api/token", userInfoURL: "https://api.spotify.com/v1/me" },
39
+ strava: { displayName: "Strava", pkce: true, scopes: ["profile:read_all"], authURL: "https://www.strava.com/oauth/authorize", tokenURL: "https://www.strava.com/api/v3/oauth/token", userInfoURL: "https://www.strava.com/api/v3/athlete" },
40
+ trakt: { displayName: "Trakt", pkce: true, scopes: [], authURL: "https://trakt.tv/oauth/authorize", tokenURL: "https://api.trakt.tv/oauth/token", userInfoURL: "https://api.trakt.tv/users/settings" },
41
+ twitch: { displayName: "Twitch", pkce: true, scopes: ["user:read:email"], authURL: "https://id.twitch.tv/oauth2/authorize", tokenURL: "https://id.twitch.tv/oauth2/token", userInfoURL: "https://api.twitch.tv/helix/users" },
42
+ twitter: { displayName: "X/Twitter", pkce: true, scopes: ["users.read", "users.email", "tweet.read"], authURL: "https://x.com/i/oauth2/authorize", tokenURL: "https://api.x.com/2/oauth2/token", userInfoURL: "https://api.x.com/2/users/me?user.fields=id,name,username,profile_image_url,confirmed_email" },
43
+ vk: { displayName: "ВКонтакте", pkce: false, scopes: ["email"], authURL: "https://oauth.vk.com/authorize", tokenURL: "https://oauth.vk.com/access_token", userInfoURL: "https://api.vk.com/method/users.get?fields=photo_max,screen_name&v=5.131" },
44
+ wakatime: { displayName: "WakaTime", pkce: true, scopes: ["email"], authURL: "https://wakatime.com/oauth/authorize", tokenURL: "https://wakatime.com/oauth/token", userInfoURL: "https://wakatime.com/api/v1/users/current" },
45
+ yandex: { displayName: "Yandex", pkce: true, scopes: ["login:email", "login:avatar", "login:info"], authURL: "https://oauth.yandex.com/authorize", tokenURL: "https://oauth.yandex.com/token", userInfoURL: "https://login.yandex.ru/info" },
46
+ };
47
+
48
+ const str = (v: unknown) => (v === null || v === undefined ? "" : String(v));
49
+ const truthy = (v: unknown) => v === true || v === "true" || v === 1 || v === "1";
50
+ const get = (o: unknown, ...path: string[]): unknown => path.reduce<unknown>((cur, k) => (cur && typeof cur === "object" ? (cur as Raw)[k] : undefined), o);
51
+ const isEmail = (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v);
52
+ const jwtClaims = (jwt: string): Raw => { try { const p = jwt.split(".")[1] ?? ""; return JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(p.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(p.length / 4) * 4, "=")), (ch) => ch.charCodeAt(0)))) as Raw; } catch { return {}; } };
53
+
54
+ async function getJSON(url: string, token: Token, headers: Record<string, string> = {}): Promise<Raw> {
55
+ const res = await fetch(url, { headers: { authorization: `Bearer ${token.access_token}`, accept: "application/json", ...headers } });
56
+ const text = await res.text();
57
+ if (res.status >= 400) throw new Error(`failed to fetch OAuth2 user profile via ${url} (${res.status}):\n${text}`);
58
+ return JSON.parse(text) as Raw;
59
+ }
60
+ async function graphQL(url: string, token: Token, query: string, headers: Record<string, string> = {}): Promise<Raw> {
61
+ const res = await fetch(url, { method: "POST", headers: { authorization: `Bearer ${token.access_token}`, "content-type": "application/json", accept: "application/json", ...headers }, body: JSON.stringify({ query }) });
62
+ const text = await res.text();
63
+ if (res.status >= 400) throw new Error(`failed to fetch OAuth2 user profile via ${url} (${res.status}):\n${text}`);
64
+ return JSON.parse(text) as Raw;
65
+ }
66
+
67
+ // the raw user-info document (most providers: GET userInfoURL with the bearer token)
68
+ export async function fetchRawUser(p: ProviderContext, token: Token): Promise<Raw> {
69
+ switch (p.name) {
70
+ case "apple": case "oidc": case "oidc2": case "oidc3": case "mailcow":
71
+ if (p.userInfoURL) return getJSON(p.userInfoURL, token);
72
+ if (!token.id_token) throw new Error("empty id_token");
73
+ return jwtClaims(String(token.id_token));
74
+ case "cloudflare": {
75
+ const info = p.userInfoURL ? await getJSON(p.userInfoURL, token) : (token.id_token ? jwtClaims(String(token.id_token)) : {});
76
+ const base = String(p.extra.apiBase || CF_API_BASE).replace(/\/$/, "");
77
+ const me = await getJSON(`${base}/user`, token);
78
+ return { ...info, cf_user: (me.result ?? me) as Raw };
79
+ }
80
+ case "linear": return graphQL(p.userInfoURL, token, "query { viewer { id displayName name email avatarUrl active } }");
81
+ case "monday": return graphQL(p.userInfoURL, token, "query { me { id enabled name email is_verified photo_small } }");
82
+ case "twitch": return getJSON(p.userInfoURL, token, { "Client-Id": p.clientId });
83
+ case "trakt": return getJSON(p.userInfoURL, token, { "trakt-api-key": p.clientId, "trakt-api-version": "2" });
84
+ case "notion": return getJSON(p.userInfoURL, token, { "Notion-Version": "2022-06-28" });
85
+ default:
86
+ if (!p.userInfoURL) throw new Error("missing userInfoURL");
87
+ return getJSON(p.userInfoURL, token);
88
+ }
89
+ }
90
+
91
+ // providers whose verified email lives on a second endpoint
92
+ async function extraEmail(p: ProviderContext, token: Token): Promise<string> {
93
+ const pick = (list: unknown, ok: (e: Raw) => boolean, key = "email") => { for (const e of (Array.isArray(list) ? list : []) as Raw[]) if (ok(e)) return str(e[key]); return ""; };
94
+ try {
95
+ switch (p.name) {
96
+ case "github": return pick(await getJSON(`${p.userInfoURL}/emails`, token), (e) => truthy(e.primary) && truthy(e.verified));
97
+ case "gitea": return pick(await getJSON(`${p.userInfoURL}/emails`, token), (e) => truthy(e.primary) && truthy(e.verified));
98
+ case "gitee": return pick(await getJSON(p.userInfoURL.replace(/\/user$/, "/emails"), token), (e) => (Array.isArray(e.scope) ? (e.scope as string[]).includes("primary") : truthy(e.primary)) && (e.state === "confirmed" || truthy(e.confirmed)));
99
+ case "bitbucket": return pick(get(await getJSON(`${p.userInfoURL}/emails`, token), "values"), (e) => truthy(e.is_primary) && truthy(e.is_confirmed));
100
+ default: return "";
101
+ }
102
+ } catch { return ""; }
103
+ }
104
+
105
+ export async function fetchProviderUser(p: ProviderContext, token: Token): Promise<AuthUser> {
106
+ const raw = await fetchRawUser(p, token);
107
+ const u = mapUser(p, raw, token);
108
+ if (!u.email && ["github", "gitea", "gitee", "bitbucket"].includes(p.name)) u.email = await extraEmail(p, token);
109
+ const expiry = token.expires_in ? new Date(Date.now() + Number(token.expires_in) * 1000) : null;
110
+ return { ...u, rawUser: raw, accessToken: token.access_token, refreshToken: str(token.refresh_token), expiry: expiry ? expiry.toISOString().replace("T", " ").replace(/\.(\d{3})Z$/, ".$1Z") : "" };
111
+ }
112
+
113
+ // tools/auth/<provider>.go FetchAuthUser mappings
114
+ export function mapUser(p: ProviderContext, raw: Raw, token: Token): Omit<AuthUser, "expiry" | "accessToken" | "refreshToken" | "rawUser"> {
115
+ const base = { id: "", name: "", username: "", avatarURL: "", email: "" };
116
+ switch (p.name) {
117
+ case "google": return { ...base, id: str(raw.sub), name: str(raw.name), avatarURL: str(raw.picture), email: truthy(raw.email_verified) ? str(raw.email) : "" };
118
+ case "apple": return { ...base, id: str(raw.sub), name: str(raw.name), email: truthy(raw.email_verified) ? str(raw.email) : "" };
119
+ case "bitbucket": if (raw.account_status !== "active") throw new Error("Bitbucket user account is not active"); return { ...base, id: str(raw.uuid), name: str(raw.display_name), username: str(raw.username), avatarURL: str(get(raw, "links", "avatar", "href")) };
120
+ case "box": if (raw.status !== "active") throw new Error(`Box user account is not active (status: ${str(raw.status)})`); return { ...base, id: str(raw.id), name: str(raw.name), avatarURL: str(raw.avatar_url), email: str(raw.login) };
121
+ case "discord": {
122
+ const id = str(raw.id); let name = str(raw.global_name) || str(raw.username);
123
+ const disc = str(raw.discriminator); if (!raw.global_name && disc && disc !== "0") name += "#" + disc;
124
+ return { ...base, id, name, username: str(raw.username), avatarURL: raw.avatar ? `https://cdn.discordapp.com/avatars/${id}/${str(raw.avatar)}.png` : "", email: truthy(raw.verified) ? str(raw.email) : "" };
125
+ }
126
+ case "cloudflare": { const u = (raw.cf_user ?? {}) as Raw; const name = `${str(u.first_name)} ${str(u.last_name)}`.trim(); return { ...base, id: str(raw.sub) || str(u.id), name, username: str(u.username), email: str(u.email) }; }
127
+ case "facebook": return { ...base, id: str(raw.id), name: str(raw.name), email: str(raw.email), avatarURL: str(get(raw, "picture", "data", "url")) };
128
+ case "gitea": if (!truthy(raw.active)) throw new Error("the Gitea user is not active"); return { ...base, id: str(raw.id), name: str(raw.full_name), username: str(raw.login), avatarURL: str(raw.avatar_url) };
129
+ case "gitee": return { ...base, id: str(raw.id), name: str(raw.name), username: str(raw.login), avatarURL: str(raw.avatar_url), email: raw.email && isEmail(str(raw.email)) ? str(raw.email) : "" };
130
+ case "github": return { ...base, id: str(raw.id), name: str(raw.name), username: str(raw.login), avatarURL: str(raw.avatar_url) };
131
+ case "gitlab": return { ...base, id: str(raw.id), name: str(raw.name), username: str(raw.username), avatarURL: str(raw.avatar_url), email: raw.confirmed_at && !Number.isNaN(Date.parse(str(raw.confirmed_at))) ? str(raw.email) : "" };
132
+ case "instagram": return { ...base, id: str(raw.user_id), name: str(raw.name), username: str(raw.username), avatarURL: str(raw.profile_picture_url) };
133
+ case "kakao": { const acc = (raw.kakao_account ?? {}) as Raw; return { ...base, id: str(raw.id), username: str(get(raw, "properties", "nickname")), avatarURL: str(get(raw, "properties", "profile_image")), email: truthy(acc.is_email_valid) && truthy(acc.is_email_verified) ? str(acc.email) : "" }; }
134
+ case "lark": return { ...base, id: str(get(raw, "data", "union_id")), name: str(get(raw, "data", "name")), avatarURL: str(get(raw, "data", "avatar_url")) };
135
+ case "linear": { const v = (get(raw, "data", "viewer") ?? {}) as Raw; if (!truthy(v.active)) throw new Error("the Linear user is not active"); return { ...base, id: str(v.id), name: str(v.name), username: str(v.displayName), email: str(v.email), avatarURL: str(v.avatarUrl) }; }
136
+ case "livechat": return { ...base, id: str(raw.account_id), name: str(raw.name), avatarURL: str(raw.avatar_url), email: truthy(raw.email_verified) ? str(raw.email) : "" };
137
+ case "mailcow": { if (Number(raw.active) !== 1) throw new Error("the mailcow user is not active"); const username = str(raw.username); return { ...base, id: str(raw.id), name: str(raw.full_name), username: username.includes("@") ? username.split("@")[0]! : username, email: str(raw.email) }; }
138
+ case "microsoft": { const claims = token.id_token ? jwtClaims(String(token.id_token)) : {}; return { ...base, id: str(raw.id), name: str(raw.displayName), email: str(claims.email) || str(raw.mail) }; }
139
+ case "monday": { const me = (get(raw, "data", "me") ?? {}) as Raw; if (!truthy(me.enabled)) throw new Error("the monday.com user is not enabled"); return { ...base, id: str(me.id), name: str(me.name), avatarURL: str(me.photo_small), email: truthy(me.is_verified) ? str(me.email) : "" }; }
140
+ case "notion": { const u = (get(raw, "bot", "owner", "user") ?? {}) as Raw; return { ...base, id: str(u.id), name: str(u.name), email: str(get(u, "person", "email")), avatarURL: str(u.avatar_url) }; }
141
+ case "patreon": { const a = (get(raw, "data", "attributes") ?? {}) as Raw; return { ...base, id: str(get(raw, "data", "id")), username: str(a.vanity), name: str(a.full_name), avatarURL: str(a.image_url), email: truthy(a.is_email_verified) ? str(a.email) : "" }; }
142
+ case "planningcenter": { const a = (get(raw, "data", "attributes") ?? {}) as Raw; if (a.status !== "active") throw new Error("the Planning Center user is not active"); return { ...base, id: str(get(raw, "data", "id")), name: str(a.name), avatarURL: str(a.avatar) }; }
143
+ case "spotify": { const images = Array.isArray(raw.images) ? (raw.images as Raw[]) : []; return { ...base, id: str(raw.id), name: str(raw.display_name), avatarURL: str(images[0]?.url) }; }
144
+ case "strava": return { ...base, id: raw.id ? str(raw.id) : "", name: `${str(raw.firstname)} ${str(raw.lastname)}`, username: str(raw.username), avatarURL: str(raw.profile) };
145
+ case "trakt": { const u = (raw.user ?? {}) as Raw; return { ...base, id: str(get(u, "ids", "uuid")), username: str(u.username), name: str(u.name), avatarURL: str(get(u, "images", "avatar", "full")) }; }
146
+ case "twitch": { const d = (Array.isArray(raw.data) ? (raw.data as Raw[])[0] : undefined); if (!d) throw new Error("failed to fetch Twitch user"); return { ...base, id: str(d.id), name: str(d.display_name), username: str(d.login), email: str(d.email), avatarURL: str(d.profile_image_url) }; }
147
+ case "twitter": { const d = (raw.data ?? {}) as Raw; return { ...base, id: str(d.id), name: str(d.name), username: str(d.username), email: str(d.confirmed_email), avatarURL: str(d.profile_image_url) }; }
148
+ case "vk": { const r = (Array.isArray(raw.response) ? (raw.response as Raw[])[0] : undefined); if (!r) throw new Error("failed to fetch VK user"); return { ...base, id: str(r.id), name: `${str(r.first_name)} ${str(r.last_name)}`.trim(), username: str(r.screen_name), avatarURL: str(r.photo_max), email: token.email ? str(token.email) : "" }; }
149
+ case "wakatime": { const d = (raw.data ?? {}) as Raw; return { ...base, id: str(d.id), name: str(d.display_name), username: str(d.username), email: truthy(d.is_email_confirmed) ? str(d.email) : "", avatarURL: truthy(d.photo_public) ? str(d.photo) : "" }; }
150
+ case "yandex": return { ...base, id: str(raw.id), name: str(raw.real_name), username: str(raw.login), email: str(raw.default_email), avatarURL: !truthy(raw.is_avatar_empty) && raw.default_avatar_id ? `https://avatars.yandex.net/get-yapic/${str(raw.default_avatar_id)}/islands-200` : "" };
151
+ default: return { ...base, id: str(raw.sub), name: str(raw.name), username: str(raw.preferred_username), avatarURL: str(raw.picture), email: truthy(raw.email_verified) ? str(raw.email) : "" }; // oidc
152
+ }
153
+ }
@@ -0,0 +1,17 @@
1
+ import bcrypt from "bcryptjs";
2
+
3
+ // PocketBase uses bcrypt with bcrypt.DefaultCost (10) unless the field sets a cost.
4
+ export const DEFAULT_COST = 10;
5
+
6
+ export function hashPassword(plain: string, cost = DEFAULT_COST): Promise<string> {
7
+ return bcrypt.hash(plain, cost);
8
+ }
9
+
10
+ export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
11
+ if (!hash) return false;
12
+ try {
13
+ return await bcrypt.compare(plain, hash);
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
@@ -0,0 +1,50 @@
1
+ // The Worker side of the realtime hub (src/server/hub.ts): a per-instance Durable Object reached through the HUB
2
+ // binding the deploy declares. Record writes publish change events to it, each SSE connection holds one hibernatable
3
+ // WebSocket to it, and subscription changes made on another isolate are relayed through it. Without the binding
4
+ // (the Bun runtime, or a deploy without the hub) everything falls back to the D1 change feed and its poll loop.
5
+ import { logger } from "#platform/log";
6
+ import type { Bindings, Row } from "../types";
7
+
8
+ export interface ChangeEvent { collection: string; recordId: string; action: "create" | "update" | "delete" | "message"; data?: Row | null }
9
+ export type HubMessage =
10
+ | { t: "changes"; changes: ChangeEvent[] }
11
+ | { t: "subs"; subscriptions: string[]; token: string }
12
+ | { t: "message"; event: string; data: unknown };
13
+
14
+ let hub: DurableObjectNamespace | undefined;
15
+ export function attachHub(env: Bindings): void { hub = env.HUB; }
16
+ export const hubActive = (): boolean => !!hub;
17
+
18
+ const stub = () => hub!.get(hub!.idFromName("hub"));
19
+ const post = async (path: string, body: unknown): Promise<Response> => stub().fetch(`https://hub${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
20
+
21
+ /** Fans the changes out to every connected client that subscribed to one of their collections. */
22
+ export async function publishChanges(changes: ChangeEvent[]): Promise<void> {
23
+ if (!hub || !changes.length) return;
24
+ try { const r = await post("/publish", { changes }); if (!r.ok) logger.error("voidbase: hub publish failed", { status: r.status }); }
25
+ catch (err) { logger.error("voidbase: hub publish failed", { error: err instanceof Error ? err.message : String(err) }); }
26
+ }
27
+ /** One-off message to one client (the OAuth2 redirect hand-off). */
28
+ export async function publishToClient(clientId: string, event: string, data: unknown): Promise<boolean> {
29
+ if (!hub) return false;
30
+ const r = await post("/client", { clientId, event, data });
31
+ return r.ok && ((await r.json()) as { delivered: number }).delivered > 0;
32
+ }
33
+ /** Tells the isolate holding the client's stream about its new subscriptions (set through any isolate). */
34
+ export async function controlClient(clientId: string, subscriptions: string[], token: string): Promise<void> {
35
+ if (!hub) return;
36
+ try { await post("/control", { clientId, subscriptions, token }); } catch (err) { logger.error("voidbase: hub control failed", { error: err instanceof Error ? err.message : String(err) }); }
37
+ }
38
+
39
+ /** Opens this connection's socket to the hub (owned by the SSE request, like the stream it feeds). */
40
+ export async function openHubSocket(clientId: string): Promise<WebSocket> {
41
+ const res = await stub().fetch(`https://hub/ws?client=${encodeURIComponent(clientId)}`, { headers: { upgrade: "websocket" } });
42
+ const ws = (res as Response & { webSocket?: WebSocket | null }).webSocket;
43
+ if (!ws) throw new Error(`hub refused the socket (${res.status})`);
44
+ ws.accept();
45
+ return ws;
46
+ }
47
+ /** The collections this connection wants (names), so the hub skips everything else; "*" when it cannot say. */
48
+ export function sendFilter(ws: WebSocket, collections: string[] | "*"): void {
49
+ try { ws.send(JSON.stringify({ t: "filter", collections })); } catch { /* closing */ }
50
+ }