@inneropen/marvin-sdk 2.0.0-next.2 → 2.0.0-next.20

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,14 +27,99 @@ __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
+ }
38
123
  }
39
124
  }
40
125
  function createConfigFromEnv(overrides) {
@@ -43,8 +128,10 @@ function createConfigFromEnv(overrides) {
43
128
  siteClientToken: overrides?.siteClientToken || process.env.MARVIN_SITE_CLIENT_TOKEN || "",
44
129
  workspaceSlug: overrides?.workspaceSlug || process.env.MARVIN_WORKSPACE_SLUG || "",
45
130
  autoInitialize: overrides?.autoInitialize ?? false,
46
- cacheDuration: overrides?.cacheDuration ?? 5 * 60 * 1e3
131
+ cacheDuration: overrides?.cacheDuration ?? 5 * 60 * 1e3,
47
132
  // 5 minutes
133
+ debug: overrides?.debug ?? process.env.MARVIN_DEBUG === "true",
134
+ logger: overrides?.logger ?? console
48
135
  };
49
136
  }
50
137
  var init_config = __esm({
@@ -54,7 +141,7 @@ var init_config = __esm({
54
141
  });
55
142
 
56
143
  // src/core/errors/MarvinError.ts
57
- var MarvinError, MarvinApiError, MarvinAuthError, MarvinConfigError, MarvinValidationError;
144
+ var MarvinError, MarvinApiError, MarvinAuthError, MarvinConfigError, MarvinValidationError, MarvinNotFoundError, MarvinNetworkError, MarvinServerError;
58
145
  var init_MarvinError = __esm({
59
146
  "src/core/errors/MarvinError.ts"() {
60
147
  "use strict";
@@ -110,6 +197,33 @@ ${body}` : ""}`;
110
197
  }
111
198
  field;
112
199
  };
200
+ MarvinNotFoundError = class _MarvinNotFoundError extends MarvinError {
201
+ constructor(message, endpoint) {
202
+ super(message, "NOT_FOUND", 404);
203
+ this.endpoint = endpoint;
204
+ this.name = "MarvinNotFoundError";
205
+ Object.setPrototypeOf(this, _MarvinNotFoundError.prototype);
206
+ }
207
+ endpoint;
208
+ };
209
+ MarvinNetworkError = class _MarvinNetworkError extends MarvinError {
210
+ constructor(message, originalError) {
211
+ super(message, "NETWORK_ERROR");
212
+ this.originalError = originalError;
213
+ this.name = "MarvinNetworkError";
214
+ Object.setPrototypeOf(this, _MarvinNetworkError.prototype);
215
+ }
216
+ originalError;
217
+ };
218
+ MarvinServerError = class _MarvinServerError extends MarvinError {
219
+ constructor(message, statusCode, endpoint) {
220
+ super(message, "SERVER_ERROR", statusCode);
221
+ this.endpoint = endpoint;
222
+ this.name = "MarvinServerError";
223
+ Object.setPrototypeOf(this, _MarvinServerError.prototype);
224
+ }
225
+ endpoint;
226
+ };
113
227
  }
114
228
  });
115
229
 
