@nexpress/core 0.3.7 → 0.3.8

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.
Files changed (94) hide show
  1. package/dist/{audit-43OLHR3U.js → audit-ZLNKBIDO.js} +3 -3
  2. package/dist/auth.js +4 -4
  3. package/dist/{can-UJ2NAOIR.js → can-U5F4JBZ7.js} +2 -2
  4. package/dist/{chunk-2GXH7566.js → chunk-2O2KMHLO.js} +10 -10
  5. package/dist/chunk-2O2KMHLO.js.map +1 -0
  6. package/dist/{chunk-ELK6AVW5.js → chunk-2X3GBJOT.js} +2 -2
  7. package/dist/{chunk-ML2E3P3X.js → chunk-5C22NDW4.js} +2 -2
  8. package/dist/chunk-5C22NDW4.js.map +1 -0
  9. package/dist/{chunk-RKM4GDWM.js → chunk-6MRTH734.js} +1 -1
  10. package/dist/chunk-6MRTH734.js.map +1 -0
  11. package/dist/{chunk-HNX7COHQ.js → chunk-6PFUXZJ6.js} +12 -12
  12. package/dist/chunk-6PFUXZJ6.js.map +1 -0
  13. package/dist/{chunk-MLXKZK6G.js → chunk-CD74WQK7.js} +76 -28
  14. package/dist/chunk-CD74WQK7.js.map +1 -0
  15. package/dist/{chunk-QBIJZZ5V.js → chunk-CGLJBRRX.js} +2 -2
  16. package/dist/chunk-CGLJBRRX.js.map +1 -0
  17. package/dist/{chunk-2VZZ7M26.js → chunk-EAYUAXW3.js} +3 -3
  18. package/dist/chunk-EAYUAXW3.js.map +1 -0
  19. package/dist/{chunk-2N53KKIL.js → chunk-EWVXP3GP.js} +2 -2
  20. package/dist/{chunk-CAS4Z6IN.js → chunk-I4FSVEJK.js} +1 -1
  21. package/dist/chunk-I4FSVEJK.js.map +1 -0
  22. package/dist/{chunk-OMGQZ4Q5.js → chunk-JKTU67A7.js} +2 -2
  23. package/dist/{chunk-OMGQZ4Q5.js.map → chunk-JKTU67A7.js.map} +1 -1
  24. package/dist/{chunk-LN6NTH6E.js → chunk-K4CJ3KXB.js} +3 -3
  25. package/dist/chunk-K4CJ3KXB.js.map +1 -0
  26. package/dist/{chunk-B7DTNT4O.js → chunk-MWLSXK6Y.js} +2 -2
  27. package/dist/{chunk-PW43RCJK.js → chunk-PPUHXOWZ.js} +2 -2
  28. package/dist/{chunk-NFHS7CFV.js → chunk-Q7MK5ZKG.js} +2 -2
  29. package/dist/{chunk-6UV2P5MW.js → chunk-TIWJVQOO.js} +3 -3
  30. package/dist/chunk-TIWJVQOO.js.map +1 -0
  31. package/dist/{chunk-L6VG7IK6.js → chunk-VBVLYFSZ.js} +2 -2
  32. package/dist/chunk-VBVLYFSZ.js.map +1 -0
  33. package/dist/{chunk-PUV3VZPD.js → chunk-VX3HM5TF.js} +2 -2
  34. package/dist/{chunk-RDTTK27V.js → chunk-XPD7EQML.js} +3 -3
  35. package/dist/chunk-XPD7EQML.js.map +1 -0
  36. package/dist/{chunk-RJ76SKWQ.js → chunk-XU2GJJ6Z.js} +1 -1
  37. package/dist/chunk-XU2GJJ6Z.js.map +1 -0
  38. package/dist/{chunk-WJJ5MBH5.js → chunk-YEOQJ7WW.js} +1 -1
  39. package/dist/chunk-YEOQJ7WW.js.map +1 -0
  40. package/dist/community.js +14 -14
  41. package/dist/{config-YHUEYQ66.js → config-2CV7KZ3D.js} +5 -5
  42. package/dist/{digest-ZODDTXA2.js → digest-IWHMJPXI.js} +4 -4
  43. package/dist/{host-XBGYIQEE.js → host-C5PGUXX7.js} +4 -4
  44. package/dist/i18n.js +2 -2
  45. package/dist/index.js +21 -21
  46. package/dist/index.js.map +1 -1
  47. package/dist/{job-log-N3IGI4NA.js → job-log-UY6ERPQZ.js} +3 -3
  48. package/dist/jobs.js +3 -3
  49. package/dist/{logger-2WUTTELV.js → logger-6ZGEKEMK.js} +2 -2
  50. package/dist/media.js +3 -3
  51. package/dist/{mentions-U4JACYI6.js → mentions-LQRZWAGO.js} +2 -2
  52. package/dist/{mutes-MNQP6ACF.js → mutes-PQA6U5X7.js} +2 -2
  53. package/dist/{notification-prefs-H4HFVCL7.js → notification-prefs-62NX2GBF.js} +2 -2
  54. package/dist/observability.js +2 -2
  55. package/dist/{reputation-ICIXDGPM.js → reputation-5DJLDBZY.js} +3 -3
  56. package/dist/{scheduled-S6IO47JD.js → scheduled-PF2HECSF.js} +5 -5
  57. package/dist/seo.js +4 -4
  58. package/dist/{settings-OZWM6L2K.js → settings-NBAP7E5E.js} +2 -2
  59. package/dist/{strings-4EWJYDOG.js → strings-O2M7VSKV.js} +3 -3
  60. package/package.json +1 -1
  61. package/dist/chunk-2GXH7566.js.map +0 -1
  62. package/dist/chunk-2VZZ7M26.js.map +0 -1
  63. package/dist/chunk-6UV2P5MW.js.map +0 -1
  64. package/dist/chunk-CAS4Z6IN.js.map +0 -1
  65. package/dist/chunk-HNX7COHQ.js.map +0 -1
  66. package/dist/chunk-L6VG7IK6.js.map +0 -1
  67. package/dist/chunk-LN6NTH6E.js.map +0 -1
  68. package/dist/chunk-ML2E3P3X.js.map +0 -1
  69. package/dist/chunk-MLXKZK6G.js.map +0 -1
  70. package/dist/chunk-QBIJZZ5V.js.map +0 -1
  71. package/dist/chunk-RDTTK27V.js.map +0 -1
  72. package/dist/chunk-RJ76SKWQ.js.map +0 -1
  73. package/dist/chunk-RKM4GDWM.js.map +0 -1
  74. package/dist/chunk-WJJ5MBH5.js.map +0 -1
  75. /package/dist/{audit-43OLHR3U.js.map → audit-ZLNKBIDO.js.map} +0 -0
  76. /package/dist/{can-UJ2NAOIR.js.map → can-U5F4JBZ7.js.map} +0 -0
  77. /package/dist/{chunk-ELK6AVW5.js.map → chunk-2X3GBJOT.js.map} +0 -0
  78. /package/dist/{chunk-2N53KKIL.js.map → chunk-EWVXP3GP.js.map} +0 -0
  79. /package/dist/{chunk-B7DTNT4O.js.map → chunk-MWLSXK6Y.js.map} +0 -0
  80. /package/dist/{chunk-PW43RCJK.js.map → chunk-PPUHXOWZ.js.map} +0 -0
  81. /package/dist/{chunk-NFHS7CFV.js.map → chunk-Q7MK5ZKG.js.map} +0 -0
  82. /package/dist/{chunk-PUV3VZPD.js.map → chunk-VX3HM5TF.js.map} +0 -0
  83. /package/dist/{config-YHUEYQ66.js.map → config-2CV7KZ3D.js.map} +0 -0
  84. /package/dist/{digest-ZODDTXA2.js.map → digest-IWHMJPXI.js.map} +0 -0
  85. /package/dist/{host-XBGYIQEE.js.map → host-C5PGUXX7.js.map} +0 -0
  86. /package/dist/{job-log-N3IGI4NA.js.map → job-log-UY6ERPQZ.js.map} +0 -0
  87. /package/dist/{logger-2WUTTELV.js.map → logger-6ZGEKEMK.js.map} +0 -0
  88. /package/dist/{mentions-U4JACYI6.js.map → mentions-LQRZWAGO.js.map} +0 -0
  89. /package/dist/{mutes-MNQP6ACF.js.map → mutes-PQA6U5X7.js.map} +0 -0
  90. /package/dist/{notification-prefs-H4HFVCL7.js.map → notification-prefs-62NX2GBF.js.map} +0 -0
  91. /package/dist/{reputation-ICIXDGPM.js.map → reputation-5DJLDBZY.js.map} +0 -0
  92. /package/dist/{scheduled-S6IO47JD.js.map → scheduled-PF2HECSF.js.map} +0 -0
  93. /package/dist/{settings-OZWM6L2K.js.map → settings-NBAP7E5E.js.map} +0 -0
  94. /package/dist/{strings-4EWJYDOG.js.map → strings-O2M7VSKV.js.map} +0 -0
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-OROPGO65.js";
4
4
  import {
5
5
  getLogger
6
- } from "./chunk-NFHS7CFV.js";
6
+ } from "./chunk-Q7MK5ZKG.js";
7
7
  import {
8
8
  getDb
9
9
  } from "./chunk-XANPEOJC.js";
@@ -78,4 +78,4 @@ export {
78
78
  pruneJobLogsOlderThan,
79
79
  countJobLogs
80
80
  };
81
- //# sourceMappingURL=chunk-QBIJZZ5V.js.map
81
+ //# sourceMappingURL=chunk-CGLJBRRX.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/jobs/job-log.ts"],"sourcesContent":["import { AsyncLocalStorage } from \"node:async_hooks\";\n\nimport { and, asc, eq, gte, lt } from \"drizzle-orm\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { readEnvPositiveInt } from \"../config/env.js\";\nimport { npJobLogs } from \"../db/schema/system.js\";\nimport { type NpLogLevel, getLogger } from \"../observability/logger.js\";\n\n/**\n * Phase 20.3 — per-job log capture.\n *\n * Each handler invocation runs inside an AsyncLocalStorage context\n * keyed on the pg-boss job id. While inside the context,\n * `recordJobLog()` writes to `np_job_logs` stamped with that id;\n * outside the context it no-ops, so the helper is safe to import\n * from non-handler code (and from plugins that don't know whether\n * they're inside a handler).\n *\n * The framework's pg-boss adapter sets the context automatically\n * (see `pg-boss-adapter.ts` — every `boss.work()` callback is\n * wrapped in `runInJobContext`). Handlers don't have to do\n * anything to opt in — calls to `recordJobLog()` just work.\n */\n\ninterface JobLogContext {\n jobId: string;\n}\n\nconst jobLogStorage = new AsyncLocalStorage<JobLogContext>();\n\nexport function runInJobContext<T>(jobId: string, fn: () => Promise<T> | T): Promise<T> | T {\n return jobLogStorage.run({ jobId }, fn);\n}\n\nexport function getCurrentJobId(): string | null {\n const store = jobLogStorage.getStore();\n return store?.jobId ?? null;\n}\n\n/**\n * Record one log entry for the currently-running job. Async because\n * it writes to Postgres; callers can `void` the promise if they\n * don't need to wait. No-ops outside a job context (returns\n * immediately without touching the DB).\n *\n * Errors writing to the log table are swallowed via the framework\n * logger at `warn` — a logging failure must never cascade into a\n * job failure or shutdown loop.\n */\nexport async function recordJobLog(\n level: NpLogLevel,\n message: string,\n context?: Record<string, unknown>,\n): Promise<void> {\n const jobId = getCurrentJobId();\n if (!jobId) return;\n\n try {\n const db = getDb();\n await db.insert(npJobLogs).values({\n jobId,\n level,\n message,\n context: context ?? null,\n });\n } catch (err) {\n // Don't throw from a logging path — just surface to whatever\n // sink the framework logger is wired to.\n getLogger().warn(\"recordJobLog failed\", {\n jobId,\n level,\n message,\n error: err instanceof Error ? err.message : String(err),\n });\n }\n}\n\nexport interface NpJobLogEntry {\n id: string;\n jobId: string;\n level: NpLogLevel;\n message: string;\n context: Record<string, unknown> | null;\n createdAt: Date;\n}\n\nexport interface ListJobLogsOptions {\n /** Cap on rows returned. Default 200, max 1000 to keep the admin UI snappy. */\n limit?: number;\n /** Skip this many rows for pagination. */\n offset?: number;\n}\n\n/**\n * Fetch log entries for one job in chronological order. Paged so\n * a runaway handler doesn't blow up the admin UI.\n */\nexport async function listJobLogs(\n jobId: string,\n options: ListJobLogsOptions = {},\n): Promise<NpJobLogEntry[]> {\n const limit = Math.min(Math.max(1, options.limit ?? 200), 1000);\n const offset = Math.max(0, options.offset ?? 0);\n const db = getDb();\n\n const rows = (await db\n .select()\n .from(npJobLogs)\n .where(eq(npJobLogs.jobId, jobId))\n .orderBy(asc(npJobLogs.createdAt))\n .limit(limit)\n .offset(offset)) as Array<{\n id: string;\n jobId: string;\n level: string;\n message: string;\n context: Record<string, unknown> | null;\n createdAt: Date;\n }>;\n\n return rows.map((row) => ({\n id: row.id,\n jobId: row.jobId,\n level: row.level as NpLogLevel,\n message: row.message,\n context: row.context,\n createdAt: row.createdAt,\n }));\n}\n\n/**\n * How long per-job log rows survive before the cleanup handler\n * deletes them. Compliance regimes (GDPR, SOX) frequently dictate\n * a specific window — override via `NP_JOB_LOG_RETENTION_DAYS`.\n */\nexport const DEFAULT_JOB_LOG_RETENTION_MS =\n readEnvPositiveInt(\"NP_JOB_LOG_RETENTION_DAYS\", 14) * 24 * 60 * 60 * 1000;\n\n/**\n * Delete log rows older than the cutoff. Safe to call from a\n * scheduled handler — does not touch logs for active or recent\n * jobs unless they pre-date the cutoff.\n *\n * Returns the row count deleted so the cron handler can log a\n * useful retention summary.\n */\nexport async function pruneJobLogsOlderThan(cutoff: Date): Promise<number> {\n const db = getDb();\n const deleted = (await db\n .delete(npJobLogs)\n .where(lt(npJobLogs.createdAt, cutoff))\n .returning({ id: npJobLogs.id })) as Array<{ id: string }>;\n return deleted.length;\n}\n\n/**\n * Count entries for a job — drives the admin badge \"37 log lines\"\n * without paying for the page payload until the operator expands.\n */\nexport async function countJobLogs(jobId: string, sinceCreatedAt?: Date): Promise<number> {\n const db = getDb();\n const where = sinceCreatedAt\n ? and(eq(npJobLogs.jobId, jobId), gte(npJobLogs.createdAt, sinceCreatedAt))\n : eq(npJobLogs.jobId, jobId);\n const rows = (await db.select({ id: npJobLogs.id }).from(npJobLogs).where(where)) as Array<{\n id: string;\n }>;\n return rows.length;\n}\n"],"mappings":";;;;;;;;;;;;;;AAAA,SAAS,yBAAyB;AAElC,SAAS,KAAK,KAAK,IAAI,KAAK,UAAU;AA2BtC,IAAM,gBAAgB,IAAI,kBAAiC;AAEpD,SAAS,gBAAmB,OAAe,IAA0C;AAC1F,SAAO,cAAc,IAAI,EAAE,MAAM,GAAG,EAAE;AACxC;AAEO,SAAS,kBAAiC;AAC/C,QAAM,QAAQ,cAAc,SAAS;AACrC,SAAO,OAAO,SAAS;AACzB;AAYA,eAAsB,aACpB,OACA,SACA,SACe;AACf,QAAM,QAAQ,gBAAgB;AAC9B,MAAI,CAAC,MAAO;AAEZ,MAAI;AACF,UAAM,KAAK,MAAM;AACjB,UAAM,GAAG,OAAO,SAAS,EAAE,OAAO;AAAA,MAChC;AAAA,MACA;AAAA,MACA;AAAA,MACA,SAAS,WAAW;AAAA,IACtB,CAAC;AAAA,EACH,SAAS,KAAK;AAGZ,cAAU,EAAE,KAAK,uBAAuB;AAAA,MACtC;AAAA,MACA;AAAA,MACA;AAAA,MACA,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAAA,IACxD,CAAC;AAAA,EACH;AACF;AAsBA,eAAsB,YACpB,OACA,UAA8B,CAAC,GACL;AAC1B,QAAM,QAAQ,KAAK,IAAI,KAAK,IAAI,GAAG,QAAQ,SAAS,GAAG,GAAG,GAAI;AAC9D,QAAM,SAAS,KAAK,IAAI,GAAG,QAAQ,UAAU,CAAC;AAC9C,QAAM,KAAK,MAAM;AAEjB,QAAM,OAAQ,MAAM,GACjB,OAAO,EACP,KAAK,SAAS,EACd,MAAM,GAAG,UAAU,OAAO,KAAK,CAAC,EAChC,QAAQ,IAAI,UAAU,SAAS,CAAC,EAChC,MAAM,KAAK,EACX,OAAO,MAAM;AAShB,SAAO,KAAK,IAAI,CAAC,SAAS;AAAA,IACxB,IAAI,IAAI;AAAA,IACR,OAAO,IAAI;AAAA,IACX,OAAO,IAAI;AAAA,IACX,SAAS,IAAI;AAAA,IACb,SAAS,IAAI;AAAA,IACb,WAAW,IAAI;AAAA,EACjB,EAAE;AACJ;AAOO,IAAM,+BACX,mBAAmB,6BAA6B,EAAE,IAAI,KAAK,KAAK,KAAK;AAUvE,eAAsB,sBAAsB,QAA+B;AACzE,QAAM,KAAK,MAAM;AACjB,QAAM,UAAW,MAAM,GACpB,OAAO,SAAS,EAChB,MAAM,GAAG,UAAU,WAAW,MAAM,CAAC,EACrC,UAAU,EAAE,IAAI,UAAU,GAAG,CAAC;AACjC,SAAO,QAAQ;AACjB;AAMA,eAAsB,aAAa,OAAe,gBAAwC;AACxF,QAAM,KAAK,MAAM;AACjB,QAAM,QAAQ,iBACV,IAAI,GAAG,UAAU,OAAO,KAAK,GAAG,IAAI,UAAU,WAAW,cAAc,CAAC,IACxE,GAAG,UAAU,OAAO,KAAK;AAC7B,QAAM,OAAQ,MAAM,GAAG,OAAO,EAAE,IAAI,UAAU,GAAG,CAAC,EAAE,KAAK,SAAS,EAAE,MAAM,KAAK;AAG/E,SAAO,KAAK;AACd;","names":[]}
@@ -6,7 +6,7 @@ import {
6
6
  } from "./chunk-OROPGO65.js";
