@classytic/arc 2.6.3 → 2.7.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 +84 -1
- package/dist/{BaseController-DzRtluEF.mjs → BaseController-CpMfCXdn.mjs} +134 -16
- package/dist/adapters/index.d.mts +2 -2
- package/dist/adapters/index.mjs +1 -1
- package/dist/{adapters-gM-WYjNe.mjs → adapters-BxGgSHjj.mjs} +1 -9
- package/dist/applyPermissionResult-D6GPMsvh.mjs +37 -0
- package/dist/audit/index.d.mts +1 -1
- package/dist/audit/index.mjs +1 -1
- package/dist/audit/mongodb.d.mts +1 -1
- package/dist/audit/mongodb.mjs +1 -1
- package/dist/auth/index.d.mts +4 -4
- package/dist/auth/index.mjs +7 -6
- package/dist/auth/mongoose.d.mts +191 -0
- package/dist/auth/mongoose.mjs +73 -0
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/{betterAuthOpenApi-lz0IRbXJ.mjs → betterAuthOpenApi-CCw3YX0g.mjs} +1 -1
- package/dist/cache/index.d.mts +2 -2
- package/dist/cache/index.mjs +2 -2
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/generate.mjs +1 -1
- package/dist/cli/commands/init.mjs +7 -5
- package/dist/cli/commands/introspect.mjs +1 -1
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +4 -4
- package/dist/{core-C1XCMtqM.mjs → core-BWekSEju.mjs} +41 -13
- package/dist/{createApp-D2w0LdYJ.mjs → createApp-B_nvKNAQ.mjs} +11 -11
- package/dist/{defineResource-wWMBB4GP.mjs → defineResource-DZzyl4a4.mjs} +42 -37
- package/dist/docs/index.d.mts +2 -2
- package/dist/docs/index.mjs +1 -1
- package/dist/dynamic/index.d.mts +2 -2
- package/dist/dynamic/index.mjs +2 -2
- package/dist/{elevation-BEdACOLB.mjs → elevation-By_p2lnn.mjs} +1 -1
- package/dist/elevation-Dm-HTBCt.d.mts +23 -0
- package/dist/{errorHandler-Do4vVQ1f.d.mts → errorHandler-COa51ho_.d.mts} +1 -1
- package/dist/{errorHandler-r2595m8T.mjs → errorHandler-DXUttWEO.mjs} +1 -1
- package/dist/{eventPlugin-DW45v4V5.d.mts → eventPlugin-BgLxJkIB.d.mts} +1 -1
- package/dist/{eventPlugin-Ba00swHF.mjs → eventPlugin-DsaNNXzZ.mjs} +1 -1
- package/dist/events/index.d.mts +3 -3
- package/dist/events/index.mjs +1 -1
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/factory/index.d.mts +1 -1
- package/dist/factory/index.mjs +1 -1
- 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/mongodb.d.mts +1 -1
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/index-BYpRGXif.d.mts +640 -0
- package/dist/{index-gz6iuzCp.d.mts → index-KXM8_JmQ.d.mts} +47 -4
- package/dist/{index-CHeJa4Zd.d.mts → index-StgFaQKD.d.mts} +1 -1
- package/dist/index.d.mts +8 -8
- package/dist/index.mjs +10 -9
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +1 -1
- package/dist/integrations/mcp/index.d.mts +2 -2
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/{interface-DYH8AXGe.d.mts → interface-Dwzqt4mn.d.mts} +150 -14
- package/dist/{mongodb-pMvOlR5_.d.mts → mongodb-Bq90j-Uj.d.mts} +1 -1
- package/dist/{mongodb-kltrBPa1.d.mts → mongodb-DdyYlIXg.d.mts} +1 -1
- package/dist/{openapi-CBmZ6EQN.mjs → openapi-C5UhIeWu.mjs} +1 -1
- package/dist/org/index.d.mts +2 -2
- package/dist/org/index.mjs +1 -1
- package/dist/permissions/index.d.mts +4 -4
- package/dist/permissions/index.mjs +3 -2
- package/dist/{permissions-C8ImI8gC.mjs → permissions-CH4cNwJi.mjs} +358 -64
- package/dist/plugins/index.d.mts +4 -4
- package/dist/plugins/index.mjs +10 -10
- 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/policies/index.d.mts +1 -1
- package/dist/presets/index.d.mts +3 -3
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/multiTenant.d.mts +53 -3
- package/dist/presets/multiTenant.mjs +89 -47
- package/dist/{presets-BMfdy34e.mjs → presets-BFrGvvjL.mjs} +2 -2
- package/dist/{queryCachePlugin-DcmETvcB.d.mts → queryCachePlugin-Bw8XyJpX.d.mts} +1 -1
- package/dist/{queryCachePlugin-XtFplYO9.mjs → queryCachePlugin-CwTpR04-.mjs} +2 -2
- package/dist/{redis-D0Qc-9EW.d.mts → redis-CyCntzTO.d.mts} +1 -1
- package/dist/{redis-stream-BW9UKLZM.d.mts → redis-stream-We_Ucl9-.d.mts} +1 -1
- package/dist/registry/index.d.mts +1 -1
- package/dist/registry/index.mjs +2 -2
- package/dist/{resourceToTools-nCJWnG1r.mjs → resourceToTools-CkVSSzKg.mjs} +64 -21
- package/dist/rpc/index.d.mts +1 -1
- package/dist/rpc/index.mjs +1 -1
- package/dist/scope/index.d.mts +3 -2
- package/dist/scope/index.mjs +4 -3
- package/dist/{sse-BF7GR7IB.mjs → sse-Bp3dabF1.mjs} +2 -2
- package/dist/testing/index.d.mts +2 -2
- package/dist/testing/index.mjs +1 -1
- package/dist/types/index.d.mts +4 -3
- package/dist/types/index.mjs +1 -1
- package/dist/types-AOD8fxIw.mjs +229 -0
- package/dist/types-CNEbix8T.d.mts +286 -0
- package/dist/{types-B4_TDdPe.d.mts → types-ClmkMDK1.d.mts} +1 -1
- package/dist/{types-By-5mIfn.d.mts → types-D0qf0Mf4.d.mts} +9 -9
- package/dist/types-DPsC0taJ.d.mts +178 -0
- package/dist/utils/index.d.mts +3 -3
- package/dist/utils/index.mjs +5 -5
- package/package.json +17 -5
- package/skills/arc/SKILL.md +253 -6
- package/skills/arc/references/multi-tenancy.md +208 -0
- package/dist/elevation-C_taLQrM.d.mts +0 -147
- package/dist/index-NGZksqM5.d.mts +0 -398
- package/dist/types-BNUccdcf.d.mts +0 -101
- package/dist/types-BhtYdxZU.mjs +0 -91
- /package/dist/{EventTransport-wc5hSLik.d.mts → EventTransport-CUpRK_Lg.d.mts} +0 -0
- /package/dist/{HookSystem-COkyWztM.mjs → HookSystem-D7lfx--K.mjs} +0 -0
- /package/dist/{ResourceRegistry-C6ngvOnn.mjs → ResourceRegistry-DsHiG9cL.mjs} +0 -0
- /package/dist/{caching-BSXB-Xr7.mjs → caching-5DtLwIqb.mjs} +0 -0
- /package/dist/{circuitBreaker-JP2GdJ4b.d.mts → circuitBreaker-DwxrljLB.d.mts} +0 -0
- /package/dist/{circuitBreaker-BOBOpN2w.mjs → circuitBreaker-l18oRgL5.mjs} +0 -0
- /package/dist/{errors-CcVbl1-T.d.mts → errors-CCSsMpXE.d.mts} +0 -0
- /package/dist/{errors-NoQKsbAT.mjs → errors-Cg58SLNi.mjs} +0 -0
- /package/dist/{externalPaths-DpO-s7r8.d.mts → externalPaths-Dg7OLsKo.d.mts} +0 -0
- /package/dist/{fields-DFwdaWCq.d.mts → fields-CYuLMJPD.d.mts} +0 -0
- /package/dist/{interface-gr-7qo9j.d.mts → interface-B9rHWPxD.d.mts} +0 -0
- /package/dist/{interface-D_BWALyZ.d.mts → interface-CnluRL4_.d.mts} +0 -0
- /package/dist/{logger-Dz3j1ItV.mjs → logger-DLg8-Ueg.mjs} +0 -0
- /package/dist/{memory-BFAYkf8H.mjs → memory-Cp7_cAko.mjs} +0 -0
- /package/dist/{metrics-Csh4nsvv.mjs → metrics-Qnvwc-LQ.mjs} +0 -0
- /package/dist/{mongodb-BuQ7fNTg.mjs → mongodb-mlgxkYI3.mjs} +0 -0
- /package/dist/{pluralize-CcT6qF0a.mjs → pluralize-COpOVar8.mjs} +0 -0
- /package/dist/{registry-I-ogLgL9.mjs → registry-B3lRFBWo.mjs} +0 -0
- /package/dist/{requestContext-DYtmNpm5.mjs → requestContext-xHIKedG6.mjs} +0 -0
- /package/dist/{schemaConverter-DjzHpFam.mjs → schemaConverter-0TyONAwM.mjs} +0 -0
- /package/dist/{sessionManager-wbkYj2HL.d.mts → sessionManager-IW4sbIea.d.mts} +0 -0
- /package/dist/{tracing-bz_U4EM1.d.mts → tracing-65B51Dw3.d.mts} +0 -0
- /package/dist/{typeGuards-Cj5Rgvlg.mjs → typeGuards-CcFZXgU7.mjs} +0 -0
- /package/dist/{utils-Dc0WhlIl.mjs → utils-B-l6410F.mjs} +0 -0
- /package/dist/{versioning-BzfeHmhj.mjs → versioning-aUUVziBY.mjs} +0 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Multi-Tenancy Playbook
|
|
3
|
+
description: Where `tenantField` takes effect, how it composes with `_policyFilters`, and how to install scope from custom auth (API keys, service accounts, gateway headers)
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Multi-Tenancy Playbook
|
|
7
|
+
|
|
8
|
+
Arc's multi-tenancy has exactly **one source of truth**: `request.scope`. This document shows where it's read, how filters compose, and how to wire custom authentication strategies (API keys, service accounts) without fighting the framework.
|
|
9
|
+
|
|
10
|
+
## Mental model
|
|
11
|
+
|
|
12
|
+
Every request goes through this ladder — if any step is missed, tenant isolation silently breaks:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
1. Authenticator populates request.scope (member | service | elevated | ...)
|
|
16
|
+
↓
|
|
17
|
+
2. Permission check validates access (allowPublic | requireAuth | requireApiKey | ...)
|
|
18
|
+
↓ may ALSO set request.scope via PermissionResult.scope
|
|
19
|
+
3. fastifyAdapter snapshots scope into metadata._scope
|
|
20
|
+
↓
|
|
21
|
+
4. QueryResolver / AccessControl read metadata._scope
|
|
22
|
+
↓
|
|
23
|
+
5. If tenantField is set AND getOrgId(scope) returns an orgId,
|
|
24
|
+
the org filter is ALWAYS prepended to the query
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## The 5 scope kinds
|
|
28
|
+
|
|
29
|
+
| Kind | Use case | Has `userId` | Has `organizationId` | Helper |
|
|
30
|
+
|-----------------|-------------------------------------|:------------:|:--------------------:|--------------------------|
|
|
31
|
+
| `public` | No authentication | ❌ | ❌ | `PUBLIC_SCOPE` |
|
|
32
|
+
| `authenticated` | Logged-in user, no org context | ✅ | ❌ | `getUserId()` |
|
|
33
|
+
| `member` | User in an org with specific roles | ✅ | ✅ | `isMember()` + `getOrgId()` |
|
|
34
|
+
| `service` | Machine-to-machine (API keys, bots) | ❌ (`clientId` instead) | ✅ | `isService()` + `getClientId()` |
|
|
35
|
+
| `elevated` | Platform admin, explicit elevation | ✅ | Optional | `isElevated()` |
|
|
36
|
+
|
|
37
|
+
**Why `service` is distinct from `member`:** a service account is not a human. It has no user record, no global roles, and should be auditable as a machine actor. Faking it as a `member` with `userId: client._id` pollutes audit logs, confuses rate-limit keys, and makes future per-service-scope authorization (OAuth scopes) harder.
|
|
38
|
+
|
|
39
|
+
## Where `tenantField` is read
|
|
40
|
+
|
|
41
|
+
Every resource defines `tenantField` (default: `'organizationId'`, or `false` to disable). It is read in **exactly three places** and ALL of them derive the org ID from `metadata._scope` via `getOrgId()`:
|
|
42
|
+
|
|
43
|
+
| File | Method | Purpose |
|
|
44
|
+
|--------------------------------|-------------------------|------------------------------------------------|
|
|
45
|
+
| `src/core/QueryResolver.ts` | `resolve()` | `list` — prepends `{ [tenantField]: orgId }` to the filter |
|
|
46
|
+
| `src/core/AccessControl.ts` | `buildIdFilter()` | `get`, `update`, `delete` by ID — compound filter so cross-tenant IDs 404 |
|
|
47
|
+
| `src/core/BaseController.ts` | `buildBulkFilter()` | `bulkUpdate`, `bulkDelete` — narrows bulk ops to the caller's org |
|
|
48
|
+
|
|
49
|
+
**Everything else** (`BodySanitizer`, `MongoKit QueryParser`, presets) already composes cleanly with these three read points. If `getOrgId(scope)` returns the right value, you have tenant isolation — end of story.
|
|
50
|
+
|
|
51
|
+
## Filter composition
|
|
52
|
+
|
|
53
|
+
`_policyFilters` and `tenantField` are different and both apply:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
// The final filter passed to the database is:
|
|
57
|
+
{
|
|
58
|
+
...parsedUserFilters, // from the incoming query string
|
|
59
|
+
..._policyFilters, // from permission checks (ownership, project scoping, etc.)
|
|
60
|
+
[tenantField]: orgId, // from metadata._scope (if tenantField is set and scope has orgId)
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Priority rules:**
|
|
65
|
+
- `_policyFilters` overrides user-supplied filters (the user can't bypass them)
|
|
66
|
+
- `tenantField` only gets injected if `_policyFilters` does not already set it (so a more specific policy filter wins)
|
|
67
|
+
- `elevated` scope with no `organizationId` means the filter is skipped entirely (admin sees everything)
|
|
68
|
+
|
|
69
|
+
## Four ways to install scope
|
|
70
|
+
|
|
71
|
+
### 1. Better Auth (recommended for human users)
|
|
72
|
+
|
|
73
|
+
The `betterAuth` adapter reads the session cookie and installs `kind: 'member'` automatically. Nothing to wire.
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
await createApp({
|
|
77
|
+
auth: getAuth(), // Better Auth instance
|
|
78
|
+
// ... scope is set by the adapter, zero code
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 2. Custom Fastify authenticator (JWT, headers, whatever)
|
|
83
|
+
|
|
84
|
+
Arc's `authPlugin.ts` auto-derives scope from `request.user` after your authenticator returns. If your user has `organizationId`, you get a `member` scope for free:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
await createApp({
|
|
88
|
+
auth: {
|
|
89
|
+
type: 'authenticator',
|
|
90
|
+
authenticate: async (request) => {
|
|
91
|
+
const user = await verifyJwt(request.headers.authorization);
|
|
92
|
+
return user; // Must have `organizationId` for member scope
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 3. `PermissionResult.scope` (NEW — best for API keys, service accounts, custom headers)
|
|
99
|
+
|
|
100
|
+
Return the scope directly from your permission check. This is the cleanest integration point for machine-to-machine auth — no separate auth plugin, no scope-derivation-from-user magic. It's explicit, typed, and auditable.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
// src/permissions/apiKey.ts
|
|
104
|
+
import type { PermissionCheck } from '@classytic/arc/permissions';
|
|
105
|
+
import { ClientModel } from '../models/Client.js';
|
|
106
|
+
|
|
107
|
+
export function requireApiKey(): PermissionCheck {
|
|
108
|
+
return async ({ request }) => {
|
|
109
|
+
const apiKey = request.headers['x-api-key'] as string | undefined;
|
|
110
|
+
if (!apiKey) return { granted: false, reason: 'Missing API key' };
|
|
111
|
+
|
|
112
|
+
const client = await ClientModel.findOne({ apiKey });
|
|
113
|
+
if (!client) return { granted: false, reason: 'Invalid API key' };
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
granted: true,
|
|
117
|
+
scope: {
|
|
118
|
+
kind: 'service',
|
|
119
|
+
clientId: String(client._id),
|
|
120
|
+
organizationId: String(client.companyId),
|
|
121
|
+
scopes: client.allowedScopes, // optional OAuth-style scope strings
|
|
122
|
+
},
|
|
123
|
+
// Optional additional row-level narrowing
|
|
124
|
+
filters: client.projectId ? { projectId: client.projectId } : undefined,
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Then wire it into the resource:
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
defineResource({
|
|
134
|
+
name: 'job',
|
|
135
|
+
adapter: createMongooseAdapter({ model: Job, repository: jobRepo }),
|
|
136
|
+
controller: new BaseController(jobRepo, { tenantField: 'companyId' }),
|
|
137
|
+
permissions: {
|
|
138
|
+
list: requireApiKey(),
|
|
139
|
+
get: requireApiKey(),
|
|
140
|
+
create: requireApiKey(),
|
|
141
|
+
update: requireApiKey(),
|
|
142
|
+
delete: requireApiKey(),
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**That's the whole setup.** No auth plugin, no scope-resolution hook, no separate tenant-filter middleware. One file, one function, full tenant isolation — verified against cross-tenant `get`, `list`, `update`, and `delete` in [tests/e2e/permission-scope-wire.test.ts](../../../tests/e2e/permission-scope-wire.test.ts).
|
|
148
|
+
|
|
149
|
+
**Override safety:** `PermissionResult.scope` only writes to `request.scope` when the current scope is still `public`. An already-authenticated request (from Better Auth, JWT, etc.) is never downgraded or overwritten.
|
|
150
|
+
|
|
151
|
+
### 4. Custom Fastify hook (advanced)
|
|
152
|
+
|
|
153
|
+
For exotic cases (mTLS, downstream RPC calls), write an `onRequest` hook that sets `request.scope` directly. Arc reads from `request.scope` — it doesn't care how you got there.
|
|
154
|
+
|
|
155
|
+
## Decision table
|
|
156
|
+
|
|
157
|
+
| Your auth source | Where to set scope |
|
|
158
|
+
|-----------------------------------|------------------------------------------------------|
|
|
159
|
+
| Better Auth session cookie | Nothing — `betterAuth` adapter handles it |
|
|
160
|
+
| JWT with `organizationId` claim | Return it from `authenticate()` — auth plugin derives scope |
|
|
161
|
+
| API key → DB lookup | **`PermissionResult.scope`** ← the clean path |
|
|
162
|
+
| Gateway-injected headers | `PermissionResult.scope` or a custom `onRequest` hook |
|
|
163
|
+
| Service account OAuth token | `PermissionResult.scope` with `kind: 'service'` |
|
|
164
|
+
| Platform admin elevation | `elevationPlugin` — sets `kind: 'elevated'` |
|
|
165
|
+
|
|
166
|
+
## What happens when scope is missing
|
|
167
|
+
|
|
168
|
+
If `tenantField` is set but `getOrgId(scope)` returns `undefined`:
|
|
169
|
+
|
|
170
|
+
- **`list` / `get` / `update` / `delete`** — the filter is NOT narrowed. A caller with `public` scope would see ALL rows across ALL tenants. **This is why you must always pair `tenantField` with a permission check that installs a scope with `organizationId`.**
|
|
171
|
+
- **`bulkUpdate` / `bulkDelete`** — `buildBulkFilter()` returns `null` and the bulk operation is rejected. This is a safety net, but don't rely on it for `list`.
|
|
172
|
+
|
|
173
|
+
Arc does NOT auto-derive scope from `request.user.organizationId` — that's a footgun (silent tenant leak if you set the user but forget the scope). If you want this behavior, either use the JWT/custom authenticator path (which does derive automatically) or return `scope` explicitly from your permission check.
|
|
174
|
+
|
|
175
|
+
## Gotchas
|
|
176
|
+
|
|
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)`.
|
|
178
|
+
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
|
+
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
|
+
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.
|
|
181
|
+
|
|
182
|
+
## Helpers reference
|
|
183
|
+
|
|
184
|
+
All exported from `@classytic/arc/scope`:
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
import {
|
|
188
|
+
// Type guards
|
|
189
|
+
isMember, isService, isElevated, isAuthenticated, hasOrgAccess,
|
|
190
|
+
|
|
191
|
+
// Accessors (return undefined when not applicable — no throws)
|
|
192
|
+
getUserId, // authenticated | member | elevated
|
|
193
|
+
getClientId, // service only
|
|
194
|
+
getOrgId, // member | service | elevated
|
|
195
|
+
getUserRoles, // authenticated | member
|
|
196
|
+
getOrgRoles, // member only
|
|
197
|
+
getServiceScopes, // service only (OAuth-style scope strings)
|
|
198
|
+
getTeamId, // member with teamId set
|
|
199
|
+
|
|
200
|
+
// Canonical request extractor
|
|
201
|
+
getOrgContext,
|
|
202
|
+
|
|
203
|
+
// Constants
|
|
204
|
+
PUBLIC_SCOPE, AUTHENTICATED_SCOPE,
|
|
205
|
+
|
|
206
|
+
type RequestScope,
|
|
207
|
+
} from '@classytic/arc/scope';
|
|
208
|
+
```
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
import { FastifyPluginAsync, FastifyRequest } from "fastify";
|
|
2
|
-
|
|
3
|
-
//#region src/scope/types.d.ts
|
|
4
|
-
/**
|
|
5
|
-
* Request Scope — The One Standard
|
|
6
|
-
*
|
|
7
|
-
* Discriminated union representing the access context of every request.
|
|
8
|
-
* Replaces scattered orgScope/orgRoles/organizationId/bypassRoles.
|
|
9
|
-
*
|
|
10
|
-
* Set once by auth adapters, read everywhere by permissions/presets/guards.
|
|
11
|
-
*
|
|
12
|
-
* @example
|
|
13
|
-
* ```typescript
|
|
14
|
-
* // In a permission check
|
|
15
|
-
* const scope = request.scope;
|
|
16
|
-
* if (isElevated(scope)) return true;
|
|
17
|
-
* if (isMember(scope) && scope.orgRoles.includes('admin')) return true;
|
|
18
|
-
*
|
|
19
|
-
* // Get user identity from scope
|
|
20
|
-
* const userId = getUserId(scope);
|
|
21
|
-
* const globalRoles = getUserRoles(scope);
|
|
22
|
-
* ```
|
|
23
|
-
*/
|
|
24
|
-
/**
|
|
25
|
-
* Request scope — 4 kinds, 4 states, no ambiguity.
|
|
26
|
-
*
|
|
27
|
-
* | Kind | Meaning |
|
|
28
|
-
* |---------------|-----------------------------------|
|
|
29
|
-
* | public | No authentication |
|
|
30
|
-
* | authenticated | Logged in, no org context |
|
|
31
|
-
* | member | In an org with specific roles |
|
|
32
|
-
* | elevated | Platform admin, explicit elevation |
|
|
33
|
-
*
|
|
34
|
-
* `userId` and `userRoles` are available on all authenticated variants.
|
|
35
|
-
* `orgRoles` are org-level roles (from membership); `userRoles` are global roles (from user document).
|
|
36
|
-
*/
|
|
37
|
-
type RequestScope = {
|
|
38
|
-
kind: "public";
|
|
39
|
-
} | {
|
|
40
|
-
kind: "authenticated";
|
|
41
|
-
userId?: string;
|
|
42
|
-
userRoles?: string[];
|
|
43
|
-
} | {
|
|
44
|
-
kind: "member";
|
|
45
|
-
userId?: string;
|
|
46
|
-
userRoles: string[];
|
|
47
|
-
organizationId: string;
|
|
48
|
-
orgRoles: string[];
|
|
49
|
-
teamId?: string;
|
|
50
|
-
} | {
|
|
51
|
-
kind: "elevated";
|
|
52
|
-
userId?: string;
|
|
53
|
-
organizationId?: string;
|
|
54
|
-
elevatedBy: string;
|
|
55
|
-
};
|
|
56
|
-
/** Check if scope is `member` kind */
|
|
57
|
-
declare function isMember(scope: RequestScope): scope is Extract<RequestScope, {
|
|
58
|
-
kind: "member";
|
|
59
|
-
}>;
|
|
60
|
-
/** Check if scope is `elevated` kind */
|
|
61
|
-
declare function isElevated(scope: RequestScope): scope is Extract<RequestScope, {
|
|
62
|
-
kind: "elevated";
|
|
63
|
-
}>;
|
|
64
|
-
/** Check if scope has org access (member OR elevated) */
|
|
65
|
-
declare function hasOrgAccess(scope: RequestScope): boolean;
|
|
66
|
-
/** Check if request is authenticated (any kind except public) */
|
|
67
|
-
declare function isAuthenticated(scope: RequestScope): boolean;
|
|
68
|
-
/** Get organizationId from scope (if present) */
|
|
69
|
-
declare function getOrgId(scope: RequestScope): string | undefined;
|
|
70
|
-
/** Get org roles from scope (empty array if not a member) */
|
|
71
|
-
declare function getOrgRoles(scope: RequestScope): string[];
|
|
72
|
-
/** Get team ID from scope (only available on member kind) */
|
|
73
|
-
declare function getTeamId(scope: RequestScope): string | undefined;
|
|
74
|
-
/**
|
|
75
|
-
* Get userId from scope (available on authenticated, member, elevated).
|
|
76
|
-
*
|
|
77
|
-
* @example
|
|
78
|
-
* ```typescript
|
|
79
|
-
* import { getUserId } from '@classytic/arc/scope';
|
|
80
|
-
* const userId = getUserId(request.scope);
|
|
81
|
-
* ```
|
|
82
|
-
*/
|
|
83
|
-
declare function getUserId(scope: RequestScope): string | undefined;
|
|
84
|
-
/**
|
|
85
|
-
* Get global user roles from scope (available on authenticated and member).
|
|
86
|
-
* These are user-level roles (e.g. superadmin, finance-admin) distinct from
|
|
87
|
-
* org-level roles (scope.orgRoles).
|
|
88
|
-
*
|
|
89
|
-
* @example
|
|
90
|
-
* ```typescript
|
|
91
|
-
* import { getUserRoles } from '@classytic/arc/scope';
|
|
92
|
-
* const globalRoles = getUserRoles(request.scope);
|
|
93
|
-
* ```
|
|
94
|
-
*/
|
|
95
|
-
declare function getUserRoles(scope: RequestScope): string[];
|
|
96
|
-
/**
|
|
97
|
-
* Org context — canonical extraction from a Fastify request.
|
|
98
|
-
*
|
|
99
|
-
* Works regardless of auth type (JWT, Better Auth, custom) by reading
|
|
100
|
-
* `request.scope` and `request.user`. Eliminates the need for each resource
|
|
101
|
-
* to re-invent org extraction from headers/user/scope.
|
|
102
|
-
*
|
|
103
|
-
* @example
|
|
104
|
-
* ```typescript
|
|
105
|
-
* import { getOrgContext } from '@classytic/arc/scope';
|
|
106
|
-
*
|
|
107
|
-
* handler: async (request, reply) => {
|
|
108
|
-
* const { userId, organizationId, roles, orgRoles } = getOrgContext(request);
|
|
109
|
-
* }
|
|
110
|
-
* ```
|
|
111
|
-
*/
|
|
112
|
-
declare function getOrgContext(request: {
|
|
113
|
-
scope?: RequestScope;
|
|
114
|
-
user?: Record<string, unknown> | null;
|
|
115
|
-
headers?: Record<string, string | string[] | undefined>;
|
|
116
|
-
}): {
|
|
117
|
-
userId: string | undefined;
|
|
118
|
-
organizationId: string | undefined;
|
|
119
|
-
roles: string[];
|
|
120
|
-
orgRoles: string[];
|
|
121
|
-
};
|
|
122
|
-
/** Default public scope — used as initial decoration value */
|
|
123
|
-
declare const PUBLIC_SCOPE: Readonly<RequestScope>;
|
|
124
|
-
/** Default authenticated scope — used when user is logged in but no org */
|
|
125
|
-
declare const AUTHENTICATED_SCOPE: Readonly<RequestScope>;
|
|
126
|
-
//#endregion
|
|
127
|
-
//#region src/scope/elevation.d.ts
|
|
128
|
-
interface ElevationOptions {
|
|
129
|
-
/** Roles that can use elevation (default: ['superadmin']) */
|
|
130
|
-
platformRoles?: string[];
|
|
131
|
-
/** Header name for scope declaration (default: 'x-arc-scope') */
|
|
132
|
-
scopeHeader?: string;
|
|
133
|
-
/** Header name for target organization (default: 'x-organization-id') */
|
|
134
|
-
orgHeader?: string;
|
|
135
|
-
/** Called when elevation happens — use for audit logging */
|
|
136
|
-
onElevation?: (event: ElevationEvent) => void | Promise<void>;
|
|
137
|
-
}
|
|
138
|
-
interface ElevationEvent {
|
|
139
|
-
userId: string;
|
|
140
|
-
organizationId?: string;
|
|
141
|
-
request: FastifyRequest;
|
|
142
|
-
timestamp: Date;
|
|
143
|
-
}
|
|
144
|
-
declare const elevationPlugin: FastifyPluginAsync<ElevationOptions>;
|
|
145
|
-
declare const _default: FastifyPluginAsync<ElevationOptions>;
|
|
146
|
-
//#endregion
|
|
147
|
-
export { isMember as _, AUTHENTICATED_SCOPE as a, getOrgContext as c, getTeamId as d, getUserId as f, isElevated as g, isAuthenticated as h, elevationPlugin as i, getOrgId as l, hasOrgAccess as m, ElevationOptions as n, PUBLIC_SCOPE as o, getUserRoles as p, _default as r, RequestScope as s, ElevationEvent as t, getOrgRoles as u };
|