@inneropen/marvin-sdk 2.0.0 → 3.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.cjs CHANGED
@@ -27,24 +27,117 @@ __export(config_exports, {
27
27
  validateConfig: () => validateConfig
28
28
  });
29
29
  function validateConfig(config) {
30
+ const receivedKeys = Object.keys(config).join(", ");
30
31
  if (!config.apiUrl) {
31
- throw new Error("MARVIN_API_URL is required");
32
+ throw new Error(
33
+ `Missing required config: 'apiUrl'
34
+
35
+ The API URL is the base URL of your Marvin server.
36
+
37
+ Examples:
38
+ - Production: https://marvin.example.com
39
+ - Development: http://localhost:8080
40
+
41
+ Solutions:
42
+ 1. Set environment variable: MARVIN_API_URL=https://marvin.example.com
43
+ 2. Pass directly: createMarvinClient({ apiUrl: 'https://marvin.example.com' })
44
+
45
+ Received config keys: ${receivedKeys || "(none)"}`
46
+ );
32
47
  }
33
48
  if (!config.siteClientToken) {
34
- throw new Error("MARVIN_SITE_CLIENT_TOKEN is required");
49
+ throw new Error(
50
+ `Missing required config: 'siteClientToken'
51
+
52
+ The site client token provides read-only access to published content.
53
+
54
+ How to get it:
55
+ 1. Log into Marvin admin panel
56
+ 2. Navigate to: Settings \u2192 API Clients
57
+ 3. Copy the token for your workspace
58
+
59
+ Examples:
60
+ - site_client_abc123xyz
61
+ - sc_1234567890abcdef
62
+
63
+ Solutions:
64
+ 1. Set environment variable: MARVIN_SITE_CLIENT_TOKEN=site_client_abc123
65
+ 2. Pass directly: createMarvinClient({ siteClientToken: 'site_client_abc123' })
66
+
67
+ Received config keys: ${receivedKeys}`
68
+ );
35
69
  }
36
70
  if (!config.workspaceSlug) {
37
- throw new Error("MARVIN_WORKSPACE_SLUG is required");
71
+ throw new Error(
72
+ `Missing required config: 'workspaceSlug'
73
+
74
+ The workspace slug identifies which workspace to access.
75
+
76
+ Examples:
77
+ - my-blog
78
+ - company-website
79
+ - acme-corp
80
+
81
+ How to find it:
82
+ 1. Check your workspace URL: https://marvin.example.com/workspace/[slug]
83
+ 2. Or find it in: Settings \u2192 General \u2192 Workspace Slug
84
+
85
+ Solutions:
86
+ 1. Set environment variable: MARVIN_WORKSPACE_SLUG=my-blog
87
+ 2. Pass directly: createMarvinClient({ workspaceSlug: 'my-blog' })
88
+
89
+ Received config keys: ${receivedKeys}`
90
+ );
91
+ }
92
+ if (config.apiUrl) {
93
+ try {
94
+ new URL(config.apiUrl);
95
+ } catch {
96
+ throw new Error(
97
+ `Invalid 'apiUrl' format: "${config.apiUrl}"
98
+
99
+ The API URL must be a valid HTTP or HTTPS URL.
100
+
101
+ Valid examples:
102
+ \u2713 https://marvin.example.com
103
+ \u2713 http://localhost:8080
104
+ \u2713 https://api.marvin.io
105
+
106
+ Invalid examples:
107
+ \u2717 marvin.example.com (missing protocol)
108
+ \u2717 ftp://marvin.example.com (wrong protocol)
109
+ \u2717 /api (not a full URL)`
110
+ );
111
+ }
112
+ const url = new URL(config.apiUrl);
113
+ if (!["http:", "https:"].includes(url.protocol)) {
114
+ throw new Error(
115
+ `Invalid 'apiUrl' protocol: "${url.protocol}"
116
+
117
+ Only HTTP and HTTPS protocols are supported.
118
+
119
+ Your URL: ${config.apiUrl}
120
+ Change to: ${config.apiUrl.replace(url.protocol, "https:")}`
121
+ );
122
+ }
123
+ }
124
+ }
125
+ function getEnv(key) {
126
+ if (typeof process !== "undefined" && process.env) {
127
+ return process.env[key];
38
128
  }
129
+ return void 0;
39
130
  }
