@classytic/arc 2.11.3 → 2.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/README.md +27 -18
  2. package/dist/{BaseController-swXruJ2_.mjs → BaseController-DX_T-bDB.mjs} +388 -423
  3. package/dist/EventTransport-CT_52aWU.d.mts +34 -0
  4. package/dist/EventTransport-DLWoUMHy.mjs +103 -0
  5. package/dist/{QueryCache-DOBNHBE0.d.mts → QueryCache-D41bfdBB.d.mts} +1 -1
  6. package/dist/{ResourceRegistry-DkAeAuTX.mjs → ResourceRegistry-CTERg_2x.mjs} +139 -66
  7. package/dist/audit/index.d.mts +2 -2
  8. package/dist/audit/index.mjs +1 -1
  9. package/dist/auth/audit.d.mts +199 -0
  10. package/dist/auth/audit.mjs +288 -0
  11. package/dist/auth/index.d.mts +5 -5
  12. package/dist/auth/index.mjs +117 -191
  13. package/dist/auth/redis-session.d.mts +1 -1
  14. package/dist/{betterAuthOpenApi-DwxtK3uG.mjs → betterAuthOpenApi--M_i87dQ.mjs} +1 -1
  15. package/dist/buildHandler-olo-gt94.mjs +610 -0
  16. package/dist/cache/index.d.mts +3 -3
  17. package/dist/cache/index.mjs +3 -3
  18. package/dist/cli/commands/describe.d.mts +89 -13
  19. package/dist/cli/commands/describe.mjs +56 -2
  20. package/dist/cli/commands/docs.mjs +2 -2
  21. package/dist/cli/commands/generate.mjs +147 -48
  22. package/dist/cli/commands/init.d.mts +13 -0
  23. package/dist/cli/commands/init.mjs +237 -112
  24. package/dist/cli/commands/introspect.mjs +8 -1
  25. package/dist/context/index.mjs +1 -1
  26. package/dist/core/index.d.mts +3 -3
  27. package/dist/core/index.mjs +5 -5
  28. package/dist/core-D72ia0EH.mjs +1399 -0
  29. package/dist/{createActionRouter-u3ql2EDo.mjs → createActionRouter-CEvzKcy8.mjs} +7 -20
  30. package/dist/createAggregationRouter-CyecOxnO.mjs +114 -0
  31. package/dist/{createApp-BFxtdKy6.mjs → createApp-XX2-N0Yd.mjs} +31 -27
  32. package/dist/defineEvent-D5h7EvAx.mjs +188 -0
  33. package/dist/docs/index.d.mts +2 -2
  34. package/dist/docs/index.mjs +2 -2
  35. package/dist/{elevation-DOFoxoDs.mjs → elevation-DgoeTyfX.mjs} +1 -1
  36. package/dist/errorHandler-Bk-AGhkU.mjs +174 -0
  37. package/dist/errorHandler-DFr45ZG4.d.mts +45 -0
  38. package/dist/errors-j4aJm1Wg.mjs +184 -0
  39. package/dist/{eventPlugin-KrFIQ097.mjs → eventPlugin-CaKTYkYM.mjs} +35 -137
  40. package/dist/{eventPlugin-CUNjYYRY.d.mts → eventPlugin-qXpqTebY.d.mts} +57 -7
  41. package/dist/events/index.d.mts +164 -5
  42. package/dist/events/index.mjs +133 -209
  43. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  44. package/dist/events/transports/redis-stream-entry.mjs +204 -31
  45. package/dist/events/transports/redis.d.mts +1 -1
  46. package/dist/factory/index.d.mts +2 -2
  47. package/dist/factory/index.mjs +2 -2
  48. package/dist/{fields-C8Y0XLAu.d.mts → fields-COhcH3fk.d.mts} +23 -2
  49. package/dist/hooks/index.d.mts +1 -1
  50. package/dist/hooks/index.mjs +1 -1
  51. package/dist/idempotency/index.d.mts +3 -3
  52. package/dist/idempotency/index.mjs +1 -20
  53. package/dist/idempotency/redis.d.mts +1 -1
  54. package/dist/idempotency/redis.mjs +1 -1
  55. package/dist/{index-BYCqHCVu.d.mts → index-BTqLEvhu.d.mts} +164 -4
  56. package/dist/{index-6u4_Gg6G.d.mts → index-BtW7qYwa.d.mts} +661 -281
  57. package/dist/{index-BdXnTPRj.d.mts → index-Ds61mrJE.d.mts} +50 -4
  58. package/dist/{index-DdQ3O9Pg.d.mts → index-Dz5IKsrE.d.mts} +360 -219
  59. package/dist/index.d.mts +6 -7
  60. package/dist/index.mjs +9 -10
  61. package/dist/integrations/event-gateway.d.mts +2 -2
  62. package/dist/integrations/event-gateway.mjs +1 -1
  63. package/dist/integrations/index.d.mts +2 -2
  64. package/dist/integrations/mcp/index.d.mts +2 -2
  65. package/dist/integrations/mcp/index.mjs +1 -1
  66. package/dist/integrations/mcp/testing.d.mts +1 -1
  67. package/dist/integrations/mcp/testing.mjs +1 -1
  68. package/dist/integrations/streamline.d.mts +60 -11
  69. package/dist/integrations/streamline.mjs +75 -85
  70. package/dist/integrations/websocket-redis.d.mts +1 -1
  71. package/dist/integrations/websocket.d.mts +1 -1
  72. package/dist/integrations/websocket.mjs +2 -8
  73. package/dist/middleware/index.d.mts +1 -1
  74. package/dist/middleware/index.mjs +2 -2
  75. package/dist/migrations/index.d.mts +23 -3
  76. package/dist/migrations/index.mjs +0 -7
  77. package/dist/{multipartBody-CvTR1Un6.mjs → multipartBody-BOvVSVCD.mjs} +11 -8
  78. package/dist/{openapi-BGUn7Ki1.mjs → openapi-CiOMVW1p.mjs} +143 -13
  79. package/dist/org/index.d.mts +2 -2
  80. package/dist/org/index.mjs +1 -1
  81. package/dist/permissions/index.d.mts +3 -3
  82. package/dist/permissions/index.mjs +3 -3
  83. package/dist/{permissions-gd_aUWrR.mjs → permissions-ohQyv50e.mjs} +404 -176
  84. package/dist/{pipe-DVoIheVC.mjs → pipe-Zr0KXjQe.mjs} +1 -1
  85. package/dist/pipeline/index.d.mts +1 -1
  86. package/dist/pipeline/index.mjs +1 -1
  87. package/dist/plugins/index.d.mts +18 -33
  88. package/dist/plugins/index.mjs +33 -13
  89. package/dist/plugins/response-cache.mjs +1 -1
  90. package/dist/plugins/tracing-entry.d.mts +1 -1
  91. package/dist/plugins/tracing-entry.mjs +1 -1
  92. package/dist/presets/filesUpload.d.mts +5 -5
  93. package/dist/presets/filesUpload.mjs +6 -9
  94. package/dist/presets/index.d.mts +1 -1
  95. package/dist/presets/index.mjs +1 -1
  96. package/dist/presets/multiTenant.d.mts +1 -1
  97. package/dist/presets/multiTenant.mjs +2 -2
  98. package/dist/presets/search.d.mts +2 -2
  99. package/dist/presets/search.mjs +6 -8
  100. package/dist/{presets-Z7P5w4gF.mjs → presets-BbkjdPeH.mjs} +6 -28
  101. package/dist/{queryCachePlugin-BUXBSm4F.d.mts → queryCachePlugin-CqMdLI2-.d.mts} +2 -2
  102. package/dist/{queryCachePlugin-Bq6bO6vc.mjs → queryCachePlugin-m1XsgAIJ.mjs} +3 -3
  103. package/dist/{redis-Cm1gnRDf.d.mts → redis-DiMkdHEl.d.mts} +1 -1
  104. package/dist/redis-stream-D6HzR1Z_.d.mts +232 -0
  105. package/dist/registry/index.d.mts +1 -1
  106. package/dist/registry/index.mjs +2 -2
  107. package/dist/{replyHelpers-ByllIXXV.mjs → replyHelpers-CK-FNO8E.mjs} +3 -21
  108. package/dist/{resourceToTools-ByZpgjeH.mjs → resourceToTools-C5coh64w.mjs} +224 -71
  109. package/dist/{routerShared-BqLRb5l7.mjs → routerShared-D6_fEGHh.mjs} +40 -36
  110. package/dist/{schemaIR-BlG9bY7v.mjs → schemaIR-7Vl611Qs.mjs} +1 -1
  111. package/dist/schemas/index.d.mts +100 -30
  112. package/dist/schemas/index.mjs +86 -29
  113. package/dist/scim/index.d.mts +264 -0
  114. package/dist/scim/index.mjs +963 -0
  115. package/dist/scope/index.d.mts +3 -3
  116. package/dist/scope/index.mjs +4 -4
  117. package/dist/{sse-V7aXc3bW.mjs → sse-Bz-5ZeTt.mjs} +1 -1
  118. package/dist/{store-helpers-BhrzxvyQ.mjs → store-helpers-BkIN9-vu.mjs} +1 -1
  119. package/dist/testing/index.d.mts +2 -8
  120. package/dist/testing/index.mjs +16 -24
  121. package/dist/testing/storageContract.d.mts +1 -1
  122. package/dist/types/index.d.mts +4 -4
  123. package/dist/types/storage.d.mts +1 -1
  124. package/dist/{types-BH7dEGvU.d.mts → types-BvqwCCSx.d.mts} +77 -29
  125. package/dist/{types-tgR4Pt8F.d.mts → types-CTYvcwHe.d.mts} +195 -1
  126. package/dist/{types-AOD8fxIw.mjs → types-C_s5moIu.mjs} +117 -1
  127. package/dist/{types-9beEMe25.d.mts → types-DQHFc8PM.d.mts} +1 -1
  128. package/dist/utils/index.d.mts +2 -2
  129. package/dist/utils/index.mjs +5 -5
  130. package/dist/{utils-CcYTj09l.mjs → utils-_h9B3c57.mjs} +1269 -1334
  131. package/dist/{versioning-M9lNLhO8.d.mts → versioning-DTTvc80y.d.mts} +1 -1
  132. package/package.json +24 -34
  133. package/skills/arc/SKILL.md +521 -785
  134. package/skills/arc/references/agent-auth.md +238 -0
  135. package/skills/arc/references/api-reference.md +187 -0
  136. package/skills/arc/references/auth.md +354 -7
  137. package/skills/arc/references/enterprise-auth.md +94 -0
  138. package/skills/arc/references/events.md +8 -6
  139. package/skills/arc/references/mcp.md +2 -2
  140. package/skills/arc/references/multi-tenancy.md +11 -2
  141. package/skills/arc/references/production.md +10 -9
  142. package/skills/arc/references/scim.md +247 -0
  143. package/skills/arc/references/testing.md +1 -1
  144. package/skills/arc-code-review/SKILL.md +141 -0
  145. package/skills/arc-code-review/references/anti-patterns.md +911 -0
  146. package/skills/arc-code-review/references/arc-cheatsheet.md +380 -0
  147. package/skills/arc-code-review/references/migration-recipes.md +700 -0
  148. package/skills/arc-code-review/references/mongokit-migration.md +386 -0
  149. package/skills/arc-code-review/references/scaffolding.md +230 -0
  150. package/skills/arc-code-review/references/severity.md +127 -0
  151. package/dist/EventTransport-CfVEGaEl.d.mts +0 -293
  152. package/dist/adapters/index.d.mts +0 -3
  153. package/dist/adapters/index.mjs +0 -2
  154. package/dist/adapters-D0tT2Tyo.mjs +0 -949
  155. package/dist/auth/mongoose.d.mts +0 -191
  156. package/dist/auth/mongoose.mjs +0 -73
  157. package/dist/core-DnUsRpuX.mjs +0 -1049
  158. package/dist/errorHandler-BQm8ZxTK.mjs +0 -173
  159. package/dist/errorHandler-Co3lnVmJ.d.mts +0 -114
  160. package/dist/errors-D5c-5BJL.mjs +0 -232
  161. package/dist/index-BbMrcvGp.d.mts +0 -362
  162. package/dist/redis-stream-CM8TXTix.d.mts +0 -110
  163. /package/dist/{HookSystem-CGsMd6oK.mjs → HookSystem-Iiebom92.mjs} +0 -0
  164. /package/dist/{actionPermissions-sUUKDhtP.mjs → actionPermissions-CyUkQu6O.mjs} +0 -0
  165. /package/dist/{caching-CheW3m-S.mjs → caching-SM8gghN6.mjs} +0 -0
  166. /package/dist/{constants-BhY1OHoH.mjs → constants-Cxde4rpC.mjs} +0 -0
  167. /package/dist/{elevation-s5ykdNHr.d.mts → elevation-BXOWoGCF.d.mts} +0 -0
  168. /package/dist/{externalPaths-Bapitwvd.d.mts → externalPaths-BD5nw6St.d.mts} +0 -0
  169. /package/dist/{interface-CkkWm5uR.d.mts → interface-DfLGcus7.d.mts} +0 -0
  170. /package/dist/{interface-Da0r7Lna.d.mts → interface-beEtJyWM.d.mts} +0 -0
  171. /package/dist/{keys-CARyUjiR.mjs → keys-CGcCbNyu.mjs} +0 -0
  172. /package/dist/{loadResources-CPpkyKfM.mjs → loadResources-DBMQg_Aj.mjs} +0 -0
  173. /package/dist/{memory-DikHSvWa.mjs → memory-UBydS5ku.mjs} +0 -0
  174. /package/dist/{metrics-Csh4nsvv.mjs → metrics-Qnvwc-LQ.mjs} +0 -0
  175. /package/dist/{pluralize-BneOJkpi.mjs → pluralize-DQgqgifU.mjs} +0 -0
  176. /package/dist/{registry-D63ee7fl.mjs → registry-I-ogLgL9.mjs} +0 -0
  177. /package/dist/{requestContext-C5XeK3VA.mjs → requestContext-SSaaTgW8.mjs} +0 -0
  178. /package/dist/{schemaConverter-B0oKLuqI.mjs → schemaConverter-De34B1ZG.mjs} +0 -0
  179. /package/dist/{sessionManager-D-oNWHz3.d.mts → sessionManager-C4Le_UB3.d.mts} +0 -0
  180. /package/dist/{storage-BwGQXUpd.d.mts → storage-Dfzt4VTl.d.mts} +0 -0
  181. /package/dist/{tracing-DokiEsuz.d.mts → tracing-QJVprktp.d.mts} +0 -0
  182. /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-BzkXkvVv.mjs} +0 -0
  183. /package/dist/{types-DV9WDfeg.mjs → types-D57iXYb8.mjs} +0 -0
  184. /package/dist/{versioning-CGPjkqAg.mjs → versioning-BUrT5aP4.mjs} +0 -0
  185. /package/dist/{websocket-CyJ1VIFI.d.mts → websocket-ChC2rqe1.d.mts} +0 -0
