@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
@@ -1 +0,0 @@
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\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\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() as unknown as NodePgDatabase<Record<string, unknown>>;\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() as unknown as NodePgDatabase<Record<string, unknown>>;\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() as unknown as NodePgDatabase<Record<string, unknown>>;\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;AA4BtC,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":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/community/mentions.ts","../src/community/notifications.ts"],"sourcesContent":["import { inArray } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npMembers } from \"../db/schema/community.js\";\n\nimport { createNotification } from \"./notifications.js\";\n\n/**\n * Phase 16.2 — @mention extraction + notification fan-out.\n *\n * The mention vocabulary mirrors the handle constraint enforced\n * during registration (`/^[a-z0-9][a-z0-9_-]{2,29}$/`). The matcher\n * uses a negative lookbehind so `email@host.com` doesn't trigger a\n * mention, plus a negative lookahead so `@alice-` (handle followed\n * by a hyphen that's not part of the handle) is rejected — handles\n * end at non-handle characters, never mid-symbol.\n *\n * Fan-out semantics:\n * - Self-mentions are skipped (the author already knows).\n * - Caller-supplied `exclude` set lets the comment write path\n * skip the parent author so they don't get both `comment.reply`\n * AND `comment.mention`.\n * - Caller-supplied `previousHandles` lets the edit path only\n * notify newly-added mentions (otherwise toggling a single\n * other word in a comment would re-notify everyone).\n * - Inactive / banned / deleted members are filtered out at\n * resolve time.\n * - Mute is enforced inside `createNotification` (the\n * recipient's mute list drops actor-keyed notifications).\n */\n\n/** Source-of-truth handle pattern, kept in sync with `apps/web` register routes. */\nexport const MENTION_HANDLE_RE = /^[a-z0-9][a-z0-9_-]{2,29}$/;\n\nconst MENTION_PATTERN = /(?<![A-Za-z0-9_])@([a-z0-9][a-z0-9_-]{2,29})(?![A-Za-z0-9_-])/g;\n\nexport interface NpMentionTarget {\n id: string;\n handle: string;\n}\n\n/**\n * Extract unique mention handles from plain text or markdown source.\n * Order is preserved (first appearance wins) so a UI that wants to\n * display \"you mentioned @alice and @bob\" gets the same order as\n * the body text.\n */\nexport function extractMentionHandles(source: string): string[] {\n if (!source) return [];\n const seen = new Set<string>();\n const out: string[] = [];\n for (const match of source.matchAll(MENTION_PATTERN)) {\n const handle = match[1]?.toLowerCase();\n if (!handle || seen.has(handle)) continue;\n seen.add(handle);\n out.push(handle);\n }\n return out;\n}\n\n/**\n * Walk a Lexical-shaped rich-text payload, concatenate its text\n * nodes, and run the mention extractor over the joined result.\n * Mirrors the search-index walker (`collections/search.ts`) so a\n * mention split across two adjacent text spans (e.g. `@` and\n * `alice` in different runs because of formatting toggles) still\n * resolves correctly — text nodes are joined without separators.\n */\nexport function extractMentionHandlesFromRichText(content: unknown): string[] {\n if (!content || typeof content !== \"object\") return [];\n const root = (content as { root?: { children?: unknown } }).root;\n if (!root || !Array.isArray(root.children)) return [];\n const parts: string[] = [];\n walkRichTextNodes(root.children, parts);\n return extractMentionHandles(parts.join(\"\"));\n}\n\nfunction walkRichTextNodes(nodes: unknown[], parts: string[]): void {\n for (const node of nodes) {\n if (!node || typeof node !== \"object\") continue;\n const n = node as Record<string, unknown>;\n if (typeof n.text === \"string\") parts.push(n.text);\n if (Array.isArray(n.children)) walkRichTextNodes(n.children, parts);\n }\n}\n\n/**\n * Scan a collection-document data payload (the same shape passed\n * to `createMemberDocument` / `updateMemberDocument`) and pull\n * out every mention handle it contains. String values are scanned\n * with the markdown extractor; object values shaped like Lexical\n * rich text (`{ root: { children: [...] } }`) are walked. Other\n * values are ignored.\n *\n * Field names are not assumed: any string or rich-text field\n * contributes. The mention pattern is anchored to `@<handle>`\n * with handle-shape constraints, so unrelated string fields\n * (`category: \"news\"`) won't trigger false positives.\n */\nexport function extractMentionHandlesFromDocData(data: Record<string, unknown>): string[] {\n if (!data || typeof data !== \"object\") return [];\n const seen = new Set<string>();\n for (const value of Object.values(data)) {\n if (typeof value === \"string\") {\n for (const h of extractMentionHandles(value)) seen.add(h);\n continue;\n }\n if (value && typeof value === \"object\") {\n const root = (value as { root?: { children?: unknown } }).root;\n if (root && Array.isArray(root.children)) {\n for (const h of extractMentionHandlesFromRichText(value)) seen.add(h);\n }\n }\n }\n return Array.from(seen);\n}\n\n/**\n * Resolve handles to active member ids. Inactive / banned /\n * deleted members are filtered out so a mention of an account\n * the site no longer wants to notify is silently dropped (rather\n * than raising an error to the writer — the writer can't tell the\n * difference between \"typo\" and \"account closed\", and either way\n * the right behaviour is \"no notification\").\n *\n * Lookups are case-insensitive on the handle (the storage column\n * stores the canonical lowercased form).\n */\nexport async function resolveMentionedMembers(handles: string[]): Promise<NpMentionTarget[]> {\n if (handles.length === 0) return [];\n const lower = Array.from(new Set(handles.map((h) => h.toLowerCase())));\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n const rows = (await db\n .select({ id: npMembers.id, handle: npMembers.handle, status: npMembers.status })\n .from(npMembers)\n .where(inArray(npMembers.handle, lower))) as Array<{\n id: string;\n handle: string;\n status: string;\n }>;\n return rows.filter((r) => r.status === \"active\").map((r) => ({ id: r.id, handle: r.handle }));\n}\n\nexport interface FanOutMentionsInput {\n /** The author whose write triggered the fan-out. Self-mentions are skipped. */\n actorMemberId: string;\n /** Notification `kind` (e.g. `\"comment.mention\"`, `\"discussion.mention\"`). */\n kind: string;\n /**\n * Plain text or markdown to scan. Either `source` or `content`\n * (or both) must be provided; if both are set the handles are\n * unioned.\n */\n source?: string;\n /** Lexical-shaped rich-text JSON to scan. */\n content?: unknown;\n /**\n * Collection-document data payload to scan. All string +\n * rich-text fields contribute. Useful for the\n * `createMemberDocument` / `updateMemberDocument` paths.\n */\n data?: Record<string, unknown>;\n /**\n * Recipients that already received a notification for this same\n * event (e.g. the parent author got `comment.reply`). They are\n * skipped to avoid the \"two pings for one comment\" pattern.\n */\n exclude?: ReadonlySet<string>;\n /** Merged into the notification payload. `mentionedMemberId` is added automatically. */\n payload?: Record<string, unknown>;\n /**\n * Edit path: handles that were present in the prior revision\n * are skipped so toggling unrelated words doesn't re-notify\n * everyone already mentioned.\n */\n previousHandles?: ReadonlySet<string>;\n}\n\n/**\n * Fan-out mention notifications. Returns the number of\n * notifications actually inserted (mute / inactive / self / dedup\n * exclusions all reduce the count).\n */\nexport async function fanOutMentionNotifications(input: FanOutMentionsInput): Promise<number> {\n const handles = new Set<string>();\n if (input.source) {\n for (const h of extractMentionHandles(input.source)) handles.add(h);\n }\n if (input.content !== undefined) {\n for (const h of extractMentionHandlesFromRichText(input.content)) handles.add(h);\n }\n if (input.data) {\n for (const h of extractMentionHandlesFromDocData(input.data)) handles.add(h);\n }\n if (input.previousHandles) {\n for (const prev of input.previousHandles) handles.delete(prev);\n }\n if (handles.size === 0) return 0;\n\n const targets = await resolveMentionedMembers(Array.from(handles));\n let fired = 0;\n for (const t of targets) {\n if (t.id === input.actorMemberId) continue;\n if (input.exclude?.has(t.id)) continue;\n const row = await createNotification({\n memberId: t.id,\n kind: input.kind,\n actorMemberId: input.actorMemberId,\n payload: {\n ...(input.payload ?? {}),\n mentionedMemberId: t.id,\n mentionedHandle: t.handle,\n },\n });\n if (row) fired += 1;\n }\n return fired;\n}\n","import { and, count, desc, eq, isNull, inArray } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npNotifications } from \"../db/schema/community.js\";\nimport { NpForbiddenError, NpValidationError } from \"../errors.js\";\nimport { getCurrentSiteId, requireSiteId } from \"../sites/context.js\";\nimport { NP_DEFAULT_SITE_ID } from \"../sites/registry.js\";\n\n/**\n * Per-member notification inbox. v1 is synchronous: every event that\n * generates a notification writes a row immediately. The inbox is\n * in-app only — email fan-out and per-member frequency preferences\n * are out of scope for the shipped roadmap.\n *\n * `kind` is a free-form string. The current vocabulary:\n * - `comment.reply` — your comment got a reply\n * - `reaction.received` — someone reacted to your content\n * - `follow.received` — someone followed you\n * Plugins can write their own kinds; the recipient UI fans them out\n * to whichever rendering it knows.\n */\n\nexport interface NpNotificationRow {\n id: string;\n memberId: string;\n kind: string;\n payload: Record<string, unknown>;\n readAt: Date | null;\n createdAt: Date;\n}\n\nexport interface CreateNotificationInput {\n /** The recipient — whose inbox this lands in. */\n memberId: string;\n kind: string;\n payload?: Record<string, unknown>;\n /**\n * Phase 16.1 — the member whose action triggered the\n * notification (e.g. the comment author, the reactor, the\n * follower). When set, the recipient's mute list is\n * consulted: if the recipient has muted the actor, the\n * notification is silently dropped. Returns `null` from\n * the call site.\n *\n * Optional because some kinds are actor-less (system\n * notices, scheduled reminders).\n */\n actorMemberId?: string | null;\n}\n\nexport async function createNotification(\n input: CreateNotificationInput,\n): Promise<NpNotificationRow | null> {\n // Mute check — defer the import to avoid a notifications →\n // mutes circular at module load. Mutes module imports\n // nothing back from here, but TypeScript sometimes flags\n // the cycle anyway depending on resolver order.\n if (input.actorMemberId && input.actorMemberId !== input.memberId) {\n const { isMuted } = await import(\"./mutes.js\");\n const muted = await isMuted({\n memberId: input.memberId,\n targetId: input.actorMemberId,\n });\n if (muted) return null;\n }\n\n // Phase 16.3 — recipient-controlled kind toggle. Fails open\n // on read error (transient DB blip shouldn't silently swallow\n // notifications). Deferred import for the same reason as\n // mutes.\n {\n const { isNotificationKindEnabled } = await import(\"./notification-prefs.js\");\n const enabled = await isNotificationKindEnabled(input.memberId, input.kind);\n if (!enabled) return null;\n }\n\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n // Phase 18 — site comes from the request resolver. The\n // notification belongs to the tenant where the actor's\n // action happened (a reaction on tenant A → notification\n // shows up in the recipient's tenant-A inbox).\n // #272 — write: must NOT silently fall through; an actor on\n // tenant A would otherwise create a notification on the\n // default tenant.\n const siteId = await requireSiteId();\n const [row] = (await db\n .insert(npNotifications)\n .values({\n memberId: input.memberId,\n kind: input.kind,\n payload: input.payload ?? {},\n siteId,\n })\n .returning()) as NpNotificationRow[];\n if (!row) throw new Error(\"Notification insert returned no row\");\n return row;\n}\n\nexport interface ListNotificationsOptions {\n /** Default 50, max 200. */\n limit?: number;\n /** Default 0. */\n offset?: number;\n /** When true, returns only unread. */\n unreadOnly?: boolean;\n}\n\nexport interface NpNotificationListResult {\n notifications: NpNotificationRow[];\n totalDocs: number;\n unread: number;\n}\n\nexport async function listNotifications(\n memberId: string,\n options: ListNotificationsOptions = {},\n): Promise<NpNotificationListResult> {\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);\n const offset = Math.max(options.offset ?? 0, 0);\n\n // Phase 18 — inbox is per-site. A member who's active on\n // multiple tenants sees a separate notification list on each.\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const baseWhere = and(eq(npNotifications.memberId, memberId), eq(npNotifications.siteId, siteId));\n const where = options.unreadOnly ? and(baseWhere, isNull(npNotifications.readAt)) : baseWhere;\n\n const rows = (await db\n .select()\n .from(npNotifications)\n .where(where)\n .orderBy(desc(npNotifications.createdAt))\n .limit(limit)\n .offset(offset)) as NpNotificationRow[];\n\n const [totalRow] = (await db\n .select({ total: count() })\n .from(npNotifications)\n .where(where)) as Array<{ total: number | string }>;\n\n const [unreadRow] = (await db\n .select({ total: count() })\n .from(npNotifications)\n .where(and(baseWhere, isNull(npNotifications.readAt)))) as Array<{\n total: number | string;\n }>;\n\n return {\n notifications: rows,\n totalDocs: Number(totalRow?.total ?? 0),\n unread: Number(unreadRow?.total ?? 0),\n };\n}\n\nexport async function unreadNotificationCount(memberId: string): Promise<number> {\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n // Phase 18 — count only notifications on the current site.\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const [row] = (await db\n .select({ total: count() })\n .from(npNotifications)\n .where(\n and(\n eq(npNotifications.memberId, memberId),\n eq(npNotifications.siteId, siteId),\n isNull(npNotifications.readAt),\n ),\n )) as Array<{ total: number | string }>;\n return Number(row?.total ?? 0);\n}\n\nexport interface MarkReadInput {\n memberId: string;\n notificationIds: string[];\n}\n\nexport async function markNotificationsRead(input: MarkReadInput): Promise<number> {\n if (input.notificationIds.length === 0) return 0;\n if (input.notificationIds.length > 200) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"notificationIds\", message: \"Up to 200 ids per request\" },\n ]);\n }\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n // Issue #219 — scope the update to the current site so a member\n // active on multiple tenants can't mark IDs read across tenants\n // by passing a site-A request that names site-B notification ids.\n // The caller's existing `memberId` predicate covered ownership\n // but not tenant; without this, unread counts on the other site\n // would silently drop. Using `returning({ id })` also gives us\n // an exact count instead of a follow-up SELECT — replaces the\n // pre-existing best-effort COUNT round trip.\n // #272 — write: must NOT silently fall through.\n const siteId = await requireSiteId();\n const updated = (await db\n .update(npNotifications)\n .set({ readAt: new Date() })\n .where(\n and(\n eq(npNotifications.memberId, input.memberId),\n eq(npNotifications.siteId, siteId),\n inArray(npNotifications.id, input.notificationIds),\n isNull(npNotifications.readAt),\n ),\n )\n .returning({ id: npNotifications.id })) as Array<{ id: string }>;\n return updated.length;\n}\n\nexport async function markAllNotificationsRead(memberId: string): Promise<number> {\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n // Phase 18 — \"mark all read\" only marks the current site's\n // inbox so a member doesn't accidentally clear another\n // tenant's unread count when toggling on this one.\n // #272 — write: must NOT silently fall through.\n const siteId = await requireSiteId();\n const before = await unreadNotificationCount(memberId);\n await db\n .update(npNotifications)\n .set({ readAt: new Date() })\n .where(\n and(\n eq(npNotifications.memberId, memberId),\n eq(npNotifications.siteId, siteId),\n isNull(npNotifications.readAt),\n ),\n );\n return before;\n}\n\n/**\n * Internal sanity check used by the API: throws when one principal\n * tries to read another member's notification. Centralised here\n * because every per-id route gets the same rule.\n */\nexport async function assertOwnsNotification(\n memberId: string,\n notificationId: string,\n): Promise<void> {\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n const [row] = (await db\n .select({ memberId: npNotifications.memberId })\n .from(npNotifications)\n .where(eq(npNotifications.id, notificationId))\n .limit(1)) as Array<{ memberId: string }>;\n if (!row || row.memberId !== memberId) {\n throw new NpForbiddenError(\"notification\", \"read\");\n }\n}\n"],"mappings":"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","names":["inArray","inArray"]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/community/can.ts","../src/community/roles.ts"],"sourcesContent":["import { and, eq, gt, isNull, or } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\n\nimport { NpForbiddenError } from \"../errors.js\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npBans, npMemberRoles } from \"../db/schema/community.js\";\nimport { getCurrentSiteId } from \"../sites/context.js\";\nimport { NP_DEFAULT_SITE_ID } from \"../sites/registry.js\";\nimport { type CommunityCapability, type CommunityScope, getCommunityRole } from \"./roles.js\";\n\n/**\n * Active-ban probe shared by `memberCan` and direct write-path\n * callers. The community write services (`createComment`,\n * `addReaction`, `fileReport`, `follow`) call `assertNotBanned`\n * straight away — they never went through `memberCan`, so without\n * this gate banned members could still write community content\n * even though their bans were recorded. (#53)\n *\n * Ban-match rules:\n * - `site` ban → blocks every write.\n * - `category` / `collection` ban → blocks when the action's scope\n * chain contains the matching scope.\n *\n * The `or()` helper is required for the `expires_at IS NULL OR\n * expires_at > now` clause; the previous raw `sql` template let\n * Postgres' AND-binds-tighter-than-OR rule re-associate and leak\n * other members' bans (same precedence trap as #006 in 9.5\n * postmortem).\n */\nexport async function isMemberBanned(\n memberId: string,\n scopes: ReadonlyArray<{ type: CommunityScope; id: string }> = [],\n db?: NodePgDatabase<Record<string, unknown>>,\n now: Date = new Date(),\n): Promise<boolean> {\n const handle = db ?? (getDb() as unknown as NodePgDatabase<Record<string, unknown>>);\n // Phase 18 — bans are tenant-scoped. A site-wide ban on\n // tenant A doesn't block writes on tenant B; the ban row\n // includes `site_id` and we filter by the resolver's\n // current value.\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const bans = (await handle\n .select({\n scopeType: npBans.scopeType,\n scopeId: npBans.scopeId,\n })\n .from(npBans)\n .where(\n and(\n eq(npBans.memberId, memberId),\n eq(npBans.siteId, siteId),\n or(isNull(npBans.expiresAt), gt(npBans.expiresAt, now)),\n ),\n )) as Array<{\n scopeType: \"site\" | \"category\" | \"collection\";\n scopeId: string | null;\n }>;\n\n return bans.some((ban) => {\n if (ban.scopeType === \"site\") return true;\n return scopes.some((s) => s.type === ban.scopeType && s.id === ban.scopeId);\n });\n}\n\n/**\n * Throws `NpForbiddenError` if the member is currently banned for any\n * scope in the chain. Used at the top of community write services\n * before any DB mutation. Pre-existing `memberCan` enforces the same\n * rule for permission-based actions; this helper is the catch-all\n * for write paths that don't go through capability checks.\n */\nexport async function assertNotBanned(\n memberId: string,\n scopes: ReadonlyArray<{ type: CommunityScope; id: string }> = [],\n): Promise<void> {\n if (await isMemberBanned(memberId, scopes)) {\n throw new NpForbiddenError(\"community\", \"banned\");\n }\n}\n\n/**\n * Structural enforcement of the ban-check gate (#311). Every\n * community write service should run inside this wrapper — the ban\n * check fires before `fn` and a service author can't accidentally\n * ship a new write path that skips it.\n *\n * Pre-validation that doesn't write (input shape, target lookup\n * existence) can run *before* this call; the gate is specifically\n * for the moment between \"we know enough to attempt the write\" and\n * the first DB mutation.\n *\n * `scopes` is the same chain `assertNotBanned` accepts — pass\n * `[{ type: \"collection\", id: targetType }]` for collection-scoped\n * actions, leave empty for site-wide-only enforcement (e.g. follows,\n * polymorphic-target reactions where no obvious scope chain exists).\n */\nexport async function withMemberWrite<T>(\n memberId: string,\n scopes: ReadonlyArray<{ type: CommunityScope; id: string }>,\n fn: () => Promise<T>,\n): Promise<T> {\n await assertNotBanned(memberId, scopes);\n return fn();\n}\n\n/**\n * Action a member is attempting. Most actions are real\n * `CommunityCapability` literals — those map 1:1 to a role's\n * capability list. The two exceptions are `\"edit-own\"` and\n * `\"delete-own\"`, which short-circuit on ownership without consulting\n * grants at all.\n */\nexport type MemberAction = CommunityCapability | \"edit-own\" | \"delete-own\";\n\n/**\n * Caller-provided context for a permission check. The caller — the\n * comment service, a future thread service, etc. — provides the\n * target's ownership + scope chain rather than `memberCan` looking\n * it up via a polymorphic join. This keeps the resolver decoupled\n * from the per-target table layout, and lets the surface evolve\n * without touching this resolver.\n */\nexport interface MemberCanTarget {\n /** Free-form target type — `\"comment\" | \"thread\" | \"reply\" | \"category\" | \"report\" | \"member\"`. */\n type: string;\n /** Stable id for logs / future denial reasons. */\n id: string;\n /** Member id of the target's author. Required for own-action checks. */\n ownerId?: string;\n /**\n * Scope chain from most specific to least specific. A reply might\n * provide `[{ type: \"thread\", id: \"<threadId>\" }, { type: \"category\",\n * id: \"<categoryId>\" }]`; the resolver also checks site-wide grants\n * regardless of what's in the chain.\n */\n scopes?: ReadonlyArray<{ type: CommunityScope; id: string }>;\n}\n\ninterface MemberCanOptions {\n /** Override the DB handle (tests). Defaults to `getDb()`. */\n db?: NodePgDatabase<Record<string, unknown>>;\n /** Reference time for ban/grant expiry checks. Defaults to `new Date()`. */\n now?: Date;\n}\n\n/**\n * Returns true when `memberId` is allowed to perform `action` on\n * `target`. Walk order:\n *\n * 1. Active scoped ban → deny everything.\n * 2. `edit-own` / `delete-own` → allow only when `target.ownerId === memberId`.\n * 3. Site-wide grants whose role's capability list includes `action`.\n * 4. Scoped grants matching any element of `target.scopes`, whose role\n * includes `action`.\n * 5. Otherwise deny.\n *\n * The resolver ignores staff (`np_users`) entirely. Staff bypass is the\n * caller's responsibility — typically `principalCan(principal, …)` at\n * the API layer, which routes to `memberCan` only when the principal\n * is a member.\n */\nexport async function memberCan(\n memberId: string,\n action: MemberAction,\n target: MemberCanTarget,\n options: MemberCanOptions = {},\n): Promise<boolean> {\n const db = options.db ?? (getDb() as unknown as NodePgDatabase<Record<string, unknown>>);\n const now = options.now ?? new Date();\n const scopes = target.scopes ?? [];\n\n // Step 1: ban check. Site-wide bans always apply; scoped bans match\n // when the target's scope chain contains the ban's scope.\n const isBanned = await isMemberBanned(memberId, scopes, db, now);\n if (isBanned) return false;\n\n // Step 2: ownership shortcut for own-content actions.\n if (action === \"edit-own\" || action === \"delete-own\") {\n return Boolean(target.ownerId) && target.ownerId === memberId;\n }\n\n // Step 3+4: walk grants. Pull the member's unexpired grants\n // on the current tenant only — a community-mod on tenant A\n // shouldn't authorize actions on tenant B. Site-wide grants\n // (scope_type='site') still match every action on the\n // resolved tenant.\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const grants = (await db\n .select({\n role: npMemberRoles.role,\n scopeType: npMemberRoles.scopeType,\n scopeId: npMemberRoles.scopeId,\n })\n .from(npMemberRoles)\n .where(\n and(\n eq(npMemberRoles.memberId, memberId),\n eq(npMemberRoles.siteId, siteId),\n or(isNull(npMemberRoles.expiresAt), gt(npMemberRoles.expiresAt, now)),\n ),\n )) as Array<{\n role: string;\n scopeType: CommunityScope;\n scopeId: string | null;\n }>;\n\n for (const grant of grants) {\n const def = getCommunityRole(grant.role, grant.scopeType);\n if (!def) continue;\n if (!def.capabilities.includes(action)) continue;\n\n if (grant.scopeType === \"site\") {\n return true;\n }\n const matchesTargetScope = scopes.some(\n (s) => s.type === grant.scopeType && s.id === grant.scopeId,\n );\n if (matchesTargetScope) return true;\n }\n\n return false;\n}\n","/**\n * Community role registry. Maps a role name + scope type to the\n * capabilities a grant of that role unlocks. Plugins extend the registry\n * via `registerCommunityRole(...)` (gated by the `members:write` or\n * `community:moderate` capability — enforced at registration time, not\n * here).\n *\n * The capability vocabulary is the single source of truth for \"what\n * actions exist in the community.\" `memberCan()` (../community/can.ts)\n * looks up grants and matches their roles' capability lists against the\n * requested action.\n */\n\nexport type CommunityScope = \"site\" | \"category\" | \"collection\" | \"thread\";\n\n/**\n * Action vocabulary. Adding new actions later is fine, but rename with\n * care — built-in role definitions reference these literals and a\n * silent typo widens permissions instead of narrowing them.\n */\nexport type CommunityCapability =\n | \"hide-comment\"\n | \"restore-comment\"\n | \"edit-any-comment\"\n | \"delete-any-comment\"\n | \"hide-thread\"\n | \"restore-thread\"\n | \"lock-thread\"\n | \"unlock-thread\"\n | \"pin-thread\"\n | \"unpin-thread\"\n | \"edit-any-thread\"\n | \"delete-any-thread\"\n | \"edit-own-thread\"\n | \"lock-own-thread\"\n | \"ban-member\"\n | \"unban-member\"\n | \"resolve-report\"\n | \"manage-category\"\n | \"view-staff-tools\";\n\nexport interface CommunityRoleDefinition {\n /** e.g. `\"category-mod\"`. Plugins can ship custom roles like `\"tag-mod\"`. */\n role: string;\n /** What kind of scope a grant of this role applies to. */\n scopeType: CommunityScope;\n /** Capabilities a grant of this role unlocks within its scope. */\n capabilities: readonly CommunityCapability[];\n /**\n * Human-readable label for admin UIs that surface a role picker. Falls\n * back to `role` when omitted.\n */\n label?: string;\n /** Optional plugin id that registered this role; null for built-ins. */\n source?: string;\n}\n\nconst ALL_MOD_CAPS: readonly CommunityCapability[] = [\n \"hide-comment\",\n \"restore-comment\",\n \"edit-any-comment\",\n \"delete-any-comment\",\n \"hide-thread\",\n \"restore-thread\",\n \"lock-thread\",\n \"unlock-thread\",\n \"pin-thread\",\n \"unpin-thread\",\n \"edit-any-thread\",\n \"delete-any-thread\",\n \"ban-member\",\n \"unban-member\",\n \"resolve-report\",\n \"view-staff-tools\",\n];\n\nconst builtInRoles: CommunityRoleDefinition[] = [\n {\n role: \"community-mod\",\n scopeType: \"site\",\n label: \"Community moderator\",\n capabilities: [...ALL_MOD_CAPS, \"manage-category\"],\n },\n {\n role: \"category-mod\",\n scopeType: \"category\",\n label: \"Category moderator\",\n capabilities: ALL_MOD_CAPS,\n },\n {\n role: \"collection-mod\",\n scopeType: \"collection\",\n label: \"Collection moderator\",\n // Collection-mods only have authority over the comments under a\n // collection's documents. Thread-only capabilities don't apply, so\n // they're omitted on purpose.\n capabilities: [\n \"hide-comment\",\n \"restore-comment\",\n \"edit-any-comment\",\n \"delete-any-comment\",\n \"ban-member\",\n \"unban-member\",\n \"resolve-report\",\n \"view-staff-tools\",\n ],\n },\n {\n role: \"thread-author\",\n scopeType: \"thread\",\n label: \"Thread author\",\n // Auto-granted on thread create. Lets the OP edit / lock their own\n // thread without giving them broader powers.\n capabilities: [\"edit-own-thread\", \"lock-own-thread\"],\n },\n];\n\nconst customRoles: CommunityRoleDefinition[] = [];\n\nfunction key(role: string, scopeType: CommunityScope): string {\n return `${scopeType}:${role}`;\n}\n\n/**\n * Plugins call this from setup() to add their own role kinds. Throws\n * when the (role, scopeType) pair is already registered to keep the\n * registry deterministic — a plugin overriding a built-in role would\n * silently widen permissions and is almost always a mistake.\n */\nexport function registerCommunityRole(definition: CommunityRoleDefinition): void {\n const composite = key(definition.role, definition.scopeType);\n if (\n builtInRoles.some((b) => key(b.role, b.scopeType) === composite) ||\n customRoles.some((c) => key(c.role, c.scopeType) === composite)\n ) {\n throw new Error(\n `[community] role \"${definition.role}\" already registered for scope \"${definition.scopeType}\".`,\n );\n }\n customRoles.push({ ...definition });\n}\n\n/** Look up a role by `(role, scopeType)`. Returns undefined when unknown. */\nexport function getCommunityRole(\n role: string,\n scopeType: CommunityScope,\n): CommunityRoleDefinition | undefined {\n const composite = key(role, scopeType);\n return (\n builtInRoles.find((b) => key(b.role, b.scopeType) === composite) ??\n customRoles.find((c) => key(c.role, c.scopeType) === composite)\n );\n}\n\n/**\n * Returns every role currently registered, built-ins first then\n * plugin-defined. Used by the admin role picker to render selectable\n * options for a given scope.\n */\nexport function listCommunityRoles(scopeType?: CommunityScope): CommunityRoleDefinition[] {\n const all = [...builtInRoles, ...customRoles];\n return scopeType ? all.filter((r) => r.scopeType === scopeType) : all;\n}\n\n/** Tests reset state between cases; production callers should never need this. */\nexport function resetCommunityRoles(): void {\n customRoles.length = 0;\n}\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/community/settings.ts"],"sourcesContent":["import { and, eq } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npSettings } from \"../db/schema/system.js\";\nimport { NpValidationError } from \"../errors.js\";\n\n/**\n * Site-wide community settings, persisted in the generic `np_settings`\n * table under the `community` key. Sites that never visit the admin UI\n * inherit `DEFAULT_COMMUNITY_SETTINGS` — every read goes through\n * `getCommunitySettings()` which merges the stored value over the\n * defaults so adding a new field doesn't break existing installs.\n *\n * Validation runs on the write path only — readers trust whatever is\n * in the table because the only writer is the admin API which\n * pre-validates. Tests poke values directly into `np_settings` for\n * fault-injection cases.\n */\n/**\n * Per-member upload quota / rate limit. `null` on either field\n * means unlimited (the default — no quota). Both bounds count\n * non-deleted rows on `np_media` keyed by `uploaded_by_member_id`,\n * so admin purges (Phase 9.7l) free up quota the same way a\n * member self-deleting their content would. Staff uploads are\n * never gated.\n */\nexport interface NpMemberUploadQuota {\n /** Max uploads in the trailing 24h window. `null` = unlimited. */\n perDay: number | null;\n /** Lifetime cap on non-deleted member uploads. `null` = unlimited. */\n total: number | null;\n}\n\nexport interface NpCommunitySettings {\n /**\n * Allow-list of reaction `kind` strings. Members can only add\n * reactions whose kind is in this list; values that pass the\n * `KIND_RE` regex but aren't in the list are rejected with a 400.\n * Removal of an already-existing reaction is NOT gated — if a kind\n * is removed from the list, members can still un-react it.\n */\n reactionKinds: string[];\n /**\n * When false, `/api/members/register` refuses new sign-ups with a\n * 403. Existing members can still sign in. Sites that want\n * invite-only flows turn this off and provision via admin tooling.\n */\n registrationEnabled: boolean;\n /** Per-member upload limits. See `NpMemberUploadQuota`. */\n memberUploadQuota: NpMemberUploadQuota;\n}\n\nexport const DEFAULT_COMMUNITY_SETTINGS: NpCommunitySettings = {\n reactionKinds: [\"like\"],\n registrationEnabled: true,\n memberUploadQuota: { perDay: null, total: null },\n};\n\nconst SETTINGS_KEY = \"community\";\nconst KIND_RE = /^[a-z][a-z0-9_-]{0,29}$/;\nconst MAX_REACTION_KINDS = 32;\nconst MAX_QUOTA_VALUE = 1_000_000;\n\nfunction mergeWithDefaults(stored: unknown): NpCommunitySettings {\n if (!stored || typeof stored !== \"object\" || Array.isArray(stored)) {\n return { ...DEFAULT_COMMUNITY_SETTINGS };\n }\n const raw = stored as Record<string, unknown>;\n const reactionKinds =\n Array.isArray(raw.reactionKinds) && raw.reactionKinds.every((k) => typeof k === \"string\")\n ? (raw.reactionKinds)\n : DEFAULT_COMMUNITY_SETTINGS.reactionKinds;\n const registrationEnabled =\n typeof raw.registrationEnabled === \"boolean\"\n ? raw.registrationEnabled\n : DEFAULT_COMMUNITY_SETTINGS.registrationEnabled;\n const memberUploadQuota = readQuota(raw.memberUploadQuota);\n return { reactionKinds, registrationEnabled, memberUploadQuota };\n}\n\nfunction readQuota(raw: unknown): NpMemberUploadQuota {\n if (!raw || typeof raw !== \"object\" || Array.isArray(raw)) {\n return { ...DEFAULT_COMMUNITY_SETTINGS.memberUploadQuota };\n }\n const v = raw as Record<string, unknown>;\n const perDay =\n typeof v.perDay === \"number\" && Number.isFinite(v.perDay) && v.perDay >= 0\n ? Math.floor(v.perDay)\n : null;\n const total =\n typeof v.total === \"number\" && Number.isFinite(v.total) && v.total >= 0\n ? Math.floor(v.total)\n : null;\n return { perDay, total };\n}\n\nexport async function getCommunitySettings(): Promise<NpCommunitySettings> {\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n const { getCurrentSiteId } = await import(\"../sites/context.js\");\n const { NP_DEFAULT_SITE_ID } = await import(\"../sites/registry.js\");\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const [row] = (await db\n .select()\n .from(npSettings)\n .where(and(eq(npSettings.siteId, siteId), eq(npSettings.key, SETTINGS_KEY)))\n .limit(1)) as Array<{ value: unknown }>;\n return mergeWithDefaults(row?.value);\n}\n\n/**\n * Validates an incoming partial patch from the admin UI. Returns the\n * fully-merged settings object that should be persisted. Throws\n * `NpValidationError` with field-level errors on any malformed input.\n */\nexport function validateCommunitySettingsPatch(\n current: NpCommunitySettings,\n patch: unknown,\n): NpCommunitySettings {\n if (!patch || typeof patch !== \"object\" || Array.isArray(patch)) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"body\", message: \"Body must be a JSON object\" },\n ]);\n }\n const raw = patch as Record<string, unknown>;\n const errors: Array<{ field: string; message: string }> = [];\n let next: NpCommunitySettings = { ...current };\n\n if (\"reactionKinds\" in raw) {\n if (!Array.isArray(raw.reactionKinds)) {\n errors.push({ field: \"reactionKinds\", message: \"Must be an array of strings\" });\n } else if (raw.reactionKinds.length === 0) {\n // Empty list disables reactions entirely. Allowed deliberately —\n // sites that don't want reactions opt out by clearing the list.\n next = { ...next, reactionKinds: [] };\n } else if (raw.reactionKinds.length > MAX_REACTION_KINDS) {\n errors.push({\n field: \"reactionKinds\",\n message: `At most ${MAX_REACTION_KINDS} kinds`,\n });\n } else {\n const seen = new Set<string>();\n const cleaned: string[] = [];\n for (let i = 0; i < raw.reactionKinds.length; i++) {\n const k = raw.reactionKinds[i];\n if (typeof k !== \"string\" || !KIND_RE.test(k)) {\n errors.push({\n field: `reactionKinds[${i}]`,\n message: \"Each kind must match [a-z][a-z0-9_-]{0,29}\",\n });\n continue;\n }\n if (seen.has(k)) {\n errors.push({ field: `reactionKinds[${i}]`, message: `Duplicate kind '${k}'` });\n continue;\n }\n seen.add(k);\n cleaned.push(k);\n }\n if (errors.length === 0) next = { ...next, reactionKinds: cleaned };\n }\n }\n\n if (\"registrationEnabled\" in raw) {\n if (typeof raw.registrationEnabled !== \"boolean\") {\n errors.push({ field: \"registrationEnabled\", message: \"Must be a boolean\" });\n } else {\n next = { ...next, registrationEnabled: raw.registrationEnabled };\n }\n }\n\n if (\"memberUploadQuota\" in raw) {\n const q = raw.memberUploadQuota;\n if (!q || typeof q !== \"object\" || Array.isArray(q)) {\n errors.push({\n field: \"memberUploadQuota\",\n message: \"Must be an object with optional `perDay` / `total` keys\",\n });\n } else {\n const obj = q as Record<string, unknown>;\n const validateBound = (\n key: \"perDay\" | \"total\",\n ): number | null | undefined => {\n if (!(key in obj)) return undefined; // not patched — keep current\n const v = obj[key];\n if (v === null) return null;\n if (typeof v !== \"number\" || !Number.isFinite(v) || v < 0 || !Number.isInteger(v)) {\n errors.push({\n field: `memberUploadQuota.${key}`,\n message: \"Must be a non-negative integer or null\",\n });\n return undefined;\n }\n if (v > MAX_QUOTA_VALUE) {\n errors.push({\n field: `memberUploadQuota.${key}`,\n message: `At most ${MAX_QUOTA_VALUE}`,\n });\n return undefined;\n }\n return v;\n };\n const perDay = validateBound(\"perDay\");\n const total = validateBound(\"total\");\n if (perDay !== undefined || total !== undefined) {\n next = {\n ...next,\n memberUploadQuota: {\n perDay: perDay !== undefined ? perDay : next.memberUploadQuota.perDay,\n total: total !== undefined ? total : next.memberUploadQuota.total,\n },\n };\n }\n }\n }\n\n if (errors.length > 0) throw new NpValidationError(\"Invalid input\", errors);\n return next;\n}\n\nexport async function updateCommunitySettings(\n patch: unknown,\n updatedBy: string | null,\n): Promise<NpCommunitySettings> {\n const current = await getCommunitySettings();\n const next = validateCommunitySettingsPatch(current, patch);\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n // #272 — write: must NOT silently fall through. A staff member\n // on tenant A who saves community settings without a resolved\n // site context would otherwise overwrite the default tenant's\n // policy — silently and across tenants.\n const { requireSiteId } = await import(\"../sites/context.js\");\n const siteId = await requireSiteId();\n await db\n .insert(npSettings)\n .values({\n siteId,\n key: SETTINGS_KEY,\n value: next,\n updatedBy: updatedBy ?? null,\n updatedAt: new Date(),\n })\n .onConflictDoUpdate({\n target: [npSettings.siteId, npSettings.key],\n set: {\n value: next,\n updatedBy: updatedBy ?? null,\n updatedAt: new Date(),\n },\n });\n return next;\n}\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/community/mutes.ts"],"sourcesContent":["import { and, desc, eq } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\n\nimport { getDb } from \"../db/runtime.js\";\nimport { npMemberMutes, npMembers } from \"../db/schema/community.js\";\nimport { NpNotFoundError, NpValidationError } from \"../errors.js\";\nimport { getCurrentSiteId, requireSiteId } from \"../sites/context.js\";\nimport { NP_DEFAULT_SITE_ID } from \"../sites/registry.js\";\n\n/**\n * Phase 16.1 — member-to-member mute. One-directional: A\n * muting B hides B from A's surfaces (comments, notification\n * fan-out). B keeps posting normally.\n *\n * Distinct from `np_bans` (staff-issued, global write block).\n * Mutes are always self-service: a member calls these helpers\n * for their own mute list, never for someone else's.\n */\n\nexport interface NpMemberMuteRow {\n memberId: string;\n targetId: string;\n createdAt: Date;\n}\n\nexport interface MuteMemberInput {\n /** The muter — the current member taking the action. */\n memberId: string;\n /** The muted — whose content should disappear. */\n targetId: string;\n}\n\nexport async function muteMember(input: MuteMemberInput): Promise<void> {\n if (input.memberId === input.targetId) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"targetId\", message: \"Cannot mute yourself.\" },\n ]);\n }\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n\n // Confirm both rows exist — otherwise the FK violation\n // surfaces as an opaque 500. NotFound is the right shape:\n // a deleted member shouldn't be muteable.\n const [muter] = (await db\n .select({ id: npMembers.id })\n .from(npMembers)\n .where(eq(npMembers.id, input.memberId))\n .limit(1)) as Array<{ id: string }>;\n if (!muter) throw new NpNotFoundError(\"member\", input.memberId);\n const [target] = (await db\n .select({ id: npMembers.id, status: npMembers.status })\n .from(npMembers)\n .where(eq(npMembers.id, input.targetId))\n .limit(1)) as Array<{ id: string; status: string }>;\n if (!target) throw new NpNotFoundError(\"member\", input.targetId);\n\n // Phase 18 — site_id is part of the PK so the same muter can\n // hold a separate \"muted-on-site-A\" / \"muted-on-site-B\" set.\n // Idempotent: muting twice on the same site doesn't error.\n // #272 — write: must NOT silently fall through to default site.\n const siteId = await requireSiteId();\n await db\n .insert(npMemberMutes)\n .values({\n memberId: input.memberId,\n targetId: input.targetId,\n siteId,\n })\n .onConflictDoNothing();\n}\n\nexport async function unmuteMember(input: MuteMemberInput): Promise<boolean> {\n if (input.memberId === input.targetId) {\n throw new NpValidationError(\"Invalid input\", [\n { field: \"targetId\", message: \"Cannot unmute yourself.\" },\n ]);\n }\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n // #272 — write: must NOT silently fall through to default site.\n const siteId = await requireSiteId();\n const result = (await db\n .delete(npMemberMutes)\n .where(\n and(\n eq(npMemberMutes.memberId, input.memberId),\n eq(npMemberMutes.targetId, input.targetId),\n eq(npMemberMutes.siteId, siteId),\n ),\n )\n .returning({ memberId: npMemberMutes.memberId })) as Array<{\n memberId: string;\n }>;\n return result.length > 0;\n}\n\n/**\n * `true` when `memberId` has muted `targetId` on the current\n * site. Used by comment listing + notification fan-out to\n * filter views and skip alerts.\n */\nexport async function isMuted(input: MuteMemberInput): Promise<boolean> {\n if (input.memberId === input.targetId) return false;\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const [row] = (await db\n .select({ memberId: npMemberMutes.memberId })\n .from(npMemberMutes)\n .where(\n and(\n eq(npMemberMutes.memberId, input.memberId),\n eq(npMemberMutes.targetId, input.targetId),\n eq(npMemberMutes.siteId, siteId),\n ),\n )\n .limit(1)) as Array<{ memberId: string }>;\n return !!row;\n}\n\n/**\n * Returns the set of `targetId`s the given member has muted on\n * the current site. Used to filter listComments output in one\n * DB round-trip rather than `isMuted()` per row.\n */\nexport async function getMutedTargetIds(memberId: string): Promise<Set<string>> {\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const rows = (await db\n .select({ targetId: npMemberMutes.targetId })\n .from(npMemberMutes)\n .where(and(eq(npMemberMutes.memberId, memberId), eq(npMemberMutes.siteId, siteId)))) as Array<{\n targetId: string;\n }>;\n return new Set(rows.map((r) => r.targetId));\n}\n\nexport interface NpMemberMuteSummary {\n targetId: string;\n handle: string;\n displayName: string;\n createdAt: string;\n}\n\nexport interface ListMutesOptions {\n /** Default 50, max 200. */\n limit?: number;\n}\n\n/**\n * Surfaces the muter's list with the muted member's display\n * info joined in, so the settings UI doesn't have to round-\n * trip through `/api/members/[handle]` for every row.\n */\nexport async function listMutes(\n memberId: string,\n options: ListMutesOptions = {},\n): Promise<NpMemberMuteSummary[]> {\n const db = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n const limit = Math.min(Math.max(options.limit ?? 50, 1), 200);\n // Phase 18 — settings list is per-site. The same muter can\n // see different lists on different tenants.\n const siteId = (await getCurrentSiteId()) ?? NP_DEFAULT_SITE_ID;\n const rows = (await db\n .select({\n targetId: npMemberMutes.targetId,\n handle: npMembers.handle,\n displayName: npMembers.displayName,\n createdAt: npMemberMutes.createdAt,\n })\n .from(npMemberMutes)\n .innerJoin(npMembers, eq(npMemberMutes.targetId, npMembers.id))\n .where(and(eq(npMemberMutes.memberId, memberId), eq(npMemberMutes.siteId, siteId)))\n .orderBy(desc(npMemberMutes.createdAt))\n .limit(limit)) as Array<{\n targetId: string;\n handle: string;\n displayName: string;\n createdAt: Date;\n }>;\n return rows.map((r) => ({\n targetId: r.targetId,\n handle: r.handle,\n displayName: r.displayName,\n createdAt: r.createdAt.toISOString(),\n }));\n}\n"],"mappings":"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","names":[]}