@structcms/api 0.1.0 → 0.1.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Storage, domain API, delivery API, and content export for StructCMS. Provides Supabase-agnostic adapter interfaces, handler functions for content CRUD, media management, and JSON export.
4
4
 
5
- For architectural context, see [ARCHITECTURE.md](../../ARCHITECTURE.md) (Layer 3: Storage, Layer 4: Domain API, Layer 5: Delivery API).
5
+ For architectural context, see [ARCHITECTURE.md](../../docs/ARCHITECTURE.md) (Layer 3: Storage, Layer 4: Domain API, Layer 5: Delivery API).
6
6
 
7
7
  **[← Back to main README](../../README.md)**
8
8
 
@@ -164,7 +164,7 @@ Rich text content is sanitized on write using `sanitize-html` to prevent XSS att
164
164
 
165
165
  ## Database
166
166
 
167
- Migrations live in `supabase/migrations/` at the monorepo root. See [ARCHITECTURE.md](../../ARCHITECTURE.md) for the database schema.
167
+ Migrations live in `supabase/migrations/` at the monorepo root. See [ARCHITECTURE.md](../../docs/ARCHITECTURE.md) for the database schema.
168
168
 
169
169
  ## Development
170
170
 
package/dist/index.cjs CHANGED
@@ -30,22 +30,34 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ ALLOWED_DOCUMENT_MIME_TYPES: () => ALLOWED_DOCUMENT_MIME_TYPES,
33
34
  ALLOWED_MIME_TYPES: () => ALLOWED_MIME_TYPES,
35
+ ALL_ALLOWED_MIME_TYPES: () => ALL_ALLOWED_MIME_TYPES,
34
36
  AuthError: () => AuthError,
35
37
  AuthValidationError: () => AuthValidationError,
38
+ CreateNavigationSchema: () => CreateNavigationSchema,
39
+ CreatePageSchema: () => CreatePageSchema,
40
+ MAX_FILE_SIZE: () => MAX_FILE_SIZE2,
36
41
  MediaError: () => MediaError,
42
+ MediaUploadSchema: () => MediaUploadSchema,
37
43
  MediaValidationError: () => MediaValidationError,
44
+ SignInSchema: () => SignInSchema,
38
45
  StorageError: () => StorageError,
39
46
  StorageValidationError: () => StorageValidationError,
40
47
  SupabaseAuthAdapter: () => SupabaseAuthAdapter,
41
48
  SupabaseMediaAdapter: () => SupabaseMediaAdapter,
42
49
  SupabaseStorageAdapter: () => SupabaseStorageAdapter,
50
+ UpdateNavigationSchema: () => UpdateNavigationSchema,
51
+ UpdatePageSchema: () => UpdatePageSchema,
52
+ createAuditLogger: () => createAuditLogger,
43
53
  createAuthAdapter: () => createAuthAdapter,
44
54
  createAuthMiddleware: () => createAuthMiddleware,
45
55
  createMediaAdapter: () => createMediaAdapter,
56
+ createRateLimiter: () => createRateLimiter,
46
57
  createStorageAdapter: () => createStorageAdapter,
47
58
  createSupabaseAdapters: () => createSupabaseAdapters,
48
59
  ensureUniqueSlug: () => ensureUniqueSlug,
60
+ generateCsrfToken: () => generateCsrfToken,
49
61
  generateSlug: () => generateSlug,
50
62
  handleCreateNavigation: () => handleCreateNavigation,
51
63
  handleCreatePage: () => handleCreatePage,
@@ -70,7 +82,10 @@ __export(index_exports, {
70
82
  handleUpdatePage: () => handleUpdatePage,
71
83
  handleUploadMedia: () => handleUploadMedia,
72
84
  handleVerifySession: () => handleVerifySession,
73
- resolveMediaReferences: () => resolveMediaReferences
85
+ resolveMediaReferences: () => resolveMediaReferences,
86
+ stripTags: () => stripTags,
87
+ validateCsrfToken: () => validateCsrfToken,
88
+ withAuditLog: () => withAuditLog
74
89
  });