@@ -1,5 +1,19 @@
1
1
  # Arc Authentication & Authorization
2
2
 
3
+ ## Decision tree — which pattern fits your auth provider
4
+
5
+ Arc deliberately ships **zero per-provider code**. The contract is `request.scope`. Three shapes cover every realistic provider:
6
+
7
+ | Where does user data live? | Who writes it? | Pattern |
8
+ |---|---|---|
9
+ | Your DB | You | **arc JWT / authenticator** — define your User model normally; no overlay |
10
+ | The provider's cloud | Provider (Clerk, Auth0, Supabase Auth...) | **authenticate callback** — verify provider JWT, map claims to `request.scope`. Optionally webhook-sync to a local user table for joins |
11
+ | Your DB | A library writes it via its own driver (Better Auth) | **kit overlay** — `@classytic/mongokit/better-auth`, sqlite hand-rolled — gives you arc CRUD + queryparser over the library's tables |
12
+
13
+ **Better Auth is the recommended path** when you want both "users live in my DB" AND "I don't want to hand-roll signup / OAuth / 2FA / orgs". It's the only provider that needs the overlay because it's the only one writing your tables.
14
+
15
+ For everything else, the canonical primitive is the `authenticate` callback — verify the token, populate `request.scope`. Arc doesn't care which library produced the token.
16
+
3
17
  ## Auth Strategies (Discriminated Union)
