@mybase-dev/sdk 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +147 -34
- package/dist/analytics-script.d.ts +3 -0
- package/dist/better-auth.d.ts +123 -0
- package/dist/better-auth.js +81 -0
- package/dist/captcha-widget.d.ts +1 -0
- package/dist/captcha.d.ts +54 -0
- package/dist/captcha.js +239 -0
- package/dist/captcha.min.js +1 -0
- package/dist/config.d.ts +6 -0
- package/dist/search.d.ts +120 -0
- package/dist/search.js +315 -0
- package/dist/secrets.d.ts +138 -0
- package/dist/secrets.js +274 -0
- package/dist/vector.d.ts +234 -0
- package/dist/vector.js +331 -0
- package/package.json +33 -3
- package/src/better-auth.ts +189 -0
- package/src/captcha.ts +110 -0
- package/src/config.ts +10 -3
- package/src/search.ts +250 -0
- package/src/secrets.ts +234 -0
- package/src/vector.ts +356 -0
package/dist/vector.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
function defineConfig(config) {
|
|
3
|
+
return config;
|
|
4
|
+
}
|
|
5
|
+
var DEFAULT_CRON_PATH = "/api/cron";
|
|
6
|
+
|
|
7
|
+
// src/cron.ts
|
|
8
|
+
var SIGNATURE_HEADER = "X-Mybase-Signature";
|
|
9
|
+
var JOB_HEADER = "X-Mybase-Job";
|
|
10
|
+
var RUN_HEADER = "X-Mybase-Run";
|
|
11
|
+
|
|
12
|
+
class CronVerificationError extends Error {
|
|
13
|
+
name = "CronVerificationError";
|
|
14
|
+
}
|
|
15
|
+
var encoder = new TextEncoder;
|
|
16
|
+
async function hmacHex(secret, message) {
|
|
17
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
18
|
+
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(message)));
|
|
19
|
+
return Array.from(mac, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
20
|
+
}
|
|
21
|
+
function safeEqual(a, b) {
|
|
22
|
+
if (a.length !== b.length)
|
|
23
|
+
return false;
|
|
24
|
+
let diff = 0;
|
|
25
|
+
for (let i = 0;i < a.length; i++)
|
|
26
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
27
|
+
return diff === 0;
|
|
28
|
+
}
|
|
29
|
+
async function signCronRequest(secret, body, t = Math.floor(Date.now() / 1000)) {
|
|
30
|
+
return `t=${t},v1=${await hmacHex(secret, `${t}.${body}`)}`;
|
|
31
|
+
}
|
|
32
|
+
async function verifySigned(signature, secret, toleranceSec, message) {
|
|
33
|
+
if (!signature)
|
|
34
|
+
throw new CronVerificationError(`missing ${SIGNATURE_HEADER} header`);
|
|
35
|
+
let t;
|
|
36
|
+
const sigs = [];
|
|
37
|
+
for (const part of signature.split(",")) {
|
|
38
|
+
const [k, v] = part.trim().split("=", 2);
|
|
39
|
+
if (k === "t" && v && /^\d+$/.test(v))
|
|
40
|
+
t = Number(v);
|
|
41
|
+
else if (k === "v1" && v)
|
|
42
|
+
sigs.push(v);
|
|
43
|
+
}
|
|
44
|
+
if (t === undefined || sigs.length === 0)
|
|
45
|
+
throw new CronVerificationError(`malformed ${SIGNATURE_HEADER} header`);
|
|
46
|
+
if (Math.abs(Date.now() / 1000 - t) > toleranceSec)
|
|
47
|
+
throw new CronVerificationError("signature timestamp outside tolerance");
|
|
48
|
+
const secrets = typeof secret === "string" ? [secret] : secret;
|
|
49
|
+
if (secrets.length === 0 || secrets.some((s) => !s))
|
|
50
|
+
throw new CronVerificationError("no cron secret configured");
|
|
51
|
+
for (const s of secrets) {
|
|
52
|
+
const expected = await hmacHex(s, message(t));
|
|
53
|
+
if (sigs.some((sig) => safeEqual(sig, expected)))
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
throw new CronVerificationError("signature does not match");
|
|
57
|
+
}
|
|
58
|
+
async function verifyCronSignature({ body, signature, secret, toleranceSec = 300 }) {
|
|
59
|
+
await verifySigned(signature, secret, toleranceSec, (t) => `${t}.${body}`);
|
|
60
|
+
}
|
|
61
|
+
async function verifyCronRequest(req, secret, options = {}) {
|
|
62
|
+
const body = await req.text();
|
|
63
|
+
await verifyCronSignature({ body, signature: req.headers.get(SIGNATURE_HEADER), secret, ...options });
|
|
64
|
+
let run;
|
|
65
|
+
try {
|
|
66
|
+
run = JSON.parse(body);
|
|
67
|
+
} catch {
|
|
68
|
+
throw new CronVerificationError("body is not JSON");
|
|
69
|
+
}
|
|
70
|
+
if (typeof run?.job !== "string" || typeof run.runId !== "string")
|
|
71
|
+
throw new CronVerificationError("body is not a cron run");
|
|
72
|
+
return run;
|
|
73
|
+
}
|
|
74
|
+
var reply = (body, status) => new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
|
75
|
+
function cronHandler(options) {
|
|
76
|
+
const jobs = options.jobs;
|
|
77
|
+
return async (req) => {
|
|
78
|
+
if (req.method !== "POST")
|
|
79
|
+
return reply({ error: "method not allowed" }, 405);
|
|
80
|
+
let run;
|
|
81
|
+
try {
|
|
82
|
+
run = await verifyCronRequest(req, options.secret, { toleranceSec: options.toleranceSec });
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if (err instanceof CronVerificationError)
|
|
85
|
+
return reply({ error: err.message }, 401);
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
const job = Object.hasOwn(jobs, run.job) ? jobs[run.job] : undefined;
|
|
89
|
+
if (!job)
|
|
90
|
+
return reply({ error: `no handler for job "${run.job}"` }, 404);
|
|
91
|
+
try {
|
|
92
|
+
await job(run);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (options.onError)
|
|
95
|
+
options.onError(err, run);
|
|
96
|
+
else
|
|
97
|
+
console.error(`mybase cron job "${run.job}" failed`, err);
|
|
98
|
+
return reply({ error: "job failed" }, 500);
|
|
99
|
+
}
|
|
100
|
+
return reply({ ok: true }, 200);
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
class MybaseApiError extends Error {
|
|
105
|
+
status;
|
|
106
|
+
name = "MybaseApiError";
|
|
107
|
+
constructor(message, status) {
|
|
108
|
+
super(message);
|
|
109
|
+
this.status = status;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
var UNITS = { ms: 1, s: 1000, m: 60000, h: 3600000, d: 86400000 };
|
|
113
|
+
function parseDelay(value) {
|
|
114
|
+
if (typeof value === "number") {
|
|
115
|
+
if (!Number.isFinite(value) || value < 0)
|
|
116
|
+
throw new RangeError("delay must be a non-negative number of ms");
|
|
117
|
+
return Math.round(value);
|
|
118
|
+
}
|
|
119
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)\s*$/.exec(value);
|
|
120
|
+
if (!m)
|
|
121
|
+
throw new RangeError(`invalid delay "${value}". Use ms, or a number with ms, s, m, h, or d (e.g. "15m")`);
|
|
122
|
+
return Math.round(Number(m[1]) * UNITS[m[2]]);
|
|
123
|
+
}
|
|
124
|
+
function createCronClient(options) {
|
|
125
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
126
|
+
const apiUrl = options.apiUrl.replace(/\/+$/, "");
|
|
127
|
+
const base = `/cron/${encodeURIComponent(options.project)}/tasks`;
|
|
128
|
+
async function call(method, path, body) {
|
|
129
|
+
const raw = body === undefined ? "" : JSON.stringify(body);
|
|
130
|
+
const res = await doFetch(`${apiUrl}${path}`, {
|
|
131
|
+
method,
|
|
132
|
+
body: body === undefined ? undefined : raw,
|
|
133
|
+
headers: {
|
|
134
|
+
"Content-Type": "application/json",
|
|
135
|
+
Authorization: `Bearer ${options.apiKey}`
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
if (res.status === 404 && method !== "POST")
|
|
139
|
+
return null;
|
|
140
|
+
const data = await res.json().catch(() => ({}));
|
|
141
|
+
if (!res.ok)
|
|
142
|
+
throw new MybaseApiError(data.error ?? `mybase API answered ${res.status}`, res.status);
|
|
143
|
+
return data;
|
|
144
|
+
}
|
|
145
|
+
const byId = (id) => `${base}/${encodeURIComponent(id)}`;
|
|
146
|
+
const byKey = (key) => `${base}/by-key/${encodeURIComponent(key)}`;
|
|
147
|
+
return {
|
|
148
|
+
async schedule(job, opts = {}) {
|
|
149
|
+
if (opts.at !== undefined && opts.in !== undefined)
|
|
150
|
+
throw new TypeError("give `at` or `in`, not both");
|
|
151
|
+
let url = opts.url;
|
|
152
|
+
if (!url) {
|
|
153
|
+
if (!options.baseUrl)
|
|
154
|
+
throw new TypeError("set `baseUrl` on the client, or `url` on the task");
|
|
155
|
+
url = `${options.baseUrl.replace(/\/+$/, "")}/${(opts.path ?? DEFAULT_CRON_PATH).replace(/^\/+/, "")}`;
|
|
156
|
+
}
|
|
157
|
+
const at = opts.at === undefined ? undefined : new Date(opts.at);
|
|
158
|
+
if (at && Number.isNaN(at.getTime()))
|
|
159
|
+
throw new RangeError(`invalid date "${String(opts.at)}"`);
|
|
160
|
+
const req = {
|
|
161
|
+
job,
|
|
162
|
+
url,
|
|
163
|
+
runAt: at?.toISOString(),
|
|
164
|
+
delayMs: opts.in === undefined ? undefined : parseDelay(opts.in),
|
|
165
|
+
key: opts.key,
|
|
166
|
+
payload: opts.payload,
|
|
167
|
+
retries: opts.retries,
|
|
168
|
+
timeoutMs: opts.timeoutMs
|
|
169
|
+
};
|
|
170
|
+
return await call("POST", base, req);
|
|
171
|
+
},
|
|
172
|
+
get: (id) => call("GET", byId(id)),
|
|
173
|
+
getByKey: (key) => call("GET", byKey(key)),
|
|
174
|
+
cancel: (id) => call("DELETE", byId(id)),
|
|
175
|
+
cancelByKey: (key) => call("DELETE", byKey(key))
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/vector.ts
|
|
180
|
+
var VECTOR_INDEX_PATTERN = /^[a-z][a-z0-9_]{0,47}$/;
|
|
181
|
+
var VECTOR_INDEX_RULES = "Use 1–48 lowercase letters, digits, or `_`, starting with a letter.";
|
|
182
|
+
var VECTOR_METRICS = ["cosine", "l2", "ip"];
|
|
183
|
+
var VECTOR_LANGUAGES = [
|
|
184
|
+
"simple",
|
|
185
|
+
"arabic",
|
|
186
|
+
"armenian",
|
|
187
|
+
"basque",
|
|
188
|
+
"catalan",
|
|
189
|
+
"danish",
|
|
190
|
+
"dutch",
|
|
191
|
+
"english",
|
|
192
|
+
"finnish",
|
|
193
|
+
"french",
|
|
194
|
+
"german",
|
|
195
|
+
"greek",
|
|
196
|
+
"hindi",
|
|
197
|
+
"hungarian",
|
|
198
|
+
"indonesian",
|
|
199
|
+
"irish",
|
|
200
|
+
"italian",
|
|
201
|
+
"lithuanian",
|
|
202
|
+
"nepali",
|
|
203
|
+
"norwegian",
|
|
204
|
+
"portuguese",
|
|
205
|
+
"romanian",
|
|
206
|
+
"russian",
|
|
207
|
+
"serbian",
|
|
208
|
+
"spanish",
|
|
209
|
+
"swedish",
|
|
210
|
+
"tamil",
|
|
211
|
+
"turkish",
|
|
212
|
+
"yiddish"
|
|
213
|
+
];
|
|
214
|
+
var VECTOR_QUERY_MODES = ["text", "vector", "hybrid"];
|
|
215
|
+
var VECTOR_LIMITS = {
|
|
216
|
+
batch: 100,
|
|
217
|
+
batchBytes: 8 * 1024 * 1024,
|
|
218
|
+
idLength: 256,
|
|
219
|
+
titleLength: 1000,
|
|
220
|
+
textLength: 1e5,
|
|
221
|
+
metadataBytes: 16 * 1024,
|
|
222
|
+
dimensions: { min: 1, max: 2000 },
|
|
223
|
+
topK: { min: 1, max: 100, default: 10 },
|
|
224
|
+
queryLength: 2000,
|
|
225
|
+
indexesPerProject: 50
|
|
226
|
+
};
|
|
227
|
+
function createVectorClient(options) {
|
|
228
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
229
|
+
const base = `${options.apiUrl.replace(/\/+$/, "")}/vector/${encodeURIComponent(options.project)}/indexes`;
|
|
230
|
+
const timeoutMs = options.timeoutMs ?? 30000;
|
|
231
|
+
const path = (name, rest = "") => `${base}/${encodeURIComponent(name)}${rest}`;
|
|
232
|
+
async function call(method, url, body) {
|
|
233
|
+
const res = await doFetch(url, {
|
|
234
|
+
method,
|
|
235
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
236
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${options.apiKey}` },
|
|
237
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
238
|
+
});
|
|
239
|
+
if (res.status === 204)
|
|
240
|
+
return;
|
|
241
|
+
const data = await res.json().catch(() => ({}));
|
|
242
|
+
if (!res.ok)
|
|
243
|
+
throw new MybaseApiError(data.error ?? `mybase API answered ${res.status}`, res.status);
|
|
244
|
+
return data;
|
|
245
|
+
}
|
|
246
|
+
function index(name) {
|
|
247
|
+
return {
|
|
248
|
+
name,
|
|
249
|
+
upsert: (documents) => call("POST", path(name, "/documents"), { documents: Array.isArray(documents) ? documents : [documents] }),
|
|
250
|
+
async get(ids, opts = {}) {
|
|
251
|
+
const params = new URLSearchParams;
|
|
252
|
+
for (const id of Array.isArray(ids) ? ids : [ids])
|
|
253
|
+
params.append("id", id);
|
|
254
|
+
if (opts.includeVectors)
|
|
255
|
+
params.set("includeVectors", "true");
|
|
256
|
+
const { documents } = await call("GET", path(name, `/documents?${params}`));
|
|
257
|
+
return documents;
|
|
258
|
+
},
|
|
259
|
+
delete(target) {
|
|
260
|
+
const body = typeof target === "string" ? { ids: [target] } : Array.isArray(target) ? { ids: target } : { filter: target.filter };
|
|
261
|
+
return call("POST", path(name, "/documents/delete"), body);
|
|
262
|
+
},
|
|
263
|
+
query(query, opts = {}) {
|
|
264
|
+
const body = typeof query === "string" ? { ...opts, text: query } : { ...opts, ...query };
|
|
265
|
+
return call("POST", path(name, "/query"), body);
|
|
266
|
+
},
|
|
267
|
+
list({ limit, after } = {}) {
|
|
268
|
+
const params = new URLSearchParams;
|
|
269
|
+
if (limit !== undefined)
|
|
270
|
+
params.set("limit", String(limit));
|
|
271
|
+
if (after !== undefined)
|
|
272
|
+
params.set("after", after);
|
|
273
|
+
return call("GET", path(name, `/documents?${params}`));
|
|
274
|
+
},
|
|
275
|
+
info: () => call("GET", path(name))
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
index,
|
|
280
|
+
listIndexes: () => call("GET", base),
|
|
281
|
+
getIndex: (name) => call("GET", path(name)),
|
|
282
|
+
createIndex: (name, settings = {}) => call("POST", base, { name, ...settings }),
|
|
283
|
+
updateIndex: (name, settings) => call("PATCH", path(name), settings),
|
|
284
|
+
deleteIndex: (name) => call("DELETE", path(name))
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function retrievalTool(index, options = {}) {
|
|
288
|
+
const maxChars = options.maxChars ?? 2000;
|
|
289
|
+
return {
|
|
290
|
+
name: options.name ?? "search_documents",
|
|
291
|
+
description: `Search ${options.about ?? "the knowledge base"} and return the most relevant passages with their ids. ` + "Use specific words from the topic you need.",
|
|
292
|
+
inputSchema: {
|
|
293
|
+
type: "object",
|
|
294
|
+
properties: { query: { type: "string", description: "What to look for." } },
|
|
295
|
+
required: ["query"],
|
|
296
|
+
additionalProperties: false
|
|
297
|
+
},
|
|
298
|
+
async run(input) {
|
|
299
|
+
const query = typeof input === "object" && input !== null ? input.query : undefined;
|
|
300
|
+
if (typeof query !== "string" || !query.trim())
|
|
301
|
+
return "Error: query is required.";
|
|
302
|
+
try {
|
|
303
|
+
const { matches } = await index.query(query, { topK: options.topK ?? 5, filter: options.filter, mode: options.mode });
|
|
304
|
+
if (matches.length === 0)
|
|
305
|
+
return `Nothing found for "${query}".`;
|
|
306
|
+
return matches.map((m, i) => {
|
|
307
|
+
const text = (m.text ?? "").length > maxChars ? `${m.text.slice(0, maxChars)}…` : m.text ?? "";
|
|
308
|
+
return `${i + 1}. [${m.id}]${m.title ? ` ${m.title}` : ""}
|
|
309
|
+
${text}`;
|
|
310
|
+
}).join(`
|
|
311
|
+
|
|
312
|
+
`);
|
|
313
|
+
} catch (error) {
|
|
314
|
+
if (error instanceof MybaseApiError && ![401, 403, 404].includes(error.status))
|
|
315
|
+
return `Error: ${error.message}`;
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
export {
|
|
322
|
+
MybaseApiError,
|
|
323
|
+
VECTOR_INDEX_PATTERN,
|
|
324
|
+
VECTOR_INDEX_RULES,
|
|
325
|
+
VECTOR_LANGUAGES,
|
|
326
|
+
VECTOR_LIMITS,
|
|
327
|
+
VECTOR_METRICS,
|
|
328
|
+
VECTOR_QUERY_MODES,
|
|
329
|
+
createVectorClient,
|
|
330
|
+
retrievalTool
|
|
331
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mybase-dev/sdk",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Client helpers for apps running on mybase: analytics, cron calls, one-off tasks, cache, rate limits, queues.",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Client helpers for apps running on mybase: analytics, cron calls, one-off tasks, cache, rate limits, queues, web search, secrets, vector and full-text search, Better Auth, captcha.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -38,6 +38,31 @@
|
|
|
38
38
|
"bun": "./src/queue.ts",
|
|
39
39
|
"types": "./dist/queue.d.ts",
|
|
40
40
|
"default": "./dist/queue.js"
|
|
41
|
+
},
|
|
42
|
+
"./search": {
|
|
43
|
+
"bun": "./src/search.ts",
|
|
44
|
+
"types": "./dist/search.d.ts",
|
|
45
|
+
"default": "./dist/search.js"
|
|
46
|
+
},
|
|
47
|
+
"./secrets": {
|
|
48
|
+
"bun": "./src/secrets.ts",
|
|
49
|
+
"types": "./dist/secrets.d.ts",
|
|
50
|
+
"default": "./dist/secrets.js"
|
|
51
|
+
},
|
|
52
|
+
"./vector": {
|
|
53
|
+
"bun": "./src/vector.ts",
|
|
54
|
+
"types": "./dist/vector.d.ts",
|
|
55
|
+
"default": "./dist/vector.js"
|
|
56
|
+
},
|
|
57
|
+
"./better-auth": {
|
|
58
|
+
"bun": "./src/better-auth.ts",
|
|
59
|
+
"types": "./dist/better-auth.d.ts",
|
|
60
|
+
"default": "./dist/better-auth.js"
|
|
61
|
+
},
|
|
62
|
+
"./captcha": {
|
|
63
|
+
"bun": "./src/captcha.ts",
|
|
64
|
+
"types": "./dist/captcha.d.ts",
|
|
65
|
+
"default": "./dist/captcha.js"
|
|
41
66
|
}
|
|
42
67
|
},
|
|
43
68
|
"files": [
|
|
@@ -48,13 +73,18 @@
|
|
|
48
73
|
"src/cache.ts",
|
|
49
74
|
"src/ratelimit.ts",
|
|
50
75
|
"src/queue.ts",
|
|
76
|
+
"src/search.ts",
|
|
77
|
+
"src/secrets.ts",
|
|
78
|
+
"src/vector.ts",
|
|
79
|
+
"src/better-auth.ts",
|
|
80
|
+
"src/captcha.ts",
|
|
51
81
|
"README.md"
|
|
52
82
|
],
|
|
53
83
|
"engines": {
|
|
54
84
|
"node": ">=20"
|
|
55
85
|
},
|
|
56
86
|
"scripts": {
|
|
57
|
-
"build": "rm -rf dist && bun build src/analytics.ts src/cron.ts src/config.ts src/cache.ts src/ratelimit.ts src/queue.ts --outdir dist --target node --format esm && bun build src/analytics-script.ts --outfile dist/analytics.min.js --target browser --format iife --minify && tsc -p tsconfig.build.json",
|
|
87
|
+
"build": "rm -rf dist && bun build src/analytics.ts src/cron.ts src/config.ts src/cache.ts src/ratelimit.ts src/queue.ts src/search.ts src/secrets.ts src/vector.ts src/better-auth.ts src/captcha.ts --root src --outdir dist --target node --format esm && bun build src/analytics-script.ts --outfile dist/analytics.min.js --target browser --format iife --minify && bun build src/captcha-widget.ts --outfile dist/captcha.min.js --target browser --format iife --minify && tsc -p tsconfig.build.json",
|
|
58
88
|
"test": "bun test",
|
|
59
89
|
"prepublishOnly": "bun run build"
|
|
60
90
|
},
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Sign-in for your app's users with Better Auth (https://better-auth.com), backed by mybase.
|
|
2
|
+
// Better Auth runs in your app and does the auth work; these helpers plug mybase in:
|
|
3
|
+
//
|
|
4
|
+
// import { betterAuth } from "better-auth";
|
|
5
|
+
// import { captcha } from "better-auth/plugins";
|
|
6
|
+
// import { Pool } from "pg";
|
|
7
|
+
// import { createCache } from "@mybase-dev/sdk/cache";
|
|
8
|
+
// import { authDatabaseUrl, mybaseAuth, mybaseCaptcha, mybaseSecondaryStorage } from "@mybase-dev/sdk/better-auth";
|
|
9
|
+
//
|
|
10
|
+
// const mybase = { apiUrl: process.env.MYBASE_URL!, project: "shop", apiKey: process.env.MYBASE_API_KEY! };
|
|
11
|
+
// export const auth = betterAuth({
|
|
12
|
+
// database: new Pool({ connectionString: authDatabaseUrl(process.env.DATABASE_URL!) }),
|
|
13
|
+
// secondaryStorage: mybaseSecondaryStorage(createCache(mybase)),
|
|
14
|
+
// session: { storeSessionInDatabase: true },
|
|
15
|
+
// emailAndPassword: { enabled: true },
|
|
16
|
+
// plugins: [mybaseAuth(mybase), captcha(mybaseCaptcha({ ...mybase, secretKey: process.env.MYBASE_CAPTCHA_SECRET! }))],
|
|
17
|
+
// });
|
|
18
|
+
//
|
|
19
|
+
// Nothing here imports better-auth, so this module adds no dependencies; the shapes below
|
|
20
|
+
// match Better Auth's `SecondaryStorage`, plugin, and captcha options (checked against 1.7).
|
|
21
|
+
import type { Cache } from "./cache";
|
|
22
|
+
|
|
23
|
+
/** Better Auth's tables live in this schema of the project's database (the `users` service creates it). */
|
|
24
|
+
export const AUTH_SCHEMA = "auth";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The project's connection string with `search_path` set to the auth schema, so Better Auth's
|
|
28
|
+
* tables (and its migrations) land there instead of next to your app's tables in `public`.
|
|
29
|
+
* Pass `extra` schemas to also see them unqualified (e.g. `["public"]`).
|
|
30
|
+
*/
|
|
31
|
+
export function authDatabaseUrl(url: string, extra: string[] = []): string {
|
|
32
|
+
const u = new URL(url);
|
|
33
|
+
const path = [AUTH_SCHEMA, ...extra].join(",");
|
|
34
|
+
u.searchParams.set("options", `-c search_path=${path}`);
|
|
35
|
+
return u.toString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What Better Auth's `secondaryStorage` option takes. TTLs are in seconds. */
|
|
39
|
+
export interface SecondaryStorage {
|
|
40
|
+
get(key: string): Promise<string | null>;
|
|
41
|
+
getAndDelete(key: string): Promise<string | null>;
|
|
42
|
+
increment(key: string, ttl: number): Promise<number>;
|
|
43
|
+
set(key: string, value: string, ttl?: number): Promise<void>;
|
|
44
|
+
delete(key: string): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Better Auth's `secondaryStorage` on the project's mybase cache (Valkey): sessions and rate
|
|
49
|
+
* limit counters stay in memory instead of hitting Postgres (and the SD card) on every request.
|
|
50
|
+
* Keys go under `<project>:auth:`, inside the project's cache ACL. Needs the cache service and an
|
|
51
|
+
* API key with `cache:write`. The dashboard's Users page revokes sessions here too.
|
|
52
|
+
*/
|
|
53
|
+
export function mybaseSecondaryStorage(cache: Cache, { prefix = "auth:" }: { prefix?: string } = {}): SecondaryStorage {
|
|
54
|
+
const k = (key: string) => cache.key(prefix + key);
|
|
55
|
+
return {
|
|
56
|
+
get: (key) => cache.command<string | null>(["GET", k(key)]),
|
|
57
|
+
getAndDelete: (key) => cache.command<string | null>(["GETDEL", k(key)]),
|
|
58
|
+
async increment(key, ttl) {
|
|
59
|
+
// Created at 0 with the TTL (only if absent), then incremented: the window starts at the first hit.
|
|
60
|
+
const [, incr] = await cache
|
|
61
|
+
.pipeline()
|
|
62
|
+
.command(["SET", k(key), 0, "EX", Math.max(1, Math.ceil(ttl)), "NX"])
|
|
63
|
+
.command(["INCR", k(key)])
|
|
64
|
+
.exec({ atomic: true });
|
|
65
|
+
if (!incr || "error" in incr) throw new Error(`mybase cache: ${incr && "error" in incr ? incr.error : "no reply"}`);
|
|
66
|
+
return Number(incr.result);
|
|
67
|
+
},
|
|
68
|
+
async set(key, value, ttl) {
|
|
69
|
+
await cache.command(ttl && ttl > 0 ? ["SET", k(key), value, "EX", Math.ceil(ttl)] : ["SET", k(key), value]);
|
|
70
|
+
},
|
|
71
|
+
async delete(key) {
|
|
72
|
+
await cache.command(["DEL", k(key)]);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface MybaseAuthOptions {
|
|
78
|
+
/** Where the mybase API is reachable from your app. */
|
|
79
|
+
apiUrl: string;
|
|
80
|
+
project: string;
|
|
81
|
+
/** A mybase API key with `users:write` for this project. Server-side only. */
|
|
82
|
+
apiKey: string;
|
|
83
|
+
/** Default `globalThis.fetch`. */
|
|
84
|
+
fetch?: typeof fetch;
|
|
85
|
+
/** Called when an event can't be sent (it's dropped, never retried). Default: console.warn. */
|
|
86
|
+
onError?: (err: unknown) => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** What the app reports to mybase's audit log (`POST /users/:project/events`). */
|
|
90
|
+
export type UserEventType = "user.created" | "user.deleted" | "session.created" | "session.revoked";
|
|
91
|
+
|
|
92
|
+
export interface UserEvent {
|
|
93
|
+
type: UserEventType;
|
|
94
|
+
userId: string;
|
|
95
|
+
/** The end user's IP and user agent, as Better Auth saw them. */
|
|
96
|
+
ip?: string | null;
|
|
97
|
+
userAgent?: string | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface SessionLike {
|
|
101
|
+
userId: string;
|
|
102
|
+
ipAddress?: string | null;
|
|
103
|
+
userAgent?: string | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A Better Auth plugin that reports sign-ups, sign-ins, sign-outs, and deleted users to the
|
|
108
|
+
* project's mybase audit log, where they show up next to everything else (and on the
|
|
109
|
+
* dashboard's Users page). No passwords, emails, or tokens are sent: user ids, IPs, user agents.
|
|
110
|
+
* Sending never blocks or fails a sign-in.
|
|
111
|
+
*/
|
|
112
|
+
export function mybaseAuth(options: MybaseAuthOptions) {
|
|
113
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
114
|
+
const url = `${options.apiUrl.replace(/\/+$/, "")}/users/${encodeURIComponent(options.project)}/events`;
|
|
115
|
+
const onError = options.onError ?? ((err: unknown) => console.warn("mybase: auth event not sent:", err));
|
|
116
|
+
|
|
117
|
+
const send = (event: UserEvent) => {
|
|
118
|
+
void doFetch(url, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
body: JSON.stringify({ events: [event] }),
|
|
121
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${options.apiKey}` },
|
|
122
|
+
})
|
|
123
|
+
.then(async (res) => {
|
|
124
|
+
if (!res.ok) onError(new Error(`mybase API answered ${res.status}: ${await res.text().catch(() => "")}`));
|
|
125
|
+
})
|
|
126
|
+
.catch(onError);
|
|
127
|
+
};
|
|
128
|
+
const fromSession = (type: UserEventType, s: SessionLike) => send({ type, userId: s.userId, ip: s.ipAddress ?? null, userAgent: s.userAgent ?? null });
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
id: "mybase" as const,
|
|
132
|
+
init() {
|
|
133
|
+
return {
|
|
134
|
+
options: {
|
|
135
|
+
databaseHooks: {
|
|
136
|
+
user: {
|
|
137
|
+
create: { after: async (user: { id: string }) => send({ type: "user.created", userId: user.id }) },
|
|
138
|
+
delete: { after: async (user: { id: string }) => send({ type: "user.deleted", userId: user.id }) },
|
|
139
|
+
},
|
|
140
|
+
session: {
|
|
141
|
+
create: { after: async (session: SessionLike) => fromSession("session.created", session) },
|
|
142
|
+
delete: { after: async (session: SessionLike) => fromSession("session.revoked", session) },
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface MybaseCaptchaOptions {
|
|
152
|
+
apiUrl: string;
|
|
153
|
+
project: string;
|
|
154
|
+
/** The project's captcha secret (dashboard → Captcha, or `mybase captcha key`). Server-side only. */
|
|
155
|
+
secretKey: string;
|
|
156
|
+
/** Better Auth routes that need a solved captcha. Default: sign-up, sign-in, and password reset. */
|
|
157
|
+
endpoints?: string[];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Options for Better Auth's `captcha()` plugin that check tokens with mybase's self-hosted
|
|
162
|
+
* captcha instead of Cloudflare's: mybase answers in Turnstile's siteverify format. Put the
|
|
163
|
+
* widget (`<script src="<mybase>/captcha.js">`) on your forms and send its token in the
|
|
164
|
+
* `x-captcha-response` header, as Better Auth expects.
|
|
165
|
+
*/
|
|
166
|
+
export function mybaseCaptcha(options: MybaseCaptchaOptions) {
|
|
167
|
+
return {
|
|
168
|
+
provider: "cloudflare-turnstile" as const,
|
|
169
|
+
secretKey: options.secretKey,
|
|
170
|
+
siteVerifyURLOverride: `${options.apiUrl.replace(/\/+$/, "")}/captcha/${encodeURIComponent(options.project)}/siteverify`,
|
|
171
|
+
...(options.endpoints ? { endpoints: options.endpoints } : {}),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The statement that tells Postgres who the signed-in user is, for row-level security policies
|
|
177
|
+
* on `mybase_auth.uid()`. Run it inside the same transaction as your queries:
|
|
178
|
+
*
|
|
179
|
+
* await sql.begin(async (tx) => {
|
|
180
|
+
* const q = userContextQuery(session.user.id);
|
|
181
|
+
* await tx.unsafe(q.text, q.values);
|
|
182
|
+
* return tx`select * from orders`; // policy: using (user_id = mybase_auth.uid())
|
|
183
|
+
* });
|
|
184
|
+
*
|
|
185
|
+
* Policies only apply to the table owner (your app's role) with `force row level security`.
|
|
186
|
+
*/
|
|
187
|
+
export function userContextQuery(userId: string): { text: string; values: [string] } {
|
|
188
|
+
return { text: "select set_config('mybase.user_id', $1, true)", values: [userId] };
|
|
189
|
+
}
|