@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,344 @@
1
+ // The JSVM-compatible global API for pb_hooks files, plus the registries their calls populate.
2
+ // Per-request state ($app's database, the request) is carried by AsyncLocalStorage.
3
+ import { AsyncLocalStorage } from "node:async_hooks";
4
+ import type { Context } from "hono";
5
+ import type { Collection } from "../collections/model";
6
+ import { ApiError } from "../errors";
7
+ import { normalizeFilename, sniffMime } from "../records/files";
8
+ import type { RecordContext } from "../records/service";
9
+ import type { Upload } from "../records/values";
10
+ import type { Settings } from "../settings";
11
+ import type { AppEnv, AuthRecord } from "../types";
12
+ import { CollectionRef, HookRecord } from "./record";
13
+
14
+ export interface HookStore {
15
+ c: Context<AppEnv> | null;
16
+ ctx: () => Promise<RecordContext>;
17
+ collections: Map<string, Collection>;
18
+ settings: Settings;
19
+ env: Record<string, unknown>;
20
+ }
21
+ export const hookStore = new AsyncLocalStorage<HookStore>();
22
+ const store = () => hookStore.getStore();
23
+ const mustStore = () => { const s = store(); if (!s) throw new Error("hooks: no request context"); return s; };
24
+
25
+ // ---- registries ------------------------------------------------------------------------------------
26
+ export interface RouteReg { method: string; path: string; handler: HookFn; middlewares: HookMiddleware[] }
27
+ export type HookFn = (e: unknown) => unknown;
28
+ export type HookMiddleware = HookFn | { func: HookFn; id?: string; priority?: number };
29
+ export const routes: RouteReg[] = [];
30
+ export const eventHooks = new Map<string, { fn: HookFn; tags: string[] }[]>();
31
+ export const crons = new Map<string, { expr: string; fn: () => unknown }>();
32
+
33
+ export function onEvent(name: string, fn: HookFn, tags: string[]) {
34
+ const list = eventHooks.get(name) ?? [];
35
+ list.push({ fn, tags });
36
+ eventHooks.set(name, list);
37
+ }
38
+
39
+ // Runs the handlers for an event as a chain around `inner` (the core action). e.next() runs the rest once.
40
+ export const hasHandlers = (name: string, tag: string | null) => (eventHooks.get(name) ?? []).some((h) => h.tags.length === 0 || (tag !== null && h.tags.includes(tag)));
41
+
42
+ export async function trigger<T extends { next?: () => Promise<unknown> }>(name: string, e: T, tag: string | null, inner: () => Promise<unknown>): Promise<unknown> {
43
+ const handlers = (eventHooks.get(name) ?? []).filter((h) => h.tags.length === 0 || (tag !== null && h.tags.includes(tag)));
44
+ if (handlers.length === 0) return inner();
45
+ let i = 0;
46
+ let result: unknown;
47
+ let innerDone = false;
48
+ const next = async () => {
49
+ if (i < handlers.length) { const h = handlers[i++]!; return h.fn(e); }
50
+ if (!innerDone) { innerDone = true; result = await inner(); }
51
+ return result;
52
+ };
53
+ (e as { next: () => Promise<unknown> }).next = next;
54
+ await next();
55
+ if (!innerDone) { innerDone = true; result = await inner(); } // handlers that never call next() still run the core action
56
+ return result;
57
+ }
58
+
59
+ // Request-level hooks (OnXRequest): the handler's work is the innermost step; hooks may write the response
60
+ // themselves (e.json), swap event fields before e.next(), or throw an ApiError.
61
+ export async function requestHook(name: string, c: Context<AppEnv>, tag: string | null, fields: Record<string, unknown>, inner: (ev: RequestEvent & Record<string, unknown>) => Promise<Response>): Promise<Response> {
62
+ const ev = Object.assign(new RequestEvent(c, authToHookRecord(c.get("auth"))), fields) as unknown as RequestEvent & Record<string, unknown>;
63
+ let response: Response | null = null;
64
+ await trigger(name, ev, tag, async () => { response = await inner(ev); return response; });
65
+ return ev.written ?? response ?? c.body(null, 204);
66
+ }
67
+ // Same, for handlers that answer JSON hooks may edit after e.next(): the result lives on ev.result until the end.
68
+ export async function requestHookResult(name: string, c: Context<AppEnv>, tag: string | null, fields: Record<string, unknown>, compute: (ev: RequestEvent & Record<string, unknown>) => Promise<unknown>): Promise<Response> {
69
+ const ev = Object.assign(new RequestEvent(c, authToHookRecord(c.get("auth"))), { result: null, ...fields }) as unknown as RequestEvent & Record<string, unknown>;
70
+ await trigger(name, ev, tag, async () => { ev.result = await compute(ev); return ev.result; });
71
+ return ev.written ?? c.json(ev.result as Record<string, unknown>);
72
+ }
73
+
74
+ // ---- errors (JSVM names) -----------------------------------------------------------------------------
75
+ export class HookApiError extends ApiError {}
76
+ export class NotFoundError extends ApiError { constructor(message = "The requested resource wasn't found.", data?: Record<string, unknown>) { super(404, message, data ?? {}); } }
77
+ export class BadRequestError extends ApiError { constructor(message = "Something went wrong while processing your request.", data?: Record<string, unknown>) { super(400, message, data ?? {}); } }
78
+ export class ForbiddenError extends ApiError { constructor(message = "You are not allowed to perform this request.", data?: Record<string, unknown>) { super(403, message, data ?? {}); } }
79
+ export class UnauthorizedError extends ApiError { constructor(message = "Missing or invalid authentication.", data?: Record<string, unknown>) { super(401, message, data ?? {}); } }
80
+ export class InternalServerError extends ApiError { constructor(message = "Something went wrong while processing your request.", data?: Record<string, unknown>) { super(500, message, data ?? {}); } }
81
+ export class ValidationError extends Error { constructor(public code: string, message: string) { super(message); } }
82
+
83
+ // ---- request event (the `c` / `e` object handlers receive) ------------------------------------------
84
+ export class RequestEvent {
85
+ private storeMap = new Map<string, unknown>();
86
+ written: Response | null = null; // PocketBase handlers write the response (e.JSON) rather than return it
87
+ next: () => Promise<unknown> = async () => undefined;
88
+ record: HookRecord | null = null;
89
+ collection: CollectionRef | null = null;
90
+ constructor(public c: Context<AppEnv>, public auth: HookRecord | null) {}
91
+ get request() { return this.c.req.raw; }
92
+ get app() { return $app; }
93
+ get response() { return this.c.res; }
94
+ // goja values cross into Go maps, which marshal with sorted keys
95
+ json(status: number, data: unknown) { return (this.written = this.c.json(sortKeysDeep(data) as Record<string, unknown>, status as 200)); }
96
+ string(status: number, data: string) { return (this.written = this.c.text(data, status as 200)); }
97
+ html(status: number, data: string) { return (this.written = this.c.html(data, status as 200)); }
98
+ noContent(status = 204) { return (this.written = this.c.body(null, status as 204)); }
99
+ redirect(status: number, url: string) { return (this.written = this.c.redirect(url, status as 302)); }
100
+ get(key: string) { return this.storeMap.get(key); }
101
+ set(key: string, value: unknown) { this.storeMap.set(key, value); }
102
+ params: Record<string, string> = {}; // set by the hook route dispatcher (Hono's catch-all carries no named params)
103
+ pathParam(name: string) { return this.params[name] ?? this.c.req.param(name) ?? ""; }
104
+ queryParam(name: string) { return this.c.req.query(name) ?? ""; }
105
+ hasSuperuserAuth() { return !!this.auth?.isSuperuser(); }
106
+ realIP() { return this.c.req.header("CF-Connecting-IP") ?? this.c.req.header("X-Forwarded-For")?.split(",")[0]?.trim() ?? ""; }
107
+ async requestInfo() {
108
+ const query: Record<string, string> = {}; new URL(this.c.req.url).searchParams.forEach((v, k) => { query[k] = v; });
109
+ const headers: Record<string, string> = {}; this.c.req.raw.headers.forEach((v, k) => { headers[k.toLowerCase().replace(/-/g, "_")] = v; });
110
+ return { method: this.c.req.method, query, headers, body: await this.bindBody({}), auth: this.auth, context: "default", hasSuperuserAuth: () => this.hasSuperuserAuth() };
111
+ }
112
+ async bindBody(target: Record<string, unknown>): Promise<Record<string, unknown>> {
113
+ const ct = this.c.req.header("content-type") ?? "";
114
+ let data: Record<string, unknown> = {};
115
+ try {
116
+ if (ct.includes("multipart/form-data") || ct.includes("application/x-www-form-urlencoded")) data = (await this.c.req.parseBody({ all: true })) as Record<string, unknown>;
117
+ else { const t = await this.c.req.text(); data = t.trim() ? JSON.parse(t) : {}; }
118
+ } catch { data = {}; }
119
+ Object.assign(target, data);
120
+ return target;
121
+ }
122
+ }
123
+
124
+ // ---- $app ---------------------------------------------------------------------------------------------
125
+ export interface AppServices {
126
+ saveCollection(ref: CollectionRef): Promise<CollectionRef>;
127
+ deleteCollection(ref: CollectionRef): Promise<void>;
128
+ saveRecord: (rec: HookRecord) => Promise<HookRecord>;
129
+ deleteRecord: (rec: HookRecord) => Promise<void>;
130
+ findRecordById: (collection: string, id: string) => Promise<HookRecord | null>;
131
+ findRecordsByFilter: (collection: string, filter: string, sort: string, limit: number, offset: number, params?: Record<string, unknown>) => Promise<HookRecord[]>;
132
+ countRecords: (collection: string, where: DbxExpr | string | null) => Promise<number>;
133
+ findAuthRecordByEmail: (collection: string, email: string) => Promise<HookRecord | null>;
134
+ findAuthRecordByToken: (token: string, type: string) => Promise<HookRecord | null>;
135
+ expandRecords: (records: HookRecord[], expands: string[]) => Promise<void>;
136
+ sendMail: (msg: MailerMessage) => Promise<void>;
137
+ }
138
+ // $dbx: the small subset generated hooks use ($dbx.exp("col = {:v}", {v}), $dbx.hashExp({col: v}))
139
+ export interface DbxExpr { sql: string; params: Record<string, unknown> }
140
+ export const $dbx = {
141
+ exp: (sql: string, params: Record<string, unknown> = {}): DbxExpr => ({ sql, params }),
142
+ hashExp: (pairs: Record<string, unknown>): DbxExpr => ({ sql: Object.keys(pairs).map((k) => `[[${k}]] = {:${k}}`).join(" AND "), params: { ...pairs } }),
143
+ };
144
+ let services: AppServices | null = null;
145
+ export function installServices(s: AppServices) { services = s; }
146
+ const svc = () => { if (!services) throw new Error("hooks: services not installed"); return services; };
147
+
148
+ export interface AppApi {
149
+ findCollectionByNameOrId(idOrName: string): CollectionRef;
150
+ findAllCollections(...types: string[]): CollectionRef[];
151
+ findRecordById(collection: string | CollectionRef, id: string): Promise<HookRecord | null>;
152
+ findRecordsByFilter(collection: string | CollectionRef, filter: string, sort?: string, limit?: number, offset?: number, params?: Record<string, unknown>): Promise<HookRecord[]>;
153
+ findFirstRecordByFilter(collection: string | CollectionRef, filter: string, params?: Record<string, unknown>): Promise<HookRecord>;
154
+ findFirstRecordByData(collection: string | CollectionRef, key: string, value: unknown): Promise<HookRecord>;
155
+ countRecords(collection: string | CollectionRef, ...exprs: (DbxExpr | string)[]): Promise<number>;
156
+ findAuthRecordByEmail(collection: string | CollectionRef, email: string): Promise<HookRecord>;
157
+ findAuthRecordByToken(token: string, type?: string): Promise<HookRecord>;
158
+ expandRecord(record: HookRecord, expands: string[], fetch?: unknown): Promise<void>;
159
+ expandRecords(records: HookRecord[], expands: string[], fetch?: unknown): Promise<void>;
160
+ save(model: HookRecord | CollectionRef): Promise<HookRecord | CollectionRef>;
161
+ saveNoValidate(model: HookRecord | CollectionRef): Promise<HookRecord | CollectionRef>;
162
+ delete(model: HookRecord | CollectionRef): Promise<void>;
163
+ settings(): Settings;
164
+ newMailClient(): { send: (msg: MailerMessage) => Promise<void> };
165
+ logger(): Console;
166
+ dao(): AppApi;
167
+ isDev(): boolean;
168
+ runInTransaction(fn: (txApp: AppApi) => unknown): Promise<unknown>;
169
+ }
170
+
171
+ export const $app: AppApi = {
172
+ findCollectionByNameOrId(idOrName: string): CollectionRef {
173
+ const c = mustStore().collections.get(idOrName);
174
+ if (!c) throw new Error(`sql: no rows in result set (collection "${idOrName}")`);
175
+ return new CollectionRef(c);
176
+ },
177
+ findAllCollections(...types: string[]): CollectionRef[] {
178
+ const seen = new Set<string>(); const out: CollectionRef[] = [];
179
+ for (const c of mustStore().collections.values()) { if (seen.has(c.id)) continue; seen.add(c.id); if (!types.length || types.includes(c.type)) out.push(new CollectionRef(c)); }
180
+ return out;
181
+ },
182
+ findRecordById: (collection: string | CollectionRef, id: string) => svc().findRecordById(typeof collection === "string" ? collection : collection.name, id),
183
+ findRecordsByFilter: (collection: string | CollectionRef, filter: string, sort = "", limit = 0, offset = 0, params?: Record<string, unknown>) => svc().findRecordsByFilter(typeof collection === "string" ? collection : collection.name, filter, sort, limit, offset, params),
184
+ async findFirstRecordByFilter(collection: string | CollectionRef, filter: string, params?: Record<string, unknown>) {
185
+ const rows = await svc().findRecordsByFilter(typeof collection === "string" ? collection : collection.name, filter, "", 1, 0, params);
186
+ if (!rows[0]) throw new Error("sql: no rows in result set");
187
+ return rows[0];
188
+ },
189
+ async findFirstRecordByData(collection: string | CollectionRef, key: string, value: unknown) {
190
+ const rows = await svc().findRecordsByFilter(typeof collection === "string" ? collection : collection.name, `${key} = {:v}`, "", 1, 0, { v: value });
191
+ if (!rows[0]) throw new Error("sql: no rows in result set");
192
+ return rows[0];
193
+ },
194
+ countRecords: (collection: string | CollectionRef, ...exprs: (DbxExpr | string)[]) => svc().countRecords(typeof collection === "string" ? collection : collection.name, exprs.length ? (exprs.length === 1 ? exprs[0]! : { sql: exprs.map((x) => `(${typeof x === "string" ? x : x.sql})`).join(" AND "), params: Object.assign({}, ...exprs.map((x) => (typeof x === "string" ? {} : x.params))) }) : null),
195
+ async findAuthRecordByEmail(collection: string | CollectionRef, email: string) {
196
+ const rec = await svc().findAuthRecordByEmail(typeof collection === "string" ? collection : collection.name, email);
197
+ if (!rec) throw new Error("sql: no rows in result set");
198
+ return rec;
199
+ },
200
+ async findAuthRecordByToken(token: string, type = "auth") {
201
+ const rec = await svc().findAuthRecordByToken(token, type);
202
+ if (!rec) throw new Error("sql: no rows in result set");
203
+ return rec;
204
+ },
205
+ expandRecord: (record: HookRecord, expands: string[]) => svc().expandRecords([record], expands),
206
+ expandRecords: (records: HookRecord[], expands: string[]) => svc().expandRecords(records, expands),
207
+ save: (model: HookRecord | CollectionRef) => (model instanceof CollectionRef ? svc().saveCollection(model) : svc().saveRecord(model)),
208
+ saveNoValidate: (model: HookRecord | CollectionRef) => (model instanceof CollectionRef ? svc().saveCollection(model) : svc().saveRecord(model)),
209
+ delete: (model: HookRecord | CollectionRef) => (model instanceof CollectionRef ? svc().deleteCollection(model) : svc().deleteRecord(model)),
210
+ settings() { return mustStore().settings; },
211
+ newMailClient() { return { send: (msg: MailerMessage) => svc().sendMail(msg) }; },
212
+ logger() { return console; },
213
+ dao() { return $app; }, // deprecated alias kept for older hooks
214
+ isDev() { return false; },
215
+ async runInTransaction(fn: (txApp: typeof $app) => unknown) { return fn($app); },
216
+ };
217
+
218
+ export class MailerMessage {
219
+ from: { address: string; name?: string } = { address: "" };
220
+ to: { address: string; name?: string }[] = [];
221
+ cc: { address: string; name?: string }[] = [];
222
+ bcc: { address: string; name?: string }[] = [];
223
+ subject = "";
224
+ html = "";
225
+ text = "";
226
+ headers: Record<string, string> = {};
227
+ constructor(init: Partial<MailerMessage> = {}) { Object.assign(this, init); }
228
+ }
229
+
230
+ export const $apis = {
231
+ // $apis.enrichRecord(e, record, ...expands): expands and applies the auth-aware export (as the API would)
232
+ async enrichRecord(_e: unknown, record: HookRecord, ...expands: string[]): Promise<HookRecord> { if (expands.length) await svc().expandRecords([record], expands); return record; },
233
+ async enrichRecords(_e: unknown, records: HookRecord[], ...expands: string[]): Promise<HookRecord[]> { if (expands.length) await svc().expandRecords(records, expands); return records; },
234
+ requireAuth(...collections: string[]): HookMiddleware {
235
+ return { id: "pbRequireAuth", func: async (e) => {
236
+ const ev = e as RequestEvent;
237
+ if (!ev.auth || (collections.length && !collections.includes(ev.auth.collection().name))) throw new UnauthorizedError("The request requires valid record authorization token.");
238
+ return ev.next();
239
+ } };
240
+ },
241
+ requireSuperuserAuth(): HookMiddleware {
242
+ return { id: "pbRequireSuperuserAuth", func: async (e) => {
243
+ const ev = e as RequestEvent;
244
+ if (!ev.auth) throw new UnauthorizedError("The request requires valid record authorization token.");
245
+ if (!ev.auth.isSuperuser()) throw new ForbiddenError("The authorized record is not allowed to perform this action.");
246
+ return ev.next();
247
+ } };
248
+ },
249
+ requireGuestOnly(): HookMiddleware {
250
+ return { id: "pbRequireGuestOnly", func: async (e) => { const ev = e as RequestEvent; if (ev.auth) throw new BadRequestError("The request can be accessed only by guests."); return ev.next(); } };
251
+ },
252
+ requireSuperuserOrOwnerAuth(ownerIdPathParam = "id"): HookMiddleware {
253
+ return { id: "pbRequireSuperuserOrOwnerAuth", func: async (e) => {
254
+ const ev = e as RequestEvent;
255
+ if (!ev.auth) throw new UnauthorizedError("The request requires superuser or record authorization token.");
256
+ if (!ev.auth.isSuperuser() && ev.auth.id !== ev.pathParam(ownerIdPathParam)) throw new ForbiddenError("You are not allowed to perform this request.");
257
+ return ev.next();
258
+ } };
259
+ },
260
+ };
261
+
262
+ export const $http = {
263
+ async send(cfg: { url: string; method?: string; body?: string; headers?: Record<string, string>; timeout?: number }) {
264
+ const ac = new AbortController();
265
+ const t = setTimeout(() => ac.abort(), (cfg.timeout ?? 120) * 1000);
266
+ try {
267
+ const r = await fetch(cfg.url, { method: cfg.method ?? "GET", body: cfg.body, headers: cfg.headers, signal: ac.signal });
268
+ const raw = await r.text();
269
+ let json: unknown = null; try { json = JSON.parse(raw); } catch { json = null; }
270
+ const headers: Record<string, string[]> = {}; r.headers.forEach((v, k) => { headers[k] = [v]; });
271
+ return { statusCode: r.status, headers, cookies: {}, raw, json, body: new TextEncoder().encode(raw) };
272
+ } finally { clearTimeout(t); }
273
+ },
274
+ };
275
+
276
+ export const $filesystem = {
277
+ async fileFromURL(url: string): Promise<Upload> {
278
+ const r = await fetch(url);
279
+ if (!r.ok) throw new Error(`failed to download ${url}: ${r.status}`);
280
+ const bytes = await r.arrayBuffer();
281
+ const name = decodeURIComponent(new URL(url).pathname.split("/").filter(Boolean).pop() ?? "file");
282
+ const sniffed = sniffMime(new Uint8Array(bytes), r.headers.get("content-type") ?? "", name);
283
+ return { name: normalizeFilename(name, sniffed.ext), type: sniffed.type, size: bytes.byteLength, bytes };
284
+ },
285
+ async fileFromBytes(bytes: ArrayBuffer | Uint8Array | number[], name: string): Promise<Upload> {
286
+ const buf = bytes instanceof ArrayBuffer ? bytes : new Uint8Array(bytes).buffer as ArrayBuffer;
287
+ const sniffed = sniffMime(new Uint8Array(buf), "", name);
288
+ return { name: normalizeFilename(name, sniffed.ext), type: sniffed.type, size: buf.byteLength, bytes: buf };
289
+ },
290
+ async fileFromPath(): Promise<never> { throw new Error("$filesystem.fileFromPath is not available on Workers"); },
291
+ };
292
+
293
+ export const $security = {
294
+ randomString: (n: number) => { const a = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; const b = crypto.getRandomValues(new Uint8Array(n)); let s = ""; for (let i = 0; i < n; i++) s += a[b[i]! % a.length]; return s; },
295
+ randomStringWithAlphabet: (n: number, a: string) => { const b = crypto.getRandomValues(new Uint8Array(n)); let s = ""; for (let i = 0; i < n; i++) s += a[b[i]! % a.length]; return s; },
296
+ pseudorandomString: (n: number) => $security.randomString(n),
297
+ sha256: async (s: string) => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)))).map((b) => b.toString(16).padStart(2, "0")).join(""),
298
+ };
299
+
300
+ export function makeOs(files: Record<string, string>, hooksDir: string) {
301
+ return {
302
+ getenv: (name: string) => { const v = store()?.env[name]; return v == null ? "" : String(v); },
303
+ readFile: (path: string) => {
304
+ const rel = path.startsWith(hooksDir) ? path.slice(hooksDir.length).replace(/^\/+/, "") : path;
305
+ const text = files[rel];
306
+ if (text === undefined) throw new Error(`open ${path}: no such file or directory`);
307
+ return Array.from(new TextEncoder().encode(text));
308
+ },
309
+ writeFile: () => { throw new Error("$os.writeFile is not available on Workers"); },
310
+ exec: () => { throw new Error("$os.exec is not available on Workers"); },
311
+ cmd: () => { throw new Error("$os.cmd is not available on Workers"); },
312
+ args: [] as string[],
313
+ };
314
+ }
315
+
316
+ export function routerAdd(method: string, path: string, handler: HookFn, ...middlewares: HookMiddleware[]) {
317
+ routes.push({ method: method.toUpperCase() === "ANY" ? "ALL" : method.toUpperCase(), path: toHonoPath(path), handler, middlewares });
318
+ }
319
+ export function routerUse(..._middlewares: HookMiddleware[]) { /* global hook middleware: milestone six */ }
320
+ export function cronAdd(id: string, expr: string, fn: () => unknown) { crons.set(id, { expr, fn }); }
321
+ export function cronRemove(id: string) { crons.delete(id); }
322
+
323
+ // Go 1.22 mux patterns -> Hono: {name} -> :name, {path...} -> *
324
+ export function toHonoPath(p: string): string {
325
+ return p.replace(/\{(\w+)\.\.\.\}/g, "*").replace(/\{(\w+)\}/g, ":$1");
326
+ }
327
+
328
+ export const RecordUpsertFormFactory = (_app: AppApi) =>
329
+ class RecordUpsertForm {
330
+ constructor(private a: AppApi, private record: HookRecord) {}
331
+ submit() { return this.a.save(this.record); }
332
+ setRecord(r: HookRecord) { this.record = r; }
333
+ };
334
+
335
+ export const authToHookRecord = (auth: AuthRecord | null) => (auth ? HookRecord.fromRow(auth.collection, auth.row) : null);
336
+
337
+ export function sortKeysDeep(v: unknown): unknown {
338
+ if (Array.isArray(v)) return v.map(sortKeysDeep);
339
+ if (v && typeof v === "object" && !(v instanceof HookRecord) && Object.getPrototypeOf(v) === Object.prototype) {
340
+ return Object.fromEntries(Object.entries(v as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([k, x]) => [k, sortKeysDeep(x)]));
341
+ }
342
+ if (v instanceof HookRecord) return v.publicExport();
343
+ return v;
344
+ }
@@ -0,0 +1,4 @@
1
+ declare module "virtual:voidbase-migrations" {
2
+ export const migrationsDir: string;
3
+ export const migrations: { name: string; run: (g: Record<string, unknown>) => Promise<void> }[];
4
+ }
@@ -0,0 +1,7 @@
1
+ declare module "virtual:voidbase-hooks" {
2
+ export const hooksDir: string;
3
+ export const asyncNames: string[];
4
+ export const hooks: { name: string; run: (g: Record<string, unknown>) => Promise<void> }[];
5
+ export const modules: Record<string, (g: Record<string, unknown>) => Promise<unknown>>;
6
+ export const files: Record<string, string>;
7
+ }
@@ -0,0 +1,91 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ // The realtime hub: one SQLite-backed Durable Object per voidbase instance, exported from the instance's own Worker
3
+ // (hooks-plugin appends it to Void's generated entry; the deploy's wrangler.jsonc declares the HUB binding and the
4
+ // new_sqlite_classes migration), so no two instances share it or anything else. Every SSE connection holds one
5
+ // hibernatable WebSocket here; a record write POSTs /publish and the object sends the change to the sockets whose
6
+ // filter includes the collection, then goes back to sleep. Nothing is stored beyond each socket's attachment.
7
+ interface Attachment { id: string; all: boolean; collections: string[]; since: number }
8
+ interface Change { collection: string; recordId: string; action: string; data?: unknown }
9
+
10
+ export class VoidbaseHub implements DurableObject {
11
+ constructor(private readonly state: DurableObjectState) {
12
+ // keepalive answered without waking the object; the alarm sweeps sockets that stopped pinging
13
+ this.state.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));
14
+ }
15
+
16
+ // Connections ping every minute (auto-answered). A Worker whose request was torn down never closes its socket, so
17
+ // every few minutes the object drops sockets whose last ping is older than two intervals.
18
+ async alarm(): Promise<void> {
19
+ const open = this.sweep(150_000);
20
+ await this.state.storage.put("lastSweep", Date.now());
21
+ if (open) await this.state.storage.setAlarm(Date.now() + 120_000);
22
+ }
23
+ // closes sockets that have not pinged for staleMs; returns how many stay open
24
+ private sweep(staleMs: number): number {
25
+ const stale = Date.now() - staleMs;
26
+ let open = 0;
27
+ for (const ws of this.state.getWebSockets()) {
28
+ const seen = this.state.getWebSocketAutoResponseTimestamp(ws)?.getTime() ?? this.attachment(ws).since;
29
+ if (seen < stale) { try { ws.close(1001, "stale"); } catch { /* gone */ } } else open++;
30
+ }
31
+ return open;
32
+ }
33
+ private async armSweep(): Promise<void> { if ((await this.state.storage.getAlarm()) === null) await this.state.storage.setAlarm(Date.now() + 120_000); }
34
+
35
+ async fetch(req: Request): Promise<Response> {
36
+ const url = new URL(req.url);
37
+ if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
38
+ const clientId = url.searchParams.get("client") ?? "";
39
+ const pair = new WebSocketPair();
40
+ this.state.acceptWebSocket(pair[1], clientId ? [clientId] : []);
41
+ pair[1].serializeAttachment({ id: clientId, all: true, collections: [], since: Date.now() } satisfies Attachment);
42
+ await this.armSweep();
43
+ return new Response(null, { status: 101, webSocket: pair[0] });
44
+ }
45
+ if (url.pathname === "/stats") return Response.json({ sockets: this.state.getWebSockets().length, alarm: await this.state.storage.getAlarm(), lastSweep: (await this.state.storage.get<number>("lastSweep")) ?? null });
46
+ if (req.method !== "POST") return new Response("Not Found", { status: 404 });
47
+ const body = (await req.json()) as Record<string, unknown>;
48
+ if (url.pathname === "/sweep") { const before = this.state.getWebSockets().length; const open = this.sweep(Number(body.staleMs ?? 150_000)); return Response.json({ before, closed: before - open, open, after: this.state.getWebSockets().length }); }
49
+ if (url.pathname === "/publish") {
50
+ const changes = (body.changes ?? []) as Change[];
51
+ let delivered = 0;
52
+ for (const ws of this.state.getWebSockets()) {
53
+ const att = this.attachment(ws);
54
+ const mine = att.all ? changes : changes.filter((ch) => att.collections.includes(ch.collection));
55
+ if (!mine.length) continue;
56
+ try { ws.send(JSON.stringify({ t: "changes", changes: mine })); delivered++; } catch { this.drop(ws); }
57
+ }
58
+ return Response.json({ delivered });
59
+ }
60
+ if (url.pathname === "/client" || url.pathname === "/control") {
61
+ const clientId = String(body.clientId ?? "");
62
+ const msg = url.pathname === "/client" ? { t: "message", event: body.event, data: body.data } : { t: "subs", subscriptions: body.subscriptions, token: body.token };
63
+ let delivered = 0;
64
+ for (const ws of clientId ? this.state.getWebSockets(clientId) : []) { try { ws.send(JSON.stringify(msg)); delivered++; } catch { this.drop(ws); } }
65
+ return Response.json({ delivered });
66
+ }
67
+ return new Response("Not Found", { status: 404 });
68
+ }
69
+
70
+ // the connection narrows what it wants: {t:"filter", collections:[names] | "*"}
71
+ webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void {
72
+ if (typeof message !== "string") return;
73
+ try {
74
+ const m = JSON.parse(message) as { t?: string; collections?: string[] | "*" };
75
+ if (m.t !== "filter") return;
76
+ const att = this.attachment(ws);
77
+ const list = Array.isArray(m.collections) ? m.collections : null;
78
+ const all = !list;
79
+ const collections = list ? list.map(String).slice(0, 100) : [];
80
+ ws.serializeAttachment({ ...att, all, collections } satisfies Attachment);
81
+ } catch { /* ignore malformed frames */ }
82
+ }
83
+ webSocketClose(ws: WebSocket): void { try { ws.close(); } catch { /* already closed */ } }
84
+ webSocketError(ws: WebSocket): void { try { ws.close(); } catch { /* already closed */ } }
85
+
86
+ private drop(ws: WebSocket): void { try { ws.close(1011, "send failed"); } catch { /* gone */ } }
87
+ private attachment(ws: WebSocket): Attachment {
88
+ const att = ws.deserializeAttachment() as Attachment | null;
89
+ return att ?? { id: "", all: true, collections: [], since: 0 };
90
+ }
91
+ }
@@ -0,0 +1,22 @@
1
+ const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
2
+ const STRING_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
3
+
4
+ export function randomWithAlphabet(length: number, alphabet: string): string {
5
+ const bytes = crypto.getRandomValues(new Uint8Array(length));
6
+ let out = "";
7
+ for (let i = 0; i < length; i++) out += alphabet[bytes[i]! % alphabet.length];
8
+ return out;
9
+ }
10
+
11
+ // PocketBase record ids: 15 chars of [a-z0-9].
12
+ export const randomId = () => randomWithAlphabet(15, ID_ALPHABET);
13
+ // PocketBase security.RandomString: [a-zA-Z0-9]. Used for token secrets (50) and tokenKey (50).
14
+ export const randomString = (length: number) => randomWithAlphabet(length, STRING_ALPHABET);
15
+
16
+ // PocketBase datetime format: "2006-01-02 15:04:05.000Z"
17
+ export function nowString(d = new Date()): string {
18
+ return d.toISOString().replace("T", " ");
19
+ }
20
+
21
+ // PocketBase appends a 10-char pseudorandom suffix to auto-generated index names.
22
+ export const randomIdSuffix = () => randomWithAlphabet(10, ID_ALPHABET);
@@ -0,0 +1,84 @@
1
+ // Background jobs: outbound mail and automatic backups. On Cloudflare a Queue carries them
2
+ // (binding QUEUE_JOBS, declared by queues/jobs.ts) with at-least-once delivery and retries; without the binding
3
+ // (the Bun runtime, or a deploy whose token could not create the queue) `dispatch` runs the job inline instead.
4
+ // Handlers register themselves from the module that owns the work, so this module stays free of heavy imports.
5
+ import { logger } from "#platform/log";
6
+ import type { MailMessage } from "./mail/message";
7
+ import type { Bindings } from "./types";
8
+
9
+ export type Job =
10
+ | { type: "mail"; message: MailMessage; text: string }
11
+ | { type: "backup"; name: string };
12
+ export type JobHandler<T extends Job = Job> = (env: Bindings, job: T) => Promise<void>;
13
+
14
+ const handlers = new Map<Job["type"], JobHandler>();
15
+ export function registerJobHandler<T extends Job["type"]>(type: T, fn: JobHandler<Extract<Job, { type: T }>>): void { handlers.set(type, fn as JobHandler); }
16
+
17
+ // the bindings of the last bootstrap on this isolate (Workers hand every request the same binding objects)
18
+ let attached: Bindings | undefined;
19
+ export function attachJobs(env: Bindings): void { attached = env; }
20
+ // Void names the binding after the consumer file: QUEUE_JOBS for queues/jobs.ts (this checkout), QUEUE_<WORKER>_JOBS for
21
+ // the queues/<worker>-jobs.ts a deploy writes (queue names are account-wide, so each app gets its own)
22
+ export function jobQueue(env: Bindings | undefined): Bindings["QUEUE_JOBS"] | undefined {
23
+ if (!env) return undefined;
24
+ if (env.QUEUE_JOBS) return env.QUEUE_JOBS;
25
+ const key = Object.keys(env).find((k) => /^QUEUE_[A-Z0-9_]*JOBS$/.test(k));
26
+ return key ? (env as unknown as Record<string, Bindings["QUEUE_JOBS"]>)[key] : undefined;
27
+ }
28
+ export const jobsQueued = (): boolean => !!jobQueue(attached);
29
+
30
+ const MAX_MESSAGE_BYTES = 120_000; // Cloudflare Queues: 128 KB per message, ~100 bytes of it metadata
31
+
32
+ export async function runJob(env: Bindings, job: Job): Promise<void> {
33
+ const fn = handlers.get(job.type);
34
+ if (!fn) throw new Error(`voidbase: no handler for job "${job.type}"`);
35
+ await fn(env, job);
36
+ }
37
+
38
+ /** Queues the job when a queue is attached (and the message fits), otherwise runs it now. `inline` forces the
39
+ * immediate path so the caller sees delivery errors (the panel's test email, hooks' mail client). */
40
+ export async function dispatch(job: Job, opts: { env?: Bindings; inline?: boolean } = {}): Promise<"queued" | "ran"> {
41
+ const env = opts.env ?? attached;
42
+ const queue = jobQueue(env) ?? jobQueue(attached);
43
+ if (!opts.inline && queue && JSON.stringify(job).length <= MAX_MESSAGE_BYTES) { await queue.send(job); return "queued"; }
44
+ if (!env) throw new Error("voidbase: jobs have no bindings (attachJobs was never called)");
45
+ await runJob(env, job);
46
+ return "ran";
47
+ }
48
+
49
+ /** Queue-only: for work that is an optimization and is simply skipped without a queue. */
50
+ export async function dispatchIfQueued(job: Job, env: Bindings | undefined = attached): Promise<boolean> {
51
+ const queue = jobQueue(env);
52
+ if (!queue) return false;
53
+ try { await queue.send(job); return true; } catch (err) { logger.warn("voidbase: queue send failed", { job: job.type, error: err instanceof Error ? err.message : String(err) }); return false; }
54
+ }
55
+
56
+ export interface QueuedMessage { id: string; body: Job; attempts: number; ack(): void; retry(options?: { delaySeconds?: number }): void }
57
+ export interface JobBatch { queue?: string; messages: QueuedMessage[] }
58
+
59
+ /** The queue consumer: runs every message, retries failures with backoff (30 s, 60 s, ... up to 15 min) and logs the
60
+ * ones Cloudflare is about to drop after `maxRetries` (the consumer file's export). */
61
+ export async function consumeJobs(batch: JobBatch, env: Bindings, maxRetries = 5): Promise<{ done: number; failed: number }> {
62
+ attachJobs(env);
63
+ let done = 0, failed = 0;
64
+ for (const msg of batch.messages) {
65
+ try { await runJob(env, msg.body); msg.ack(); done++; }
66
+ catch (err) {
67
+ failed++;
68
+ const error = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
69
+ const dropping = msg.attempts > maxRetries;
70
+ logger.error(dropping ? "voidbase: job dropped after its last retry" : "voidbase: job failed, will retry", { job: msg.body.type, id: msg.id, attempt: msg.attempts, error });
71
+ if (dropping) await alertWebhook({ message: `job ${msg.body.type} dropped after ${msg.attempts} attempts`, job: msg.body.type, id: msg.id, error });
72
+ msg.retry({ delaySeconds: Math.min(900, 30 * 2 ** Math.max(0, msg.attempts - 1)) });
73
+ }
74
+ }
75
+ return { done, failed };
76
+ }
77
+
78
+ // VOIDBASE_ALERT_WEBHOOK_URL: the same receiver the request error handler posts to
79
+ export async function alertWebhook(fields: Record<string, unknown>): Promise<void> {
80
+ const { env } = await import("#platform/env");
81
+ const webhook = String((env as Record<string, unknown>).VOIDBASE_ALERT_WEBHOOK_URL ?? "").trim();
82
+ if (!webhook) return;
83
+ await fetch(webhook, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ source: "voidbase", level: "error", time: new Date().toISOString(), ...fields }) }).catch(() => undefined);
84
+ }
@@ -0,0 +1,61 @@
1
+ // HS256 JWT compatible with PocketBase tokens (header {"alg":"HS256","typ":"JWT"}).
2
+ const enc = new TextEncoder();
3
+
4
+ export function b64urlEncode(input: string | Uint8Array): string {
5
+ const bytes = typeof input === "string" ? enc.encode(input) : input;
6
+ let bin = "";
7
+ for (const b of bytes) bin += String.fromCharCode(b);
8
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
9
+ }
10
+
11
+ export function b64urlDecode(input: string): Uint8Array<ArrayBuffer> {
12
+ const pad = "=".repeat((4 - (input.length % 4)) % 4);
13
+ const bin = atob(input.replace(/-/g, "+").replace(/_/g, "/") + pad);
14
+ const out = new Uint8Array(bin.length);
15
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
16
+ return out;
17
+ }
18
+
19
+ async function hmacKey(secret: string): Promise<CryptoKey> {
20
+ return crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
21
+ }
22
+
23
+ export type JwtClaims = Record<string, unknown> & { exp?: number };
24
+
25
+ export async function signJWT(claims: JwtClaims, secret: string, durationSeconds: number): Promise<string> {
26
+ // Go's jwt.MapClaims marshals keys sorted; match it so tokens diff cleanly against PocketBase.
27
+ const unsorted: JwtClaims = { ...claims, exp: Math.floor(Date.now() / 1000) + durationSeconds };
28
+ const payload = Object.fromEntries(Object.keys(unsorted).sort().map((k) => [k, unsorted[k]])) as JwtClaims;
29
+ const head = b64urlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
30
+ const body = b64urlEncode(JSON.stringify(payload));
31
+ const sig = await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(`${head}.${body}`));
32
+ return `${head}.${body}.${b64urlEncode(new Uint8Array(sig))}`;
33
+ }
34
+
35
+ // Decode without verifying. Returns null on malformed input.
36
+ export function decodeJWT(token: string): JwtClaims | null {
37
+ const parts = token.split(".");
38
+ if (parts.length !== 3) return null;
39
+ try {
40
+ return JSON.parse(new TextDecoder().decode(b64urlDecode(parts[1]!)));
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ export async function verifyJWT(token: string, secret: string): Promise<JwtClaims | null> {
47
+ const parts = token.split(".");
48
+ if (parts.length !== 3) return null;
49
+ const [head, body, sig] = parts as [string, string, string];
50
+ let ok = false;
51
+ try {
52
+ ok = await crypto.subtle.verify("HMAC", await hmacKey(secret), b64urlDecode(sig), enc.encode(`${head}.${body}`));
53
+ } catch {
54
+ return null;
55
+ }
56
+ if (!ok) return null;
57
+ const claims = decodeJWT(token);
58
+ if (!claims) return null;
59
+ if (typeof claims.exp !== "number" || claims.exp <= Date.now() / 1000) return null;
60
+ return claims;
61
+ }