7
7
  import {
8
8
  getLogger
9
- } from "./chunk-NFHS7CFV.js";
9
+ } from "./chunk-Q7MK5ZKG.js";
10
10
  import {
11
11
  getDb
12
12
  } from "./chunk-XANPEOJC.js";
@@ -170,7 +170,7 @@ async function uploadMedia(file, uploader, folderId) {
170
170
  return { id, status: "processing" };
171
171
  }
172
172
  async function assertMemberUploadQuota(memberId, txDb) {
173
- const { getCommunitySettings } = await import("./settings-OZWM6L2K.js");
173
+ const { getCommunitySettings } = await import("./settings-NBAP7E5E.js");
174
174
  const { NpRateLimitError } = await import("./errors-5OS3S2J3.js");
175
175
  const settings = await getCommunitySettings();
176
176
  const { perDay, total } = settings.memberUploadQuota;
@@ -535,4 +535,4 @@ export {
535
535
  listMedia,
536
536
  cleanupDeletedMedia
537
537
  };
538
- //# sourceMappingURL=chunk-2VZZ7M26.js.map
538
+ //# sourceMappingURL=chunk-EAYUAXW3.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/media/processor.ts","../src/media/service.ts"],"sourcesContent":["import sharp from \"sharp\";\n\nimport type { NpImageSize } from \"../config/types.js\";\n\nexport interface NpProcessedImageVariant {\n name: string;\n buffer: Buffer;\n width: number;\n height: number;\n size: number;\n}\n\nexport interface NpProcessedImageSourceMetadata {\n width: number | null;\n height: number | null;\n format: string | null;\n}\n\nexport interface NpProcessedImageResult {\n source: NpProcessedImageSourceMetadata;\n variants: NpProcessedImageVariant[];\n}\n\nexport const DEFAULT_IMAGE_SIZES: NpImageSize[] = [\n { name: \"thumbnail\", width: 300 },\n { name: \"small\", width: 600 },\n { name: \"medium\", width: 900 },\n { name: \"large\", width: 1400 },\n { name: \"xlarge\", width: 1920 },\n { name: \"og\", width: 1200, height: 630, crop: \"center\" },\n];\n\nexport async function processImage(\n inputBuffer: Buffer,\n sizes: NpImageSize[],\n options: { format?: string; quality?: number } = {},\n): Promise<NpProcessedImageResult> {\n const format = options.format ?? \"webp\";\n const quality = options.quality ?? 80;\n const sourceImage = sharp(inputBuffer).autoOrient();\n const metadata = await sourceImage.metadata();\n\n const variants = await Promise.all(\n sizes.map(async (size) => {\n const resized = size.height\n ? sourceImage.clone().resize({\n width: size.width,\n height: size.height,\n fit: \"cover\",\n position: resolveCropPosition(size.crop),\n })\n : sourceImage.clone().resize({\n width: size.width,\n fit: \"inside\",\n withoutEnlargement: true,\n });\n\n const formatted = applyFormat(resized, format, quality);\n const { data, info } = await formatted.toBuffer({ resolveWithObject: true });\n\n return {\n name: size.name,\n buffer: data,\n width: info.width,\n height: info.height,\n size: info.size ?? data.byteLength,\n };\n }),\n );\n\n return {\n source: {\n width: metadata.width ?? null,\n height: metadata.height ?? null,\n format: metadata.format ?? null,\n },\n variants,\n };\n}\n\nfunction applyFormat(\n image: sharp.Sharp,\n format: string,\n quality: number,\n): sharp.Sharp {\n switch (format) {\n case \"avif\":\n return image.avif({ quality });\n case \"jpeg\":\n return image.jpeg({ quality });\n case \"png\":\n return image.png({ quality });\n case \"webp\":\n default:\n return image.webp({ quality });\n }\n}\n\nfunction resolveCropPosition(crop?: NpImageSize[\"crop\"]): sharp.Gravity | number {\n switch (crop) {\n case \"top\":\n return \"top\";\n case \"bottom\":\n return \"bottom\";\n case \"left\":\n return \"left\";\n case \"right\":\n return \"right\";\n case \"center\":\n return \"centre\";\n default:\n return sharp.strategy.attention;\n }\n}\n","import { createHash, randomUUID } from \"node:crypto\";\nimport { extname } from \"node:path\";\nimport { buffer as consumeBuffer } from \"node:stream/consumers\";\nimport { Readable } from \"node:stream\";\n\nimport { and, count, desc, eq, gte, ilike, inArray, isNotNull, isNull, lt, or, sql } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\nimport type { PgTable } from \"drizzle-orm/pg-core\";\n\nimport type { NpFindResult, NpImageSize } from \"../config/types.js\";\nimport { readEnvPositiveInt } from \"../config/env.js\";\nimport { npMembers } from \"../db/schema/community.js\";\nimport { npMedia, npMediaRefs } from \"../db/schema/media.js\";\nimport { npUsers } from \"../db/schema/system.js\";\nimport { enqueueJob } from \"../jobs/queue.js\";\nimport { getLogger } from \"../observability/logger.js\";\nimport { getDb } from \"../db/runtime.js\";\nimport {\n DEFAULT_IMAGE_SIZES,\n processImage,\n type NpProcessedImageResult,\n} from \"./processor.js\";\nimport type { NpStorageAdapter } from \"../storage/types.js\";\n\n/**\n * Trailing-window for member upload quotas (`perDay` in\n * `npMemberUploadQuota`). Default 24h matches the historical\n * \"daily quota\" semantics; override via\n * `NP_MEMBER_QUOTA_WINDOW_HOURS` to shift to weekly or hourly\n * caps without touching code.\n */\nconst MEMBER_QUOTA_WINDOW_MS =\n readEnvPositiveInt(\"NP_MEMBER_QUOTA_WINDOW_HOURS\", 24) * 60 * 60 * 1000;\n\ninterface SelectQuery extends Promise<unknown[]> {\n where(condition: ReturnType<typeof and> | ReturnType<typeof isNull>): SelectQuery;\n orderBy(order: ReturnType<typeof desc>): SelectQuery;\n limit(limit: number): SelectQuery;\n offset(offset: number): SelectQuery;\n}\n\ninterface InsertValuesQuery extends Promise<unknown> {\n returning(): Promise<unknown[]>;\n}\n\ninterface DrizzleDatabaseLike {\n insert(table: PgTable): {\n values(values: Record<string, unknown> | Record<string, unknown>[]): InsertValuesQuery;\n };\n update(table: PgTable): {\n set(values: Record<string, unknown>): {\n where(condition: ReturnType<typeof and> | ReturnType<typeof eq>): {\n returning(): Promise<unknown[]>;\n };\n };\n };\n delete(table: PgTable): {\n where(condition: ReturnType<typeof inArray> ): Promise<unknown>;\n };\n select(selection?: Record<string, unknown>): {\n from(table: PgTable): SelectQuery;\n };\n}\n\ninterface MediaRecord {\n id: string;\n filename: string;\n originalFilename: string;\n mimeType: string;\n filesize: number;\n width: number | null;\n height: number | null;\n sizes: Record<string, Record<string, unknown>> | null;\n storageKey: string;\n hash: string;\n status: \"processing\" | \"ready\" | \"error\";\n folderId: string | null;\n uploadedBy: string | null;\n createdAt: Date;\n updatedAt: Date;\n deletedAt: Date | null;\n}\n\nlet storageAdapter: NpStorageAdapter | null = null;\n\nexport function setStorageAdapter(adapter: NpStorageAdapter): void {\n storageAdapter = adapter;\n}\n\nexport function getStorageAdapter(): NpStorageAdapter {\n if (!storageAdapter) {\n throw new Error(\"Storage adapter not initialized. Call setStorageAdapter() first.\");\n }\n\n return storageAdapter;\n}\n\n/**\n * Polymorphic uploader: a row on `np_media` is owned by exactly\n * one of staff (`uploadedBy` → `np_users.id`) or member\n * (`uploadedByMemberId` → `np_members.id`, Phase 9.7j). Pass a\n * `null` value as the second argument to `uploadMedia` for plugin /\n * system uploads with no human owner — both columns stay null and\n * the audit log carries the actor.\n */\nexport type NpMediaUploader =\n | { kind: \"staff\"; userId: string }\n | { kind: \"member\"; memberId: string }\n | null;\n\nexport async function uploadMedia(\n file: { buffer: Buffer; originalFilename: string; mimeType: string },\n uploader: NpMediaUploader | string,\n folderId?: string,\n): Promise<{ id: string; status: string }> {\n // Backwards-compat: the original signature was\n // `uploadMedia(file, userId: string | null, folderId?)`. Existing\n // callers (plugin context, admin bulk uploads, etc.) pass a bare\n // string. Coerce that into the staff variant of the polymorphic\n // shape so the rest of this function only deals with the union.\n const resolvedUploader: NpMediaUploader =\n typeof uploader === \"string\"\n ? { kind: \"staff\", userId: uploader }\n : uploader;\n\n const id = randomUUID();\n const extension = resolveFileExtension(file.originalFilename, file.mimeType);\n const storageKey = `media/${id}/original.${extension}`;\n const now = new Date();\n const insertValues = {\n id,\n filename: file.originalFilename,\n originalFilename: file.originalFilename,\n mimeType: file.mimeType,\n filesize: file.buffer.byteLength,\n storageKey,\n hash: createHash(\"sha256\").update(file.buffer).digest(\"hex\"),\n status: \"processing\" as const,\n folderId,\n uploadedBy:\n resolvedUploader && resolvedUploader.kind === \"staff\"\n ? resolvedUploader.userId\n : null,\n uploadedByMemberId:\n resolvedUploader && resolvedUploader.kind === \"member\"\n ? resolvedUploader.memberId\n : null,\n createdAt: now,\n updatedAt: now,\n };\n\n // Phase 9.7p: per-member upload quota. Staff uploads are never\n // gated. Phase 9.7p-followup (#120) — the count + insert must be\n // atomic per member, otherwise concurrent uploads can both\n // observe the same pre-insert count and both succeed past the\n // cap. Wrap the gated branch in a transaction holding a Postgres\n // advisory lock keyed on the member id; cross-member uploaders\n // don't contend (different lock keys), same-member concurrent\n // uploaders serialize and the second one sees the updated\n // count.\n //\n // Storage upload happens AFTER the DB row commits so the quota\n // count is correct before bytes touch storage. If the upload\n // fails (#138 follow-up), we hard-delete the just-inserted row\n // so it stops counting against quota and doesn't strand the\n // member with a permanent ghost. We do NOT just mark the row\n // `error` here — there's no storage object to inspect, no\n // processor will arrive (the job hasn't been enqueued yet),\n // and the quota count filters by `deletedAt IS NULL`, not\n // `status`. Hard delete is the right semantic.\n if (resolvedUploader && resolvedUploader.kind === \"member\") {\n const memberId = resolvedUploader.memberId;\n const dbPg = getDb();\n await dbPg.transaction(async (tx) => {\n // `pg_advisory_xact_lock` auto-releases on commit/rollback.\n // `hashtextextended` produces a stable int8 from a UUID\n // string — collisions across different member ids are\n // benign (worst case some unrelated members serialize).\n await tx.execute(\n sql`SELECT pg_advisory_xact_lock(hashtextextended(${memberId}, 0))`,\n );\n await assertMemberUploadQuota(memberId, tx);\n await tx.insert(npMedia).values(insertValues);\n });\n } else {\n const db = getDb() as unknown as DrizzleDatabaseLike;\n await db.insert(npMedia).values(insertValues);\n }\n\n const adapter = getStorageAdapter();\n try {\n await adapter.upload(storageKey, file.buffer, {\n contentType: file.mimeType,\n contentLength: file.buffer.byteLength,\n originalFilename: file.originalFilename,\n });\n } catch (err) {\n // Storage failed after the DB row committed. Roll the row\n // back so it doesn't (a) eat the member's quota allowance\n // for nothing, (b) confuse operators with a permanent\n // `processing` row that never gets a job. Cleanup is\n // best-effort — if the delete itself fails we still surface\n // the original storage error to the caller, since that's\n // what they need to act on.\n try {\n const cleanupDb = getDb() as unknown as DrizzleDatabaseLike;\n await cleanupDb.delete(npMedia).where(eq(npMedia.id, id));\n } catch (cleanupErr) {\n // Swallow so the original storage error reaches the\n // caller — that's what they need to act on. But don't go\n // silent: a failed cleanup leaves a permanent ghost row\n // in `processing` that eats the member's quota with no\n // storage object to inspect and no job ever enqueued.\n // Operators need a signal to find and remediate it.\n getLogger().error(\"media upload cleanup failed\", {\n mediaId: id,\n storageKey,\n error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr),\n });\n }\n throw err;\n }\n\n await enqueueJob(\"media:processImage\", { mediaId: id });\n\n return { id, status: \"processing\" };\n}\n\n/**\n * Throws `NpRateLimitError` (429) if the member is at or over\n * their per-day or lifetime upload cap. Both bounds count\n * non-deleted rows, so admin / member deletes free up quota the\n * same way (mirrors the 9.7l purge semantic). When both bounds\n * are `null` (the default), this function is a no-op aside from\n * a single settings read.\n *\n * Defer-loaded `getCommunitySettings` to avoid an import cycle\n * with `community/settings.ts` — that module reads `getDb()`,\n * which is wired by the same bootstrap that wires the media DB,\n * so they sit on the same module layer; deferring keeps a clean\n * one-way edge from media → community for this single call site.\n */\nasync function assertMemberUploadQuota(\n memberId: string,\n txDb?: NodePgDatabase<Record<string, unknown>>,\n): Promise<void> {\n const { getCommunitySettings } = await import(\n \"../community/settings.js\"\n );\n const { NpRateLimitError } = await import(\"../errors.js\");\n const settings = await getCommunitySettings();\n const { perDay, total } = settings.memberUploadQuota;\n if (perDay === null && total === null) return;\n\n // When invoked inside the upload transaction (#120 fix), the\n // count + downstream insert run under the same advisory lock,\n // so the count must use the tx handle to see writes by sibling\n // statements. When called from elsewhere we fall back to the\n // shared media DB.\n const db =\n txDb ??\n (getDb());\n\n if (total !== null) {\n const [row] = (await db\n .select({ value: count() })\n .from(npMedia)\n .where(\n and(\n eq(npMedia.uploadedByMemberId, memberId),\n isNull(npMedia.deletedAt),\n ),\n )) as Array<{ value: number }>;\n const used = row?.value ?? 0;\n if (used >= total) {\n throw new NpRateLimitError(\n `Upload quota exceeded — this account has reached its lifetime cap of ${total} uploads.`,\n );\n }\n }\n\n if (perDay !== null) {\n const since = new Date(Date.now() - MEMBER_QUOTA_WINDOW_MS);\n const [row] = (await db\n .select({ value: count() })\n .from(npMedia)\n .where(\n and(\n eq(npMedia.uploadedByMemberId, memberId),\n isNull(npMedia.deletedAt),\n gte(npMedia.createdAt, since),\n ),\n )) as Array<{ value: number }>;\n const recent = row?.value ?? 0;\n if (recent >= perDay) {\n throw new NpRateLimitError(\n `Upload rate limit exceeded — try again later (max ${perDay} uploads per 24 hours).`,\n );\n }\n }\n}\n\nexport async function processMediaImage(\n mediaId: string,\n config: { sizes?: NpImageSize[]; format?: string; quality?: number },\n): Promise<void> {\n const db = getDb() as unknown as DrizzleDatabaseLike;\n const adapter = getStorageAdapter();\n const media = await getMediaRecordById(mediaId);\n\n if (!media) {\n throw new Error(`Media '${mediaId}' not found.`);\n }\n\n try {\n const originalStream = await adapter.getStream(media.storageKey);\n const originalBuffer = await consumeBuffer(Readable.fromWeb(originalStream));\n const processed = await processImage(\n originalBuffer,\n config.sizes ?? DEFAULT_IMAGE_SIZES,\n { format: config.format, quality: config.quality },\n );\n const format = config.format ?? \"webp\";\n const mimeType = getFormatMimeType(format);\n const sizes = await uploadImageVariants(adapter, media.id, processed, format, mimeType);\n\n await db\n .update(npMedia)\n .set({\n sizes,\n width: processed.source.width,\n height: processed.source.height,\n status: \"ready\",\n updatedAt: new Date(),\n })\n .where(eq(npMedia.id, media.id))\n .returning();\n } catch (error) {\n await db\n .update(npMedia)\n .set({\n status: \"error\",\n updatedAt: new Date(),\n })\n .where(eq(npMedia.id, media.id))\n .returning();\n\n throw error;\n }\n}\n\nexport async function getMediaById(id: string): Promise<Record<string, unknown> | null> {\n const db = getDb() as unknown as DrizzleDatabaseLike;\n const [media] = await db\n .select()\n .from(npMedia)\n .where(and(eq(npMedia.id, id), isNull(npMedia.deletedAt)))\n .limit(1);\n\n return media ? toRecord(media) : null;\n}\n\nexport async function deleteMedia(\n id: string,\n): Promise<{ deleted: boolean; references?: unknown[] }> {\n const db = getDb() as unknown as DrizzleDatabaseLike;\n const references = await db.select().from(npMediaRefs).where(eq(npMediaRefs.mediaId, id));\n\n if (references.length > 0) {\n return { deleted: false, references };\n }\n\n const [media] = await db\n .select()\n .from(npMedia)\n .where(and(eq(npMedia.id, id), isNull(npMedia.deletedAt)))\n .limit(1);\n\n if (!media) {\n return { deleted: false };\n }\n\n await db\n .update(npMedia)\n .set({\n deletedAt: new Date(),\n updatedAt: new Date(),\n })\n .where(eq(npMedia.id, id))\n .returning();\n\n return { deleted: true };\n}\n\n/**\n * Phase 9.7k uploader filters. `uploaderKind` partitions the\n * library into staff-uploaded rows (`uploaded_by IS NOT NULL`) vs\n * member-uploaded rows (`uploaded_by_member_id IS NOT NULL`) — the\n * two columns are mutually exclusive on every row written through\n * `uploadMedia`. `uploadedByMemberId` narrows to a specific member\n * for \"show me everything @handle uploaded\" investigations after a\n * spam wave.\n */\nexport type NpMediaUploaderKindFilter = \"staff\" | \"member\";\n\nexport async function listMedia(options: {\n page?: number;\n limit?: number;\n folderId?: string;\n mimeType?: string;\n uploaderKind?: NpMediaUploaderKindFilter;\n uploadedByMemberId?: string;\n /**\n * Substring match against `filename` and `alt`. Matches\n * server-side via `ILIKE`, so the page-builder block-image\n * picker can search the whole library without paging through\n * every result client-side. Empty / whitespace-only `q` is\n * treated as no filter.\n */\n q?: string;\n}): Promise<NpFindResult> {\n const db = getDb() as unknown as DrizzleDatabaseLike;\n const page = normalizePage(options.page);\n const limit = normalizeLimit(options.limit);\n const offset = (page - 1) * limit;\n const conditions = [isNull(npMedia.deletedAt)];\n\n if (options.folderId) {\n conditions.push(eq(npMedia.folderId, options.folderId));\n }\n\n if (options.mimeType) {\n conditions.push(eq(npMedia.mimeType, options.mimeType));\n }\n\n if (options.uploaderKind === \"staff\") {\n conditions.push(isNotNull(npMedia.uploadedBy));\n } else if (options.uploaderKind === \"member\") {\n conditions.push(isNotNull(npMedia.uploadedByMemberId));\n }\n\n if (options.uploadedByMemberId) {\n conditions.push(eq(npMedia.uploadedByMemberId, options.uploadedByMemberId));\n }\n\n // Substring search across filename + alt. We match `ILIKE\n // %q%` against both columns and OR them so the picker's\n // search box hits filenames the operator remembers and alt\n // text they wrote. SQL escapes the literal `%` / `_` chars\n // by doubling them so a filename containing them isn't\n // treated as a wildcard.\n if (options.q && options.q.trim().length > 0) {\n const needle = `%${options.q.trim().replace(/[%_]/g, (c) => `\\\\${c}`)}%`;\n const search = or(\n ilike(npMedia.filename, needle),\n ilike(npMedia.alt, needle),\n );\n if (search) conditions.push(search);\n }\n\n const whereClause = combineConditions(conditions);\n // The local `DrizzleDatabaseLike` interface in this file is\n // narrow on purpose (only `select/insert/update/delete`); a\n // proper leftJoin chain would require typing the full Drizzle\n // builder pipeline. Cast through `unknown` for this query —\n // safer than widening the interface and dragging join semantics\n // into every other media call site.\n const joined = (db as unknown as {\n select: (s: Record<string, unknown>) => {\n from: (t: PgTable) => {\n leftJoin: (j: PgTable, c: unknown) => {\n leftJoin: (j: PgTable, c: unknown) => {\n where: (c: unknown) => {\n orderBy: (o: unknown) => {\n limit: (n: number) => {\n offset: (n: number) => Promise<Array<Record<string, unknown>>>;\n };\n };\n };\n };\n };\n };\n };\n })\n .select({\n media: npMedia,\n userName: npUsers.name,\n userEmail: npUsers.email,\n memberHandle: npMembers.handle,\n memberDisplayName: npMembers.displayName,\n })\n .from(npMedia)\n .leftJoin(npUsers, eq(npMedia.uploadedBy, npUsers.id))\n .leftJoin(npMembers, eq(npMedia.uploadedByMemberId, npMembers.id))\n .where(whereClause)\n .orderBy(desc(npMedia.createdAt))\n .limit(limit)\n .offset(offset);\n\n const rows = (await joined) as Array<{\n media: Record<string, unknown>;\n userName: string | null;\n userEmail: string | null;\n memberHandle: string | null;\n memberDisplayName: string | null;\n }>;\n const [{ total }] = (whereClause\n ? await db.select({ total: count() }).from(npMedia).where(whereClause)\n : await db.select({ total: count() }).from(npMedia)) as Array<{ total: number | string }>;\n const totalDocs = Number(total ?? 0);\n const totalPages = totalDocs === 0 ? 0 : Math.ceil(totalDocs / limit);\n\n // Flatten the JOIN result so each doc carries an `uploader`\n // sub-object alongside the standard media columns. Keeps the\n // shape backwards-compatible (the existing media columns are\n // still at the top level).\n const docs = rows.map((row) => ({\n ...row.media,\n uploader: row.userName !== null\n ? {\n kind: \"staff\" as const,\n name: row.userName,\n email: row.userEmail,\n }\n : row.memberHandle !== null\n ? {\n kind: \"member\" as const,\n handle: row.memberHandle,\n displayName: row.memberDisplayName,\n }\n : null,\n }));\n\n return {\n docs: docs,\n totalDocs,\n totalPages,\n page,\n limit,\n hasNextPage: page < totalPages,\n hasPrevPage: page > 1 && totalDocs > 0,\n };\n}\n\nexport async function cleanupDeletedMedia(olderThanDays: number): Promise<number> {\n const db = getDb() as unknown as DrizzleDatabaseLike;\n const adapter = getStorageAdapter();\n const threshold = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000);\n const rows = await db\n .select()\n .from(npMedia)\n .where(and(isNotNull(npMedia.deletedAt), lt(npMedia.deletedAt, threshold)));\n const mediaRows = rows.map(toMediaRecord);\n\n if (mediaRows.length === 0) {\n return 0;\n }\n\n for (const media of mediaRows) {\n const keys = new Set<string>([\n media.storageKey,\n ...extractVariantStorageKeys(media.sizes),\n ]);\n\n for (const key of keys) {\n try {\n await adapter.delete(key);\n } catch {\n continue;\n }\n }\n }\n\n await db.delete(npMedia).where(inArray(npMedia.id, mediaRows.map((media) => media.id)));\n\n return mediaRows.length;\n}\n\nasync function getMediaRecordById(id: string): Promise<MediaRecord | null> {\n const db = getDb() as unknown as DrizzleDatabaseLike;\n const [media] = await db\n .select()\n .from(npMedia)\n .where(and(eq(npMedia.id, id), isNull(npMedia.deletedAt)))\n .limit(1);\n\n return media ? toMediaRecord(media) : null;\n}\n\nasync function uploadImageVariants(\n adapter: NpStorageAdapter,\n mediaId: string,\n processed: NpProcessedImageResult,\n format: string,\n mimeType: string,\n): Promise<Record<string, Record<string, unknown>>> {\n const entries = await Promise.all(\n processed.variants.map(async (variant) => {\n const filename = `${variant.name}.${format}`;\n const storageKey = `media/${mediaId}/${filename}`;\n\n await adapter.upload(storageKey, variant.buffer, {\n contentType: mimeType,\n contentLength: variant.size,\n originalFilename: filename,\n });\n\n return [\n variant.name,\n {\n filename,\n mimeType,\n filesize: variant.size,\n width: variant.width,\n height: variant.height,\n storageKey,\n url: await adapter.getUrl(storageKey),\n },\n ] as const;\n }),\n );\n\n return Object.fromEntries(entries);\n}\n\nfunction extractVariantStorageKeys(\n sizes: Record<string, Record<string, unknown>> | null,\n): string[] {\n if (!sizes) {\n return [];\n }\n\n return Object.values(sizes)\n .map((size) => size.storageKey)\n .filter((value): value is string => typeof value === \"string\" && value.length > 0);\n}\n\nfunction resolveFileExtension(originalFilename: string, mimeType: string): string {\n const extension = extname(originalFilename).slice(1).toLowerCase();\n\n if (extension) {\n return extension;\n }\n\n switch (mimeType) {\n case \"image/jpeg\":\n return \"jpg\";\n case \"image/png\":\n return \"png\";\n case \"image/webp\":\n return \"webp\";\n case \"image/avif\":\n return \"avif\";\n case \"image/gif\":\n return \"gif\";\n case \"application/pdf\":\n return \"pdf\";\n default:\n return \"bin\";\n }\n}\n\nfunction getFormatMimeType(format: string): string {\n switch (format) {\n case \"avif\":\n return \"image/avif\";\n case \"jpeg\":\n return \"image/jpeg\";\n case \"png\":\n return \"image/png\";\n case \"webp\":\n default:\n return \"image/webp\";\n }\n}\n\nfunction combineConditions(\n conditions: Array<ReturnType<typeof and> | ReturnType<typeof isNull> >,\n): ReturnType<typeof and> | ReturnType<typeof isNull> | undefined {\n if (conditions.length === 0) {\n return undefined;\n }\n\n if (conditions.length === 1) {\n return conditions[0];\n }\n\n return and(...conditions);\n}\n\nfunction normalizePage(page?: number): number {\n if (!page || page < 1) {\n return 1;\n }\n\n return Math.floor(page);\n}\n\nfunction normalizeLimit(limit?: number): number {\n if (!limit || limit < 1) {\n return 10;\n }\n\n return Math.floor(limit);\n}\n\nfunction toMediaRecord(value: unknown): MediaRecord {\n const record = toRecord(value);\n\n return {\n id: asString(record.id, \"id\"),\n filename: asString(record.filename, \"filename\"),\n originalFilename: asString(record.originalFilename, \"originalFilename\"),\n mimeType: asString(record.mimeType, \"mimeType\"),\n filesize: asNumber(record.filesize, \"filesize\"),\n width: asNullableNumber(record.width),\n height: asNullableNumber(record.height),\n sizes: asSizes(record.sizes),\n storageKey: asString(record.storageKey, \"storageKey\"),\n hash: asString(record.hash, \"hash\"),\n status: asMediaStatus(record.status),\n folderId: asNullableString(record.folderId),\n uploadedBy: asNullableString(record.uploadedBy),\n createdAt: asDate(record.createdAt, \"createdAt\"),\n updatedAt: asDate(record.updatedAt, \"updatedAt\"),\n deletedAt: asNullableDate(record.deletedAt),\n };\n}\n\nfunction asSizes(value: unknown): Record<string, Record<string, unknown>> | null {\n if (value == null) {\n return null;\n }\n\n const record = toRecord(value);\n const sizes: Record<string, Record<string, unknown>> = {};\n\n for (const [key, entry] of Object.entries(record)) {\n const sizeRecord = toRecord(entry);\n sizes[key] = sizeRecord;\n }\n\n return sizes;\n}\n\nfunction asMediaStatus(value: unknown): MediaRecord[\"status\"] {\n if (value === \"processing\" || value === \"ready\" || value === \"error\") {\n return value;\n }\n\n throw new Error(\"Invalid media status.\");\n}\n\nfunction asString(value: unknown, field: string): string {\n if (typeof value !== \"string\" || value.length === 0) {\n throw new Error(`Invalid ${field}.`);\n }\n\n return value;\n}\n\nfunction asNullableString(value: unknown): string | null {\n if (value == null) {\n return null;\n }\n\n return asString(value, \"string field\");\n}\n\nfunction asNumber(value: unknown, field: string): number {\n if (typeof value !== \"number\") {\n throw new Error(`Invalid ${field}.`);\n }\n\n return value;\n}\n\nfunction asNullableNumber(value: unknown): number | null {\n if (value == null) {\n return null;\n }\n\n return asNumber(value, \"number field\");\n}\n\nfunction asDate(value: unknown, field: string): Date {\n if (!(value instanceof Date)) {\n throw new Error(`Invalid ${field}.`);\n }\n\n return value;\n}\n\nfunction asNullableDate(value: unknown): Date | null {\n if (value == null) {\n return null;\n }\n\n return asDate(value, \"date field\");\n}\n\nfunction toRecord(value: unknown): Record<string, unknown> {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) {\n throw new Error(\"Expected object record.\");\n }\n\n return value as Record<string, unknown>;\n}\n"],"mappings":"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","names":[]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  getStorageAdapter
3
- } from "./chunk-2VZZ7M26.js";
3
+ } from "./chunk-EAYUAXW3.js";
4
4
  import {
5
5
  getDb
6
6
  } from "./chunk-XANPEOJC.js";
