@nexpress/core 0.1.0

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 (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +69 -0
  3. package/dist/audit-54XLVCWD.js +14 -0
  4. package/dist/audit-54XLVCWD.js.map +1 -0
  5. package/dist/auth.d.ts +640 -0
  6. package/dist/auth.js +94 -0
  7. package/dist/auth.js.map +1 -0
  8. package/dist/can-YLUHRJAB.js +19 -0
  9. package/dist/can-YLUHRJAB.js.map +1 -0
  10. package/dist/chunk-2G264RCD.js +68 -0
  11. package/dist/chunk-2G264RCD.js.map +1 -0
  12. package/dist/chunk-2YDGE7YX.js +92 -0
  13. package/dist/chunk-2YDGE7YX.js.map +1 -0
  14. package/dist/chunk-473S4TER.js +538 -0
  15. package/dist/chunk-473S4TER.js.map +1 -0
  16. package/dist/chunk-4ZLMEKFX.js +18 -0
  17. package/dist/chunk-4ZLMEKFX.js.map +1 -0
  18. package/dist/chunk-55FU6WED.js +179 -0
  19. package/dist/chunk-55FU6WED.js.map +1 -0
  20. package/dist/chunk-6YI5K2TI.js +1959 -0
  21. package/dist/chunk-6YI5K2TI.js.map +1 -0
  22. package/dist/chunk-BHK3AD3Q.js +41 -0
  23. package/dist/chunk-BHK3AD3Q.js.map +1 -0
  24. package/dist/chunk-CRUQBZUF.js +39 -0
  25. package/dist/chunk-CRUQBZUF.js.map +1 -0
  26. package/dist/chunk-CTSQ7BRI.js +175 -0
  27. package/dist/chunk-CTSQ7BRI.js.map +1 -0
  28. package/dist/chunk-DK2JBJH7.js +81 -0
  29. package/dist/chunk-DK2JBJH7.js.map +1 -0
  30. package/dist/chunk-DP2PREDU.js +597 -0
  31. package/dist/chunk-DP2PREDU.js.map +1 -0
  32. package/dist/chunk-EQ2Z3KMD.js +24 -0
  33. package/dist/chunk-EQ2Z3KMD.js.map +1 -0
  34. package/dist/chunk-FZ7O6DWI.js +305 -0
  35. package/dist/chunk-FZ7O6DWI.js.map +1 -0
  36. package/dist/chunk-ISLYFQWL.js +1270 -0
  37. package/dist/chunk-ISLYFQWL.js.map +1 -0
  38. package/dist/chunk-JJL74ZPK.js +68 -0
  39. package/dist/chunk-JJL74ZPK.js.map +1 -0
  40. package/dist/chunk-JKXAPSU4.js +24 -0
  41. package/dist/chunk-JKXAPSU4.js.map +1 -0
  42. package/dist/chunk-KU5M27ZC.js +24 -0
  43. package/dist/chunk-KU5M27ZC.js.map +1 -0
  44. package/dist/chunk-LSHHRDVR.js +34 -0
  45. package/dist/chunk-LSHHRDVR.js.map +1 -0
  46. package/dist/chunk-M43PGOQY.js +715 -0
  47. package/dist/chunk-M43PGOQY.js.map +1 -0
  48. package/dist/chunk-MEJAHXIO.js +150 -0
  49. package/dist/chunk-MEJAHXIO.js.map +1 -0
  50. package/dist/chunk-NUCGHWCF.js +101 -0
  51. package/dist/chunk-NUCGHWCF.js.map +1 -0
  52. package/dist/chunk-OK5HOCQI.js +845 -0
  53. package/dist/chunk-OK5HOCQI.js.map +1 -0
  54. package/dist/chunk-OROPGO65.js +13 -0
  55. package/dist/chunk-OROPGO65.js.map +1 -0
  56. package/dist/chunk-PPAS4SZR.js +176 -0
  57. package/dist/chunk-PPAS4SZR.js.map +1 -0
  58. package/dist/chunk-PPBWRKO2.js +171 -0
  59. package/dist/chunk-PPBWRKO2.js.map +1 -0
  60. package/dist/chunk-PZ5AY32C.js +10 -0
  61. package/dist/chunk-PZ5AY32C.js.map +1 -0
  62. package/dist/chunk-QO7LAQZH.js +321 -0
  63. package/dist/chunk-QO7LAQZH.js.map +1 -0
  64. package/dist/chunk-QVJ2HCAX.js +225 -0
  65. package/dist/chunk-QVJ2HCAX.js.map +1 -0
  66. package/dist/chunk-RIPHIRPP.js +68 -0
  67. package/dist/chunk-RIPHIRPP.js.map +1 -0
  68. package/dist/chunk-S27S42QY.js +134 -0
  69. package/dist/chunk-S27S42QY.js.map +1 -0
  70. package/dist/chunk-SBCVAC2Z.js +40 -0
  71. package/dist/chunk-SBCVAC2Z.js.map +1 -0
  72. package/dist/chunk-TFJ4MKPH.js +694 -0
  73. package/dist/chunk-TFJ4MKPH.js.map +1 -0
  74. package/dist/chunk-THX3SHYA.js +75 -0
  75. package/dist/chunk-THX3SHYA.js.map +1 -0
  76. package/dist/chunk-UGQSQO5B.js +222 -0
  77. package/dist/chunk-UGQSQO5B.js.map +1 -0
  78. package/dist/chunk-V2UNHGAP.js +26 -0
  79. package/dist/chunk-V2UNHGAP.js.map +1 -0
  80. package/dist/chunk-VGTPQXNQ.js +2790 -0
  81. package/dist/chunk-VGTPQXNQ.js.map +1 -0
  82. package/dist/chunk-VNIHXQ7W.js +194 -0
  83. package/dist/chunk-VNIHXQ7W.js.map +1 -0
  84. package/dist/chunk-WV272MPW.js +31 -0
  85. package/dist/chunk-WV272MPW.js.map +1 -0
  86. package/dist/chunk-X5KKBOUS.js +26 -0
  87. package/dist/chunk-X5KKBOUS.js.map +1 -0
  88. package/dist/chunk-XANPEOJC.js +17 -0
  89. package/dist/chunk-XANPEOJC.js.map +1 -0
  90. package/dist/chunk-XPVQIHAQ.js +83 -0
  91. package/dist/chunk-XPVQIHAQ.js.map +1 -0
  92. package/dist/chunk-ZCINJSS4.js +75 -0
  93. package/dist/chunk-ZCINJSS4.js.map +1 -0
  94. package/dist/community.d.ts +1425 -0
  95. package/dist/community.js +206 -0
  96. package/dist/community.js.map +1 -0
  97. package/dist/config-2GDU7PCK.js +32 -0
  98. package/dist/config-2GDU7PCK.js.map +1 -0
  99. package/dist/context-MNZ4QXPC.js +16 -0
  100. package/dist/context-MNZ4QXPC.js.map +1 -0
  101. package/dist/db-schema.d.ts +4 -0
  102. package/dist/db-schema.js +102 -0
  103. package/dist/db-schema.js.map +1 -0
  104. package/dist/db.d.ts +7 -0
  105. package/dist/db.js +117 -0
  106. package/dist/db.js.map +1 -0
  107. package/dist/digest-SY42GQSU.js +17 -0
  108. package/dist/digest-SY42GQSU.js.map +1 -0
  109. package/dist/errors-5OS3S2J3.js +22 -0
  110. package/dist/errors-5OS3S2J3.js.map +1 -0
  111. package/dist/host-OBOI4MJK.js +51 -0
  112. package/dist/host-OBOI4MJK.js.map +1 -0
  113. package/dist/i18n.d.ts +301 -0
  114. package/dist/i18n.js +68 -0
  115. package/dist/i18n.js.map +1 -0
  116. package/dist/index-B6-_vr_m.d.ts +590 -0
  117. package/dist/index-CY55LC0u.d.ts +4722 -0
  118. package/dist/index-CeiTvwbp.d.ts +168 -0
  119. package/dist/index-XwP1ET8b.d.ts +61 -0
  120. package/dist/index.d.ts +2037 -0
  121. package/dist/index.js +2205 -0
  122. package/dist/index.js.map +1 -0
  123. package/dist/job-log-VZXWQUDK.js +24 -0
  124. package/dist/job-log-VZXWQUDK.js.map +1 -0
  125. package/dist/jobs.d.ts +4 -0
  126. package/dist/jobs.js +76 -0
  127. package/dist/jobs.js.map +1 -0
  128. package/dist/logger-DqGaOU_j.d.ts +29 -0
  129. package/dist/logger-S7REWDNE.js +16 -0
  130. package/dist/logger-S7REWDNE.js.map +1 -0
  131. package/dist/media.d.ts +5 -0
  132. package/dist/media.js +41 -0
  133. package/dist/media.js.map +1 -0
  134. package/dist/mentions-2IHFVSHW.js +23 -0
  135. package/dist/mentions-2IHFVSHW.js.map +1 -0
  136. package/dist/mutes-EWAE5FZR.js +21 -0
  137. package/dist/mutes-EWAE5FZR.js.map +1 -0
  138. package/dist/notification-prefs-VPJDU7I6.js +21 -0
  139. package/dist/notification-prefs-VPJDU7I6.js.map +1 -0
  140. package/dist/observability.d.ts +156 -0
  141. package/dist/observability.js +32 -0
  142. package/dist/observability.js.map +1 -0
  143. package/dist/profanity-adapter-NU2JQSLX.js +12 -0
  144. package/dist/profanity-adapter-NU2JQSLX.js.map +1 -0
  145. package/dist/queue-XE5BC75T.js +14 -0
  146. package/dist/queue-XE5BC75T.js.map +1 -0
  147. package/dist/rate-limit.d.ts +99 -0
  148. package/dist/rate-limit.js +14 -0
  149. package/dist/rate-limit.js.map +1 -0
  150. package/dist/registry-XIXDEPVI.js +31 -0
  151. package/dist/registry-XIXDEPVI.js.map +1 -0
  152. package/dist/reputation-JRL2YQHM.js +11 -0
  153. package/dist/reputation-JRL2YQHM.js.map +1 -0
  154. package/dist/routes.d.ts +43 -0
  155. package/dist/routes.js +12 -0
  156. package/dist/routes.js.map +1 -0
  157. package/dist/scheduled-CIQM57HT.js +20 -0
  158. package/dist/scheduled-CIQM57HT.js.map +1 -0
  159. package/dist/seo.d.ts +410 -0
  160. package/dist/seo.js +44 -0
  161. package/dist/seo.js.map +1 -0
  162. package/dist/settings-FOBIESPB.js +17 -0
  163. package/dist/settings-FOBIESPB.js.map +1 -0
  164. package/dist/spam-adapter-XX3G737Z.js +12 -0
  165. package/dist/spam-adapter-XX3G737Z.js.map +1 -0
  166. package/dist/strings-VAE47B2C.js +29 -0
  167. package/dist/strings-VAE47B2C.js.map +1 -0
  168. package/dist/templates-IFVJMCJ6.js +12 -0
  169. package/dist/templates-IFVJMCJ6.js.map +1 -0
  170. package/dist/types-TlsbXS0T.d.ts +871 -0
  171. package/package.json +129 -0
@@ -0,0 +1,179 @@
1
+ import {
2
+ NP_DEFAULT_SITE_ID
3
+ } from "./chunk-FZ7O6DWI.js";
4
+ import {
5
+ getCurrentSiteId
6
+ } from "./chunk-SBCVAC2Z.js";
7
+ import {
8
+ NpForbiddenError
9
+ } from "./chunk-ZCINJSS4.js";
10
+ import {
11
+ getDb
12
+ } from "./chunk-XANPEOJC.js";
13
+ import {
14
+ npBans,
15
+ npMemberRoles
16
+ } from "./chunk-M43PGOQY.js";
17
+
18
+ // src/community/can.ts
19
+ import { and, eq, gt, isNull, or } from "drizzle-orm";
20
+
21
+ // src/community/roles.ts
22
+ var ALL_MOD_CAPS = [
23
+ "hide-comment",
24
+ "restore-comment",
25
+ "edit-any-comment",
26
+ "delete-any-comment",
27
+ "hide-thread",
28
+ "restore-thread",
29
+ "lock-thread",
30
+ "unlock-thread",
31
+ "pin-thread",
32
+ "unpin-thread",
33
+ "edit-any-thread",
34
+ "delete-any-thread",
35
+ "ban-member",
36
+ "unban-member",
37
+ "resolve-report",
38
+ "view-staff-tools"
39
+ ];
40
+ var builtInRoles = [
41
+ {
42
+ role: "community-mod",
43
+ scopeType: "site",
44
+ label: "Community moderator",
45
+ capabilities: [...ALL_MOD_CAPS, "manage-category"]
46
+ },
47
+ {
48
+ role: "category-mod",
49
+ scopeType: "category",
50
+ label: "Category moderator",
51
+ capabilities: ALL_MOD_CAPS
52
+ },
53
+ {
54
+ role: "collection-mod",
55
+ scopeType: "collection",
56
+ label: "Collection moderator",
57
+ // Collection-mods only have authority over the comments under a
58
+ // collection's documents. Thread-only capabilities don't apply, so
59
+ // they're omitted on purpose.
60
+ capabilities: [
61
+ "hide-comment",
62
+ "restore-comment",
63
+ "edit-any-comment",
64
+ "delete-any-comment",
65
+ "ban-member",
66
+ "unban-member",
67
+ "resolve-report",
68
+ "view-staff-tools"
69
+ ]
70
+ },
71
+ {
72
+ role: "thread-author",
73
+ scopeType: "thread",
74
+ label: "Thread author",
75
+ // Auto-granted on thread create. Lets the OP edit / lock their own
76
+ // thread without giving them broader powers.
77
+ capabilities: ["edit-own-thread", "lock-own-thread"]
78
+ }
79
+ ];
80
+ var customRoles = [];
81
+ function key(role, scopeType) {
82
+ return `${scopeType}:${role}`;
83
+ }
84
+ function registerCommunityRole(definition) {
85
+ const composite = key(definition.role, definition.scopeType);
86
+ if (builtInRoles.some((b) => key(b.role, b.scopeType) === composite) || customRoles.some((c) => key(c.role, c.scopeType) === composite)) {
87
+ throw new Error(
88
+ `[community] role "${definition.role}" already registered for scope "${definition.scopeType}".`
89
+ );
90
+ }
91
+ customRoles.push({ ...definition });
92
+ }
93
+ function getCommunityRole(role, scopeType) {
94
+ const composite = key(role, scopeType);
95
+ return builtInRoles.find((b) => key(b.role, b.scopeType) === composite) ?? customRoles.find((c) => key(c.role, c.scopeType) === composite);
96
+ }
97
+ function listCommunityRoles(scopeType) {
98
+ const all = [...builtInRoles, ...customRoles];
99
+ return scopeType ? all.filter((r) => r.scopeType === scopeType) : all;
100
+ }
101
+ function resetCommunityRoles() {
102
+ customRoles.length = 0;
103
+ }
104
+
105
+ // src/community/can.ts
106
+ async function isMemberBanned(memberId, scopes = [], db, now = /* @__PURE__ */ new Date()) {
107
+ const handle = db ?? getDb();
108
+ const siteId = await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
109
+ const bans = await handle.select({
110
+ scopeType: npBans.scopeType,
111
+ scopeId: npBans.scopeId
112
+ }).from(npBans).where(
113
+ and(
114
+ eq(npBans.memberId, memberId),
115
+ eq(npBans.siteId, siteId),
116
+ or(isNull(npBans.expiresAt), gt(npBans.expiresAt, now))
117
+ )
118
+ );
119
+ return bans.some((ban) => {
120
+ if (ban.scopeType === "site") return true;
121
+ return scopes.some((s) => s.type === ban.scopeType && s.id === ban.scopeId);
122
+ });
123
+ }
124
+ async function assertNotBanned(memberId, scopes = []) {
125
+ if (await isMemberBanned(memberId, scopes)) {
126
+ throw new NpForbiddenError("community", "banned");
127
+ }
128
+ }
129
+ async function withMemberWrite(memberId, scopes, fn) {
130
+ await assertNotBanned(memberId, scopes);
131
+ return fn();
132
+ }
133
+ async function memberCan(memberId, action, target, options = {}) {
134
+ const db = options.db ?? getDb();
135
+ const now = options.now ?? /* @__PURE__ */ new Date();
136
+ const scopes = target.scopes ?? [];
137
+ const isBanned = await isMemberBanned(memberId, scopes, db, now);
138
+ if (isBanned) return false;
139
+ if (action === "edit-own" || action === "delete-own") {
140
+ return Boolean(target.ownerId) && target.ownerId === memberId;
141
+ }
142
+ const siteId = await getCurrentSiteId() ?? NP_DEFAULT_SITE_ID;
143
+ const grants = await db.select({
144
+ role: npMemberRoles.role,
145
+ scopeType: npMemberRoles.scopeType,
146
+ scopeId: npMemberRoles.scopeId
147
+ }).from(npMemberRoles).where(
148
+ and(
149
+ eq(npMemberRoles.memberId, memberId),
150
+ eq(npMemberRoles.siteId, siteId),
151
+ or(isNull(npMemberRoles.expiresAt), gt(npMemberRoles.expiresAt, now))
152
+ )
153
+ );
154
+ for (const grant of grants) {
155
+ const def = getCommunityRole(grant.role, grant.scopeType);
156
+ if (!def) continue;
157
+ if (!def.capabilities.includes(action)) continue;
158
+ if (grant.scopeType === "site") {
159
+ return true;
160
+ }
161
+ const matchesTargetScope = scopes.some(
162
+ (s) => s.type === grant.scopeType && s.id === grant.scopeId
163
+ );
164
+ if (matchesTargetScope) return true;
165
+ }
166
+ return false;
167
+ }
168
+
169
+ export {
170
+ registerCommunityRole,
171
+ getCommunityRole,
172
+ listCommunityRoles,
173
+ resetCommunityRoles,
174
+ isMemberBanned,
175
+ assertNotBanned,
176
+ withMemberWrite,
177
+ memberCan
178
+ };
179
+ //# sourceMappingURL=chunk-55FU6WED.js.map
@@ -0,0 +1 @@
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":[]}