75
90
  module.exports = __toCommonJS(index_exports);
76
91
 
@@ -291,6 +306,12 @@ var SANITIZE_OPTIONS = {
291
306
  function sanitizeString(value) {
292
307
  return (0, import_sanitize_html.default)(value, SANITIZE_OPTIONS);
293
308
  }
309
+ function stripTags(value) {
310
+ return (0, import_sanitize_html.default)(value, {
311
+ allowedTags: [],
312
+ allowedAttributes: {}
313
+ });
314
+ }
294
315
  function sanitizeValue(value) {
295
316
  if (typeof value === "string") {
296
317
  return sanitizeString(value);
@@ -357,10 +378,11 @@ var StorageValidationError = class extends Error {
357
378
  }
358
379
  };
359
380
  async function handleCreatePage(adapter, input) {
360
- if (!input.title.trim()) {
381
+ const sanitizedTitle = stripTags(input.title).trim();
382
+ if (!sanitizedTitle) {
361
383
  throw new StorageValidationError("Page title must not be empty", "EMPTY_TITLE");
362
384
  }
363
- const slug = input.slug?.trim() || generateSlug(input.title);
385
+ const slug = input.slug?.trim() || generateSlug(sanitizedTitle);
364
386
  if (!slug) {
365
387
  throw new StorageValidationError(
366
388
  "Could not generate a valid slug from the provided title",
@@ -373,6 +395,7 @@ async function handleCreatePage(adapter, input) {
373
395
  const sanitizedSections = input.sections ? sanitizeSectionData(input.sections) : void 0;
374
396
  return adapter.createPage({
375
397
  ...input,
398
+ title: sanitizedTitle,
376
399
  slug: uniqueSlug,
377
400
  sections: sanitizedSections
378
401
  });
@@ -381,8 +404,12 @@ async function handleUpdatePage(adapter, input) {
381
404
  if (!input.id.trim()) {
382
405
  throw new StorageValidationError("Page ID must not be empty", "EMPTY_ID");
383
406
  }
384
- if (input.title !== void 0 && !input.title.trim()) {
385
- throw new StorageValidationError("Page title must not be empty", "EMPTY_TITLE");
407
+ let sanitizedTitle;
408
+ if (input.title !== void 0) {
409
+ sanitizedTitle = stripTags(input.title).trim();
410
+ if (!sanitizedTitle) {
411
+ throw new StorageValidationError("Page title must not be empty", "EMPTY_TITLE");
412
+ }
386
413
  }
387
414
  if (input.slug !== void 0) {
388
415
  const slug = input.slug.trim();
@@ -395,7 +422,11 @@ async function handleUpdatePage(adapter, input) {
395
422
  throw new StorageValidationError(`Slug "${slug}" is already in use`, "DUPLICATE_SLUG");
396
423
  }
397
424
  }
398
- const sanitizedInput = input.sections ? { ...input, sections: sanitizeSectionData(input.sections) } : input;
425
+ const sanitizedInput = {
426
+ ...input,
427
+ ...sanitizedTitle !== void 0 && { title: sanitizedTitle },
428
+ ...input.sections && { sections: sanitizeSectionData(input.sections) }
429
+ };
399
430
  return adapter.updatePage(sanitizedInput);
400
431
  }
401
432
  async function handleDeletePage(adapter, id) {
@@ -405,38 +436,46 @@ async function handleDeletePage(adapter, id) {
405
436
  return adapter.deletePage(id);
406
437
  }
407
438
  async function handleCreateNavigation(adapter, input) {
408
- if (!input.name.trim()) {
439
+ const sanitizedName = stripTags(input.name).trim();
440
+ if (!sanitizedName) {
409
441
  throw new StorageValidationError("Navigation name must not be empty", "EMPTY_NAME");
410
442
  }
411
443
  const existingNavigations = await adapter.listNavigations();
412
444
  const existingNames = existingNavigations.map((n) => n.name);
413
- if (existingNames.includes(input.name.trim())) {
445
+ if (existingNames.includes(sanitizedName)) {
414
446
  throw new StorageValidationError(
415
- `Navigation name "${input.name.trim()}" is already in use`,
447
+ `Navigation name "${sanitizedName}" is already in use`,
416
448
  "DUPLICATE_NAME"
417
449
  );
418
450
  }
419
- return adapter.createNavigation(input);
451
+ return adapter.createNavigation({
452
+ ...input,
453
+ name: sanitizedName
454
+ });
420
455
  }
421
456
  async function handleUpdateNavigation(adapter, input) {
422
457
  if (!input.id.trim()) {
423
458
  throw new StorageValidationError("Navigation ID must not be empty", "EMPTY_ID");
424
459
  }
460
+ let sanitizedName;
425
461
  if (input.name !== void 0) {
426
- const name = input.name.trim();
427
- if (!name) {
462
+ sanitizedName = stripTags(input.name).trim();
463
+ if (!sanitizedName) {
428
464
  throw new StorageValidationError("Navigation name must not be empty", "EMPTY_NAME");
429
465
  }
430
466
  const existingNavigations = await adapter.listNavigations();
431
467
  const existingNames = existingNavigations.filter((n) => n.id !== input.id).map((n) => n.name);
432
- if (existingNames.includes(name)) {
468
+ if (existingNames.includes(sanitizedName)) {
433
469
  throw new StorageValidationError(
434
- `Navigation name "${name}" is already in use`,
470
+ `Navigation name "${sanitizedName}" is already in use`,
435
471
  "DUPLICATE_NAME"
436
472
  );
437
473
  }
438
474
  }
439
- return adapter.updateNavigation(input);
475
+ return adapter.updateNavigation({
476
+ ...input,
477
+ ...sanitizedName !== void 0 && { name: sanitizedName }
478
+ });
440
479
  }
441
480
  async function handleDeleteNavigation(adapter, id) {
442
481
  if (!id.trim()) {
@@ -445,12 +484,61 @@ async function handleDeleteNavigation(adapter, id) {
445
484
  return adapter.deleteNavigation(id);
446
485
  }
447
486
 
487
+ // src/validation/schemas.ts
488
+ var import_zod = require("zod");
489
+ var MAX_FILE_SIZE = 50 * 1024 * 1024;
490
+ var PageSectionSchema = import_zod.z.object({
491
+ id: import_zod.z.string().optional(),
492
+ type: import_zod.z.string(),
493
+ data: import_zod.z.record(import_zod.z.unknown())
494
+ });
495
+ var CreatePageSchema = import_zod.z.object({
496
+ title: import_zod.z.string().max(200, "Title must not exceed 200 characters"),
497
+ pageType: import_zod.z.string(),
498
+ slug: import_zod.z.string().max(200, "Slug must not exceed 200 characters").optional(),
499
+ sections: import_zod.z.array(PageSectionSchema).optional()
500
+ });
501
+ var UpdatePageSchema = CreatePageSchema.partial();
502
+ var NavigationItemSchema = import_zod.z.lazy(
503
+ () => import_zod.z.object({
504
+ label: import_zod.z.string(),
505
+ href: import_zod.z.string(),
506
+ children: import_zod.z.array(NavigationItemSchema).optional()
507
+ })
508
+ );
509
+ var CreateNavigationSchema = import_zod.z.object({
510
+ name: import_zod.z.string().max(100, "Name must not exceed 100 characters"),
511
+ items: import_zod.z.array(NavigationItemSchema)
512
+ });
513
+ var UpdateNavigationSchema = CreateNavigationSchema.partial();
514
+ var SignInSchema = import_zod.z.object({
515
+ email: import_zod.z.string().email("Invalid email address"),
516
+ password: import_zod.z.string().min(1, "Password is required")
517
+ });
518
+ var MediaUploadSchema = import_zod.z.object({
519
+ filename: import_zod.z.string().max(255, "Filename must not exceed 255 characters"),
520
+ mimeType: import_zod.z.string(),
521
+ size: import_zod.z.number().max(MAX_FILE_SIZE, `File size must not exceed ${MAX_FILE_SIZE / 1024 / 1024}MB`),
522
+ data: import_zod.z.instanceof(ArrayBuffer)
523
+ });
524
+
448
525
  // src/media/resolve.ts
449
526
  var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
450
- var MEDIA_FIELD_SUFFIXES = ["_image", "_media", "_photo", "_thumbnail", "_avatar", "_icon"];
527
+ var MEDIA_FIELD_SUFFIXES = [
528
+ "_image",
529
+ "_media",
530
+ "_photo",
531
+ "_thumbnail",
532
+ "_avatar",
533
+ "_icon",
534
+ "_file",
535
+ "_document",
536
+ "_attachment",
537
+ "_download"
538
+ ];
451
539
  function isMediaField(fieldName) {
452
540
  const lower = fieldName.toLowerCase();
453
- if (lower === "image" || lower === "media" || lower === "photo" || lower === "thumbnail" || lower === "avatar" || lower === "icon") {
541
+ if (lower === "image" || lower === "media" || lower === "photo" || lower === "thumbnail" || lower === "avatar" || lower === "icon" || lower === "file" || lower === "document" || lower === "attachment" || lower === "download") {
454
542
  return true;
455
543
  }
456
544
  return MEDIA_FIELD_SUFFIXES.some((suffix) => lower.endsWith(suffix));
@@ -538,6 +626,7 @@ async function handleGetNavigation(adapter, name) {
538
626
  }
539
627
 
540
628
  // src/media/types.ts
629
+ var MAX_FILE_SIZE2 = 50 * 1024 * 1024;
541
630
  var ALLOWED_MIME_TYPES = [
542
631
  "image/jpeg",
543
632
  "image/png",
@@ -545,8 +634,29 @@ var ALLOWED_MIME_TYPES = [
545
634
  "image/webp",
546
635
  "image/svg+xml"
547
636
  ];
637
+ var ALLOWED_DOCUMENT_MIME_TYPES = [
638
+ "application/pdf",
639
+ "application/msword",
640
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
641
+ "application/vnd.ms-excel",
642
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
643
+ "application/vnd.ms-powerpoint",
644
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
645
+ "text/plain",
646
+ "text/csv",
647
+ "application/zip",
648
+ "application/gzip"
649
+ ];
650
+ var ALL_ALLOWED_MIME_TYPES = [
651
+ ...ALLOWED_MIME_TYPES,
652
+ ...ALLOWED_DOCUMENT_MIME_TYPES
653
+ ];
548
654
 
549
655
  // src/media/supabase-adapter.ts
656
+ function deriveCategory(mimeType) {
657
+ const imageMimes = ALLOWED_MIME_TYPES;
658
+ return imageMimes.includes(mimeType) ? "image" : "document";
659
+ }
550
660
  var MediaError = class extends Error {
551
661
  constructor(message, code, details) {
552
662
  super(message);
@@ -588,6 +698,7 @@ var SupabaseMediaAdapter = class {
588
698
  url: this.getPublicUrl(row.storage_path),
589
699
  mimeType: row.mime_type,
590
700
  size: row.size,
701
+ category: row.category,
591
702
  createdAt: new Date(row.created_at)
592
703
  };
593
704
  }
@@ -608,7 +719,8 @@ var SupabaseMediaAdapter = class {
608
719
  filename: input.filename,
609
720
  storage_path: storagePath,
610
721
  mime_type: input.mimeType,
611
- size: input.size
722
+ size: input.size,
723
+ category: deriveCategory(input.mimeType)
612
724
  }).select().single();
613
725
  if (dbError) {
614
726
  await this.client.storage.from(this.bucketName).remove([storagePath]);
@@ -635,6 +747,9 @@ var SupabaseMediaAdapter = class {
635
747
  if (filter?.mimeType) {
636
748
  query = query.eq("mime_type", filter.mimeType);
637
749
  }
750
+ if (filter?.category) {
751
+ query = query.eq("category", filter.category);
752
+ }
638
753
  query = query.order("created_at", { ascending: false });
639
754
  if (filter?.limit) {
640
755
  query = query.limit(filter.limit);
@@ -684,16 +799,25 @@ var MediaValidationError = class extends Error {
684
799
  }
685
800
  };
686
801
  function validateMimeType(mimeType) {
687
- const allowed = ALLOWED_MIME_TYPES;
802
+ const allowed = ALL_ALLOWED_MIME_TYPES;
688
803
  if (!allowed.includes(mimeType)) {
689
804
  throw new MediaValidationError(
690
- `Invalid file type: ${mimeType}. Allowed types: ${ALLOWED_MIME_TYPES.join(", ")}`,
805
+ `Invalid file type: ${mimeType}. Allowed types: ${ALL_ALLOWED_MIME_TYPES.join(", ")}`,
691
806
  "INVALID_MIME_TYPE"
692
807
  );
693
808
  }
694
809
  }
810
+ function validateFileSize(size) {
811
+ if (size > MAX_FILE_SIZE2) {
812
+ throw new MediaValidationError(
813
+ `File size exceeds maximum allowed size of ${MAX_FILE_SIZE2 / 1024 / 1024}MB`,
814
+ "FILE_TOO_LARGE"
815
+ );
816
+ }
817
+ }
695
818
  async function handleUploadMedia(adapter, input) {
696
819
  validateMimeType(input.mimeType);
820
+ validateFileSize(input.size);
697
821
  return adapter.upload(input);
698
822
  }
699
823
  async function handleGetMedia(adapter, id) {
@@ -947,6 +1071,82 @@ function createAuthMiddleware(config) {
947
1071
  };
948
1072
  }
949
1073
 
1074
+ // src/auth/csrf.ts
1075
+ function generateCsrfToken() {
1076
+ const bytes = new Uint8Array(32);
1077
+ crypto.getRandomValues(bytes);
1078
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
1079
+ }
1080
+ function validateCsrfToken(cookieToken, headerToken) {
1081
+ if (!cookieToken || !headerToken) {
1082
+ return false;
1083
+ }
1084
+ if (cookieToken.trim() === "" || headerToken.trim() === "") {
1085
+ return false;
1086
+ }
1087
+ return cookieToken === headerToken;
1088
+ }
1089
+
1090
+ // src/auth/rate-limiter.ts
1091
+ function createRateLimiter(config) {
1092
+ const { windowMs, maxRequests } = config;
1093
+ const entries = /* @__PURE__ */ new Map();
1094
+ function cleanupEntry(entry, now) {
1095
+ const windowStart = now - windowMs;
1096
+ entry.timestamps = entry.timestamps.filter((ts) => ts > windowStart);
1097
+ }
1098
+ function scheduleCleanup(key) {
1099
+ const entry = entries.get(key);
1100
+ if (!entry) return;
1101
+ if (entry.timeoutId) {
1102
+ clearTimeout(entry.timeoutId);
1103
+ }
1104
+ entry.timeoutId = setTimeout(() => {
1105
+ const currentEntry = entries.get(key);
1106
+ if (currentEntry) {
1107
+ cleanupEntry(currentEntry, Date.now());
1108
+ if (currentEntry.timestamps.length === 0) {
1109
+ entries.delete(key);
1110
+ } else {
1111
+ scheduleCleanup(key);
1112
+ }
1113
+ }
1114
+ }, windowMs);
1115
+ }
1116
+ return {
1117
+ check(key) {
1118
+ const now = Date.now();
1119
+ let entry = entries.get(key);
1120
+ if (!entry) {
1121
+ entry = { timestamps: [] };
1122
+ entries.set(key, entry);
1123
+ }
1124
+ cleanupEntry(entry, now);
1125
+ if (entry.timestamps.length >= maxRequests) {
1126
+ const oldestTimestamp = entry.timestamps[0] ?? now;
1127
+ const retryAfterMs = oldestTimestamp + windowMs - now;
1128
+ const retryAfterSeconds = Math.ceil(retryAfterMs / 1e3);
1129
+ return {
1130
+ allowed: false,
1131
+ retryAfter: retryAfterSeconds
1132
+ };
1133
+ }
1134
+ entry.timestamps.push(now);
1135
+ scheduleCleanup(key);
1136
+ return {
1137
+ allowed: true
1138
+ };
1139
+ },
1140
+ reset(key) {
1141
+ const entry = entries.get(key);
1142
+ if (entry?.timeoutId) {
1143
+ clearTimeout(entry.timeoutId);
1144
+ }
1145
+ entries.delete(key);
1146
+ }
1147
+ };
1148
+ }
1149
+
950
1150
  // src/supabase/factory.ts
951
1151
  function readEnv(name) {
952
1152
  const processLike = globalThis.process;
@@ -1077,24 +1277,66 @@ async function handleExportSite(storageAdapter, mediaAdapter) {
1077
1277
  contentDisposition: contentDisposition("site-export.json")
1078
1278
  };
1079
1279
  }
1280
+
1281
+ // src/audit/logger.ts
1282
+ var defaultSink = (entry) => {
1283
+ const output = JSON.stringify(entry, null, 2);
1284
+ globalThis.console?.log?.(output);
1285
+ };
1286
+ function createAuditLogger(sink = defaultSink) {
1287
+ return {
1288
+ log: async (entry) => {
1289
+ await sink({
1290
+ ...entry,
1291
+ timestamp: /* @__PURE__ */ new Date()
1292
+ });
1293
+ }
1294
+ };
1295
+ }
1296
+ function withAuditLog(handler, options, sink) {
1297
+ const logger = createAuditLogger(sink);
1298
+ return (async (...args) => {
1299
+ const result = await handler(...args);
1300
+ await logger.log({
1301
+ action: options.action,
1302
+ entity: options.entity,
1303
+ entityId: options.extractEntityId(args),
1304
+ userId: options.extractUserId?.(args),
1305
+ metadata: options.metadata?.(args)
1306
+ });
1307
+ return result;
1308
+ });
1309
+ }
1080
1310
  // Annotate the CommonJS export names for ESM import in node:
1081
1311
  0 && (module.exports = {
1312
+ ALLOWED_DOCUMENT_MIME_TYPES,
1082
1313
  ALLOWED_MIME_TYPES,
1314
+ ALL_ALLOWED_MIME_TYPES,
1083
1315
  AuthError,
1084
1316
  AuthValidationError,
1317
+ CreateNavigationSchema,
1318
+ CreatePageSchema,
1319
+ MAX_FILE_SIZE,
1085
1320
  MediaError,
1321
+ MediaUploadSchema,
1086
1322
  MediaValidationError,
1323
+ SignInSchema,
1087
1324
  StorageError,
1088
1325
  StorageValidationError,
1089
1326
  SupabaseAuthAdapter,
1090
1327
  SupabaseMediaAdapter,
1091
1328
  SupabaseStorageAdapter,
1329
+ UpdateNavigationSchema,
1330
+ UpdatePageSchema,
1331
+ createAuditLogger,
1092
1332
  createAuthAdapter,
1093
1333
  createAuthMiddleware,
1094
1334
  createMediaAdapter,
1335
+ createRateLimiter,
1095
1336
  createStorageAdapter,
1096
1337
  createSupabaseAdapters,
1097
1338
  ensureUniqueSlug,
1339
+ generateCsrfToken,
1098
1340
  generateSlug,
1099
1341
  handleCreateNavigation,
1100
1342
  handleCreatePage,
@@ -1119,6 +1361,9 @@ async function handleExportSite(storageAdapter, mediaAdapter) {
1119
1361
  handleUpdatePage,
1120
1362
  handleUploadMedia,
1121
1363
  handleVerifySession,
1122
- resolveMediaReferences
1364
+ resolveMediaReferences,
1365
+ stripTags,
1366
+ validateCsrfToken,
1367
+ withAuditLog
1123
1368
  });
1124
1369
  //# sourceMappingURL=index.cjs.map