@@ -38,4 +38,4 @@ async function getMediaUrl(id, options = {}) {
38
38
  export {
39
39
  getMediaUrl
40
40
  };
41
- //# sourceMappingURL=chunk-2N53KKIL.js.map
41
+ //# sourceMappingURL=chunk-EWVXP3GP.js.map
@@ -172,4 +172,4 @@ export {
172
172
  recordDigestSent,
173
173
  isNotificationKindEnabled
174
174
  };
175
- //# sourceMappingURL=chunk-CAS4Z6IN.js.map
175
+ //# sourceMappingURL=chunk-I4FSVEJK.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/community/notification-prefs.ts"],"sourcesContent":["import { eq } from \"drizzle-orm\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npMembers } from \"../db/schema/community.js\";\nimport { NpNotFoundError, NpValidationError } from \"../errors.js\";\n\n/**\n * Phase 16.3 — per-member notification preferences.\n *\n * The persisted shape is a JSONB blob on `np_members.notification_prefs`\n * so adding fields (digest cadence in 16.4, channel toggles later)\n * stays a typescript-only change. Today we honor:\n *\n * - `disabled: string[]` — kinds the member opted out of. The\n * `createNotification` gate consults this and silently drops\n * the row. Default empty (= every kind enabled).\n *\n * The vocabulary of `kinds` is defined here so the UI has a single\n * source of truth — settings page renders a toggle for each entry,\n * and the API only accepts kinds that appear in the list (so a\n * forged client can't disable arbitrary strings to bloat the JSONB).\n */\n\nexport interface NpNotificationKindMeta {\n kind: string;\n /** Short human label. */\n label: string;\n /** Description rendered next to the toggle. */\n description: string;\n}\n\n/**\n * Closed vocabulary of toggle-able kinds. New notification kinds\n * land here when they ship; plugins that want their own\n * preferences register entries via `registerNotificationKind`.\n */\nconst builtinKinds: NpNotificationKindMeta[] = [\n {\n kind: \"comment.reply\",\n label: \"Replies\",\n description: \"Someone replied to one of your comments.\",\n },\n {\n kind: \"comment.mention\",\n label: \"Mentions in comments\",\n description: \"Someone @-mentioned you in a comment.\",\n },\n {\n kind: \"document.mention\",\n label: \"Mentions in discussions\",\n description: \"Someone @-mentioned you in a discussion / thread.\",\n },\n {\n kind: \"reaction.received\",\n label: \"Reactions\",\n description: \"Someone reacted to your comment or document.\",\n },\n {\n kind: \"follow.received\",\n label: \"New followers\",\n description: \"Someone started following you.\",\n },\n];\n\nconst dynamicKinds: NpNotificationKindMeta[] = [];\n\n/** Plugin-extensible registration. Idempotent on `kind`. */\nexport function registerNotificationKind(meta: NpNotificationKindMeta): void {\n if (builtinKinds.some((k) => k.kind === meta.kind)) return;\n const idx = dynamicKinds.findIndex((k) => k.kind === meta.kind);\n if (idx >= 0) {\n dynamicKinds[idx] = meta;\n } else {\n dynamicKinds.push(meta);\n }\n}\n\n/** Returns the union of builtin + plugin-registered kinds. */\nexport function listNotificationKinds(): NpNotificationKindMeta[] {\n return [...builtinKinds, ...dynamicKinds];\n}\n\nexport type NpDigestCadence = \"off\" | \"daily\" | \"weekly\";\n\nconst DIGEST_CADENCES: readonly NpDigestCadence[] = [\"off\", \"daily\", \"weekly\"] as const;\n\nexport interface NpNotificationPrefs {\n /** Kinds the member opted out of. Empty / missing = all kinds enabled. */\n disabled: string[];\n /**\n * Phase 16.4 — email digest cadence. `off` (default) disables\n * the digest. `daily` and `weekly` opt the member into a\n * batched email of unread notifications, scheduled by the\n * `notifications:sendDigest` recurring job.\n */\n digest: NpDigestCadence;\n /**\n * Set when the digest sweep last sent an email to this member.\n * Used to scope each digest to \"unread since the last send\" so\n * members aren't repeatedly emailed about the same row. Stored\n * as ISO-8601 string in the JSONB blob; `null` for accounts\n * that have never received a digest.\n *\n * Issue #218 — superseded by `lastDigestAtBySite` once a member\n * receives a digest under the per-site fan-out path. The legacy\n * field is preserved for forward-compat reads (single-site\n * deploys still see + write it via the fallback chain) and as\n * a \"any digest, ever?\" marker for analytics.\n */\n lastDigestAt: string | null;\n /**\n * Issue #218 — per-(site, cadence) timestamp map. Replaces the\n * single `lastDigestAt` for multi-site deployments. Empty when\n * the member has never received a digest under the site-scoped\n * sweep.\n */\n lastDigestAtBySite: Record<string, Partial<Record<NpDigestCadence, string>>>;\n}\n\nconst EMPTY_PREFS: NpNotificationPrefs = {\n disabled: [],\n digest: \"off\",\n lastDigestAt: null,\n lastDigestAtBySite: {},\n};\n\nfunction normalizeDigest(raw: unknown): NpDigestCadence {\n return DIGEST_CADENCES.includes(raw as NpDigestCadence) ? (raw as NpDigestCadence) : \"off\";\n}\n\nfunction normalizeLastDigestAt(raw: unknown): string | null {\n return typeof raw === \"string\" && raw.length > 0 ? raw : null;\n}\n\nfunction normalizeLastDigestBySite(\n raw: unknown,\n): Record<string, Partial<Record<NpDigestCadence, string>>> {\n if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) return {};\n const out: Record<string, Partial<Record<NpDigestCadence, string>>> = {};\n for (const [siteId, value] of Object.entries(raw as Record<string, unknown>)) {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) continue;\n const inner: Partial<Record<NpDigestCadence, string>> = {};\n for (const [cadence, ts] of Object.entries(value as Record<string, unknown>)) {\n if (!DIGEST_CADENCES.includes(cadence as NpDigestCadence)) continue;\n if (typeof ts === \"string\" && ts.length > 0) {\n inner[cadence as NpDigestCadence] = ts;\n }\n }\n if (Object.keys(inner).length > 0) out[siteId] = inner;\n }\n return out;\n}\n\nfunction normalizePrefs(raw: unknown): NpNotificationPrefs {\n if (!raw || typeof raw !== \"object\") return { ...EMPTY_PREFS, lastDigestAtBySite: {} };\n const obj = raw as Record<string, unknown>;\n const disabled = Array.isArray(obj.disabled)\n ? obj.disabled.filter((k): k is string => typeof k === \"string\")\n : [];\n return {\n disabled,\n digest: normalizeDigest(obj.digest),\n lastDigestAt: normalizeLastDigestAt(obj.lastDigestAt),\n lastDigestAtBySite: normalizeLastDigestBySite(obj.lastDigestAtBySite),\n };\n}\n\nexport async function getMemberNotificationPrefs(memberId: string): Promise<NpNotificationPrefs> {\n const db = getDb();\n const [row] = (await db\n .select({ prefs: npMembers.notificationPrefs })\n .from(npMembers)\n .where(eq(npMembers.id, memberId))\n .limit(1)) as Array<{ prefs: Record<string, unknown> }>;\n if (!row) throw new NpNotFoundError(\"member\", memberId);\n return normalizePrefs(row.prefs);\n}\n\nexport interface SetMemberNotificationPrefsInput {\n memberId: string;\n /**\n * Replacement deny-list. Only kinds listed in\n * `listNotificationKinds()` are accepted; unknown strings\n * raise NpValidationError so a forged client can't bloat the\n * JSONB or hide future framework kinds via a stale list.\n * Optional — when omitted the existing list is preserved.\n */\n disabled?: string[];\n /**\n * Phase 16.4 — email digest cadence. Optional; when omitted\n * the existing setting is preserved. `off` clears the\n * member's enrollment.\n */\n digest?: NpDigestCadence;\n}\n\nexport async function setMemberNotificationPrefs(\n input: SetMemberNotificationPrefsInput,\n): Promise<NpNotificationPrefs> {\n const known = new Set(listNotificationKinds().map((k) => k.kind));\n let cleanedDisabled: string[] | undefined;\n if (input.disabled !== undefined) {\n cleanedDisabled = [];\n const seen = new Set<string>();\n for (const raw of input.disabled) {\n if (typeof raw !== \"string\") {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"disabled\", message: \"Each entry must be a string\" },\n ]);\n }\n if (!known.has(raw)) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"disabled\", message: `Unknown notification kind: ${raw}` },\n ]);\n }\n if (seen.has(raw)) continue;\n seen.add(raw);\n cleanedDisabled.push(raw);\n }\n }\n if (input.digest !== undefined && !DIGEST_CADENCES.includes(input.digest)) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"digest\",\n message: `digest must be one of: ${DIGEST_CADENCES.join(\", \")}`,\n },\n ]);\n }\n const db = getDb();\n\n // Read-then-merge so we don't clobber other JSONB keys\n // (lastDigestAt, future channel toggles, etc.).\n const [existing] = (await db\n .select({ prefs: npMembers.notificationPrefs })\n .from(npMembers)\n .where(eq(npMembers.id, input.memberId))\n .limit(1)) as Array<{ prefs: Record<string, unknown> }>;\n if (!existing) throw new NpNotFoundError(\"member\", input.memberId);\n\n const merged: Record<string, unknown> = { ...(existing.prefs ?? {}) };\n if (cleanedDisabled !== undefined) merged.disabled = cleanedDisabled;\n if (input.digest !== undefined) merged.digest = input.digest;\n\n await db\n .update(npMembers)\n .set({ notificationPrefs: merged, updatedAt: new Date() })\n .where(eq(npMembers.id, input.memberId));\n\n return normalizePrefs(merged);\n}\n\n/**\n * Phase 16.4 — bookkeeping helper called by the digest sweep\n * after a successful email send. Stamps `lastDigestAt` so the\n * next run scopes its query to the correct window. Read-merge\n * to preserve other JSONB keys.\n *\n * Issue #218 — when a `siteId` + `cadence` pair is supplied,\n * the per-site / per-cadence map is updated so the next sweep\n * for that tenant scopes to the correct \"since\" window. The\n * legacy single `lastDigestAt` field is also stamped for\n * forward-compat with single-site deploys (and as a \"received\n * any digest, ever?\" marker for analytics).\n */\nexport async function recordDigestSent(\n memberId: string,\n sentAt: Date,\n scope?: { siteId: string; cadence: NpDigestCadence },\n): Promise<void> {\n const db = getDb();\n const [existing] = (await db\n .select({ prefs: npMembers.notificationPrefs })\n .from(npMembers)\n .where(eq(npMembers.id, memberId))\n .limit(1)) as Array<{ prefs: Record<string, unknown> }>;\n if (!existing) return;\n const prior = existing.prefs ?? {};\n const merged: Record<string, unknown> = {\n ...prior,\n lastDigestAt: sentAt.toISOString(),\n };\n if (scope) {\n const priorBySite = normalizeLastDigestBySite(\n (prior as { lastDigestAtBySite?: unknown }).lastDigestAtBySite,\n );\n const siteSlot = { ...(priorBySite[scope.siteId] ?? {}) };\n siteSlot[scope.cadence] = sentAt.toISOString();\n merged.lastDigestAtBySite = { ...priorBySite, [scope.siteId]: siteSlot };\n }\n await db\n .update(npMembers)\n .set({ notificationPrefs: merged, updatedAt: new Date() })\n .where(eq(npMembers.id, memberId));\n}\n\n/**\n * Inbox-side gate consulted by `createNotification`. Returns\n * `false` when the recipient explicitly opted out of `kind`.\n * Errors fail-open (return `true`) so a transient DB blip\n * doesn't silently swallow notifications.\n */\nexport async function isNotificationKindEnabled(memberId: string, kind: string): Promise<boolean> {\n try {\n const prefs = await getMemberNotificationPrefs(memberId);\n return !prefs.disabled.includes(kind);\n } catch {\n return true;\n }\n}\n"],"mappings":"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","names":[]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  getPluginRegistration
3
- } from "./chunk-MLXKZK6G.js";
3
+ } from "./chunk-CD74WQK7.js";
4
4
  import {
5
5
  getCurrentSiteId
6
6
  } from "./chunk-SBCVAC2Z.js";