4
18
 
5
19
  Auth config uses `type` field to select strategy:
@@ -41,6 +55,10 @@ const decoded = app.auth.verifyRefreshToken(refreshToken);
41
55
 
42
56
  ### Better Auth (`type: 'betterAuth'`)
43
57
 
58
+ Two pieces — the **request-side bridge** (catch-all `/api/auth/*` route + scope adapter) and the **table-side overlay** (so arc resources can read BA's collections with full pagination / queryparser / OpenAPI).
59
+
60
+ #### Request-side bridge
61
+
44
62
  ```typescript
45
63
  import { createBetterAuthAdapter } from '@classytic/arc/auth';
46
64
 
@@ -56,30 +74,316 @@ const app = await createApp({
56
74
  ```
57
75
 
58
76
  **Org context flow** (when `orgContext: true`):
59
- 1. Gets session via Better Auth
77
+ 1. Gets session via `auth.api.getSession()` (in-process, no HTTP round-trip)
60
78
  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)
79
+ 3. Looks up org membership via `auth.api.getActiveMember` (or `getActiveMemberRole` with explicit `organizationId` for header-based resolution)
62
80
  4. Splits roles: `"admin,recruiter"` → `['admin', 'recruiter']`
63
81
  5. Sets `request.scope`: `{ kind: 'member', organizationId, orgRoles: string[], teamId? }`
64
82
 
