@cavuno/board 1.0.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.
package/dist/index.mjs ADDED
@@ -0,0 +1,621 @@
1
+ // src/errors.ts
2
+ var BoardApiError = class extends Error {
3
+ status;
4
+ /** `<domain>_<snake_reason>` code from the v1 error envelope. */
5
+ code;
6
+ /** Structured, per-code details — shape varies by `code`. */
7
+ details;
8
+ requestId;
9
+ /** The parsed response body, untouched. */
10
+ raw;
11
+ constructor(input) {
12
+ super(input.message);
13
+ this.name = "BoardApiError";
14
+ this.status = input.status;
15
+ this.code = input.code;
16
+ this.details = input.details;
17
+ this.requestId = input.requestId;
18
+ this.raw = input.raw;
19
+ }
20
+ };
21
+ function isBoardApiError(e) {
22
+ return e instanceof BoardApiError;
23
+ }
24
+ function isNotFound(e) {
25
+ return isBoardApiError(e) && e.status === 404;
26
+ }
27
+ function isUnauthorized(e) {
28
+ return isBoardApiError(e) && e.status === 401;
29
+ }
30
+ function isForbidden(e) {
31
+ return isBoardApiError(e) && e.status === 403;
32
+ }
33
+ function isValidationError(e) {
34
+ return isBoardApiError(e) && e.status === 400 && e.code === "validation_bad_request";
35
+ }
36
+ function isRateLimited(e) {
37
+ return isBoardApiError(e) && e.status === 429;
38
+ }
39
+ function isConflict(e) {
40
+ return isBoardApiError(e) && e.status === 409;
41
+ }
42
+
43
+ // src/query.ts
44
+ function toSearchParams(query) {
45
+ const params = new URLSearchParams();
46
+ if (!query) return params;
47
+ for (const [key, value] of Object.entries(query)) {
48
+ if (value === void 0 || value === null) continue;
49
+ if (Array.isArray(value)) {
50
+ for (const item of value) {
51
+ if (item === void 0 || item === null) continue;
52
+ params.append(key, String(item));
53
+ }
54
+ } else {
55
+ params.append(key, String(value));
56
+ }
57
+ }
58
+ return params;
59
+ }
60
+
61
+ // src/storage.ts
62
+ var ACCESS_TOKEN_KEY = "cavuno_board_access_token";
63
+ var REFRESH_TOKEN_KEY = "cavuno_board_refresh_token";
64
+ function isBrowser() {
65
+ return typeof globalThis.document !== "undefined";
66
+ }
67
+ function memoryStorage() {
68
+ const backing = /* @__PURE__ */ new Map();
69
+ return {
70
+ getItem: (key) => backing.get(key) ?? null,
71
+ setItem: (key, value) => {
72
+ backing.set(key, value);
73
+ },
74
+ removeItem: (key) => {
75
+ backing.delete(key);
76
+ }
77
+ };
78
+ }
79
+ var NOSTORE = {
80
+ getItem: () => null,
81
+ setItem: () => {
82
+ },
83
+ removeItem: () => {
84
+ }
85
+ };
86
+ function resolveStorage(mode) {
87
+ const resolved = mode ?? (isBrowser() ? "memory" : "nostore");
88
+ if (typeof resolved === "object") return resolved;
89
+ switch (resolved) {
90
+ case "memory":
91
+ return memoryStorage();
92
+ case "nostore":
93
+ return NOSTORE;
94
+ default:
95
+ throw new Error(`Unknown storage mode '${String(resolved)}'`);
96
+ }
97
+ }
98
+ async function writeSession(storage, session) {
99
+ await storage.setItem(ACCESS_TOKEN_KEY, session.accessToken);
100
+ await storage.setItem(REFRESH_TOKEN_KEY, session.refreshToken);
101
+ }
102
+ async function clearSession(storage) {
103
+ await storage.removeItem(ACCESS_TOKEN_KEY);
104
+ await storage.removeItem(REFRESH_TOKEN_KEY);
105
+ }
106
+
107
+ // src/version.ts
108
+ var SDK_VERSION = "1.0.0";
109
+
110
+ // src/client.ts
111
+ function isRawBody(body) {
112
+ return typeof body === "string" || body instanceof URLSearchParams || typeof FormData !== "undefined" && body instanceof FormData || typeof Blob !== "undefined" && body instanceof Blob || body instanceof ArrayBuffer || typeof ReadableStream !== "undefined" && body instanceof ReadableStream;
113
+ }
114
+ var BoardClient = class {
115
+ storage;
116
+ basePath;
117
+ options;
118
+ constructor(options) {
119
+ this.options = options;
120
+ this.storage = options.storage;
121
+ const baseUrl = options.baseUrl.replace(/\/+$/, "");
122
+ this.basePath = `${baseUrl}/v1/boards/${encodeURIComponent(options.board)}`;
123
+ }
124
+ /**
125
+ * The full request pipeline. Public and first-class: custom endpoints
126
+ * work without an SDK release, and still get the board-identifier
127
+ * base path, default headers, bearer token, and both hooks.
128
+ *
129
+ * @example
130
+ * const stats = await board.client.fetch<MyShape>('/custom/stats');
131
+ */
132
+ async fetch(path, init = {}) {
133
+ const { body, query, headers: callHeaders, ...passthrough } = init;
134
+ const method = (init.method ?? "GET").toUpperCase();
135
+ let url = `${this.basePath}${path}`;
136
+ const params = toSearchParams(query);
137
+ const search = params.toString();
138
+ if (search) url += `?${search}`;
139
+ const jsonBody = body !== void 0 && !isRawBody(body);
140
+ const serializedBody = body === void 0 ? void 0 : jsonBody ? JSON.stringify(body) : body;
141
+ const headers = new Headers({ accept: "application/json" });
142
+ if (jsonBody) headers.set("content-type", "application/json");
143
+ for (const [key, value] of Object.entries(
144
+ this.options.globalHeaders ?? {}
145
+ )) {
146
+ headers.set(key, value);
147
+ }
148
+ const token = await this.storage.getItem(ACCESS_TOKEN_KEY);
149
+ if (token !== null) headers.set("authorization", `Bearer ${token}`);
150
+ if (callHeaders) {
151
+ new Headers(callHeaders).forEach((value, key) => {
152
+ headers.set(key, value);
153
+ });
154
+ }
155
+ if ((method !== "GET" || headers.has("authorization")) && !headers.has("x-cavuno-sdk")) {
156
+ headers.set("x-cavuno-sdk", `board@${SDK_VERSION}`);
157
+ }
158
+ let req = {
159
+ url,
160
+ init: { ...passthrough, method, headers, body: serializedBody }
161
+ };
162
+ if (this.options.onRequest) req = await this.options.onRequest(req);
163
+ this.options.logger?.debug(`${method} ${req.url}`);
164
+ const res = await globalThis.fetch(req.url, req.init);
165
+ this.options.logger?.debug(`${res.status} ${method} ${req.url}`);
166
+ if (this.options.onResponse) await this.options.onResponse(res.clone(), req);
167
+ if (res.status === 204) return void 0;
168
+ if (res.ok) return await res.json();
169
+ let parsed;
170
+ try {
171
+ parsed = await res.json();
172
+ } catch {
173
+ parsed = void 0;
174
+ }
175
+ const envelope = parsed?.error;
176
+ const error = envelope && typeof envelope.code === "string" && typeof envelope.message === "string" ? new BoardApiError({
177
+ status: res.status,
178
+ code: envelope.code,
179
+ message: envelope.message,
180
+ details: envelope.details,
181
+ requestId: envelope.requestId,
182
+ raw: parsed
183
+ }) : new BoardApiError({
184
+ status: res.status,
185
+ code: "unknown_error",
186
+ message: res.statusText || "Request failed",
187
+ raw: parsed
188
+ });
189
+ this.options.logger?.error(
190
+ `${error.status} ${error.code} ${method} ${req.url}`
191
+ );
192
+ throw error;
193
+ }
194
+ };
195
+
196
+ // src/namespaces/auth.ts
197
+ function authNamespace(client) {
198
+ async function persist(session) {
199
+ await writeSession(client.storage, session);
200
+ return session;
201
+ }
202
+ async function resolveRefreshToken(body) {
203
+ if (body?.refreshToken) return body.refreshToken;
204
+ const stored = await client.storage.getItem(REFRESH_TOKEN_KEY);
205
+ if (stored === null) {
206
+ throw new Error(
207
+ "No refresh token available \u2014 pass { refreshToken } or configure auth.storage"
208
+ );
209
+ }
210
+ return stored;
211
+ }
212
+ return {
213
+ /**
214
+ * Register a board user (emailpass). Persists the returned token
215
+ * pair to storage and returns the session.
216
+ *
217
+ * @example
218
+ * await board.auth.register({
219
+ * role: 'candidate',
220
+ * method: 'emailpass',
221
+ * email: 'a@b.com',
222
+ * password: 'hunter22',
223
+ * displayName: 'Ada',
224
+ * });
225
+ */
226
+ async register(body, options) {
227
+ const session = await client.fetch("/auth/register", {
228
+ ...options,
229
+ method: "POST",
230
+ body
231
+ });
232
+ return persist(session);
233
+ },
234
+ /**
235
+ * Log in with email + password. Persists the returned token pair to
236
+ * storage and returns the session. The SDK never navigates — the
237
+ * host app owns any redirect/verification UX.
238
+ *
239
+ * @example
240
+ * const { boardUser } = await board.auth.login({
241
+ * email: 'a@b.com',
242
+ * password: 'hunter22',
243
+ * });
244
+ */
245
+ async login(body, options) {
246
+ const session = await client.fetch("/auth/login", {
247
+ ...options,
248
+ method: "POST",
249
+ body
250
+ });
251
+ return persist(session);
252
+ },
253
+ /**
254
+ * Rotate the refresh token for a new bearer pair. Refresh tokens
255
+ * are single-use; the rotated pair is persisted to storage. There
256
+ * is no automatic refresh on 401 — call this explicitly.
257
+ *
258
+ * @example
259
+ * await board.auth.refresh(); // uses the stored refresh token
260
+ */
261
+ async refresh(body, options) {
262
+ const refreshToken = await resolveRefreshToken(body);
263
+ const session = await client.fetch("/auth/refresh", {
264
+ ...options,
265
+ method: "POST",
266
+ body: { refreshToken }
267
+ });
268
+ return persist(session);
269
+ },
270
+ /**
271
+ * Revoke the refresh token and clear stored tokens. Idempotent
272
+ * server-side (204 even for unknown tokens). If the request itself
273
+ * fails (network error, 5xx), stored tokens are deliberately kept —
274
+ * clearing them would leave a live refresh token server-side while
275
+ * the client believes it's logged out. Catch and retry, or clear
276
+ * storage yourself to force a local logout.
277
+ *
278
+ * @example
279
+ * await board.auth.logout();
280
+ */
281
+ async logout(body, options) {
282
+ const refreshToken = await resolveRefreshToken(body);
283
+ await client.fetch("/auth/logout", {
284
+ ...options,
285
+ method: "POST",
286
+ body: { refreshToken }
287
+ });
288
+ await clearSession(client.storage);
289
+ },
290
+ /**
291
+ * Confirm an email-verification token (sent by register).
292
+ *
293
+ * @example
294
+ * await board.auth.verifyEmail({ token });
295
+ */
296
+ verifyEmail(body, options) {
297
+ return client.fetch("/auth/verify-email", {
298
+ ...options,
299
+ method: "POST",
300
+ body
301
+ });
302
+ },
303
+ /**
304
+ * Request a password-reset email. Always resolves (204) whether or
305
+ * not the email exists — no account oracle.
306
+ *
307
+ * @example
308
+ * await board.auth.forgotPassword({ email: 'a@b.com' });
309
+ */
310
+ forgotPassword(body, options) {
311
+ return client.fetch("/auth/forgot-password", {
312
+ ...options,
313
+ method: "POST",
314
+ body
315
+ });
316
+ },
317
+ /**
318
+ * Set a new password with a reset token. The token is single-use;
319
+ * all existing sessions are invalidated server-side.
320
+ *
321
+ * @example
322
+ * await board.auth.resetPassword({ token, password: 'newpass99' });
323
+ */
324
+ resetPassword(body, options) {
325
+ return client.fetch("/auth/reset-password", {
326
+ ...options,
327
+ method: "POST",
328
+ body
329
+ });
330
+ }
331
+ };
332
+ }
333
+
334
+ // src/namespaces/blog.ts
335
+ function blogNamespace(client) {
336
+ return {
337
+ posts: {
338
+ /**
339
+ * List published posts (summaries — no `html`).
340
+ *
341
+ * @example
342
+ * const { data } = await board.blog.posts.list({ tagSlug: 'news' });
343
+ */
344
+ list(query, options) {
345
+ return client.fetch(
346
+ "/blog/posts",
347
+ { ...options, query }
348
+ );
349
+ },
350
+ /**
351
+ * Retrieve one post by slug, including its `html` body.
352
+ *
353
+ * @example
354
+ * const post = await board.blog.posts.retrieve('hello-world');
355
+ */
356
+ retrieve(postSlug, query, options) {
357
+ return client.fetch(
358
+ `/blog/posts/${encodeURIComponent(postSlug)}`,
359
+ { ...options, query }
360
+ );
361
+ }
362
+ },
363
+ tags: {
364
+ /** @example const { data } = await board.blog.tags.list(); */
365
+ list(query, options) {
366
+ return client.fetch("/blog/tags", {
367
+ ...options,
368
+ query
369
+ });
370
+ },
371
+ /** @example const tag = await board.blog.tags.retrieve('news'); */
372
+ retrieve(tagSlug, query, options) {
373
+ return client.fetch(
374
+ `/blog/tags/${encodeURIComponent(tagSlug)}`,
375
+ { ...options, query }
376
+ );
377
+ }
378
+ },
379
+ authors: {
380
+ /** @example const { data } = await board.blog.authors.list(); */
381
+ list(query, options) {
382
+ return client.fetch("/blog/authors", {
383
+ ...options,
384
+ query
385
+ });
386
+ },
387
+ /** @example const author = await board.blog.authors.retrieve('jane'); */
388
+ retrieve(authorSlug, query, options) {
389
+ return client.fetch(
390
+ `/blog/authors/${encodeURIComponent(authorSlug)}`,
391
+ { ...options, query }
392
+ );
393
+ }
394
+ },
395
+ /**
396
+ * Free-text post search (returns post summaries).
397
+ *
398
+ * @example
399
+ * const { data } = await board.blog.search({ query: 'launch' });
400
+ */
401
+ search(body, query, options) {
402
+ return client.fetch(
403
+ "/blog/search",
404
+ { ...options, method: "POST", body, query }
405
+ );
406
+ }
407
+ };
408
+ }
409
+
410
+ // src/namespaces/companies.ts
411
+ function companiesNamespace(client) {
412
+ return {
413
+ /**
414
+ * List companies on the board.
415
+ *
416
+ * @example
417
+ * const { data } = await board.companies.list({ limit: 20 });
418
+ */
419
+ list(query, options) {
420
+ return client.fetch("/companies", {
421
+ ...options,
422
+ query
423
+ });
424
+ },
425
+ /**
426
+ * Retrieve one company by slug.
427
+ *
428
+ * @example
429
+ * const company = await board.companies.retrieve('acme');
430
+ */
431
+ retrieve(companySlug, query, options) {
432
+ return client.fetch(
433
+ `/companies/${encodeURIComponent(companySlug)}`,
434
+ { ...options, query }
435
+ );
436
+ },
437
+ /**
438
+ * Free-text company search.
439
+ *
440
+ * @example
441
+ * const { data } = await board.companies.search({ query: 'acme' });
442
+ */
443
+ search(body, query, options) {
444
+ return client.fetch("/companies/search", {
445
+ ...options,
446
+ method: "POST",
447
+ body,
448
+ query
449
+ });
450
+ },
451
+ /**
452
+ * List one company's published jobs.
453
+ *
454
+ * @example
455
+ * const { data } = await board.companies.listJobs('acme', { limit: 10 });
456
+ */
457
+ listJobs(companySlug, query, options) {
458
+ return client.fetch(
459
+ `/companies/${encodeURIComponent(companySlug)}/jobs`,
460
+ { ...options, query }
461
+ );
462
+ }
463
+ };
464
+ }
465
+
466
+ // src/namespaces/jobs.ts
467
+ function jobsNamespace(client) {
468
+ return {
469
+ /**
470
+ * List published jobs.
471
+ *
472
+ * @example
473
+ * const { data, nextCursor } = await board.jobs.list({ limit: 20 });
474
+ */
475
+ list(query, options) {
476
+ return client.fetch("/jobs", {
477
+ ...options,
478
+ query
479
+ });
480
+ },
481
+ /**
482
+ * Retrieve one published job by slug.
483
+ *
484
+ * @example
485
+ * const job = await board.jobs.retrieve('senior-chef');
486
+ */
487
+ retrieve(jobSlug, query, options) {
488
+ return client.fetch(`/jobs/${encodeURIComponent(jobSlug)}`, {
489
+ ...options,
490
+ query
491
+ });
492
+ },
493
+ /**
494
+ * Free-text + faceted job search.
495
+ *
496
+ * @example
497
+ * const { data } = await board.jobs.search({
498
+ * query: 'chef',
499
+ * filters: { seniority: ['senior'] },
500
+ * });
501
+ */
502
+ search(body, query, options) {
503
+ return client.fetch("/jobs/search", {
504
+ ...options,
505
+ method: "POST",
506
+ body,
507
+ query
508
+ });
509
+ }
510
+ };
511
+ }
512
+
513
+ // src/namespaces/me.ts
514
+ function meNamespace(client) {
515
+ return {
516
+ /**
517
+ * Retrieve the authenticated board user.
518
+ *
519
+ * @example
520
+ * const me = await board.me.retrieve();
521
+ */
522
+ // `query?: Record<string, never>` = no query params today; the slot
523
+ // holds the locked (id, query?, options?) shape and widens to a real
524
+ // query type without breaking callers when the API grows one.
525
+ retrieve(query, options) {
526
+ return client.fetch("/me", { ...options, query });
527
+ },
528
+ savedJobs: {
529
+ /**
530
+ * List the authenticated user's saved jobs (each embeds the full
531
+ * `public_job`).
532
+ *
533
+ * @example
534
+ * const { data } = await board.me.savedJobs.list({ limit: 20 });
535
+ */
536
+ list(query, options) {
537
+ return client.fetch("/me/saved-jobs", {
538
+ ...options,
539
+ query
540
+ });
541
+ },
542
+ /**
543
+ * Save a job. Converges — saving an already-saved job returns the
544
+ * identical row.
545
+ *
546
+ * @example
547
+ * await board.me.savedJobs.save({ jobId: job.id });
548
+ */
549
+ save(body, query, options) {
550
+ return client.fetch("/me/saved-jobs", {
551
+ ...options,
552
+ method: "POST",
553
+ body,
554
+ query
555
+ });
556
+ },
557
+ /**
558
+ * Unsave a job. Idempotent — unknown IDs still 204.
559
+ *
560
+ * @example
561
+ * await board.me.savedJobs.unsave(job.id);
562
+ */
563
+ unsave(jobId, query, options) {
564
+ return client.fetch(
565
+ `/me/saved-jobs/${encodeURIComponent(jobId)}`,
566
+ { ...options, method: "DELETE", query }
567
+ );
568
+ }
569
+ }
570
+ };
571
+ }
572
+
573
+ // src/index.ts
574
+ function createBoardClient(options) {
575
+ const client = new BoardClient({
576
+ baseUrl: options.baseUrl,
577
+ board: options.board,
578
+ storage: resolveStorage(options.auth?.storage),
579
+ globalHeaders: options.globalHeaders,
580
+ onRequest: options.onRequest,
581
+ onResponse: options.onResponse,
582
+ logger: options.logger
583
+ });
584
+ return {
585
+ /**
586
+ * Escape hatch: raw typed request through the full pipeline (board
587
+ * base path, default headers, bearer token, hooks). Custom
588
+ * endpoints work without an SDK release.
589
+ */
590
+ client,
591
+ /**
592
+ * Board context — identity, features, analytics, theme.
593
+ *
594
+ * @example
595
+ * const { name, theme } = await board.context();
596
+ */
597
+ context(options2) {
598
+ return client.fetch("", options2);
599
+ },
600
+ jobs: jobsNamespace(client),
601
+ companies: companiesNamespace(client),
602
+ blog: blogNamespace(client),
603
+ auth: authNamespace(client),
604
+ me: meNamespace(client)
605
+ };
606
+ }
607
+ export {
608
+ ACCESS_TOKEN_KEY,
609
+ BoardApiError,
610
+ BoardClient,
611
+ REFRESH_TOKEN_KEY,
612
+ SDK_VERSION,
613
+ createBoardClient,
614
+ isBoardApiError,
615
+ isConflict,
616
+ isForbidden,
617
+ isNotFound,
618
+ isRateLimited,
619
+ isUnauthorized,
620
+ isValidationError
621
+ };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@cavuno/board",
3
+ "version": "1.0.0",
4
+ "description": "Typed isomorphic client for the Cavuno Board API",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/wollemiahq/cavuno.git",
9
+ "directory": "packages/board-sdk"
10
+ },
11
+ "sideEffects": false,
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
15
+ "main": "./dist/index.js",
16
+ "module": "./dist/index.mjs",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "import": {
21
+ "types": "./dist/index.d.mts",
22
+ "default": "./dist/index.mjs"
23
+ },
24
+ "require": {
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ }
28
+ }
29
+ },
30
+ "files": [
31
+ "dist"
32
+ ],
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "24.9.1",
38
+ "tsup": "^8.4.0",
39
+ "vitest": "^2.1.9",
40
+ "@kit/tsconfig": "0.1.0"
41
+ },
42
+ "scripts": {
43
+ "build": "tsup",
44
+ "clean": "git clean -xdf .turbo dist node_modules",
45
+ "typecheck": "tsgo --noEmit",
46
+ "test": "vitest run",
47
+ "assert-publish-target": "node -e \"const p=require('./package.json'); if(p.name!=='@cavuno/board'){throw new Error('Refusing to publish: package.json name is '+p.name+', expected @cavuno/board')}; if(p.private){throw new Error('Refusing to publish: package.json has private:true')}\""
48
+ }
49
+ }