@@ -319,4 +319,4 @@ export {
319
319
  setPluginConfig,
320
320
  pluginConfigCacheTag
321
321
  };
322
- //# sourceMappingURL=chunk-OMGQZ4Q5.js.map
322
+ //# sourceMappingURL=chunk-JKTU67A7.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/plugins/config.ts","../src/themes/settings-schema.ts"],"sourcesContent":["import { and, eq } from \"drizzle-orm\";\nimport type { ZodTypeAny } from \"zod\";\n\nimport { getDb } from \"../db/index.js\";\nimport { npSettings } from \"../db/schema/system.js\";\nimport { NpValidationError } from \"../errors.js\";\nimport { getCurrentSiteId } from \"../sites/context.js\";\nimport { getPluginRegistration } from \"./host.js\";\nimport {\n introspectThemeSettingsSchema,\n type NpThemeSettingsField,\n} from \"../themes/settings-schema.js\";\n\nconst DEFAULT_SITE = \"default\";\nconst CONFIG_KEY_PREFIX = \"plugin.config:\";\n\n/**\n * G.1 — per-plugin operator config.\n *\n * Stored at `np_settings.(site_id, key=\"plugin.config:<pluginId>\")`.\n * Mirrors theme settings storage exactly, including the `__npVersion` /\n * `__npSettings` envelope, so a future shared `getCachedSetting<T>(key)`\n * helper can read both surfaces. Cache invalidation rides a new\n * `np:plugin:<id>` tag (see `packages/next/src/cache.ts`).\n *\n * Per locked decision E (`docs/design/plugin-config-auto-form.md` § 2):\n * we store under `np_settings`, NOT `np_plugins.config` (the legacy\n * column was dropped in the same migration that introduced this module).\n */\n\nfunction configKey(pluginId: string): string {\n return `${CONFIG_KEY_PREFIX}${pluginId}`;\n}\n\n/**\n * Versioned envelope shape for persisted plugin config — identical to the\n * theme `NpVersionedSettings` shape. Two parallel definitions instead of a\n * shared one because (a) themes and plugins share zero schema surface\n * otherwise, (b) the type is only ~5 lines, and (c) collapsing them would\n * couple `themes/` and `plugins/` modules without functional benefit.\n */\nexport interface NpVersionedPluginConfig {\n __npVersion: number;\n __npSettings: unknown;\n}\n\nexport function isVersionedPluginConfig(\n value: unknown,\n): value is NpVersionedPluginConfig {\n if (!value || typeof value !== \"object\") return false;\n const candidate = value as Partial<NpVersionedPluginConfig>;\n return (\n typeof candidate.__npVersion === \"number\" &&\n Number.isFinite(candidate.__npVersion) &&\n \"__npSettings\" in candidate\n );\n}\n\n/**\n * Run the plugin's `configMigrate` from `from` to current schema version.\n * No-op when versions match or the plugin doesn't declare a migrator.\n * Defensive try/catch — a buggy migrate fn shouldn't blow up the read\n * path; we fall back to the original value and let `safeParse` decide.\n *\n * Mirrors `applyMigration` in `packages/core/src/themes/settings.ts` line\n * for line.\n */\nexport function applyPluginConfigMigration(\n registration: {\n configVersion?: number;\n configMigrate?: (old: unknown, fromVersion: number) => unknown;\n },\n rawValue: unknown,\n fromVersion: number,\n): unknown {\n const target = registration.configVersion ?? 1;\n if (fromVersion >= target) return rawValue;\n const migrate = registration.configMigrate;\n if (typeof migrate !== \"function\") return rawValue;\n try {\n return migrate(rawValue, fromVersion);\n } catch {\n return rawValue;\n }\n}\n\nfunction defaultsFrom(fields: NpThemeSettingsField[]): Record<string, unknown> {\n const out: Record<string, unknown> = {};\n for (const f of fields) {\n if (f.default !== undefined) {\n out[f.name] = f.default;\n continue;\n }\n if (f.type === \"object\") {\n out[f.name] = defaultsFrom(f.fields);\n }\n if (f.type === \"array\") {\n out[f.name] = [];\n }\n }\n return out;\n}\n\nexport interface NpPluginConfigResult {\n pluginId: string;\n /** Parsed config or schema defaults. Empty object when the plugin has\n * no configSchema. */\n value: unknown;\n /** True when there's a stored row, regardless of whether it passed\n * validation. */\n hasPersisted: boolean;\n /** Set when the persisted value failed `schema.parse()`. The admin\n * surface uses this to render a \"settings were reset\" banner. */\n parseError?: string;\n}\n\n/**\n * Read the persisted config for a plugin and parse it via the plugin's\n * `configSchema`. Returns the parsed value when valid; falls back to\n * schema defaults on parse failure (with the failure recorded for the\n * admin to surface, see `getPluginConfigWithStatus`).\n *\n * Return type is `unknown` because core can't type-narrow to the plugin's\n * `z.infer<typeof configSchema>` — the schema lives in the plugin\n * package, not in core. Plugin code that reads its own config should\n * cast at the call site, ideally against an exported type alias from the\n * plugin package itself:\n *\n * // packages/plugins/oauth-github/src/index.ts\n * export const configSchema = z.object({ ... });\n * export type GithubOauthConfig = z.infer<typeof configSchema>;\n *\n * // a plugin handler\n * const config = (await getPluginConfig(\"oauth-github\")) as GithubOauthConfig;\n */\nexport async function getPluginConfig(pluginId: string): Promise<unknown> {\n const result = await getPluginConfigWithStatus(pluginId);\n return result.value;\n}\n\nexport async function getPluginConfigWithStatus(\n pluginId: string,\n): Promise<NpPluginConfigResult> {\n // Registration is consulted for schema-driven validation + defaults\n // when present, but a missing registration MUST NOT short-circuit the\n // DB read. `ctx.settings.setPlugin` writes to `np_settings` for any\n // pluginId regardless of registration — bailing here would create a\n // read/write asymmetry where stored config silently disappears on\n // read. Treat \"not registered\" the same as \"registered with no\n // schema\": surface the row raw if it exists.\n const registration = getPluginRegistration(pluginId);\n const schema = registration?.configSchema as ZodTypeAny | undefined;\n\n let row: { value: unknown } | undefined;\n try {\n const db = getDb();\n const siteId = (await getCurrentSiteId()) ?? DEFAULT_SITE;\n const rows = (await db\n .select()\n .from(npSettings)\n .where(\n and(eq(npSettings.siteId, siteId), eq(npSettings.key, configKey(pluginId))),\n )\n .limit(1)) as Array<{ value: unknown }>;\n row = rows[0];\n } catch {\n // DB not ready — caller is asking before bootstrap. Return empty\n // shape; treats DB-not-ready the same as \"no row stored yet\".\n return { pluginId, value: schema ? defaultsFromSchema(schema) : {}, hasPersisted: false };\n }\n\n if (!schema) {\n // Plugin doesn't declare a configSchema. If a row exists (legacy\n // hand-coded UI saved into np_settings, or migrated from\n // np_plugins.config), surface it raw — callers can still read it.\n if (!row) {\n return { pluginId, value: {}, hasPersisted: false };\n }\n const versioned = isVersionedPluginConfig(row.value) ? row.value : null;\n const rawValue = versioned ? versioned.__npSettings : row.value;\n return {\n pluginId,\n value: rawValue ?? {},\n hasPersisted: true,\n };\n }\n\n const fields = introspectThemeSettingsSchema(schema);\n const defaults = defaultsFrom(fields);\n\n if (!row) {\n const parsed = schema.safeParse(defaults);\n return {\n pluginId,\n value: parsed.success ? parsed.data : defaults,\n hasPersisted: false,\n };\n }\n\n // Versioned envelope detection + lazy migration. Mirrors\n // `getThemeSettingsWithStatus` exactly. Registration is guaranteed\n // defined here: schema is only truthy when registration exists\n // (line ~152), and the `if (!schema) return` above narrows the rest\n // of the function — but TS can't infer that across `?.` so we\n // restate it for the migration helper.\n const versioned = isVersionedPluginConfig(row.value) ? row.value : null;\n const storedVersion = versioned ? versioned.__npVersion : 1;\n const rawValue = versioned ? versioned.__npSettings : row.value;\n const valueToParse = applyPluginConfigMigration(\n registration ?? { configVersion: 1 },\n rawValue,\n storedVersion,\n );\n\n const parsed = schema.safeParse(valueToParse);\n if (parsed.success) {\n return { pluginId, value: parsed.data, hasPersisted: true };\n }\n\n return {\n pluginId,\n value: defaults,\n hasPersisted: true,\n parseError: parsed.error.message,\n };\n}\n\nfunction defaultsFromSchema(schema: ZodTypeAny): Record<string, unknown> {\n return defaultsFrom(introspectThemeSettingsSchema(schema));\n}\n\n/**\n * Validate and persist a plugin's config. Throws `NpValidationError` when\n * `value` doesn't pass the schema — the admin form must surface\n * field-level errors before calling this.\n *\n * **Cache invalidation is the caller's responsibility.** This function\n * writes to `np_settings` only; it doesn't import `next/cache`. The\n * admin API route (`PUT /api/admin/plugins/[id]/config`) busts\n * `np:plugin:<id>` after a successful write.\n *\n * Mirrors `setThemeSettings` in `packages/core/src/themes/settings.ts`.\n */\nexport async function setPluginConfig(\n pluginId: string,\n value: unknown,\n updatedBy: string | null = null,\n): Promise<unknown> {\n const registration = getPluginRegistration(pluginId);\n if (!registration) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"pluginId\",\n message: `Unknown plugin '${pluginId}'. Register it in nexpress.config.ts first.`,\n },\n ]);\n }\n const schema = registration.configSchema as ZodTypeAny | undefined;\n if (!schema) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"pluginId\",\n message: `Plugin '${pluginId}' does not declare a configSchema.`,\n },\n ]);\n }\n\n const parsed = schema.safeParse(value);\n if (!parsed.success) {\n throw new NpValidationError(\n \"Config failed validation\",\n parsed.error.issues.map((i) => ({\n field: i.path.join(\".\"),\n message: i.message,\n })),\n );\n }\n\n const wrapped: NpVersionedPluginConfig = {\n __npVersion: registration.configVersion ?? 1,\n __npSettings: parsed.data,\n };\n\n const db = getDb();\n const now = new Date();\n const siteId = (await getCurrentSiteId()) ?? DEFAULT_SITE;\n await db\n .insert(npSettings)\n .values({\n siteId,\n key: configKey(pluginId),\n value: wrapped,\n updatedAt: now,\n updatedBy,\n })\n .onConflictDoUpdate({\n target: [npSettings.siteId, npSettings.key],\n set: { value: wrapped, updatedAt: now, updatedBy },\n });\n\n return parsed.data;\n}\n\n/** Cache tag for a plugin's config invalidation. Per the prefix policy\n * in CLAUDE.md (Naming convention table) every framework-owned tag\n * uses the `np` prefix. Distinct from the legacy `nx:theme:<siteId>`\n * tag — see `docs/design/plugin-config-auto-form.md` § 7. */\nexport function pluginConfigCacheTag(pluginId: string): string {\n return `np:plugin:${pluginId}`;\n}\n","import type { ZodTypeAny } from \"zod\";\n\n/**\n * Phase F.3 — server-side introspection of a theme's\n * `settingsSchema` Zod tree into a JSON metadata shape the\n * admin form generator consumes.\n *\n * The schema lives in the theme package (server bundle); we\n * don't ship the schema itself to the browser. Instead, this\n * function walks the tree once on the server, emits the\n * metadata as plain JSON, and the admin renders form fields\n * from the metadata. The browser doesn't need zod at runtime.\n *\n * Coverage in v0.2: text, url, color (regex heuristic), number,\n * boolean, enum, array(object), object. Anything else\n * introspects as `{ type: \"unsupported\" }` so the form generator\n * can render a JSON textarea fallback (operator can still edit;\n * a follow-up phase widens coverage).\n */\n\nexport type NpThemeSettingsField =\n | NpThemeSettingsTextField\n | NpThemeSettingsTextareaField\n | NpThemeSettingsPasswordField\n | NpThemeSettingsUrlField\n | NpThemeSettingsColorField\n | NpThemeSettingsNumberField\n | NpThemeSettingsBooleanField\n | NpThemeSettingsEnumField\n | NpThemeSettingsArrayField\n | NpThemeSettingsStringArrayField\n | NpThemeSettingsObjectField\n | NpThemeSettingsUnsupportedField;\n\ninterface NpThemeSettingsFieldBase {\n /** Field path key (\"hero\", \"social.0.url\", etc. — the\n * introspector returns flat keys per node; nested objects\n * carry their own children). */\n name: string;\n label?: string;\n description?: string;\n required: boolean;\n default?: unknown;\n}\n\nexport interface NpThemeSettingsTextField extends NpThemeSettingsFieldBase {\n type: \"text\";\n}\n\nexport interface NpThemeSettingsTextareaField extends NpThemeSettingsFieldBase {\n type: \"textarea\";\n /** Optional row count hint for the rendered `<textarea>`.\n * Theme authors set this via `.meta({ widget: \"textarea\",\n * rows: 6 })`. Defaults to 4 when unset. */\n rows?: number;\n}\n\nexport interface NpThemeSettingsPasswordField extends NpThemeSettingsFieldBase {\n type: \"password\";\n}\n\nexport interface NpThemeSettingsUrlField extends NpThemeSettingsFieldBase {\n type: \"url\";\n}\n\nexport interface NpThemeSettingsColorField extends NpThemeSettingsFieldBase {\n type: \"color\";\n}\n\nexport interface NpThemeSettingsNumberField extends NpThemeSettingsFieldBase {\n type: \"number\";\n int?: boolean;\n min?: number;\n max?: number;\n}\n\nexport interface NpThemeSettingsBooleanField extends NpThemeSettingsFieldBase {\n type: \"boolean\";\n}\n\nexport interface NpThemeSettingsEnumField extends NpThemeSettingsFieldBase {\n type: \"enum\";\n options: string[];\n}\n\nexport interface NpThemeSettingsArrayField extends NpThemeSettingsFieldBase {\n type: \"array\";\n /** v0.2 supports `z.array(z.object(...))`. The element\n * schema introspects as the array's child fields. */\n element: NpThemeSettingsField[];\n}\n\n/** Phase G follow-up — `z.array(z.string())`. Renders as a\n * one-item-per-line input. Surfaced for OAuth scopes and\n * similar string-list configs that don't fit the object-array\n * shape; previously fell through to the JSON-textarea\n * `unsupported` fallback. */\nexport interface NpThemeSettingsStringArrayField extends NpThemeSettingsFieldBase {\n type: \"string-array\";\n}\n\nexport interface NpThemeSettingsObjectField extends NpThemeSettingsFieldBase {\n type: \"object\";\n fields: NpThemeSettingsField[];\n}\n\nexport interface NpThemeSettingsUnsupportedField extends NpThemeSettingsFieldBase {\n type: \"unsupported\";\n /** Best-effort label for what was at this position so\n * operators can recognize their schema in the JSON fallback. */\n zodTypeName: string;\n}\n\n// Heuristic: regex sources that look like a hex color check.\n// We test against the regex `source` string (no flags, no\n// surrounding slashes), so e.g. `/^#[0-9a-f]{6}$/i` arrives\n// as `^#[0-9a-f]{6}$`. Matches both 6-digit and 3-to-8 digit\n// variants, case sensitivity-agnostic via the `i` flag on\n// the heuristic itself.\nconst COLOR_REGEX_PATTERNS = [\n /^\\^#\\[0-9a-f\\]\\{6\\}\\$$/i,\n /^\\^#\\[0-9a-f\\]\\{3,8\\}\\$$/i,\n /^\\^#\\[\\\\da-f\\]\\{6\\}\\$$/i,\n];\n\ninterface ZodCheck {\n _zod?: { def?: { format?: string; pattern?: { source: string }; check?: string; value?: number } };\n}\n\ninterface ZodDef {\n type: string;\n innerType?: { _def: ZodDef };\n defaultValue?: unknown;\n description?: string;\n shape?: Record<string, { _def: ZodDef; description?: string }>;\n entries?: Record<string, string>;\n element?: { _def: ZodDef };\n checks?: ZodCheck[];\n}\n\ninterface ZodNode {\n _def: ZodDef;\n description?: string;\n shape?: Record<string, ZodNode>;\n}\n\n/**\n * Strip `default` / `optional` / `nullable` wrappers, returning\n * the inner schema, the resolved default value, and whether\n * the field is required (i.e. neither optional nor nullable).\n */\nfunction unwrap(node: ZodNode): {\n inner: ZodNode;\n defaultValue: unknown;\n required: boolean;\n} {\n let current = node;\n let defaultValue: unknown = undefined;\n let required = true;\n\n while (true) {\n const t = current._def.type;\n if (t === \"default\") {\n defaultValue =\n typeof current._def.defaultValue === \"function\"\n ? (current._def.defaultValue as () => unknown)()\n : current._def.defaultValue;\n current = (current._def.innerType as ZodNode | undefined) ?? current;\n if (!current._def.innerType) break;\n continue;\n }\n if (t === \"optional\" || t === \"nullable\") {\n required = false;\n const next = current._def.innerType as ZodNode | undefined;\n if (!next) break;\n current = next;\n continue;\n }\n break;\n }\n\n return { inner: current, defaultValue, required };\n}\n\nfunction detectStringFormat(\n checks: ZodCheck[] | undefined,\n): \"url\" | \"color\" | \"text\" {\n if (!checks) return \"text\";\n for (const c of checks) {\n const fmt = c._zod?.def?.format;\n if (fmt === \"url\") return \"url\";\n if (fmt === \"regex\") {\n const src = c._zod?.def?.pattern?.source;\n if (src && COLOR_REGEX_PATTERNS.some((p) => p.test(src))) {\n return \"color\";\n }\n }\n }\n return \"text\";\n}\n\n/**\n * Phase F.3 follow-up — pull `.meta()` off a Zod node when\n * present. Used to read theme-author hints like\n * `{ widget: \"textarea\", rows: 6 }` that don't fit Zod's\n * narrow widget matrix (z.string() has no textarea variant\n * built in).\n */\nfunction readMeta(node: ZodNode): Record<string, unknown> | undefined {\n const fn = (node as unknown as { meta?: () => unknown }).meta;\n if (typeof fn !== \"function\") return undefined;\n const out = fn.call(node);\n return out && typeof out === \"object\" ? (out as Record<string, unknown>) : undefined;\n}\n\nfunction detectNumberConstraints(\n checks: ZodCheck[] | undefined,\n): { int?: boolean; min?: number; max?: number } {\n const out: { int?: boolean; min?: number; max?: number } = {};\n if (!checks) return out;\n for (const c of checks) {\n const def = c._zod?.def;\n if (!def) continue;\n if (def.format === \"safeint\" || def.check === \"int\") out.int = true;\n if (def.check === \"greater_than\" && typeof def.value === \"number\")\n out.min = def.value;\n if (def.check === \"less_than\" && typeof def.value === \"number\")\n out.max = def.value;\n }\n return out;\n}\n\nfunction introspectField(\n name: string,\n node: ZodNode,\n): NpThemeSettingsField {\n const description = node.description;\n const { inner, defaultValue, required } = unwrap(node);\n const innerDef = inner._def;\n const base: NpThemeSettingsFieldBase = {\n name,\n description,\n label: description,\n required,\n default: defaultValue,\n };\n\n switch (innerDef.type) {\n case \"string\": {\n // Phase F.3 follow-up — `.meta({ widget: \"textarea\" })`\n // opts a `z.string()` into multi-line rendering. Theme\n // authors pair it with `.describe()` for the field\n // label; row count is optional (defaults to 4).\n //\n // Check `node` (outer) first then `inner` because Zod v4's\n // `.meta()` returns a new instance, so the meta lives at\n // whichever level the author called .meta() at:\n //\n // z.string().meta({...}).optional() → meta on inner string\n // z.string().optional().meta({...}) → meta on outer optional\n //\n // Both patterns are valid in author code; both should work.\n const meta = readMeta(node) ?? readMeta(inner);\n if (meta && meta.sensitive === true) {\n return { ...base, type: \"password\" };\n }\n if (meta && meta.widget === \"textarea\") {\n const rows =\n typeof meta.rows === \"number\" && meta.rows > 0\n ? meta.rows\n : undefined;\n return {\n ...base,\n type: \"textarea\",\n ...(rows !== undefined ? { rows } : {}),\n };\n }\n const fmt = detectStringFormat(innerDef.checks);\n return { ...base, type: fmt };\n }\n case \"number\": {\n const c = detectNumberConstraints(innerDef.checks);\n return { ...base, type: \"number\", ...c };\n }\n case \"boolean\":\n return { ...base, type: \"boolean\" };\n case \"enum\": {\n const entries = innerDef.entries ?? {};\n return { ...base, type: \"enum\", options: Object.values(entries) };\n }\n case \"array\": {\n const element = innerDef.element as ZodNode | undefined;\n // v0.2 supports z.array(z.object(...)) — typed nested form\n // for each item.\n if (element?._def.type === \"object\" && element._def.shape) {\n const childFields = introspectShape(element._def.shape);\n return { ...base, type: \"array\", element: childFields };\n }\n // Phase G follow-up — z.array(z.string()) gets a dedicated\n // string-array widget (one item per line). Surfaced for\n // OAuth scopes and similar string-list configs.\n if (element?._def.type === \"string\") {\n return { ...base, type: \"string-array\" };\n }\n return { ...base, type: \"unsupported\", zodTypeName: \"array\" };\n }\n case \"object\": {\n const shape = innerDef.shape;\n if (shape) {\n return { ...base, type: \"object\", fields: introspectShape(shape) };\n }\n return { ...base, type: \"unsupported\", zodTypeName: \"object\" };\n }\n default:\n return {\n ...base,\n type: \"unsupported\",\n zodTypeName: innerDef.type ?? \"unknown\",\n };\n }\n}\n\nfunction introspectShape(\n shape: Record<string, { _def: ZodDef; description?: string }>,\n): NpThemeSettingsField[] {\n const out: NpThemeSettingsField[] = [];\n for (const [name, raw] of Object.entries(shape)) {\n out.push(introspectField(name, raw as ZodNode));\n }\n return out;\n}\n\n/**\n * Walk a theme's `settingsSchema` (top-level z.object) and emit\n * the form metadata. Returns an empty array when the schema\n * isn't a top-level object — themes are expected to ship\n * `settingsSchema: z.object({...})` (validated implicitly: a\n * non-object top schema yields an empty form, signalling\n * \"nothing to configure\").\n */\nexport function introspectThemeSettingsSchema(\n schema: ZodTypeAny | undefined,\n): NpThemeSettingsField[] {\n if (!schema) return [];\n // Strip any top-level default/optional/nullable wrapper before\n // checking for object shape — themes that wrap their whole\n // schema in `.default({...})` are unusual but valid; without\n // unwrap we'd silently render an empty form.\n const { inner } = unwrap(schema as unknown as ZodNode);\n if (inner._def.type !== \"object\" || !inner._def.shape) return [];\n return introspectShape(inner._def.shape);\n}\n"],"mappings":"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","names":["versioned","rawValue","parsed"]}
1
+ {"version":3,"sources":["../src/plugins/config.ts","../src/themes/settings-schema.ts"],"sourcesContent":["import { and, eq } from \"drizzle-orm\";\nimport type { ZodTypeAny } from \"zod\";\n\nimport { getDb } from \"../db/index.js\";\nimport { npSettings } from \"../db/schema/system.js\";\nimport { NpValidationError } from \"../errors.js\";\nimport { getCurrentSiteId } from \"../sites/context.js\";\nimport { getPluginRegistration } from \"./host.js\";\nimport {\n introspectThemeSettingsSchema,\n type NpThemeSettingsField,\n} from \"../themes/settings-schema.js\";\n\nconst DEFAULT_SITE = \"default\";\nconst CONFIG_KEY_PREFIX = \"plugin.config:\";\n\n/**\n * G.1 — per-plugin operator config.\n *\n * Stored at `np_settings.(site_id, key=\"plugin.config:<pluginId>\")`.\n * Mirrors theme settings storage exactly, including the `__npVersion` /\n * `__npSettings` envelope, so a future shared `getCachedSetting<T>(key)`\n * helper can read both surfaces. Cache invalidation rides a new\n * `np:plugin:<id>` tag (see `packages/next/src/cache.ts`).\n *\n * Per locked decision E (`docs/design/plugin-config-auto-form.md` § 2):\n * we store under `np_settings`, NOT `np_plugins.config` (the legacy\n * column was dropped in the same migration that introduced this module).\n */\n\nfunction configKey(pluginId: string): string {\n return `${CONFIG_KEY_PREFIX}${pluginId}`;\n}\n\n/**\n * Versioned envelope shape for persisted plugin config — identical to the\n * theme `NpVersionedSettings` shape. Two parallel definitions instead of a\n * shared one because (a) themes and plugins share zero schema surface\n * otherwise, (b) the type is only ~5 lines, and (c) collapsing them would\n * couple `themes/` and `plugins/` modules without functional benefit.\n */\nexport interface NpVersionedPluginConfig {\n __npVersion: number;\n __npSettings: unknown;\n}\n\nexport function isVersionedPluginConfig(\n value: unknown,\n): value is NpVersionedPluginConfig {\n if (!value || typeof value !== \"object\") return false;\n const candidate = value as Partial<NpVersionedPluginConfig>;\n return (\n typeof candidate.__npVersion === \"number\" &&\n Number.isFinite(candidate.__npVersion) &&\n \"__npSettings\" in candidate\n );\n}\n\n/**\n * Run the plugin's `configMigrate` from `from` to current schema version.\n * No-op when versions match or the plugin doesn't declare a migrator.\n * Defensive try/catch — a buggy migrate fn shouldn't blow up the read\n * path; we fall back to the original value and let `safeParse` decide.\n *\n * Mirrors `applyMigration` in `packages/core/src/themes/settings.ts` line\n * for line.\n */\nexport function applyPluginConfigMigration(\n registration: {\n configVersion?: number;\n configMigrate?: (old: unknown, fromVersion: number) => unknown;\n },\n rawValue: unknown,\n fromVersion: number,\n): unknown {\n const target = registration.configVersion ?? 1;\n if (fromVersion >= target) return rawValue;\n const migrate = registration.configMigrate;\n if (typeof migrate !== \"function\") return rawValue;\n try {\n return migrate(rawValue, fromVersion);\n } catch {\n return rawValue;\n }\n}\n\nfunction defaultsFrom(fields: NpThemeSettingsField[]): Record<string, unknown> {\n const out: Record<string, unknown> = {};\n for (const f of fields) {\n if (f.default !== undefined) {\n out[f.name] = f.default;\n continue;\n }\n if (f.type === \"object\") {\n out[f.name] = defaultsFrom(f.fields);\n }\n if (f.type === \"array\") {\n out[f.name] = [];\n }\n }\n return out;\n}\n\nexport interface NpPluginConfigResult {\n pluginId: string;\n /** Parsed config or schema defaults. Empty object when the plugin has\n * no configSchema. */\n value: unknown;\n /** True when there's a stored row, regardless of whether it passed\n * validation. */\n hasPersisted: boolean;\n /** Set when the persisted value failed `schema.parse()`. The admin\n * surface uses this to render a \"settings were reset\" banner. */\n parseError?: string;\n}\n\n/**\n * Read the persisted config for a plugin and parse it via the plugin's\n * `configSchema`. Returns the parsed value when valid; falls back to\n * schema defaults on parse failure (with the failure recorded for the\n * admin to surface, see `getPluginConfigWithStatus`).\n *\n * Return type is `unknown` because core can't type-narrow to the plugin's\n * `z.infer<typeof configSchema>` — the schema lives in the plugin\n * package, not in core. Plugin code that reads its own config should\n * cast at the call site, ideally against an exported type alias from the\n * plugin package itself:\n *\n * // packages/plugins/oauth-github/src/index.ts\n * export const configSchema = z.object({ ... });\n * export type GithubOauthConfig = z.infer<typeof configSchema>;\n *\n * // a plugin handler\n * const config = (await getPluginConfig(\"oauth-github\")) as GithubOauthConfig;\n */\nexport async function getPluginConfig(pluginId: string): Promise<unknown> {\n const result = await getPluginConfigWithStatus(pluginId);\n return result.value;\n}\n\nexport async function getPluginConfigWithStatus(\n pluginId: string,\n): Promise<NpPluginConfigResult> {\n // Registration is consulted for schema-driven validation + defaults\n // when present, but a missing registration MUST NOT short-circuit the\n // DB read. `ctx.settings.setPlugin` writes to `np_settings` for any\n // pluginId regardless of registration — bailing here would create a\n // read/write asymmetry where stored config silently disappears on\n // read. Treat \"not registered\" the same as \"registered with no\n // schema\": surface the row raw if it exists.\n const registration = getPluginRegistration(pluginId);\n const schema = registration?.configSchema as ZodTypeAny | undefined;\n\n let row: { value: unknown } | undefined;\n try {\n const db = getDb();\n const siteId = (await getCurrentSiteId()) ?? DEFAULT_SITE;\n const rows = (await db\n .select()\n .from(npSettings)\n .where(\n and(eq(npSettings.siteId, siteId), eq(npSettings.key, configKey(pluginId))),\n )\n .limit(1)) as Array<{ value: unknown }>;\n row = rows[0];\n } catch {\n // DB not ready — caller is asking before bootstrap. Return empty\n // shape; treats DB-not-ready the same as \"no row stored yet\".\n return { pluginId, value: schema ? defaultsFromSchema(schema) : {}, hasPersisted: false };\n }\n\n if (!schema) {\n // Plugin doesn't declare a configSchema. If a row exists (legacy\n // hand-coded UI saved into np_settings, or migrated from\n // np_plugins.config), surface it raw — callers can still read it.\n if (!row) {\n return { pluginId, value: {}, hasPersisted: false };\n }\n const versioned = isVersionedPluginConfig(row.value) ? row.value : null;\n const rawValue = versioned ? versioned.__npSettings : row.value;\n return {\n pluginId,\n value: rawValue ?? {},\n hasPersisted: true,\n };\n }\n\n const fields = introspectThemeSettingsSchema(schema);\n const defaults = defaultsFrom(fields);\n\n if (!row) {\n const parsed = schema.safeParse(defaults);\n return {\n pluginId,\n value: parsed.success ? parsed.data : defaults,\n hasPersisted: false,\n };\n }\n\n // Versioned envelope detection + lazy migration. Mirrors\n // `getThemeSettingsWithStatus` exactly. Registration is guaranteed\n // defined here: schema is only truthy when registration exists\n // (line ~152), and the `if (!schema) return` above narrows the rest\n // of the function — but TS can't infer that across `?.` so we\n // restate it for the migration helper.\n const versioned = isVersionedPluginConfig(row.value) ? row.value : null;\n const storedVersion = versioned ? versioned.__npVersion : 1;\n const rawValue = versioned ? versioned.__npSettings : row.value;\n const valueToParse = applyPluginConfigMigration(\n registration ?? { configVersion: 1 },\n rawValue,\n storedVersion,\n );\n\n const parsed = schema.safeParse(valueToParse);\n if (parsed.success) {\n return { pluginId, value: parsed.data, hasPersisted: true };\n }\n\n return {\n pluginId,\n value: defaults,\n hasPersisted: true,\n parseError: parsed.error.message,\n };\n}\n\nfunction defaultsFromSchema(schema: ZodTypeAny): Record<string, unknown> {\n return defaultsFrom(introspectThemeSettingsSchema(schema));\n}\n\n/**\n * Validate and persist a plugin's config. Throws `NpValidationError` when\n * `value` doesn't pass the schema — the admin form must surface\n * field-level errors before calling this.\n *\n * **Cache invalidation is the caller's responsibility.** This function\n * writes to `np_settings` only; it doesn't import `next/cache`. The\n * admin API route (`PUT /api/admin/plugins/[id]/config`) busts\n * `np:plugin:<id>` after a successful write.\n *\n * Mirrors `setThemeSettings` in `packages/core/src/themes/settings.ts`.\n */\nexport async function setPluginConfig(\n pluginId: string,\n value: unknown,\n updatedBy: string | null = null,\n): Promise<unknown> {\n const registration = getPluginRegistration(pluginId);\n if (!registration) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"pluginId\",\n message: `Unknown plugin '${pluginId}'. Register it in nexpress.config.ts first.`,\n },\n ]);\n }\n const schema = registration.configSchema as ZodTypeAny | undefined;\n if (!schema) {\n throw new NpValidationError(\"Invalid input\", [\n {\n field: \"pluginId\",\n message: `Plugin '${pluginId}' does not declare a configSchema.`,\n },\n ]);\n }\n\n const parsed = schema.safeParse(value);\n if (!parsed.success) {\n throw new NpValidationError(\n \"Config failed validation\",\n parsed.error.issues.map((i) => ({\n field: i.path.join(\".\"),\n message: i.message,\n })),\n );\n }\n\n const wrapped: NpVersionedPluginConfig = {\n __npVersion: registration.configVersion ?? 1,\n __npSettings: parsed.data,\n };\n\n const db = getDb();\n const now = new Date();\n const siteId = (await getCurrentSiteId()) ?? DEFAULT_SITE;\n await db\n .insert(npSettings)\n .values({\n siteId,\n key: configKey(pluginId),\n value: wrapped,\n updatedAt: now,\n updatedBy,\n })\n .onConflictDoUpdate({\n target: [npSettings.siteId, npSettings.key],\n set: { value: wrapped, updatedAt: now, updatedBy },\n });\n\n return parsed.data;\n}\n\n/** Cache tag for a plugin's config invalidation. Per the prefix policy\n * in CLAUDE.md (Naming convention table) every framework-owned tag\n * uses the `np` prefix. Distinct from the legacy `nx:theme:<siteId>`\n * tag — see `docs/design/plugin-config-auto-form.md` § 7. */\nexport function pluginConfigCacheTag(pluginId: string): string {\n return `np:plugin:${pluginId}`;\n}\n","import type { ZodTypeAny } from \"zod\";\n\n/**\n * Phase F.3 — server-side introspection of a theme's\n * `settingsSchema` Zod tree into a JSON metadata shape the\n * admin form generator consumes.\n *\n * The schema lives in the theme package (server bundle); we\n * don't ship the schema itself to the browser. Instead, this\n * function walks the tree once on the server, emits the\n * metadata as plain JSON, and the admin renders form fields\n * from the metadata. The browser doesn't need zod at runtime.\n *\n * Coverage in v0.2: text, url, color (regex heuristic), number,\n * boolean, enum, array(object), object. Anything else\n * introspects as `{ type: \"unsupported\" }` so the form generator\n * can render a JSON textarea fallback (operator can still edit;\n * a follow-up phase widens coverage).\n */\n\nexport type NpThemeSettingsField =\n | NpThemeSettingsTextField\n | NpThemeSettingsTextareaField\n | NpThemeSettingsPasswordField\n | NpThemeSettingsUrlField\n | NpThemeSettingsColorField\n | NpThemeSettingsNumberField\n | NpThemeSettingsBooleanField\n | NpThemeSettingsEnumField\n | NpThemeSettingsArrayField\n | NpThemeSettingsStringArrayField\n | NpThemeSettingsObjectField\n | NpThemeSettingsUnsupportedField;\n\ninterface NpThemeSettingsFieldBase {\n /** Field path key (\"hero\", \"social.0.url\", etc. — the\n * introspector returns flat keys per node; nested objects\n * carry their own children). */\n name: string;\n label?: string;\n description?: string;\n required: boolean;\n default?: unknown;\n}\n\nexport interface NpThemeSettingsTextField extends NpThemeSettingsFieldBase {\n type: \"text\";\n}\n\nexport interface NpThemeSettingsTextareaField extends NpThemeSettingsFieldBase {\n type: \"textarea\";\n /** Optional row count hint for the rendered `<textarea>`.\n * Theme authors set this via `.meta({ widget: \"textarea\",\n * rows: 6 })`. Defaults to 4 when unset. */\n rows?: number;\n}\n\nexport interface NpThemeSettingsPasswordField extends NpThemeSettingsFieldBase {\n type: \"password\";\n}\n\nexport interface NpThemeSettingsUrlField extends NpThemeSettingsFieldBase {\n type: \"url\";\n}\n\nexport interface NpThemeSettingsColorField extends NpThemeSettingsFieldBase {\n type: \"color\";\n}\n\nexport interface NpThemeSettingsNumberField extends NpThemeSettingsFieldBase {\n type: \"number\";\n int?: boolean;\n min?: number;\n max?: number;\n}\n\nexport interface NpThemeSettingsBooleanField extends NpThemeSettingsFieldBase {\n type: \"boolean\";\n}\n\nexport interface NpThemeSettingsEnumField extends NpThemeSettingsFieldBase {\n type: \"enum\";\n options: string[];\n}\n\nexport interface NpThemeSettingsArrayField extends NpThemeSettingsFieldBase {\n type: \"array\";\n /** v0.2 supports `z.array(z.object(...))`. The element\n * schema introspects as the array's child fields. */\n element: NpThemeSettingsField[];\n}\n\n/** Phase G follow-up — `z.array(z.string())`. Renders as a\n * one-item-per-line input. Surfaced for OAuth scopes and\n * similar string-list configs that don't fit the object-array\n * shape; previously fell through to the JSON-textarea\n * `unsupported` fallback. */\nexport interface NpThemeSettingsStringArrayField extends NpThemeSettingsFieldBase {\n type: \"string-array\";\n}\n\nexport interface NpThemeSettingsObjectField extends NpThemeSettingsFieldBase {\n type: \"object\";\n fields: NpThemeSettingsField[];\n}\n\nexport interface NpThemeSettingsUnsupportedField extends NpThemeSettingsFieldBase {\n type: \"unsupported\";\n /** Best-effort label for what was at this position so\n * operators can recognize their schema in the JSON fallback. */\n zodTypeName: string;\n}\n\n// Heuristic: regex sources that look like a hex color check.\n// We test against the regex `source` string (no flags, no\n// surrounding slashes), so e.g. `/^#[0-9a-f]{6}$/i` arrives\n// as `^#[0-9a-f]{6}$`. Matches both 6-digit and 3-to-8 digit\n// variants, case sensitivity-agnostic via the `i` flag on\n// the heuristic itself.\nconst COLOR_REGEX_PATTERNS = [\n /^\\^#\\[0-9a-f\\]\\{6\\}\\$$/i,\n /^\\^#\\[0-9a-f\\]\\{3,8\\}\\$$/i,\n /^\\^#\\[\\\\da-f\\]\\{6\\}\\$$/i,\n];\n\ninterface ZodCheck {\n _zod?: { def?: { format?: string; pattern?: { source: string }; check?: string; value?: number } };\n}\n\ninterface ZodDef {\n type: string;\n innerType?: { _def: ZodDef };\n defaultValue?: unknown;\n description?: string;\n shape?: Record<string, { _def: ZodDef; description?: string }>;\n entries?: Record<string, string>;\n element?: { _def: ZodDef };\n checks?: ZodCheck[];\n}\n\ninterface ZodNode {\n _def: ZodDef;\n description?: string;\n shape?: Record<string, ZodNode>;\n}\n\n/**\n * Strip `default` / `optional` / `nullable` wrappers, returning\n * the inner schema, the resolved default value, and whether\n * the field is required (i.e. neither optional nor nullable).\n */\nfunction unwrap(node: ZodNode): {\n inner: ZodNode;\n defaultValue: unknown;\n required: boolean;\n} {\n let current = node;\n let defaultValue: unknown = undefined;\n let required = true;\n\n while (true) {\n const t = current._def.type;\n if (t === \"default\") {\n defaultValue =\n typeof current._def.defaultValue === \"function\"\n ? (current._def.defaultValue as () => unknown)()\n : current._def.defaultValue;\n current = (current._def.innerType) ?? current;\n if (!current._def.innerType) break;\n continue;\n }\n if (t === \"optional\" || t === \"nullable\") {\n required = false;\n const next = current._def.innerType;\n if (!next) break;\n current = next;\n continue;\n }\n break;\n }\n\n return { inner: current, defaultValue, required };\n}\n\nfunction detectStringFormat(\n checks: ZodCheck[] | undefined,\n): \"url\" | \"color\" | \"text\" {\n if (!checks) return \"text\";\n for (const c of checks) {\n const fmt = c._zod?.def?.format;\n if (fmt === \"url\") return \"url\";\n if (fmt === \"regex\") {\n const src = c._zod?.def?.pattern?.source;\n if (src && COLOR_REGEX_PATTERNS.some((p) => p.test(src))) {\n return \"color\";\n }\n }\n }\n return \"text\";\n}\n\n/**\n * Phase F.3 follow-up — pull `.meta()` off a Zod node when\n * present. Used to read theme-author hints like\n * `{ widget: \"textarea\", rows: 6 }` that don't fit Zod's\n * narrow widget matrix (z.string() has no textarea variant\n * built in).\n */\nfunction readMeta(node: ZodNode): Record<string, unknown> | undefined {\n const fn = (node as unknown as { meta?: () => unknown }).meta;\n if (typeof fn !== \"function\") return undefined;\n const out = fn.call(node);\n return out && typeof out === \"object\" ? (out as Record<string, unknown>) : undefined;\n}\n\nfunction detectNumberConstraints(\n checks: ZodCheck[] | undefined,\n): { int?: boolean; min?: number; max?: number } {\n const out: { int?: boolean; min?: number; max?: number } = {};\n if (!checks) return out;\n for (const c of checks) {\n const def = c._zod?.def;\n if (!def) continue;\n if (def.format === \"safeint\" || def.check === \"int\") out.int = true;\n if (def.check === \"greater_than\" && typeof def.value === \"number\")\n out.min = def.value;\n if (def.check === \"less_than\" && typeof def.value === \"number\")\n out.max = def.value;\n }\n return out;\n}\n\nfunction introspectField(\n name: string,\n node: ZodNode,\n): NpThemeSettingsField {\n const description = node.description;\n const { inner, defaultValue, required } = unwrap(node);\n const innerDef = inner._def;\n const base: NpThemeSettingsFieldBase = {\n name,\n description,\n label: description,\n required,\n default: defaultValue,\n };\n\n switch (innerDef.type) {\n case \"string\": {\n // Phase F.3 follow-up — `.meta({ widget: \"textarea\" })`\n // opts a `z.string()` into multi-line rendering. Theme\n // authors pair it with `.describe()` for the field\n // label; row count is optional (defaults to 4).\n //\n // Check `node` (outer) first then `inner` because Zod v4's\n // `.meta()` returns a new instance, so the meta lives at\n // whichever level the author called .meta() at:\n //\n // z.string().meta({...}).optional() → meta on inner string\n // z.string().optional().meta({...}) → meta on outer optional\n //\n // Both patterns are valid in author code; both should work.\n const meta = readMeta(node) ?? readMeta(inner);\n if (meta && meta.sensitive === true) {\n return { ...base, type: \"password\" };\n }\n if (meta && meta.widget === \"textarea\") {\n const rows =\n typeof meta.rows === \"number\" && meta.rows > 0\n ? meta.rows\n : undefined;\n return {\n ...base,\n type: \"textarea\",\n ...(rows !== undefined ? { rows } : {}),\n };\n }\n const fmt = detectStringFormat(innerDef.checks);\n return { ...base, type: fmt };\n }\n case \"number\": {\n const c = detectNumberConstraints(innerDef.checks);\n return { ...base, type: \"number\", ...c };\n }\n case \"boolean\":\n return { ...base, type: \"boolean\" };\n case \"enum\": {\n const entries = innerDef.entries ?? {};\n return { ...base, type: \"enum\", options: Object.values(entries) };\n }\n case \"array\": {\n const element = innerDef.element;\n // v0.2 supports z.array(z.object(...)) — typed nested form\n // for each item.\n if (element?._def.type === \"object\" && element._def.shape) {\n const childFields = introspectShape(element._def.shape);\n return { ...base, type: \"array\", element: childFields };\n }\n // Phase G follow-up — z.array(z.string()) gets a dedicated\n // string-array widget (one item per line). Surfaced for\n // OAuth scopes and similar string-list configs.\n if (element?._def.type === \"string\") {\n return { ...base, type: \"string-array\" };\n }\n return { ...base, type: \"unsupported\", zodTypeName: \"array\" };\n }\n case \"object\": {\n const shape = innerDef.shape;\n if (shape) {\n return { ...base, type: \"object\", fields: introspectShape(shape) };\n }\n return { ...base, type: \"unsupported\", zodTypeName: \"object\" };\n }\n default:\n return {\n ...base,\n type: \"unsupported\",\n zodTypeName: innerDef.type ?? \"unknown\",\n };\n }\n}\n\nfunction introspectShape(\n shape: Record<string, { _def: ZodDef; description?: string }>,\n): NpThemeSettingsField[] {\n const out: NpThemeSettingsField[] = [];\n for (const [name, raw] of Object.entries(shape)) {\n out.push(introspectField(name, raw));\n }\n return out;\n}\n\n/**\n * Walk a theme's `settingsSchema` (top-level z.object) and emit\n * the form metadata. Returns an empty array when the schema\n * isn't a top-level object — themes are expected to ship\n * `settingsSchema: z.object({...})` (validated implicitly: a\n * non-object top schema yields an empty form, signalling\n * \"nothing to configure\").\n */\nexport function introspectThemeSettingsSchema(\n schema: ZodTypeAny | undefined,\n): NpThemeSettingsField[] {\n if (!schema) return [];\n // Strip any top-level default/optional/nullable wrapper before\n // checking for object shape — themes that wrap their whole\n // schema in `.default({...})` are unusual but valid; without\n // unwrap we'd silently render an empty form.\n const { inner } = unwrap(schema);\n if (inner._def.type !== \"object\" || !inner._def.shape) return [];\n return introspectShape(inner._def.shape);\n}\n"],"mappings":"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","names":["versioned","rawValue","parsed"]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  recordDigestSent
3
- } from "./chunk-CAS4Z6IN.js";
3
+ } from "./chunk-I4FSVEJK.js";
4
4
  import {
5
5
  NP_DEFAULT_SITE_ID,
6
6
  listSites
@@ -10,7 +10,7 @@ import {
10
10
  } from "./chunk-LSHHRDVR.js";
11
11
  import {
12
12
  getLogger
13
- } from "./chunk-NFHS7CFV.js";
13
+ } from "./chunk-Q7MK5ZKG.js";
14
14
  import {
15
15
  getDb
16
16
  } from "./chunk-XANPEOJC.js";
