@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/dist/index.js CHANGED
@@ -215,6 +215,12 @@ var SANITIZE_OPTIONS = {
215
215
  function sanitizeString(value) {
216
216
  return sanitizeHtml(value, SANITIZE_OPTIONS);
217
217
  }
218
+ function stripTags(value) {
219
+ return sanitizeHtml(value, {
220
+ allowedTags: [],
221
+ allowedAttributes: {}
222
+ });
223
+ }
218
224
  function sanitizeValue(value) {
219
225
  if (typeof value === "string") {
220
226
  return sanitizeString(value);
@@ -281,10 +287,11 @@ var StorageValidationError = class extends Error {
281
287
  }
282
288
  };
283
289
  async function handleCreatePage(adapter, input) {
284
- if (!input.title.trim()) {
290
+ const sanitizedTitle = stripTags(input.title).trim();
291
+ if (!sanitizedTitle) {
285
292
  throw new StorageValidationError("Page title must not be empty", "EMPTY_TITLE");
286
293
  }
287
- const slug = input.slug?.trim() || generateSlug(input.title);
294
+ const slug = input.slug?.trim() || generateSlug(sanitizedTitle);
288
295
  if (!slug) {
289
296
  throw new StorageValidationError(
290
297
  "Could not generate a valid slug from the provided title",
@@ -297,6 +304,7 @@ async function handleCreatePage(adapter, input) {
297
304
  const sanitizedSections = input.sections ? sanitizeSectionData(input.sections) : void 0;
298
305
  return adapter.createPage({
299
306
  ...input,
307
+ title: sanitizedTitle,
300
308
  slug: uniqueSlug,
301
309
  sections: sanitizedSections
302
310
  });
@@ -305,8 +313,12 @@ async function handleUpdatePage(adapter, input) {
305
313
  if (!input.id.trim()) {
306
314
  throw new StorageValidationError("Page ID must not be empty", "EMPTY_ID");
307
315
  }
308
- if (input.title !== void 0 && !input.title.trim()) {
309
- throw new StorageValidationError("Page title must not be empty", "EMPTY_TITLE");
316
+ let sanitizedTitle;
317
+ if (input.title !== void 0) {
318
+ sanitizedTitle = stripTags(input.title).trim();
319
+ if (!sanitizedTitle) {
320
+ throw new StorageValidationError("Page title must not be empty", "EMPTY_TITLE");
321
+ }
310
322
  }
311
323
  if (input.slug !== void 0) {
312
324
  const slug = input.slug.trim();
@@ -319,7 +331,11 @@ async function handleUpdatePage(adapter, input) {
319
331
  throw new StorageValidationError(`Slug "${slug}" is already in use`, "DUPLICATE_SLUG");
320
332
  }
321
333
  }
322
- const sanitizedInput = input.sections ? { ...input, sections: sanitizeSectionData(input.sections) } : input;
334
+ const sanitizedInput = {
335
+ ...input,
336
+ ...sanitizedTitle !== void 0 && { title: sanitizedTitle },
337
+ ...input.sections && { sections: sanitizeSectionData(input.sections) }
338
+ };
323
339
  return adapter.updatePage(sanitizedInput);
324
340
  }
325
341
  async function handleDeletePage(adapter, id) {
@@ -329,38 +345,46 @@ async function handleDeletePage(adapter, id) {
329
345
  return adapter.deletePage(id);
330
346
  }
331
347
  async function handleCreateNavigation(adapter, input) {
332
- if (!input.name.trim()) {
348
+ const sanitizedName = stripTags(input.name).trim();
349
+ if (!sanitizedName) {
333
350
  throw new StorageValidationError("Navigation name must not be empty", "EMPTY_NAME");
334
351
  }
335
352
  const existingNavigations = await adapter.listNavigations();
336
353
  const existingNames = existingNavigations.map((n) => n.name);
337
- if (existingNames.includes(input.name.trim())) {
354
+ if (existingNames.includes(sanitizedName)) {
338
355
  throw new StorageValidationError(
339
- `Navigation name "${input.name.trim()}" is already in use`,
356
+ `Navigation name "${sanitizedName}" is already in use`,
340
357
  "DUPLICATE_NAME"
341
358
  );
342
359
  }
343
- return adapter.createNavigation(input);
360
+ return adapter.createNavigation({
361
+ ...input,
362
+ name: sanitizedName
363
+ });
344
364
  }
345
365
  async function handleUpdateNavigation(adapter, input) {
346
366
  if (!input.id.trim()) {
347
367
  throw new StorageValidationError("Navigation ID must not be empty", "EMPTY_ID");
348
368
  }
369
+ let sanitizedName;
349
370
  if (input.name !== void 0) {
350
- const name = input.name.trim();
351
- if (!name) {
371
+ sanitizedName = stripTags(input.name).trim();
372
+ if (!sanitizedName) {
352
373
  throw new StorageValidationError("Navigation name must not be empty", "EMPTY_NAME");
353
374
  }
354
375
  const existingNavigations = await adapter.listNavigations();
355
376
  const existingNames = existingNavigations.filter((n) => n.id !== input.id).map((n) => n.name);
356
- if (existingNames.includes(name)) {
377
+ if (existingNames.includes(sanitizedName)) {
357
378
  throw new StorageValidationError(
358
- `Navigation name "${name}" is already in use`,
379
+ `Navigation name "${sanitizedName}" is already in use`,
359
380
  "DUPLICATE_NAME"
360
381
  );
361
382
  }
362
383
  }
363
- return adapter.updateNavigation(input);
384
+ return adapter.updateNavigation({
385
+ ...input,
386
+ ...sanitizedName !== void 0 && { name: sanitizedName }
387
+ });
364
388
  }
365
389
  async function handleDeleteNavigation(adapter, id) {
366
390
  if (!id.trim()) {
@@ -369,12 +393,61 @@ async function handleDeleteNavigation(adapter, id) {
369
393
  return adapter.deleteNavigation(id);
370
394
  }
371
395
 
396
+ // src/validation/schemas.ts
397
+ import { z } from "zod";
398
+ var MAX_FILE_SIZE = 50 * 1024 * 1024;
399
+ var PageSectionSchema = z.object({
400
+ id: z.string().optional(),
401
+ type: z.string(),
402
+ data: z.record(z.unknown())
403
+ });
404
+ var CreatePageSchema = z.object({
405
+ title: z.string().max(200, "Title must not exceed 200 characters"),
406
+ pageType: z.string(),
407
+ slug: z.string().max(200, "Slug must not exceed 200 characters").optional(),
408
+ sections: z.array(PageSectionSchema).optional()
409
+ });
410
+ var UpdatePageSchema = CreatePageSchema.partial();
411
+ var NavigationItemSchema = z.lazy(
412
+ () => z.object({
413
+ label: z.string(),
414
+ href: z.string(),
415
+ children: z.array(NavigationItemSchema).optional()
416
+ })
417
+ );
418
+ var CreateNavigationSchema = z.object({
419
+ name: z.string().max(100, "Name must not exceed 100 characters"),
420
+ items: z.array(NavigationItemSchema)
421
+ });
422
+ var UpdateNavigationSchema = CreateNavigationSchema.partial();
423
+ var SignInSchema = z.object({
424
+ email: z.string().email("Invalid email address"),
425
+ password: z.string().min(1, "Password is required")
426
+ });
427
+ var MediaUploadSchema = z.object({
428
+ filename: z.string().max(255, "Filename must not exceed 255 characters"),
429
+ mimeType: z.string(),
430
+ size: z.number().max(MAX_FILE_SIZE, `File size must not exceed ${MAX_FILE_SIZE / 1024 / 1024}MB`),
431
+ data: z.instanceof(ArrayBuffer)
432
+ });
433
+
372
434
  // src/media/resolve.ts
373
435
  var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
374
- var MEDIA_FIELD_SUFFIXES = ["_image", "_media", "_photo", "_thumbnail", "_avatar", "_icon"];
436
+ var MEDIA_FIELD_SUFFIXES = [
437
+ "_image",
438
+ "_media",
439
+ "_photo",
440
+ "_thumbnail",
441
+ "_avatar",
442
+ "_icon",
443
+ "_file",
444
+ "_document",
445
+ "_attachment",
446
+ "_download"
447
+ ];
375
448
  function isMediaField(fieldName) {
376
449
  const lower = fieldName.toLowerCase();
377
- if (lower === "image" || lower === "media" || lower === "photo" || lower === "thumbnail" || lower === "avatar" || lower === "icon") {
450
+ if (lower === "image" || lower === "media" || lower === "photo" || lower === "thumbnail" || lower === "avatar" || lower === "icon" || lower === "file" || lower === "document" || lower === "attachment" || lower === "download") {
378
451
  return true;
379
452
  }
380
453
  return MEDIA_FIELD_SUFFIXES.some((suffix) => lower.endsWith(suffix));
@@ -462,6 +535,7 @@ async function handleGetNavigation(adapter, name) {
462
535
  }
463
536
 
464
537
  // src/media/types.ts
538
+ var MAX_FILE_SIZE2 = 50 * 1024 * 1024;
465
539
  var ALLOWED_MIME_TYPES = [
466
540
  "image/jpeg",
467
541
  "image/png",
@@ -469,8 +543,29 @@ var ALLOWED_MIME_TYPES = [
469
543
  "image/webp",
470
544
  "image/svg+xml"
471
545
  ];
546
+ var ALLOWED_DOCUMENT_MIME_TYPES = [
547
+ "application/pdf",
548
+ "application/msword",
549
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
550
+ "application/vnd.ms-excel",
551
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
552
+ "application/vnd.ms-powerpoint",
553
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
554
+ "text/plain",
555
+ "text/csv",
556
+ "application/zip",
557
+ "application/gzip"
558
+ ];
559
+ var ALL_ALLOWED_MIME_TYPES = [
560
+ ...ALLOWED_MIME_TYPES,
561
+ ...ALLOWED_DOCUMENT_MIME_TYPES
562
+ ];
472
563
 
473
564
  // src/media/supabase-adapter.ts
565
+ function deriveCategory(mimeType) {
566
+ const imageMimes = ALLOWED_MIME_TYPES;
567
+ return imageMimes.includes(mimeType) ? "image" : "document";
568
+ }
474
569
  var MediaError = class extends Error {
475
570
  constructor(message, code, details) {
476
571
  super(message);
@@ -512,6 +607,7 @@ var SupabaseMediaAdapter = class {
512
607
  url: this.getPublicUrl(row.storage_path),
513
608
  mimeType: row.mime_type,
514
609
  size: row.size,
610
+ category: row.category,
515
611
  createdAt: new Date(row.created_at)
516
612
  };
517
613
  }
@@ -532,7 +628,8 @@ var SupabaseMediaAdapter = class {
532
628
  filename: input.filename,
533
629
  storage_path: storagePath,
534
630
  mime_type: input.mimeType,
535
- size: input.size
631
+ size: input.size,
632
+ category: deriveCategory(input.mimeType)
536
633
  }).select().single();
537
634
  if (dbError) {
538
635
  await this.client.storage.from(this.bucketName).remove([storagePath]);
@@ -559,6 +656,9 @@ var SupabaseMediaAdapter = class {
559
656
  if (filter?.mimeType) {
560
657
  query = query.eq("mime_type", filter.mimeType);
561
658
  }
659
+ if (filter?.category) {
660
+ query = query.eq("category", filter.category);
661
+ }
562
662
  query = query.order("created_at", { ascending: false });
563
663
  if (filter?.limit) {
564
664
  query = query.limit(filter.limit);
@@ -608,16 +708,25 @@ var MediaValidationError = class extends Error {
608
708
  }
609
709
  };
610
710
  function validateMimeType(mimeType) {
611
- const allowed = ALLOWED_MIME_TYPES;
711
+ const allowed = ALL_ALLOWED_MIME_TYPES;
612
712
  if (!allowed.includes(mimeType)) {
613
713
  throw new MediaValidationError(
614
- `Invalid file type: ${mimeType}. Allowed types: ${ALLOWED_MIME_TYPES.join(", ")}`,
714
+ `Invalid file type: ${mimeType}. Allowed types: ${ALL_ALLOWED_MIME_TYPES.join(", ")}`,
615
715
  "INVALID_MIME_TYPE"
616
716
  );
617
717
  }
618
718
  }
719
+ function validateFileSize(size) {
720
+ if (size > MAX_FILE_SIZE2) {
721
+ throw new MediaValidationError(
722
+ `File size exceeds maximum allowed size of ${MAX_FILE_SIZE2 / 1024 / 1024}MB`,
723
+ "FILE_TOO_LARGE"
724
+ );
725
+ }
726
+ }
619
727
  async function handleUploadMedia(adapter, input) {
620
728
  validateMimeType(input.mimeType);
729
+ validateFileSize(input.size);
621
730
  return adapter.upload(input);
622
731
  }
623
732
  async function handleGetMedia(adapter, id) {
@@ -871,6 +980,82 @@ function createAuthMiddleware(config) {
871
980
  };
872
981
  }
873
982
 
983
+ // src/auth/csrf.ts
984
+ function generateCsrfToken() {
985
+ const bytes = new Uint8Array(32);
986
+ crypto.getRandomValues(bytes);
987
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
988
+ }
989
+ function validateCsrfToken(cookieToken, headerToken) {
990
+ if (!cookieToken || !headerToken) {
991
+ return false;
992
+ }
993
+ if (cookieToken.trim() === "" || headerToken.trim() === "") {
994
+ return false;
995
+ }
996
+ return cookieToken === headerToken;
997
+ }
998
+
999
+ // src/auth/rate-limiter.ts
1000
+ function createRateLimiter(config) {
1001
+ const { windowMs, maxRequests } = config;
1002
+ const entries = /* @__PURE__ */ new Map();
1003
+ function cleanupEntry(entry, now) {
1004
+ const windowStart = now - windowMs;
1005
+ entry.timestamps = entry.timestamps.filter((ts) => ts > windowStart);
1006
+ }
1007
+ function scheduleCleanup(key) {
1008
+ const entry = entries.get(key);
1009
+ if (!entry) return;
1010
+ if (entry.timeoutId) {
1011
+ clearTimeout(entry.timeoutId);
1012
+ }
1013
+ entry.timeoutId = setTimeout(() => {
1014
+ const currentEntry = entries.get(key);
1015
+ if (currentEntry) {
1016
+ cleanupEntry(currentEntry, Date.now());
1017
+ if (currentEntry.timestamps.length === 0) {
1018
+ entries.delete(key);
1019
+ } else {
1020
+ scheduleCleanup(key);
1021
+ }
1022
+ }
1023
+ }, windowMs);
1024
+ }
1025
+ return {
1026
+ check(key) {
1027
+ const now = Date.now();
1028
+ let entry = entries.get(key);
1029
+ if (!entry) {
1030
+ entry = { timestamps: [] };
1031
+ entries.set(key, entry);
1032
+ }
1033
+ cleanupEntry(entry, now);
1034
+ if (entry.timestamps.length >= maxRequests) {
1035
+ const oldestTimestamp = entry.timestamps[0] ?? now;
1036
+ const retryAfterMs = oldestTimestamp + windowMs - now;
1037
+ const retryAfterSeconds = Math.ceil(retryAfterMs / 1e3);
1038
+ return {
1039
+ allowed: false,
1040
+ retryAfter: retryAfterSeconds
1041
+ };
1042
+ }
1043
+ entry.timestamps.push(now);
1044
+ scheduleCleanup(key);
1045
+ return {
1046
+ allowed: true
1047
+ };
1048
+ },
1049
+ reset(key) {
1050
+ const entry = entries.get(key);
1051
+ if (entry?.timeoutId) {
1052
+ clearTimeout(entry.timeoutId);
1053
+ }
1054
+ entries.delete(key);
1055
+ }
1056
+ };
1057
+ }
1058
+
874
1059
  // src/supabase/factory.ts
875
1060
  function readEnv(name) {
876
1061
  const processLike = globalThis.process;
@@ -1001,23 +1186,65 @@ async function handleExportSite(storageAdapter, mediaAdapter) {
1001
1186
  contentDisposition: contentDisposition("site-export.json")
1002
1187
  };
1003
1188
  }
1189
+
1190
+ // src/audit/logger.ts
1191
+ var defaultSink = (entry) => {
1192
+ const output = JSON.stringify(entry, null, 2);
1193
+ globalThis.console?.log?.(output);
1194
+ };
1195
+ function createAuditLogger(sink = defaultSink) {
1196
+ return {
1197
+ log: async (entry) => {
1198
+ await sink({
1199
+ ...entry,
1200
+ timestamp: /* @__PURE__ */ new Date()
1201
+ });
1202
+ }
1203
+ };
1204
+ }
1205
+ function withAuditLog(handler, options, sink) {
1206
+ const logger = createAuditLogger(sink);
1207
+ return (async (...args) => {
1208
+ const result = await handler(...args);
1209
+ await logger.log({
1210
+ action: options.action,
1211
+ entity: options.entity,
1212
+ entityId: options.extractEntityId(args),
1213
+ userId: options.extractUserId?.(args),
1214
+ metadata: options.metadata?.(args)
1215
+ });
1216
+ return result;
1217
+ });
1218
+ }
1004
1219
  export {
1220
+ ALLOWED_DOCUMENT_MIME_TYPES,
1005
1221
  ALLOWED_MIME_TYPES,
1222
+ ALL_ALLOWED_MIME_TYPES,
1006
1223
  AuthError,
1007
1224
  AuthValidationError,
1225
+ CreateNavigationSchema,
1226
+ CreatePageSchema,
1227
+ MAX_FILE_SIZE2 as MAX_FILE_SIZE,
1008
1228
  MediaError,
1229
+ MediaUploadSchema,
1009
1230
  MediaValidationError,
1231
+ SignInSchema,
1010
1232
  StorageError,
1011
1233
  StorageValidationError,
1012
1234
  SupabaseAuthAdapter,
1013
1235
  SupabaseMediaAdapter,
1014
1236
  SupabaseStorageAdapter,
1237
+ UpdateNavigationSchema,
1238
+ UpdatePageSchema,
1239
+ createAuditLogger,
1015
1240
  createAuthAdapter,
1016
1241
  createAuthMiddleware,
1017
1242
  createMediaAdapter,
1243
+ createRateLimiter,
1018
1244
  createStorageAdapter,
1019
1245
  createSupabaseAdapters,
1020
1246
  ensureUniqueSlug,
1247
+ generateCsrfToken,
1021
1248
  generateSlug,
1022
1249
  handleCreateNavigation,
1023
1250
  handleCreatePage,
@@ -1042,6 +1269,9 @@ export {
1042
1269
  handleUpdatePage,
1043
1270
  handleUploadMedia,
1044
1271
  handleVerifySession,
1045
- resolveMediaReferences
1272
+ resolveMediaReferences,
1273
+ stripTags,
1274
+ validateCsrfToken,
1275
+ withAuditLog
1046
1276
  };
1047
1277
  //# sourceMappingURL=index.js.map