83
+ > **Arc 2.13+ requires `auth.api.*`** — pass a real `betterAuth()` instance. The pre-2.13 HTTP fallback for org/team lookups was retired.
84
+
85
+ #### Table-side overlay — read BA's tables as arc resources
86
+
87
+ Better Auth writes its own tables (`user`, `organization`, `member`, `invitation`, ...) via its own driver. To expose them as arc resources with pagination, queryparser, OpenAPI, audit, permissions, and multi-tenant scope, use the kit-side overlay:
88
+
89
+ ##### Mongoose / mongokit (Tier 2 — convenience factory)
90
+
91
+ ```typescript
92
+ import { betterAuth } from 'better-auth';
93
+ import { mongodbAdapter } from '@better-auth/mongo-adapter';
94
+ import { organization } from 'better-auth/plugins';
95
+ import mongoose from 'mongoose';
96
+ import {
97
+ createBetterAuthOverlay,
98
+ registerBetterAuthStubs,
99
+ } from '@classytic/mongokit/better-auth';
100
+
101
+ const auth = betterAuth({
102
+ database: mongodbAdapter(mongoose.connection.getClient().db()),
103
+ plugins: [organization()],
104
+ // ...
105
+ });
106
+
107
+ // Bulk-register stubs so `populate('user')` works app-wide.
108
+ registerBetterAuthStubs(mongoose, { plugins: ['organization'] });
109
+
110
+ // Per-resource overlay — ready to plug into defineResource.
111
+ const orgAdapter = createBetterAuthOverlay({
112
+ mongoose,
113
+ collection: 'organization',
114
+ });
115
+
116
+ defineResource({
117
+ name: 'organization',
118
+ adapter: orgAdapter,
119
+ tenantField: false, // platform-wide, not tenant-scoped
120
+ permissions: { list: requireAuth(), get: requireAuth() },
121
+ });
122
+ ```
123
+
124
+ ##### Mongoose / mongokit (Tier 1 — hand-rolled, full control)
125
+
126
+ When you need custom validators, `toJSON` transforms (e.g., strip `password`), domain methods on the Repository, or special indexes — drop the factory and hand-roll:
127
+
128
+ ```typescript
129
+ const userSchema = new mongoose.Schema(
130
+ {
131
+ name: String,
132
+ email: String,
133
+ role: { type: [String], enum: SYSTEM_ROLES, default: ['user'] },
134
+ isActive: { type: Boolean, default: true },
135
+ // BA owns these via strict:false overlay; only declare what you need typed
136
+ },
137
+ { strict: false, timestamps: false, collection: 'user' },
138
+ );
139
+ userSchema.set('toJSON', { transform: (_, ret) => { delete ret.password; return ret; } });
140
+ const User = mongoose.model('User', userSchema);
141
+
142
+ class UserRepository extends Repository<IUser> {
143
+ async getByRole(role: string) { return this.getAll({ filters: { role, isActive: true } }); }
144
+ }
145
+
146
+ defineResource({
147
+ name: 'user',
148
+ adapter: createMongooseAdapter(User, new UserRepository(User)),
149
+ tenantField: false,
150
+ fields: { password: fields.hidden() }, // belt-and-braces
151
+ permissions: { list: requireRoles(['admin']) },
152
+ });
153
+ ```
154
+
155
+ ##### sqlite / sqlitekit (Tier 2 — convenience factory)
156
+
157
+ Same parallel structure as mongokit. The factory derives the Drizzle table dynamically from BA's resolved schema (`auth.$context.tables`) — `additionalFields`, `modelName` overrides, and plugin schema additions all flow through automatically.
158
+
159
+ ```typescript
160
+ import Database from 'better-sqlite3';
161
+ import { drizzle } from 'drizzle-orm/better-sqlite3';
162
+ import { betterAuth } from 'better-auth';
163
+ import { organization } from 'better-auth/plugins';
164
+ import { createBetterAuthOverlay } from '@classytic/sqlitekit/better-auth';
165
+
166
+ const sqlite = new Database('app.db');
167
+ const auth = betterAuth({ database: sqlite, plugins: [organization()] });
168
+ const db = drizzle(sqlite);
169
+
170
+ // Async because we await BA's resolved schema. Resolves once at boot.
171
+ const orgAdapter = await createBetterAuthOverlay({ auth, db, collection: 'organization' });
172
+
173
+ defineResource({ name: 'organization', adapter: orgAdapter, idField: 'id', tenantField: false });
174
+ ```
175
+
176
+ Need a column BA doesn't declare (host-side audit, custom JSON column, etc.)? Pass `additionalColumns`:
177
+
178
+ ```typescript
179
+ import { integer, text } from 'drizzle-orm/sqlite-core';
180
+
181
+ const orgAdapter = await createBetterAuthOverlay({
182
+ auth, db, collection: 'organization',
183
+ additionalColumns: {
184
+ syncedAt: integer('syncedAt'),
185
+ branchType: text('branchType'), // matches your BA additionalFields entry
186
+ },
187
+ });
188
+ ```
189
+
190
+ ##### sqlite / sqlitekit (Tier 1 — hand-roll for full control)
191
+
192
+ When you need a custom `SqliteRepository` subclass with domain methods, custom Drizzle column modes (`integer({ mode: 'timestamp_ms' })` for typed Date), or table-level extensions the factory can't express — drop the factory:
193
+
194
+ ```typescript
195
+ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
196
+ import { drizzle } from 'drizzle-orm/better-sqlite3';
197
+ import { SqliteRepository } from '@classytic/sqlitekit/repository';
198
+ import { createDrizzleAdapter } from '@classytic/sqlitekit/adapter';
199
+
200
+ export const organizationTable = sqliteTable('organization', {
201
+ id: text('id').primaryKey().notNull(),
202
+ name: text('name').notNull(),
203
+ slug: text('slug'),
204
+ logo: text('logo'),
205
+ createdAt: integer('createdAt', { mode: 'timestamp_ms' }).notNull(), // typed Date
206
+ metadata: text('metadata'),
207
+ });
208
+
209
+ class OrgRepository extends SqliteRepository<{ id: string; name: string }> {
210
+ async getActive() { return this.getAll({ filters: { /* ... */ } }); }
211
+ }
212
+
213
+ const db = drizzle(sqliteDatabase);
214
+ const repo = new OrgRepository({ db, table: organizationTable, idField: 'id' });
215
+ const orgAdapter = createDrizzleAdapter({ table: organizationTable, repository: repo });
216
+ ```
217
+
218
+ Reference playgrounds: [`playground/better-auth/mongo/`](../../../playground/better-auth/mongo/) · [`playground/better-auth/sqlite/`](../../../playground/better-auth/sqlite/). Both pass identical 17-scenario smoke suites — same `defineResource` host code, only the kit import differs.
219
+
220
+ > **Removed in arc 2.13** — `@classytic/arc/auth/mongoose` (the old `registerBetterAuthMongooseModels`). The helper moved to `@classytic/mongokit/better-auth` as `registerBetterAuthStubs`. Same behavior, kit-owned location.
221
+
222
+ ### Better Auth plugin integration matrix
223
+
224
+ The `auth.api.*` direct in-process API map and `auth.$context.tables` schema introspection let arc and the kit overlays be **plugin-agnostic** — wire any combination of BA plugins, the overlay reads what's there. No per-plugin code in arc/mongokit/sqlitekit.
225
+
226
+ | BA plugin | Adds tables | Tested in | What you need on the arc side |
227
+ |---|---|---|---|
228
+ | (core) — `emailAndPassword`, OAuth providers | `user`, `session`, `account`, `verification` | both kits + playground | Nothing extra. Optionally overlay `user` as a resource. |
229
+ | `organization()` (built-in) | `organization`, `member`, `invitation` | both kits + playground | `createBetterAuthAdapter({ orgContext: true })`; overlay `organization` / `member` as resources. |
230
+ | `organization({ teams: true })` | + `team`, `teamMember` | mongokit | `requireTeamMembership()` works on `scope.teamId`. |
231
+ | `twoFactor()` (built-in) | `twoFactor` | both kits | Picked up automatically by overlay; expose `twoFactor` as a resource if needed. |
232
+ | `admin()` (built-in) | (field-only, augments `user`) | both kits | `requireRoles(['admin'])` reads the platform role; no extra plumbing. |
233
+ | `bearer()` (built-in) | (none — header strategy) | CLI scaffold | Token comes via `Authorization: Bearer <session>` instead of cookie — same `auth.api.getSession()` path. Use for SPA / mobile clients. |
234
+ | `apiKey()` from **`@better-auth/api-key`** (separate npm package) | `apikey` | both kits | Arc auto-resolves API key sessions via `enableSessionForAPIKeys: true`; falls back to `x-organization-id` header for org context. See "API Key Auth" below. |
235
+ | `magicLink()`, `username()`, `passkey()`, `oidcProvider()`, `mcp()`, `deviceAuthorization()` | varies (passkey: `passkey`; oidc: `oauthApplication`/`oauthAccessToken`; deviceAuth: `deviceCode`) | not in kit tests | Overlay reads them automatically — pass plugin name to `registerBetterAuthStubs` or list under `extraCollections`. |
236
+
237
+ #### Multi-plugin recipe — combine without overlay code changes
238
+
239
+ ```typescript
240
+ import { betterAuth } from 'better-auth';
241
+ import { mongodbAdapter } from '@better-auth/mongo-adapter';
242
+ import { admin, organization, twoFactor, bearer } from 'better-auth/plugins';
243
+ import { apiKey } from '@better-auth/api-key';
244
+ import { createBetterAuthOverlay, registerBetterAuthStubs } from '@classytic/mongokit/better-auth';
245
+
246
+ const auth = betterAuth({
247
+ database: mongodbAdapter(mongoose.connection.getClient().db()),
248
+ emailAndPassword: { enabled: true },
249
+ plugins: [
250
+ organization(), // adds organization, member, invitation
251
+ twoFactor(), // adds twoFactor
252
+ admin(), // augments user with role/banned/banReason
253
+ bearer(), // header-based session for SPA/mobile
254
+ apiKey({ enableSessionForAPIKeys: true }), // adds apikey
255
+ ],
256
+ });
257
+
258
+ // One stub-registration call covers the populate('user') / ref:'organization' surface
259
+ // for every plugin you've enabled. apikey isn't ref'd by populate so it goes into extras.
260
+ registerBetterAuthStubs(mongoose, {
261
+ plugins: ['organization'],
262
+ extraCollections: ['apikey'],
263
+ });
264
+
265
+ // Overlay any of the resulting tables — picks modelName + additionalFields up
266
+ // automatically from auth.$context.tables. Tested combinations: organization,
267
+ // member, invitation, apikey, twoFactor, user.
268
+ const orgAdapter = await createBetterAuthOverlay({ auth, mongoose, collection: 'organization' });
269
+ const memberAdapter = await createBetterAuthOverlay({ auth, mongoose, collection: 'member' });
270
+ const apiKeyAdapter = await createBetterAuthOverlay({ auth, mongoose, collection: 'apikey' });
271
+ ```
272
+
273
+ Sqlitekit is symmetric — replace `{ mongoose }` with `{ db }` (a Drizzle binding), use `additionalColumns` instead of `additionalFields`, and skip the stub registration step (no `populate()` in Drizzle).
274
+
275
+ #### Multi-role members — `role: "admin,recruiter,viewer"`
276
+
277
+ BA's organization plugin stores multiple roles in a single comma-separated `member.role` string (be-prod canonical). The overlay round-trips this unchanged; arc's `requireRoles()` and `requireOrgRole()` split on comma when reading `scope.orgRoles`:
278
+
279
+ ```typescript
280
+ // BA writes: arc reads:
281
+ // member.role = "admin,recruiter,viewer" → scope.orgRoles = ['admin', 'recruiter', 'viewer']
282
+
283
+ defineResource({
284
+ name: 'invoice',
285
+ permissions: {
286
+ create: requireOrgRole('admin'), // matches — admin is in the list
287
+ delete: requireOrgRole('owner'), // does NOT match — string-equality on each role
288
+ },
289
+ });
290
+ ```
291
+
292
+ Filtering `member` by exact role string with `?role=admin` will NOT match a multi-role member — the column is opaque text, not an array. Filter with `role[like]=admin` (case-sensitive substring) or model membership server-side.
293
+
294
+ #### `registerBetterAuthStubs(mongoose, opts?)` — full options (mongokit only)
295
+
296
+ Bulk-registers `strict: false` Mongoose stubs so `populate('user')` / `ref: 'organization'` work app-wide without per-collection schemas. Idempotent — calling twice returns `[]` the second time.
297
+
298
+ ```typescript
299
+ registerBetterAuthStubs(mongoose, {
300
+ plugins: ['organization', 'organization-teams', 'mcp', 'oidcProvider', 'deviceAuthorization'],
301
+ extraCollections: ['passkey', 'ssoProvider', 'apikey'], // anything not in the plugin map
302
+ modelOverrides: { organization: 'OrgRoot' }, // BA modelName → Mongoose model name
303
+ usePlural: true, // register 'organizations' alongside 'organization'
304
+ });
305
+ ```
306
+
307
+ Plugin keys recognized by the registry: `organization`, `organization-teams`, `twoFactor`, `jwt`, `oidcProvider`, `mcp`, `deviceAuthorization`. Field-only plugins (`admin`, `username`, `magicLink`, `bearer`, `apiKey` field augmentations) need no entry.
308
+
309
+ **Gotcha — stub already registered + `additionalFields` requested:** `createBetterAuthOverlay` throws when the host called `registerBetterAuthStubs` for the same collection AND now passes `additionalFields` to the overlay. The stub model is already locked to `strict: false` with no extra paths; adding fields after the fact would silently no-op. Call order: register stubs OR overlay-with-additional-fields, not both for the same collection.
310
+
65
311
  ### API Key Auth (Better Auth `apiKey()` plugin)