@@ -191,4 +191,4 @@ export {
191
191
  buildDigestEmail,
192
192
  runDigestSweep
193
193
  };
194
- //# sourceMappingURL=chunk-LN6NTH6E.js.map
194
+ //# sourceMappingURL=chunk-K4CJ3KXB.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/community/digest.ts"],"sourcesContent":["import { and, desc, eq, gt, isNull, sql } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npMembers, npNotifications } from \"../db/schema/community.js\";\nimport { getEmailAdapter } from \"../email/service.js\";\nimport { getLogger } from \"../observability/logger.js\";\nimport { listSites, NP_DEFAULT_SITE_ID } from \"../sites/registry.js\";\n\nimport { type NpDigestCadence, recordDigestSent } from \"./notification-prefs.js\";\n\n/**\n * Phase 16.4 — email digest fan-out. The `notifications:sendDigest`\n * recurring job calls `runDigestSweep(cadence)` on a daily and a\n * weekly schedule; the function fetches every active member who\n * opted into that cadence, builds an inbox summary scoped to \"since\n * last digest\" (falling back to the cadence window when the member\n * has never received one), renders an email through the configured\n * `NpEmailAdapter`, and stamps `lastDigestAt` on success.\n *\n * The job is idempotent enough for production use: a sweep that\n * runs twice for the same window won't re-email members because\n * `lastDigestAt` advances on the first send. Failures inside the\n * loop are logged-and-continued — one stuck member doesn't block\n * the rest of the sweep.\n */\n\nexport interface NpDigestNotificationSummary {\n id: string;\n kind: string;\n payload: Record<string, unknown>;\n createdAt: Date;\n}\n\nexport interface NpDigestEmailContent {\n subject: string;\n text: string;\n html: string;\n}\n\nexport interface BuildDigestEmailInput {\n member: { displayName: string; handle: string };\n notifications: NpDigestNotificationSummary[];\n cadence: NpDigestCadence;\n /** Site display name; defaults to \"your site\" so the noop adapter is still readable. */\n siteName?: string;\n}\n\nconst LABELS: Record<string, string> = {\n \"comment.reply\": \"New reply on your comment\",\n \"comment.mention\": \"You were mentioned in a comment\",\n \"document.mention\": \"You were mentioned in a discussion\",\n \"reaction.received\": \"Someone reacted to your content\",\n \"follow.received\": \"Someone followed you\",\n};\n\nfunction labelFor(kind: string): string {\n return LABELS[kind] ?? `Notification (${kind})`;\n}\n\nfunction escapeHtml(value: string): string {\n return value\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\");\n}\n\n/**\n * Pure renderer; exposed so plugins / tests can call it without\n * the DB read path.\n */\nexport function buildDigestEmail(input: BuildDigestEmailInput): NpDigestEmailContent {\n const site = input.siteName ?? \"your site\";\n const cadenceWord = input.cadence === \"weekly\" ? \"weekly\" : \"daily\";\n const total = input.notifications.length;\n const subject =\n total === 1\n ? `Your ${cadenceWord} digest from ${site}: 1 notification`\n : `Your ${cadenceWord} digest from ${site}: ${total} notifications`;\n\n const lines = input.notifications.map((n) => {\n const label = labelFor(n.kind);\n const when = n.createdAt.toISOString();\n return `- ${label} (${when})`;\n });\n const text = [\n `Hi @${input.member.handle},`,\n \"\",\n `You have ${total} unread notification${total === 1 ? \"\" : \"s\"} from the last ${cadenceWord} window:`,\n \"\",\n ...lines,\n \"\",\n `Manage your digest settings: /members/me/notifications`,\n ].join(\"\\n\");\n\n const items = input.notifications\n .map((n) => {\n const label = escapeHtml(labelFor(n.kind));\n const when = escapeHtml(n.createdAt.toISOString());\n return `<li><strong>${label}</strong> <span style=\"color:#64748b\">— ${when}</span></li>`;\n })\n .join(\"\");\n const html = [\n `<p>Hi @${escapeHtml(input.member.handle)},</p>`,\n `<p>You have ${total} unread notification${total === 1 ? \"\" : \"s\"} from the last ${cadenceWord} window:</p>`,\n `<ul>${items}</ul>`,\n `<p style=\"color:#64748b;font-size:0.9rem\">`,\n `Manage your digest settings at `,\n `<a href=\"/members/me/notifications\">/members/me/notifications</a>.`,\n `</p>`,\n ].join(\"\");\n\n return { subject, text, html };\n}\n\ninterface MemberDigestRow {\n id: string;\n email: string;\n handle: string;\n displayName: string;\n prefs: Record<string, unknown>;\n}\n\nfunction fallbackWindow(cadence: NpDigestCadence, now: Date): Date {\n const ms = cadence === \"weekly\" ? 7 * 24 * 60 * 60 * 1000 : 24 * 60 * 60 * 1000;\n return new Date(now.getTime() - ms);\n}\n\n/**\n * Pulls every active member whose `notification_prefs.digest`\n * matches `cadence`. The JSONB filter uses Postgres `->>`\n * extraction; the `digest` field is a small string, indexes are\n * unnecessary at v1 scale.\n */\nasync function listMembersForCadence(\n db: NodePgDatabase<Record<string, unknown>>,\n cadence: Exclude<NpDigestCadence, \"off\">,\n): Promise<MemberDigestRow[]> {\n const rows = (await db\n .select({\n id: npMembers.id,\n email: npMembers.email,\n handle: npMembers.handle,\n displayName: npMembers.displayName,\n prefs: npMembers.notificationPrefs,\n status: npMembers.status,\n })\n .from(npMembers)\n .where(\n and(\n eq(npMembers.status, \"active\"),\n sql`${npMembers.notificationPrefs} ->> 'digest' = ${cadence}`,\n ),\n )) as Array<MemberDigestRow & { status: string }>;\n return rows.map((r) => ({\n id: r.id,\n email: r.email,\n handle: r.handle,\n displayName: r.displayName,\n prefs: r.prefs,\n }));\n}\n\nasync function fetchUnreadSince(\n db: NodePgDatabase<Record<string, unknown>>,\n memberId: string,\n siteId: string,\n since: Date,\n): Promise<NpDigestNotificationSummary[]> {\n const rows = (await db\n .select({\n id: npNotifications.id,\n kind: npNotifications.kind,\n payload: npNotifications.payload,\n createdAt: npNotifications.createdAt,\n })\n .from(npNotifications)\n .where(\n and(\n eq(npNotifications.memberId, memberId),\n // Issue #218 — scope to the site we're sweeping. Without\n // this the digest mixed inboxes across tenants and the\n // recipient saw notifications from sites they don't even\n // know exist.\n eq(npNotifications.siteId, siteId),\n // Unread + within the window. If the member already read\n // everything in the inbox the digest would be noise, so we\n // skip silently (caller increments `skipped` when the list\n // comes back empty).\n gt(npNotifications.createdAt, since),\n isNull(npNotifications.readAt),\n ),\n )\n .orderBy(desc(npNotifications.createdAt))\n .limit(50)) as NpDigestNotificationSummary[];\n return rows;\n}\n\nexport interface RunDigestSweepInput {\n cadence: \"daily\" | \"weekly\";\n /** Defaults to `new Date()`. Tests override for determinism. */\n now?: Date;\n /** Site name woven into subject + body. Defaults to `\"your site\"`. */\n siteName?: string;\n}\n\nexport interface RunDigestSweepResult {\n considered: number;\n sent: number;\n skipped: number;\n failed: number;\n}\n\nexport async function runDigestSweep(input: RunDigestSweepInput): Promise<RunDigestSweepResult> {\n const now = input.now ?? new Date();\n const db = getDb();\n const adapter = getEmailAdapter();\n const log = getLogger();\n\n // Issue #218 — fan-out per site. The previous implementation\n // ran a single sweep that mixed every tenant's inbox into one\n // digest and stamped one global `lastDigestAt`; advancing it\n // for tenant A would suppress tenant B's next digest entirely.\n // We now iterate the site registry and run an independent\n // sweep per (site, member) — same email cadence, but each\n // recipient gets one email per site they have unread\n // notifications on.\n const sites = await listSites();\n const candidateSites = sites.length > 0 ? sites : [{ id: NP_DEFAULT_SITE_ID, name: \"\" }];\n const members = await listMembersForCadence(db, input.cadence);\n\n let considered = 0;\n let sent = 0;\n let skipped = 0;\n let failed = 0;\n\n for (const site of candidateSites) {\n for (const member of members) {\n considered += 1;\n const since = lastDigestSinceFor(member, site.id, input.cadence, now);\n\n const notifications = await fetchUnreadSince(db, member.id, site.id, since);\n if (notifications.length === 0) {\n skipped += 1;\n continue;\n }\n\n const email = buildDigestEmail({\n member: { displayName: member.displayName, handle: member.handle },\n notifications,\n cadence: input.cadence,\n // Caller-supplied `siteName` is an explicit override\n // (single-tenant deploys, tests pinning a friendly\n // brand name); the per-site `name` is the natural\n // multi-tenant default.\n siteName:\n input.siteName && input.siteName.length > 0\n ? input.siteName\n : typeof site.name === \"string\" && site.name.length > 0\n ? site.name\n : undefined,\n });\n\n try {\n await adapter.send({\n to: member.email,\n subject: email.subject,\n text: email.text,\n html: email.html,\n });\n await recordDigestSent(member.id, now, { siteId: site.id, cadence: input.cadence });\n sent += 1;\n } catch (err) {\n failed += 1;\n log.warn(\"digest send failed\", {\n memberId: member.id,\n siteId: site.id,\n cadence: input.cadence,\n error: err instanceof Error ? err.message : String(err),\n });\n }\n }\n }\n\n return { considered, sent, skipped, failed };\n}\n\n/**\n * Issue #218 — pick the right \"since\" cutoff for one (site,\n * member, cadence) sweep. Reads precedence:\n * 1. `lastDigestAtBySite[siteId][cadence]` — the per-site\n * timestamp the new sweep writes after each successful send.\n * 2. legacy `lastDigestAt` — single-tenant deploys without\n * site-scoped writes still keep their existing window.\n * 3. fallback window (24h / 7d) — a member who has never\n * received any digest.\n */\nfunction lastDigestSinceFor(\n member: MemberDigestRow,\n siteId: string,\n cadence: NpDigestCadence,\n now: Date,\n): Date {\n const prefs = (member.prefs ?? {});\n const bySite = prefs.lastDigestAtBySite as\n | Record<string, Partial<Record<string, string>>>\n | undefined;\n const perSite = bySite?.[siteId]?.[cadence];\n if (typeof perSite === \"string\") {\n const parsed = new Date(perSite);\n if (Number.isFinite(parsed.getTime())) return parsed;\n }\n if (typeof prefs.lastDigestAt === \"string\") {\n const parsed = new Date(prefs.lastDigestAt);\n if (Number.isFinite(parsed.getTime())) return parsed;\n }\n return fallbackWindow(cadence, now);\n}\n"],"mappings":"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","names":[]}
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  getScopedLogger
3
- } from "./chunk-NFHS7CFV.js";
3
+ } from "./chunk-Q7MK5ZKG.js";
4
4
 
