@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.
@@ -0,0 +1,239 @@
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/captcha.ts
180
+ var CAPTCHA_LIMITS = {
181
+ expiresMs: 5 * 60000,
182
+ maxNumber: { min: 1000, max: 1e6, default: 50000 },
183
+ tokenLength: 2000
184
+ };
185
+ var hex = (buf) => Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join("");
186
+ async function sha256Hex(text) {
187
+ return hex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
188
+ }
189
+ function encodeToken(c, number) {
190
+ const json = JSON.stringify({ algorithm: c.algorithm, challenge: c.challenge, number, salt: c.salt, signature: c.signature });
191
+ return btoa(json);
192
+ }
193
+ async function solveChallenge(c, { signal } = {}) {
194
+ if (c.algorithm !== "SHA-256")
195
+ throw new Error(`mybase captcha: unknown algorithm ${c.algorithm}`);
196
+ for (let n = 0;n <= c.maxnumber; n++) {
197
+ if (await sha256Hex(c.salt + n) === c.challenge)
198
+ return encodeToken(c, n);
199
+ if (n % 1000 === 0 && signal?.aborted)
200
+ throw signal.reason ?? new Error("aborted");
201
+ }
202
+ throw new Error("mybase captcha: challenge has no solution");
203
+ }
204
+ async function solveCaptcha(options) {
205
+ const doFetch = options.fetch ?? globalThis.fetch;
206
+ const res = await doFetch(`${options.apiUrl.replace(/\/+$/, "")}/captcha/${encodeURIComponent(options.project)}/challenge`, { signal: options.signal });
207
+ const body = await res.json().catch(() => ({}));
208
+ if (!res.ok)
209
+ throw new MybaseApiError(body.error ?? `mybase API answered ${res.status}`, res.status);
210
+ return solveChallenge(body, { signal: options.signal });
211
+ }
212
+ function createCaptchaClient(options) {
213
+ const doFetch = options.fetch ?? globalThis.fetch;
214
+ const url = `${options.apiUrl.replace(/\/+$/, "")}/captcha/${encodeURIComponent(options.project)}/verify`;
215
+ return {
216
+ async verify(token) {
217
+ if (typeof token !== "string" || !token || token.length > CAPTCHA_LIMITS.tokenLength)
218
+ return { success: false, error: "invalid" };
219
+ const res = await doFetch(url, {
220
+ method: "POST",
221
+ body: JSON.stringify({ token }),
222
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${options.apiKey}` }
223
+ });
224
+ const body = await res.json().catch(() => ({}));
225
+ if (!res.ok)
226
+ throw new MybaseApiError(body.error ?? `mybase API answered ${res.status}`, res.status);
227
+ return body;
228
+ }
229
+ };
230
+ }
231
+ export {
232
+ CAPTCHA_LIMITS,
233
+ MybaseApiError,
234
+ createCaptchaClient,
235
+ encodeToken,
236
+ sha256Hex,
237
+ solveCaptcha,
238
+ solveChallenge
239
+ };
@@ -0,0 +1 @@
1
+ (()=>{class u extends Error{status;name="MybaseApiError";constructor(e,r){super(e);this.status=r}}var p=(e)=>Array.from(new Uint8Array(e),(r)=>r.toString(16).padStart(2,"0")).join("");async function f(e){return p(await crypto.subtle.digest("SHA-256",new TextEncoder().encode(e)))}function h(e,r){let t=JSON.stringify({algorithm:e.algorithm,challenge:e.challenge,number:r,salt:e.salt,signature:e.signature});return btoa(t)}async function y(e,{signal:r}={}){if(e.algorithm!=="SHA-256")throw Error(`mybase captcha: unknown algorithm ${e.algorithm}`);for(let t=0;t<=e.maxnumber;t++){if(await f(e.salt+t)===e.challenge)return h(e,t);if(t%1000===0&&r?.aborted)throw r.reason??Error("aborted")}throw Error("mybase captcha: challenge has no solution")}async function g(e){let t=await(e.fetch??globalThis.fetch)(`${e.apiUrl.replace(/\/+$/,"")}/captcha/${encodeURIComponent(e.project)}/challenge`,{signal:e.signal}),o=await t.json().catch(()=>({}));if(!t.ok)throw new u(o.error??`mybase API answered ${t.status}`,t.status);return y(o,{signal:e.signal})}var m=document.currentScript,l=m?.dataset??{},d="mybase-captcha";if(!l.project)console.warn("mybase captcha: add data-project to the script tag");else{let e=l.api||new URL(m.src,location.href).origin,r=l.project,t=()=>g({apiUrl:e,project:r});window.mybase={...window.mybase,captcha:{solve:t}};let o=(a,n)=>{let i=a.querySelector(".mybase-captcha-status");if(i)i.textContent=n};document.addEventListener("submit",(a)=>{let n=a.target;if(!(n instanceof HTMLFormElement)||!n.hasAttribute("data-mybase-captcha"))return;if(n.dataset.mybaseCaptchaReady==="1"){delete n.dataset.mybaseCaptchaReady;return}a.preventDefault(),a.stopImmediatePropagation();let i=a.submitter;o(n,"Checking you're not a bot…"),t().then((c)=>{let s=n.querySelector(`input[name="${d}"]`);if(!s)s=document.createElement("input"),s.type="hidden",s.name=d,n.append(s);s.value=c,o(n,""),n.dataset.mybaseCaptchaReady="1",n.requestSubmit(i instanceof HTMLElement?i:void 0)}).catch((c)=>{console.warn("mybase captcha:",c),o(n,"Couldn't check this form. Try again.")})},!0)}})();
package/dist/config.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { DeadLetterSettings } from "./queue";
2
2
  import type { RateLimitAlgorithm } from "./ratelimit";
3
+ import type { VectorIndexInput } from "./vector";
3
4
  /** One job mybase calls on a schedule. */
4
5
  export interface CronJobConfig {
5
6
  /** Standard 5-field cron expression (minute precision), e.g. `0 3 * * *`. */
@@ -45,6 +46,8 @@ export interface QueueConfig {
45
46
  /** After `maxReceives` failed deliveries, move messages to this queue. */
46
47
  deadLetter?: DeadLetterSettings;
47
48
  }
49
+ /** One vector/full-text index. Dimensions, metric, and language can't change after creation. */
50
+ export type VectorIndexConfig = VectorIndexInput;
48
51
  export interface MybaseConfig {
49
52
  /** The mybase project these jobs belong to. */
50
53
  project: string;
@@ -59,6 +62,9 @@ export interface MybaseConfig {
59
62
  queue?: {
60
63
  queues: Record<string, QueueConfig>;
61
64
  };
65
+ vector?: {
66
+ indexes: Record<string, VectorIndexConfig>;
67
+ };
62
68
  }
63
69
  /** Identity function that types `mybase.config.ts`. */
64
70
  export declare function defineConfig<const C extends MybaseConfig>(config: C): C;
@@ -0,0 +1,120 @@
1
+ import { MybaseApiError } from "./cron";
2
+ export { MybaseApiError };
3
+ /** `general`: the web. `news`: recent articles. `it`: GitHub and Stack Overflow. */
4
+ export declare const SEARCH_CATEGORIES: readonly ["general", "news", "it"];
5
+ export type SearchCategory = (typeof SEARCH_CATEGORIES)[number];
6
+ export declare const SEARCH_TIME_RANGES: readonly ["day", "week", "month", "year"];
7
+ export type SearchTimeRange = (typeof SEARCH_TIME_RANGES)[number];
8
+ export declare const SEARCH_LIMITS: {
9
+ readonly queryLength: 400;
10
+ readonly results: {
11
+ readonly default: 10;
12
+ readonly max: 20;
13
+ };
14
+ readonly page: {
15
+ readonly max: 10;
16
+ };
17
+ readonly urlLength: 2048;
18
+ readonly maxChars: {
19
+ readonly default: 20000;
20
+ readonly min: 500;
21
+ readonly max: 100000;
22
+ };
23
+ };
24
+ /** `en`, `de`, `en-US`, …: what language results should be in. */
25
+ export declare const SEARCH_LANGUAGE_PATTERN: RegExp;
26
+ export interface SearchOptions {
27
+ /** Results to return, 1–20. Default 10. */
28
+ limit?: number;
29
+ /** Default `general`. */
30
+ category?: SearchCategory;
31
+ /** Only results from the last day/week/month/year. Not every engine supports it. */
32
+ timeRange?: SearchTimeRange;
33
+ /** `en`, `en-US`, …. Default: any language. */
34
+ language?: string;
35
+ /** Results page, 1–10. Default 1. */
36
+ page?: number;
37
+ }
38
+ export interface SearchResult {
39
+ title: string;
40
+ url: string;
41
+ /** The text the engines showed under the link. May be empty. */
42
+ snippet: string;
43
+ /** Which engines returned it; more engines usually means more relevant. */
44
+ engines: string[];
45
+ /** ISO date, when the engine knows it (mostly news). */
46
+ publishedAt: string | null;
47
+ }
48
+ /** Answer of `GET /search/<project>?q=…`. */
49
+ export interface SearchResponse {
50
+ query: string;
51
+ results: SearchResult[];
52
+ /** True when this answer came from mybase's cache (searches are kept 1 hour). */
53
+ cached: boolean;
54
+ }
55
+ export interface FetchOptions {
56
+ /** Cut the markdown at this many characters, 500–100000. Default 20000. */
57
+ maxChars?: number;
58
+ }
59
+ /** Answer of `GET /search/<project>/fetch?url=…`. */
60
+ export interface FetchResponse {
61
+ /** Where the page ended up, after redirects. */
62
+ url: string;
63
+ title: string | null;
64
+ /** The page's main content as markdown (navigation, ads, and scripts left out). */
65
+ markdown: string;
66
+ /** True when the markdown was cut at `maxChars`. */
67
+ truncated: boolean;
68
+ contentType: string;
69
+ /** True when this answer came from mybase's cache (pages are kept 1 day). */
70
+ cached: boolean;
71
+ }
72
+ export interface SearchClientOptions {
73
+ /** Where the mybase API is reachable from your app, e.g. `https://mybase.example.com`. */
74
+ apiUrl: string;
75
+ project: string;
76
+ /** A mybase API key (`mb_…`) with `search:read` for this project. Server-side only. */
77
+ apiKey: string;
78
+ /** How long to wait for mybase, in ms. Default 20000 (searches and page reads take seconds). */
79
+ timeoutMs?: number;
80
+ /** Default `globalThis.fetch`. */
81
+ fetch?: typeof fetch;
82
+ }
83
+ export interface SearchClient {
84
+ /** Searches the web. Throws `MybaseApiError` when mybase answers with an error. */
85
+ search(query: string, options?: SearchOptions): Promise<SearchResponse>;
86
+ /** Reads a public web page as markdown. Throws `MybaseApiError` when it can't (blocked address, not HTML, …). */
87
+ fetch(url: string, options?: FetchOptions): Promise<FetchResponse>;
88
+ }
89
+ export declare function createSearchClient(options: SearchClientOptions): SearchClient;
90
+ /** JSON Schema for a tool's input (the subset these tools use). */
91
+ export interface ToolInputSchema {
92
+ type: "object";
93
+ properties: Record<string, Record<string, unknown>>;
94
+ required: string[];
95
+ additionalProperties: false;
96
+ }
97
+ /**
98
+ * A tool for an LLM agent, independent of the provider. Map it to yours:
99
+ * Anthropic `{ name, description, input_schema: inputSchema }`,
100
+ * OpenAI `{ type: "function", function: { name, description, parameters: inputSchema } }`.
101
+ */
102
+ export interface SearchTool {
103
+ name: "web_search" | "web_fetch";
104
+ description: string;
105
+ inputSchema: ToolInputSchema;
106
+ /**
107
+ * Runs the tool with the model's input and returns text for the tool result. Problems the
108
+ * model can work around (a blocked or unreadable page, no results, bad input, rate limits) come
109
+ * back as text starting with `Error:`; a wrong key or a disabled service throws.
110
+ */
111
+ run(input: unknown): Promise<string>;
112
+ }
113
+ export interface SearchToolsOptions {
114
+ /** Most characters `web_fetch` returns per page. Default 20000. Keeps tool results inside your context budget. */
115
+ maxChars?: number;
116
+ /** Results `web_search` returns. Default 8. */
117
+ limit?: number;
118
+ }
119
+ /** `web_search` and `web_fetch`, ready for an LLM tool-use loop. */
120
+ export declare function searchTools(client: SearchClient, options?: SearchToolsOptions): SearchTool[];