40
131
  function createConfigFromEnv(overrides) {
41
132
  return {
42
- apiUrl: overrides?.apiUrl || process.env.MARVIN_API_URL || "",
43
- siteClientToken: overrides?.siteClientToken || process.env.MARVIN_SITE_CLIENT_TOKEN || "",
44
- workspaceSlug: overrides?.workspaceSlug || process.env.MARVIN_WORKSPACE_SLUG || "",
133
+ apiUrl: overrides?.apiUrl || getEnv("MARVIN_API_URL") || "",
134
+ siteClientToken: overrides?.siteClientToken || getEnv("MARVIN_SITE_CLIENT_TOKEN") || "",
135
+ workspaceSlug: overrides?.workspaceSlug || getEnv("MARVIN_WORKSPACE_SLUG") || "",
45
136
  autoInitialize: overrides?.autoInitialize ?? false,
46
- cacheDuration: overrides?.cacheDuration ?? 5 * 60 * 1e3
137
+ cacheDuration: overrides?.cacheDuration ?? 5 * 60 * 1e3,
47
138
  // 5 minutes
139
+ debug: overrides?.debug ?? getEnv("MARVIN_DEBUG") === "true",
140
+ logger: overrides?.logger ?? console
48
141
  };
49
142
  }
50
143
  var init_config = __esm({
@@ -54,7 +147,7 @@ var init_config = __esm({
54
147
  });
55
148
 
56
149
  // src/core/errors/MarvinError.ts
57
- var MarvinError, MarvinApiError, MarvinAuthError, MarvinConfigError, MarvinValidationError;
150
+ var MarvinError, MarvinApiError, MarvinAuthError, MarvinConfigError, MarvinValidationError, MarvinNotFoundError, MarvinNetworkError, MarvinServerError;
58
151
  var init_MarvinError = __esm({
59
152
  "src/core/errors/MarvinError.ts"() {
60
153
  "use strict";
@@ -110,6 +203,33 @@ ${body}` : ""}`;
110
203
  }
111
204
  field;
112
205
  };
206
+ MarvinNotFoundError = class _MarvinNotFoundError extends MarvinError {
207
+ constructor(message, endpoint) {
208
+ super(message, "NOT_FOUND", 404);
209
+ this.endpoint = endpoint;
210
+ this.name = "MarvinNotFoundError";
211
+ Object.setPrototypeOf(this, _MarvinNotFoundError.prototype);
212
+ }
213
+ endpoint;
214
+ };
215
+ MarvinNetworkError = class _MarvinNetworkError extends MarvinError {
216
+ constructor(message, originalError) {
217
+ super(message, "NETWORK_ERROR");
218
+ this.originalError = originalError;
219
+ this.name = "MarvinNetworkError";
220
+ Object.setPrototypeOf(this, _MarvinNetworkError.prototype);
221
+ }
222
+ originalError;
223
+ };
224
+ MarvinServerError = class _MarvinServerError extends MarvinError {
225
+ constructor(message, statusCode, endpoint) {
226
+ super(message, "SERVER_ERROR", statusCode);
227
+ this.endpoint = endpoint;
228
+ this.name = "MarvinServerError";
229
+ Object.setPrototypeOf(this, _MarvinServerError.prototype);
230
+ }
231
+ endpoint;
232
+ };
113
233
  }
114
234
  });
115
235
 