66
312
 
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
313
+ `apiKey` ships as a **separate npm package**: `@better-auth/api-key`. It is NOT exported from `better-auth/plugins` like `organization` / `twoFactor` / `bearer`. Common mistake.
314
+
315
+ ```typescript
316
+ import { apiKey } from '@better-auth/api-key'; // separate package
317
+ // import { apiKey } from 'better-auth/plugins'; // ✗ does not exist
318
+
319
+ const auth = betterAuth({
320
+ // ...
321
+ plugins: [apiKey({ enableSessionForAPIKeys: true })],
322
+ });
323
+ ```
324
+
325
+ When enabled, Arc's adapter automatically:
326
+ - Resolves API key sessions via `enableSessionForAPIKeys: true` (the plugin's option, not arc's).
327
+ - Falls back to `x-organization-id` header for org context — API key sessions have no `activeOrganizationId`, so without the header `scope.kind` is `'authenticated'`, not `'member'`.
328
+ - Generates OpenAPI security schemes dynamically — `apiKeyAuth` only appears in the spec when the plugin is active.
71
329
 
72
330
  ```
73
331
  x-api-key: ak_live_... # Authentication
74
332
  x-organization-id: org_abc123 # Org context (required for org-scoped resources)
75
333
  ```
76
334
 
77
- **OpenAPI security semantics:**
335
+ **OpenAPI security semantics** (auto-derived; no manual schema):
78
336
  - Resource paths: `security: [{ bearerAuth: [] }, { apiKeyAuth: [], orgHeader: [] }]`
79
337
  - Meaning: bearer token alone **OR** (API key **AND** org header together)
80
338
  - Auth endpoints: `security: [{ cookieAuth: [] }, { bearerAuth: [] }, { apiKeyAuth: [] }]`
81
339
  - No org header required for auth management endpoints
82
340
 
