@mybase-dev/sdk 1.0.0 → 1.0.1

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/src/captcha.ts ADDED
@@ -0,0 +1,110 @@
1
+ // mybase's self-hosted captcha: proof of work instead of puzzles. The browser gets a signed
2
+ // challenge from the project, finds the number whose SHA-256 matches (a fraction of a second),
3
+ // and sends the answer with the form; your server checks it once with mybase.
4
+ //
5
+ // // Browser (or use the script tag: <script src="https://mybase.example.com/captcha.js" data-project="shop">)
6
+ // const token = await solveCaptcha({ apiUrl, project: "shop" });
7
+ // await fetch("/signup", { method: "POST", headers: { "x-captcha-response": token }, body });
8
+ //
9
+ // // Server
10
+ // const captcha = createCaptchaClient({ apiUrl, project: "shop", apiKey: process.env.MYBASE_API_KEY! });
11
+ // const { success } = await captcha.verify(token);
12
+ //
13
+ // With Better Auth, use `mybaseCaptcha()` from @mybase-dev/sdk/better-auth instead of verify().
14
+ // No cookies, no third party, nothing to see or click. Tokens work once and expire after 5 minutes.
15
+ import { MybaseApiError } from "./cron";
16
+
17
+ export { MybaseApiError };
18
+
19
+ export const CAPTCHA_LIMITS = {
20
+ /** How long a challenge (and so its token) stays valid. */
21
+ expiresMs: 5 * 60_000,
22
+ /** The work: the browser tries up to this many numbers (half on average). */
23
+ maxNumber: { min: 1_000, max: 1_000_000, default: 50_000 },
24
+ tokenLength: 2_000,
25
+ } as const;
26
+
27
+ /** `GET /captcha/:project/challenge`. `salt` carries its expiry (`…?expires=<unix seconds>`). */
28
+ export interface CaptchaChallenge {
29
+ algorithm: "SHA-256";
30
+ challenge: string;
31
+ salt: string;
32
+ maxnumber: number;
33
+ signature: string;
34
+ }
35
+
36
+ /** What `verify` answers. `error` says why a token was refused. */
37
+ export interface CaptchaVerifyResponse {
38
+ success: boolean;
39
+ error?: "invalid" | "expired" | "used" | "unavailable";
40
+ }
41
+
42
+ const hex = (buf: ArrayBuffer) => Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join("");
43
+
44
+ export async function sha256Hex(text: string): Promise<string> {
45
+ return hex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
46
+ }
47
+
48
+ /** The token for a solved challenge: base64 of the challenge plus the number found. */
49
+ export function encodeToken(c: CaptchaChallenge, number: number): string {
50
+ const json = JSON.stringify({ algorithm: c.algorithm, challenge: c.challenge, number, salt: c.salt, signature: c.signature });
51
+ return btoa(json);
52
+ }
53
+
54
+ /** Finds the number. Rejects if none up to `maxnumber` matches, or when `signal` aborts. */
55
+ export async function solveChallenge(c: CaptchaChallenge, { signal }: { signal?: AbortSignal } = {}): Promise<string> {
56
+ if (c.algorithm !== "SHA-256") throw new Error(`mybase captcha: unknown algorithm ${c.algorithm}`);
57
+ for (let n = 0; n <= c.maxnumber; n++) {
58
+ if ((await sha256Hex(c.salt + n)) === c.challenge) return encodeToken(c, n);
59
+ if (n % 1000 === 0 && signal?.aborted) throw signal.reason ?? new Error("aborted");
60
+ }
61
+ throw new Error("mybase captcha: challenge has no solution");
62
+ }
63
+
64
+ export interface SolveOptions {
65
+ /** Where the mybase API is reachable from the browser. */
66
+ apiUrl: string;
67
+ project: string;
68
+ fetch?: typeof fetch;
69
+ signal?: AbortSignal;
70
+ }
71
+
72
+ /** Fetches a challenge and solves it. Returns the token to send with the form. */
73
+ export async function solveCaptcha(options: SolveOptions): Promise<string> {
74
+ const doFetch = options.fetch ?? globalThis.fetch;
75
+ const res = await doFetch(`${options.apiUrl.replace(/\/+$/, "")}/captcha/${encodeURIComponent(options.project)}/challenge`, { signal: options.signal });
76
+ const body = (await res.json().catch(() => ({}))) as CaptchaChallenge & { error?: string };
77
+ if (!res.ok) throw new MybaseApiError(body.error ?? `mybase API answered ${res.status}`, res.status);
78
+ return solveChallenge(body, { signal: options.signal });
79
+ }
80
+
81
+ export interface CaptchaClientOptions {
82
+ apiUrl: string;
83
+ project: string;
84
+ /** A mybase API key with `captcha:write` for this project. Server-side only. */
85
+ apiKey: string;
86
+ fetch?: typeof fetch;
87
+ }
88
+
89
+ export interface CaptchaClient {
90
+ /** Checks a token once. A second check of the same token fails with `used`. */
91
+ verify(token: string): Promise<CaptchaVerifyResponse>;
92
+ }
93
+
94
+ export function createCaptchaClient(options: CaptchaClientOptions): CaptchaClient {
95
+ const doFetch = options.fetch ?? globalThis.fetch;
96
+ const url = `${options.apiUrl.replace(/\/+$/, "")}/captcha/${encodeURIComponent(options.project)}/verify`;
97
+ return {
98
+ async verify(token) {
99
+ if (typeof token !== "string" || !token || token.length > CAPTCHA_LIMITS.tokenLength) return { success: false, error: "invalid" };
100
+ const res = await doFetch(url, {
101
+ method: "POST",
102
+ body: JSON.stringify({ token }),
103
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${options.apiKey}` },
104
+ });
105
+ const body = (await res.json().catch(() => ({}))) as CaptchaVerifyResponse & { error?: string };
106
+ if (!res.ok) throw new MybaseApiError((body as { error?: string }).error ?? `mybase API answered ${res.status}`, res.status);
107
+ return body;
108
+ },
109
+ };
110
+ }
package/src/config.ts CHANGED
@@ -1,8 +1,9 @@
1
- // Cron jobs, rate limit rules, and queues declared in code. `mybase cron sync`, `mybase ratelimit
2
- // sync`, and `mybase queue sync` import this file (and only this file), so keep app code out of it:
3
- // handlers live with your routes (see ./cron).
1
+ // Cron jobs, rate limit rules, queues, and vector indexes declared in code. `mybase cron sync`,
2
+ // `mybase ratelimit sync`, `mybase queue sync`, and `mybase vector sync` import this file (and
3
+ // only this file), so keep app code out of it: handlers live with your routes (see ./cron).
4
4
  import type { DeadLetterSettings } from "./queue";
5
5
  import type { RateLimitAlgorithm } from "./ratelimit";
6
+ import type { VectorIndexInput } from "./vector";
6
7
 
7
8
  /** One job mybase calls on a schedule. */
8
9
  export interface CronJobConfig {
@@ -52,6 +53,9 @@ export interface QueueConfig {
52
53
  deadLetter?: DeadLetterSettings;
53
54
  }
54
55
 
56
+ /** One vector/full-text index. Dimensions, metric, and language can't change after creation. */
57
+ export type VectorIndexConfig = VectorIndexInput;
58
+
55
59
  export interface MybaseConfig {
56
60
  /** The mybase project these jobs belong to. */
57
61
  project: string;
@@ -66,6 +70,9 @@ export interface MybaseConfig {
66
70
  queue?: {
67
71
  queues: Record<string, QueueConfig>;
68
72
  };
73
+ vector?: {
74
+ indexes: Record<string, VectorIndexConfig>;
75
+ };
69
76
  }
70
77
 
71
78
  /** Identity function that types `mybase.config.ts`. */
package/src/search.ts ADDED
@@ -0,0 +1,250 @@
1
+ // Web search through mybase. The API asks its own SearXNG (a metasearch engine over Bing,
2
+ // Mojeek, Yahoo, DuckDuckGo, Brave, …) and reads pages for you, so an agent can search and then
3
+ // read without paying a search API.
4
+ //
5
+ // const web = createSearchClient({ apiUrl, project: "shop", apiKey: process.env.MYBASE_API_KEY! });
6
+ // const { results } = await web.search("bun sql postgres");
7
+ // const page = await web.fetch(results[0].url); // → { title, markdown, … }
8
+ //
9
+ // For an LLM agent, `searchTools(web)` gives `web_search` and `web_fetch` as tool definitions.
10
+ import { MybaseApiError } from "./cron";
11
+
12
+ export { MybaseApiError };
13
+
14
+ /** `general`: the web. `news`: recent articles. `it`: GitHub and Stack Overflow. */
15
+ export const SEARCH_CATEGORIES = ["general", "news", "it"] as const;
16
+ export type SearchCategory = (typeof SEARCH_CATEGORIES)[number];
17
+
18
+ export const SEARCH_TIME_RANGES = ["day", "week", "month", "year"] as const;
19
+ export type SearchTimeRange = (typeof SEARCH_TIME_RANGES)[number];
20
+
21
+ export const SEARCH_LIMITS = {
22
+ queryLength: 400,
23
+ results: { default: 10, max: 20 },
24
+ page: { max: 10 },
25
+ urlLength: 2048,
26
+ maxChars: { default: 20_000, min: 500, max: 100_000 },
27
+ } as const;
28
+
29
+ /** `en`, `de`, `en-US`, …: what language results should be in. */
30
+ export const SEARCH_LANGUAGE_PATTERN = /^[a-z]{2}(-[A-Z]{2})?$/;
31
+
32
+ export interface SearchOptions {
33
+ /** Results to return, 1–20. Default 10. */
34
+ limit?: number;
35
+ /** Default `general`. */
36
+ category?: SearchCategory;
37
+ /** Only results from the last day/week/month/year. Not every engine supports it. */
38
+ timeRange?: SearchTimeRange;
39
+ /** `en`, `en-US`, …. Default: any language. */
40
+ language?: string;
41
+ /** Results page, 1–10. Default 1. */
42
+ page?: number;
43
+ }
44
+
45
+ export interface SearchResult {
46
+ title: string;
47
+ url: string;
48
+ /** The text the engines showed under the link. May be empty. */
49
+ snippet: string;
50
+ /** Which engines returned it; more engines usually means more relevant. */
51
+ engines: string[];
52
+ /** ISO date, when the engine knows it (mostly news). */
53
+ publishedAt: string | null;
54
+ }
55
+
56
+ /** Answer of `GET /search/<project>?q=…`. */
57
+ export interface SearchResponse {
58
+ query: string;
59
+ results: SearchResult[];
60
+ /** True when this answer came from mybase's cache (searches are kept 1 hour). */
61
+ cached: boolean;
62
+ }
63
+
64
+ export interface FetchOptions {
65
+ /** Cut the markdown at this many characters, 500–100000. Default 20000. */
66
+ maxChars?: number;
67
+ }
68
+
69
+ /** Answer of `GET /search/<project>/fetch?url=…`. */
70
+ export interface FetchResponse {
71
+ /** Where the page ended up, after redirects. */
72
+ url: string;
73
+ title: string | null;
74
+ /** The page's main content as markdown (navigation, ads, and scripts left out). */
75
+ markdown: string;
76
+ /** True when the markdown was cut at `maxChars`. */
77
+ truncated: boolean;
78
+ contentType: string;
79
+ /** True when this answer came from mybase's cache (pages are kept 1 day). */
80
+ cached: boolean;
81
+ }
82
+
83
+ export interface SearchClientOptions {
84
+ /** Where the mybase API is reachable from your app, e.g. `https://mybase.example.com`. */
85
+ apiUrl: string;
86
+ project: string;
87
+ /** A mybase API key (`mb_…`) with `search:read` for this project. Server-side only. */
88
+ apiKey: string;
89
+ /** How long to wait for mybase, in ms. Default 20000 (searches and page reads take seconds). */
90
+ timeoutMs?: number;
91
+ /** Default `globalThis.fetch`. */
92
+ fetch?: typeof fetch;
93
+ }
94
+
95
+ export interface SearchClient {
96
+ /** Searches the web. Throws `MybaseApiError` when mybase answers with an error. */
97
+ search(query: string, options?: SearchOptions): Promise<SearchResponse>;
98
+ /** Reads a public web page as markdown. Throws `MybaseApiError` when it can't (blocked address, not HTML, …). */
99
+ fetch(url: string, options?: FetchOptions): Promise<FetchResponse>;
100
+ }
101
+
102
+ export function createSearchClient(options: SearchClientOptions): SearchClient {
103
+ const doFetch = options.fetch ?? globalThis.fetch;
104
+ const base = `${options.apiUrl.replace(/\/+$/, "")}/search/${encodeURIComponent(options.project)}`;
105
+ const timeoutMs = options.timeoutMs ?? 20_000;
106
+
107
+ async function get<T>(url: string): Promise<T> {
108
+ const res = await doFetch(url, {
109
+ headers: { Authorization: `Bearer ${options.apiKey}` },
110
+ signal: AbortSignal.timeout(timeoutMs),
111
+ });
112
+ const data = (await res.json().catch(() => ({}))) as T & { error?: string };
113
+ if (!res.ok) throw new MybaseApiError(data.error ?? `mybase API answered ${res.status}`, res.status);
114
+ return data;
115
+ }
116
+
117
+ return {
118
+ search(query, { limit, category, timeRange, language, page } = {}) {
119
+ const params = new URLSearchParams({ q: query });
120
+ if (limit !== undefined) params.set("limit", String(limit));
121
+ if (category) params.set("category", category);
122
+ if (timeRange) params.set("timeRange", timeRange);
123
+ if (language) params.set("language", language);
124
+ if (page !== undefined) params.set("page", String(page));
125
+ return get<SearchResponse>(`${base}?${params}`);
126
+ },
127
+ fetch(url, { maxChars } = {}) {
128
+ const params = new URLSearchParams({ url });
129
+ if (maxChars !== undefined) params.set("maxChars", String(maxChars));
130
+ return get<FetchResponse>(`${base}/fetch?${params}`);
131
+ },
132
+ };
133
+ }
134
+
135
+ // ---- agent tools ------------------------------------------------------------
136
+
137
+ /** JSON Schema for a tool's input (the subset these tools use). */
138
+ export interface ToolInputSchema {
139
+ type: "object";
140
+ properties: Record<string, Record<string, unknown>>;
141
+ required: string[];
142
+ additionalProperties: false;
143
+ }
144
+
145
+ /**
146
+ * A tool for an LLM agent, independent of the provider. Map it to yours:
147
+ * Anthropic `{ name, description, input_schema: inputSchema }`,
148
+ * OpenAI `{ type: "function", function: { name, description, parameters: inputSchema } }`.
149
+ */
150
+ export interface SearchTool {
151
+ name: "web_search" | "web_fetch";
152
+ description: string;
153
+ inputSchema: ToolInputSchema;
154
+ /**
155
+ * Runs the tool with the model's input and returns text for the tool result. Problems the
156
+ * model can work around (a blocked or unreadable page, no results, bad input, rate limits) come
157
+ * back as text starting with `Error:`; a wrong key or a disabled service throws.
158
+ */
159
+ run(input: unknown): Promise<string>;
160
+ }
161
+
162
+ export interface SearchToolsOptions {
163
+ /** Most characters `web_fetch` returns per page. Default 20000. Keeps tool results inside your context budget. */
164
+ maxChars?: number;
165
+ /** Results `web_search` returns. Default 8. */
166
+ limit?: number;
167
+ }
168
+
169
+ /** `web_search` and `web_fetch`, ready for an LLM tool-use loop. */
170
+ export function searchTools(client: SearchClient, options: SearchToolsOptions = {}): SearchTool[] {
171
+ const maxChars = options.maxChars ?? SEARCH_LIMITS.maxChars.default;
172
+ const limit = options.limit ?? 8;
173
+
174
+ return [
175
+ {
176
+ name: "web_search",
177
+ description:
178
+ "Search the web. Returns titles, URLs, and snippets. Use web_fetch to read a result in full. " +
179
+ "Write queries like a search engine query (keywords), not a question to a person.",
180
+ inputSchema: {
181
+ type: "object",
182
+ properties: {
183
+ query: { type: "string", description: "The search query." },
184
+ category: {
185
+ type: "string",
186
+ enum: [...SEARCH_CATEGORIES],
187
+ description: "general (default): the web. news: recent articles. it: GitHub and Stack Overflow.",
188
+ },
189
+ timeRange: { type: "string", enum: [...SEARCH_TIME_RANGES], description: "Only results from this recent period." },
190
+ },
191
+ required: ["query"],
192
+ additionalProperties: false,
193
+ },
194
+ run: (input) =>
195
+ attempt(async () => {
196
+ const { query, category, timeRange } = object(input);
197
+ if (typeof query !== "string" || !query.trim()) return "Error: query is required.";
198
+ const { results } = await client.search(query, {
199
+ limit,
200
+ category: oneOf(category, SEARCH_CATEGORIES),
201
+ timeRange: oneOf(timeRange, SEARCH_TIME_RANGES),
202
+ });
203
+ if (results.length === 0) return `No results for "${query}".`;
204
+ return results
205
+ .map((r, i) => {
206
+ const date = r.publishedAt ? ` (${r.publishedAt.slice(0, 10)})` : "";
207
+ return `${i + 1}. ${r.title}${date}\n ${r.url}${r.snippet ? `\n ${r.snippet}` : ""}`;
208
+ })
209
+ .join("\n\n");
210
+ }),
211
+ },
212
+ {
213
+ name: "web_fetch",
214
+ description:
215
+ "Read a public web page and return its main content as markdown. Use it on URLs from web_search " +
216
+ "or ones the user gave. Long pages are cut off.",
217
+ inputSchema: {
218
+ type: "object",
219
+ properties: { url: { type: "string", description: "The http(s) URL to read." } },
220
+ required: ["url"],
221
+ additionalProperties: false,
222
+ },
223
+ run: (input) =>
224
+ attempt(async () => {
225
+ const { url } = object(input);
226
+ if (typeof url !== "string" || !url.trim()) return "Error: url is required.";
227
+ const page = await client.fetch(url, { maxChars });
228
+ const head = `# ${page.title ?? page.url}\nURL: ${page.url}\n\n`;
229
+ return head + page.markdown + (page.truncated ? `\n\n[Cut off at ${maxChars} characters.]` : "");
230
+ }),
231
+ },
232
+ ];
233
+ }
234
+
235
+ const object = (input: unknown): Record<string, unknown> =>
236
+ typeof input === "object" && input !== null && !Array.isArray(input) ? (input as Record<string, unknown>) : {};
237
+
238
+ function oneOf<T extends string>(value: unknown, allowed: readonly T[]): T | undefined {
239
+ return allowed.includes(value as T) ? (value as T) : undefined;
240
+ }
241
+
242
+ // Errors the model can act on go back as text; ones only the developer can fix still throw.
243
+ async function attempt(run: () => Promise<string>): Promise<string> {
244
+ try {
245
+ return await run();
246
+ } catch (error) {
247
+ if (error instanceof MybaseApiError && ![401, 403, 404].includes(error.status)) return `Error: ${error.message}`;
248
+ throw error;
249
+ }
250
+ }
package/src/secrets.ts ADDED
@@ -0,0 +1,234 @@
1
+ // Secrets and config per environment, stored encrypted on mybase. Locally and in CI, the
2
+ // CLI injects them into your process (`mybase run -- bun start`); this client is for reading
3
+ // them at runtime instead, e.g. to pick up a rotated value without a restart.
4
+ //
5
+ // const secrets = createSecretsClient({ apiUrl, project: "shop", env: "prod", apiKey: process.env.MYBASE_API_KEY! });
6
+ // await secrets.load(); // into process.env (existing vars win)
7
+ // const stripe = await secrets.get("STRIPE_KEY");
8
+ import { MybaseApiError } from "./cron";
9
+
10
+ export { MybaseApiError };
11
+
12
+ /** `dev`, `staging`, `prod`, `prod_eu`, … */
13
+ export const SECRETS_ENV_PATTERN = /^[a-z][a-z0-9_-]{0,39}$/;
14
+ export const SECRETS_ENV_RULES = "1–40 characters: lowercase letters, digits, _ or -, starting with a letter";
15
+
16
+ /** Env var names: `STRIPE_KEY`, `databaseUrl`, … */
17
+ export const SECRET_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
18
+ export const SECRET_KEY_RULES = "1–128 characters: letters, digits, or _, not starting with a digit";
19
+
20
+ export const SECRETS_LIMITS = {
21
+ /** Per value, UTF-8. */
22
+ valueBytes: 64 * 1024,
23
+ keysPerEnv: 1000,
24
+ envsPerProject: 50,
25
+ /** Older revisions are pruned. */
26
+ revisionsKept: 200,
27
+ messageLength: 200,
28
+ } as const;
29
+
30
+ /** Every project starts with these (they can be renamed or deleted). */
31
+ export const DEFAULT_SECRETS_ENVS = ["dev", "staging", "prod"] as const;
32
+
33
+ /**
34
+ * Keys `mybase secrets link` writes and keeps up to date. Read-only elsewhere: running link
35
+ * again rotates them.
36
+ */
37
+ export const MANAGED_SECRET_KEYS = [
38
+ "DATABASE_URL",
39
+ "S3_ENDPOINT",
40
+ "S3_REGION",
41
+ "S3_BUCKET",
42
+ "S3_ACCESS_KEY_ID",
43
+ "S3_SECRET_ACCESS_KEY",
44
+ "REDIS_URL",
45
+ "MYBASE_URL",
46
+ "MYBASE_PROJECT",
47
+ ] as const;
48
+
49
+ // ---- wire types -------------------------------------------------------------
50
+
51
+ export interface SecretsEnv {
52
+ name: string;
53
+ /** Branch envs inherit every key of their parent (a root env) and override some. */
54
+ parent: string | null;
55
+ /** Bumped on every change. */
56
+ revision: number;
57
+ /** Own keys, not counting inherited ones. */
58
+ keys: number;
59
+ /** Holds the project's credentials (`mybase secrets link`). */
60
+ linked: boolean;
61
+ createdAt: string;
62
+ updatedAt: string;
63
+ }
64
+
65
+ /** `GET /projects/<project>/secrets`. Only envs the caller's key may read are listed. */
66
+ export interface SecretsOverview {
67
+ envs: SecretsEnv[];
68
+ apiUrl: string;
69
+ enabledAt: string;
70
+ }
71
+
72
+ export interface SecretEntry {
73
+ key: string;
74
+ /** Only when values were asked for. */
75
+ value?: string;
76
+ /** Written by `mybase secrets link`. */
77
+ managed: boolean;
78
+ /** From the parent env (a branch env hasn't overridden it). */
79
+ inherited: boolean;
80
+ updatedAt: string;
81
+ }
82
+
83
+ export interface SecretsEnvDetail {
84
+ env: SecretsEnv;
85
+ secrets: SecretEntry[];
86
+ }
87
+
88
+ export interface CreateSecretsEnvRequest {
89
+ name: string;
90
+ /** Makes a branch env of this root env. */
91
+ parent?: string | null;
92
+ }
93
+
94
+ export interface UpdateSecretsRequest {
95
+ set?: Record<string, string>;
96
+ unset?: string[];
97
+ /** Shown in the history. */
98
+ message?: string;
99
+ }
100
+
101
+ export interface UpdateSecretsResponse {
102
+ /** The env's revision after the change (unchanged when nothing changed). */
103
+ revision: number;
104
+ changed: number;
105
+ }
106
+
107
+ export type SecretChangeAction = "added" | "changed" | "removed";
108
+
109
+ export interface SecretChange {
110
+ key: string;
111
+ action: SecretChangeAction;
112
+ managed: boolean;
113
+ /** Only when values were asked for. */
114
+ oldValue?: string | null;
115
+ newValue?: string | null;
116
+ }
117
+
118
+ export interface SecretsRevision {
119
+ revision: number;
120
+ /** `admin`, `key:<name>`, or `link`. */
121
+ actor: string;
122
+ message: string | null;
123
+ createdAt: string;
124
+ changes: SecretChange[];
125
+ }
126
+
127
+ /** `GET /secrets/<project>/<env>`: every key resolved, branch overrides applied. */
128
+ export interface SecretsResponse {
129
+ env: string;
130
+ /** Changes whenever any value (own or inherited) may have changed. Also sent as the ETag. */
131
+ version: string;
132
+ secrets: Record<string, string>;
133
+ }
134
+
135
+ export interface LinkCredentialsRequest {
136
+ envs: string[];
137
+ }
138
+
139
+ export interface LinkCredentialsResponse {
140
+ /** Every env now holding the credentials (earlier links included). */
141
+ envs: string[];
142
+ keys: string[];
143
+ }
144
+
145
+ // ---- client -----------------------------------------------------------------
146
+
147
+ export interface SecretsClientOptions {
148
+ /** Where the mybase API is reachable from your app, e.g. `https://mybase.example.com`. */
149
+ apiUrl: string;
150
+ project: string;
151
+ /** `dev`, `prod`, a branch env, … */
152
+ env: string;
153
+ /** A mybase API key (`mb_…`) with `secrets:read` for this project (and env, if it's env-scoped). Server-side only. */
154
+ apiKey: string;
155
+ /** Reuse fetched secrets this long without asking mybase, in ms. Default 0: every read revalidates (a 304 when nothing changed). */
156
+ maxAgeMs?: number;
157
+ /** Default 10000. */
158
+ timeoutMs?: number;
159
+ /** Default `globalThis.fetch`. */
160
+ fetch?: typeof fetch;
161
+ }
162
+
163
+ export interface SecretsClient {
164
+ /** Every key in the env, inherited ones included. */
165
+ all(): Promise<Record<string, string>>;
166
+ /** One value, or undefined when the key isn't set. */
167
+ get(key: string): Promise<string | undefined>;
168
+ /** Copies the secrets into `process.env`. Existing vars win unless `override`. Returns the keys set. */
169
+ load(options?: { override?: boolean }): Promise<string[]>;
170
+ /** Needs `secrets:write`. One revision in the history. */
171
+ set(values: Record<string, string>, options?: { message?: string }): Promise<UpdateSecretsResponse>;
172
+ unset(keys: string | string[], options?: { message?: string }): Promise<UpdateSecretsResponse>;
173
+ }
174
+
175
+ export function createSecretsClient(options: SecretsClientOptions): SecretsClient {
176
+ const doFetch = options.fetch ?? globalThis.fetch;
177
+ const url = `${options.apiUrl.replace(/\/+$/, "")}/secrets/${encodeURIComponent(options.project)}/${encodeURIComponent(options.env)}`;
178
+ const timeoutMs = options.timeoutMs ?? 10_000;
179
+ const maxAgeMs = options.maxAgeMs ?? 0;
180
+ const headers = { Authorization: `Bearer ${options.apiKey}` };
181
+ let cached: { etag: string | null; secrets: Record<string, string>; at: number } | undefined;
182
+
183
+ async function fail(res: Response): Promise<never> {
184
+ const data = (await res.json().catch(() => ({}))) as { error?: string };
185
+ throw new MybaseApiError(data.error ?? `mybase API answered ${res.status}`, res.status);
186
+ }
187
+
188
+ async function all(): Promise<Record<string, string>> {
189
+ if (cached && Date.now() - cached.at < maxAgeMs) return { ...cached.secrets };
190
+ const res = await doFetch(url, {
191
+ headers: cached?.etag ? { ...headers, "If-None-Match": cached.etag } : headers,
192
+ signal: AbortSignal.timeout(timeoutMs),
193
+ });
194
+ if (res.status === 304 && cached) {
195
+ cached.at = Date.now();
196
+ return { ...cached.secrets };
197
+ }
198
+ if (!res.ok) return fail(res);
199
+ const data = (await res.json()) as SecretsResponse;
200
+ cached = { etag: res.headers.get("ETag"), secrets: data.secrets, at: Date.now() };
201
+ return { ...data.secrets };
202
+ }
203
+
204
+ async function update(body: UpdateSecretsRequest): Promise<UpdateSecretsResponse> {
205
+ const res = await doFetch(url, {
206
+ method: "PATCH",
207
+ body: JSON.stringify(body),
208
+ headers: { ...headers, "Content-Type": "application/json" },
209
+ signal: AbortSignal.timeout(timeoutMs),
210
+ });
211
+ if (!res.ok) return fail(res);
212
+ cached = undefined;
213
+ return (await res.json()) as UpdateSecretsResponse;
214
+ }
215
+
216
+ return {
217
+ all,
218
+ get: async (key) => (await all())[key],
219
+ async load({ override = false } = {}) {
220
+ const secrets = await all();
221
+ const env = (globalThis as { process?: { env: Record<string, string | undefined> } }).process?.env;
222
+ if (!env) throw new Error("load() needs process.env (Node, Bun, Deno); use all() elsewhere");
223
+ const set: string[] = [];
224
+ for (const [key, value] of Object.entries(secrets)) {
225
+ if (!override && env[key] !== undefined) continue;
226
+ env[key] = value;
227
+ set.push(key);
228
+ }
229
+ return set;
230
+ },
231
+ set: (values, opts = {}) => update({ set: values, message: opts.message }),
232
+ unset: (keys, opts = {}) => update({ unset: [keys].flat(), message: opts.message }),
233
+ };
234
+ }