@classytic/arc 2.3.0 → 2.4.2
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 +187 -18
- package/bin/arc.js +11 -3
- package/dist/BaseController-CkM5dUh_.mjs +1031 -0
- package/dist/{EventTransport-BkUDYZEb.d.mts → EventTransport-wc5hSLik.d.mts} +1 -1
- package/dist/{HookSystem-BsGV-j2l.mjs → HookSystem-COkyWztM.mjs} +2 -3
- package/dist/{ResourceRegistry-7Ic20ZMw.mjs → ResourceRegistry-DeCIFlix.mjs} +8 -5
- package/dist/adapters/index.d.mts +3 -5
- package/dist/adapters/index.mjs +2 -3
- package/dist/{prisma-DJbMt3yf.mjs → adapters-DTC4Ug66.mjs} +45 -12
- package/dist/audit/index.d.mts +4 -7
- package/dist/audit/index.mjs +2 -29
- package/dist/audit/mongodb.d.mts +1 -4
- package/dist/audit/mongodb.mjs +2 -3
- package/dist/auth/index.d.mts +7 -9
- package/dist/auth/index.mjs +65 -63
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/auth/redis-session.mjs +1 -2
- package/dist/{betterAuthOpenApi-DjWDddNc.mjs → betterAuthOpenApi-lz0IRbXJ.mjs} +4 -6
- package/dist/cache/index.d.mts +23 -23
- package/dist/cache/index.mjs +4 -6
- package/dist/{caching-GSDJcA6-.mjs → caching-BSXB-Xr7.mjs} +2 -24
- package/dist/chunk-BpYLSNr0.mjs +14 -0
- package/dist/circuitBreaker-BOBOpN2w.mjs +284 -0
- package/dist/circuitBreaker-JP2GdJ4b.d.mts +206 -0
- package/dist/cli/commands/describe.mjs +24 -7
- package/dist/cli/commands/docs.mjs +6 -7
- package/dist/cli/commands/doctor.d.mts +10 -0
- package/dist/cli/commands/doctor.mjs +156 -0
- package/dist/cli/commands/generate.mjs +66 -17
- package/dist/cli/commands/init.mjs +315 -45
- package/dist/cli/commands/introspect.mjs +2 -4
- package/dist/cli/index.d.mts +1 -10
- package/dist/cli/index.mjs +4 -153
- package/dist/{constants-DdXFXQtN.mjs → constants-Cxde4rpC.mjs} +1 -2
- package/dist/core/index.d.mts +3 -5
- package/dist/core/index.mjs +5 -4
- package/dist/core-C1XCMtqM.mjs +185 -0
- package/dist/{createApp-CgKOPhA4.mjs → createApp-ByWNRsZj.mjs} +64 -35
- package/dist/{defineResource-DWbpJYtm.mjs → defineResource-D9aY5Cy6.mjs} +108 -1157
- package/dist/discovery/index.mjs +37 -5
- package/dist/docs/index.d.mts +6 -9
- package/dist/docs/index.mjs +3 -21
- package/dist/dynamic/index.d.mts +93 -0
- package/dist/dynamic/index.mjs +122 -0
- package/dist/{elevation-DSTbVvYj.mjs → elevation-BEdACOLB.mjs} +5 -36
- package/dist/{elevation-DGo5shaX.d.mts → elevation-Ca_yveIO.d.mts} +41 -7
- package/dist/{errorHandler-C3GY3_ow.mjs → errorHandler--zp54tGc.mjs} +3 -5
- package/dist/errorHandler-Do4vVQ1f.d.mts +139 -0
- package/dist/{errors-DBANPbGr.mjs → errors-rxhfP7Hf.mjs} +1 -2
- package/dist/{eventPlugin-BEOvaDqo.mjs → eventPlugin-Ba00swHF.mjs} +25 -27
- package/dist/{eventPlugin-H6wDDjGO.d.mts → eventPlugin-iGrSEmwJ.d.mts} +105 -5
- package/dist/events/index.d.mts +72 -7
- package/dist/events/index.mjs +216 -4
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +19 -7
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/events/transports/redis.mjs +3 -4
- package/dist/factory/index.d.mts +23 -9
- package/dist/factory/index.mjs +48 -3
- package/dist/{fields-Bi_AVKSo.d.mts → fields-DFwdaWCq.d.mts} +1 -1
- package/dist/{fields-CTd_CrKr.mjs → fields-ipsbIRPK.mjs} +1 -2
- package/dist/hooks/index.d.mts +1 -3
- package/dist/hooks/index.mjs +2 -3
- package/dist/idempotency/index.d.mts +5 -5
- package/dist/idempotency/index.mjs +3 -7
- package/dist/idempotency/mongodb.d.mts +1 -1
- package/dist/idempotency/mongodb.mjs +4 -5
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/idempotency/redis.mjs +2 -5
- package/dist/{fastifyAdapter-6b_eRDBw.d.mts → index-BL8CaQih.d.mts} +56 -57
- package/dist/index-Diqcm14c.d.mts +369 -0
- package/dist/{prisma-Dy5S5F5i.d.mts → index-yhxyjqNb.d.mts} +4 -5
- package/dist/index.d.mts +100 -105
- package/dist/index.mjs +85 -58
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/event-gateway.mjs +8 -4
- package/dist/integrations/index.d.mts +4 -2
- package/dist/integrations/index.mjs +1 -1
- package/dist/integrations/jobs.d.mts +2 -2
- package/dist/integrations/jobs.mjs +63 -14
- package/dist/integrations/mcp/index.d.mts +219 -0
- package/dist/integrations/mcp/index.mjs +572 -0
- package/dist/integrations/mcp/testing.d.mts +53 -0
- package/dist/integrations/mcp/testing.mjs +104 -0
- package/dist/integrations/streamline.mjs +39 -19
- package/dist/integrations/webhooks.d.mts +56 -0
- package/dist/integrations/webhooks.mjs +139 -0
- package/dist/integrations/websocket-redis.d.mts +46 -0
- package/dist/integrations/websocket-redis.mjs +50 -0
- package/dist/integrations/websocket.d.mts +68 -2
- package/dist/integrations/websocket.mjs +96 -13
- package/dist/{interface-CSNjltAc.d.mts → interface-B4awm1RJ.d.mts} +2 -2
- package/dist/interface-DGmPxakH.d.mts +2213 -0
- package/dist/{keys-DhqDRxv3.mjs → keys-qcD-TVJl.mjs} +3 -4
- package/dist/{logger-ByrvQWZO.mjs → logger-Dz3j1ItV.mjs} +2 -4
- package/dist/{memory-B2v7KrCB.mjs → memory-Cb_7iy9e.mjs} +2 -4
- package/dist/metrics-Csh4nsvv.mjs +224 -0
- package/dist/migrations/index.d.mts +113 -44
- package/dist/migrations/index.mjs +84 -102
- package/dist/{mongodb-DNKEExbf.mjs → mongodb-BuQ7fNTg.mjs} +1 -4
- package/dist/{mongodb-ClykrfGo.d.mts → mongodb-CUpYfxfD.d.mts} +2 -3
- package/dist/{mongodb-Dg8O_gvd.d.mts → mongodb-bga9AbkD.d.mts} +2 -2
- package/dist/{openapi-9nB_kiuR.mjs → openapi-CBmZ6EQN.mjs} +4 -21
- package/dist/org/index.d.mts +12 -14
- package/dist/org/index.mjs +92 -119
- package/dist/org/types.d.mts +2 -2
- package/dist/org/types.mjs +1 -1
- package/dist/permissions/index.d.mts +4 -278
- package/dist/permissions/index.mjs +4 -579
- package/dist/permissions-CA5zg0yK.mjs +751 -0
- package/dist/plugins/index.d.mts +104 -107
- package/dist/plugins/index.mjs +203 -313
- package/dist/plugins/response-cache.mjs +4 -69
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +24 -11
- package/dist/{pluralize-CM-jZg7p.mjs → pluralize-CcT6qF0a.mjs} +12 -13
- package/dist/policies/index.d.mts +2 -2
- package/dist/policies/index.mjs +80 -83
- package/dist/presets/index.d.mts +26 -19
- package/dist/presets/index.mjs +2 -142
- package/dist/presets/multiTenant.d.mts +1 -4
- package/dist/presets/multiTenant.mjs +4 -6
- package/dist/presets-C9QXJV1u.mjs +422 -0
- package/dist/{queryCachePlugin-B6R0d4av.mjs → queryCachePlugin-ClosZdNS.mjs} +6 -27
- package/dist/{queryCachePlugin-Q6SYuHZ6.d.mts → queryCachePlugin-DcmETvcB.d.mts} +3 -3
- package/dist/queryParser-CgCtsjti.mjs +352 -0
- package/dist/{redis-UwjEp8Ea.d.mts → redis-CQ5YxMC5.d.mts} +2 -2
- package/dist/{redis-stream-CBg0upHI.d.mts → redis-stream-BW9UKLZM.d.mts} +9 -2
- package/dist/registry/index.d.mts +1 -4
- package/dist/registry/index.mjs +3 -4
- package/dist/{introspectionPlugin-B3JkrjwU.mjs → registry-I-ogLgL9.mjs} +1 -8
- package/dist/{requestContext-xi6OKBL-.mjs → requestContext-DYtmNpm5.mjs} +1 -3
- package/dist/resourceToTools-PMFE8HIv.mjs +533 -0
- package/dist/rpc/index.d.mts +90 -0
- package/dist/rpc/index.mjs +248 -0
- package/dist/{schemaConverter-Dtg0Kt9T.mjs → schemaConverter-DjzHpFam.mjs} +1 -2
- package/dist/schemas/index.d.mts +30 -30
- package/dist/schemas/index.mjs +2 -4
- package/dist/scope/index.d.mts +13 -2
- package/dist/scope/index.mjs +18 -5
- package/dist/{sessionManager-D_iEHjQl.d.mts → sessionManager-wbkYj2HL.d.mts} +2 -2
- package/dist/{sse-DkqQ1uxb.mjs → sse-BkViJPlT.mjs} +4 -25
- package/dist/testing/index.d.mts +551 -567
- package/dist/testing/index.mjs +1744 -1799
- package/dist/{tracing-8CEbhF0w.d.mts → tracing-bz_U4EM1.d.mts} +6 -1
- package/dist/{typeGuards-DwxA1t_L.mjs → typeGuards-Cj5Rgvlg.mjs} +1 -2
- package/dist/types/index.d.mts +4 -946
- package/dist/types/index.mjs +2 -4
- package/dist/types-BJmgxNbF.d.mts +275 -0
- package/dist/{types-RLkFVgaw.d.mts → types-BNUccdcf.d.mts} +2 -2
- package/dist/{types-Beqn1Un7.mjs → types-C6TQjtdi.mjs} +30 -2
- package/dist/{types-tKwaViYB.d.mts → types-Dt0-AI6E.d.mts} +68 -27
- package/dist/{types-DelU6kln.mjs → types-ZUu_h0jp.mjs} +1 -2
- package/dist/utils/index.d.mts +254 -351
- package/dist/utils/index.mjs +7 -6
- package/dist/utils-Dc0WhlIl.mjs +594 -0
- package/dist/versioning-BzfeHmhj.mjs +37 -0
- package/package.json +44 -10
- package/skills/arc/SKILL.md +518 -0
- package/skills/arc/references/auth.md +250 -0
- package/skills/arc/references/events.md +272 -0
- package/skills/arc/references/integrations.md +385 -0
- package/skills/arc/references/mcp.md +431 -0
- package/skills/arc/references/production.md +610 -0
- package/skills/arc/references/testing.md +183 -0
- package/dist/audited-CGdLiSlE.mjs +0 -140
- package/dist/chunk-C7Uep-_p.mjs +0 -20
- package/dist/circuitBreaker-CSS2VvL6.mjs +0 -1109
- package/dist/errorHandler-CW3OOeYq.d.mts +0 -72
- package/dist/interface-BtdYtQUA.d.mts +0 -1114
- package/dist/presets-BTeYbw7h.d.mts +0 -57
- package/dist/presets-CeFtfDR8.mjs +0 -119
- /package/dist/{errors-DAWRdiYP.d.mts → errors-CPpvPHT0.d.mts} +0 -0
- /package/dist/{externalPaths-SyPF2tgK.d.mts → externalPaths-DpO-s7r8.d.mts} +0 -0
- /package/dist/{interface-DTbsvIWe.d.mts → interface-D_BWALyZ.d.mts} +0 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# Arc Authentication & Authorization
|
|
2
|
+
|
|
3
|
+
## Auth Strategies (Discriminated Union)
|
|
4
|
+
|
|
5
|
+
Auth config uses `type` field to select strategy:
|
|
6
|
+
|
|
7
|
+
### JWT (`type: 'jwt'`)
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
const app = await createApp({
|
|
11
|
+
auth: {
|
|
12
|
+
type: 'jwt',
|
|
13
|
+
jwt: {
|
|
14
|
+
secret: process.env.JWT_SECRET, // Required, 32+ chars
|
|
15
|
+
expiresIn: '15m', // Access token TTL
|
|
16
|
+
refreshSecret: process.env.JWT_REFRESH_SECRET,
|
|
17
|
+
refreshExpiresIn: '7d',
|
|
18
|
+
},
|
|
19
|
+
// Optional: custom authenticator with JWT helpers
|
|
20
|
+
authenticate: async (request, { jwt }) => {
|
|
21
|
+
const token = request.headers.authorization?.split(' ')[1];
|
|
22
|
+
if (!token) return null;
|
|
23
|
+
const decoded = jwt.verify(token);
|
|
24
|
+
return await User.findById(decoded._id);
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
// Decorates: app.authenticate, app.optionalAuthenticate, app.authorize, app.auth
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Token lifecycle:**
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
// Issue tokens
|
|
35
|
+
const tokens = app.auth.issueTokens({ _id: user._id, email, role });
|
|
36
|
+
// Returns: { accessToken, refreshToken?, expiresIn, tokenType: 'Bearer' }
|
|
37
|
+
|
|
38
|
+
// Verify refresh token (rejects access tokens)
|
|
39
|
+
const decoded = app.auth.verifyRefreshToken(refreshToken);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Better Auth (`type: 'betterAuth'`)
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
import { createBetterAuthAdapter } from '@classytic/arc/auth';
|
|
46
|
+
|
|
47
|
+
const adapter = createBetterAuthAdapter({
|
|
48
|
+
auth, // Your betterAuth() instance
|
|
49
|
+
basePath: '/api/auth',
|
|
50
|
+
orgContext: true, // Extract org membership into request.scope
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const app = await createApp({
|
|
54
|
+
auth: { type: 'betterAuth', betterAuth: adapter },
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**Org context flow** (when `orgContext: true`):
|
|
59
|
+
1. Gets session via Better Auth
|
|
60
|
+
2. Reads `session.activeOrganizationId`, or falls back to `x-organization-id` header (needed for API key auth where synthetic sessions have no org context)
|
|
61
|
+
3. Looks up org membership via `getActiveMemberRole` (with explicit `organizationId` param for header-based resolution)
|
|
62
|
+
4. Splits roles: `"admin,recruiter"` → `['admin', 'recruiter']`
|
|
63
|
+
5. Sets `request.scope`: `{ kind: 'member', organizationId, orgRoles: string[], teamId? }`
|
|
64
|
+
|
|
65
|
+
### API Key Auth (Better Auth `apiKey()` plugin)
|
|
66
|
+
|
|
67
|
+
When the `apiKey()` plugin is enabled in Better Auth, Arc's adapter automatically:
|
|
68
|
+
- Resolves API key sessions via `enableSessionForAPIKeys: true`
|
|
69
|
+
- Falls back to `x-organization-id` header for org context (API key sessions have no `activeOrganizationId`)
|
|
70
|
+
- Generates OpenAPI security schemes dynamically — `apiKeyAuth` only appears in the spec when the plugin is active
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
x-api-key: ak_live_... # Authentication
|
|
74
|
+
x-organization-id: org_abc123 # Org context (required for org-scoped resources)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**OpenAPI security semantics:**
|
|
78
|
+
- Resource paths: `security: [{ bearerAuth: [] }, { apiKeyAuth: [], orgHeader: [] }]`
|
|
79
|
+
- Meaning: bearer token alone **OR** (API key **AND** org header together)
|
|
80
|
+
- Auth endpoints: `security: [{ cookieAuth: [] }, { bearerAuth: [] }, { apiKeyAuth: [] }]`
|
|
81
|
+
- No org header required for auth management endpoints
|
|
82
|
+
|
|
83
|
+
### Custom Plugin (`type: 'custom'`)
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
auth: { type: 'custom', plugin: myAuthPlugin }
|
|
87
|
+
// Plugin must decorate fastify.authenticate
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Custom Function (`type: 'authenticator'`)
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
auth: {
|
|
94
|
+
type: 'authenticator',
|
|
95
|
+
authenticate: async (req, reply) => {
|
|
96
|
+
const session = await validateSession(req);
|
|
97
|
+
if (!session) reply.code(401).send({ error: 'Unauthorized' });
|
|
98
|
+
req.user = session.user;
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Disabled
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
auth: false
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Fastify Decorators
|
|
110
|
+
|
|
111
|
+
| Decorator | Description | JWT | Better Auth |
|
|
112
|
+
|-----------|-------------|-----|-------------|
|
|
113
|
+
| `fastify.authenticate` | Verify JWT/session, set `request.user` | Yes | Yes |
|
|
114
|
+
| `fastify.optionalAuthenticate` | Parse token if present, skip if absent | Yes | Yes |
|
|
115
|
+
| `fastify.authorize(...roles)` | Check `user.role`. `authorize('*')` = any auth user | Yes | No |
|
|
116
|
+
|
|
117
|
+
## Permission Functions
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
import {
|
|
121
|
+
allowPublic, requireAuth, requireRoles, requireOwnership,
|
|
122
|
+
requireOrgMembership, requireOrgRole, requireTeamMembership,
|
|
123
|
+
allOf, anyOf, when, denyAll,
|
|
124
|
+
} from '@classytic/arc';
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
| Function | Description |
|
|
128
|
+
|----------|-------------|
|
|
129
|
+
| `allowPublic()` | No authentication |
|
|
130
|
+
| `requireAuth()` | Any authenticated user |
|
|
131
|
+
| `requireRoles(['admin'])` | At least one role matches |
|
|
132
|
+
| `requireOwnership('userId')` | Resource owner only (elevated bypasses) |
|
|
133
|
+
| `requireOrgMembership()` | Must be org member |
|
|
134
|
+
| `requireOrgRole('admin', 'owner')` | Must have org-level role |
|
|
135
|
+
| `requireTeamMembership()` | Must have active team |
|
|
136
|
+
| `allOf(p1, p2)` | AND — all must pass |
|
|
137
|
+
| `anyOf(p1, p2)` | OR — any can pass |
|
|
138
|
+
| `denyAll(reason?)` | Always deny |
|
|
139
|
+
|
|
140
|
+
**Custom permission:**
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
const requirePro = (): PermissionCheck => async (ctx) => {
|
|
144
|
+
if (!ctx.user) return { granted: false, reason: 'Auth required' };
|
|
145
|
+
return { granted: ctx.user.plan === 'pro' };
|
|
146
|
+
};
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Dynamic ACL (DB-managed)
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
import { createDynamicPermissionMatrix } from '@classytic/arc/permissions';
|
|
153
|
+
|
|
154
|
+
const acl = createDynamicPermissionMatrix({
|
|
155
|
+
resolveRolePermissions: async ({ request }) => aclService.getRoleMatrix(orgId),
|
|
156
|
+
cacheStore: new RedisCacheStore({ client: redis, prefix: 'acl:' }),
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
permissions: { list: acl.canAction('product', 'read') }
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
- Reads org roles from `request.scope.orgRoles`
|
|
163
|
+
- Elevated scope bypasses all checks
|
|
164
|
+
- Supports `*` wildcard for resource/action
|
|
165
|
+
- Cache failures fail open to resolver
|
|
166
|
+
|
|
167
|
+
## Org Guards
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
import { orgGuard, requireOrg, requireOrgRole } from '@classytic/arc/org';
|
|
171
|
+
|
|
172
|
+
// Require org context
|
|
173
|
+
fastify.get('/invoices', { preHandler: [fastify.authenticate, requireOrg()] }, handler);
|
|
174
|
+
|
|
175
|
+
// Require specific org role
|
|
176
|
+
fastify.post('/invoices', { preHandler: [fastify.authenticate, requireOrgRole('admin')] }, handler);
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Request Scope
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
import type { RequestScope } from '@classytic/arc/scope';
|
|
183
|
+
import { getUserId, getUserRoles, getOrgId, getOrgRoles, getTeamId } from '@classytic/arc/scope';
|
|
184
|
+
|
|
185
|
+
// Variants (userId/userRoles on all authenticated variants):
|
|
186
|
+
// { kind: 'public' }
|
|
187
|
+
// { kind: 'authenticated', userId?, userRoles? }
|
|
188
|
+
// { kind: 'member', userId?, userRoles, organizationId, orgRoles: string[], teamId? }
|
|
189
|
+
// { kind: 'elevated', userId?, organizationId?, elevatedBy }
|
|
190
|
+
|
|
191
|
+
const userId = getUserId(request.scope); // string | undefined
|
|
192
|
+
const globalRoles = getUserRoles(request.scope); // string[]
|
|
193
|
+
const orgId = getOrgId(request.scope); // string | undefined
|
|
194
|
+
const orgRoles = getOrgRoles(request.scope); // string[]
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Two Role Layers
|
|
198
|
+
|
|
199
|
+
| Layer | Source | Scope Field | Checked By |
|
|
200
|
+
|-------|--------|-------------|------------|
|
|
201
|
+
| Global roles | `user.role` | `scope.userRoles` | `requireRoles()`, `authorize()` |
|
|
202
|
+
| Org roles | Org membership | `scope.orgRoles` | `requireOrgRole()`, `requireOrgMembership()` |
|
|
203
|
+
|
|
204
|
+
## Role Hierarchy
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
import { createRoleHierarchy } from '@classytic/arc/permissions';
|
|
208
|
+
|
|
209
|
+
const hierarchy = createRoleHierarchy({
|
|
210
|
+
superadmin: ['admin'],
|
|
211
|
+
admin: ['branch_manager'],
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
hierarchy.expand(['superadmin']); // → ['superadmin', 'admin', 'branch_manager']
|
|
215
|
+
hierarchy.includes(['admin'], 'branch_manager'); // → true
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Token Extraction & Revocation
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
auth: {
|
|
222
|
+
type: 'jwt', jwt: { secret },
|
|
223
|
+
tokenExtractor: (req) => req.cookies?.['auth-token'] ?? null,
|
|
224
|
+
isRevoked: async (decoded) => redis.sismember('revoked', decoded.jti),
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Microservice Gateway Pattern
|
|
229
|
+
|
|
230
|
+
```
|
|
231
|
+
Frontend → API Gateway (Arc + Better Auth) → Downstream Services
|
|
232
|
+
↓
|
|
233
|
+
Forwards: X-User-Id, X-Org-Id, X-User-Roles
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Gateway verifies session, downstream services trust headers:
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
// Downstream — no Better Auth needed
|
|
240
|
+
const app = await createApp({
|
|
241
|
+
auth: {
|
|
242
|
+
type: 'authenticator',
|
|
243
|
+
authenticate: async (req, reply) => {
|
|
244
|
+
const userId = req.headers['x-user-id'];
|
|
245
|
+
if (!userId) return reply.code(401).send({ error: 'Unauthorized' });
|
|
246
|
+
req.user = { id: userId, role: JSON.parse(req.headers['x-user-roles'] || '[]') };
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
```
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# Arc Events System
|
|
2
|
+
|
|
3
|
+
Domain event pub/sub with pluggable transports. Auto-emits events on CRUD operations.
|
|
4
|
+
|
|
5
|
+
## Hooks vs Events
|
|
6
|
+
|
|
7
|
+
| Aspect | Hooks | Events |
|
|
8
|
+
|--------|-------|--------|
|
|
9
|
+
| Purpose | Internal lifecycle callbacks | External integration |
|
|
10
|
+
| Scope | Same process, synchronous flow | Cross-service, async |
|
|
11
|
+
| Use when | Validating, transforming, auditing | Notifying services, event-driven systems |
|
|
12
|
+
| Transport | In-process only | Pluggable (Memory → Redis → Kafka) |
|
|
13
|
+
| Pattern | `beforeCreate`, `afterUpdate` | `product.created`, `order.updated` |
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
The `createApp()` factory auto-registers `eventPlugin` — no manual registration needed:
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { createApp } from '@classytic/arc/factory';
|
|
21
|
+
|
|
22
|
+
// Development (in-memory, default — zero config)
|
|
23
|
+
const app = await createApp({ preset: 'development' });
|
|
24
|
+
// app.events is ready to use
|
|
25
|
+
|
|
26
|
+
// Production (Redis transport, with retry)
|
|
27
|
+
import { RedisEventTransport } from '@classytic/arc/events/redis';
|
|
28
|
+
|
|
29
|
+
const app = await createApp({
|
|
30
|
+
stores: { events: new RedisEventTransport(redis) },
|
|
31
|
+
arcPlugins: {
|
|
32
|
+
events: {
|
|
33
|
+
logEvents: true,
|
|
34
|
+
failOpen: true, // default: suppress transport failures
|
|
35
|
+
retry: { maxRetries: 3, backoffMs: 1000 },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Disable event plugin entirely
|
|
41
|
+
const app = await createApp({ arcPlugins: { events: false } });
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
**Manual registration** (for apps not using `createApp`):
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
import { eventPlugin } from '@classytic/arc/events';
|
|
48
|
+
|
|
49
|
+
await fastify.register(eventPlugin); // Memory transport
|
|
50
|
+
|
|
51
|
+
// Redis Pub/Sub
|
|
52
|
+
import { RedisEventTransport } from '@classytic/arc/events/redis';
|
|
53
|
+
await fastify.register(eventPlugin, {
|
|
54
|
+
transport: new RedisEventTransport(redisClient, { channel: 'arc-events' }),
|
|
55
|
+
logEvents: true,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Redis Streams (ordered, persistent, consumer groups)
|
|
59
|
+
import { RedisStreamTransport } from '@classytic/arc/events/redis-stream';
|
|
60
|
+
await fastify.register(eventPlugin, {
|
|
61
|
+
transport: new RedisStreamTransport(redisClient, {
|
|
62
|
+
stream: 'arc:events',
|
|
63
|
+
group: 'api-service',
|
|
64
|
+
consumer: 'worker-1',
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`failOpen` behavior:
|
|
70
|
+
- `true` (default): publish/subscribe/close transport errors are logged and suppressed.
|
|
71
|
+
- `false`: transport errors are thrown to caller.
|
|
72
|
+
|
|
73
|
+
## Auto-Emitted Events
|
|
74
|
+
|
|
75
|
+
BaseController automatically emits events when eventPlugin is registered:
|
|
76
|
+
|
|
77
|
+
| Operation | Event Type | Payload |
|
|
78
|
+
|-----------|------------|---------|
|
|
79
|
+
| `create()` | `{resource}.created` | Created document |
|
|
80
|
+
| `update()` | `{resource}.updated` | Updated document |
|
|
81
|
+
| `delete()` | `{resource}.deleted` | Deleted document |
|
|
82
|
+
| `restore()` | `{resource}.restored` | Restored document |
|
|
83
|
+
|
|
84
|
+
Disable per-controller: `super({ disableEvents: true })`
|
|
85
|
+
|
|
86
|
+
## Publishing & Subscribing
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
// Publish
|
|
90
|
+
await fastify.events.publish('order.created', {
|
|
91
|
+
orderId: 'order-123',
|
|
92
|
+
total: 99.99,
|
|
93
|
+
}, {
|
|
94
|
+
userId: request.user._id,
|
|
95
|
+
organizationId: getOrgId(request.scope),
|
|
96
|
+
correlationId: request.id,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Subscribe to specific event
|
|
100
|
+
await fastify.events.subscribe('order.created', async (event) => {
|
|
101
|
+
await sendConfirmation(event.payload);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Subscribe to pattern (wildcard)
|
|
105
|
+
await fastify.events.subscribe('order.*', async (event) => {
|
|
106
|
+
await updateAnalytics(event.type, event.payload);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Subscribe to all
|
|
110
|
+
await fastify.events.subscribe('*', async (event) => {
|
|
111
|
+
await auditLog.create(event);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Unsubscribe
|
|
115
|
+
const unsub = await fastify.events.subscribe('order.created', handler);
|
|
116
|
+
unsub();
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Event Structure
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
interface DomainEvent<T> {
|
|
123
|
+
type: string; // e.g., 'order.created'
|
|
124
|
+
payload: T;
|
|
125
|
+
meta: {
|
|
126
|
+
id: string; // Unique event ID
|
|
127
|
+
timestamp: Date;
|
|
128
|
+
source?: string;
|
|
129
|
+
resource?: string;
|
|
130
|
+
resourceId?: string;
|
|
131
|
+
userId?: string;
|
|
132
|
+
organizationId?: string;
|
|
133
|
+
correlationId?: string;
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Custom Transport
|
|
139
|
+
|
|
140
|
+
Implement `EventTransport` for RabbitMQ, Kafka, etc.:
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
import type { EventTransport, DomainEvent } from '@classytic/arc/events';
|
|
144
|
+
|
|
145
|
+
class KafkaTransport implements EventTransport {
|
|
146
|
+
readonly name = 'kafka';
|
|
147
|
+
|
|
148
|
+
async publish(event: DomainEvent): Promise<void> {
|
|
149
|
+
await this.producer.send({ topic: event.type, messages: [{ value: JSON.stringify(event) }] });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async subscribe(pattern: string, handler: EventHandler): Promise<() => void> {
|
|
153
|
+
// Subscribe to Kafka topic matching pattern
|
|
154
|
+
return () => { /* unsubscribe */ };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async close(): Promise<void> {
|
|
158
|
+
await this.producer.disconnect();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Built-in Transports
|
|
164
|
+
|
|
165
|
+
| Transport | Import | Use Case |
|
|
166
|
+
|-----------|--------|----------|
|
|
167
|
+
| Memory | `@classytic/arc/events` (default) | Development, testing, single-instance |
|
|
168
|
+
| Redis Pub/Sub | `@classytic/arc/events/redis` | Multi-instance, real-time |
|
|
169
|
+
| Redis Streams | `@classytic/arc/events/redis-stream` | Ordered, persistent, consumer groups |
|
|
170
|
+
|
|
171
|
+
## Injectable Logger
|
|
172
|
+
|
|
173
|
+
All transports and retry accept a `logger` option — defaults to `console`, compatible with pino/fastify.log:
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
import type { EventLogger } from '@classytic/arc/events';
|
|
177
|
+
|
|
178
|
+
// Interface: { warn(msg, ...args): void; error(msg, ...args): void }
|
|
179
|
+
|
|
180
|
+
// Use Fastify's logger
|
|
181
|
+
await fastify.register(eventPlugin, {
|
|
182
|
+
transport: new RedisEventTransport(redisClient, {
|
|
183
|
+
logger: fastify.log, // pino logger
|
|
184
|
+
}),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Use with Memory transport
|
|
188
|
+
new MemoryEventTransport({ logger: fastify.log });
|
|
189
|
+
|
|
190
|
+
// Use with Redis Streams
|
|
191
|
+
new RedisStreamTransport(redisClient, { logger: fastify.log });
|
|
192
|
+
|
|
193
|
+
// Use with retry wrapper
|
|
194
|
+
import { withRetry } from '@classytic/arc/events';
|
|
195
|
+
const retried = withRetry(handler, { maxRetries: 3, logger: fastify.log });
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
| Component | File | `logger` option |
|
|
199
|
+
|-----------|------|-----------------|
|
|
200
|
+
| `MemoryEventTransport` | `EventTransport.ts` | `MemoryEventTransportOptions.logger` |
|
|
201
|
+
| `withRetry()` | `retry.ts` | `RetryOptions.logger` |
|
|
202
|
+
| `RedisEventTransport` | `transports/redis.ts` | `RedisEventTransportOptions.logger` |
|
|
203
|
+
| `RedisStreamTransport` | `transports/redis-stream.ts` | `RedisStreamTransportOptions.logger` |
|
|
204
|
+
|
|
205
|
+
## Typed Events — defineEvent & Event Registry
|
|
206
|
+
|
|
207
|
+
Declare events with schemas for runtime validation and introspection:
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
import { defineEvent, createEventRegistry } from '@classytic/arc/events';
|
|
211
|
+
|
|
212
|
+
const OrderCreated = defineEvent({
|
|
213
|
+
name: 'order.created',
|
|
214
|
+
version: 1,
|
|
215
|
+
description: 'Emitted when an order is placed',
|
|
216
|
+
schema: {
|
|
217
|
+
type: 'object',
|
|
218
|
+
properties: {
|
|
219
|
+
orderId: { type: 'string' },
|
|
220
|
+
total: { type: 'number' },
|
|
221
|
+
},
|
|
222
|
+
required: ['orderId', 'total'],
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Type-safe event creation
|
|
227
|
+
const event = OrderCreated.create({ orderId: 'o-1', total: 100 }, { userId: 'user-1' });
|
|
228
|
+
await app.events.publish(event.type, event.payload, event.meta);
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
**Event Registry** — catalog + auto-validation on publish:
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
const registry = createEventRegistry();
|
|
235
|
+
registry.register(OrderCreated);
|
|
236
|
+
|
|
237
|
+
const app = await createApp({
|
|
238
|
+
arcPlugins: {
|
|
239
|
+
events: { registry, validateMode: 'warn' },
|
|
240
|
+
// 'warn' (default): log warning, still publish
|
|
241
|
+
// 'reject': throw error, do NOT publish
|
|
242
|
+
// 'off': registry is introspection-only
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Introspect at runtime
|
|
247
|
+
app.events.registry?.catalog();
|
|
248
|
+
// → [{ name: 'order.created', version: 1, schema: {...} }, ...]
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## QueryCache Integration
|
|
252
|
+
|
|
253
|
+
QueryCache uses events for auto-invalidation. When `arcPlugins.queryCache` is enabled, all CRUD events automatically bump resource versions, invalidating cached queries — zero config required.
|
|
254
|
+
|
|
255
|
+
## Retry Logic
|
|
256
|
+
|
|
257
|
+
Events module includes retry with exponential backoff for failed handlers:
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
import { withRetry } from '@classytic/arc/events';
|
|
261
|
+
|
|
262
|
+
const retriedHandler = withRetry(async (event) => {
|
|
263
|
+
await processEvent(event);
|
|
264
|
+
}, {
|
|
265
|
+
maxRetries: 3, // default: 3
|
|
266
|
+
backoffMs: 1000, // initial delay, doubles each retry (default: 1000)
|
|
267
|
+
maxBackoffMs: 30000, // cap (default: 30000)
|
|
268
|
+
logger: fastify.log, // default: console
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await fastify.events.subscribe('order.created', retriedHandler);
|
|
272
|
+
```
|