341
+ **Overlay the `apikey` table as an arc resource** (admin-only, for issuing/revoking keys via REST):
342
+
343
+ ```typescript
344
+ const apiKeyAdapter = await createBetterAuthOverlay({ auth, mongoose, collection: 'apikey' });
345
+ // or sqlitekit: { auth, db, collection: 'apikey' }
346
+
347
+ defineResource({
348
+ name: 'apikey',
349
+ adapter: apiKeyAdapter,
350
+ tenantField: false, // platform-managed, not tenant-scoped
351
+ permissions: {
352
+ list: requireRoles(['admin']),
353
+ get: requireRoles(['admin']),
354
+ create: denyAll('use POST /api/auth/api-key/create'), // BA owns the issuance path
355
+ delete: requireRoles(['admin']),
356
+ },
357
+ fields: {
358
+ key: fields.hidden(), // never expose the raw key after creation
359
+ },
360
+ });
361
+ ```
362
+
363
+ The plugin also adds a required `configId` field on `apikey` rows and uses `referenceId` (not `userId`) — note when seeding test fixtures.
364
+
365
+ ### Bearer plugin — SPA / mobile clients
366
+
367
+ For non-cookie clients (React Native, native mobile, headless SPA), enable `bearer()` so sessions travel as `Authorization: Bearer <session-token>` instead of cookies. Same `auth.api.getSession()` path, just a different transport.
368
+
369
+ ```typescript
370
+ import { bearer } from 'better-auth/plugins';
371
+ const auth = betterAuth({ plugins: [bearer(), organization()] });
372
+ ```
373
+
374
+ Arc's adapter handles both transports identically — you don't switch arc config based on which is enabled. Enable both `cookie` (default) + `bearer` for hybrid web + mobile apps.
375
+
376
+ ### CLI scaffolding for Better Auth
377
+
378
+ `arc init my-api --better-auth --mongokit --ts` prompts for session strategy and api-key:
379
+
380
+ ```
381
+ Session strategy [1=Cookie (web app, default), 2=Bearer token (mobile/SPA), 3=Both]: 3
382
+ Enable API key plugin (machine-to-machine auth via @better-auth/api-key)? [y/N]: y
383
+ ```
384
+
385
+ The scaffold wires `registerBetterAuthStubs(mongoose, { plugins: [...], extraCollections: ['apikey'] })`, conditional plugin imports, and the matching peer dep entries (`@better-auth/api-key` only if you opted in).
386
+
83
387
  ### Custom Plugin (`type: 'custom'`)
84
388
 
85
389
  ```typescript
@@ -100,6 +404,49 @@ auth: {
100
404
  }
101
405
  ```
102
406
 
407
+ ### Clerk / Auth0 / Supabase Auth / any cloud SaaS
408
+
409
+ User data lives in the provider's cloud, not your DB. No tables to overlay — just verify the provider's JWT and map claims to `request.scope`. The same shape works for any cloud auth provider; only the verifier function differs.
410
+
411
+ ```typescript
412
+ import { verifyToken } from '@clerk/backend';
413
+
414
+ await createApp({
415
+ auth: {
416
+ type: 'jwt', // arc's plugin handles plumbing
417
+ jwt: { secret: 'unused' }, // overridden by authenticate
418
+ authenticate: async (request) => {
419
+ const token = request.headers.authorization?.slice(7);
420
+ if (!token) return null;
421
+ const claims = await verifyToken(token, { secretKey: process.env.CLERK_SECRET_KEY! });
422
+
423
+ // Map provider claims → arc scope (same shape BA produces)
424
+ if (claims.org_id) {
425
+ request.scope = {
426
+ kind: 'member',
427
+ userId: claims.sub,
428
+ organizationId: claims.org_id,
429
+ orgRoles: [claims.org_role],
430
+ userRoles: claims.org_role ? [claims.org_role] : [],
431
+ };
432
+ }
433
+ return { id: claims.sub, email: claims.email };
434
+ },
435
+ },
436
+ });
437
+ ```
438
+
439
+ **Optional: local user table for joins.** If your resources reference users (`Order.userId → populate`), webhook-sync from Clerk into a local `users` collection and treat it as a normal arc resource — no overlay needed because *you* write it.
440
+
441
+ ```typescript
442
+ // POST /webhooks/clerk
443
+ fastify.post('/webhooks/clerk', { config: { rawBody: true } }, async (req, reply) => {
444
+ // Verify Clerk webhook signature, then upsert into your User collection.
445
+ await User.findOneAndUpdate({ _id: payload.data.id }, payload.data, { upsert: true });
446
+ return { ok: true };
447
+ });
448
+ ```
449
+
103
450
  ### Disabled
104
451
 
