@classytic/arc 2.11.4 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -12
- package/dist/{BaseController-swXruJ2_.mjs → BaseController-DX_T-bDB.mjs} +388 -423
- package/dist/EventTransport-CT_52aWU.d.mts +34 -0
- package/dist/EventTransport-DLWoUMHy.mjs +103 -0
- package/dist/{ResourceRegistry-DkAeAuTX.mjs → ResourceRegistry-CTERg_2x.mjs} +139 -66
- package/dist/audit/index.d.mts +2 -2
- package/dist/audit/index.mjs +1 -1
- package/dist/auth/audit.d.mts +199 -0
- package/dist/auth/audit.mjs +288 -0
- package/dist/auth/index.d.mts +3 -3
- package/dist/auth/index.mjs +117 -191
- package/dist/{betterAuthOpenApi-DwxtK3uG.mjs → betterAuthOpenApi--M_i87dQ.mjs} +1 -1
- package/dist/buildHandler-olo-gt94.mjs +610 -0
- package/dist/cache/index.mjs +3 -3
- package/dist/cli/commands/describe.d.mts +89 -13
- package/dist/cli/commands/describe.mjs +56 -2
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/generate.mjs +147 -48
- package/dist/cli/commands/init.d.mts +13 -0
- package/dist/cli/commands/init.mjs +130 -87
- package/dist/cli/commands/introspect.mjs +8 -1
- package/dist/context/index.mjs +1 -1
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +5 -5
- package/dist/core-DECn6zaU.mjs +1399 -0
- package/dist/{createActionRouter-CIKOcNA7.mjs → createActionRouter-CBxLLbn3.mjs} +7 -20
- package/dist/createAggregationRouter-CRIBv4sC.mjs +114 -0
- package/dist/{createApp-C9bRrqlX.mjs → createApp-XX2-N0Yd.mjs} +28 -22
- package/dist/{defineEvent-D1Ky9M1D.mjs → defineEvent-D5h7EvAx.mjs} +1 -1
- package/dist/docs/index.d.mts +24 -11
- package/dist/docs/index.mjs +2 -2
- package/dist/{elevation-DOFoxoDs.mjs → elevation-DgoeTyfX.mjs} +1 -1
- package/dist/errorHandler-Bk-AGhkU.mjs +174 -0
- package/dist/errorHandler-DFr45ZG4.d.mts +45 -0
- package/dist/errors-j4aJm1Wg.mjs +184 -0
- package/dist/{eventPlugin-Cts2-Tfj.mjs → eventPlugin-CaKTYkYM.mjs} +28 -4
- package/dist/{eventPlugin-DDJoNEPL.d.mts → eventPlugin-qXpqTebY.d.mts} +24 -1
- package/dist/events/index.d.mts +6 -6
- package/dist/events/index.mjs +11 -35
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/factory/index.d.mts +2 -2
- package/dist/factory/index.mjs +2 -2
- package/dist/{fields-BRjxOAFp.d.mts → fields-COhcH3fk.d.mts} +23 -2
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +1 -1
- package/dist/idempotency/index.d.mts +1 -1
- package/dist/idempotency/index.mjs +1 -20
- package/dist/idempotency/redis.mjs +1 -1
- package/dist/{index-rHjXmJar.d.mts → index-BTqLEvhu.d.mts} +163 -3
- package/dist/{index-CXXRbnf8.d.mts → index-BtW7qYwa.d.mts} +660 -326
- package/dist/{index-m8mOOlFW.d.mts → index-Ds61mrJE.d.mts} +50 -4
- package/dist/{index-D9t1KNaB.d.mts → index-Dz5IKsrE.d.mts} +360 -219
- package/dist/index.d.mts +6 -7
- package/dist/index.mjs +9 -10
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +1 -1
- package/dist/integrations/mcp/index.d.mts +2 -2
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/streamline.d.mts +60 -11
- package/dist/integrations/streamline.mjs +75 -85
- package/dist/integrations/websocket.mjs +2 -8
- package/dist/middleware/index.d.mts +1 -1
- package/dist/middleware/index.mjs +2 -2
- package/dist/migrations/index.d.mts +23 -3
- package/dist/migrations/index.mjs +0 -7
- package/dist/{multipartBody-CvTR1Un6.mjs → multipartBody-BOvVSVCD.mjs} +11 -8
- package/dist/openapi-noXno2CV.mjs +968 -0
- package/dist/org/index.d.mts +2 -2
- package/dist/org/index.mjs +1 -1
- package/dist/permissions/index.d.mts +3 -3
- package/dist/permissions/index.mjs +3 -3
- package/dist/{permissions-gd_aUWrR.mjs → permissions-ohQyv50e.mjs} +404 -176
- package/dist/{pipe-DVoIheVC.mjs → pipe-Zr0KXjQe.mjs} +1 -1
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/pipeline/index.mjs +1 -1
- package/dist/plugins/index.d.mts +16 -31
- package/dist/plugins/index.mjs +33 -13
- package/dist/plugins/response-cache.mjs +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/presets/filesUpload.d.mts +4 -4
- package/dist/presets/filesUpload.mjs +6 -9
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +2 -2
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +6 -8
- package/dist/{presets-Z7P5w4gF.mjs → presets-BbkjdPeH.mjs} +6 -28
- package/dist/{queryCachePlugin-Bq6bO6vc.mjs → queryCachePlugin-m1XsgAIJ.mjs} +3 -3
- package/dist/{redis-stream-xTGxB2bm.d.mts → redis-stream-D6HzR1Z_.d.mts} +1 -1
- package/dist/registry/index.d.mts +1 -1
- package/dist/registry/index.mjs +2 -2
- package/dist/{replyHelpers-ByllIXXV.mjs → replyHelpers-CK-FNO8E.mjs} +3 -21
- package/dist/{resourceToTools-CxNmI6xF.mjs → resourceToTools-DLL32us3.mjs} +224 -71
- package/dist/{routerShared-BqLRb5l7.mjs → routerShared-DrOa-26E.mjs} +41 -36
- package/dist/{schemaIR-Dy2p4MxS.mjs → schemaIR-lYhC2gE5.mjs} +1 -1
- package/dist/schemas/index.d.mts +100 -30
- package/dist/schemas/index.mjs +86 -29
- package/dist/scim/index.d.mts +264 -0
- package/dist/scim/index.mjs +963 -0
- package/dist/scope/index.d.mts +3 -3
- package/dist/scope/index.mjs +4 -4
- package/dist/{sse-V7aXc3bW.mjs → sse-Bz-5ZeTt.mjs} +1 -1
- package/dist/{store-helpers-Cp4uKC1U.mjs → store-helpers-BkIN9-vu.mjs} +1 -1
- package/dist/testing/index.d.mts +2 -8
- package/dist/testing/index.mjs +16 -24
- package/dist/types/index.d.mts +4 -4
- package/dist/{types-D7KpfiL1.d.mts → types-BvqwCCSx.d.mts} +73 -25
- package/dist/{types-DDyTPc6y.d.mts → types-CTYvcwHe.d.mts} +195 -1
- package/dist/{types-AOD8fxIw.mjs → types-C_s5moIu.mjs} +117 -1
- package/dist/{types-BQ9TJQNy.d.mts → types-DQHFc8PM.d.mts} +1 -1
- package/dist/utils/index.d.mts +2 -2
- package/dist/utils/index.mjs +5 -5
- package/dist/{utils-CcYTj09l.mjs → utils-_h9B3c57.mjs} +1269 -1334
- package/dist/{versioning-DsglKfM_.d.mts → versioning-DTTvc80y.d.mts} +1 -1
- package/package.json +24 -34
- package/skills/arc/SKILL.md +147 -51
- package/skills/arc/references/agent-auth.md +238 -0
- package/skills/arc/references/api-reference.md +187 -0
- package/skills/arc/references/auth.md +354 -7
- package/skills/arc/references/enterprise-auth.md +94 -0
- package/skills/arc/references/events.md +8 -6
- package/skills/arc/references/mcp.md +2 -2
- package/skills/arc/references/multi-tenancy.md +11 -2
- package/skills/arc/references/production.md +10 -9
- package/skills/arc/references/scim.md +247 -0
- package/skills/arc/references/testing.md +1 -1
- package/skills/arc-code-review/SKILL.md +141 -0
- package/skills/arc-code-review/references/anti-patterns.md +911 -0
- package/skills/arc-code-review/references/arc-cheatsheet.md +380 -0
- package/skills/arc-code-review/references/migration-recipes.md +700 -0
- package/skills/arc-code-review/references/mongokit-migration.md +386 -0
- package/skills/arc-code-review/references/scaffolding.md +230 -0
- package/skills/arc-code-review/references/severity.md +127 -0
- package/dist/EventTransport-BFQjw9pB.mjs +0 -133
- package/dist/EventTransport-CYNUXdCJ.d.mts +0 -293
- package/dist/adapters/index.d.mts +0 -3
- package/dist/adapters/index.mjs +0 -2
- package/dist/adapters-DUUiiimH.mjs +0 -964
- package/dist/auth/mongoose.d.mts +0 -191
- package/dist/auth/mongoose.mjs +0 -73
- package/dist/core-CbcQRIch.mjs +0 -1054
- package/dist/errorHandler-BQm8ZxTK.mjs +0 -173
- package/dist/errorHandler-DEWmGWPz.d.mts +0 -114
- package/dist/errors-D5c-5BJL.mjs +0 -232
- package/dist/index-Rg8axYPz.d.mts +0 -370
- package/dist/openapi-D7G1V7ex.mjs +0 -557
- /package/dist/{HookSystem-CGsMd6oK.mjs → HookSystem-Iiebom92.mjs} +0 -0
- /package/dist/{actionPermissions-sUUKDhtP.mjs → actionPermissions-CyUkQu6O.mjs} +0 -0
- /package/dist/{caching-CheW3m-S.mjs → caching-SM8gghN6.mjs} +0 -0
- /package/dist/{constants-BhY1OHoH.mjs → constants-Cxde4rpC.mjs} +0 -0
- /package/dist/{elevation-BQQXZ_VR.d.mts → elevation-BXOWoGCF.d.mts} +0 -0
- /package/dist/{keys-CARyUjiR.mjs → keys-CGcCbNyu.mjs} +0 -0
- /package/dist/{loadResources-CPpkyKfM.mjs → loadResources-DBMQg_Aj.mjs} +0 -0
- /package/dist/{memory-DikHSvWa.mjs → memory-UBydS5ku.mjs} +0 -0
- /package/dist/{metrics-Csh4nsvv.mjs → metrics-Qnvwc-LQ.mjs} +0 -0
- /package/dist/{pluralize-CWP6MB39.mjs → pluralize-DQgqgifU.mjs} +0 -0
- /package/dist/{registry-D63ee7fl.mjs → registry-I-ogLgL9.mjs} +0 -0
- /package/dist/{requestContext-C5XeK3VA.mjs → requestContext-SSaaTgW8.mjs} +0 -0
- /package/dist/{schemaConverter-B0oKLuqI.mjs → schemaConverter-De34B1ZG.mjs} +0 -0
- /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-BzkXkvVv.mjs} +0 -0
- /package/dist/{types-DV9WDfeg.mjs → types-D57iXYb8.mjs} +0 -0
- /package/dist/{versioning-CGPjkqAg.mjs → versioning-BUrT5aP4.mjs} +0 -0
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
# Arc Anti-Pattern Detection Catalog
|
|
2
|
+
|
|
3
|
+
Every entry below has: **detection** (greppable regex / file pattern), **severity**, **why it matters**, and **fix** (with arc API citation). Run each detection against `src/` (excluding `node_modules`, `dist`, `coverage`, `*.test.*`).
|
|
4
|
+
|
|
5
|
+
Severity legend: 🔴 Critical · 🟠 High · 🟡 Medium · 🟢 Low
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## §1. Manual query parsing 🟡
|
|
10
|
+
|
|
11
|
+
**Detection (Grep):**
|
|
12
|
+
```
|
|
13
|
+
pattern: "req\\.query\\.(filter|sort|page|limit|search|q)\\b"
|
|
14
|
+
glob: "**/*.{ts,js}"
|
|
15
|
+
output_mode: content
|
|
16
|
+
```
|
|
17
|
+
Also: `\\$or\\s*:\\s*\\[`, `\\$and\\s*:\\s*\\[`, `\\$regex`, `Number\\(req\\.query\\.`, `parseInt\\(req\\.query\\.`.
|
|
18
|
+
|
|
19
|
+
**Anti-pattern:**
|
|
20
|
+
```typescript
|
|
21
|
+
const filters: any = {};
|
|
22
|
+
if (req.query.status) filters.status = req.query.status;
|
|
23
|
+
if (req.query.minPrice) filters.price = { $gte: Number(req.query.minPrice) };
|
|
24
|
+
if (req.query.q) {
|
|
25
|
+
filters.$or = [
|
|
26
|
+
{ name: { $regex: req.query.q, $options: 'i' } },
|
|
27
|
+
{ description: { $regex: req.query.q, $options: 'i' } },
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
const items = await Product.find(filters)
|
|
31
|
+
.skip((Number(req.query.page) - 1) * 20)
|
|
32
|
+
.limit(20);
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Why critical-adjacent:** ad-hoc parsing is a ReDoS vector (no regex bounds), bypasses field whitelists, and silently diverges across resources.
|
|
36
|
+
|
|
37
|
+
**Fix:** Arc's built-in parser handles filters, operators, sort, pagination, populate, select.
|
|
38
|
+
```typescript
|
|
39
|
+
defineResource({
|
|
40
|
+
name: 'product',
|
|
41
|
+
schemaOptions: {
|
|
42
|
+
query: {
|
|
43
|
+
filterableFields: { status: { type: 'string' }, price: { type: 'number' } },
|
|
44
|
+
allowedPopulate: ['category'],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
// GET /products?status=active&price[gte]=100&sort=-createdAt&page=2&limit=20&populate=category
|
|
49
|
+
```
|
|
50
|
+
For MongoDB-specific operators (`$lookup`, geo): use `QueryParser` from `@classytic/mongokit`:
|
|
51
|
+
```typescript
|
|
52
|
+
import { QueryParser } from '@classytic/mongokit';
|
|
53
|
+
defineResource({
|
|
54
|
+
queryParser: new QueryParser({
|
|
55
|
+
allowedFilterFields: ['status', 'category'],
|
|
56
|
+
allowedSortFields: ['createdAt', 'price'],
|
|
57
|
+
allowedOperators: ['eq', 'gte', 'lte', 'in'],
|
|
58
|
+
}),
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
ReDoS protection, max-depth, max-limit are enforced.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## §2. Hand-written Fastify route schemas 🟡
|
|
66
|
+
|
|
67
|
+
**Detection:**
|
|
68
|
+
```
|
|
69
|
+
pattern: "fastify\\.(get|post|patch|put|delete)\\([^,]+,\\s*\\{\\s*schema\\s*:"
|
|
70
|
+
multiline: true
|
|
71
|
+
```
|
|
72
|
+
Or: `properties\\s*:\\s*\\{` near `route\\(|fastify\\.(get|post|patch)`.
|
|
73
|
+
|
|
74
|
+
**Anti-pattern:**
|
|
75
|
+
```typescript
|
|
76
|
+
fastify.post('/products', {
|
|
77
|
+
schema: {
|
|
78
|
+
body: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
properties: {
|
|
81
|
+
name: { type: 'string', minLength: 1 },
|
|
82
|
+
price: { type: 'number', minimum: 0 },
|
|
83
|
+
},
|
|
84
|
+
required: ['name', 'price'],
|
|
85
|
+
},
|
|
86
|
+
response: { 200: { type: 'object', properties: { id: {...}, name: {...} } } },
|
|
87
|
+
},
|
|
88
|
+
handler: async (req) => Product.create(req.body),
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Why:** Schema duplicates Mongoose model + diverges over time. No OpenAPI sync.
|
|
93
|
+
|
|
94
|
+
**Fix:**
|
|
95
|
+
```typescript
|
|
96
|
+
defineResource({
|
|
97
|
+
name: 'product',
|
|
98
|
+
schemaOptions: {
|
|
99
|
+
fieldRules: {
|
|
100
|
+
name: { type: 'string', minLength: 1, required: true },
|
|
101
|
+
price: { type: 'number', minimum: 0, required: true },
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
```
|
|
106
|
+
Body, response, and OpenAPI auto-derive. Mongoose model constraints take precedence; `fieldRules` supplements.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## §3. Manual CRUD routes 🟠
|
|
111
|
+
|
|
112
|
+
**Detection:** Multiple `fastify.(get|post|patch|delete)` calls for the same path stem.
|
|
113
|
+
```
|
|
114
|
+
pattern: "fastify\\.(get|post|patch|put|delete)\\(['\"]\\/(\\w+)"
|
|
115
|
+
output_mode: content
|
|
116
|
+
```
|
|
117
|
+
Group hits by capture group 2 (path stem). Three or more methods on the same stem ⇒ candidate for `defineResource`.
|
|
118
|
+
|
|
119
|
+
**Anti-pattern:** A `routes/products.ts` file with `GET /products`, `GET /products/:id`, `POST /products`, `PATCH /products/:id`, `DELETE /products/:id` each implemented inline.
|
|
120
|
+
|
|
121
|
+
**Why high:** One resource gets a fix (e.g., pagination, error handling) and the others drift. Arc generates all five identically.
|
|
122
|
+
|
|
123
|
+
**Fix:** Replace the whole file with one `defineResource`. See [migration-recipes.md §1](migration-recipes.md).
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## §4. Manual permission checks inside handlers 🔴
|
|
128
|
+
|
|
129
|
+
**Detection:**
|
|
130
|
+
```
|
|
131
|
+
pattern: "(req|request)\\.user\\.(role|roles)\\b|user\\.role\\s*[!=]==|roles\\.includes\\("
|
|
132
|
+
output_mode: content
|
|
133
|
+
```
|
|
134
|
+
Also: `if\\s*\\(\\s*!\\s*req\\.user\\s*\\)`, `throw\\s+new\\s+Error\\(['\"](Unauthorized|Forbidden)`.
|
|
135
|
+
|
|
136
|
+
**Anti-pattern:**
|
|
137
|
+
```typescript
|
|
138
|
+
fastify.post('/products', async (req, reply) => {
|
|
139
|
+
if (!req.user) throw new Error('Unauthorized'); // 401 hand-rolled
|
|
140
|
+
if (!req.user.roles.includes('admin')) throw new Error('Forbidden'); // 403 hand-rolled
|
|
141
|
+
if (product.createdBy !== req.user.id) throw new Error('Not yours'); // ownership
|
|
142
|
+
// proceed
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Why critical:**
|
|
147
|
+
- Inconsistent error shape (Error vs ArcError). Frontend gets unpredictable bodies.
|
|
148
|
+
- `req.user.id` vs `req.user._id` mismatches between routes silently bypass ownership.
|
|
149
|
+
- No row-level filter — admin sees `Product.find()` with no tenant scope.
|
|
150
|
+
|
|
151
|
+
**Fix:** Declarative permission combinators. Permissions run at framework level, `filters` propagate into the repo query (row-level ABAC):
|
|
152
|
+
```typescript
|
|
153
|
+
import {
|
|
154
|
+
allowPublic, requireAuth, requireRoles, requireOwnership,
|
|
155
|
+
requireOrgMembership, requireOrgRole, requireServiceScope,
|
|
156
|
+
allOf, anyOf,
|
|
157
|
+
} from '@classytic/arc';
|
|
158
|
+
|
|
159
|
+
defineResource({
|
|
160
|
+
permissions: {
|
|
161
|
+
list: allowPublic(),
|
|
162
|
+
get: allowPublic(),
|
|
163
|
+
create: requireRoles(['admin', 'editor']),
|
|
164
|
+
update: anyOf(requireRoles(['admin']), requireOwnership('createdBy')),
|
|
165
|
+
delete: requireRoles(['admin']),
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
For mixed human + service: `anyOf(requireOrgRole('admin'), requireServiceScope('jobs:bulk-write'))`.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## §5. Manual `toJSON` transforms / response field stripping 🔴
|
|
174
|
+
|
|
175
|
+
**Detection:**
|
|
176
|
+
```
|
|
177
|
+
pattern: "schema\\.set\\(['\"]toJSON['\"]|toJSON\\s*=\\s*function|delete\\s+(ret|obj|doc)\\.(password|__v|secret)"
|
|
178
|
+
output_mode: content
|
|
179
|
+
```
|
|
180
|
+
Also: `onSerialization` Fastify hook bodies that delete fields.
|
|
181
|
+
|
|
182
|
+
**Anti-pattern:**
|
|
183
|
+
```typescript
|
|
184
|
+
userSchema.set('toJSON', {
|
|
185
|
+
transform: (_doc, ret) => {
|
|
186
|
+
delete ret.password;
|
|
187
|
+
delete ret.__v;
|
|
188
|
+
delete ret.secretToken;
|
|
189
|
+
return ret;
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**Why critical:** Easy to forget on a new field; password leaks happen this way. Also breaks lean reads (the transform doesn't fire on `.lean()` results).
|
|
195
|
+
|
|
196
|
+
**Fix:** `fieldRules.hidden` (or field-level read permission) — applies at framework serialization for both REST and MCP, and works with lean reads:
|
|
197
|
+
```typescript
|
|
198
|
+
import { fields } from '@classytic/arc';
|
|
199
|
+
|
|
200
|
+
defineResource({
|
|
201
|
+
schemaOptions: {
|
|
202
|
+
fieldRules: {
|
|
203
|
+
password: { hidden: true },
|
|
204
|
+
__v: { hidden: true },
|
|
205
|
+
secretToken: { hidden: true },
|
|
206
|
+
salary: fields.visibleTo(['admin', 'hr']),
|
|
207
|
+
email: fields.redactFor(['viewer'], '***'),
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## §6. Hand-maintained OpenAPI / Swagger 🟡
|
|
216
|
+
|
|
217
|
+
**Detection:**
|
|
218
|
+
- Files: `openapi.yaml`, `openapi.json`, `swagger.yaml`, `swagger.json`, `api-spec.*` checked into the repo.
|
|
219
|
+
- `@fastify/swagger` registered with hand-written `swagger.definitions`.
|
|
220
|
+
|
|
221
|
+
**Why medium:** Spec drifts from code; consumers integrate against stale docs.
|
|
222
|
+
|
|
223
|
+
**Fix:** `arc docs ./openapi.json --entry ./dist/index.js` — emits OpenAPI from registered resources. Wire into `prebuild` or CI.
|
|
224
|
+
```typescript
|
|
225
|
+
import { SpecGenerator } from '@classytic/arc/docs';
|
|
226
|
+
const spec = await app.arc.docs.getSpec({ title: 'My API', version: '1.0.0' });
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## §7. Manual event emission 🟠
|
|
232
|
+
|
|
233
|
+
**Detection:**
|
|
234
|
+
```
|
|
235
|
+
pattern: "(eventBus|emitter|events|pubsub)\\.(emit|publish)\\(['\"](\\w+)\\.(created|updated|deleted)"
|
|
236
|
+
output_mode: content
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
**Anti-pattern:**
|
|
240
|
+
```typescript
|
|
241
|
+
const product = await Product.create(req.body);
|
|
242
|
+
await eventBus.emit('product.created', { id: product._id });
|
|
243
|
+
// ... and somebody forgets it on PATCH
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
**Why high:** Inconsistent emission breaks downstream consumers (search index, audit log, cache invalidation). Test coverage rarely catches missing emits.
|
|
247
|
+
|
|
248
|
+
**Fix:** Arc auto-emits `{resource}.created` / `.updated` / `.deleted` on every CRUD. Custom events go through hooks:
|
|
249
|
+
```typescript
|
|
250
|
+
defineResource({
|
|
251
|
+
name: 'product',
|
|
252
|
+
events: {
|
|
253
|
+
created: { description: 'Product created' },
|
|
254
|
+
priceChanged: { description: 'Price updated', schema: { oldPrice, newPrice } },
|
|
255
|
+
},
|
|
256
|
+
hooks: {
|
|
257
|
+
afterUpdate: async (ctx) => {
|
|
258
|
+
if (ctx.data.price !== ctx.meta?.existing?.price) {
|
|
259
|
+
await ctx.fastify.events.publish('product.priceChanged', {
|
|
260
|
+
id: ctx.data._id,
|
|
261
|
+
oldPrice: ctx.meta.existing.price,
|
|
262
|
+
newPrice: ctx.data.price,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
```
|
|
269
|
+
For guaranteed delivery, use `EventOutbox` (transactional outbox pattern).
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## §8. Manual cache invalidation 🟡
|
|
274
|
+
|
|
275
|
+
**Detection:**
|
|
276
|
+
```
|
|
277
|
+
pattern: "(redis|cache)\\.(del|invalidate|set)\\(['\"](\\w+)[-:_]"
|
|
278
|
+
output_mode: content
|
|
279
|
+
```
|
|
280
|
+
Also: hand-rolled cache key strings.
|
|
281
|
+
|
|
282
|
+
**Anti-pattern:**
|
|
283
|
+
```typescript
|
|
284
|
+
const product = await Product.create(req.body);
|
|
285
|
+
await cache.del(`product-${product._id}`);
|
|
286
|
+
await cache.del('products-list'); // forgot the org-scoped key
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
**Fix:** Arc's `QueryCache` invalidates by tag on every mutation. Enable via `arcPlugins.queryCache: true` and declare tags per resource:
|
|
290
|
+
```typescript
|
|
291
|
+
defineResource({
|
|
292
|
+
cache: {
|
|
293
|
+
staleTime: 30,
|
|
294
|
+
gcTime: 300,
|
|
295
|
+
tags: ['catalog'],
|
|
296
|
+
invalidateOn: { 'category.*': ['catalog'] }, // cross-resource
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
```
|
|
300
|
+
Modes: `memory` (default) / `distributed` (requires `stores.queryCache: RedisCacheStore`). Response header: `x-cache: HIT | STALE | MISS`.
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## §9. Bypassing RequestScope 🔴
|
|
305
|
+
|
|
306
|
+
**Detection:**
|
|
307
|
+
```
|
|
308
|
+
pattern: "req(uest)?\\.user\\.(_id|id|orgId|organizationId|tenantId|parentOrgId|teamId)"
|
|
309
|
+
output_mode: content
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
**Anti-pattern:**
|
|
313
|
+
```typescript
|
|
314
|
+
const userId = req.user._id; // crashes on public route
|
|
315
|
+
const orgId = req.user.orgId; // undefined for service tokens
|
|
316
|
+
const parent = req.user.parentOrgId; // not real — fragile custom field
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
**Why critical:**
|
|
320
|
+
- Crashes on public routes (`request.user` is `Record<string, unknown> | undefined`).
|
|
321
|
+
- Doesn't differentiate `member` vs `service` vs `elevated` scope kinds.
|
|
322
|
+
- Org hierarchy not honored (no ancestor lookup).
|
|
323
|
+
- Inconsistent across routes.
|
|
324
|
+
|
|
325
|
+
**Fix:** Always use accessors from `@classytic/arc/scope`:
|
|
326
|
+
```typescript
|
|
327
|
+
import {
|
|
328
|
+
getUserId, getOrgId, getOrgRoles, getServiceScopes,
|
|
329
|
+
getScopeContext, getAncestorOrgIds,
|
|
330
|
+
isMember, isService, isElevated, isAuthenticated, hasOrgAccess,
|
|
331
|
+
isOrgInScope,
|
|
332
|
+
} from '@classytic/arc/scope';
|
|
333
|
+
|
|
334
|
+
if (!isAuthenticated(req.scope)) return reply.unauthorized();
|
|
335
|
+
const userId = getUserId(req.scope); // typed, undefined-safe
|
|
336
|
+
const orgId = getOrgId(req.scope);
|
|
337
|
+
const branch = getScopeContext(req.scope, 'branchId');
|
|
338
|
+
const inOrg = isOrgInScope(req.scope, 'acme-eu');
|
|
339
|
+
```
|
|
340
|
+
For permissions, prefer combinators (`requireOrgRole`, `requireScopeContext`, `requireOrgInScope`) over accessor logic in handlers.
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
## §10. Mongoose/Prisma imports outside adapters 🟠
|
|
345
|
+
|
|
346
|
+
**Detection:**
|
|
347
|
+
```
|
|
348
|
+
pattern: "^import\\s+.*\\b(mongoose|@prisma/client|drizzle-orm)\\b"
|
|
349
|
+
glob: "src/**/*.{ts,js}"
|
|
350
|
+
output_mode: content
|
|
351
|
+
```
|
|
352
|
+
Then exclude allowed paths: `src/**/adapter*.ts` (host-side adapter file), `src/db/**` (depending on convention), and `src/**/*.model.ts` (model definition is OK).
|
|
353
|
+
|
|
354
|
+
**Anti-pattern:** `import mongoose from 'mongoose'` in `src/services/`, `src/hooks/`, `src/routes/`, or any `.resource.ts`.
|
|
355
|
+
|
|
356
|
+
**Why high:** Defeats arc's DB-agnostic boundary. Forces every consumer of that file to pull a database driver. Migration to a different DB becomes a multi-thousand-LOC rewrite instead of swapping one adapter file.
|
|
357
|
+
|
|
358
|
+
**Fix:** Mongoose calls go in:
|
|
359
|
+
1. `*.model.ts` — schema definition only.
|
|
360
|
+
2. `*.repository.ts` — extends `Repository<TDoc>` from `@classytic/mongokit` (or implements `MinimalRepo`).
|
|
361
|
+
3. `*.adapter.ts` — `createMongooseAdapter({ model, repository })`.
|
|
362
|
+
|
|
363
|
+
Resources, hooks, and services consume `RepositoryLike<TDoc>` — never the model directly.
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
## §11. `console.log` in `src/` 🟢
|
|
368
|
+
|
|
369
|
+
**Detection:**
|
|
370
|
+
```
|
|
371
|
+
pattern: "console\\.(log|warn|error|info|debug)\\("
|
|
372
|
+
glob: "src/**/*.{ts,js}"
|
|
373
|
+
output_mode: content
|
|
374
|
+
```
|
|
375
|
+
Allowed: `src/cli/**`, scripts, examples.
|
|
376
|
+
|
|
377
|
+
**Fix:** Use Fastify's logger via `request.log` / `app.log`, or arc's logger if exposed.
|
|
378
|
+
|
|
379
|
+
---
|
|
380
|
+
|
|
381
|
+
## §12. `any` and `@ts-ignore` 🟢
|
|
382
|
+
|
|
383
|
+
**Detection:**
|
|
384
|
+
```
|
|
385
|
+
pattern: ":\\s*any\\b|<any>|as\\s+any\\b|@ts-ignore|@ts-expect-error"
|
|
386
|
+
glob: "src/**/*.ts"
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
**Fix:** Use `unknown` + type narrowing. `as unknown as X` is a last resort, not a shortcut. Never `@ts-ignore` — fix the type. For repo casts, prefer `RepositoryLike<TDoc>` from `@classytic/arc`.
|
|
390
|
+
|
|
391
|
+
---
|
|
392
|
+
|
|
393
|
+
## §13. Default exports 🟢
|
|
394
|
+
|
|
395
|
+
**Detection:**
|
|
396
|
+
```
|
|
397
|
+
pattern: "^export\\s+default\\b"
|
|
398
|
+
glob: "src/**/*.ts"
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
**Fix:** Named exports only. `export const productResource = defineResource({...})`. Knip enforces in arc itself; client projects should follow.
|
|
402
|
+
|
|
403
|
+
**Exception:** `*.resource.ts` files used by `loadResources()` may use `export default` because the loader recognizes both `default` export AND `export const resource`. Prefer named for grep-ability.
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
## §14. Reimplementing presets manually 🟡
|
|
408
|
+
|
|
409
|
+
**Detection:** Look for handler names + route paths that match preset signatures:
|
|
410
|
+
|
|
411
|
+
| Preset | Handler / route fingerprint |
|
|
412
|
+
|---|---|
|
|
413
|
+
| `softDelete` | `getDeleted`, `restore`, `GET /deleted`, `POST /:id/restore`, field `deletedAt` with manual filtering on every read |
|
|
414
|
+
| `slugLookup` | `getBySlug`, `GET /slug/:slug`, `GET /by-slug/:slug` |
|
|
415
|
+
| `tree` | `getChildren`, `getTree`, `GET /tree`, `GET /:id/children`, manual `parentId` field |
|
|
416
|
+
| `bulk` | `bulkCreate`, `bulkUpdate`, `bulkDelete`, `POST /bulk`, `PATCH /bulk`, `DELETE /bulk` |
|
|
417
|
+
| `multiTenant` | Every find call appended with `organizationId: req.user.orgId` |
|
|
418
|
+
| `ownedByUser` | Every find call appended with `userId: req.user.id` |
|
|
419
|
+
| `audited` | Manual write to an audit collection on every mutation |
|
|
420
|
+
| `filesUpload` | `multer`, `@fastify/multipart` ad-hoc routes per resource |
|
|
421
|
+
| `search` | `POST /search`, `/search-similar`, `/embed` hand-rolled |
|
|
422
|
+
|
|
423
|
+
**Fix:**
|
|
424
|
+
```typescript
|
|
425
|
+
defineResource({
|
|
426
|
+
presets: [
|
|
427
|
+
'softDelete',
|
|
428
|
+
{ name: 'multiTenant', tenantField: 'organizationId' },
|
|
429
|
+
{ name: 'slugLookup', slugField: 'slug' },
|
|
430
|
+
'bulk',
|
|
431
|
+
{ name: 'filesUpload', storage: s3Storage, allowedMimeTypes: ['image/*'] },
|
|
432
|
+
],
|
|
433
|
+
});
|
|
434
|
+
```
|
|
435
|
+
Multi-level tenancy (branch/project/region) — use `multiTenantPreset({ tenantFields: TenantFieldSpec[] })`.
|
|
436
|
+
|
|
437
|
+
---
|
|
438
|
+
|
|
439
|
+
## §15. Hand-written MCP tools 🟡
|
|
440
|
+
|
|
441
|
+
**Detection:**
|
|
442
|
+
```
|
|
443
|
+
pattern: "CallToolRequestSchema|setRequestHandler\\(\\s*Call|inputSchema\\s*:\\s*\\{\\s*type\\s*:\\s*['\"]object"
|
|
444
|
+
output_mode: content
|
|
445
|
+
```
|
|
446
|
+
Or: a file pattern of `tools.ts`/`mcp.ts` defining `name`, `description`, `inputSchema`, `handler` objects manually.
|
|
447
|
+
|
|
448
|
+
**Fix:** Resources auto-generate MCP tools (5 CRUD + custom routes + actions). Permissions and field rules carry over.
|
|
449
|
+
```typescript
|
|
450
|
+
import { mcpPlugin } from '@classytic/arc/mcp';
|
|
451
|
+
|
|
452
|
+
await app.register(mcpPlugin, {
|
|
453
|
+
resources: [productResource, orderResource],
|
|
454
|
+
auth: false, // or getAuth() / custom
|
|
455
|
+
exclude: ['credential'],
|
|
456
|
+
overrides: { product: { operations: ['list', 'get'] } },
|
|
457
|
+
});
|
|
458
|
+
```
|
|
459
|
+
For domain-specific tools, use `extraTools` + `buildMcpToolsFromBridges([...])` instead of hand-rolling MCP server plumbing.
|
|
460
|
+
|
|
461
|
+
---
|
|
462
|
+
|
|
463
|
+
## §16. Custom search routes / query language 🟡
|
|
464
|
+
|
|
465
|
+
**Detection:** `GET /search`, `GET /:resource/search`, `/find`, `/lookup` with custom param parsing.
|
|
466
|
+
|
|
467
|
+
**Fix:** Either use arc's standard `QueryParser` syntax (`?search=...&filter=...`), or `searchPreset` for full-text/vector backends:
|
|
468
|
+
```typescript
|
|
469
|
+
import { searchPreset } from '@classytic/arc/presets/search';
|
|
470
|
+
|
|
471
|
+
defineResource({
|
|
472
|
+
presets: [searchPreset({
|
|
473
|
+
search: { handler: (req) => elastic.search({ index: 'products', q: req.body.q }) },
|
|
474
|
+
similar: { handler: (req) => pinecone.query({ vector: req.body.vector, topK: 10 }) },
|
|
475
|
+
})],
|
|
476
|
+
});
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
---
|
|
480
|
+
|
|
481
|
+
## §17. `request.user` access without guard on public routes 🔴
|
|
482
|
+
|
|
483
|
+
**Detection:**
|
|
484
|
+
```
|
|
485
|
+
pattern: "(req|request)\\.user\\.\\w+"
|
|
486
|
+
output_mode: content
|
|
487
|
+
```
|
|
488
|
+
Then for each hit, check whether the enclosing route is `allowPublic()` or has no `permissions`. Crashes happen on public routes.
|
|
489
|
+
|
|
490
|
+
**Fix:** Always guard:
|
|
491
|
+
```typescript
|
|
492
|
+
if (req.user) {
|
|
493
|
+
const userId = req.user.id;
|
|
494
|
+
// ...
|
|
495
|
+
}
|
|
496
|
+
// or: use scope accessors which return undefined safely
|
|
497
|
+
```
|
|
498
|
+
Arc rule: `request.user: Record<string, unknown> | undefined`. The property is required (not optional `?:`), but the value is union-with-undefined.
|
|
499
|
+
|
|
500
|
+
---
|
|
501
|
+
|
|
502
|
+
## §18. Custom controller without forwarding `tenantField`/`schemaOptions`/`idField` 🟠
|
|
503
|
+
|
|
504
|
+
**Detection:** A `class FooController extends BaseController` where the constructor doesn't forward `tenantField`, `idField`, `schemaOptions`, `cache`, `onFieldWriteDenied`.
|
|
505
|
+
|
|
506
|
+
**Why:** When you pass your own controller to `defineResource({ controller })`, arc CANNOT thread these into it. They must be forwarded through `super(repo, opts)` AND mirrored on the `defineResource` call.
|
|
507
|
+
|
|
508
|
+
**Fix:**
|
|
509
|
+
```typescript
|
|
510
|
+
class ProductController extends BaseController<Product> {
|
|
511
|
+
constructor(opts: { tenantField?: string | false; idField?: string } = {}) {
|
|
512
|
+
super(productRepo, { resourceName: 'product', ...opts });
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
defineResource({
|
|
517
|
+
name: 'product',
|
|
518
|
+
controller: new ProductController({ tenantField: 'organizationId', idField: '_id' }),
|
|
519
|
+
tenantField: 'organizationId', // mirror!
|
|
520
|
+
idField: '_id', // mirror!
|
|
521
|
+
});
|
|
522
|
+
```
|
|
523
|
+
Or skip the custom controller entirely — arc's auto-built `BaseController` handles 90% of cases. Use `BaseCrudController` + `SoftDeleteMixin`/`BulkMixin`/etc. for slim CRUD-only.
|
|
524
|
+
|
|
525
|
+
---
|
|
526
|
+
|
|
527
|
+
## §19. `tenantField` left at default for company-wide tables 🟠
|
|
528
|
+
|
|
529
|
+
**Detection:** Resources for lookup tables, platform settings, or cross-org reports that don't set `tenantField: false`.
|
|
530
|
+
|
|
531
|
+
**Why:** Default `tenantField: 'organizationId'` silently scopes queries to caller's org. For an `account-type` lookup table or `currency` table, this returns empty results forever.
|
|
532
|
+
|
|
533
|
+
**Fix:**
|
|
534
|
+
```typescript
|
|
535
|
+
defineResource({ name: 'account-type', tenantField: false }); // company-wide
|
|
536
|
+
defineResource({ name: 'workspace-item', tenantField: 'workspaceId' }); // different scope
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
---
|
|
540
|
+
|
|
541
|
+
## §20. Wrong lifecycle slot for async-booted engines 🟠
|
|
542
|
+
|
|
543
|
+
**Detection:** A resource adapter that depends on an engine initialized inside the same module (e.g., `await ensureCatalogEngine()`) is exported as a static `defineResource()` at module top — which runs before `bootstrap[]`.
|
|
544
|
+
|
|
545
|
+
**Anti-pattern:**
|
|
546
|
+
```typescript
|
|
547
|
+
// product.resource.ts — runs at IMPORT time, before bootstrap
|
|
548
|
+
const engine = await ensureCatalogEngine(); // top-level await
|
|
549
|
+
export const productResource = defineResource({ adapter: engine.adapter() });
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
**Fix:** Use the factory form for `resources` so it runs *after* `bootstrap[]`:
|
|
553
|
+
```typescript
|
|
554
|
+
const app = await createApp({
|
|
555
|
+
bootstrap: [async () => { await ensureCatalogEngine(); }],
|
|
556
|
+
resources: async () => {
|
|
557
|
+
const engine = await getCatalogEngine();
|
|
558
|
+
return [buildProductResource(engine), buildCategoryResource(engine)];
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
```
|
|
562
|
+
Or via `loadResources` with `context`:
|
|
563
|
+
```typescript
|
|
564
|
+
resources: async () => loadResources(import.meta.url, { context: { engine } }),
|
|
565
|
+
// Each *.resource.ts then exports: (ctx) => defineResource({ adapter: ctx.engine.adapter() })
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
---
|
|
569
|
+
|
|
570
|
+
## §21. Field-write denied as silent strip when reject is needed (or vice versa) 🟡
|
|
571
|
+
|
|
572
|
+
**Detection:** Resources with sensitive fields (`role`, `permissions`, `tenantId`, `organizationId`) that don't declare `onFieldWriteDenied`. Default is `reject` (403 with denied list) — usually correct. If client set `'strip'`, sensitive denied writes silently disappear.
|
|
573
|
+
|
|
574
|
+
**Fix:** Explicit choice per resource:
|
|
575
|
+
```typescript
|
|
576
|
+
defineResource({ onFieldWriteDenied: 'reject' }); // default — explicit
|
|
577
|
+
defineResource({ onFieldWriteDenied: 'strip' }); // only for low-stakes UX flows
|
|
578
|
+
```
|
|
579
|
+
Tests should assert 403 on protected-field write attempts.
|
|
580
|
+
|
|
581
|
+
---
|
|
582
|
+
|
|
583
|
+
## §22. Manual idempotency 🟠
|
|
584
|
+
|
|
585
|
+
**Detection:** Hand-rolled deduplication via `Idempotency-Key` header reads, custom Redis/Mongo lookup, retry tables.
|
|
586
|
+
|
|
587
|
+
**Fix:** Arc ships `idempotencyPlugin`:
|
|
588
|
+
```typescript
|
|
589
|
+
import { idempotencyPlugin } from '@classytic/arc/idempotency';
|
|
590
|
+
await app.register(idempotencyPlugin, { repository: idempotencyRepo, ttlMs: 24 * 3600_000 });
|
|
591
|
+
```
|
|
592
|
+
Requires `getOne`, `deleteMany`, `findOneAndUpdate` on the repo (mongokit + sqlitekit conform).
|
|
593
|
+
|
|
594
|
+
---
|
|
595
|
+
|
|
596
|
+
## §23. Manual audit log 🟡
|
|
597
|
+
|
|
598
|
+
**Detection:** Every mutation handler appends to an `audit_logs` / `events` / `history` collection inline.
|
|
599
|
+
|
|
600
|
+
**Fix:** Per-resource opt-in via `auditPlugin`:
|
|
601
|
+
```typescript
|
|
602
|
+
await fastify.register(auditPlugin, { autoAudit: { perResource: true } });
|
|
603
|
+
defineResource({ name: 'order', audit: true });
|
|
604
|
+
defineResource({ name: 'payment', audit: { operations: ['delete'] } });
|
|
605
|
+
```
|
|
606
|
+
Or `presets: ['audited']`.
|
|
607
|
+
|
|
608
|
+
---
|
|
609
|
+
|
|
610
|
+
## §24. Manual rate limiting per route 🟡
|
|
611
|
+
|
|
612
|
+
**Detection:** `@fastify/rate-limit` registered per-route with hand-tuned configs.
|
|
613
|
+
|
|
614
|
+
**Fix:** Per-resource `rateLimit: RateLimitConfig` on `defineResource`, or global at `createApp({ rateLimit })`.
|
|
615
|
+
|
|
616
|
+
---
|
|
617
|
+
|
|
618
|
+
## §25. Webhook signature verification on parsed body 🔴
|
|
619
|
+
|
|
620
|
+
**Detection:**
|
|
621
|
+
```
|
|
622
|
+
pattern: "verifySignature\\(.*req\\.body"
|
|
623
|
+
output_mode: content
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
**Why critical:** `verifySignature(body, ...)` throws `TypeError` if body isn't string/Buffer. Parsed body silently fails verification → forged webhooks accepted.
|
|
627
|
+
|
|
628
|
+
**Fix:** `verifySignature(req.rawBody, ...)`. Mark webhook routes `raw: true` and ensure `@fastify/raw-body` is registered (arc registers it automatically when needed).
|
|
629
|
+
|
|
630
|
+
---
|
|
631
|
+
|
|
632
|
+
## §26. Multipart/file upload handled with multer or ad-hoc parsing 🟡
|
|
633
|
+
|
|
634
|
+
**Detection:** `multer`, `formidable`, `busboy` used directly; or `req.body` consumed without `multipartBody` middleware.
|
|
635
|
+
|
|
636
|
+
**Fix:**
|
|
637
|
+
```typescript
|
|
638
|
+
import { multipartBody } from '@classytic/arc/middleware';
|
|
639
|
+
defineResource({
|
|
640
|
+
middlewares: { create: [multipartBody({ allowedMimeTypes: ['image/png'], maxFileSize: 5 * 1024 * 1024 })] },
|
|
641
|
+
});
|
|
642
|
+
```
|
|
643
|
+
Or `presets: [{ name: 'filesUpload', storage, allowedMimeTypes, maxFileSize }]`.
|
|
644
|
+
`multipartBody()` is a no-op for JSON requests — safe to always add to create/update.
|
|
645
|
+
|
|
646
|
+
---
|
|
647
|
+
|
|
648
|
+
## §27. SSE auth via query string without `preAuth` 🔴
|
|
649
|
+
|
|
650
|
+
**Detection:** SSE/EventSource routes that read `req.query.token` and call `verify()` inline.
|
|
651
|
+
|
|
652
|
+
**Fix:** EventSource can't set headers, so arc's `preAuth` slot copies `?token=` into the `Authorization` header before auth runs:
|
|
653
|
+
```typescript
|
|
654
|
+
routes: [
|
|
655
|
+
{
|
|
656
|
+
method: 'GET', path: '/stream', raw: true,
|
|
657
|
+
preAuth: [(req) => { req.headers.authorization = `Bearer ${req.query.token}`; }],
|
|
658
|
+
permissions: requireAuth(),
|
|
659
|
+
handler: async (req, reply) => reply.send(stream),
|
|
660
|
+
},
|
|
661
|
+
],
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
---
|
|
665
|
+
|
|
666
|
+
## §28. Mongoose without mongokit 🟠
|
|
667
|
+
|
|
668
|
+
**Detection:** `package.json` has `mongoose` but not `@classytic/mongokit`. Or `import { Schema, model } from 'mongoose'` paired with hand-rolled repository classes (`class FooRepository`).
|
|
669
|
+
|
|
670
|
+
**Why high:** Every Mongoose model + repo class is duplicating mongokit's hook engine, plugin system, pagination, multi-tenant scoping, soft-delete, audit trail, transaction helpers. A 150 LOC repo class typically reduces to ~30 LOC with mongokit.
|
|
671
|
+
|
|
672
|
+
**Fix:** Full migration recipe → [mongokit-migration.md](mongokit-migration.md).
|
|
673
|
+
|
|
674
|
+
---
|
|
675
|
+
|
|
676
|
+
## §29. `app.register(authPlugin)` instead of `createApp({ auth })` 🟢
|
|
677
|
+
|
|
678
|
+
**Detection:** Manual JWT plugin registration with `@fastify/jwt`, hand-rolled `app.authenticate` decorator.
|
|
679
|
+
|
|
680
|
+
**Fix:** `createApp({ auth: { type: 'jwt', jwt: { secret, ... } } })` decorates `app.authenticate`, `app.optionalAuthenticate`, `app.authorize` and wires permission checks. For Better Auth, `createApp({ auth: { type: 'betterAuth', betterAuth: createBetterAuthAdapter({ auth, orgContext: true }) } })`.
|
|
681
|
+
|
|
682
|
+
---
|
|
683
|
+
|
|
684
|
+
## §30. Missing `arc-discovered` resources because of tsconfig path aliases 🟢
|
|
685
|
+
|
|
686
|
+
**Detection:** Resources defined under `import.meta.url`-discovered directories don't appear in routes; resource files import other resource files via `@/foo` aliases.
|
|
687
|
+
|
|
688
|
+
**Why:** `loadResources()` uses Node's resolver. `@/*` path aliases are compile-time only, not Node-resolvable. Auto-discovery silently misses these files.
|
|
689
|
+
|
|
690
|
+
**Fix:** Use relative imports OR Node's `#` subpath imports (`"imports": { "#foo/*": "./src/foo/*.js" }` in `package.json`) for files that participate in `loadResources`. Path aliases work fine for type-only imports.
|
|
691
|
+
|
|
692
|
+
---
|
|
693
|
+
|
|
694
|
+
## §31. `select` field manipulated client-side 🟢
|
|
695
|
+
|
|
696
|
+
**Detection:** Code post-processing `select` value from query (normalizing case, splitting, building projection objects).
|
|
697
|
+
|
|
698
|
+
**Why:** Arc preserves `select` as-is for DB-agnosticism (Mongo `'name email'` vs SQL `['name', 'email']`). Client normalization is wasted work and divergent across resources.
|
|
699
|
+
|
|
700
|
+
**Fix:** Pass through. The repository / adapter handles its native shape.
|
|
701
|
+
|
|
702
|
+
---
|
|
703
|
+
|
|
704
|
+
## §32a. Importing pagination types from primitives or mongokit 🟠
|
|
705
|
+
|
|
706
|
+
**Detection:**
|
|
707
|
+
```
|
|
708
|
+
pattern: "from\\s+['\"]@classytic/(primitives/pagination|mongokit)['\"][^\\n]*\\b(OffsetPaginationResult|KeysetPaginationResult|AggregatePaginationResult|PaginationResult|toCanonicalList)\\b"
|
|
709
|
+
output_mode: content
|
|
710
|
+
```
|
|
711
|
+
Also `@classytic/arc-next/api` for `OffsetPaginationResponse` / `KeysetPaginationResponse` / `AggregatePaginationResponse` / `PaginatedResponse`.
|
|
712
|
+
|
|
713
|
+
**Why high:** Pagination types and the `toCanonicalList()` helper now live in `@classytic/repo-core/pagination` as the single source of truth (primitives' duplicate dropped, mongokit's local declarations dropped, arc-next's response types removed). Importing from any other package risks drift between the type the host believes and the one the kit actually returns.
|
|
714
|
+
|
|
715
|
+
**Fix:**
|
|
716
|
+
```typescript
|
|
717
|
+
import type {
|
|
718
|
+
OffsetPaginationResult, KeysetPaginationResult,
|
|
719
|
+
AggregatePaginationResult, PaginationResult,
|
|
720
|
+
} from '@classytic/repo-core/pagination';
|
|
721
|
+
import { toCanonicalList } from '@classytic/repo-core/pagination';
|
|
722
|
+
```
|
|
723
|
+
The wire envelope carries `method` ('offset' | 'keyset' | 'aggregate') as the discriminant; arc's `reply.sendList()` calls `toCanonicalList` internally.
|
|
724
|
+
|
|
725
|
+
---
|
|
726
|
+
|
|
727
|
+
## §32b. Importing event types from arc 🟠
|
|
728
|
+
|
|
729
|
+
**Detection:**
|
|
730
|
+
```
|
|
731
|
+
pattern: "from\\s+['\"]@classytic/arc/events['\"][^\\n]*\\b(EventMeta|DomainEvent|EventHandler|EventLogger|EventTransport|DeadLetteredEvent|PublishManyResult|createEvent|createChildEvent|matchEventPattern)\\b"
|
|
732
|
+
output_mode: content
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
**Why high:** Event types now live in `@classytic/primitives/events` as the single source of truth. Arc only re-exports the runtime `MemoryEventTransport`. Hosts importing event types from arc lock themselves to arc's version even when the type lives in primitives.
|
|
736
|
+
|
|
737
|
+
**Fix:**
|
|
738
|
+
```typescript
|
|
739
|
+
import type {
|
|
740
|
+
EventMeta, DomainEvent, EventHandler, EventLogger, EventTransport,
|
|
741
|
+
DeadLetteredEvent, PublishManyResult,
|
|
742
|
+
} from '@classytic/primitives/events';
|
|
743
|
+
import { createEvent, createChildEvent, matchEventPattern } from '@classytic/primitives/events';
|
|
744
|
+
import { MemoryEventTransport } from '@classytic/arc/events'; // runtime stays at arc
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
---
|
|
748
|
+
|
|
749
|
+
## §32c. Importing tenant config from primitives 🟡
|
|
750
|
+
|
|
751
|
+
**Detection:**
|
|
752
|
+
```
|
|
753
|
+
pattern: "from\\s+['\"]@classytic/primitives/tenant['\"]"
|
|
754
|
+
output_mode: content
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
**Why medium:** `TenantConfig`, `TenantStrategy`, `TenantFieldType`, `resolveTenantConfig`, `DEFAULT_TENANT_CONFIG`, `ResolvedTenantConfig` now live in `@classytic/repo-core/tenant`. Mongokit and sqlitekit both `extends Pick<TenantConfig, ...>` from repo-core.
|
|
758
|
+
|
|
759
|
+
**Fix:**
|
|
760
|
+
```typescript
|
|
761
|
+
import type { TenantConfig, ResolvedTenantConfig } from '@classytic/repo-core/tenant';
|
|
762
|
+
import { resolveTenantConfig, DEFAULT_TENANT_CONFIG } from '@classytic/repo-core/tenant';
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
---
|
|
766
|
+
|
|
767
|
+
## §32d. Importing error types from primitives or mongokit 🟠
|
|
768
|
+
|
|
769
|
+
**Detection:**
|
|
770
|
+
```
|
|
771
|
+
pattern: "from\\s+['\"]@classytic/(primitives/errors|mongokit)['\"][^\\n]*\\bHttpError\\b"
|
|
772
|
+
output_mode: content
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
**Why high:** `HttpError` (throwable contract), `ErrorContract` (wire shape), `ErrorDetail`, `ErrorCode`, `ERROR_CODES`, `toErrorContract()`, `statusToErrorCode()` all live in `@classytic/repo-core/errors`. `ArcError implements HttpError` from repo-core. Mongokit no longer publishes its own `HttpError`.
|
|
776
|
+
|
|
777
|
+
**Fix:**
|
|
778
|
+
```typescript
|
|
779
|
+
import type { HttpError, ErrorContract, ErrorDetail, ErrorCode } from '@classytic/repo-core/errors';
|
|
780
|
+
import { toErrorContract, statusToErrorCode, ERROR_CODES } from '@classytic/repo-core/errors';
|
|
781
|
+
```
|
|
782
|
+
|
|
783
|
+
---
|
|
784
|
+
|
|
785
|
+
## §32e. Hand-rolled `if (!getOrgId(scope)) throw …` 🟡
|
|
786
|
+
|
|
787
|
+
**Detection:**
|
|
788
|
+
```
|
|
789
|
+
pattern: "if\\s*\\(\\s*!\\s*getOrgId\\s*\\("
|
|
790
|
+
output_mode: content
|
|
791
|
+
```
|
|
792
|
+
Also: `if (!getUserId(...))`, `if (!getClientId(...))`, `if (!getTeamId(...))` followed by a manual throw.
|
|
793
|
+
|
|
794
|
+
**Why medium:** Arc 2.12 ships `requireOrgId(scope, hint?)` / `requireUserId(scope, hint?)` / `requireClientId(scope, hint?)` / `requireTeamId(scope, hint?)` which return the value or throw a 403 `ArcError`. Hand-rolled guards usually throw a generic `Error` (wrong status), forget the optional hint, or duplicate the same boilerplate at every call site.
|
|
795
|
+
|
|
796
|
+
**Fix:**
|
|
797
|
+
```typescript
|
|
798
|
+
import { requireOrgId, requireUserId } from '@classytic/arc/scope';
|
|
799
|
+
|
|
800
|
+
const orgId = requireOrgId(scope); // throws 403 if missing
|
|
801
|
+
const userId = requireUserId(scope, 'finalize-checkout'); // hint surfaces in error.details
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
---
|
|
805
|
+
|
|
806
|
+
## §32f. `createMongooseAdapter` without `schemaGenerator` 🟠
|
|
807
|
+
|
|
808
|
+
**Detection:**
|
|
809
|
+
```
|
|
810
|
+
pattern: "createMongooseAdapter\\s*\\(\\s*\\{[^}]*\\}\\s*\\)"
|
|
811
|
+
multiline: true
|
|
812
|
+
```
|
|
813
|
+
Then check whether the object literal includes a `schemaGenerator:` key.
|
|
814
|
+
|
|
815
|
+
Also: `createDrizzleAdapter` without `schemaGenerator: buildCrudSchemasFromTable`.
|
|
816
|
+
|
|
817
|
+
**Why high:** Arc 2.12 deleted the built-in mongoose / drizzle schema-gen fallback (~290 LOC). If `schemaGenerator` is omitted, OpenAPI bodies are emitted as `null` rather than silently inferred — the route still serves traffic but the docs / MCP tool input schemas are empty.
|
|
818
|
+
|
|
819
|
+
**Fix:**
|
|
820
|
+
```typescript
|
|
821
|
+
import { createMongooseAdapter } from '@classytic/mongokit/adapter'; // arc 2.12+
|
|
822
|
+
import { buildCrudSchemasFromModel } from '@classytic/mongokit';
|
|
823
|
+
|
|
824
|
+
createMongooseAdapter({
|
|
825
|
+
model: ProductModel,
|
|
826
|
+
repository: productRepo,
|
|
827
|
+
schemaGenerator: buildCrudSchemasFromModel, // required
|
|
828
|
+
});
|
|
829
|
+
```
|
|
830
|
+
For drizzle: `import { createDrizzleAdapter } from '@classytic/sqlitekit/adapter'` + `schemaGenerator: buildCrudSchemasFromTable` from `@classytic/sqlitekit`.
|
|
831
|
+
|
|
832
|
+
---
|
|
833
|
+
|
|
834
|
+
## §32g. Importing kit-specific adapters from `@classytic/arc` (any subpath) 🔴
|
|
835
|
+
|
|
836
|
+
**Detection:**
|
|
837
|
+
```
|
|
838
|
+
pattern: "from\\s+['\"]@classytic/arc(/adapters)?['\"][^\\n]*\\b(createMongooseAdapter|MongooseAdapter|createDrizzleAdapter|DrizzleAdapter|createPrismaAdapter|PrismaAdapter|PrismaQueryParser|InferMongooseDoc|MongooseDocument|isMongooseModel|MongooseAdapterOptions|DrizzleAdapterOptions|DrizzleColumnLike|DrizzleTableLike)\\b"
|
|
839
|
+
output_mode: content
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
Also: adapter contract types imported from arc — `import type { DataAdapter, AdapterRepositoryInput, AdapterFactory, OpenApiSchemas, SchemaMetadata, FieldMetadata, RelationMetadata, AdapterValidationResult, AdapterSchemaContext } from '@classytic/arc'` (any subpath). And: `mergeFieldRuleConstraints` from `@classytic/arc/adapters`. Any `from '@classytic/arc/adapters'` import — the entire subpath was removed in arc 2.12.
|
|
843
|
+
|
|
844
|
+
**Why critical:** This only worked in arc ≤ 2.x. Arc 2.12 moved every kit-specific adapter (Mongoose, Drizzle, Prisma) into its kit and the cross-framework adapter contract into `@classytic/repo-core/adapter`. The `@classytic/arc/adapters` subpath was removed entirely. Importing these names from arc fails to resolve on 3.x — the build breaks at install time. The new shape is **strict**: kit-specific things MUST come from the kit; the contract MUST come from repo-core.
|
|
845
|
+
|
|
846
|
+
**Fix:**
|
|
847
|
+
```typescript
|
|
848
|
+
// Mongoose
|
|
849
|
+
import {
|
|
850
|
+
createMongooseAdapter, MongooseAdapter,
|
|
851
|
+
type MongooseAdapterOptions, type InferMongooseDoc,
|
|
852
|
+
type MongooseDocument, isMongooseModel,
|
|
853
|
+
} from '@classytic/mongokit/adapter';
|
|
854
|
+
|
|
855
|
+
// Drizzle
|
|
856
|
+
import {
|
|
857
|
+
createDrizzleAdapter, DrizzleAdapter,
|
|
858
|
+
type DrizzleAdapterOptions, type DrizzleColumnLike, type DrizzleTableLike,
|
|
859
|
+
} from '@classytic/sqlitekit/adapter';
|
|
860
|
+
|
|
861
|
+
// Prisma
|
|
862
|
+
import {
|
|
863
|
+
createPrismaAdapter, PrismaAdapter, PrismaQueryParser,
|
|
864
|
+
} from '@classytic/prismakit/adapter';
|
|
865
|
+
|
|
866
|
+
// Cross-framework adapter contract
|
|
867
|
+
import type {
|
|
868
|
+
DataAdapter, RepositoryLike, AdapterRepositoryInput,
|
|
869
|
+
AdapterFactory, AdapterValidationResult, AdapterSchemaContext,
|
|
870
|
+
OpenApiSchemas, SchemaMetadata, FieldMetadata, RelationMetadata,
|
|
871
|
+
} from '@classytic/repo-core/adapter';
|
|
872
|
+
import { asRepositoryLike, isRepository } from '@classytic/repo-core/adapter';
|
|
873
|
+
|
|
874
|
+
// Schema helpers
|
|
875
|
+
import { mergeFieldRuleConstraints, applyNullable } from '@classytic/repo-core/schema';
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
The `@classytic/arc/adapters` subpath has been **removed** in arc 2.12 — no symbols ship there anymore. `RepositoryLike` is re-exported from `@classytic/arc` for convenience, but importing from `@classytic/repo-core/adapter` is canonical. Custom kits implementing `DataAdapter<TDoc>` from `@classytic/repo-core/adapter` plug in identically.
|
|
879
|
+
|
|
880
|
+
---
|
|
881
|
+
|
|
882
|
+
## §32. Headers set in `onSend` hook 🔴
|
|
883
|
+
|
|
884
|
+
**Detection:**
|
|
885
|
+
```
|
|
886
|
+
pattern: "addHook\\(['\"]onSend"
|
|
887
|
+
output_mode: content
|
|
888
|
+
```
|
|
889
|
+
Then check whether the hook body sets headers (`reply.header(`, `reply.headers[`).
|
|
890
|
+
|
|
891
|
+
**Why critical:** Async `onSend` races with Fastify's `onSendEnd → safeWriteHead` flush path and produces `ERR_HTTP_HEADERS_SENT` under slow responses. Intermittent prod failures, hard to reproduce.
|
|
892
|
+
|
|
893
|
+
**Fix:** Set headers in `onRequest` (before handler) or `preSerialization` (before flush). Never `onSend`.
|
|
894
|
+
|
|
895
|
+
---
|
|
896
|
+
|
|
897
|
+
## Detection checklist (run-order)
|
|
898
|
+
|
|
899
|
+
Run sweeps in this order — early hits often invalidate later context:
|
|
900
|
+
|
|
901
|
+
1. §10 — driver imports outside adapters (most architecturally significant)
|
|
902
|
+
2. §28 — mongoose without mongokit (scope of mongokit migration)
|
|
903
|
+
3. §3 — manual CRUD route count (scope of defineResource migration)
|
|
904
|
+
4. §4 + §9 + §17 — auth/scope (security-critical)
|
|
905
|
+
5. §5 — toJSON / response stripping (security-critical)
|
|
906
|
+
6. §25, §27, §32 — webhook/SSE/header gotchas (security-critical)
|
|
907
|
+
7. §1, §2, §6, §16 — query/schema/openapi/search (style + drift)
|
|
908
|
+
8. §7, §8, §22, §23 — events/cache/idempotency/audit (silent inconsistency)
|
|
909
|
+
9. §14, §18, §19 — preset adoption + custom controller wiring
|
|
910
|
+
10. §11, §12, §13, §29, §30, §31 — style and edges
|
|
911
|
+
11. §32a–§32g — canonical-contract drift (pagination / events / tenant / errors / adapter imports), missing `requireOrgId` accessors, missing `schemaGenerator`, kit-specific adapter imports from arc (3.0 break)
|