@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.
- package/README.md +247 -794
- package/bin/arc.js +91 -52
- package/dist/EventTransport-BkUDYZEb.d.mts +99 -0
- package/dist/HookSystem-BsGV-j2l.mjs +404 -0
- package/dist/ResourceRegistry-7Ic20ZMw.mjs +249 -0
- package/dist/adapters/index.d.mts +5 -0
- package/dist/adapters/index.mjs +3 -0
- package/dist/audit/index.d.mts +81 -0
- package/dist/audit/index.mjs +275 -0
- package/dist/audit/mongodb.d.mts +5 -0
- package/dist/audit/mongodb.mjs +3 -0
- package/dist/audited-CGdLiSlE.mjs +140 -0
- package/dist/auth/index.d.mts +188 -0
- package/dist/auth/index.mjs +1096 -0
- package/dist/auth/redis-session.d.mts +43 -0
- package/dist/auth/redis-session.mjs +75 -0
- package/dist/betterAuthOpenApi-DjWDddNc.mjs +249 -0
- package/dist/cache/index.d.mts +145 -0
- package/dist/cache/index.mjs +91 -0
- package/dist/caching-GSDJcA6-.mjs +93 -0
- package/dist/chunk-C7Uep-_p.mjs +20 -0
- package/dist/circuitBreaker-DYhWBW_D.mjs +1096 -0
- package/dist/cli/commands/describe.d.mts +18 -0
- package/dist/cli/commands/describe.mjs +238 -0
- package/dist/cli/commands/docs.d.mts +13 -0
- package/dist/cli/commands/docs.mjs +52 -0
- package/dist/cli/commands/{generate.d.ts → generate.d.mts} +3 -2
- package/dist/cli/commands/generate.mjs +357 -0
- package/dist/cli/commands/{init.d.ts → init.d.mts} +11 -8
- package/dist/cli/commands/{init.js → init.mjs} +807 -617
- package/dist/cli/commands/introspect.d.mts +10 -0
- package/dist/cli/commands/introspect.mjs +75 -0
- package/dist/cli/index.d.mts +16 -0
- package/dist/cli/index.mjs +156 -0
- package/dist/constants-DdXFXQtN.mjs +84 -0
- package/dist/core/index.d.mts +5 -0
- package/dist/core/index.mjs +4 -0
- package/dist/createApp-D2D5XXaV.mjs +559 -0
- package/dist/defineResource-PXzSJ15_.mjs +2197 -0
- package/dist/discovery/index.d.mts +46 -0
- package/dist/discovery/index.mjs +109 -0
- package/dist/docs/index.d.mts +162 -0
- package/dist/docs/index.mjs +74 -0
- package/dist/elevation-DGo5shaX.d.mts +87 -0
- package/dist/elevation-DSTbVvYj.mjs +113 -0
- package/dist/errorHandler-C3GY3_ow.mjs +108 -0
- package/dist/errorHandler-CW3OOeYq.d.mts +72 -0
- package/dist/errors-DAWRdiYP.d.mts +124 -0
- package/dist/errors-DBANPbGr.mjs +211 -0
- package/dist/eventPlugin-BEOvaDqo.mjs +229 -0
- package/dist/eventPlugin-H6wDDjGO.d.mts +124 -0
- package/dist/events/index.d.mts +53 -0
- package/dist/events/index.mjs +51 -0
- package/dist/events/transports/redis-stream-entry.d.mts +2 -0
- package/dist/events/transports/redis-stream-entry.mjs +177 -0
- package/dist/events/transports/redis.d.mts +76 -0
- package/dist/events/transports/redis.mjs +124 -0
- package/dist/externalPaths-SyPF2tgK.d.mts +50 -0
- package/dist/factory/index.d.mts +63 -0
- package/dist/factory/index.mjs +3 -0
- package/dist/fastifyAdapter-C8DlE0YH.d.mts +216 -0
- package/dist/fields-Bi_AVKSo.d.mts +109 -0
- package/dist/fields-CTd_CrKr.mjs +114 -0
- package/dist/hooks/index.d.mts +4 -0
- package/dist/hooks/index.mjs +3 -0
- package/dist/idempotency/index.d.mts +96 -0
- package/dist/idempotency/index.mjs +319 -0
- package/dist/idempotency/mongodb.d.mts +2 -0
- package/dist/idempotency/mongodb.mjs +114 -0
- package/dist/idempotency/redis.d.mts +2 -0
- package/dist/idempotency/redis.mjs +103 -0
- package/dist/index.d.mts +260 -0
- package/dist/index.mjs +104 -0
- package/dist/integrations/event-gateway.d.mts +46 -0
- package/dist/integrations/event-gateway.mjs +43 -0
- package/dist/integrations/index.d.mts +5 -0
- package/dist/integrations/index.mjs +1 -0
- package/dist/integrations/jobs.d.mts +103 -0
- package/dist/integrations/jobs.mjs +123 -0
- package/dist/integrations/streamline.d.mts +60 -0
- package/dist/integrations/streamline.mjs +125 -0
- package/dist/integrations/websocket.d.mts +82 -0
- package/dist/integrations/websocket.mjs +288 -0
- package/dist/interface-CSNjltAc.d.mts +77 -0
- package/dist/interface-DTbsvIWe.d.mts +54 -0
- package/dist/interface-e9XfSsUV.d.mts +1097 -0
- package/dist/introspectionPlugin-B3JkrjwU.mjs +53 -0
- package/dist/keys-DhqDRxv3.mjs +42 -0
- package/dist/logger-ByrvQWZO.mjs +78 -0
- package/dist/memory-B2v7KrCB.mjs +143 -0
- package/dist/migrations/index.d.mts +156 -0
- package/dist/migrations/index.mjs +260 -0
- package/dist/mongodb-ClykrfGo.d.mts +118 -0
- package/dist/mongodb-DNKEExbf.mjs +93 -0
- package/dist/mongodb-Dg8O_gvd.d.mts +71 -0
- package/dist/openapi-9nB_kiuR.mjs +525 -0
- package/dist/org/index.d.mts +68 -0
- package/dist/org/index.mjs +513 -0
- package/dist/org/types.d.mts +82 -0
- package/dist/org/types.mjs +1 -0
- package/dist/permissions/index.d.mts +278 -0
- package/dist/permissions/index.mjs +579 -0
- package/dist/plugins/index.d.mts +172 -0
- package/dist/plugins/index.mjs +522 -0
- package/dist/plugins/response-cache.d.mts +87 -0
- package/dist/plugins/response-cache.mjs +283 -0
- package/dist/plugins/tracing-entry.d.mts +2 -0
- package/dist/plugins/tracing-entry.mjs +185 -0
- package/dist/pluralize-CM-jZg7p.mjs +86 -0
- package/dist/policies/{index.d.ts → index.d.mts} +204 -170
- package/dist/policies/index.mjs +321 -0
- package/dist/presets/{index.d.ts → index.d.mts} +62 -131
- package/dist/presets/index.mjs +143 -0
- package/dist/presets/multiTenant.d.mts +24 -0
- package/dist/presets/multiTenant.mjs +113 -0
- package/dist/presets-BTeYbw7h.d.mts +57 -0
- package/dist/presets-CeFtfDR8.mjs +119 -0
- package/dist/prisma-C3iornoK.d.mts +274 -0
- package/dist/prisma-DJbMt3yf.mjs +627 -0
- package/dist/queryCachePlugin-B6R0d4av.mjs +138 -0
- package/dist/queryCachePlugin-Q6SYuHZ6.d.mts +71 -0
- package/dist/redis-UwjEp8Ea.d.mts +49 -0
- package/dist/redis-stream-CBg0upHI.d.mts +103 -0
- package/dist/registry/index.d.mts +11 -0
- package/dist/registry/index.mjs +4 -0
- package/dist/requestContext-xi6OKBL-.mjs +55 -0
- package/dist/schemaConverter-Dtg0Kt9T.mjs +98 -0
- package/dist/schemas/index.d.mts +63 -0
- package/dist/schemas/index.mjs +82 -0
- package/dist/scope/index.d.mts +21 -0
- package/dist/scope/index.mjs +65 -0
- package/dist/sessionManager-D_iEHjQl.d.mts +186 -0
- package/dist/sse-DkqQ1uxb.mjs +123 -0
- package/dist/testing/index.d.mts +907 -0
- package/dist/testing/index.mjs +1976 -0
- package/dist/tracing-8CEbhF0w.d.mts +70 -0
- package/dist/typeGuards-DwxA1t_L.mjs +9 -0
- package/dist/types/index.d.mts +946 -0
- package/dist/types/index.mjs +14 -0
- package/dist/types-B0dhNrnd.d.mts +445 -0
- package/dist/types-Beqn1Un7.mjs +38 -0
- package/dist/types-DelU6kln.mjs +25 -0
- package/dist/types-RLkFVgaw.d.mts +101 -0
- package/dist/utils/index.d.mts +747 -0
- package/dist/utils/index.mjs +6 -0
- package/package.json +194 -68
- package/dist/BaseController-DVAiHxEQ.d.ts +0 -233
- package/dist/adapters/index.d.ts +0 -237
- package/dist/adapters/index.js +0 -668
- package/dist/arcCorePlugin-CsShQdyP.d.ts +0 -273
- package/dist/audit/index.d.ts +0 -195
- package/dist/audit/index.js +0 -319
- package/dist/auth/index.d.ts +0 -47
- package/dist/auth/index.js +0 -174
- package/dist/cli/commands/docs.d.ts +0 -11
- package/dist/cli/commands/docs.js +0 -474
- package/dist/cli/commands/generate.js +0 -334
- package/dist/cli/commands/introspect.d.ts +0 -8
- package/dist/cli/commands/introspect.js +0 -338
- package/dist/cli/index.d.ts +0 -4
- package/dist/cli/index.js +0 -3269
- package/dist/core/index.d.ts +0 -220
- package/dist/core/index.js +0 -2786
- package/dist/createApp-Ce9wl8W9.d.ts +0 -77
- package/dist/docs/index.d.ts +0 -166
- package/dist/docs/index.js +0 -658
- package/dist/errors-8WIxGS_6.d.ts +0 -122
- package/dist/events/index.d.ts +0 -117
- package/dist/events/index.js +0 -89
- package/dist/factory/index.d.ts +0 -38
- package/dist/factory/index.js +0 -1652
- package/dist/hooks/index.d.ts +0 -4
- package/dist/hooks/index.js +0 -199
- package/dist/idempotency/index.d.ts +0 -323
- package/dist/idempotency/index.js +0 -500
- package/dist/index-B4t03KQ0.d.ts +0 -1366
- package/dist/index.d.ts +0 -135
- package/dist/index.js +0 -4756
- package/dist/migrations/index.d.ts +0 -185
- package/dist/migrations/index.js +0 -274
- package/dist/org/index.d.ts +0 -129
- package/dist/org/index.js +0 -220
- package/dist/permissions/index.d.ts +0 -144
- package/dist/permissions/index.js +0 -103
- package/dist/plugins/index.d.ts +0 -46
- package/dist/plugins/index.js +0 -1069
- package/dist/policies/index.js +0 -196
- package/dist/presets/index.js +0 -384
- package/dist/presets/multiTenant.d.ts +0 -39
- package/dist/presets/multiTenant.js +0 -112
- package/dist/registry/index.d.ts +0 -16
- package/dist/registry/index.js +0 -253
- package/dist/testing/index.d.ts +0 -618
- package/dist/testing/index.js +0 -48020
- package/dist/types/index.d.ts +0 -4
- package/dist/types/index.js +0 -8
- package/dist/types-B99TBmFV.d.ts +0 -76
- package/dist/types-BvckRbs2.d.ts +0 -143
- package/dist/utils/index.d.ts +0 -679
- 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 { };
|