@@ -133,12 +247,47 @@ var init_HttpClient = __esm({
133
247
  defaultHeaders;
134
248
  credentials;
135
249
  timeout;
250
+ debug;
251
+ retryConfig;
252
+ logger;
136
253
  constructor(config) {
137
254
  this.baseUrl = config.baseUrl;
138
255
  this.auth = config.auth;
139
256
  this.defaultHeaders = config.defaultHeaders || {};
140
257
  this.credentials = config.credentials || "same-origin";
141
- this.timeout = config.timeout || 3e4;
258
+ const MAX_TIMEOUT = 12e4;
259
+ this.timeout = Math.min(config.timeout || 3e4, MAX_TIMEOUT);
260
+ this.debug = config.debug ?? false;
261
+ this.logger = config.logger ?? console;
262
+ this.retryConfig = {
263
+ maxRetries: config.retry?.maxRetries ?? 3,
264
+ initialDelay: config.retry?.initialDelay ?? 1e3,
265
+ maxDelay: config.retry?.maxDelay ?? 1e4,
266
+ retryableStatuses: config.retry?.retryableStatuses ?? [408, 429, 500, 502, 503, 504]
267
+ };
268
+ }
269
+ /**
270
+ * Validate path parameter to prevent path traversal attacks
271
+ */
272
+ validatePathParam(param, paramName = "parameter") {
273
+ if (typeof param === "number") {
274
+ return String(param);
275
+ }
276
+ if (!param || typeof param !== "string") {
277
+ throw new MarvinValidationError(`Invalid ${paramName}: must be a non-empty string or number`);
278
+ }
279
+ if (param.includes("..") || param.includes("/") || param.includes("\\")) {
280
+ throw new MarvinValidationError(
281
+ `Invalid ${paramName}: cannot contain path traversal characters (.. / \\)`
282
+ );
283
+ }
284
+ if (param.includes("\0")) {
285
+ throw new MarvinValidationError(`Invalid ${paramName}: cannot contain null bytes`);
286
+ }
287
+ if (param.length > 255) {
288
+ throw new MarvinValidationError(`Invalid ${paramName}: exceeds maximum length of 255 characters`);
289
+ }
290
+ return param;
142
291
  }
143
292
  /**
144
293
  * Build full URL from endpoint
@@ -172,14 +321,78 @@ var init_HttpClient = __esm({
172
321
  return filtered.length > 0 ? `?${filtered.join("&")}` : "";
173
322
  }
174
323
  /**
175
- * Perform HTTP request
324
+ * Sanitize data for logging by redacting sensitive fields
176
325
  */
177
- async request(method, endpoint, options) {
326
+ sanitizeForLogging(data) {
327
+ if (!data || typeof data !== "object") {
328
+ return data;
329
+ }
330
+ const sensitiveFields = [
331
+ "token",
332
+ "access_token",
333
+ "refresh_token",
334
+ "password",
335
+ "secret",
336
+ "apiKey",
337
+ "api_key",
338
+ "bearer",
339
+ "authorization",
340
+ "csrf_token",
341
+ "session",
342
+ "cookie"
343
+ ];
344
+ const sanitize = (obj) => {
345
+ const sanitized = {};
346
+ for (const [key, value] of Object.entries(obj)) {
347
+ const keyLower = key.toLowerCase();
348
+ const isSensitive = sensitiveFields.some((field) => keyLower.includes(field));
349
+ if (isSensitive) {
350
+ sanitized[key] = "[REDACTED]";
351
+ } else if (value && typeof value === "object" && !Array.isArray(value)) {
352
+ sanitized[key] = sanitize(value);
353
+ } else if (Array.isArray(value)) {
354
+ sanitized[key] = value.map(
355
+ (item) => item && typeof item === "object" ? sanitize(item) : item
356
+ );
357
+ } else {
358
+ sanitized[key] = value;
359
+ }
360
+ }
361
+ return sanitized;
362
+ };
363
+ if (Array.isArray(data)) {
364
+ return data.map(
365
+ (item) => item && typeof item === "object" ? sanitize(item) : item
366
+ );
367
+ }
368
+ return sanitize(data);
369
+ }
370
+ /**
371
+ * Calculate delay for retry with exponential backoff
372
+ */
373
+ calculateRetryDelay(attempt) {
374
+ const delay = this.retryConfig.initialDelay * Math.pow(2, attempt);
375
+ return Math.min(delay, this.retryConfig.maxDelay);
376
+ }
377
+ /**
378
+ * Sleep for specified milliseconds
379
+ */
380
+ sleep(ms) {
381
+ return new Promise((resolve) => setTimeout(resolve, ms));
382
+ }
383
+ /**
384
+ * Perform HTTP request with automatic retry on transient failures
385
+ */
386
+ async request(method, endpoint, options, retryAttempt = 0) {
178
387
  const url = this.buildUrl(endpoint) + (options?.params ? this.buildQueryString(options.params) : "");
179
388
  const isFormData = options?.body instanceof FormData;
180
389
  const headers = this.buildHeaders(options?.headers, isFormData);
181
390
  const controller = new AbortController();
182
391
  const timeoutId = setTimeout(() => controller.abort(), this.timeout);
392
+ const startTime = Date.now();
393
+ if (this.debug) {
394
+ this.logger.log(`[Marvin] ${method} ${url}`);
395
+ }
183
396
  try {
184
397
  const response = await fetch(url, {
185
398
  method,
@@ -189,11 +402,54 @@ var init_HttpClient = __esm({
189
402
  signal: controller.signal
190
403
  });
191
404
  clearTimeout(timeoutId);
405
+ const duration = Date.now() - startTime;
406
+ if (this.debug) {
407
+ this.logger.log(
408
+ `[Marvin] ${response.status} ${response.statusText} (${duration}ms)`
409
+ );
410
+ }
192
411
  if (response.status === 401 && this.auth.handleUnauthorized) {
193
412
  await this.auth.handleUnauthorized();
194
413
  }
195
414
  if (!response.ok) {
196
415
  const errorText = await response.text().catch(() => "");
416
+ if (this.debug) {
417
+ this.logger.error(
418
+ `[Marvin] Error response: ${response.status} ${response.statusText}`,
419
+ errorText ? `Body: ${errorText}` : ""
420
+ );
421
+ }
422
+ const isRetryable = this.retryConfig.retryableStatuses.includes(response.status);
423
+ const canRetry = retryAttempt < this.retryConfig.maxRetries;
424
+ if (isRetryable && canRetry) {
425
+ const delay = this.calculateRetryDelay(retryAttempt);
426
+ if (this.debug) {
427
+ this.logger.warn(
428
+ `[Marvin] Retry ${retryAttempt + 1}/${this.retryConfig.maxRetries} after ${delay}ms (status: ${response.status})`
429
+ );
430
+ }
431
+ await this.sleep(delay);
432
+ return this.request(method, endpoint, options, retryAttempt + 1);
433
+ }
434
+ if (response.status === 404) {
435
+ throw new MarvinNotFoundError(
436
+ `Resource not found: ${endpoint}`,
437
+ endpoint
438
+ );
439
+ }
440
+ if (response.status === 401 || response.status === 403) {
441
+ throw new MarvinAuthError(
442
+ `Authentication failed: ${response.statusText}`,
443
+ response.status
444
+ );
445
+ }
446
+ if (response.status >= 500) {
447
+ throw new MarvinServerError(
448
+ `Server error: ${response.status} ${response.statusText}`,
449
+ response.status,
450
+ endpoint
451
+ );
452
+ }
197
453
  throw MarvinApiError.fromResponse(
198
454
  response.status,
199
455
  response.statusText,
@@ -205,21 +461,39 @@ var init_HttpClient = __esm({
205
461
  if (!contentType || !contentType.includes("application/json")) {
206
462
  return void 0;
207
463
  }
208
- return await response.json();
464
+ const data = await response.json();
465
+ if (this.debug) {
466
+ const sanitized = this.sanitizeForLogging(data);
467
+ this.logger.log("[Marvin] Response:", JSON.stringify(sanitized, null, 2));
468
+ }
469
+ return data;
209
470
  } catch (error) {
210
471
  clearTimeout(timeoutId);
211
- if (error instanceof MarvinApiError) {
472
+ if (error instanceof MarvinApiError || error instanceof MarvinNotFoundError || error instanceof MarvinAuthError || error instanceof MarvinServerError) {
212
473
  throw error;
213
474
  }
214
475
  if (error instanceof Error) {
476
+ const canRetry = retryAttempt < this.retryConfig.maxRetries;
477
+ if (canRetry && error.name !== "AbortError") {
478
+ const delay = this.calculateRetryDelay(retryAttempt);
479
+ if (this.debug) {
480
+ this.logger.warn(
481
+ `[Marvin] Retry ${retryAttempt + 1}/${this.retryConfig.maxRetries} after ${delay}ms (network error)`
482
+ );
483
+ }
484
+ await this.sleep(delay);
485
+ return this.request(method, endpoint, options, retryAttempt + 1);
486
+ }
215
487
  if (error.name === "AbortError") {
216
- throw new MarvinApiError(
488
+ throw new MarvinNetworkError(
217
489
  `Request timeout after ${this.timeout}ms`,
218
- 408,
219
- endpoint
490
+ error
220
491
  );
221
492
  }
222
- throw new MarvinApiError(error.message, 0, endpoint);
493
+ throw new MarvinNetworkError(
494
+ `Network error: ${error.message}`,
495
+ error
496
+ );
223
497
  }
224
498
  throw error;
225
499
  }
@@ -290,10 +564,27 @@ var init_AuthStrategy = __esm({
290
564
  }
291
565
  sessionToken;
292
566
  onUnauthorized;
567
+ csrfToken;
568
+ /**
569
+ * Set CSRF token for cross-site request forgery protection
570
+ * The token should be obtained from the server after login
571
+ */
572
+ setCsrfToken(token) {
573
+ this.csrfToken = token;
574
+ }
575
+ /**
576
+ * Get current CSRF token
577
+ */
578
+ getCsrfToken() {
579
+ return this.csrfToken;
580
+ }
293
581
  injectAuth(headers) {
294
582
  if (this.sessionToken) {
295
583
  headers.set("Authorization", `Bearer ${this.sessionToken}`);
296
584
  }
585
+ if (this.csrfToken) {
586
+ headers.set("X-CSRF-Token", this.csrfToken);
587
+ }
297
588
  }
298
589
  async handleUnauthorized() {
299
590
  if (this.onUnauthorized) {
@@ -301,7 +592,9 @@ var init_AuthStrategy = __esm({
301
592
  }
302
593
  }
303
594
  getDescription() {
304
- return this.sessionToken ? "Session authentication (explicit token)" : "Session authentication (cookies)";
595
+ const auth = this.sessionToken ? "Session authentication (explicit token)" : "Session authentication (cookies)";
596
+ const csrf = this.csrfToken ? " with CSRF protection" : "";
597
+ return auth + csrf;
305
598
  }
306
599
  };
307
600
  NoAuth = class {
@@ -361,11 +654,17 @@ var init_cache = __esm({
361
654
  "src/core/cache.ts"() {
362
655
  "use strict";
363
656
  MarvinCache = class {
364
- constructor(duration = 5 * 60 * 1e3) {
657
+ constructor(duration = 5 * 60 * 1e3, maxEntries = 1e3) {
365
658
  this.duration = duration;
659
+ this.maxEntries = maxEntries;
366
660
  }
367
661
  duration;
662
+ maxEntries;
368
663
  cache = /* @__PURE__ */ new Map();
664
+ /**
665
+ * Get cached data by key
666
+ * Returns undefined if not found or expired
667
+ */
369
668
  get(key) {
370
669
  const entry = this.cache.get(key);
371
670
  if (!entry) return void 0;
@@ -376,25 +675,149 @@ var init_cache = __esm({
376
675
  }
377
676
  return entry.data;
378
677
  }
379
- set(key, data) {
678
+ /**
679
+ * Set cached data with optional tags for invalidation
680
+ */
681
+ set(key, data, tags) {
682
+ if (this.cache.size >= this.maxEntries) {
683
+ const oldestKey = this.cache.keys().next().value;
684
+ if (oldestKey) {
685
+ this.cache.delete(oldestKey);
686
+ }
687
+ }
380
688
  this.cache.set(key, {
381
689
  data,
382
- timestamp: Date.now()
690
+ timestamp: Date.now(),
691
+ tags
383
692
  });
384
693
  }
694
+ /**
695
+ * Check if key exists and is not expired
696
+ */
385
697
  has(key) {
386
698
  return this.get(key) !== void 0;
387
699
  }
700
+ /**
701
+ * Clear all cached entries
702
+ */
388
703
  clear() {
389
704
  this.cache.clear();
390
705
  }
706
+ /**
707
+ * Delete a specific cache entry
708
+ */
391
709
  delete(key) {
392
710
  this.cache.delete(key);
393
711
  }
712
+ /**
713
+ * Invalidate all cache entries with a specific tag
714
+ * Useful for invalidating related data after mutations
715
+ *
716
+ * @example
717
+ * ```typescript
718
+ * // Cache entries with tags
719
+ * cache.set('entry:1', data, ['entries', 'entry:1']);
720
+ * cache.set('entries:list', data, ['entries']);
721
+ *
722
+ * // Invalidate all entries
723
+ * cache.invalidateByTag('entries');
724
+ * ```
725
+ */
726
+ invalidateByTag(tag) {
727
+ let count = 0;
728
+ for (const [key, entry] of this.cache.entries()) {
729
+ if (entry.tags?.includes(tag)) {
730
+ this.cache.delete(key);
731
+ count++;
732
+ }
733
+ }
734
+ return count;
735
+ }
736
+ /**
737
+ * Get cache statistics
738
+ */
739
+ getStats() {
740
+ let oldest = null;
741
+ let newest = null;
742
+ for (const entry of this.cache.values()) {
743
+ if (oldest === null || entry.timestamp < oldest) {
744
+ oldest = entry.timestamp;
745
+ }
746
+ if (newest === null || entry.timestamp > newest) {
747
+ newest = entry.timestamp;
748
+ }
749
+ }
750
+ return {
751
+ size: this.cache.size,
752
+ maxEntries: this.maxEntries,
753
+ oldestEntry: oldest,
754
+ newestEntry: newest
755
+ };
756
+ }
757
+ /**
758
+ * Remove expired entries (garbage collection)
759
+ * Returns number of entries removed
760
+ */
761
+ prune() {
762
+ let count = 0;
763
+ const now = Date.now();
764
+ for (const [key, entry] of this.cache.entries()) {
765
+ const age = now - entry.timestamp;
766
+ if (age > this.duration) {
767
+ this.cache.delete(key);
768
+ count++;
769
+ }
770
+ }
771
+ return count;
772
+ }
394
773
  };
395
774
  }
396
775
  });
397
776
 
777
+ // src/core/validation.ts
778
+ function validateEmail(email, fieldName = "Email") {
779
+ if (!email || typeof email !== "string") {
780
+ throw new MarvinValidationError(`${fieldName} is required and must be a string`);
781
+ }
782
+ const trimmed = email.trim();
783
+ if (trimmed.length === 0) {
784
+ throw new MarvinValidationError(`${fieldName} cannot be empty`);
785
+ }
786
+ if (trimmed.length < 5) {
787
+ throw new MarvinValidationError(`${fieldName} is too short`);
788
+ }
789
+ if (trimmed.length > 254) {
790
+ throw new MarvinValidationError(`${fieldName} exceeds maximum length of 254 characters`);
791
+ }
792
+ if (!EMAIL_REGEX.test(trimmed)) {
793
+ throw new MarvinValidationError(
794
+ `${fieldName} format is invalid. Expected format: user@example.com`
795
+ );
796
+ }
797
+ const [localPart, domain] = trimmed.split("@");
798
+ if (localPart.length > 64) {
799
+ throw new MarvinValidationError(`${fieldName} local part exceeds 64 characters`);
800
+ }
801
+ if (domain.length > 253) {
802
+ throw new MarvinValidationError(`${fieldName} domain exceeds 253 characters`);
803
+ }
804
+ if (trimmed.includes("..")) {
805
+ throw new MarvinValidationError(`${fieldName} cannot contain consecutive dots`);
806
+ }
807
+ if (localPart.startsWith(".") || localPart.endsWith(".")) {
808
+ throw new MarvinValidationError(`${fieldName} local part cannot start or end with a dot`);
809
+ }
810
+ return trimmed;
811
+ }
812
+ var EMAIL_REGEX;
813
+ var init_validation = __esm({
814
+ "src/core/validation.ts"() {
815
+ "use strict";
816
+ init_errors();
817
+ 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])?)*$/;
818
+ }
819
+ });
820
+
398
821
  // src/core/index.ts
399
822
  var init_core = __esm({
400
823
  "src/core/index.ts"() {
@@ -404,6 +827,7 @@ var init_core = __esm({
404
827
  init_errors();
405
828
  init_pagination();
406
829
  init_cache();
830
+ init_validation();
407
831
  }
408
832
  });
409
833
 
@@ -418,7 +842,9 @@ var init_http2 = __esm({
418
842
  super({
419
843
  baseUrl: config.apiUrl,
420
844
  auth: new BearerTokenAuth(config.siteClientToken),
421
- credentials: "same-origin"
845
+ credentials: "same-origin",
846
+ debug: config.debug,
847
+ logger: config.logger
422
848
  });
423
849
  }
424
850
  /**
@@ -555,6 +981,7 @@ var init_entries = __esm({
555
981
  "src/entries/entries.ts"() {
556
982
  "use strict";
557
983
  init_entry();
984
+ init_errors();
558
985
  EntriesModule = class {
559
986
  constructor(http, workspaceSlug) {
560
987
  this.http = http;
@@ -579,11 +1006,19 @@ var init_entries = __esm({
579
1006
  }
580
1007
  /**
581
1008
  * Get a single entry by slug
1009
+ * Returns null if entry is not found (404)
582
1010
  */
583
1011
  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);
1012
+ try {
1013
+ const endpoint = `/api/publish/${this.workspaceSlug}/entries/${slug}`;
1014
+ const data = await this.http.fetch(endpoint);
1015
+ return new Entry(data, this.http, this.workspaceSlug);
1016
+ } catch (error) {
1017
+ if (error instanceof MarvinNotFoundError) {
1018
+ return null;
1019
+ }
1020
+ throw error;
1021
+ }
587
1022
  }
588
1023
  /**
589
1024
  * Convenience: Get all pages
@@ -689,6 +1124,7 @@ var init_collections = __esm({
689
1124
  "src/collections/collections.ts"() {
690
1125
  "use strict";
691
1126
  init_collection();
1127
+ init_errors();
692
1128
  CollectionsModule = class {
693
1129
  constructor(http, workspaceSlug) {
694
1130
  this.http = http;
@@ -706,19 +1142,63 @@ var init_collections = __esm({
706
1142
  }
707
1143
  /**
708
1144
  * Get a single collection by slug
1145
+ * Returns null if collection is not found (404)
709
1146
  */
710
1147
  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);
1148
+ try {
1149
+ const endpoint = `/api/publish/${this.workspaceSlug}/collections/${slug}`;
1150
+ const data = await this.http.fetch(endpoint);
1151
+ return new Collection(data, this.http, this.workspaceSlug);
1152
+ } catch (error) {
1153
+ if (error instanceof MarvinNotFoundError) {
1154
+ return null;
1155
+ }
1156
+ throw error;
1157
+ }
714
1158
  }
715
1159
  /**
716
1160
  * Get entries in a collection
1161
+ * Returns empty array if collection is not found
717
1162
  */
718
1163
  async entries(slug) {
719
1164
  const collection = await this.get(slug);
1165
+ if (!collection) {
1166
+ return [];
1167
+ }
720
1168
  return collection.entries();
721
1169
  }
1170
+ /**
1171
+ * Try multiple collection slugs in fallback order
1172
+ * Returns the first collection that exists and has entries
1173
+ *
1174
+ * @param slugs - Collection slugs to try in order
1175
+ * @param options - Options for fallback behavior
1176
+ * @returns Entries from the first matching collection, or empty array
1177
+ *
1178
+ * @example
1179
+ * ```ts
1180
+ * // Try bench-notes, then journal, then blog
1181
+ * const entries = await collections.fallback(['bench-notes', 'journal', 'blog']);
1182
+ * ```
1183
+ */
1184
+ async fallback(slugs, options = {}) {
1185
+ const { requireEntries = true } = options;
1186
+ for (const slug of slugs) {
1187
+ try {
1188
+ const collection = await this.get(slug);
1189
+ if (!collection) {
1190
+ continue;
1191
+ }
1192
+ const entries = await collection.entries();
1193
+ if (!requireEntries || entries.length > 0) {
1194
+ return entries;
1195
+ }
1196
+ } catch (error) {
1197
+ continue;
1198
+ }
1199
+ }
1200
+ return [];
1201
+ }
722
1202
  };
723
1203
  }
724
1204
  });
@@ -890,6 +1370,26 @@ var init_resources = __esm({
890
1370
  }
891
1371
  });
892
1372
 
1373
+ // src/renderers/renderers.ts
1374
+ var RenderersModule;
1375
+ var init_renderers = __esm({
1376
+ "src/renderers/renderers.ts"() {
1377
+ "use strict";
1378
+ RenderersModule = class {
1379
+ constructor(http, workspaceSlug) {
1380
+ this.http = http;
1381
+ this.workspaceSlug = workspaceSlug;
1382
+ }
1383
+ http;
1384
+ workspaceSlug;
1385
+ async list() {
1386
+ const endpoint = `/api/publish/${this.workspaceSlug}/entry-types`;
1387
+ return this.http.fetch(endpoint);
1388
+ }
1389
+ };
1390
+ }
1391
+ });
1392
+
893
1393
  // src/workspaces/workspace.ts
894
1394
  var Workspace;
895
1395
  var init_workspace = __esm({
@@ -899,6 +1399,7 @@ var init_workspace = __esm({
899
1399
  init_collections();
900
1400
  init_assets();
901
1401
  init_resources();
1402
+ init_renderers();
902
1403
  Workspace = class {
903
1404
  constructor(http, slug) {
904
1405
  this.http = http;
@@ -907,6 +1408,7 @@ var init_workspace = __esm({
907
1408
  this.collections = new CollectionsModule(http, slug);
908
1409
  this.assets = new AssetsModule(http, slug);
909
1410
  this.resources = new ResourcesModule(http, slug);
1411
+ this.renderers = new RenderersModule(http, slug);
910
1412
  }
911
1413
  http;
912
1414
  _site = null;
@@ -915,6 +1417,7 @@ var init_workspace = __esm({
915
1417
  collections;
916
1418
  assets;
917
1419
  resources;
1420
+ renderers;
918
1421
  /**
919
1422
  * Get workspace slug
920
1423
  */
@@ -1042,6 +1545,12 @@ var init_marvin = __esm({
1042
1545
  get resources() {
1043
1546
  return this.workspace.resources;
1044
1547
  }
1548
+ /**
1549
+ * Get renderers module
1550
+ */
1551
+ get renderers() {
1552
+ return this.workspace.renderers;
1553
+ }
1045
1554
  // ======================
1046
1555
  // Convenience Methods
1047
1556
  // ======================
@@ -1081,6 +1590,23 @@ var init_marvin = __esm({
1081
1590
  async resource(slug) {
1082
1591
  return this.workspace.resources.get(slug);
1083
1592
  }
1593
+ /**
1594
+ * Try multiple collection slugs in fallback order
1595
+ * Returns entries from the first collection that exists and has content
1596
+ *
1597
+ * @param slugs - Collection slugs to try in order
1598
+ * @param options - Options for fallback behavior
1599
+ * @returns Entries from the first matching collection, or empty array
1600
+ *
1601
+ * @example
1602
+ * ```ts
1603
+ * // Try bench-notes, then journal, then blog
1604
+ * const entries = await client.collectionFallback(['bench-notes', 'journal', 'blog']);
1605
+ * ```
1606
+ */
1607
+ async collectionFallback(slugs, options) {
1608
+ return this.workspace.collections.fallback(slugs, options);
1609
+ }
1084
1610
  // ======================
1085
1611
  // Backwards-Compatible Publishing API
1086
1612
  // ======================
@@ -1104,6 +1630,9 @@ var init_marvin = __esm({
1104
1630
  */
1105
1631
  async getEntry(slug) {
1106
1632
  const entry = await this.workspace.entries.get(slug);
1633
+ if (!entry) {
1634
+ return null;
1635
+ }
1107
1636
  return entry.toJSON();
1108
1637
  }
1109
1638
  /**
@@ -1119,6 +1648,9 @@ var init_marvin = __esm({
1119
1648
  */
1120
1649
  async getCollection(slug) {
1121
1650
  const collection = await this.workspace.collections.get(slug);
1651
+ if (!collection) {
1652
+ return null;
1653
+ }
1122
1654
  return collection.toJSON();
1123
1655
  }
1124
1656
  /**
@@ -1175,6 +1707,9 @@ __export(index_exports, {
1175
1707
  MarvinClient: () => MarvinClient,
1176
1708
  MarvinConfigError: () => MarvinConfigError,
1177
1709
  MarvinError: () => MarvinError,
1710
+ MarvinNetworkError: () => MarvinNetworkError,
1711
+ MarvinNotFoundError: () => MarvinNotFoundError,
1712
+ MarvinServerError: () => MarvinServerError,
1178
1713
  MarvinValidationError: () => MarvinValidationError,
1179
1714
  NoAuth: () => NoAuth,
1180
1715
  Resource: () => Resource,
@@ -1209,12 +1744,14 @@ var AuthClient = class extends HttpClient {
1209
1744
  * Returns the created user object
1210
1745
  */
1211
1746
  async register(data) {
1747
+ validateEmail(data.email, "Email");
1212
1748
  return this.post("/api/users/register", data);
1213
1749
  }
1214
1750
  /**
1215
1751
  * Request password reset (sends email with token)
1216
1752
  */
1217
1753
  async forgotPassword(data) {
1754
+ validateEmail(data.email, "Email");
1218
1755
  return this.post("/api/users/forgot-password", data);
1219
1756
  }
1220
1757
  /**
@@ -1271,6 +1808,9 @@ function createMarvinClient(config) {
1271
1808
  MarvinClient,
1272
1809
  MarvinConfigError,
1273
1810
  MarvinError,
1811
+ MarvinNetworkError,
1812
+ MarvinNotFoundError,
1813
+ MarvinServerError,
1274
1814
  MarvinValidationError,
1275
1815
  NoAuth,
1276
1816
  Resource,