@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.
- package/README.md +27 -18
- 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/{QueryCache-DOBNHBE0.d.mts → QueryCache-D41bfdBB.d.mts} +1 -1
- 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 +5 -5
- package/dist/auth/index.mjs +117 -191
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/{betterAuthOpenApi-DwxtK3uG.mjs → betterAuthOpenApi--M_i87dQ.mjs} +1 -1
- package/dist/buildHandler-olo-gt94.mjs +610 -0
- package/dist/cache/index.d.mts +3 -3
- 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 +237 -112
- 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-D72ia0EH.mjs +1399 -0
- package/dist/{createActionRouter-u3ql2EDo.mjs → createActionRouter-CEvzKcy8.mjs} +7 -20
- package/dist/createAggregationRouter-CyecOxnO.mjs +114 -0
- package/dist/{createApp-BFxtdKy6.mjs → createApp-XX2-N0Yd.mjs} +31 -27
- package/dist/defineEvent-D5h7EvAx.mjs +188 -0
- package/dist/docs/index.d.mts +2 -2
- 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-KrFIQ097.mjs → eventPlugin-CaKTYkYM.mjs} +35 -137
- package/dist/{eventPlugin-CUNjYYRY.d.mts → eventPlugin-qXpqTebY.d.mts} +57 -7
- package/dist/events/index.d.mts +164 -5
- package/dist/events/index.mjs +133 -209
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +204 -31
- 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-C8Y0XLAu.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 +3 -3
- package/dist/idempotency/index.mjs +1 -20
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/idempotency/redis.mjs +1 -1
- package/dist/{index-BYCqHCVu.d.mts → index-BTqLEvhu.d.mts} +164 -4
- package/dist/{index-6u4_Gg6G.d.mts → index-BtW7qYwa.d.mts} +661 -281
- package/dist/{index-BdXnTPRj.d.mts → index-Ds61mrJE.d.mts} +50 -4
- package/dist/{index-DdQ3O9Pg.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 +2 -2
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +2 -2
- 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-redis.d.mts +1 -1
- package/dist/integrations/websocket.d.mts +1 -1
- 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-BGUn7Ki1.mjs → openapi-CiOMVW1p.mjs} +143 -13
- 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 +18 -33
- package/dist/plugins/index.mjs +33 -13
- package/dist/plugins/response-cache.mjs +1 -1
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/presets/filesUpload.d.mts +5 -5
- 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-BUXBSm4F.d.mts → queryCachePlugin-CqMdLI2-.d.mts} +2 -2
- package/dist/{queryCachePlugin-Bq6bO6vc.mjs → queryCachePlugin-m1XsgAIJ.mjs} +3 -3
- package/dist/{redis-Cm1gnRDf.d.mts → redis-DiMkdHEl.d.mts} +1 -1
- package/dist/redis-stream-D6HzR1Z_.d.mts +232 -0
- 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-ByZpgjeH.mjs → resourceToTools-C5coh64w.mjs} +224 -71
- package/dist/{routerShared-BqLRb5l7.mjs → routerShared-D6_fEGHh.mjs} +40 -36
- package/dist/{schemaIR-BlG9bY7v.mjs → schemaIR-7Vl611Qs.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-BhrzxvyQ.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/testing/storageContract.d.mts +1 -1
- package/dist/types/index.d.mts +4 -4
- package/dist/types/storage.d.mts +1 -1
- package/dist/{types-BH7dEGvU.d.mts → types-BvqwCCSx.d.mts} +77 -29
- package/dist/{types-tgR4Pt8F.d.mts → types-CTYvcwHe.d.mts} +195 -1
- package/dist/{types-AOD8fxIw.mjs → types-C_s5moIu.mjs} +117 -1
- package/dist/{types-9beEMe25.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-M9lNLhO8.d.mts → versioning-DTTvc80y.d.mts} +1 -1
- package/package.json +24 -34
- package/skills/arc/SKILL.md +521 -785
- 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-CfVEGaEl.d.mts +0 -293
- package/dist/adapters/index.d.mts +0 -3
- package/dist/adapters/index.mjs +0 -2
- package/dist/adapters-D0tT2Tyo.mjs +0 -949
- package/dist/auth/mongoose.d.mts +0 -191
- package/dist/auth/mongoose.mjs +0 -73
- package/dist/core-DnUsRpuX.mjs +0 -1049
- package/dist/errorHandler-BQm8ZxTK.mjs +0 -173
- package/dist/errorHandler-Co3lnVmJ.d.mts +0 -114
- package/dist/errors-D5c-5BJL.mjs +0 -232
- package/dist/index-BbMrcvGp.d.mts +0 -362
- package/dist/redis-stream-CM8TXTix.d.mts +0 -110
- /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-s5ykdNHr.d.mts → elevation-BXOWoGCF.d.mts} +0 -0
- /package/dist/{externalPaths-Bapitwvd.d.mts → externalPaths-BD5nw6St.d.mts} +0 -0
- /package/dist/{interface-CkkWm5uR.d.mts → interface-DfLGcus7.d.mts} +0 -0
- /package/dist/{interface-Da0r7Lna.d.mts → interface-beEtJyWM.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-BneOJkpi.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/{sessionManager-D-oNWHz3.d.mts → sessionManager-C4Le_UB3.d.mts} +0 -0
- /package/dist/{storage-BwGQXUpd.d.mts → storage-Dfzt4VTl.d.mts} +0 -0
- /package/dist/{tracing-DokiEsuz.d.mts → tracing-QJVprktp.d.mts} +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
- /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
|
|
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`
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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/
|
|
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/
|
|
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
|
-
// → {
|
|
560
|
+
// → { data }
|
|
561
561
|
return reply.send(envelope(data, { total: 100, page: 1 }));
|
|
562
|
-
// → {
|
|
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
|
|
302
|
+
## Reply Helpers
|
|
303
303
|
|
|
304
|
-
Opt-in
|
|
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
|
-
|
|
308
|
-
return reply.
|
|
309
|
-
return reply.
|
|
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({
|
|
646
|
+
return reply.send({ data: result.results });
|
|
646
647
|
},
|
|
647
648
|
}],
|
|
648
649
|
});
|