@inneropen/marvin-sdk 2.0.1 → 3.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/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
+ );
38
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];
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
  /**
@@ -437,113 +869,54 @@ var init_entry = __esm({
437
869
  "src/entries/entry.ts"() {
438
870
  "use strict";
439
871
  Entry = class {
440
- constructor(data, http, workspaceSlug) {
441
- this.data = data;
442
- this.http = http;
443
- this.workspaceSlug = workspaceSlug;
444
- }
445
- data;
446
- http;
447
- workspaceSlug;
448
- // Expose all entry data as properties
449
- get id() {
450
- return this.data.id;
872
+ constructor(raw) {
873
+ this.raw = raw;
451
874
  }
875
+ raw;
452
876
  get title() {
453
- return this.data.title;
877
+ return this.raw.title;
454
878
  }
455
879
  get slug() {
456
- return this.data.slug;
880
+ return this.raw.slug;
457
881
  }
458
882
  get summary() {
459
- return this.data.summary;
883
+ return this.raw.summary;
460
884
  }
461
- get description() {
462
- return this.data.description;
463
- }
464
- /**
465
- * @deprecated Use field('body') or dataJson instead. Will be removed in v3.0.0
466
- */
467
- get contentMarkdown() {
468
- return this.data.contentMarkdown;
885
+ get entryType() {
886
+ return this.raw.entryType;
469
887
  }
470
- /**
471
- * Schema-driven content data (replaces contentMarkdown in v2.0.0).
472
- * Access specific fields using the field() helper method.
473
- * @since 2.0.0
474
- */
475
- get dataJson() {
476
- return this.data.dataJson;
888
+ get entryTypeInfo() {
889
+ return this.raw.entryTypeInfo;
477
890
  }
478
- get metadata() {
479
- return this.data.metadata;
891
+ get data() {
892
+ return this.raw.data;
480
893
  }
481
- get status() {
482
- return this.data.status;
894
+ get metadataJson() {
895
+ return this.raw.metadataJson;
483
896
  }
484
897
  get publishedAt() {
485
- return this.data.publishedAt;
898
+ return this.raw.publishedAt;
486
899
  }
487
- get createdAt() {
488
- return this.data.createdAt;
900
+ get order() {
901
+ return this.raw.order;
489
902
  }
490
- get updatedAt() {
491
- return this.data.updatedAt;
492
- }
493
- get entryTypeId() {
494
- return this.data.entryTypeId;
495
- }
496
- get entryType() {
497
- return this.data.entryType;
498
- }
499
- /**
500
- * Get entry assets
501
- */
502
903
  get assets() {
503
- return this.data.assets || [];
904
+ return this.raw.assets;
504
905
  }
505
- /**
506
- * Get entry collections
507
- */
508
906
  get collections() {
509
- return this.data.collections || [];
907
+ return this.raw.collections;
908
+ }
909
+ get resources() {
910
+ return this.raw.resources;
510
911
  }
511
- /**
512
- * Get a specific field value from the entry's schema-driven content (dataJson).
513
- *
514
- * @param key - Field key as defined in the entry type's schema
515
- * @returns The field value, or undefined if not present
516
- * @since 2.0.0
517
- *
518
- * @example
519
- * ```typescript
520
- * const entry = await client.entries.get('my-entry');
521
- * const body = entry.field('body'); // Get markdown body
522
- * const difficulty = entry.field('difficulty'); // Get difficulty level
523
- * const heroImage = entry.field('heroImage'); // Get hero image UUID
524
- * ```
525
- */
526
912
  field(key) {
527
- return this.data.dataJson?.[key];
913
+ return this.raw.data[key];
528
914
  }
529
- /**
530
- * Get all fields from the entry's schema-driven content.
531
- * @since 2.0.0
532
- */
533
915
  get fields() {
534
- return this.data.dataJson || {};
535
- }
536
- /**
537
- * Get related entries (future)
538
- */
539
- async relatedEntries() {
540
- throw new Error("Related entries not yet implemented");
916
+ return this.raw.data;
541
917
  }
542
- /**
543
- * Get raw entry data
544
- */
545
918
  toJSON() {
546
- return this.data;
919
+ return this.raw;
547
920
  }
548
921
  };
549
922
  }
@@ -555,6 +928,7 @@ var init_entries = __esm({
555
928
  "src/entries/entries.ts"() {
556
929
  "use strict";
557
930
  init_entry();
931
+ init_errors();
558
932
  EntriesModule = class {
559
933
  constructor(http, workspaceSlug) {
560
934
  this.http = http;
@@ -571,7 +945,6 @@ var init_entries = __esm({
571
945
  collection: options.collection,
572
946
  limit: options.limit,
573
947
  offset: options.offset
574
- // Don't send status - backend always returns published
575
948
  });
576
949
  const endpoint = `/api/publish/${this.workspaceSlug}/entries${queryString}`;
577
950
  const response = await this.http.fetch(endpoint);
@@ -579,11 +952,19 @@ var init_entries = __esm({
579
952
  }
580
953
  /**
581
954
  * Get a single entry by slug
955
+ * Returns null if entry is not found (404)
582
956
  */
583
957
  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);
958
+ try {
959
+ const endpoint = `/api/publish/${this.workspaceSlug}/entries/${slug}`;
960
+ const data = await this.http.fetch(endpoint);
961
+ return new Entry(data);
962
+ } catch (error) {
963
+ if (error instanceof MarvinNotFoundError) {
964
+ return null;
965
+ }
966
+ throw error;
967
+ }
587
968
  }
588
969
  /**
589
970
  * Convenience: Get all pages
@@ -613,18 +994,10 @@ var init_collection = __esm({
613
994
  "src/collections/collection.ts"() {
614
995
  "use strict";
615
996
  Collection = class {
616
- constructor(data, http, workspaceSlug) {
997
+ constructor(data) {
617
998
  this.data = data;
618
- this.http = http;
619
- this.workspaceSlug = workspaceSlug;
620
999
  }
621
1000
  data;
622
- http;
623
- workspaceSlug;
624
- // Expose all collection data as properties
625
- get id() {
626
- return this.data.id;
627
- }
628
1001
  get name() {
629
1002
  return this.data.name;
630
1003
  }
@@ -634,48 +1007,21 @@ var init_collection = __esm({
634
1007
  get description() {
635
1008
  return this.data.description;
636
1009
  }
637
- get icon() {
638
- return this.data.icon;
639
- }
640
- get color() {
641
- return this.data.color;
642
- }
643
- get sortOrder() {
644
- return this.data.sortOrder;
645
- }
646
1010
  get isSmart() {
647
1011
  return this.data.isSmart;
648
1012
  }
649
1013
  get smartRules() {
650
1014
  return this.data.smartRules;
651
1015
  }
652
- get createdAt() {
653
- return this.data.createdAt;
1016
+ get metadataJson() {
1017
+ return this.data.metadataJson;
654
1018
  }
655
- get updatedAt() {
656
- return this.data.updatedAt;
1019
+ get entryCount() {
1020
+ return this.data.entryCount;
657
1021
  }
658
- /**
659
- * Get all entries in this collection
660
- */
661
- async entries() {
662
- return this.data.entries || [];
663
- }
664
- /**
665
- * Get assets in this collection (future)
666
- */
667
- async assets() {
668
- throw new Error("Collection assets not yet implemented");
669
- }
670
- /**
671
- * Get collection metadata (future)
672
- */
673
- async metadata() {
674
- throw new Error("Collection metadata not yet implemented");
1022
+ get entries() {
1023
+ return this.data.entries;
675
1024
  }
676
- /**
677
- * Get raw collection data
678
- */
679
1025
  toJSON() {
680
1026
  return this.data;
681
1027
  }
@@ -689,6 +1035,7 @@ var init_collections = __esm({
689
1035
  "src/collections/collections.ts"() {
690
1036
  "use strict";
691
1037
  init_collection();
1038
+ init_errors();
692
1039
  CollectionsModule = class {
693
1040
  constructor(http, workspaceSlug) {
694
1041
  this.http = http;
@@ -706,18 +1053,52 @@ var init_collections = __esm({
706
1053
  }
707
1054
  /**
708
1055
  * Get a single collection by slug
1056
+ * Returns null if collection is not found (404)
709
1057
  */
710
1058
  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);
1059
+ try {
1060
+ const endpoint = `/api/publish/${this.workspaceSlug}/collections/${slug}`;
1061
+ const data = await this.http.fetch(endpoint);
1062
+ return new Collection(data);
1063
+ } catch (error) {
1064
+ if (error instanceof MarvinNotFoundError) {
1065
+ return null;
1066
+ }
1067
+ throw error;
1068
+ }
714
1069
  }
715
1070
  /**
716
1071
  * Get entries in a collection
1072
+ * Returns empty array if collection is not found
717
1073
  */
718
1074
  async entries(slug) {
719
1075
  const collection = await this.get(slug);
720
- return collection.entries();
1076
+ if (!collection) {
1077
+ return [];
1078
+ }
1079
+ return collection.entries;
1080
+ }
1081
+ /**
1082
+ * Try multiple collection slugs in fallback order
1083
+ * Returns the first collection that exists and has entries
1084
+ */
1085
+ async fallback(slugs, options = {}) {
1086
+ const { requireEntries = true } = options;
1087
+ for (const slug of slugs) {
1088
+ try {
1089
+ const collection = await this.get(slug);
1090
+ if (!collection) {
1091
+ continue;
1092
+ }
1093
+ const entries = collection.entries;
1094
+ if (!requireEntries || entries.length > 0) {
1095
+ return entries;
1096
+ }
1097
+ } catch (error) {
1098
+ continue;
1099
+ }
1100
+ }
1101
+ return [];
721
1102
  }
722
1103
  };
723
1104
  }
@@ -783,18 +1164,10 @@ var init_resource = __esm({
783
1164
  "src/resources/resource.ts"() {
784
1165
  "use strict";
785
1166
  Resource = class {
786
- constructor(data, http, workspaceSlug) {
1167
+ constructor(data) {
787
1168
  this.data = data;
788
- this.http = http;
789
- this.workspaceSlug = workspaceSlug;
790
1169
  }
791
1170
  data;
792
- http;
793
- workspaceSlug;
794
- // Expose all resource data as properties
795
- get id() {
796
- return this.data.id;
797
- }
798
1171
  get name() {
799
1172
  return this.data.name;
800
1173
  }
@@ -813,31 +1186,12 @@ var init_resource = __esm({
813
1186
  get url() {
814
1187
  return this.data.url;
815
1188
  }
816
- get metadata() {
817
- return this.data.metadata;
818
- }
819
- get createdAt() {
820
- return this.data.createdAt;
821
- }
822
- get updatedAt() {
823
- return this.data.updatedAt;
1189
+ get metadataJson() {
1190
+ return this.data.metadataJson;
824
1191
  }
825
- /**
826
- * Get all entries that reference this resource
827
- */
828
- async entries() {
829
- const endpoint = `/api/publish/${this.workspaceSlug}/resources/${this.data.slug}/entries`;
830
- return this.http.fetch(endpoint);
831
- }
832
- /**
833
- * Get assets related to this resource (future)
834
- */
835
- async assets() {
836
- throw new Error("Resource assets not yet implemented");
1192
+ get entries() {
1193
+ return this.data.entries;
837
1194
  }
838
- /**
839
- * Get raw resource data
840
- */
841
1195
  toJSON() {
842
1196
  return this.data;
843
1197
  }
@@ -877,14 +1231,34 @@ var init_resources = __esm({
877
1231
  async get(slug) {
878
1232
  const endpoint = `/api/publish/${this.workspaceSlug}/resources/${slug}`;
879
1233
  const data = await this.http.fetch(endpoint);
880
- return new Resource(data, this.http, this.workspaceSlug);
1234
+ return new Resource(data);
881
1235
  }
882
1236
  /**
883
- * Get entries that reference this resource
1237
+ * Get entry slugs that reference this resource
884
1238
  */
885
1239
  async entries(slug) {
886
1240
  const resource = await this.get(slug);
887
- return resource.entries();
1241
+ return resource.entries;
1242
+ }
1243
+ };
1244
+ }
1245
+ });
1246
+
1247
+ // src/renderers/renderers.ts
1248
+ var RenderersModule;
1249
+ var init_renderers = __esm({
1250
+ "src/renderers/renderers.ts"() {
1251
+ "use strict";
1252
+ RenderersModule = class {
1253
+ constructor(http, workspaceSlug) {
1254
+ this.http = http;
1255
+ this.workspaceSlug = workspaceSlug;
1256
+ }
1257
+ http;
1258
+ workspaceSlug;
1259
+ async list() {
1260
+ const endpoint = `/api/publish/${this.workspaceSlug}/entry-types`;
1261
+ return this.http.fetch(endpoint);
888
1262
  }
889
1263
  };
890
1264
  }
@@ -899,6 +1273,7 @@ var init_workspace = __esm({
899
1273
  init_collections();
900
1274
  init_assets();
901
1275
  init_resources();
1276
+ init_renderers();
902
1277
  Workspace = class {
903
1278
  constructor(http, slug) {
904
1279
  this.http = http;
@@ -907,6 +1282,7 @@ var init_workspace = __esm({
907
1282
  this.collections = new CollectionsModule(http, slug);
908
1283
  this.assets = new AssetsModule(http, slug);
909
1284
  this.resources = new ResourcesModule(http, slug);
1285
+ this.renderers = new RenderersModule(http, slug);
910
1286
  }
911
1287
  http;
912
1288
  _site = null;
@@ -915,6 +1291,7 @@ var init_workspace = __esm({
915
1291
  collections;
916
1292
  assets;
917
1293
  resources;
1294
+ renderers;
918
1295
  /**
919
1296
  * Get workspace slug
920
1297
  */
@@ -942,12 +1319,6 @@ var init_workspace = __esm({
942
1319
  const endpoint = `/api/publish/${this._slug}`;
943
1320
  return this.http.fetch(endpoint);
944
1321
  }
945
- /**
946
- * Get workspace settings (future)
947
- */
948
- async settings() {
949
- throw new Error("Workspace settings not yet implemented");
950
- }
951
1322
  };
952
1323
  }
953
1324
  });
@@ -966,8 +1337,11 @@ var init_marvin = __esm({
966
1337
  init_core();
967
1338
  init_workspace();
968
1339
  MarvinClient = class {
1340
+ http;
1341
+ cache;
1342
+ workspace;
1343
+ initialized = false;
969
1344
  constructor(config) {
970
- this.config = config;
971
1345
  validateConfig(config);
972
1346
  this.http = new MarvinHttpClient(config);
973
1347
  this.cache = new MarvinCache(config.cacheDuration);
@@ -976,11 +1350,6 @@ var init_marvin = __esm({
976
1350
  this.initialize();
977
1351
  }
978
1352
  }
979
- config;
980
- http;
981
- cache;
982
- workspace;
983
- initialized = false;
984
1353
  /**
985
1354
  * Initialize the SDK
986
1355
  * Preloads site configuration and metadata
@@ -1042,6 +1411,12 @@ var init_marvin = __esm({
1042
1411
  get resources() {
1043
1412
  return this.workspace.resources;
1044
1413
  }
1414
+ /**
1415
+ * Get renderers module
1416
+ */
1417
+ get renderers() {
1418
+ return this.workspace.renderers;
1419
+ }
1045
1420
  // ======================
1046
1421
  // Convenience Methods
1047
1422
  // ======================
@@ -1081,70 +1456,74 @@ var init_marvin = __esm({
1081
1456
  async resource(slug) {
1082
1457
  return this.workspace.resources.get(slug);
1083
1458
  }
1459
+ /**
1460
+ * Try multiple collection slugs in fallback order
1461
+ * Returns entries from the first collection that exists and has content
1462
+ */
1463
+ async collectionFallback(slugs, options) {
1464
+ return this.workspace.collections.fallback(slugs, options);
1465
+ }
1084
1466
  // ======================
1085
1467
  // Backwards-Compatible Publishing API
1086
1468
  // ======================
1087
1469
  /**
1088
1470
  * @deprecated Use workspace.site or marvin.site instead
1089
- * Get site configuration
1090
1471
  */
1091
1472
  async getSite() {
1092
1473
  return this.workspace.loadSite();
1093
1474
  }
1094
1475
  /**
1095
1476
  * @deprecated Use workspace.entries.list() instead
1096
- * Get all published entries
1097
1477
  */
1098
1478
  async getEntries(options) {
1099
1479
  return this.workspace.entries.list(options);
1100
1480
  }
1101
1481
  /**
1102
1482
  * @deprecated Use workspace.entries.get() instead
1103
- * Get a single published entry by slug
1104
1483
  */
1105
1484
  async getEntry(slug) {
1106
1485
  const entry = await this.workspace.entries.get(slug);
1486
+ if (!entry) {
1487
+ return null;
1488
+ }
1107
1489
  return entry.toJSON();
1108
1490
  }
1109
1491
  /**
1110
1492
  * @deprecated Use workspace.collections.list() instead
1111
- * Get all collections
1112
1493
  */
1113
1494
  async getCollections() {
1114
1495
  return this.workspace.collections.list();
1115
1496
  }
1116
1497
  /**
1117
1498
  * @deprecated Use workspace.collections.get() instead
1118
- * Get a single collection by slug
1119
1499
  */
1120
1500
  async getCollection(slug) {
1121
1501
  const collection = await this.workspace.collections.get(slug);
1502
+ if (!collection) {
1503
+ return null;
1504
+ }
1122
1505
  return collection.toJSON();
1123
1506
  }
1124
1507
  /**
1125
1508
  * @deprecated Use workspace.collections.entries() instead
1126
- * Get all entries in a collection
1127
1509
  */
1128
1510
  async getCollectionEntries(slug) {
1129
1511
  return this.workspace.collections.entries(slug);
1130
1512
  }
1131
1513
  /**
1132
1514
  * @deprecated Use workspace.assets.list() instead
1133
- * Get all published assets
1134
1515
  */
1135
1516
  async getAssets(options) {
1136
1517
  return this.workspace.assets.list(options);
1137
1518
  }
1138
1519
  /**
1139
1520
  * @deprecated Use workspace.resources.list() instead
1140
- * Get all published resources
1141
1521
  */
1142
1522
  async getResources(options) {
1143
1523
  return this.workspace.resources.list(options);
1144
1524
  }
1145
1525
  /**
1146
1526
  * @deprecated Use workspace.resources.get() instead
1147
- * Get a single resource by slug
1148
1527
  */
1149
1528
  async getResource(slug) {
1150
1529
  const resource = await this.workspace.resources.get(slug);
@@ -1152,7 +1531,6 @@ var init_marvin = __esm({
1152
1531
  }
1153
1532
  /**
1154
1533
  * @deprecated Use workspace.resources.entries() instead
1155
- * Get entries that reference a resource
1156
1534
  */
1157
1535
  async getResourceEntries(slug) {
1158
1536
  return this.workspace.resources.entries(slug);
@@ -1175,6 +1553,9 @@ __export(index_exports, {
1175
1553
  MarvinClient: () => MarvinClient,
1176
1554
  MarvinConfigError: () => MarvinConfigError,
1177
1555
  MarvinError: () => MarvinError,
1556
+ MarvinNetworkError: () => MarvinNetworkError,
1557
+ MarvinNotFoundError: () => MarvinNotFoundError,
1558
+ MarvinServerError: () => MarvinServerError,
1178
1559
  MarvinValidationError: () => MarvinValidationError,
1179
1560
  NoAuth: () => NoAuth,
1180
1561
  Resource: () => Resource,
@@ -1209,12 +1590,14 @@ var AuthClient = class extends HttpClient {
1209
1590
  * Returns the created user object
1210
1591
  */
1211
1592
  async register(data) {
1593
+ validateEmail(data.email, "Email");
1212
1594
  return this.post("/api/users/register", data);
1213
1595
  }
1214
1596
  /**
1215
1597
  * Request password reset (sends email with token)
1216
1598
  */
1217
1599
  async forgotPassword(data) {
1600
+ validateEmail(data.email, "Email");
1218
1601
  return this.post("/api/users/forgot-password", data);
1219
1602
  }
1220
1603
  /**
@@ -1271,6 +1654,9 @@ function createMarvinClient(config) {
1271
1654
  MarvinClient,
1272
1655
  MarvinConfigError,
1273
1656
  MarvinError,
1657
+ MarvinNetworkError,
1658
+ MarvinNotFoundError,
1659
+ MarvinServerError,
1274
1660
  MarvinValidationError,
1275
1661
  NoAuth,
1276
1662
  Resource,