@@ -133,12 +253,47 @@ var init_HttpClient = __esm({
133
253
  defaultHeaders;
134
254
  credentials;
135
255
  timeout;
256
+ debug;
257
+ retryConfig;
258
+ logger;
136
259
  constructor(config) {
137
260
  this.baseUrl = config.baseUrl;
138
261
  this.auth = config.auth;
139
262
  this.defaultHeaders = config.defaultHeaders || {};
140
263
  this.credentials = config.credentials || "same-origin";
141
- this.timeout = config.timeout || 3e4;
264
+ const MAX_TIMEOUT = 12e4;
265
+ this.timeout = Math.min(config.timeout || 3e4, MAX_TIMEOUT);
266
+ this.debug = config.debug ?? false;
267
+ this.logger = config.logger ?? console;
268
+ this.retryConfig = {
269
+ maxRetries: config.retry?.maxRetries ?? 3,
270
+ initialDelay: config.retry?.initialDelay ?? 1e3,
271
+ maxDelay: config.retry?.maxDelay ?? 1e4,
272
+ retryableStatuses: config.retry?.retryableStatuses ?? [408, 429, 500, 502, 503, 504]
273
+ };
274
+ }
275
+ /**
276
+ * Validate path parameter to prevent path traversal attacks
277
+ */
278
+ validatePathParam(param, paramName = "parameter") {
279
+ if (typeof param === "number") {
280
+ return String(param);
281
+ }
282
+ if (!param || typeof param !== "string") {
283
+ throw new MarvinValidationError(`Invalid ${paramName}: must be a non-empty string or number`);
284
+ }
285
+ if (param.includes("..") || param.includes("/") || param.includes("\\")) {
286
+ throw new MarvinValidationError(
287
+ `Invalid ${paramName}: cannot contain path traversal characters (.. / \\)`
288
+ );
289
+ }
290
+ if (param.includes("\0")) {
291
+ throw new MarvinValidationError(`Invalid ${paramName}: cannot contain null bytes`);
292
+ }
293
+ if (param.length > 255) {
294
+ throw new MarvinValidationError(`Invalid ${paramName}: exceeds maximum length of 255 characters`);
295
+ }
296
+ return param;
142
297
  }
143
298
  /**
144
299
  * Build full URL from endpoint
@@ -172,14 +327,78 @@ var init_HttpClient = __esm({
172
327
  return filtered.length > 0 ? `?${filtered.join("&")}` : "";
173
328
  }
174
329
  /**
175
- * Perform HTTP request
330
+ * Sanitize data for logging by redacting sensitive fields
331
+ */
332
+ sanitizeForLogging(data) {
333
+ if (!data || typeof data !== "object") {
334
+ return data;
335
+ }
336
+ const sensitiveFields = [
337
+ "token",
338
+ "access_token",
339
+ "refresh_token",
340
+ "password",
341
+ "secret",
342
+ "apiKey",
343
+ "api_key",
344
+ "bearer",
345
+ "authorization",
346
+ "csrf_token",
347
+ "session",
348
+ "cookie"
349
+ ];
350
+ const sanitize = (obj) => {
351
+ const sanitized = {};
352
+ for (const [key, value] of Object.entries(obj)) {
353
+ const keyLower = key.toLowerCase();
354
+ const isSensitive = sensitiveFields.some((field) => keyLower.includes(field));
355
+ if (isSensitive) {
356
+ sanitized[key] = "[REDACTED]";
357
+ } else if (value && typeof value === "object" && !Array.isArray(value)) {
358
+ sanitized[key] = sanitize(value);
359
+ } else if (Array.isArray(value)) {
360
+ sanitized[key] = value.map(
361
+ (item) => item && typeof item === "object" ? sanitize(item) : item
362
+ );
363
+ } else {
364
+ sanitized[key] = value;
365
+ }
366
+ }
367
+ return sanitized;
368
+ };
369
+ if (Array.isArray(data)) {
370
+ return data.map(
371
+ (item) => item && typeof item === "object" ? sanitize(item) : item
372
+ );
373
+ }
374
+ return sanitize(data);
375
+ }
376
+ /**
377
+ * Calculate delay for retry with exponential backoff
378
+ */
379
+ calculateRetryDelay(attempt) {
380
+ const delay = this.retryConfig.initialDelay * Math.pow(2, attempt);
381
+ return Math.min(delay, this.retryConfig.maxDelay);
382
+ }
383
+ /**
384
+ * Sleep for specified milliseconds
176
385
  */
177
- async request(method, endpoint, options) {
386
+ sleep(ms) {
387
+ return new Promise((resolve) => setTimeout(resolve, ms));
388
+ }
389
+ /**
390
+ * Perform HTTP request with automatic retry on transient failures
391
+ */
392
+ async request(method, endpoint, options, retryAttempt = 0) {
178
393
  const url = this.buildUrl(endpoint) + (options?.params ? this.buildQueryString(options.params) : "");
179
394
  const isFormData = options?.body instanceof FormData;
180
395
  const headers = this.buildHeaders(options?.headers, isFormData);
181
396
  const controller = new AbortController();
182
397
  const timeoutId = setTimeout(() => controller.abort(), this.timeout);
398
+ const startTime = Date.now();
399
+ if (this.debug) {
400
+ this.logger.log(`[Marvin] ${method} ${url}`);
401
+ }
183
402
  try {
184
403
  const response = await fetch(url, {
185
404
  method,
@@ -189,11 +408,54 @@ var init_HttpClient = __esm({
189
408
  signal: controller.signal
190
409
  });
191
410
  clearTimeout(timeoutId);
411
+ const duration = Date.now() - startTime;
412
+ if (this.debug) {
413
+ this.logger.log(
414
+ `[Marvin] ${response.status} ${response.statusText} (${duration}ms)`
415
+ );
416
+ }
192
417
  if (response.status === 401 && this.auth.handleUnauthorized) {
193
418
  await this.auth.handleUnauthorized();
194
419
  }
195
420
  if (!response.ok) {
196
421
  const errorText = await response.text().catch(() => "");
422
+ if (this.debug) {
423
+ this.logger.error(
424
+ `[Marvin] Error response: ${response.status} ${response.statusText}`,
425
+ errorText ? `Body: ${errorText}` : ""
426
+ );
427
+ }
428
+ const isRetryable = this.retryConfig.retryableStatuses.includes(response.status);
429
+ const canRetry = retryAttempt < this.retryConfig.maxRetries;
430
+ if (isRetryable && canRetry) {
431
+ const delay = this.calculateRetryDelay(retryAttempt);
432
+ if (this.debug) {
433
+ this.logger.warn(
434
+ `[Marvin] Retry ${retryAttempt + 1}/${this.retryConfig.maxRetries} after ${delay}ms (status: ${response.status})`
435
+ );
436
+ }
437
+ await this.sleep(delay);
438
+ return this.request(method, endpoint, options, retryAttempt + 1);
439
+ }
440
+ if (response.status === 404) {
441
+ throw new MarvinNotFoundError(
442
+ `Resource not found: ${endpoint}`,
443
+ endpoint
444
+ );
445
+ }
446
+ if (response.status === 401 || response.status === 403) {
447
+ throw new MarvinAuthError(
448
+ `Authentication failed: ${response.statusText}`,
449
+ response.status
450
+ );
451
+ }
452
+ if (response.status >= 500) {
453
+ throw new MarvinServerError(
454
+ `Server error: ${response.status} ${response.statusText}`,
455
+ response.status,
456
+ endpoint
457
+ );
458
+ }
197
459
  throw MarvinApiError.fromResponse(
198
460
  response.status,
199
461
  response.statusText,
@@ -205,21 +467,39 @@ var init_HttpClient = __esm({
205
467
  if (!contentType || !contentType.includes("application/json")) {
206
468
  return void 0;
207
469
  }
208
- return await response.json();
470
+ const data = await response.json();
471
+ if (this.debug) {
472
+ const sanitized = this.sanitizeForLogging(data);
473
+ this.logger.log("[Marvin] Response:", JSON.stringify(sanitized, null, 2));
474
+ }
475
+ return data;
209
476
  } catch (error) {
210
477
  clearTimeout(timeoutId);
211
- if (error instanceof MarvinApiError) {
478
+ if (error instanceof MarvinApiError || error instanceof MarvinNotFoundError || error instanceof MarvinAuthError || error instanceof MarvinServerError) {
212
479
  throw error;
213
480
  }
214
481
  if (error instanceof Error) {
482
+ const canRetry = retryAttempt < this.retryConfig.maxRetries;
483
+ if (canRetry && error.name !== "AbortError") {
484
+ const delay = this.calculateRetryDelay(retryAttempt);
485
+ if (this.debug) {
486
+ this.logger.warn(
487
+ `[Marvin] Retry ${retryAttempt + 1}/${this.retryConfig.maxRetries} after ${delay}ms (network error)`
488
+ );
489
+ }
490
+ await this.sleep(delay);
491
+ return this.request(method, endpoint, options, retryAttempt + 1);
492
+ }
215
493
  if (error.name === "AbortError") {
216
- throw new MarvinApiError(
494
+ throw new MarvinNetworkError(
217
495
  `Request timeout after ${this.timeout}ms`,
218
- 408,
219
- endpoint
496
+ error
220
497
  );
221
498
  }
222
- throw new MarvinApiError(error.message, 0, endpoint);
499
+ throw new MarvinNetworkError(
500
+ `Network error: ${error.message}`,
501
+ error
502
+ );
223
503
  }
224
504
  throw error;
225
505
  }
@@ -290,10 +570,27 @@ var init_AuthStrategy = __esm({
290
570
  }
291
571
  sessionToken;
292
572
  onUnauthorized;
573
+ csrfToken;
574
+ /**
575
+ * Set CSRF token for cross-site request forgery protection
576
+ * The token should be obtained from the server after login
577
+ */
578
+ setCsrfToken(token) {
579
+ this.csrfToken = token;
580
+ }
581
+ /**
582
+ * Get current CSRF token
583
+ */
584
+ getCsrfToken() {
585
+ return this.csrfToken;
586
+ }
293
587
  injectAuth(headers) {
294
588
  if (this.sessionToken) {
295
589
  headers.set("Authorization", `Bearer ${this.sessionToken}`);
296
590
  }
591
+ if (this.csrfToken) {
592
+ headers.set("X-CSRF-Token", this.csrfToken);
593
+ }
297
594
  }
298
595
  async handleUnauthorized() {
299
596
  if (this.onUnauthorized) {
@@ -301,7 +598,9 @@ var init_AuthStrategy = __esm({
301
598
  }
302
599
  }
303
600
  getDescription() {
304
- return this.sessionToken ? "Session authentication (explicit token)" : "Session authentication (cookies)";
601
+ const auth = this.sessionToken ? "Session authentication (explicit token)" : "Session authentication (cookies)";
602
+ const csrf = this.csrfToken ? " with CSRF protection" : "";
603
+ return auth + csrf;
305
604
  }
306
605
  };
307
606
  NoAuth = class {
@@ -361,11 +660,17 @@ var init_cache = __esm({
361
660
  "src/core/cache.ts"() {
362
661
  "use strict";
363
662
  MarvinCache = class {
364
- constructor(duration = 5 * 60 * 1e3) {
663
+ constructor(duration = 5 * 60 * 1e3, maxEntries = 1e3) {
365
664
  this.duration = duration;
665
+ this.maxEntries = maxEntries;
366
666
  }
367
667
  duration;
668
+ maxEntries;
368
669
  cache = /* @__PURE__ */ new Map();
670
+ /**
671
+ * Get cached data by key
672
+ * Returns undefined if not found or expired
673
+ */
369
674
  get(key) {
370
675
  const entry = this.cache.get(key);
371
676
  if (!entry) return void 0;
@@ -376,25 +681,149 @@ var init_cache = __esm({
376
681
  }
377
682
  return entry.data;
378
683
  }
379
- set(key, data) {
684
+ /**
685
+ * Set cached data with optional tags for invalidation
686
+ */
687
+ set(key, data, tags) {
688
+ if (this.cache.size >= this.maxEntries) {
689
+ const oldestKey = this.cache.keys().next().value;
690
+ if (oldestKey) {
691
+ this.cache.delete(oldestKey);
692
+ }
693
+ }
380
694
  this.cache.set(key, {
381
695
  data,
382
- timestamp: Date.now()
696
+ timestamp: Date.now(),
697
+ tags
383
698
  });
384
699
  }
700
+ /**
701
+ * Check if key exists and is not expired
702
+ */
385
703
  has(key) {
386
704
  return this.get(key) !== void 0;
387
705
  }
706
+ /**
707
+ * Clear all cached entries
708
+ */
388
709
  clear() {
389
710
  this.cache.clear();
390
711
  }
712
+ /**
713
+ * Delete a specific cache entry
714
+ */
391
715
  delete(key) {
392
716
  this.cache.delete(key);
393
717
  }
718
+ /**
719
+ * Invalidate all cache entries with a specific tag
720
+ * Useful for invalidating related data after mutations
721
+ *
722
+ * @example
723
+ * ```typescript
724
+ * // Cache entries with tags
725
+ * cache.set('entry:1', data, ['entries', 'entry:1']);
726
+ * cache.set('entries:list', data, ['entries']);
727
+ *
728
+ * // Invalidate all entries
729
+ * cache.invalidateByTag('entries');
730
+ * ```
731
+ */
732
+ invalidateByTag(tag) {
733
+ let count = 0;
734
+ for (const [key, entry] of this.cache.entries()) {
735
+ if (entry.tags?.includes(tag)) {
736
+ this.cache.delete(key);
737
+ count++;
738
+ }
739
+ }
740
+ return count;
741
+ }
742
+ /**
743
+ * Get cache statistics
744
+ */
745
+ getStats() {
746
+ let oldest = null;
747
+ let newest = null;
748
+ for (const entry of this.cache.values()) {
749
+ if (oldest === null || entry.timestamp < oldest) {
750
+ oldest = entry.timestamp;
751
+ }
752
+ if (newest === null || entry.timestamp > newest) {
753
+ newest = entry.timestamp;
754
+ }
755
+ }
756
+ return {
757
+ size: this.cache.size,
758
+ maxEntries: this.maxEntries,
759
+ oldestEntry: oldest,
760
+ newestEntry: newest
761
+ };
762
+ }
763
+ /**
764
+ * Remove expired entries (garbage collection)
765
+ * Returns number of entries removed
766
+ */
767
+ prune() {
768
+ let count = 0;
769
+ const now = Date.now();
770
+ for (const [key, entry] of this.cache.entries()) {
771
+ const age = now - entry.timestamp;
772
+ if (age > this.duration) {
773
+ this.cache.delete(key);
774
+ count++;
775
+ }
776
+ }
777
+ return count;
778
+ }
394
779
  };
395
780
  }
396
781
  });
397
782
 
783
+ // src/core/validation.ts
784
+ function validateEmail(email, fieldName = "Email") {
785
+ if (!email || typeof email !== "string") {
786
+ throw new MarvinValidationError(`${fieldName} is required and must be a string`);
787
+ }
788
+ const trimmed = email.trim();
789
+ if (trimmed.length === 0) {
790
+ throw new MarvinValidationError(`${fieldName} cannot be empty`);
791
+ }
792
+ if (trimmed.length < 5) {
793
+ throw new MarvinValidationError(`${fieldName} is too short`);
794
+ }
795
+ if (trimmed.length > 254) {
796
+ throw new MarvinValidationError(`${fieldName} exceeds maximum length of 254 characters`);
797
+ }
798
+ if (!EMAIL_REGEX.test(trimmed)) {
799
+ throw new MarvinValidationError(
800
+ `${fieldName} format is invalid. Expected format: user@example.com`
801
+ );
802
+ }
803
+ const [localPart, domain] = trimmed.split("@");
804
+ if (localPart.length > 64) {
805
+ throw new MarvinValidationError(`${fieldName} local part exceeds 64 characters`);
806
+ }
807
+ if (domain.length > 253) {
808
+ throw new MarvinValidationError(`${fieldName} domain exceeds 253 characters`);
809
+ }
810
+ if (trimmed.includes("..")) {
811
+ throw new MarvinValidationError(`${fieldName} cannot contain consecutive dots`);
812
+ }
813
+ if (localPart.startsWith(".") || localPart.endsWith(".")) {
814
+ throw new MarvinValidationError(`${fieldName} local part cannot start or end with a dot`);
815
+ }
816
+ return trimmed;
817
+ }
818
+ var EMAIL_REGEX;
819
+ var init_validation = __esm({
820
+ "src/core/validation.ts"() {
821
+ "use strict";
822
+ init_errors();
823
+ EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
824
+ }
825
+ });
826
+
398
827
  // src/core/index.ts
399
828
  var init_core = __esm({
400
829
  "src/core/index.ts"() {
@@ -404,6 +833,7 @@ var init_core = __esm({
404
833
  init_errors();
405
834
  init_pagination();
406
835
  init_cache();
836
+ init_validation();
407
837
  }
408
838
  });
409
839
 
@@ -418,7 +848,9 @@ var init_http2 = __esm({
418
848
  super({
419
849
  baseUrl: config.apiUrl,
420
850
  auth: new BearerTokenAuth(config.siteClientToken),
421
- credentials: "same-origin"
851
+ credentials: "same-origin",
852
+ debug: config.debug,
853
+ logger: config.logger
422
854
  });
423
855
  }
424
856
  /**
@@ -555,6 +987,7 @@ var init_entries = __esm({
555
987
  "src/entries/entries.ts"() {
556
988
  "use strict";
557
989
  init_entry();
990
+ init_errors();
558
991
  EntriesModule = class {
559
992
  constructor(http, workspaceSlug) {
560
993
  this.http = http;
@@ -579,11 +1012,19 @@ var init_entries = __esm({
579
1012
  }
580
1013
  /**
581
1014
  * Get a single entry by slug
1015
+ * Returns null if entry is not found (404)
582
1016
  */
583
1017
  async get(slug) {
584
- const endpoint = `/api/publish/${this.workspaceSlug}/entries/${slug}`;
585
- const data = await this.http.fetch(endpoint);
586
- return new Entry(data, this.http, this.workspaceSlug);
1018
+ try {
1019
+ const endpoint = `/api/publish/${this.workspaceSlug}/entries/${slug}`;
1020
+ const data = await this.http.fetch(endpoint);
1021
+ return new Entry(data, this.http, this.workspaceSlug);
1022
+ } catch (error) {
1023
+ if (error instanceof MarvinNotFoundError) {
1024
+ return null;
1025
+ }
1026
+ throw error;
1027
+ }
587
1028
  }
588
1029
  /**
589
1030
  * Convenience: Get all pages
@@ -689,6 +1130,7 @@ var init_collections = __esm({
689
1130
  "src/collections/collections.ts"() {
690
1131
  "use strict";
691
1132
  init_collection();
1133
+ init_errors();
692
1134
  CollectionsModule = class {
693
1135
  constructor(http, workspaceSlug) {
694
1136
  this.http = http;
@@ -706,19 +1148,63 @@ var init_collections = __esm({
706
1148
  }
707
1149
  /**
708
1150
  * Get a single collection by slug
1151
+ * Returns null if collection is not found (404)
709
1152
  */
710
1153
  async get(slug) {
711
- const endpoint = `/api/publish/${this.workspaceSlug}/collections/${slug}`;
712
- const data = await this.http.fetch(endpoint);
713
- return new Collection(data, this.http, this.workspaceSlug);
1154
+ try {
1155
+ const endpoint = `/api/publish/${this.workspaceSlug}/collections/${slug}`;
1156
+ const data = await this.http.fetch(endpoint);
1157
+ return new Collection(data, this.http, this.workspaceSlug);
1158
+ } catch (error) {
1159
+ if (error instanceof MarvinNotFoundError) {
1160
+ return null;
1161
+ }
1162
+ throw error;
1163
+ }
714
1164
  }
715
1165
  /**
716
1166
  * Get entries in a collection
1167
+ * Returns empty array if collection is not found
717
1168
  */
718
1169
  async entries(slug) {
719
1170
  const collection = await this.get(slug);
1171
+ if (!collection) {
1172
+ return [];
1173
+ }
720
1174
  return collection.entries();
721
1175
  }
1176
+ /**
1177
+ * Try multiple collection slugs in fallback order
1178
+ * Returns the first collection that exists and has entries
1179
+ *
1180
+ * @param slugs - Collection slugs to try in order
1181
+ * @param options - Options for fallback behavior
1182
+ * @returns Entries from the first matching collection, or empty array
1183
+ *
1184
+ * @example
1185
+ * ```ts
1186
+ * // Try bench-notes, then journal, then blog
1187
+ * const entries = await collections.fallback(['bench-notes', 'journal', 'blog']);
1188
+ * ```
1189
+ */
1190
+ async fallback(slugs, options = {}) {
1191
+ const { requireEntries = true } = options;
1192
+ for (const slug of slugs) {
1193
+ try {
1194
+ const collection = await this.get(slug);
1195
+ if (!collection) {
1196
+ continue;
1197
+ }
1198
+ const entries = await collection.entries();
1199
+ if (!requireEntries || entries.length > 0) {
1200
+ return entries;
1201
+ }
1202
+ } catch (error) {
1203
+ continue;
1204
+ }
1205
+ }
1206
+ return [];
1207
+ }
722
1208
  };
723
1209
  }
724
1210
  });
@@ -890,6 +1376,26 @@ var init_resources = __esm({
890
1376
  }
891
1377
  });
892
1378
 
1379
+ // src/renderers/renderers.ts
1380
+ var RenderersModule;
1381
+ var init_renderers = __esm({
1382
+ "src/renderers/renderers.ts"() {
1383
+ "use strict";
1384
+ RenderersModule = class {
1385
+ constructor(http, workspaceSlug) {
1386
+ this.http = http;
1387
+ this.workspaceSlug = workspaceSlug;
1388
+ }
1389
+ http;
1390
+ workspaceSlug;
1391
+ async list() {
1392
+ const endpoint = `/api/publish/${this.workspaceSlug}/entry-types`;
1393
+ return this.http.fetch(endpoint);
1394
+ }
1395
+ };
1396
+ }
1397
+ });
1398
+
893
1399
  // src/workspaces/workspace.ts
894
1400
  var Workspace;
895
1401
  var init_workspace = __esm({
@@ -899,6 +1405,7 @@ var init_workspace = __esm({
899
1405
  init_collections();
900
1406
  init_assets();
901
1407
  init_resources();
1408
+ init_renderers();
902
1409
  Workspace = class {
903
1410
  constructor(http, slug) {
904
1411
  this.http = http;
@@ -907,6 +1414,7 @@ var init_workspace = __esm({
907
1414
  this.collections = new CollectionsModule(http, slug);
908
1415
  this.assets = new AssetsModule(http, slug);
909
1416
  this.resources = new ResourcesModule(http, slug);
1417
+ this.renderers = new RenderersModule(http, slug);
910
1418
  }
911
1419
  http;
912
1420
  _site = null;
@@ -915,6 +1423,7 @@ var init_workspace = __esm({
915
1423
  collections;
916
1424
  assets;
917
1425
  resources;
1426
+ renderers;
918
1427
  /**
919
1428
  * Get workspace slug
920
1429
  */
@@ -1042,6 +1551,12 @@ var init_marvin = __esm({
1042
1551
  get resources() {
1043
1552
  return this.workspace.resources;
1044
1553
  }
1554
+ /**
1555
+ * Get renderers module
1556
+ */
1557
+ get renderers() {
1558
+ return this.workspace.renderers;
1559
+ }
1045
1560
  // ======================
1046
1561
  // Convenience Methods
1047
1562
  // ======================
@@ -1081,6 +1596,23 @@ var init_marvin = __esm({
1081
1596
  async resource(slug) {
1082
1597
  return this.workspace.resources.get(slug);
1083
1598
  }
1599
+ /**
1600
+ * Try multiple collection slugs in fallback order
1601
+ * Returns entries from the first collection that exists and has content
1602
+ *
1603
+ * @param slugs - Collection slugs to try in order
1604
+ * @param options - Options for fallback behavior
1605
+ * @returns Entries from the first matching collection, or empty array
1606
+ *
1607
+ * @example
1608
+ * ```ts
1609
+ * // Try bench-notes, then journal, then blog
1610
+ * const entries = await client.collectionFallback(['bench-notes', 'journal', 'blog']);
1611
+ * ```
1612
+ */
1613
+ async collectionFallback(slugs, options) {
1614
+ return this.workspace.collections.fallback(slugs, options);
1615
+ }
1084
1616
  // ======================
1085
1617
  // Backwards-Compatible Publishing API
1086
1618
  // ======================
@@ -1104,6 +1636,9 @@ var init_marvin = __esm({
1104
1636
  */
1105
1637
  async getEntry(slug) {
1106
1638
  const entry = await this.workspace.entries.get(slug);
1639
+ if (!entry) {
1640
+ return null;
1641
+ }
1107
1642
  return entry.toJSON();
1108
1643
  }
1109
1644
  /**
@@ -1119,6 +1654,9 @@ var init_marvin = __esm({
1119
1654
  */
1120
1655
  async getCollection(slug) {
1121
1656
  const collection = await this.workspace.collections.get(slug);
1657
+ if (!collection) {
1658
+ return null;
1659
+ }
1122
1660
  return collection.toJSON();
1123
1661
  }
1124
1662
  /**
@@ -1175,6 +1713,9 @@ __export(index_exports, {
1175
1713
  MarvinClient: () => MarvinClient,
1176
1714
  MarvinConfigError: () => MarvinConfigError,
1177
1715
  MarvinError: () => MarvinError,
1716
+ MarvinNetworkError: () => MarvinNetworkError,
1717
+ MarvinNotFoundError: () => MarvinNotFoundError,
1718
+ MarvinServerError: () => MarvinServerError,
1178
1719
  MarvinValidationError: () => MarvinValidationError,
1179
1720
  NoAuth: () => NoAuth,
1180
1721
  Resource: () => Resource,
@@ -1209,12 +1750,14 @@ var AuthClient = class extends HttpClient {
1209
1750
  * Returns the created user object
1210
1751
  */
1211
1752
  async register(data) {
1753
+ validateEmail(data.email, "Email");
1212
1754
  return this.post("/api/users/register", data);
1213
1755
  }
1214
1756
  /**
1215
1757
  * Request password reset (sends email with token)
1216
1758
  */
1217
1759
  async forgotPassword(data) {
1760
+ validateEmail(data.email, "Email");
1218
1761
  return this.post("/api/users/forgot-password", data);
1219
1762
  }
1220
1763
  /**
@@ -1271,6 +1814,9 @@ function createMarvinClient(config) {
1271
1814
  MarvinClient,
1272
1815
  MarvinConfigError,
1273
1816
  MarvinError,
1817
+ MarvinNetworkError,
1818
+ MarvinNotFoundError,
1819
+ MarvinServerError,
1274
1820
  MarvinValidationError,
1275
1821
  NoAuth,
1276
1822
  Resource,