@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/dist/search.js ADDED
@@ -0,0 +1,315 @@
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/search.ts
180
+ var SEARCH_CATEGORIES = ["general", "news", "it"];
181
+ var SEARCH_TIME_RANGES = ["day", "week", "month", "year"];
182
+ var SEARCH_LIMITS = {
183
+ queryLength: 400,
184
+ results: { default: 10, max: 20 },
185
+ page: { max: 10 },
186
+ urlLength: 2048,
187
+ maxChars: { default: 20000, min: 500, max: 1e5 }
188
+ };
189
+ var SEARCH_LANGUAGE_PATTERN = /^[a-z]{2}(-[A-Z]{2})?$/;
190
+ function createSearchClient(options) {
191
+ const doFetch = options.fetch ?? globalThis.fetch;
192
+ const base = `${options.apiUrl.replace(/\/+$/, "")}/search/${encodeURIComponent(options.project)}`;
193
+ const timeoutMs = options.timeoutMs ?? 20000;
194
+ async function get(url) {
195
+ const res = await doFetch(url, {
196
+ headers: { Authorization: `Bearer ${options.apiKey}` },
197
+ signal: AbortSignal.timeout(timeoutMs)
198
+ });
199
+ const data = await res.json().catch(() => ({}));
200
+ if (!res.ok)
201
+ throw new MybaseApiError(data.error ?? `mybase API answered ${res.status}`, res.status);
202
+ return data;
203
+ }
204
+ return {
205
+ search(query, { limit, category, timeRange, language, page } = {}) {
206
+ const params = new URLSearchParams({ q: query });
207
+ if (limit !== undefined)
208
+ params.set("limit", String(limit));
209
+ if (category)
210
+ params.set("category", category);
211
+ if (timeRange)
212
+ params.set("timeRange", timeRange);
213
+ if (language)
214
+ params.set("language", language);
215
+ if (page !== undefined)
216
+ params.set("page", String(page));
217
+ return get(`${base}?${params}`);
218
+ },
219
+ fetch(url, { maxChars } = {}) {
220
+ const params = new URLSearchParams({ url });
221
+ if (maxChars !== undefined)
222
+ params.set("maxChars", String(maxChars));
223
+ return get(`${base}/fetch?${params}`);
224
+ }
225
+ };
226
+ }
227
+ function searchTools(client, options = {}) {
228
+ const maxChars = options.maxChars ?? SEARCH_LIMITS.maxChars.default;
229
+ const limit = options.limit ?? 8;
230
+ return [
231
+ {
232
+ name: "web_search",
233
+ description: "Search the web. Returns titles, URLs, and snippets. Use web_fetch to read a result in full. " + "Write queries like a search engine query (keywords), not a question to a person.",
234
+ inputSchema: {
235
+ type: "object",
236
+ properties: {
237
+ query: { type: "string", description: "The search query." },
238
+ category: {
239
+ type: "string",
240
+ enum: [...SEARCH_CATEGORIES],
241
+ description: "general (default): the web. news: recent articles. it: GitHub and Stack Overflow."
242
+ },
243
+ timeRange: { type: "string", enum: [...SEARCH_TIME_RANGES], description: "Only results from this recent period." }
244
+ },
245
+ required: ["query"],
246
+ additionalProperties: false
247
+ },
248
+ run: (input) => attempt(async () => {
249
+ const { query, category, timeRange } = object(input);
250
+ if (typeof query !== "string" || !query.trim())
251
+ return "Error: query is required.";
252
+ const { results } = await client.search(query, {
253
+ limit,
254
+ category: oneOf(category, SEARCH_CATEGORIES),
255
+ timeRange: oneOf(timeRange, SEARCH_TIME_RANGES)
256
+ });
257
+ if (results.length === 0)
258
+ return `No results for "${query}".`;
259
+ return results.map((r, i) => {
260
+ const date = r.publishedAt ? ` (${r.publishedAt.slice(0, 10)})` : "";
261
+ return `${i + 1}. ${r.title}${date}
262
+ ${r.url}${r.snippet ? `
263
+ ${r.snippet}` : ""}`;
264
+ }).join(`
265
+
266
+ `);
267
+ })
268
+ },
269
+ {
270
+ name: "web_fetch",
271
+ description: "Read a public web page and return its main content as markdown. Use it on URLs from web_search " + "or ones the user gave. Long pages are cut off.",
272
+ inputSchema: {
273
+ type: "object",
274
+ properties: { url: { type: "string", description: "The http(s) URL to read." } },
275
+ required: ["url"],
276
+ additionalProperties: false
277
+ },
278
+ run: (input) => attempt(async () => {
279
+ const { url } = object(input);
280
+ if (typeof url !== "string" || !url.trim())
281
+ return "Error: url is required.";
282
+ const page = await client.fetch(url, { maxChars });
283
+ const head = `# ${page.title ?? page.url}
284
+ URL: ${page.url}
285
+
286
+ `;
287
+ return head + page.markdown + (page.truncated ? `
288
+
289
+ [Cut off at ${maxChars} characters.]` : "");
290
+ })
291
+ }
292
+ ];
293
+ }
294
+ var object = (input) => typeof input === "object" && input !== null && !Array.isArray(input) ? input : {};
295
+ function oneOf(value, allowed) {
296
+ return allowed.includes(value) ? value : undefined;
297
+ }
298
+ async function attempt(run) {
299
+ try {
300
+ return await run();
301
+ } catch (error) {
302
+ if (error instanceof MybaseApiError && ![401, 403, 404].includes(error.status))
303
+ return `Error: ${error.message}`;
304
+ throw error;
305
+ }
306
+ }
307
+ export {
308
+ MybaseApiError,
309
+ SEARCH_CATEGORIES,
310
+ SEARCH_LANGUAGE_PATTERN,
311
+ SEARCH_LIMITS,
312
+ SEARCH_TIME_RANGES,
313
+ createSearchClient,
314
+ searchTools
315
+ };
@@ -0,0 +1,138 @@
1
+ import { MybaseApiError } from "./cron";
2
+ export { MybaseApiError };
3
+ /** `dev`, `staging`, `prod`, `prod_eu`, … */
4
+ export declare const SECRETS_ENV_PATTERN: RegExp;
5
+ export declare const SECRETS_ENV_RULES = "1\u201340 characters: lowercase letters, digits, _ or -, starting with a letter";
6
+ /** Env var names: `STRIPE_KEY`, `databaseUrl`, … */
7
+ export declare const SECRET_KEY_PATTERN: RegExp;
8
+ export declare const SECRET_KEY_RULES = "1\u2013128 characters: letters, digits, or _, not starting with a digit";
9
+ export declare const SECRETS_LIMITS: {
10
+ /** Per value, UTF-8. */
11
+ readonly valueBytes: number;
12
+ readonly keysPerEnv: 1000;
13
+ readonly envsPerProject: 50;
14
+ /** Older revisions are pruned. */
15
+ readonly revisionsKept: 200;
16
+ readonly messageLength: 200;
17
+ };
18
+ /** Every project starts with these (they can be renamed or deleted). */
19
+ export declare const DEFAULT_SECRETS_ENVS: readonly ["dev", "staging", "prod"];
20
+ /**
21
+ * Keys `mybase secrets link` writes and keeps up to date. Read-only elsewhere: running link
22
+ * again rotates them.
23
+ */
24
+ export declare const MANAGED_SECRET_KEYS: readonly ["DATABASE_URL", "S3_ENDPOINT", "S3_REGION", "S3_BUCKET", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY", "REDIS_URL", "MYBASE_URL", "MYBASE_PROJECT"];
25
+ export interface SecretsEnv {
26
+ name: string;
27
+ /** Branch envs inherit every key of their parent (a root env) and override some. */
28
+ parent: string | null;
29
+ /** Bumped on every change. */
30
+ revision: number;
31
+ /** Own keys, not counting inherited ones. */
32
+ keys: number;
33
+ /** Holds the project's credentials (`mybase secrets link`). */
34
+ linked: boolean;
35
+ createdAt: string;
36
+ updatedAt: string;
37
+ }
38
+ /** `GET /projects/<project>/secrets`. Only envs the caller's key may read are listed. */
39
+ export interface SecretsOverview {
40
+ envs: SecretsEnv[];
41
+ apiUrl: string;
42
+ enabledAt: string;
43
+ }
44
+ export interface SecretEntry {
45
+ key: string;
46
+ /** Only when values were asked for. */
47
+ value?: string;
48
+ /** Written by `mybase secrets link`. */
49
+ managed: boolean;
50
+ /** From the parent env (a branch env hasn't overridden it). */
51
+ inherited: boolean;
52
+ updatedAt: string;
53
+ }
54
+ export interface SecretsEnvDetail {
55
+ env: SecretsEnv;
56
+ secrets: SecretEntry[];
57
+ }
58
+ export interface CreateSecretsEnvRequest {
59
+ name: string;
60
+ /** Makes a branch env of this root env. */
61
+ parent?: string | null;
62
+ }
63
+ export interface UpdateSecretsRequest {
64
+ set?: Record<string, string>;
65
+ unset?: string[];
66
+ /** Shown in the history. */
67
+ message?: string;
68
+ }
69
+ export interface UpdateSecretsResponse {
70
+ /** The env's revision after the change (unchanged when nothing changed). */
71
+ revision: number;
72
+ changed: number;
73
+ }
74
+ export type SecretChangeAction = "added" | "changed" | "removed";
75
+ export interface SecretChange {
76
+ key: string;
77
+ action: SecretChangeAction;
78
+ managed: boolean;
79
+ /** Only when values were asked for. */
80
+ oldValue?: string | null;
81
+ newValue?: string | null;
82
+ }
83
+ export interface SecretsRevision {
84
+ revision: number;
85
+ /** `admin`, `key:<name>`, or `link`. */
86
+ actor: string;
87
+ message: string | null;
88
+ createdAt: string;
89
+ changes: SecretChange[];
90
+ }
91
+ /** `GET /secrets/<project>/<env>`: every key resolved, branch overrides applied. */
92
+ export interface SecretsResponse {
93
+ env: string;
94
+ /** Changes whenever any value (own or inherited) may have changed. Also sent as the ETag. */
95
+ version: string;
96
+ secrets: Record<string, string>;
97
+ }
98
+ export interface LinkCredentialsRequest {
99
+ envs: string[];
100
+ }
101
+ export interface LinkCredentialsResponse {
102
+ /** Every env now holding the credentials (earlier links included). */
103
+ envs: string[];
104
+ keys: string[];
105
+ }
106
+ export interface SecretsClientOptions {
107
+ /** Where the mybase API is reachable from your app, e.g. `https://mybase.example.com`. */
108
+ apiUrl: string;
109
+ project: string;
110
+ /** `dev`, `prod`, a branch env, … */
111
+ env: string;
112
+ /** A mybase API key (`mb_…`) with `secrets:read` for this project (and env, if it's env-scoped). Server-side only. */
113
+ apiKey: string;
114
+ /** Reuse fetched secrets this long without asking mybase, in ms. Default 0: every read revalidates (a 304 when nothing changed). */
115
+ maxAgeMs?: number;
116
+ /** Default 10000. */
117
+ timeoutMs?: number;
118
+ /** Default `globalThis.fetch`. */
119
+ fetch?: typeof fetch;
120
+ }
121
+ export interface SecretsClient {
122
+ /** Every key in the env, inherited ones included. */
123
+ all(): Promise<Record<string, string>>;
124
+ /** One value, or undefined when the key isn't set. */
125
+ get(key: string): Promise<string | undefined>;
126
+ /** Copies the secrets into `process.env`. Existing vars win unless `override`. Returns the keys set. */
127
+ load(options?: {
128
+ override?: boolean;
129
+ }): Promise<string[]>;
130
+ /** Needs `secrets:write`. One revision in the history. */
131
+ set(values: Record<string, string>, options?: {
132
+ message?: string;
133
+ }): Promise<UpdateSecretsResponse>;
134
+ unset(keys: string | string[], options?: {
135
+ message?: string;
136
+ }): Promise<UpdateSecretsResponse>;
137
+ }
138
+ export declare function createSecretsClient(options: SecretsClientOptions): SecretsClient;