5
5
  // src/observability/safety-check.ts
6
6
  var MIN_PROD_SECRET_LENGTH = 32;
@@ -89,4 +89,4 @@ function isLoopbackUrl(url) {
89
89
  export {
90
90
  verifyStartupSafety
91
91
  };
92
- //# sourceMappingURL=chunk-B7DTNT4O.js.map
92
+ //# sourceMappingURL=chunk-MWLSXK6Y.js.map
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  runHook
3
- } from "./chunk-MLXKZK6G.js";
3
+ } from "./chunk-CD74WQK7.js";
4
4
  import {
5
5
  getAllCollectionSlugs,
6
6
  getCollectionConfig,
@@ -80,4 +80,4 @@ async function publishScheduledDocuments(atTime = /* @__PURE__ */ new Date()) {
80
80
  export {
81
81
  publishScheduledDocuments
82
82
  };
83
- //# sourceMappingURL=chunk-PW43RCJK.js.map
83
+ //# sourceMappingURL=chunk-PPUHXOWZ.js.map
@@ -12,7 +12,7 @@ var teeImportPromise = null;
12
12
  async function teeToJobLog(level, message, context) {
13
13
  try {
14
14
  if (!teeImportPromise) {
15
- teeImportPromise = import("./job-log-N3IGI4NA.js");
15
+ teeImportPromise = import("./job-log-UY6ERPQZ.js");
16
16
  }
17
17
  const mod = await teeImportPromise;
18
18
  if (mod.getCurrentJobId() !== null) {
@@ -65,4 +65,4 @@ export {
65
65
  getScopedLogger,
66
66
  resetLogger
67
67
  };
68
- //# sourceMappingURL=chunk-NFHS7CFV.js.map
68
+ //# sourceMappingURL=chunk-Q7MK5ZKG.js.map
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  recordAuditEvent
3
- } from "./chunk-ML2E3P3X.js";
3
+ } from "./chunk-5C22NDW4.js";
4
4
  import {
5
5
  getCommunitySettings
6
- } from "./chunk-RKM4GDWM.js";
6
+ } from "./chunk-6MRTH734.js";
7
7
  import {
8
8
  NpAuthError,
9
9
  NpForbiddenError,
@@ -855,4 +855,4 @@ export {
855
855
  requestMemberPasswordReset,
856
856
  consumeMemberPasswordReset
857
857
  };
858
- //# sourceMappingURL=chunk-6UV2P5MW.js.map
858
+ //# sourceMappingURL=chunk-TIWJVQOO.js.map