105
452
  ```typescript
@@ -0,0 +1,94 @@
1
+ # Enterprise Auth — what's in the box
2
+
3
+ Arc 2.13 closes the enterprise-auth gaps without forcing a parallel infrastructure. Sessions / refresh / OAuth flows stay in Better Auth's hands; arc adds the three things arc actually owns: provisioning, agent-mandate gating, and audit chain.
4
+
5
+ ## In-box (2.13)
6
+
7
+ | Capability | Surface | Notes |
8
+ |---|---|---|
9
+ | **SCIM 2.0 provisioning** | `@classytic/arc/scim` | Auto-derived `/scim/v2/Users` + `/scim/v2/Groups` from existing resources. Filter, PATCH, discovery endpoints. Bearer or `verify` callback. → [scim.md](scim.md) |
10
+ | **Agent capability mandates** | `requireAgentScope`, `requireMandate`, `requireDPoP` from `@classytic/arc/permissions` | AP2 / Stripe x402 / MCP authorization. `RequestScope.service.mandate` + `.dpopJkt` are first-class. → [agent-auth.md](agent-auth.md) |
11
+ | **Auth-event audit chain** | `wireBetterAuthAudit` from `@classytic/arc/auth/audit` | BA's `databaseHooks` + endpoint hooks routed through existing `auditPlugin`. One canonical row shape for resource AND auth events. |
12
+ | **Sessions / refresh / OAuth / MFA / SSO providers** | Better Auth | Configure via BA's `secondaryStorage`, `plugins: [twoFactor(), bearer(), apiKey(), ...]`. Arc reads BA tables via the kit overlays. → [auth.md](auth.md) |
13
+ | **Multi-role / multi-tenant / parent-child orgs** | `RequestScope` + presets | Pre-2.13. → [multi-tenancy.md](multi-tenancy.md) |
14
+ | **Field-level redaction + dynamic ACL + role hierarchy** | `@classytic/arc/permissions` | Pre-2.13. |
15
+ | **Resource-op audit + retention + per-resource opt-in** | `@classytic/arc/audit` | Pre-2.13. |
16
+ | **API-key admin REST** | BA `apiKey()` plugin + arc overlay | Recipe in [auth.md](auth.md). |
17
+
18
+ ## Out-of-box (deliberate)
19
+
20
+ | Capability | Why arc doesn't ship it | What to use instead |
21
+ |---|---|---|
22
+ | **First-party SAML** | BA SAML plugin is the canonical path; arc can't compete with IdP edge cases | Better Auth SAML plugin (community, maturing) or `@node-saml/passport-saml` wrapped in arc's `authenticate` callback |
23
+ | **Session storage / Redis sessions** | BA's `secondaryStorage` covers this — duplicating fragments truth | Configure BA's `secondaryStorage: { get, set, delete }` with `ioredis` directly |
24
+ | **Refresh-token rotation** | BA owns the session model; arc would have to parallel-implement to add it | BA handles rotation in its session model; for JWT auth, use the existing `isRevoked` hook |
25
+ | **DPoP cryptographic proof verification** | One `jose.dpop.verify()` call in your authenticate function — arc would force peer-dep `jose` | `jose` (already a peer of most apps using OAuth/JWT) |
26
+ | **Mandate JWT/VC parsing** | Format varies per IdP/issuer; arc validates *what's verified*, not how it's verified | `jose` for JWTs, `did-jwt-vc` for Verifiable Credentials |
27
+ | **Device trust / risk scoring** | Out of framework scope; vendor-specific | Castle, Stytch, Auth0 Risk, Persona |
28
+ | **SOC2/HIPAA attestations** | Arc gives you the controls; certification is per-deployment | [`docs/compliance/soc2.md`](../../../docs/compliance/soc2.md) maps controls to arc primitives |
29
+ | **First-party SSO discovery** (`/.well-known/openid-configuration` aggregation) | No real demand; BA exposes its own well-known paths | BA's `openAPI()` + `mcp()` plugins emit RFC 9728 metadata |
30
+
31
+ ## Sequencing — how the three new pieces compose
32
+
33
+ ```
34
+ IdP (Okta / Azure AD)
35
+
36
+ │ SCIM provisioning ──→ /scim/v2/Users ──→ arc user resource
37
+
38
+ │ SAML / OIDC SSO ──→ Better Auth ──→ scope.kind = 'member'
39
+ │ │
40
+ ▼ │
41
+ end-user signs in │
42
+ │ │
43
+ │ → BA databaseHooks.session.create.after │
44
+ │ → wireBetterAuthAudit dispatches │
45
+ │ → audit row: { resource:'auth', action:'session.create' }
46
+
47
+ end-user delegates to AI agent
48
+
49
+ │ user authorizes mandate (cap, audience, ttl)
50
+ │ IdP / your token endpoint mints mandate JWT
51
+
52
+ AI agent calls protected route
53
+
54
+ │ Authorization: Mandate <jwt>
55
+ │ DPoP: <proof>
56
+
57
+
58
+ arc authenticate fn:
59
+ - jose.jwtVerify(mandate, JWKS)
60
+ - jose.dpop.verify(proof)
61
+ - sets scope = { kind:'service', mandate, dpopJkt, ... }
62
+
63
+
64
+ requireAgentScope({ capability, audience, validateAmount, requireDPoP })
65
+
66
+ ├─ ✓ auditPlugin.custom('invoice','INV-7','pay', { mandate.id, dpopJkt })
67
+ │ ↳ same store as session.create row above
68
+
69
+ └─ executes handler
70
+ ```
71
+
72
+ ## Threat model — what each layer prevents
73
+
74
+ | Threat | Mitigation |
75
+ |---|---|
76
+ | Stale offboarded user keeps access | SCIM `DELETE /Users/:id` → resource soft-delete on connector trigger |
77
+ | Stolen bearer token replay | `requireDPoP()` — token is bound to agent's keypair |
78
+ | Agent overspends user authorization | `requireMandate({ validateAmount, cap })` — per-call ceiling |
79
+ | Agent uses payment mandate against wrong invoice | `requireMandate({ audience: ctx => 'invoice:'+ctx.params.id })` |
80
+ | Mandate replay after revocation | `expiresAt` (short TTL) + your IdP's revocation endpoint |
81
+ | Untraceable agent action | Audit bridge stamps `mandate.id`, `dpopJkt`, `clientId` on every row |
82
+ | Org admin abuses elevated scope | `noElevatedBypass: true` on regulated mandates; `arc.scope.elevated` audit event |
83
+ | Failed sign-in / MFA flooding | `wireBetterAuthAudit` captures `mfa.failed` events; rate-limit on `clientId` |
84
+
85
+ ## Compliance
86
+
87
+ See [`docs/compliance/soc2.md`](../../../docs/compliance/soc2.md) and [`docs/compliance/hipaa.md`](../../../docs/compliance/hipaa.md) for the control matrix mapping each requirement (CC6.1 access provisioning, CC7.2 logging, §164.308 admin safeguards) to the specific arc primitive that satisfies it.
88
+
89
+ ## See also
90
+
91
+ - [scim.md](scim.md) — provisioning surface deep-dive
92
+ - [agent-auth.md](agent-auth.md) — DPoP + mandate semantics
93
+ - [auth.md](auth.md) — Better Auth + multi-plugin matrix
94
+ - [`playground/enterprise-auth/`](../../../playground/enterprise-auth/) — full runnable smoke
@@ -116,7 +116,9 @@ const unsub = await fastify.events.subscribe('order.created', handler);
116
116
  unsub();
117
117
  ```
118
118
 
119
- ## Event Structure (v2.9)
119
+ ## Event Structure
120
+
121
+ Event types live in `@classytic/primitives/events` (canonical source). Arc re-exports the runtime `MemoryEventTransport` only — every type below is imported from primitives.
120
122
 
121
123
  ```typescript
122
124
  interface EventMeta {
@@ -142,7 +144,7 @@ interface DomainEvent<T> {
142
144
  }
143
145
  ```
144
146
 
145
- **Arc is source of truth** — `@classytic/primitives/events` mirrors these shapes. Downstream packages import from primitives; arc owns evolution.
147
+ **`@classytic/primitives/events` is source of truth** — `EventMeta`, `DomainEvent`, `EventHandler`, `EventLogger`, `EventTransport`, `DeadLetteredEvent`, `PublishManyResult`, `createEvent`, `createChildEvent`, `matchEventPattern` all live there. Arc consumes them and re-exports the runtime `MemoryEventTransport`.
146
148
 
147
149
  ### DDD aggregate narrowing
148
150
 
@@ -162,7 +164,7 @@ Unlike `correlationId` / `causationId`, `aggregate` is **not inherited** by `cre
162
164
  ### Causation chains
163
165
 
164
166
  ```typescript
165
- import { createEvent, createChildEvent } from '@classytic/arc/events';
167
+ import { createEvent, createChildEvent } from '@classytic/primitives/events';
166
168
 
