@classytic/arc 1.1.0 → 2.1.3

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 (200) hide show
  1. package/README.md +247 -794
  2. package/bin/arc.js +91 -52
  3. package/dist/EventTransport-BkUDYZEb.d.mts +99 -0
  4. package/dist/HookSystem-BsGV-j2l.mjs +404 -0
  5. package/dist/ResourceRegistry-7Ic20ZMw.mjs +249 -0
  6. package/dist/adapters/index.d.mts +5 -0
  7. package/dist/adapters/index.mjs +3 -0
  8. package/dist/audit/index.d.mts +81 -0
  9. package/dist/audit/index.mjs +275 -0
  10. package/dist/audit/mongodb.d.mts +5 -0
  11. package/dist/audit/mongodb.mjs +3 -0
  12. package/dist/audited-CGdLiSlE.mjs +140 -0
  13. package/dist/auth/index.d.mts +188 -0
  14. package/dist/auth/index.mjs +1096 -0
  15. package/dist/auth/redis-session.d.mts +43 -0
  16. package/dist/auth/redis-session.mjs +75 -0
  17. package/dist/betterAuthOpenApi-DjWDddNc.mjs +249 -0
  18. package/dist/cache/index.d.mts +145 -0
  19. package/dist/cache/index.mjs +91 -0
  20. package/dist/caching-GSDJcA6-.mjs +93 -0
  21. package/dist/chunk-C7Uep-_p.mjs +20 -0
  22. package/dist/circuitBreaker-DYhWBW_D.mjs +1096 -0
  23. package/dist/cli/commands/describe.d.mts +18 -0
  24. package/dist/cli/commands/describe.mjs +238 -0
  25. package/dist/cli/commands/docs.d.mts +13 -0
  26. package/dist/cli/commands/docs.mjs +52 -0
  27. package/dist/cli/commands/{generate.d.ts → generate.d.mts} +3 -2
  28. package/dist/cli/commands/generate.mjs +357 -0
  29. package/dist/cli/commands/{init.d.ts → init.d.mts} +11 -8
  30. package/dist/cli/commands/{init.js → init.mjs} +807 -617
  31. package/dist/cli/commands/introspect.d.mts +10 -0
  32. package/dist/cli/commands/introspect.mjs +75 -0
  33. package/dist/cli/index.d.mts +16 -0
  34. package/dist/cli/index.mjs +156 -0
  35. package/dist/constants-DdXFXQtN.mjs +84 -0
  36. package/dist/core/index.d.mts +5 -0
  37. package/dist/core/index.mjs +4 -0
  38. package/dist/createApp-D2D5XXaV.mjs +559 -0
  39. package/dist/defineResource-PXzSJ15_.mjs +2197 -0
  40. package/dist/discovery/index.d.mts +46 -0
  41. package/dist/discovery/index.mjs +109 -0
  42. package/dist/docs/index.d.mts +162 -0
  43. package/dist/docs/index.mjs +74 -0
  44. package/dist/elevation-DGo5shaX.d.mts +87 -0
  45. package/dist/elevation-DSTbVvYj.mjs +113 -0
  46. package/dist/errorHandler-C3GY3_ow.mjs +108 -0
  47. package/dist/errorHandler-CW3OOeYq.d.mts +72 -0
  48. package/dist/errors-DAWRdiYP.d.mts +124 -0
  49. package/dist/errors-DBANPbGr.mjs +211 -0
  50. package/dist/eventPlugin-BEOvaDqo.mjs +229 -0
  51. package/dist/eventPlugin-H6wDDjGO.d.mts +124 -0
  52. package/dist/events/index.d.mts +53 -0
  53. package/dist/events/index.mjs +51 -0
  54. package/dist/events/transports/redis-stream-entry.d.mts +2 -0
  55. package/dist/events/transports/redis-stream-entry.mjs +177 -0
  56. package/dist/events/transports/redis.d.mts +76 -0
  57. package/dist/events/transports/redis.mjs +124 -0
  58. package/dist/externalPaths-SyPF2tgK.d.mts +50 -0
  59. package/dist/factory/index.d.mts +63 -0
  60. package/dist/factory/index.mjs +3 -0
  61. package/dist/fastifyAdapter-C8DlE0YH.d.mts +216 -0
  62. package/dist/fields-Bi_AVKSo.d.mts +109 -0
  63. package/dist/fields-CTd_CrKr.mjs +114 -0
  64. package/dist/hooks/index.d.mts +4 -0
  65. package/dist/hooks/index.mjs +3 -0
  66. package/dist/idempotency/index.d.mts +96 -0
  67. package/dist/idempotency/index.mjs +319 -0
  68. package/dist/idempotency/mongodb.d.mts +2 -0
  69. package/dist/idempotency/mongodb.mjs +114 -0
  70. package/dist/idempotency/redis.d.mts +2 -0
  71. package/dist/idempotency/redis.mjs +103 -0
  72. package/dist/index.d.mts +260 -0
  73. package/dist/index.mjs +104 -0
  74. package/dist/integrations/event-gateway.d.mts +46 -0
  75. package/dist/integrations/event-gateway.mjs +43 -0
  76. package/dist/integrations/index.d.mts +5 -0
  77. package/dist/integrations/index.mjs +1 -0
  78. package/dist/integrations/jobs.d.mts +103 -0
  79. package/dist/integrations/jobs.mjs +123 -0
  80. package/dist/integrations/streamline.d.mts +60 -0
  81. package/dist/integrations/streamline.mjs +125 -0
  82. package/dist/integrations/websocket.d.mts +82 -0
  83. package/dist/integrations/websocket.mjs +288 -0
  84. package/dist/interface-CSNjltAc.d.mts +77 -0
  85. package/dist/interface-DTbsvIWe.d.mts +54 -0
  86. package/dist/interface-e9XfSsUV.d.mts +1097 -0
  87. package/dist/introspectionPlugin-B3JkrjwU.mjs +53 -0
  88. package/dist/keys-DhqDRxv3.mjs +42 -0
  89. package/dist/logger-ByrvQWZO.mjs +78 -0
  90. package/dist/memory-B2v7KrCB.mjs +143 -0
  91. package/dist/migrations/index.d.mts +156 -0
  92. package/dist/migrations/index.mjs +260 -0
  93. package/dist/mongodb-ClykrfGo.d.mts +118 -0
  94. package/dist/mongodb-DNKEExbf.mjs +93 -0
  95. package/dist/mongodb-Dg8O_gvd.d.mts +71 -0
  96. package/dist/openapi-9nB_kiuR.mjs +525 -0
  97. package/dist/org/index.d.mts +68 -0
  98. package/dist/org/index.mjs +513 -0
  99. package/dist/org/types.d.mts +82 -0
  100. package/dist/org/types.mjs +1 -0
  101. package/dist/permissions/index.d.mts +278 -0
  102. package/dist/permissions/index.mjs +579 -0
  103. package/dist/plugins/index.d.mts +172 -0
  104. package/dist/plugins/index.mjs +522 -0
  105. package/dist/plugins/response-cache.d.mts +87 -0
  106. package/dist/plugins/response-cache.mjs +283 -0
  107. package/dist/plugins/tracing-entry.d.mts +2 -0
  108. package/dist/plugins/tracing-entry.mjs +185 -0
  109. package/dist/pluralize-CM-jZg7p.mjs +86 -0
  110. package/dist/policies/{index.d.ts → index.d.mts} +204 -170
  111. package/dist/policies/index.mjs +321 -0
  112. package/dist/presets/{index.d.ts → index.d.mts} +62 -131
  113. package/dist/presets/index.mjs +143 -0
  114. package/dist/presets/multiTenant.d.mts +24 -0
  115. package/dist/presets/multiTenant.mjs +113 -0
  116. package/dist/presets-BTeYbw7h.d.mts +57 -0
  117. package/dist/presets-CeFtfDR8.mjs +119 -0
  118. package/dist/prisma-C3iornoK.d.mts +274 -0
  119. package/dist/prisma-DJbMt3yf.mjs +627 -0
  120. package/dist/queryCachePlugin-B6R0d4av.mjs +138 -0
  121. package/dist/queryCachePlugin-Q6SYuHZ6.d.mts +71 -0
  122. package/dist/redis-UwjEp8Ea.d.mts +49 -0
  123. package/dist/redis-stream-CBg0upHI.d.mts +103 -0
  124. package/dist/registry/index.d.mts +11 -0
  125. package/dist/registry/index.mjs +4 -0
  126. package/dist/requestContext-xi6OKBL-.mjs +55 -0
  127. package/dist/schemaConverter-Dtg0Kt9T.mjs +98 -0
  128. package/dist/schemas/index.d.mts +63 -0
  129. package/dist/schemas/index.mjs +82 -0
  130. package/dist/scope/index.d.mts +21 -0
  131. package/dist/scope/index.mjs +65 -0
  132. package/dist/sessionManager-D_iEHjQl.d.mts +186 -0
  133. package/dist/sse-DkqQ1uxb.mjs +123 -0
  134. package/dist/testing/index.d.mts +907 -0
  135. package/dist/testing/index.mjs +1976 -0
  136. package/dist/tracing-8CEbhF0w.d.mts +70 -0
  137. package/dist/typeGuards-DwxA1t_L.mjs +9 -0
  138. package/dist/types/index.d.mts +946 -0
  139. package/dist/types/index.mjs +14 -0
  140. package/dist/types-B0dhNrnd.d.mts +445 -0
  141. package/dist/types-Beqn1Un7.mjs +38 -0
  142. package/dist/types-DelU6kln.mjs +25 -0
  143. package/dist/types-RLkFVgaw.d.mts +101 -0
  144. package/dist/utils/index.d.mts +747 -0
  145. package/dist/utils/index.mjs +6 -0
  146. package/package.json +194 -68
  147. package/dist/BaseController-DVAiHxEQ.d.ts +0 -233
  148. package/dist/adapters/index.d.ts +0 -237
  149. package/dist/adapters/index.js +0 -668
  150. package/dist/arcCorePlugin-CsShQdyP.d.ts +0 -273
  151. package/dist/audit/index.d.ts +0 -195
  152. package/dist/audit/index.js +0 -319
  153. package/dist/auth/index.d.ts +0 -47
  154. package/dist/auth/index.js +0 -174
  155. package/dist/cli/commands/docs.d.ts +0 -11
  156. package/dist/cli/commands/docs.js +0 -474
  157. package/dist/cli/commands/generate.js +0 -334
  158. package/dist/cli/commands/introspect.d.ts +0 -8
  159. package/dist/cli/commands/introspect.js +0 -338
  160. package/dist/cli/index.d.ts +0 -4
  161. package/dist/cli/index.js +0 -3269
  162. package/dist/core/index.d.ts +0 -220
  163. package/dist/core/index.js +0 -2786
  164. package/dist/createApp-Ce9wl8W9.d.ts +0 -77
  165. package/dist/docs/index.d.ts +0 -166
  166. package/dist/docs/index.js +0 -658
  167. package/dist/errors-8WIxGS_6.d.ts +0 -122
  168. package/dist/events/index.d.ts +0 -117
  169. package/dist/events/index.js +0 -89
  170. package/dist/factory/index.d.ts +0 -38
  171. package/dist/factory/index.js +0 -1652
  172. package/dist/hooks/index.d.ts +0 -4
  173. package/dist/hooks/index.js +0 -199
  174. package/dist/idempotency/index.d.ts +0 -323
  175. package/dist/idempotency/index.js +0 -500
  176. package/dist/index-B4t03KQ0.d.ts +0 -1366
  177. package/dist/index.d.ts +0 -135
  178. package/dist/index.js +0 -4756
  179. package/dist/migrations/index.d.ts +0 -185
  180. package/dist/migrations/index.js +0 -274
  181. package/dist/org/index.d.ts +0 -129
  182. package/dist/org/index.js +0 -220
  183. package/dist/permissions/index.d.ts +0 -144
  184. package/dist/permissions/index.js +0 -103
  185. package/dist/plugins/index.d.ts +0 -46
  186. package/dist/plugins/index.js +0 -1069
  187. package/dist/policies/index.js +0 -196
  188. package/dist/presets/index.js +0 -384
  189. package/dist/presets/multiTenant.d.ts +0 -39
  190. package/dist/presets/multiTenant.js +0 -112
  191. package/dist/registry/index.d.ts +0 -16
  192. package/dist/registry/index.js +0 -253
  193. package/dist/testing/index.d.ts +0 -618
  194. package/dist/testing/index.js +0 -48020
  195. package/dist/types/index.d.ts +0 -4
  196. package/dist/types/index.js +0 -8
  197. package/dist/types-B99TBmFV.d.ts +0 -76
  198. package/dist/types-BvckRbs2.d.ts +0 -143
  199. package/dist/utils/index.d.ts +0 -679
  200. package/dist/utils/index.js +0 -931