167
169
  const placed = createEvent('order.placed', { orderId: 'o1' }, {
168
170
  correlationId: req.id, userId: user.id,
@@ -177,7 +179,7 @@ const reserved = createChildEvent(placed, 'inventory.reserved', { sku: 'a' });
177
179
  ### Dead-letter contract
178
180
 
179
181
  ```typescript
180
- import type { DeadLetteredEvent, EventTransport } from '@classytic/arc/events';
182
+ import type { DeadLetteredEvent, EventTransport } from '@classytic/primitives/events';
181
183
 
182
184
  class KafkaTransport implements EventTransport {
183
185
  async deadLetter(dlq: DeadLetteredEvent) {
@@ -191,7 +193,7 @@ class KafkaTransport implements EventTransport {
191
193
  Implement `EventTransport` for RabbitMQ, Kafka, etc.:
192
194
 
193
195
  ```typescript
194
- import type { EventTransport, DomainEvent } from '@classytic/arc/events';
196
+ import type { EventTransport, DomainEvent } from '@classytic/primitives/events';
195
197
 
196
198
  class KafkaTransport implements EventTransport {
197
199
  readonly name = 'kafka';
@@ -253,7 +255,7 @@ For a pure cache DB (no queues, no idempotency), `allkeys-lru` is correct and wh
253
255
  All transports and retry accept a `logger` option — defaults to `console`, compatible with pino/fastify.log:
254
256
 
255
257
  ```typescript
256
- import type { EventLogger } from '@classytic/arc/events';
258
+ import type { EventLogger } from '@classytic/primitives/events';
257
259
 
258
260
  // Interface: { warn(msg, ...args): void; error(msg, ...args): void }
259
261
 
@@ -557,9 +557,9 @@ import { envelope } from '@classytic/arc';
557
557
  handler: async (req, reply) => {
558
558
  const data = await service.getResults();
559
559
  return reply.send(envelope(data));
560
- // → { success: true, data }
560
+ // → { data }
561
561
  return reply.send(envelope(data, { total: 100, page: 1 }));
562
- // → { success: true, data, total: 100, page: 1 }
562
+ // → { data, total: 100, page: 1 }
563
563
  }
564
564
  ```
565
565
 
@@ -130,9 +130,12 @@ export function requireApiKey(): PermissionCheck {
130
130
  Then wire it into the resource:
131
131
 
132
132
  ```ts
133
+ // import { createMongooseAdapter } from '@classytic/mongokit/adapter';
134
+ // import { buildCrudSchemasFromModel } from '@classytic/mongokit';
135
+
133
136
  defineResource({
134
137
  name: 'job',
135
- adapter: createMongooseAdapter({ model: Job, repository: jobRepo }),
138
+ adapter: createMongooseAdapter({ model: Job, repository: jobRepo, schemaGenerator: buildCrudSchemasFromModel }),
136
139
  controller: new BaseController(jobRepo, { tenantField: 'companyId' }),
137
140
  permissions: {
138
141
  list: requireApiKey(),
@@ -174,7 +177,7 @@ Arc does NOT auto-derive scope from `request.user.organizationId` — that's a f
174
177
 
175
178
  ## Gotchas
176
179
 
177
- 1. **`tenantField` is per-resource, not per-app.** Different resources can use different tenant field names (`companyId`, `workspaceId`, `tenantId`). The org ID always comes from `getOrgId(scope)`.
180
+ 1. **`tenantField` is per-resource, not per-app.** Different resources can use different tenant field names (`companyId`, `workspaceId`, `tenantId`). The org ID always comes from `getOrgId(scope)`. **Auto-inference (2.12):** when the Mongoose model has no `organizationId` path AND no other tenant field is configured, arc auto-infers `tenantField: false` rather than emitting queries against a non-existent column.
178
181
  2. **`elevated` with no `organizationId` bypasses tenant filtering.** This is intentional (admin sees everything), but it means you can't use `kind: 'elevated'` for normal per-org access.
179
182
  3. **`systemManaged` is your seatbelt for create/update.** Mark tenant fields (`companyId`, `organizationId`) as `systemManaged` in `fieldRules` so `BodySanitizer` strips any client-supplied value. The tenant field is then injected from the scope at write time — not from the request body.
180
183
  4. **Rate-limit keys respect all 5 scope kinds.** The built-in `createTenantKeyGenerator` uses `organizationId` for member/service/elevated and falls back to `userId`/IP for authenticated/public.
@@ -240,6 +243,12 @@ import {
240
243
  getServiceScopes, // service only (OAuth-style scope strings)
241
244
  getTeamId, // member with teamId set
242
245
 
246
+ // Throwing accessors — return the value or throw a 403 ArcError
247
+ requireUserId,
248
+ requireOrgId,
249
+ requireClientId,
250
+ requireTeamId,
251
+
243
252
  // Canonical request extractor
244
253
  getOrgContext,
245
254
 
@@ -299,16 +299,14 @@ const app = await createApp({
299
299
 
300
300
  Mappers are checked via `instanceof` before all other error classification. Multiple mappers supported — first match wins.
301
301
 
302
- ## Reply Helpers (v2.7.3)
302
+ ## Reply Helpers
303
303
 
304
- Opt-in response envelope decorators: `createApp({ replyHelpers: true })`
304
+ Opt-in `sendList` + `stream` decorators: `createApp({ replyHelpers: true })`. Arc emits raw data on success — no `{ success, data }` envelope; HTTP status discriminates — so single-doc handlers just `return doc` (or `reply.send(doc)`) and errors throw `ArcError` (the global handler serialises to `ErrorContract`). The two decorators below cover the cases that DO need framework support:
305
305
 
306
306
  ```typescript
307
- return reply.ok({ name: 'MacBook' }); // 200 { success: true, data: {...} }
308
- return reply.ok(product, 201); // 201 { success: true, data: {...} }
309
- return reply.fail('Not found', 404); // 404 { success: false, error: '...' }
310
- return reply.fail(['err1', 'err2'], 422); // → 422 { success: false, errors: [...] }
311
- return reply.paginated({ docs, total, page, limit });
307
+ // List response any kit-shaped paginated result OR a bare array → canonical wire shape
308
+ return reply.sendList({ method: 'offset', data, total, page, limit, pages, hasNext, hasPrev });
309
+ return reply.sendList(rows); // bare array endpoints
312
310
  ```
313
311
 
314
312
  ### Streaming Responses
@@ -545,9 +543,12 @@ const relayed = await outbox.relay(); // publishes pending → transport
545
543
  Adds bulk CRUD routes — repository must provide `createMany`, `updateMany`, `deleteMany`:
546
544
 
547
545
  ```typescript
546
+ import { createMongooseAdapter } from '@classytic/mongokit/adapter';
547
+ import { buildCrudSchemasFromModel } from '@classytic/mongokit';
548
+
548
549
  defineResource({
549
550
  name: 'product',
550
- adapter: createMongooseAdapter({ model, repository }),
551
+ adapter: createMongooseAdapter({ model, repository, schemaGenerator: buildCrudSchemasFromModel }),
551
552
  presets: ['bulk'], // adds POST/PATCH/DELETE /{resource}/bulk
552
553
  });
553
554
 
@@ -642,7 +643,7 @@ defineResource({
642
643
  handler: async (request, reply) => {
643
644
  const result = await withCompensation('checkout', steps, { orderId: request.params.id });
644
645
  if (!result.success) return reply.code(422).send({ error: result.error });
645
- return reply.send({ success: true, data: result.results });
646
+ return reply.send({ data: result.results });
646
647
  },
647
648
  }],
648
649
  });