@@ -0,0 +1,513 @@
1
+ import { c as isElevated, i as getOrgRoles, l as isMember, n as PUBLIC_SCOPE, o as hasOrgAccess } from "../types-Beqn1Un7.mjs";
2
+ import fp from "fastify-plugin";
3
+
4
+ //#region src/org/orgGuard.ts
5
+ /**
6
+ * Create org guard middleware.
7
+ * Reads `request.scope` for org context and roles.
8
+ * Elevated scope always passes.
9
+ */
10
+ function orgGuard(options = {}) {
11
+ const { requireOrgContext = true, roles = [] } = options;
12
+ return async function orgGuardMiddleware(request, reply) {
13
+ const scope = request.scope ?? PUBLIC_SCOPE;
14
+ if (isElevated(scope)) return;
15
+ if (requireOrgContext && !hasOrgAccess(scope)) {
16
+ reply.code(403).send({
17
+ success: false,
18
+ error: "Organization context required",
19
+ code: "ORG_CONTEXT_REQUIRED",
20
+ message: "This endpoint requires an organization context. Please specify organization via x-organization-id header."
21
+ });
22
+ return;
23
+ }
24
+ if (roles.length > 0 && isMember(scope)) {
25
+ const userOrgRoles = getOrgRoles(scope);
26
+ if (!roles.some((role) => userOrgRoles.includes(role))) {
27
+ reply.code(403).send({
28
+ success: false,
29
+ error: "Insufficient organization permissions",
30
+ code: "ORG_ROLE_REQUIRED",
31
+ message: `This action requires one of these organization roles: ${roles.join(", ")}`,
32
+ required: roles,
33
+ current: userOrgRoles
34
+ });
35
+ return;
36
+ }
37
+ }
38
+ };
39
+ }
40
+ /**
41
+ * Shorthand for requiring org context
42
+ */
43
+ function requireOrg() {
44
+ return orgGuard({ requireOrgContext: true });
45
+ }
46
+ /**
47
+ * Require org context with specific roles
48
+ */
49
+ function requireOrgRole(...roles) {
50
+ return orgGuard({
51
+ requireOrgContext: true,
52
+ roles
53
+ });
54
+ }
55
+
56
+ //#endregion
57
+ //#region src/org/orgMembership.ts
58
+ /**
59
+ * Check if user is member of organization.
60
+ * This is a low-level utility for checking membership from user object data.
61
+ * For request-level checks, use `request.scope` (isMember/isElevated guards).
62
+ */
63
+ async function orgMembershipCheck(user, orgId, options = {}) {
64
+ const { userOrgsPath = "organizations", validateFromDb } = options;
65
+ if (!user || !orgId) return false;
66
+ if ((user[userOrgsPath] ?? []).some((o) => {
67
+ return (o.organizationId?.toString() ?? String(o)) === orgId.toString();
68
+ })) return true;
69
+ if (validateFromDb) {
70
+ const userId = (user._id ?? user.id)?.toString();
71
+ if (userId) return validateFromDb(userId, orgId);
72
+ }
73
+ return false;
74
+ }
75
+ /**
76
+ * Get user's role in organization from user object data.
77
+ * For request-level role checks, use `request.scope.orgRoles` (when scope is 'member').
78
+ */
79
+ function getUserOrgRoles(user, orgId, options = {}) {
80
+ const { userOrgsPath = "organizations" } = options;
81
+ if (!user || !orgId) return [];
82
+ return (user[userOrgsPath] ?? []).find((o) => {
83
+ return (o.organizationId?.toString() ?? String(o)) === orgId.toString();
84
+ })?.roles ?? [];
85
+ }
86
+ /**
87
+ * Check if user has specific role in organization from user object data.
88
+ * For request-level role checks, use `requireOrgRole()` permission or `request.scope`.
89
+ */
90
+ function hasOrgRole(user, orgId, roles, options = {}) {
91
+ const userOrgRoles = getUserOrgRoles(user, orgId, options);
92
+ return (Array.isArray(roles) ? roles : [roles]).some((role) => userOrgRoles.includes(role));
93
+ }
94
+
95
+ //#endregion
96
+ //#region src/org/organizationPlugin.ts
97
+ /**
98
+ * Organization Plugin -- Full org management with REST endpoints
99
+ *
100
+ * Creates these routes:
101
+ * - POST /api/organizations -- Create org
102
+ * - GET /api/organizations -- List user's orgs
103
+ * - GET /api/organizations/:orgId -- Get org
104
+ * - PATCH /api/organizations/:orgId -- Update org
105
+ * - DELETE /api/organizations/:orgId -- Delete org
106
+ * - GET /api/organizations/:orgId/members -- List members
107
+ * - POST /api/organizations/:orgId/members -- Add member
108
+ * - PATCH /api/organizations/:orgId/members/:userId -- Update role
109
+ * - DELETE /api/organizations/:orgId/members/:userId -- Remove member
110
+ *
111
+ * @example
112
+ * import { organizationPlugin } from '@classytic/arc/org';
113
+ *
114
+ * await fastify.register(organizationPlugin, {
115
+ * adapter: myMongooseOrgAdapter,
116
+ * basePath: '/api/organizations',
117
+ * enableInvitations: false,
118
+ * });
119
+ */
120
+ const DEFAULT_ROLES = [
121
+ {
122
+ name: "owner",
123
+ permissions: [{
124
+ resource: "*",
125
+ action: ["*"]
126
+ }]
127
+ },
128
+ {
129
+ name: "admin",
130
+ permissions: [{
131
+ resource: "org",
132
+ action: ["read", "update"]
133
+ }, {
134
+ resource: "members",
135
+ action: ["*"]
136
+ }]
137
+ },
138
+ {
139
+ name: "member",
140
+ permissions: [{
141
+ resource: "org",
142
+ action: ["read"]
143
+ }, {
144
+ resource: "members",
145
+ action: ["read"]
146
+ }]
147
+ }
148
+ ];
149
+ /** Extract a UserBase from the request (set by auth plugin). */
150
+ function getUser(request) {
151
+ return request.user;
152
+ }
153
+ /** Get user id (supports both `id` and `_id`). */
154
+ function getUserId(user) {
155
+ const raw = user.id ?? user._id;
156
+ return raw ? String(raw) : void 0;
157
+ }
158
+ /** Standard JSON error reply. */
159
+ function sendError(reply, statusCode, code, message) {
160
+ reply.code(statusCode).send({
161
+ success: false,
162
+ code,
163
+ error: message
164
+ });
165
+ }
166
+ const organizationPlugin = async (fastify, opts) => {
167
+ const { adapter, roles = DEFAULT_ROLES, basePath = "/api/organizations", enableInvitations = false } = opts;
168
+ const validRoleNames = new Set(roles.map((r) => r.name));
169
+ /**
170
+ * Create a preHandler that:
171
+ * 1. Ensures the request is authenticated
172
+ * 2. Looks up the caller's membership in the org identified by `:orgId`
173
+ * 3. Verifies the caller holds one of the required roles
174
+ */
175
+ fastify.decorate("requireOrgRole", function requireOrgRole(requiredRoles) {
176
+ return async function requireOrgRoleHandler(request, reply) {
177
+ const user = getUser(request);
178
+ if (!user) {
179
+ sendError(reply, 401, "UNAUTHORIZED", "Authentication required");
180
+ return;
181
+ }
182
+ const userId = getUserId(user);
183
+ if (!userId) {
184
+ sendError(reply, 401, "UNAUTHORIZED", "Unable to determine user identity");
185
+ return;
186
+ }
187
+ const { orgId } = request.params;
188
+ if (!orgId) {
189
+ sendError(reply, 400, "MISSING_ORG_ID", "Organization ID is required");
190
+ return;
191
+ }
192
+ const member = await adapter.getMember(orgId, userId);
193
+ if (!member) {
194
+ sendError(reply, 403, "NOT_A_MEMBER", "You are not a member of this organization");
195
+ return;
196
+ }
197
+ if (!requiredRoles.includes(member.role)) {
198
+ sendError(reply, 403, "INSUFFICIENT_ROLE", `This action requires one of these roles: ${requiredRoles.join(", ")}`);
199
+ return;
200
+ }
201
+ };
202
+ });
203
+ /** Wrap preHandlers so that authenticate is called first (if available). */
204
+ function withAuth(...extra) {
205
+ const handlers = [];
206
+ const inst = fastify;
207
+ if (typeof inst.authenticate === "function") handlers.push(inst.authenticate);
208
+ handlers.push(...extra);
209
+ return handlers;
210
+ }
211
+ function generateSlug(name) {
212
+ return name.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
213
+ }
214
+ /**
215
+ * POST / -- Create organization
216
+ *
217
+ * Body: { name: string; slug?: string; [key: string]: unknown }
218
+ * The authenticated user becomes the owner.
219
+ */
220
+ fastify.post(basePath, { preHandler: withAuth() }, async (request, reply) => {
221
+ const user = getUser(request);
222
+ if (!user) {
223
+ sendError(reply, 401, "UNAUTHORIZED", "Authentication required");
224
+ return;
225
+ }
226
+ const userId = getUserId(user);
227
+ if (!userId) {
228
+ sendError(reply, 401, "UNAUTHORIZED", "Unable to determine user identity");
229
+ return;
230
+ }
231
+ const body = request.body;
232
+ if (!body?.name) {
233
+ sendError(reply, 400, "VALIDATION_ERROR", "Organization name is required");
234
+ return;
235
+ }
236
+ const slug = body.slug ?? generateSlug(body.name);
237
+ if (await adapter.getOrgBySlug(slug)) {
238
+ sendError(reply, 409, "SLUG_TAKEN", `An organization with slug '${slug}' already exists`);
239
+ return;
240
+ }
241
+ const org = await adapter.createOrg({
242
+ ...body,
243
+ name: body.name,
244
+ slug,
245
+ ownerId: userId
246
+ });
247
+ await adapter.addMember(org.id, userId, "owner");
248
+ reply.code(201).send({
249
+ success: true,
250
+ data: org
251
+ });
252
+ });
253
+ /**
254
+ * GET / -- List the authenticated user's organizations
255
+ */
256
+ fastify.get(basePath, { preHandler: withAuth() }, async (request, reply) => {
257
+ const user = getUser(request);
258
+ if (!user) {
259
+ sendError(reply, 401, "UNAUTHORIZED", "Authentication required");
260
+ return;
261
+ }
262
+ const userId = getUserId(user);
263
+ if (!userId) {
264
+ sendError(reply, 401, "UNAUTHORIZED", "Unable to determine user identity");
265
+ return;
266
+ }
267
+ const orgs = await adapter.listUserOrgs(userId);
268
+ reply.send({
269
+ success: true,
270
+ data: orgs
271
+ });
272
+ });
273
+ /**
274
+ * GET /:orgId -- Get a single organization
275
+ */
276
+ fastify.get(`${basePath}/:orgId`, { preHandler: withAuth(fastify.requireOrgRole([
277
+ "owner",
278
+ "admin",
279
+ "member"
280
+ ])) }, async (request, reply) => {
281
+ const { orgId } = request.params;
282
+ const org = await adapter.getOrg(orgId);
283
+ if (!org) {
284
+ sendError(reply, 404, "NOT_FOUND", "Organization not found");
285
+ return;
286
+ }
287
+ reply.send({
288
+ success: true,
289
+ data: org
290
+ });
291
+ });
292
+ /**
293
+ * PATCH /:orgId -- Update organization
294
+ */
295
+ fastify.patch(`${basePath}/:orgId`, { preHandler: withAuth(fastify.requireOrgRole(["owner", "admin"])) }, async (request, reply) => {
296
+ const { orgId } = request.params;
297
+ const body = request.body;
298
+ if (!body || Object.keys(body).length === 0) {
299
+ sendError(reply, 400, "VALIDATION_ERROR", "Request body must not be empty");
300
+ return;
301
+ }
302
+ const { ownerId: _ownerId, id: _id, ...updates } = body;
303
+ const org = await adapter.updateOrg(orgId, updates);
304
+ if (!org) {
305
+ sendError(reply, 404, "NOT_FOUND", "Organization not found");
306
+ return;
307
+ }
308
+ reply.send({
309
+ success: true,
310
+ data: org
311
+ });
312
+ });
313
+ /**
314
+ * DELETE /:orgId -- Delete organization (owner only)
315
+ */
316
+ fastify.delete(`${basePath}/:orgId`, { preHandler: withAuth(fastify.requireOrgRole(["owner"])) }, async (request, reply) => {
317
+ const { orgId } = request.params;
318
+ if (!await adapter.getOrg(orgId)) {
319
+ sendError(reply, 404, "NOT_FOUND", "Organization not found");
320
+ return;
321
+ }
322
+ await adapter.deleteOrg(orgId);
323
+ reply.send({
324
+ success: true,
325
+ message: "Organization deleted"
326
+ });
327
+ });
328
+ /**
329
+ * GET /:orgId/members -- List members
330
+ */
331
+ fastify.get(`${basePath}/:orgId/members`, { preHandler: withAuth(fastify.requireOrgRole([
332
+ "owner",
333
+ "admin",
334
+ "member"
335
+ ])) }, async (request, reply) => {
336
+ const { orgId } = request.params;
337
+ const members = await adapter.listMembers(orgId);
338
+ reply.send({
339
+ success: true,
340
+ data: members
341
+ });
342
+ });
343
+ /**
344
+ * POST /:orgId/members -- Add a member
345
+ *
346
+ * Body: { userId: string; role: string }
347
+ */
348
+ fastify.post(`${basePath}/:orgId/members`, { preHandler: withAuth(fastify.requireOrgRole(["owner", "admin"])) }, async (request, reply) => {
349
+ const { orgId } = request.params;
350
+ const body = request.body;
351
+ if (!body?.userId || !body.role) {
352
+ sendError(reply, 400, "VALIDATION_ERROR", "userId and role are required");
353
+ return;
354
+ }
355
+ if (!validRoleNames.has(body.role)) {
356
+ sendError(reply, 400, "INVALID_ROLE", `Invalid role '${body.role}'. Valid roles: ${[...validRoleNames].join(", ")}`);
357
+ return;
358
+ }
359
+ if (await adapter.getMember(orgId, body.userId)) {
360
+ sendError(reply, 409, "ALREADY_MEMBER", "User is already a member of this organization");
361
+ return;
362
+ }
363
+ const member = await adapter.addMember(orgId, body.userId, body.role);
364
+ reply.code(201).send({
365
+ success: true,
366
+ data: member
367
+ });
368
+ });
369
+ /**
370
+ * PATCH /:orgId/members/:userId -- Update a member's role
371
+ *
372
+ * Body: { role: string }
373
+ */
374
+ fastify.patch(`${basePath}/:orgId/members/:userId`, { preHandler: withAuth(fastify.requireOrgRole(["owner", "admin"])) }, async (request, reply) => {
375
+ const { orgId, userId } = request.params;
376
+ const body = request.body;
377
+ if (!body?.role) {
378
+ sendError(reply, 400, "VALIDATION_ERROR", "role is required");
379
+ return;
380
+ }
381
+ if (!validRoleNames.has(body.role)) {
382
+ sendError(reply, 400, "INVALID_ROLE", `Invalid role '${body.role}'. Valid roles: ${[...validRoleNames].join(", ")}`);
383
+ return;
384
+ }
385
+ const currentMember = await adapter.getMember(orgId, userId);
386
+ if (!currentMember) {
387
+ sendError(reply, 404, "NOT_FOUND", "Member not found");
388
+ return;
389
+ }
390
+ if (currentMember.role === "owner" && body.role !== "owner") {
391
+ if ((await adapter.listMembers(orgId)).filter((m) => m.role === "owner").length <= 1) {
392
+ sendError(reply, 400, "LAST_OWNER", "Cannot change the role of the last owner. Transfer ownership first.");
393
+ return;
394
+ }
395
+ }
396
+ const member = await adapter.updateMemberRole(orgId, userId, body.role);
397
+ if (!member) {
398
+ sendError(reply, 404, "NOT_FOUND", "Member not found");
399
+ return;
400
+ }
401
+ reply.send({
402
+ success: true,
403
+ data: member
404
+ });
405
+ });
406
+ /**
407
+ * DELETE /:orgId/members/:userId -- Remove a member
408
+ */
409
+ fastify.delete(`${basePath}/:orgId/members/:userId`, { preHandler: withAuth(fastify.requireOrgRole(["owner", "admin"])) }, async (request, reply) => {
410
+ const { orgId, userId } = request.params;
411
+ const member = await adapter.getMember(orgId, userId);
412
+ if (!member) {
413
+ sendError(reply, 404, "NOT_FOUND", "Member not found");
414
+ return;
415
+ }
416
+ if (member.role === "owner") {
417
+ if ((await adapter.listMembers(orgId)).filter((m) => m.role === "owner").length <= 1) {
418
+ sendError(reply, 400, "LAST_OWNER", "Cannot remove the last owner. Transfer ownership or delete the organization.");
419
+ return;
420
+ }
421
+ }
422
+ await adapter.removeMember(orgId, userId);
423
+ reply.send({
424
+ success: true,
425
+ message: "Member removed"
426
+ });
427
+ });
428
+ if (enableInvitations && adapter.invitations) {
429
+ const inv = adapter.invitations;
430
+ /**
431
+ * POST /:orgId/invitations -- Create invitation
432
+ *
433
+ * Body: { email: string; role: string; expiresAt?: string }
434
+ */
435
+ fastify.post(`${basePath}/:orgId/invitations`, { preHandler: withAuth(fastify.requireOrgRole(["owner", "admin"])) }, async (request, reply) => {
436
+ const user = getUser(request);
437
+ const userId = user ? getUserId(user) : void 0;
438
+ if (!userId) {
439
+ sendError(reply, 401, "UNAUTHORIZED", "Authentication required");
440
+ return;
441
+ }
442
+ const { orgId } = request.params;
443
+ const body = request.body;
444
+ if (!body?.email || !body.role) {
445
+ sendError(reply, 400, "VALIDATION_ERROR", "email and role are required");
446
+ return;
447
+ }
448
+ if (!validRoleNames.has(body.role)) {
449
+ sendError(reply, 400, "INVALID_ROLE", `Invalid role '${body.role}'. Valid roles: ${[...validRoleNames].join(", ")}`);
450
+ return;
451
+ }
452
+ const defaultExpiry = new Date(Date.now() + 10080 * 60 * 1e3);
453
+ const expiresAt = body.expiresAt ? new Date(body.expiresAt) : defaultExpiry;
454
+ const invitation = await inv.create({
455
+ orgId,
456
+ email: body.email,
457
+ role: body.role,
458
+ invitedBy: userId,
459
+ status: "pending",
460
+ expiresAt
461
+ });
462
+ reply.code(201).send({
463
+ success: true,
464
+ data: invitation
465
+ });
466
+ });
467
+ /**
468
+ * GET /:orgId/invitations -- List pending invitations
469
+ */
470
+ fastify.get(`${basePath}/:orgId/invitations`, { preHandler: withAuth(fastify.requireOrgRole(["owner", "admin"])) }, async (request, reply) => {
471
+ const { orgId } = request.params;
472
+ const invitations = await inv.listPending(orgId);
473
+ reply.send({
474
+ success: true,
475
+ data: invitations
476
+ });
477
+ });
478
+ /**
479
+ * POST /invitations/:invitationId/accept -- Accept invitation
480
+ */
481
+ fastify.post(`${basePath}/invitations/:invitationId/accept`, { preHandler: withAuth() }, async (request, reply) => {
482
+ const { invitationId } = request.params;
483
+ await inv.accept(invitationId);
484
+ reply.send({
485
+ success: true,
486
+ message: "Invitation accepted"
487
+ });
488
+ });
489
+ /**
490
+ * POST /invitations/:invitationId/reject -- Reject invitation
491
+ */
492
+ fastify.post(`${basePath}/invitations/:invitationId/reject`, { preHandler: withAuth() }, async (request, reply) => {
493
+ const { invitationId } = request.params;
494
+ await inv.reject(invitationId);
495
+ reply.send({
496
+ success: true,
497
+ message: "Invitation rejected"
498
+ });
499
+ });
500
+ }
501
+ fastify.log?.debug?.({
502
+ basePath,
503
+ roles: [...validRoleNames],
504
+ invitations: enableInvitations
505
+ }, "Organization plugin registered");
506
+ };
507
+ var organizationPlugin_default = fp(organizationPlugin, {
508
+ name: "arc-organization",
509
+ fastify: "5.x"
510
+ });
511
+
512
+ //#endregion
513
+ export { getUserOrgRoles, hasOrgRole, orgGuard, orgMembershipCheck, organizationPlugin_default as organizationPlugin, organizationPlugin as organizationPluginFn, requireOrg, requireOrgRole };
@@ -0,0 +1,82 @@
1
+ //#region src/org/types.d.ts
2
+ /**
3
+ * Organization types -- adapter interfaces for multi-tenant applications.
4
+ * Arc defines the contract, apps implement it.
5
+ */
6
+ interface OrgDoc {
7
+ id: string;
8
+ name: string;
9
+ slug: string;
10
+ ownerId: string;
11
+ metadata?: Record<string, unknown>;
12
+ createdAt?: Date;
13
+ updatedAt?: Date;
14
+ [key: string]: unknown;
15
+ }
16
+ interface MemberDoc {
17
+ id: string;
18
+ orgId: string;
19
+ userId: string;
20
+ role: string;
21
+ createdAt?: Date;
22
+ updatedAt?: Date;
23
+ [key: string]: unknown;
24
+ }
25
+ interface InvitationDoc {
26
+ id: string;
27
+ orgId: string;
28
+ email: string;
29
+ role: string;
30
+ invitedBy: string;
31
+ status: 'pending' | 'accepted' | 'rejected' | 'expired';
32
+ expiresAt: Date;
33
+ createdAt?: Date;
34
+ }
35
+ /** Core organization adapter -- apps implement this */
36
+ interface OrgAdapter {
37
+ createOrg(data: {
38
+ name: string;
39
+ slug: string;
40
+ ownerId: string;
41
+ [key: string]: unknown;
42
+ }): Promise<OrgDoc>;
43
+ getOrg(id: string): Promise<OrgDoc | null>;
44
+ getOrgBySlug(slug: string): Promise<OrgDoc | null>;
45
+ updateOrg(id: string, data: Partial<OrgDoc>): Promise<OrgDoc | null>;
46
+ deleteOrg(id: string): Promise<void>;
47
+ listUserOrgs(userId: string): Promise<OrgDoc[]>;
48
+ addMember(orgId: string, userId: string, role: string): Promise<MemberDoc>;
49
+ removeMember(orgId: string, userId: string): Promise<void>;
50
+ getMember(orgId: string, userId: string): Promise<MemberDoc | null>;
51
+ listMembers(orgId: string): Promise<MemberDoc[]>;
52
+ updateMemberRole(orgId: string, userId: string, role: string): Promise<MemberDoc | null>;
53
+ invitations?: InvitationAdapter;
54
+ }
55
+ interface InvitationAdapter {
56
+ create(data: Omit<InvitationDoc, 'id' | 'createdAt'>): Promise<InvitationDoc>;
57
+ getByToken(token: string): Promise<InvitationDoc | null>;
58
+ accept(id: string): Promise<void>;
59
+ reject(id: string): Promise<void>;
60
+ listPending(orgId: string): Promise<InvitationDoc[]>;
61
+ }
62
+ /** Statement-based permission check */
63
+ interface OrgPermissionStatement {
64
+ resource: string;
65
+ action: string[];
66
+ }
67
+ /** Role definition with permissions */
68
+ interface OrgRole {
69
+ name: string;
70
+ permissions: OrgPermissionStatement[];
71
+ }
72
+ interface OrganizationPluginOptions {
73
+ adapter: OrgAdapter;
74
+ /** Built-in roles (default: owner, admin, member) */
75
+ roles?: OrgRole[];
76
+ /** Base path for org API routes (default: '/api/organizations') */
77
+ basePath?: string;
78
+ /** Enable invitation system */
79
+ enableInvitations?: boolean;
80
+ }
81
+ //#endregion
82
+ export { InvitationAdapter, InvitationDoc, MemberDoc, OrgAdapter, OrgDoc, OrgPermissionStatement, OrgRole, OrganizationPluginOptions };
@@ -0,0 +1 @@
1
+ export { };